B2B-88: add starter kit structure and elements

This commit is contained in:
devmc-ee
2025-06-08 16:18:30 +03:00
parent 657a36a298
commit e7b25600cb
1280 changed files with 77893 additions and 5688 deletions

View File

@@ -0,0 +1,21 @@
import * as React from 'react';
const MOBILE_BREAKPOINT = 1024;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener('change', onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener('change', onChange);
}, []);
return !!isMobile;
}

View File

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

View File

@@ -0,0 +1,2 @@
export * from './cn';
export * from './is-route-active';

View File

@@ -0,0 +1,108 @@
const ROOT_PATH = '/';
/**
* @name isRouteActive
* @description A function to check if a route is active. This is used to
* @param end
* @param path
* @param currentPath
*/
export function isRouteActive(
path: string,
currentPath: string,
end?: boolean | ((path: string) => boolean),
) {
// if the path is the same as the current path, we return true
if (path === currentPath) {
return true;
}
// if the end prop is a function, we call it with the current path
if (typeof end === 'function') {
return !end(currentPath);
}
// otherwise - we use the evaluateIsRouteActive function
const defaultEnd = end ?? true;
const oneLevelDeep = 1;
const threeLevelsDeep = 3;
// how far down should segments be matched?
const depth = defaultEnd ? oneLevelDeep : threeLevelsDeep;
return checkIfRouteIsActive(path, currentPath, depth);
}
/**
* @name checkIfRouteIsActive
* @description A function to check if a route is active. This is used to
* highlight the active link in the navigation.
* @param targetLink - The link to check against
* @param currentRoute - the current route
* @param depth - how far down should segments be matched?
*/
export function checkIfRouteIsActive(
targetLink: string,
currentRoute: string,
depth = 1,
) {
// we remove any eventual query param from the route's URL
const currentRoutePath = currentRoute.split('?')[0] ?? '';
if (!isRoot(currentRoutePath) && isRoot(targetLink)) {
return false;
}
if (!currentRoutePath.includes(targetLink)) {
return false;
}
const isSameRoute = targetLink === currentRoutePath;
if (isSameRoute) {
return true;
}
return hasMatchingSegments(targetLink, currentRoutePath, depth);
}
function splitIntoSegments(href: string) {
return href.split('/').filter(Boolean);
}
function hasMatchingSegments(
targetLink: string,
currentRoute: string,
depth: number,
) {
const segments = splitIntoSegments(targetLink);
const matchingSegments = numberOfMatchingSegments(currentRoute, segments);
if (targetLink === currentRoute) {
return true;
}
// how far down should segments be matched?
// - if depth = 1 => only highlight the links of the immediate parent
// - if depth = 2 => for url = /account match /account/organization/members
return matchingSegments > segments.length - (depth - 1);
}
function numberOfMatchingSegments(href: string, segments: string[]) {
let count = 0;
for (const segment of splitIntoSegments(href)) {
// for as long as the segments match, keep counting + 1
if (segments.includes(segment)) {
count += 1;
} else {
return count;
}
}
return count;
}
function isRoot(path: string) {
return path === ROOT_PATH;
}

View File

@@ -0,0 +1,89 @@
'use client';
import { Fragment } from 'react';
import { usePathname } from 'next/navigation';
import {
Breadcrumb,
BreadcrumbEllipsis,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbSeparator,
} from '../shadcn/breadcrumb';
import { If } from './if';
import { Trans } from './trans';
const unslugify = (slug: string) => slug.replace(/-/g, ' ');
export function AppBreadcrumbs(props: {
values?: Record<string, string>;
maxDepth?: number;
}) {
const pathName = usePathname();
const splitPath = pathName.split('/').filter(Boolean);
const values = props.values ?? {};
const maxDepth = props.maxDepth ?? 6;
const Ellipsis = (
<BreadcrumbItem>
<BreadcrumbEllipsis className="h-4 w-4" />
</BreadcrumbItem>
);
const showEllipsis = splitPath.length > maxDepth;
const visiblePaths = showEllipsis
? ([splitPath[0], ...splitPath.slice(-maxDepth + 1)] as string[])
: splitPath;
return (
<Breadcrumb>
<BreadcrumbList>
{visiblePaths.map((path, index) => {
const label =
path in values ? (
values[path]
) : (
<Trans
i18nKey={`common:routes.${unslugify(path)}`}
defaults={unslugify(path)}
/>
);
return (
<Fragment key={index}>
<BreadcrumbItem className={'capitalize lg:text-xs'}>
<If
condition={index < visiblePaths.length - 1}
fallback={label}
>
<BreadcrumbLink
href={
'/' +
splitPath.slice(0, splitPath.indexOf(path) + 1).join('/')
}
>
{label}
</BreadcrumbLink>
</If>
</BreadcrumbItem>
{index === 0 && showEllipsis && (
<>
<BreadcrumbSeparator />
{Ellipsis}
</>
)}
<If condition={index !== visiblePaths.length - 1}>
<BreadcrumbSeparator />
</If>
</Fragment>
);
})}
</BreadcrumbList>
</Breadcrumb>
);
}

View File

@@ -0,0 +1,17 @@
'use client';
export function AuthenticityToken() {
const token = useCsrfToken();
return <input type="hidden" name="csrf_token" value={token} />;
}
function useCsrfToken() {
if (typeof window === 'undefined') return '';
return (
document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content') ?? ''
);
}

View File

@@ -0,0 +1,69 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn, isRouteActive } from '../lib/utils';
import { Button } from '../shadcn/button';
import {
NavigationMenu,
NavigationMenuItem,
NavigationMenuList,
} from '../shadcn/navigation-menu';
import { Trans } from './trans';
export function BorderedNavigationMenu(props: React.PropsWithChildren) {
return (
<NavigationMenu>
<NavigationMenuList className={'relative h-full space-x-2'}>
{props.children}
</NavigationMenuList>
</NavigationMenu>
);
}
export function BorderedNavigationMenuItem(props: {
path: string;
label: React.ReactNode | string;
end?: boolean | ((path: string) => boolean);
active?: boolean;
className?: string;
buttonClassName?: string;
}) {
const pathname = usePathname();
const active = props.active ?? isRouteActive(props.path, pathname, props.end);
return (
<NavigationMenuItem className={props.className}>
<Button
asChild
variant={'ghost'}
className={cn('relative active:shadow-xs', props.buttonClassName)}
>
<Link
href={props.path}
className={cn('text-sm', {
'text-secondary-foreground': active,
'text-secondary-foreground/80 hover:text-secondary-foreground':
!active,
})}
>
{typeof props.label === 'string' ? (
<Trans i18nKey={props.label} defaults={props.label} />
) : (
props.label
)}
{active ? (
<span
className={cn(
'bg-primary animate-in fade-in zoom-in-90 absolute -bottom-2.5 left-0 h-0.5 w-full',
)}
/>
) : null}
</Link>
</Button>
</NavigationMenuItem>
);
}

View File

@@ -0,0 +1,117 @@
import * as React from 'react';
import { Slot, Slottable } from '@radix-ui/react-slot';
import { ChevronRight } from 'lucide-react';
import { cn } from '../lib/utils';
export const CardButton: React.FC<
{
asChild?: boolean;
className?: string;
children: React.ReactNode;
} & React.ButtonHTMLAttributes<HTMLButtonElement>
> = function CardButton({ className, asChild, ...props }) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(
'group hover:bg-secondary/20 active:bg-secondary active:bg-secondary/50 dark:shadow-primary/20 relative flex h-36 flex-col rounded-lg border transition-all hover:shadow-xs active:shadow-lg',
className,
)}
{...props}
>
<Slottable>{props.children}</Slottable>
</Comp>
);
};
export const CardButtonTitle: React.FC<
{
asChild?: boolean;
children: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>
> = function CardButtonTitle({ className, asChild, ...props }) {
const Comp = asChild ? Slot : 'div';
return (
<Comp
className={cn(
className,
'text-muted-foreground group-hover:text-secondary-foreground align-super text-sm font-medium transition-colors',
)}
{...props}
>
<Slottable>{props.children}</Slottable>
</Comp>
);
};
export const CardButtonHeader: React.FC<
{
children: React.ReactNode;
asChild?: boolean;
displayArrow?: boolean;
} & React.HTMLAttributes<HTMLDivElement>
> = function CardButtonHeader({
className,
asChild,
displayArrow = true,
...props
}) {
const Comp = asChild ? Slot : 'div';
return (
<Comp className={cn(className, 'p-4')} {...props}>
<Slottable>
{props.children}
<ChevronRight
className={cn(
'text-muted-foreground group-hover:text-secondary-foreground absolute top-4 right-2 h-4 transition-colors',
{
hidden: !displayArrow,
},
)}
/>
</Slottable>
</Comp>
);
};
export const CardButtonContent: React.FC<
{
asChild?: boolean;
children: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>
> = function CardButtonContent({ className, asChild, ...props }) {
const Comp = asChild ? Slot : 'div';
return (
<Comp className={cn(className, 'flex flex-1 flex-col px-4')} {...props}>
<Slottable>{props.children}</Slottable>
</Comp>
);
};
export const CardButtonFooter: React.FC<
{
asChild?: boolean;
children: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>
> = function CardButtonFooter({ className, asChild, ...props }) {
const Comp = asChild ? Slot : 'div';
return (
<Comp
className={cn(
className,
'mt-auto flex h-0 w-full flex-col justify-center border-t px-4',
)}
{...props}
>
<Slottable>{props.children}</Slottable>
</Comp>
);
};

View File

@@ -0,0 +1,11 @@
import { createContext } from 'react';
const SidebarContext = createContext<{
collapsed: boolean;
setCollapsed: (collapsed: boolean) => void;
}>({
collapsed: false,
setCollapsed: (_) => _,
});
export { SidebarContext };

View File

@@ -0,0 +1,118 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { Button } from '../shadcn/button';
import { Heading } from '../shadcn/heading';
import { Trans } from './trans';
// configure this as you wish
const COOKIE_CONSENT_STATUS = 'cookie_consent_status';
enum ConsentStatus {
Accepted = 'accepted',
Rejected = 'rejected',
Unknown = 'unknown',
}
export function CookieBanner() {
const { status, accept, reject } = useCookieConsent();
if (!isBrowser()) {
return null;
}
if (status !== ConsentStatus.Unknown) {
return null;
}
return (
<DialogPrimitive.Root open modal={false}>
<DialogPrimitive.Content
onOpenAutoFocus={(e) => e.preventDefault()}
className={`dark:shadow-primary-500/40 bg-background animate-in fade-in zoom-in-95 slide-in-from-bottom-16 fill-mode-both fixed bottom-0 w-full max-w-lg border p-6 shadow-2xl delay-1000 duration-1000 lg:bottom-[2rem] lg:left-[2rem] lg:h-48 lg:rounded-lg`}
>
<div className={'flex flex-col space-y-4'}>
<div>
<Heading level={3}>
<Trans i18nKey={'cookieBanner.title'} />
</Heading>
</div>
<div className={'text-gray-500 dark:text-gray-400'}>
<Trans i18nKey={'cookieBanner.description'} />
</div>
<div className={'flex justify-end space-x-2.5'}>
<Button variant={'ghost'} onClick={reject}>
<Trans i18nKey={'cookieBanner.reject'} />
</Button>
<Button autoFocus onClick={accept}>
<Trans i18nKey={'cookieBanner.accept'} />
</Button>
</div>
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Root>
);
}
export function useCookieConsent() {
const initialState = getStatusFromLocalStorage();
const [status, setStatus] = useState<ConsentStatus>(initialState);
const accept = useCallback(() => {
const status = ConsentStatus.Accepted;
setStatus(status);
storeStatusInLocalStorage(status);
}, []);
const reject = useCallback(() => {
const status = ConsentStatus.Rejected;
setStatus(status);
storeStatusInLocalStorage(status);
}, []);
const clear = useCallback(() => {
const status = ConsentStatus.Unknown;
setStatus(status);
storeStatusInLocalStorage(status);
}, []);
return useMemo(() => {
return {
clear,
status,
accept,
reject,
};
}, [clear, status, accept, reject]);
}
function storeStatusInLocalStorage(status: ConsentStatus) {
if (!isBrowser()) {
return;
}
localStorage.setItem(COOKIE_CONSENT_STATUS, status);
}
function getStatusFromLocalStorage() {
if (!isBrowser()) {
return ConsentStatus.Unknown;
}
const status = localStorage.getItem(COOKIE_CONSENT_STATUS) as ConsentStatus;
return status ?? ConsentStatus.Unknown;
}
function isBrowser() {
return typeof window !== 'undefined';
}

View File

@@ -0,0 +1,285 @@
'use client';
import { useCallback, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import type {
ColumnDef,
ColumnFiltersState,
PaginationState,
Table as ReactTable,
Row,
SortingState,
VisibilityState,
} from '@tanstack/react-table';
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
} from 'lucide-react';
import { Button } from '../shadcn/button';
import {
Table,
TableBody,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from '../shadcn/table';
import { Trans } from './trans';
interface ReactTableProps<T extends object> {
data: T[];
columns: ColumnDef<T>[];
renderSubComponent?: (props: { row: Row<T> }) => React.ReactElement;
pageIndex?: number;
pageSize?: number;
pageCount?: number;
onPaginationChange?: (pagination: PaginationState) => void;
onSortingChange?: (sorting: SortingState) => void;
manualPagination?: boolean;
manualSorting?: boolean;
sorting?: SortingState;
tableProps?: React.ComponentProps<typeof Table> &
Record<`data-${string}`, string>;
}
export function DataTable<T extends object>({
data,
columns,
pageIndex,
pageSize,
pageCount,
onPaginationChange,
onSortingChange,
tableProps,
manualPagination = true,
manualSorting = false,
sorting: initialSorting,
}: ReactTableProps<T>) {
'use no memo';
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: pageIndex ?? 0,
pageSize: pageSize ?? 15,
});
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? []);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState({});
const navigateToPage = useNavigateToNewPage();
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
manualPagination,
manualSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
pageCount,
state: {
pagination,
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
onSortingChange: (updater) => {
if (typeof updater === 'function') {
const nextState = updater(sorting);
setSorting(nextState);
if (onSortingChange) {
onSortingChange(nextState);
}
} else {
setSorting(updater);
if (onSortingChange) {
onSortingChange(updater);
}
}
},
onPaginationChange: (updater) => {
const navigate = (page: number) => setTimeout(() => navigateToPage(page));
if (typeof updater === 'function') {
setPagination((prevState) => {
const nextState = updater(prevState);
if (onPaginationChange) {
onPaginationChange(nextState);
} else {
navigate(nextState.pageIndex);
}
return nextState;
});
} else {
setPagination(updater);
if (onPaginationChange) {
onPaginationChange(updater);
} else {
navigate(updater.pageIndex);
}
}
},
});
return (
<div className={'rounded-lg border'}>
<Table {...tableProps}>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
colSpan={header.colSpan}
style={{
width: header.column.getSize(),
}}
key={header.id}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
<Trans i18nKey={'common:noData'} />
</TableCell>
</TableRow>
)}
</TableBody>
<TableFooter className={'bg-background'}>
<TableRow>
<TableCell colSpan={columns.length}>
<Pagination table={table} />
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
);
}
function Pagination<T>({
table,
}: React.PropsWithChildren<{
table: ReactTable<T>;
}>) {
return (
<div className="flex items-center gap-x-4">
<span className="text-muted-foreground flex items-center text-sm">
<Trans
i18nKey={'common:pageOfPages'}
values={{
page: table.getState().pagination.pageIndex + 1,
total: table.getPageCount(),
}}
/>
</span>
<div className="flex items-center gap-x-1">
<Button
size={'icon'}
variant={'ghost'}
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<ChevronsLeft className={'h-4'} />
</Button>
<Button
size={'icon'}
variant={'ghost'}
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className={'h-4'} />
</Button>
<Button
size={'icon'}
variant={'ghost'}
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<ChevronRight className={'h-4'} />
</Button>
<Button
size={'icon'}
variant={'ghost'}
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<ChevronsRight className={'h-4'} />
</Button>
</div>
</div>
);
}
/**
* Navigates to a new page using the provided page index and optional page parameter.
*/
function useNavigateToNewPage(
props: { pageParam?: string } = {
pageParam: 'page',
},
) {
const router = useRouter();
const param = props.pageParam ?? 'page';
return useCallback(
(pageIndex: number) => {
const url = new URL(window.location.href);
url.searchParams.set(param, String(pageIndex + 1));
router.push(url.pathname + url.search);
},
[param, router],
);
}

View File

@@ -0,0 +1,79 @@
import React from 'react';
import { cn } from '../lib/utils';
import { Button } from '../shadcn/button';
const EmptyStateHeading: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = ({
className,
...props
}) => (
<h3
className={cn('text-2xl font-bold tracking-tight', className)}
{...props}
/>
);
EmptyStateHeading.displayName = 'EmptyStateHeading';
const EmptyStateText: React.FC<React.HTMLAttributes<HTMLParagraphElement>> = ({
className,
...props
}) => (
<p className={cn('text-muted-foreground text-sm', className)} {...props} />
);
EmptyStateText.displayName = 'EmptyStateText';
const EmptyStateButton: React.FC<
React.ComponentPropsWithoutRef<typeof Button>
> = ({ className, ...props }) => (
<Button className={cn('mt-4', className)} {...props} />
);
EmptyStateButton.displayName = 'EmptyStateButton';
const EmptyState: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
children,
className,
...props
}) => {
const childrenArray = React.Children.toArray(children);
const heading = childrenArray.find(
(child) => React.isValidElement(child) && child.type === EmptyStateHeading,
);
const text = childrenArray.find(
(child) => React.isValidElement(child) && child.type === EmptyStateText,
);
const button = childrenArray.find(
(child) => React.isValidElement(child) && child.type === EmptyStateButton,
);
const cmps = [EmptyStateHeading, EmptyStateText, EmptyStateButton];
const otherChildren = childrenArray.filter(
(child) =>
React.isValidElement(child) &&
!cmps.includes(child.type as (typeof cmps)[number]),
);
return (
<div
className={cn(
'flex flex-1 items-center justify-center rounded-lg border border-dashed shadow-xs',
className,
)}
{...props}
>
<div className="flex flex-col items-center gap-1 text-center">
{heading}
{text}
{button}
{otherChildren}
</div>
</div>
);
};
EmptyState.displayName = 'EmptyState';
export { EmptyState, EmptyStateHeading, EmptyStateText, EmptyStateButton };

View File

@@ -0,0 +1,36 @@
import { If } from './if';
import { LoadingOverlay } from './loading-overlay';
import { TopLoadingBarIndicator } from './top-loading-bar-indicator';
export function GlobalLoader({
displayLogo = false,
fullPage = false,
displaySpinner = true,
displayTopLoadingBar = true,
children,
}: React.PropsWithChildren<{
displayLogo?: boolean;
fullPage?: boolean;
displaySpinner?: boolean;
displayTopLoadingBar?: boolean;
}>) {
return (
<>
<If condition={displayTopLoadingBar}>
<TopLoadingBarIndicator />
</If>
<If condition={displaySpinner}>
<div
className={
'zoom-in-80 animate-in fade-in slide-in-from-bottom-12 flex flex-1 flex-col items-center justify-center duration-500'
}
>
<LoadingOverlay displayLogo={displayLogo} fullPage={fullPage} />
{children}
</div>
</If>
</>
);
}

View File

@@ -0,0 +1,29 @@
import { useMemo } from 'react';
type Condition<Value = unknown> = Value | false | null | undefined | 0 | '';
export function If<Value = unknown>({
condition,
children,
fallback,
}: React.PropsWithoutRef<{
condition: Condition<Value>;
children: React.ReactNode | ((value: Value) => React.ReactNode);
fallback?: React.ReactNode;
}>) {
return useMemo(() => {
if (condition) {
if (typeof children === 'function') {
return <>{children(condition)}</>;
}
return <>{children}</>;
}
if (fallback) {
return <>{fallback}</>;
}
return null;
}, [condition, fallback, children]);
}

View File

@@ -0,0 +1,200 @@
'use client';
import type { FormEvent, MouseEventHandler } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import Image from 'next/image';
import { UploadCloud, X } from 'lucide-react';
import { cn } from '../lib/utils';
import { Button } from '../shadcn/button';
import { Label } from '../shadcn/label';
import { If } from './if';
type Props = Omit<React.InputHTMLAttributes<unknown>, 'value'> & {
image?: string | null;
onClear?: () => void;
onValueChange?: (props: { image: string; file: File }) => void;
visible?: boolean;
} & React.ComponentPropsWithRef<'input'>;
const IMAGE_SIZE = 22;
export const ImageUploadInput: React.FC<Props> =
function ImageUploadInputComponent({
children,
image,
onClear,
onInput,
onValueChange,
ref: forwardedRef,
visible = true,
...props
}) {
const localRef = useRef<HTMLInputElement>(null);
const [state, setState] = useState({
image,
fileName: '',
});
const onInputChange = useCallback(
(e: FormEvent<HTMLInputElement>) => {
e.preventDefault();
const files = e.currentTarget.files;
if (files?.length) {
const file = files[0];
if (!file) {
return;
}
const data = URL.createObjectURL(file);
setState({
image: data,
fileName: file.name,
});
if (onValueChange) {
onValueChange({
image: data,
file,
});
}
}
if (onInput) {
onInput(e);
}
},
[onInput, onValueChange],
);
const onRemove = useCallback(() => {
setState({
image: '',
fileName: '',
});
if (localRef.current) {
localRef.current.value = '';
}
if (onClear) {
onClear();
}
}, [onClear]);
const imageRemoved: MouseEventHandler = useCallback(
(e) => {
e.preventDefault();
onRemove();
},
[onRemove],
);
const setRef = useCallback(
(input: HTMLInputElement) => {
localRef.current = input;
if (typeof forwardedRef === 'function') {
forwardedRef(localRef.current);
}
},
[forwardedRef],
);
useEffect(() => {
setState((state) => ({ ...state, image }));
}, [image]);
useEffect(() => {
if (!image) {
onRemove();
}
}, [image, onRemove]);
const Input = () => (
<input
{...props}
className={cn('hidden', props.className)}
ref={setRef}
type={'file'}
onInput={onInputChange}
accept="image/*"
aria-labelledby={'image-upload-input'}
/>
);
if (!visible) {
return <Input />;
}
return (
<label
id={'image-upload-input'}
className={`border-input bg-background ring-primary ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring relative flex h-10 w-full cursor-pointer rounded-md border border-dashed px-3 py-2 text-sm ring-offset-2 outline-hidden transition-all file:border-0 file:bg-transparent file:text-sm file:font-medium focus:ring-2 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50`}
>
<Input />
<div className={'flex items-center space-x-4'}>
<div className={'flex'}>
<If condition={!state.image}>
<UploadCloud className={'text-muted-foreground h-5'} />
</If>
<If condition={state.image}>
<Image
loading={'lazy'}
style={{
width: IMAGE_SIZE,
height: IMAGE_SIZE,
}}
className={'object-contain'}
width={IMAGE_SIZE}
height={IMAGE_SIZE}
src={state.image!}
alt={props.alt ?? ''}
/>
</If>
</div>
<If condition={!state.image}>
<div className={'flex flex-auto'}>
<Label className={'cursor-pointer text-xs'}>{children}</Label>
</div>
</If>
<If condition={state.image}>
<div className={'flex flex-auto'}>
<If
condition={state.fileName}
fallback={
<Label className={'cursor-pointer truncate text-xs'}>
{children}
</Label>
}
>
<Label className={'truncate text-xs'}>{state.fileName}</Label>
</If>
</div>
</If>
<If condition={state.image}>
<Button
size={'icon'}
className={'h-5! w-5!'}
onClick={imageRemoved}
>
<X className="h-4" />
</Button>
</If>
</div>
</label>
);
};

View File

@@ -0,0 +1,113 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { Image as ImageIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { Button } from '../shadcn/button';
import { ImageUploadInput } from './image-upload-input';
import { Trans } from './trans';
export function ImageUploader(
props: React.PropsWithChildren<{
value: string | null | undefined;
onValueChange: (value: File | null) => unknown;
}>,
) {
const [image, setImage] = useState(props.value);
const { setValue, register } = useForm<{
value: string | null | FileList;
}>({
defaultValues: {
value: props.value,
},
mode: 'onChange',
reValidateMode: 'onChange',
});
const control = register('value');
const onClear = useCallback(() => {
props.onValueChange(null);
setValue('value', null);
setImage('');
}, [props, setValue]);
const onValueChange = useCallback(
({ image, file }: { image: string; file: File }) => {
props.onValueChange(file);
setImage(image);
},
[props],
);
const Input = () => (
<ImageUploadInput
{...control}
accept={'image/*'}
className={'absolute h-full w-full'}
visible={false}
multiple={false}
onValueChange={onValueChange}
/>
);
useEffect(() => {
setImage(props.value);
}, [props.value]);
if (!image) {
return (
<FallbackImage descriptionSection={props.children}>
<Input />
</FallbackImage>
);
}
return (
<div className={'flex items-center space-x-4'}>
<label className={'animate-in fade-in zoom-in-50 relative h-20 w-20'}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
decoding="async"
className={'h-20 w-20 rounded-full object-cover'}
src={image}
alt={''}
/>
<Input />
</label>
<div>
<Button onClick={onClear} size={'sm'} variant={'ghost'}>
<Trans i18nKey={'common:clear'} />
</Button>
</div>
</div>
);
}
function FallbackImage(
props: React.PropsWithChildren<{
descriptionSection?: React.ReactNode;
}>,
) {
return (
<div className={'flex items-center space-x-4'}>
<label
className={
'border-border animate-in fade-in zoom-in-50 hover:border-primary relative flex h-20 w-20 cursor-pointer flex-col items-center justify-center rounded-full border'
}
>
<ImageIcon className={'text-primary h-8'} />
{props.children}
</label>
{props.descriptionSection}
</div>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../shadcn/select';
export function LanguageSelector({
onChange,
}: {
onChange?: (locale: string) => unknown;
}) {
const { i18n } = useTranslation();
const { language: currentLanguage, options } = i18n;
const locales = (options.supportedLngs as string[]).filter(
(locale) => locale.toLowerCase() !== 'cimode',
);
const languageNames = useMemo(() => {
return new Intl.DisplayNames([currentLanguage], {
type: 'language',
});
}, [currentLanguage]);
const [value, setValue] = useState(i18n.language);
const languageChanged = useCallback(
async (locale: string) => {
setValue(locale);
if (onChange) {
onChange(locale);
}
await i18n.changeLanguage(locale);
// refresh cached translations
window.location.reload();
},
[i18n, onChange],
);
return (
<Select value={value} onValueChange={languageChanged}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{locales.map((locale) => {
const label = capitalize(languageNames.of(locale) ?? locale);
const option = {
value: locale,
label,
};
return (
<SelectItem value={option.value} key={option.value}>
{option.label}
</SelectItem>
);
})}
</SelectContent>
</Select>
);
}
function capitalize(lang: string) {
return lang.slice(0, 1).toUpperCase() + lang.slice(1);
}

View File

@@ -0,0 +1,62 @@
'use client';
import { createRef, useLayoutEffect, useMemo, useState } from 'react';
/**
* @description Render a component lazily based on the IntersectionObserver
* appConfig provided.
* Full documentation at: https://makerkit.dev/docs/components-utilities#lazyrender
* @param children
* @param threshold
* @param rootMargin
* @param onVisible
* @constructor
*/
export function LazyRender({
children,
threshold,
rootMargin,
onVisible,
}: React.PropsWithChildren<{
threshold?: number;
rootMargin?: string;
onVisible?: () => void;
}>) {
const ref = useMemo(() => createRef<HTMLDivElement>(), []);
const [isVisible, setIsVisible] = useState(false);
useLayoutEffect(() => {
if (!ref.current) {
return;
}
const options = {
rootMargin: rootMargin ?? '0px',
threshold: threshold ?? 1,
};
const isIntersecting = (entry: IntersectionObserverEntry) =>
entry.isIntersecting || entry.intersectionRatio > 0;
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach((entry) => {
if (isIntersecting(entry)) {
setIsVisible(true);
observer.disconnect();
if (onVisible) {
onVisible();
}
}
});
}, options);
observer.observe(ref.current);
return () => {
observer.disconnect();
};
}, [threshold, rootMargin, ref, onVisible]);
return <div ref={ref}>{isVisible ? children : null}</div>;
}

View File

@@ -0,0 +1,33 @@
import type { PropsWithChildren } from 'react';
import { cn } from '../lib/utils';
import { Spinner } from './spinner';
export function LoadingOverlay({
children,
className,
fullPage = true,
spinnerClassName,
}: PropsWithChildren<{
className?: string;
spinnerClassName?: string;
fullPage?: boolean;
displayLogo?: boolean;
}>) {
return (
<div
className={cn(
'flex flex-col items-center justify-center space-y-4',
className,
{
[`bg-background fixed top-0 left-0 z-100 h-screen w-screen`]:
fullPage,
},
)}
>
<Spinner className={spinnerClassName} />
<div className={'text-muted-foreground text-sm'}>{children}</div>
</div>
);
}

View File

@@ -0,0 +1,107 @@
import React from 'react';
import { Button } from '@kit/ui/button';
import { cn } from '@kit/ui/utils';
import { CtaButton } from './cta-button';
import { GradientSecondaryText } from './gradient-secondary-text';
import { HeroTitle } from './hero-title';
const ComingSoonHeading: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = ({
className,
...props
}) => <HeroTitle className={cn(className)} {...props} />;
ComingSoonHeading.displayName = 'ComingSoonHeading';
const ComingSoonText: React.FC<React.HTMLAttributes<HTMLParagraphElement>> = ({
className,
...props
}) => (
<GradientSecondaryText
className={cn('text-muted-foreground text-lg md:text-xl', className)}
{...props}
/>
);
ComingSoonText.displayName = 'ComingSoonText';
const ComingSoonButton: React.FC<
React.ComponentPropsWithoutRef<typeof Button>
> = ({ className, ...props }) => (
<CtaButton className={cn('mt-8', className)} {...props} />
);
ComingSoonButton.displayName = 'ComingSoonButton';
const ComingSoon: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
children,
className,
...props
}) => {
const childrenArray = React.Children.toArray(children);
const logo = childrenArray.find(
(child) => React.isValidElement(child) && child.type === ComingSoonLogo,
);
const heading = childrenArray.find(
(child) => React.isValidElement(child) && child.type === ComingSoonHeading,
);
const text = childrenArray.find(
(child) => React.isValidElement(child) && child.type === ComingSoonText,
);
const button = childrenArray.find(
(child) => React.isValidElement(child) && child.type === ComingSoonButton,
);
const cmps = [
ComingSoonHeading,
ComingSoonText,
ComingSoonButton,
ComingSoonLogo,
];
const otherChildren = childrenArray.filter(
(child) =>
React.isValidElement(child) &&
!cmps.includes(child.type as (typeof cmps)[number]),
);
return (
<div
className={cn(
'container flex min-h-screen flex-col items-center justify-center space-y-12 p-4',
className,
)}
{...props}
>
{logo}
<div className="mx-auto flex w-full max-w-4xl flex-col items-center justify-center space-y-8 text-center">
{heading}
<div className={'mx-auto max-w-2xl'}>{text}</div>
{button}
{otherChildren}
</div>
</div>
);
};
ComingSoon.displayName = 'ComingSoon';
const ComingSoonLogo: React.FC<React.HTMLAttributes<HTMLImageElement>> = ({
className,
...props
}) => <div className={cn(className, 'fixed top-8 left-8')} {...props} />;
ComingSoonLogo.displayName = 'ComingSoonLogo';
export {
ComingSoon,
ComingSoonHeading,
ComingSoonText,
ComingSoonButton,
ComingSoonLogo,
};

View File

@@ -0,0 +1,22 @@
import { cn } from '../../lib/utils';
import { Button } from '../../shadcn/button';
export const CtaButton: React.FC<React.ComponentProps<typeof Button>> =
function CtaButtonComponent({ className, children, ...props }) {
return (
<Button
className={cn(
'h-12 rounded-xl px-4 text-base font-semibold',
className,
{
['dark:shadow-primary/30 transition-all hover:shadow-2xl']:
props.variant === 'default' || !props.variant,
},
)}
asChild
{...props}
>
{children}
</Button>
);
};

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { cn } from '../../lib/utils';
import { CardDescription, CardHeader, CardTitle } from '../../shadcn/card';
interface FeatureCardProps extends React.HTMLAttributes<HTMLDivElement> {
label: string;
description: string;
}
export const FeatureCard: React.FC<FeatureCardProps> = ({
className,
label,
description,
...props
}) => {
return (
<div className={cn('rounded-xl border p-4', className)} {...props}>
<CardHeader>
<CardTitle className="text-xl font-medium">{label}</CardTitle>
<CardDescription className="text-muted-foreground max-w-xs text-sm font-normal">
{description}
</CardDescription>
</CardHeader>
</div>
);
};

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { cn } from '../../lib/utils';
export const FeatureGrid: React.FC<React.HTMLAttributes<HTMLDivElement>> =
function FeatureGridComponent({ className, children, ...props }) {
return (
<div
className={cn(
'mt-2 grid w-full grid-cols-1 gap-4 md:mt-6 md:grid-cols-2 md:grid-cols-3 lg:grid-cols-3',
className,
)}
{...props}
>
{children}
</div>
);
};

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { cn } from '../../lib/utils';
interface FeatureShowcaseProps extends React.HTMLAttributes<HTMLDivElement> {
heading: React.ReactNode;
icon?: React.ReactNode;
}
export const FeatureShowcase: React.FC<FeatureShowcaseProps> =
function FeatureShowcaseComponent({
className,
heading,
icon,
children,
...props
}) {
return (
<div
className={cn('flex flex-col justify-between space-y-8', className)}
{...props}
>
<div className="flex w-full max-w-5xl flex-col gap-y-4">
{icon && <div className="flex">{icon}</div>}
<h3 className="text-3xl font-normal tracking-tight xl:text-5xl">
{heading}
</h3>
</div>
{children}
</div>
);
};
export function FeatureShowcaseIconContainer(
props: React.PropsWithChildren<{
className?: string;
}>,
) {
return (
<div className={'flex'}>
<div
className={cn(
'flex items-center justify-center space-x-4 rounded-lg p-3 font-medium',
props.className,
)}
>
{props.children}
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { cn } from '../../lib/utils';
interface FooterSection {
heading: React.ReactNode;
links: Array<{
href: string;
label: React.ReactNode;
}>;
}
interface FooterProps extends React.HTMLAttributes<HTMLElement> {
logo: React.ReactNode;
description: React.ReactNode;
copyright: React.ReactNode;
sections: FooterSection[];
}
export const Footer: React.FC<FooterProps> = ({
className,
logo,
description,
copyright,
sections,
...props
}) => {
return (
<footer
className={cn(
'site-footer relative mt-auto w-full py-8 2xl:py-20',
className,
)}
{...props}
>
<div className="container">
<div className="flex flex-col space-y-8 lg:flex-row lg:space-y-0">
<div className="flex w-full gap-x-3 lg:w-4/12 xl:w-4/12 xl:space-x-6 2xl:space-x-8">
<div className="flex flex-col gap-y-4">
<div>{logo}</div>
<div className="flex flex-col gap-y-4">
<div>
<p className="text-muted-foreground text-sm tracking-tight">
{description}
</p>
</div>
<div className="text-muted-foreground flex text-xs">
<p>{copyright}</p>
</div>
</div>
</div>
</div>
<div className="flex w-full flex-col gap-y-4 lg:flex-row lg:justify-end lg:gap-x-6 lg:gap-y-0 xl:gap-x-12">
{sections.map((section, index) => (
<div key={index}>
<div className="flex flex-col gap-y-2.5">
<FooterSectionHeading>{section.heading}</FooterSectionHeading>
<FooterSectionList>
{section.links.map((link, linkIndex) => (
<FooterLink key={linkIndex} href={link.href}>
{link.label}
</FooterLink>
))}
</FooterSectionList>
</div>
</div>
))}
</div>
</div>
</div>
</footer>
);
};
function FooterSectionHeading(props: React.PropsWithChildren) {
return (
<span className="font-heading text-sm font-semibold tracking-tight">
{props.children}
</span>
);
}
function FooterSectionList(props: React.PropsWithChildren) {
return <ul className="flex flex-col gap-y-2">{props.children}</ul>;
}
function FooterLink({
href,
children,
}: React.PropsWithChildren<{ href: string }>) {
return (
<li className="text-muted-foreground text-sm tracking-tight hover:underline [&>a]:transition-colors">
<a href={href}>{children}</a>
</li>
);
}

View File

@@ -0,0 +1,23 @@
import { Slot, Slottable } from '@radix-ui/react-slot';
import { cn } from '../../lib/utils';
export const GradientSecondaryText: React.FC<
React.HTMLAttributes<HTMLSpanElement> & {
asChild?: boolean;
}
> = function GradientSecondaryTextComponent({ className, ...props }) {
const Comp = props.asChild ? Slot : 'span';
return (
<Comp
className={cn(
'dark:from-foreground/60 dark:to-foreground text-secondary-foreground dark:bg-linear-to-r dark:bg-clip-text dark:text-transparent',
className,
)}
{...props}
>
<Slottable>{props.children}</Slottable>
</Comp>
);
};

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { cn } from '../../lib/utils';
export const GradientText: React.FC<React.HTMLAttributes<HTMLSpanElement>> =
function GradientTextComponent({ className, children, ...props }) {
return (
<span
className={cn(
'bg-linear-to-r bg-clip-text text-transparent',
className,
)}
{...props}
>
{children}
</span>
);
};

View File

@@ -0,0 +1,33 @@
import { cn } from '../../lib/utils';
interface HeaderProps extends React.HTMLAttributes<HTMLDivElement> {
logo?: React.ReactNode;
navigation?: React.ReactNode;
actions?: React.ReactNode;
}
export const Header: React.FC<HeaderProps> = function ({
className,
logo,
navigation,
actions,
...props
}) {
return (
<div
className={cn(
'site-header bg-background/80 dark:bg-background/50 sticky top-0 z-10 w-full py-1 backdrop-blur-md',
className,
)}
{...props}
>
<div className="container">
<div className="grid h-14 grid-cols-3 items-center">
<div className={'mx-auto md:mx-0'}>{logo}</div>
<div className="order-first md:order-none">{navigation}</div>
<div className="flex items-center justify-end gap-x-2">{actions}</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,23 @@
import { Slot, Slottable } from '@radix-ui/react-slot';
import { cn } from '../../lib/utils';
export const HeroTitle: React.FC<
React.HTMLAttributes<HTMLHeadingElement> & {
asChild?: boolean;
}
> = function HeroTitleComponent({ children, className, ...props }) {
const Comp = props.asChild ? Slot : 'h1';
return (
<Comp
className={cn(
'hero-title flex flex-col text-center font-sans text-4xl font-semibold tracking-tighter sm:text-6xl lg:max-w-5xl lg:text-7xl xl:text-[4.5rem] dark:text-white',
className,
)}
{...props}
>
<Slottable>{children}</Slottable>
</Comp>
);
};

View File

@@ -0,0 +1,90 @@
import React from 'react';
import { cn } from '../../lib/utils';
import { HeroTitle } from './hero-title';
interface HeroProps {
pill?: React.ReactNode;
title: React.ReactNode;
subtitle?: React.ReactNode;
cta?: React.ReactNode;
image?: React.ReactNode;
className?: string;
animate?: boolean;
}
export function Hero({
pill,
title,
subtitle,
cta,
image,
className,
animate = true,
}: HeroProps) {
return (
<div className={cn('mx-auto flex flex-col space-y-20', className)}>
<div
style={{
MozAnimationDuration: '100ms',
}}
className={cn(
'mx-auto flex flex-1 flex-col items-center justify-center duration-800 md:flex-row',
{
['animate-in fade-in zoom-in-90 slide-in-from-top-24']: animate,
},
)}
>
<div className="flex w-full flex-1 flex-col items-center gap-y-6 xl:gap-y-8 2xl:gap-y-12">
{pill && (
<div
className={cn({
['animate-in fade-in fill-mode-both delay-300 duration-700']:
animate,
})}
>
{pill}
</div>
)}
<div className="flex flex-col items-center gap-y-6">
<HeroTitle>{title}</HeroTitle>
{subtitle && (
<div className="flex max-w-lg">
<h3 className="text-muted-foreground p-0 text-center font-sans text-2xl font-normal tracking-tight">
{subtitle}
</h3>
</div>
)}
</div>
{cta && (
<div
className={cn({
['animate-in fade-in fill-mode-both delay-500 duration-1000']:
animate,
})}
>
{cta}
</div>
)}
</div>
</div>
{image && (
<div
style={{
MozAnimationDuration: '100ms',
}}
className={cn('container mx-auto flex justify-center py-8', {
['animate-in fade-in zoom-in-90 slide-in-from-top-32 fill-mode-both delay-600 duration-1000']:
animate,
})}
>
{image}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,15 @@
export * from './hero-title';
export * from './pill';
export * from './gradient-secondary-text';
export * from './gradient-text';
export * from './hero';
export * from './secondary-hero';
export * from './cta-button';
export * from './header';
export * from './footer';
export * from './feature-showcase';
export * from './feature-grid';
export * from './feature-card';
export * from './newsletter-signup';
export * from './newsletter-signup-container';
export * from './coming-soon';

View File

@@ -0,0 +1,86 @@
'use client';
import { useCallback, useState } from 'react';
import { cn } from '../../lib/utils';
import { Alert, AlertDescription, AlertTitle } from '../../shadcn/alert';
import { Heading } from '../../shadcn/heading';
import { Spinner } from '../spinner';
import { NewsletterSignup } from './newsletter-signup';
interface NewsletterSignupContainerProps
extends React.HTMLAttributes<HTMLDivElement> {
onSignup: (email: string) => Promise<void>;
heading?: string;
description?: string;
successMessage?: string;
errorMessage?: string;
}
export function NewsletterSignupContainer({
onSignup,
heading = 'Subscribe to our newsletter',
description = 'Get the latest updates and offers directly to your inbox.',
successMessage = 'Thank you for subscribing!',
errorMessage = 'An error occurred. Please try again.',
className,
...props
}: NewsletterSignupContainerProps) {
const [status, setStatus] = useState<
'idle' | 'loading' | 'success' | 'error'
>('idle');
const handleSubmit = useCallback(
async (data: { email: string }) => {
setStatus('loading');
try {
await onSignup(data.email);
setStatus('success');
} catch (error) {
console.error('Newsletter signup error:', error);
setStatus('error');
}
},
[onSignup],
);
return (
<div
className={cn('flex flex-col items-center space-y-4', className)}
{...props}
>
<div className="text-center">
<Heading level={4}>{heading}</Heading>
<p className="text-muted-foreground">{description}</p>
</div>
{status === 'idle' && <NewsletterSignup onSignup={handleSubmit} />}
{status === 'loading' && (
<div className="flex justify-center">
<Spinner className="h-8 w-8" />
</div>
)}
{status === 'success' && (
<div>
<Alert variant="success">
<AlertTitle>Success!</AlertTitle>
<AlertDescription>{successMessage}</AlertDescription>
</Alert>
</div>
)}
{status === 'error' && (
<div>
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,71 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { cn } from '../../lib/utils';
import { Button } from '../../shadcn/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '../../shadcn/form';
import { Input } from '../../shadcn/input';
const NewsletterFormSchema = z.object({
email: z.string().email('Please enter a valid email address'),
});
type NewsletterFormValues = z.infer<typeof NewsletterFormSchema>;
interface NewsletterSignupProps extends React.HTMLAttributes<HTMLDivElement> {
onSignup: (data: NewsletterFormValues) => void;
buttonText?: string;
placeholder?: string;
}
export function NewsletterSignup({
onSignup,
buttonText = 'Subscribe',
placeholder = 'Enter your email',
className,
...props
}: NewsletterSignupProps) {
const form = useForm<NewsletterFormValues>({
resolver: zodResolver(NewsletterFormSchema),
defaultValues: {
email: '',
},
});
return (
<div className={cn('w-full max-w-sm', className)} {...props}>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSignup)}
className="flex flex-col gap-y-3"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormControl>
<Input placeholder={placeholder} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full">
{buttonText}
</Button>
</form>
</Form>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { Slot, Slottable } from '@radix-ui/react-slot';
import { cn } from '../../lib/utils';
import { GradientSecondaryText } from './gradient-secondary-text';
export const Pill: React.FC<
React.HTMLAttributes<HTMLHeadingElement> & {
label?: React.ReactNode;
asChild?: boolean;
}
> = function PillComponent({ className, asChild, ...props }) {
const Comp = asChild ? Slot : 'h3';
return (
<Comp
className={cn(
'bg-muted/50 flex items-center gap-x-1.5 rounded-full border px-2 py-1 pr-2 text-center text-sm font-medium text-transparent',
className,
)}
{...props}
>
{props.label && (
<span
className={
'text-primary-foreground bg-primary rounded-2xl border px-1.5 py-0.5 text-xs font-bold tracking-tight'
}
>
{props.label}
</span>
)}
<Slottable>
<GradientSecondaryText
className={'flex items-center gap-x-2 font-semibold tracking-tight'}
>
{props.children}
</GradientSecondaryText>
</Slottable>
</Comp>
);
};
export const PillActionButton: React.FC<
React.HTMLAttributes<HTMLButtonElement> & {
asChild?: boolean;
}
> = ({ asChild, ...props }) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
{...props}
className={
'text-secondary-foreground bg-input active:bg-primary active:text-primary-foreground hover:ring-muted-foreground/50 rounded-full px-1.5 py-1.5 text-center text-sm font-medium ring ring-transparent transition-colors'
}
>
{props.children}
</Comp>
);
};

View File

@@ -0,0 +1,42 @@
import { cn } from '../../lib/utils';
import { Heading } from '../../shadcn/heading';
interface SecondaryHeroProps extends React.HTMLAttributes<HTMLDivElement> {
pill?: React.ReactNode;
heading: React.ReactNode;
subheading: React.ReactNode;
}
export const SecondaryHero: React.FC<SecondaryHeroProps> =
function SecondaryHeroComponent({
className,
pill,
heading,
subheading,
children,
...props
}) {
return (
<div
className={cn(
'flex flex-col items-center space-y-6 text-center',
className,
)}
{...props}
>
{pill}
<div className="flex flex-col">
<Heading level={2} className="tracking-tighter">
{heading}
</Heading>
<h3 className="text-muted-foreground font-sans text-xl font-normal tracking-tight">
{subheading}
</h3>
</div>
{children}
</div>
);
};

View File

@@ -0,0 +1,72 @@
'use client';
import { useMemo } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ChevronDown } from 'lucide-react';
import { Button } from '../shadcn/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../shadcn/dropdown-menu';
import { Trans } from './trans';
function MobileNavigationDropdown({
links,
}: {
links: {
path: string;
label: string;
}[];
}) {
const path = usePathname();
const currentPathName = useMemo(() => {
return Object.values(links).find((link) => link.path === path)?.label;
}, [links, path]);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant={'secondary'} className={'w-full'}>
<span
className={'flex w-full items-center justify-between space-x-2'}
>
<span>
<Trans i18nKey={currentPathName} defaults={currentPathName} />
</span>
<ChevronDown className={'h-5'} />
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className={
'dark:divide-dark-700 w-screen divide-y divide-gray-100' +
' rounded-none'
}
>
{Object.values(links).map((link) => {
return (
<DropdownMenuItem asChild key={link.path}>
<Link
className={'flex h-12 w-full items-center'}
href={link.path}
>
<Trans i18nKey={link.label} defaults={link.label} />
</Link>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
export default MobileNavigationDropdown;

View File

@@ -0,0 +1,77 @@
'use client';
import { useMemo } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ChevronDown } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../shadcn/dropdown-menu';
import { Trans } from './trans';
function MobileNavigationDropdown({
links,
}: {
links: {
path: string;
label: string;
}[];
}) {
const path = usePathname();
const items = useMemo(
function MenuItems() {
return Object.values(links).map((link) => {
return (
<DropdownMenuItem key={link.path}>
<Link
className={'flex h-full w-full items-center'}
href={link.path}
>
<Trans i18nKey={link.label} defaults={link.label} />
</Link>
</DropdownMenuItem>
);
});
},
[links],
);
const currentPathName = useMemo(() => {
return Object.values(links).find((link) => link.path === path)?.label;
}, [links, path]);
return (
<DropdownMenu>
<DropdownMenuTrigger className={'w-full'}>
<div
className={
'Button dark:ring-dark-700 w-full justify-start ring-2 ring-gray-100'
}
>
<span
className={
'ButtonNormal flex w-full items-center justify-between space-x-2'
}
>
<span>
<Trans i18nKey={currentPathName} defaults={currentPathName} />
</span>
<ChevronDown className={'h-5'} />
</span>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>{items}</DropdownMenuContent>
</DropdownMenu>
);
}
export default MobileNavigationDropdown;

View File

@@ -0,0 +1,141 @@
'use client';
import { useMemo } from 'react';
import { Computer, Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { cn } from '../lib/utils';
import { Button } from '../shadcn/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '../shadcn/dropdown-menu';
import { Trans } from './trans';
const MODES = ['light', 'dark', 'system'];
export function ModeToggle(props: { className?: string }) {
const { setTheme, theme } = useTheme();
const Items = useMemo(() => {
return MODES.map((mode) => {
const isSelected = theme === mode;
return (
<DropdownMenuItem
className={cn('space-x-2', {
'bg-muted': isSelected,
})}
key={mode}
onClick={() => {
setTheme(mode);
setCookeTheme(mode);
}}
>
<Icon theme={mode} />
<span>
<Trans i18nKey={`common:${mode}Theme`} />
</span>
</DropdownMenuItem>
);
});
}, [setTheme, theme]);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className={props.className}>
<Sun className="h-[0.9rem] w-[0.9rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute h-[0.9rem] w-[0.9rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">{Items}</DropdownMenuContent>
</DropdownMenu>
);
}
export function SubMenuModeToggle() {
const { setTheme, theme, resolvedTheme } = useTheme();
const MenuItems = useMemo(
() =>
MODES.map((mode) => {
const isSelected = theme === mode;
return (
<DropdownMenuItem
className={cn('flex cursor-pointer items-center space-x-2', {
'bg-muted': isSelected,
})}
key={mode}
onClick={() => {
setTheme(mode);
setCookeTheme(mode);
}}
>
<Icon theme={mode} />
<span>
<Trans i18nKey={`common:${mode}Theme`} />
</span>
</DropdownMenuItem>
);
}),
[setTheme, theme],
);
return (
<>
<DropdownMenuSub>
<DropdownMenuSubTrigger
className={
'hidden w-full items-center justify-between gap-x-3 lg:flex'
}
>
<span className={'flex space-x-2'}>
<Icon theme={resolvedTheme} />
<span>
<Trans i18nKey={'common:theme'} />
</span>
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>{MenuItems}</DropdownMenuSubContent>
</DropdownMenuSub>
<div className={'lg:hidden'}>
<DropdownMenuLabel>
<Trans i18nKey={'common:theme'} />
</DropdownMenuLabel>
{MenuItems}
</div>
</>
);
}
function setCookeTheme(theme: string) {
document.cookie = `theme=${theme}; path=/; max-age=31536000`;
}
function Icon({ theme }: { theme: string | undefined }) {
switch (theme) {
case 'light':
return <Sun className="h-4" />;
case 'dark':
return <Moon className="h-4" />;
case 'system':
return <Computer className="h-4" />;
}
}

View File

@@ -0,0 +1,435 @@
'use client';
import React, {
HTMLProps,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Slot, Slottable } from '@radix-ui/react-slot';
import { useMutation } from '@tanstack/react-query';
import { Path, UseFormReturn } from 'react-hook-form';
import { z } from 'zod';
import { cn } from '../lib/utils';
interface MultiStepFormProps<T extends z.ZodType> {
schema: T;
form: UseFormReturn<z.infer<T>>;
onSubmit: (data: z.infer<T>) => void;
useStepTransition?: boolean;
className?: string;
}
type StepProps = React.PropsWithChildren<
{
name: string;
asChild?: boolean;
} & React.HTMLProps<HTMLDivElement>
>;
const MultiStepFormContext = createContext<ReturnType<
typeof useMultiStepForm
> | null>(null);
/**
* @name MultiStepForm
* @description Multi-step form component for React
* @param schema
* @param form
* @param onSubmit
* @param children
* @param className
* @constructor
*/
export function MultiStepForm<T extends z.ZodType>({
schema,
form,
onSubmit,
children,
className,
}: React.PropsWithChildren<MultiStepFormProps<T>>) {
const steps = useMemo(
() =>
React.Children.toArray(children).filter(
(child): child is React.ReactElement<StepProps> =>
React.isValidElement(child) && child.type === MultiStepFormStep,
),
[children],
);
const header = useMemo(() => {
return React.Children.toArray(children).find(
(child) =>
React.isValidElement(child) && child.type === MultiStepFormHeader,
);
}, [children]);
const footer = useMemo(() => {
return React.Children.toArray(children).find(
(child) =>
React.isValidElement(child) && child.type === MultiStepFormFooter,
);
}, [children]);
const stepNames = steps.map((step) => step.props.name);
const multiStepForm = useMultiStepForm(schema, form, stepNames, onSubmit);
return (
<MultiStepFormContext.Provider value={multiStepForm}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className={cn(className, 'flex size-full flex-col overflow-hidden')}
>
{header}
<div className="relative transition-transform duration-500">
{steps.map((step, index) => {
const isActive = index === multiStepForm.currentStepIndex;
return (
<AnimatedStep
key={step.props.name}
direction={multiStepForm.direction}
isActive={isActive}
index={index}
currentIndex={multiStepForm.currentStepIndex}
>
{step}
</AnimatedStep>
);
})}
</div>
{footer}
</form>
</MultiStepFormContext.Provider>
);
}
export function MultiStepFormContextProvider(props: {
children: (context: ReturnType<typeof useMultiStepForm>) => React.ReactNode;
}) {
const ctx = useMultiStepFormContext();
if (Array.isArray(props.children)) {
const [child] = props.children;
return (
child as (context: ReturnType<typeof useMultiStepForm>) => React.ReactNode
)(ctx);
}
return props.children(ctx);
}
export const MultiStepFormStep: React.FC<
React.PropsWithChildren<
{
asChild?: boolean;
ref?: React.Ref<HTMLDivElement>;
} & HTMLProps<HTMLDivElement>
>
> = function MultiStepFormStep({ children, asChild, ...props }) {
const Cmp = asChild ? Slot : 'div';
return (
<Cmp {...props}>
<Slottable>{children}</Slottable>
</Cmp>
);
};
export function useMultiStepFormContext<Schema extends z.ZodType>() {
const context = useContext(MultiStepFormContext) as ReturnType<
typeof useMultiStepForm<Schema>
>;
if (!context) {
throw new Error(
'useMultiStepFormContext must be used within a MultiStepForm',
);
}
return context;
}
/**
* @name useMultiStepForm
* @description Hook for multi-step forms
* @param schema
* @param form
* @param stepNames
* @param onSubmit
*/
export function useMultiStepForm<Schema extends z.ZodType>(
schema: Schema,
form: UseFormReturn<z.infer<Schema>>,
stepNames: string[],
onSubmit: (data: z.infer<Schema>) => void,
) {
const [state, setState] = useState({
currentStepIndex: 0,
direction: undefined as 'forward' | 'backward' | undefined,
});
const isStepValid = useCallback(() => {
const currentStepName = stepNames[state.currentStepIndex] as Path<
z.TypeOf<Schema>
>;
if (schema instanceof z.ZodObject) {
const currentStepSchema = schema.shape[currentStepName] as z.ZodType;
// the user may not want to validate the current step
// or the step doesn't contain any form field
if (!currentStepSchema) {
return true;
}
const currentStepData = form.getValues(currentStepName) ?? {};
const result = currentStepSchema.safeParse(currentStepData);
return result.success;
}
throw new Error(`Unsupported schema type: ${schema.constructor.name}`);
}, [schema, form, stepNames, state.currentStepIndex]);
const nextStep = useCallback(
<Ev extends React.SyntheticEvent>(e: Ev) => {
// prevent form submission when the user presses Enter
// or if the user forgets [type="button"] on the button
e.preventDefault();
const isValid = isStepValid();
if (!isValid) {
const currentStepName = stepNames[state.currentStepIndex] as Path<
z.TypeOf<Schema>
>;
if (schema instanceof z.ZodObject) {
const currentStepSchema = schema.shape[currentStepName] as z.ZodType;
if (currentStepSchema) {
const fields = Object.keys(
(currentStepSchema as z.ZodObject<never>).shape,
);
const keys = fields.map((field) => `${currentStepName}.${field}`);
// trigger validation for all fields in the current step
for (const key of keys) {
void form.trigger(key as Path<z.TypeOf<Schema>>);
}
return;
}
}
}
if (isValid && state.currentStepIndex < stepNames.length - 1) {
setState((prevState) => {
return {
...prevState,
direction: 'forward',
currentStepIndex: prevState.currentStepIndex + 1,
};
});
}
},
[isStepValid, state.currentStepIndex, stepNames, schema, form],
);
const prevStep = useCallback(
<Ev extends React.SyntheticEvent>(e: Ev) => {
// prevent form submission when the user presses Enter
// or if the user forgets [type="button"] on the button
e.preventDefault();
if (state.currentStepIndex > 0) {
setState((prevState) => {
return {
...prevState,
direction: 'backward',
currentStepIndex: prevState.currentStepIndex - 1,
};
});
}
},
[state.currentStepIndex],
);
const goToStep = useCallback(
(index: number) => {
if (index >= 0 && index < stepNames.length && isStepValid()) {
setState((prevState) => {
return {
...prevState,
direction:
index > prevState.currentStepIndex ? 'forward' : 'backward',
currentStepIndex: index,
};
});
}
},
[isStepValid, stepNames.length],
);
const isValid = form.formState.isValid;
const errors = form.formState.errors;
const mutation = useMutation({
mutationFn: () => {
return form.handleSubmit(onSubmit)();
},
});
return useMemo(
() => ({
form,
currentStep: stepNames[state.currentStepIndex] as string,
currentStepIndex: state.currentStepIndex,
totalSteps: stepNames.length,
isFirstStep: state.currentStepIndex === 0,
isLastStep: state.currentStepIndex === stepNames.length - 1,
nextStep,
prevStep,
goToStep,
direction: state.direction,
isStepValid,
isValid,
errors,
mutation,
}),
[
form,
mutation,
stepNames,
state.currentStepIndex,
state.direction,
nextStep,
prevStep,
goToStep,
isStepValid,
isValid,
errors,
],
);
}
export const MultiStepFormHeader: React.FC<
React.PropsWithChildren<
{
asChild?: boolean;
} & HTMLProps<HTMLDivElement>
>
> = function MultiStepFormHeader({ children, asChild, ...props }) {
const Cmp = asChild ? Slot : 'div';
return (
<Cmp {...props}>
<Slottable>{children}</Slottable>
</Cmp>
);
};
export const MultiStepFormFooter: React.FC<
React.PropsWithChildren<
{
asChild?: boolean;
} & HTMLProps<HTMLDivElement>
>
> = function MultiStepFormFooter({ children, asChild, ...props }) {
const Cmp = asChild ? Slot : 'div';
return (
<Cmp {...props}>
<Slottable>{children}</Slottable>
</Cmp>
);
};
/**
* @name createStepSchema
* @description Create a schema for a multi-step form
* @param steps
*/
export function createStepSchema<T extends Record<string, z.ZodType>>(
steps: T,
) {
return z.object(steps);
}
interface AnimatedStepProps {
direction: 'forward' | 'backward' | undefined;
isActive: boolean;
index: number;
currentIndex: number;
}
function AnimatedStep({
isActive,
direction,
children,
index,
currentIndex,
}: React.PropsWithChildren<AnimatedStepProps>) {
const [shouldRender, setShouldRender] = useState(isActive);
const stepRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isActive) {
setShouldRender(true);
} else {
const timer = setTimeout(() => setShouldRender(false), 300);
return () => clearTimeout(timer);
}
}, [isActive]);
useEffect(() => {
if (isActive && stepRef.current) {
const focusableElement = stepRef.current.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
if (focusableElement) {
(focusableElement as HTMLElement).focus();
}
}
}, [isActive]);
if (!shouldRender) {
return null;
}
const baseClasses =
' top-0 left-0 w-full h-full transition-all duration-300 ease-in-out animate-in fade-in zoom-in-95';
const visibilityClasses = isActive ? 'opacity-100' : 'opacity-0 absolute';
const transformClasses = cn(
'translate-x-0',
isActive
? {}
: {
'-translate-x-full': direction === 'forward' || index < currentIndex,
'translate-x-full': direction === 'backward' || index > currentIndex,
},
);
const className = cn(baseClasses, visibilityClasses, transformClasses);
return (
<div ref={stepRef} className={className} aria-hidden={!isActive}>
{children}
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { z } from 'zod';
const RouteMatchingEnd = z
.union([z.boolean(), z.function().args(z.string()).returns(z.boolean())])
.default(false)
.optional();
const Divider = z.object({
divider: z.literal(true),
});
const RouteSubChild = z.object({
label: z.string(),
path: z.string(),
Icon: z.custom<React.ReactNode>().optional(),
end: RouteMatchingEnd,
renderAction: z.custom<React.ReactNode>().optional(),
});
const RouteChild = z.object({
label: z.string(),
path: z.string(),
Icon: z.custom<React.ReactNode>().optional(),
end: RouteMatchingEnd,
children: z.array(RouteSubChild).default([]).optional(),
collapsible: z.boolean().default(false).optional(),
collapsed: z.boolean().default(false).optional(),
renderAction: z.custom<React.ReactNode>().optional(),
});
const RouteGroup = z.object({
label: z.string(),
collapsible: z.boolean().optional(),
collapsed: z.boolean().optional(),
children: z.array(RouteChild),
renderAction: z.custom<React.ReactNode>().optional(),
});
export const NavigationConfigSchema = z.object({
style: z.enum(['custom', 'sidebar', 'header']).default('sidebar'),
sidebarCollapsed: z
.enum(['false', 'true'])
.default('true')
.optional()
.transform((value) => value === `true`),
sidebarCollapsedStyle: z.enum(['offcanvas', 'icon', 'none']).default('icon'),
routes: z.array(z.union([RouteGroup, Divider])),
});

View File

@@ -0,0 +1,234 @@
import * as React from 'react';
import { cn } from '../lib/utils';
import { Separator } from '../shadcn/separator';
import { SidebarTrigger } from '../shadcn/sidebar';
import { If } from './if';
export type PageLayoutStyle = 'sidebar' | 'header' | 'custom';
type PageProps = React.PropsWithChildren<{
style?: PageLayoutStyle;
contentContainerClassName?: string;
className?: string;
sticky?: boolean;
}>;
const ENABLE_SIDEBAR_TRIGGER = process.env.NEXT_PUBLIC_ENABLE_SIDEBAR_TRIGGER
? process.env.NEXT_PUBLIC_ENABLE_SIDEBAR_TRIGGER === 'true'
: true;
export function Page(props: PageProps) {
switch (props.style) {
case 'header':
return <PageWithHeader {...props} />;
case 'custom':
return props.children;
default:
return <PageWithSidebar {...props} />;
}
}
function PageWithSidebar(props: PageProps) {
const { Navigation, Children, MobileNavigation } = getSlotsFromPage(props);
return (
<div className={cn('flex min-w-0 flex-1', props.className)}>
{Navigation}
<div
className={
props.contentContainerClassName ??
'mx-auto flex h-screen w-full flex-col overflow-y-auto bg-inherit'
}
>
{MobileNavigation}
<div
className={
'bg-background flex flex-1 flex-col overflow-y-auto px-4 lg:px-0'
}
>
{Children}
</div>
</div>
</div>
);
}
export function PageMobileNavigation(
props: React.PropsWithChildren<{
className?: string;
}>,
) {
return (
<div
className={cn(
'flex w-full items-center border-b px-4 py-2 lg:hidden lg:px-0',
props.className,
)}
>
{props.children}
</div>
);
}
function PageWithHeader(props: PageProps) {
const { Navigation, Children, MobileNavigation } = getSlotsFromPage(props);
return (
<div className={cn('flex h-screen flex-1 flex-col', props.className)}>
<div
className={
props.contentContainerClassName ?? 'flex flex-1 flex-col space-y-4'
}
>
<div
className={cn(
'bg-muted/40 dark:border-border dark:shadow-primary/10 flex h-14 items-center justify-between px-4 lg:justify-start lg:shadow-xs',
{
'sticky top-0 z-10 backdrop-blur-md': props.sticky ?? true,
},
)}
>
<div
className={'hidden w-full flex-1 items-center space-x-8 lg:flex'}
>
{Navigation}
</div>
{MobileNavigation}
</div>
<div className={'container flex flex-1 flex-col'}>{Children}</div>
</div>
</div>
);
}
export function PageBody(
props: React.PropsWithChildren<{
className?: string;
}>,
) {
const className = cn('flex w-full flex-1 flex-col lg:px-4', props.className);
return <div className={className}>{props.children}</div>;
}
export function PageNavigation(props: React.PropsWithChildren) {
return <div className={'flex-1 bg-inherit'}>{props.children}</div>;
}
export function PageDescription(props: React.PropsWithChildren) {
return (
<div className={'flex h-6 items-center'}>
<div className={'text-muted-foreground text-xs leading-none font-normal'}>
{props.children}
</div>
</div>
);
}
export function PageTitle(props: React.PropsWithChildren) {
return (
<h1
className={
'font-heading text-base leading-none font-bold tracking-tight dark:text-white'
}
>
{props.children}
</h1>
);
}
export function PageHeaderActions(props: React.PropsWithChildren) {
return <div className={'flex items-center space-x-2'}>{props.children}</div>;
}
export function PageHeader({
children,
title,
description,
className,
displaySidebarTrigger = ENABLE_SIDEBAR_TRIGGER,
}: React.PropsWithChildren<{
className?: string;
title?: string | React.ReactNode;
description?: string | React.ReactNode;
displaySidebarTrigger?: boolean;
}>) {
return (
<div
className={cn(
'flex items-center justify-between py-5 lg:px-4',
className,
)}
>
<div className={'flex flex-col gap-y-2'}>
<div className="flex items-center gap-x-2.5">
{displaySidebarTrigger ? (
<SidebarTrigger className="text-muted-foreground hover:text-secondary-foreground hidden h-4.5 w-4.5 cursor-pointer lg:inline-flex" />
) : null}
<If condition={description}>
<If condition={displaySidebarTrigger}>
<Separator
orientation="vertical"
className="hidden h-4 w-px lg:group-data-[minimized]:block"
/>
</If>
<PageDescription>{description}</PageDescription>
</If>
</div>
<If condition={title}>
<PageTitle>{title}</PageTitle>
</If>
</div>
{children}
</div>
);
}
function getSlotsFromPage(props: React.PropsWithChildren) {
return React.Children.toArray(props.children).reduce<{
Children: React.ReactElement | null;
Navigation: React.ReactElement | null;
MobileNavigation: React.ReactElement | null;
}>(
(acc, child) => {
if (!React.isValidElement(child)) {
return acc;
}
if (child.type === PageNavigation) {
return {
...acc,
Navigation: child,
};
}
if (child.type === PageMobileNavigation) {
return {
...acc,
MobileNavigation: child,
};
}
return {
...acc,
Children: child,
};
},
{
Children: null,
Navigation: null,
MobileNavigation: null,
},
);
}

View File

@@ -0,0 +1,54 @@
import { cn } from '../lib/utils';
import { Avatar, AvatarFallback, AvatarImage } from '../shadcn/avatar';
type SessionProps = {
displayName: string | null;
pictureUrl?: string | null;
};
type TextProps = {
text: string;
};
type ProfileAvatarProps = (SessionProps | TextProps) & {
className?: string;
fallbackClassName?: string;
};
export function ProfileAvatar(props: ProfileAvatarProps) {
const avatarClassName = cn(
props.className,
'mx-auto h-9 w-9 group-focus:ring-2',
);
if ('text' in props) {
return (
<Avatar className={avatarClassName}>
<AvatarFallback
className={cn(
props.fallbackClassName,
'animate-in fade-in uppercase',
)}
>
{props.text.slice(0, 1)}
</AvatarFallback>
</Avatar>
);
}
const initials = props.displayName?.slice(0, 1);
return (
<Avatar className={avatarClassName}>
<AvatarImage src={props.pictureUrl ?? undefined} />
<AvatarFallback
className={cn(props.fallbackClassName, 'animate-in fade-in')}
>
<span suppressHydrationWarning className={'uppercase'}>
{initials}
</span>
</AvatarFallback>
</Avatar>
);
}

View File

@@ -0,0 +1,357 @@
'use client';
import { useContext, useId, useRef, useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cva } from 'class-variance-authority';
import { ChevronDown } from 'lucide-react';
import { z } from 'zod';
import { cn, isRouteActive } from '../lib/utils';
import { Button } from '../shadcn/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '../shadcn/tooltip';
import { SidebarContext } from './context/sidebar.context';
import { If } from './if';
import type { NavigationConfigSchema } from './navigation-config.schema';
import { Trans } from './trans';
export type SidebarConfig = z.infer<typeof NavigationConfigSchema>;
export { SidebarContext };
/**
* @deprecated
* This component is deprecated and will be removed in a future version.
* Please use the Shadcn Sidebar component instead.
*/
export function Sidebar(props: {
collapsed?: boolean;
expandOnHover?: boolean;
className?: string;
children:
| React.ReactNode
| ((props: {
collapsed: boolean;
setCollapsed: (collapsed: boolean) => void;
}) => React.ReactNode);
}) {
const [collapsed, setCollapsed] = useState(props.collapsed ?? false);
const isExpandedRef = useRef<boolean>(false);
const expandOnHover =
props.expandOnHover ??
process.env.NEXT_PUBLIC_EXPAND_SIDEBAR_ON_HOVER === 'true';
const sidebarSizeClassName = getSidebarSizeClassName(
collapsed,
isExpandedRef.current,
);
const className = getClassNameBuilder(
cn(props.className ?? '', sidebarSizeClassName, {}),
)();
const containerClassName = cn(sidebarSizeClassName, 'bg-inherit', {
'max-w-[4rem]': expandOnHover && isExpandedRef.current,
});
const ctx = { collapsed, setCollapsed };
const onMouseEnter =
props.collapsed && expandOnHover
? () => {
setCollapsed(false);
isExpandedRef.current = true;
}
: undefined;
const onMouseLeave =
props.collapsed && expandOnHover
? () => {
if (!isRadixPopupOpen()) {
setCollapsed(true);
isExpandedRef.current = false;
} else {
onRadixPopupClose(() => {
setCollapsed(true);
isExpandedRef.current = false;
});
}
}
: undefined;
return (
<SidebarContext.Provider value={ctx}>
<div
className={containerClassName}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<div aria-expanded={!collapsed} className={className}>
{typeof props.children === 'function'
? props.children(ctx)
: props.children}
</div>
</div>
</SidebarContext.Provider>
);
}
export function SidebarContent({
children,
className: customClassName,
}: React.PropsWithChildren<{
className?: string;
}>) {
const { collapsed } = useContext(SidebarContext);
const className = cn(
'flex w-full flex-col space-y-1.5 py-1',
customClassName,
{
'px-4': !collapsed,
'px-2': collapsed,
},
);
return <div className={className}>{children}</div>;
}
export function SidebarGroup({
label,
collapsed = false,
collapsible = true,
children,
}: React.PropsWithChildren<{
label: string | React.ReactNode;
collapsible?: boolean;
collapsed?: boolean;
}>) {
const { collapsed: sidebarCollapsed } = useContext(SidebarContext);
const [isGroupCollapsed, setIsGroupCollapsed] = useState(collapsed);
const id = useId();
const Title = (props: React.PropsWithChildren) => {
if (sidebarCollapsed) {
return null;
}
return (
<span className={'text-muted-foreground text-xs font-semibold uppercase'}>
{props.children}
</span>
);
};
const Wrapper = () => {
const className = cn(
'px-container group flex items-center justify-between space-x-2.5',
{
'py-2.5': !sidebarCollapsed,
},
);
if (collapsible) {
return (
<button
aria-expanded={!isGroupCollapsed}
aria-controls={id}
onClick={() => setIsGroupCollapsed(!isGroupCollapsed)}
className={className}
>
<Title>{label}</Title>
<If condition={collapsible}>
<ChevronDown
className={cn(`h-3 transition duration-300`, {
'rotate-180': !isGroupCollapsed,
})}
/>
</If>
</button>
);
}
return (
<div className={className}>
<Title>{label}</Title>
</div>
);
};
return (
<div
className={cn('flex flex-col', {
'gap-y-2 py-1': !collapsed,
})}
>
<Wrapper />
<If condition={collapsible ? !isGroupCollapsed : true}>
<div id={id} className={'flex flex-col space-y-1.5'}>
{children}
</div>
</If>
</div>
);
}
export function SidebarDivider() {
return (
<div className={'dark:border-dark-800 my-2 border-t border-gray-100'} />
);
}
export function SidebarItem({
end,
path,
children,
Icon,
}: React.PropsWithChildren<{
path: string;
Icon: React.ReactNode;
end?: boolean | ((path: string) => boolean);
}>) {
const { collapsed } = useContext(SidebarContext);
const currentPath = usePathname() ?? '';
const active = isRouteActive(path, currentPath, end ?? false);
const variant = active ? 'secondary' : 'ghost';
return (
<TooltipProvider delayDuration={0}>
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<Button
asChild
className={cn(
'active:bg-secondary/60 flex w-full text-sm shadow-none',
{
'justify-start space-x-2.5': !collapsed,
'hover:bg-initial': active,
},
)}
size={'sm'}
variant={variant}
>
<Link href={path}>
{Icon}
<span
className={cn('w-auto transition-opacity duration-300', {
'w-0 opacity-0': collapsed,
})}
>
{children}
</span>
</Link>
</Button>
</TooltipTrigger>
<If condition={collapsed}>
<TooltipContent side={'right'} sideOffset={10}>
{children}
</TooltipContent>
</If>
</Tooltip>
</TooltipProvider>
);
}
function getClassNameBuilder(className: string) {
return cva([
cn(
'group/sidebar transition-width fixed box-content flex h-screen w-2/12 flex-col bg-inherit backdrop-blur-xs duration-200',
className,
),
]);
}
function getSidebarSizeClassName(collapsed: boolean, isExpanded: boolean) {
return cn(['z-50 flex w-full flex-col'], {
'dark:shadow-primary/20 lg:w-[17rem]': !collapsed,
'lg:w-[4rem]': collapsed,
shadow: isExpanded,
});
}
function getRadixPopup() {
return document.querySelector('[data-radix-popper-content-wrapper]');
}
function isRadixPopupOpen() {
return getRadixPopup() !== null;
}
function onRadixPopupClose(callback: () => void) {
const element = getRadixPopup();
if (element) {
const observer = new MutationObserver(() => {
if (!getRadixPopup()) {
callback();
observer.disconnect();
}
});
observer.observe(element.parentElement!, {
childList: true,
subtree: true,
});
}
}
export function SidebarNavigation({
config,
}: React.PropsWithChildren<{
config: SidebarConfig;
}>) {
return (
<>
{config.routes.map((item, index) => {
if ('divider' in item) {
return <SidebarDivider key={index} />;
}
if ('children' in item) {
return (
<SidebarGroup
key={item.label}
label={<Trans i18nKey={item.label} defaults={item.label} />}
collapsible={item.collapsible}
collapsed={item.collapsed}
>
{item.children.map((child) => {
if ('collapsible' in child && child.collapsible) {
throw new Error(
'Collapsible groups are not supported in the old Sidebar. Please migrate to the new Sidebar.',
);
}
if ('path' in child) {
return (
<SidebarItem
key={child.path}
end={child.end}
path={child.path}
Icon={child.Icon}
>
<Trans i18nKey={child.label} defaults={child.label} />
</SidebarItem>
);
}
})}
</SidebarGroup>
);
}
})}
</>
);
}

View File

@@ -0,0 +1,30 @@
import { cn } from '../lib/utils';
export function Spinner(
props: React.PropsWithChildren<{
className?: string;
}>,
) {
return (
<div role="status">
<svg
aria-hidden="true"
className={cn(
`fill-primary-foreground text-primary dark:fill-primary dark:text-primary/30 h-8 w-8 animate-spin`,
props.className,
)}
viewBox="0 0 100 101"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</div>
);
}

View File

@@ -0,0 +1,217 @@
'use client';
import { Fragment, useCallback } from 'react';
import { cva } from 'class-variance-authority';
import { cn } from '../lib/utils';
import { If } from './if';
import { Trans } from './trans';
type Variant = 'numbers' | 'default' | 'dots';
const classNameBuilder = getClassNameBuilder();
/**
* Renders a stepper component with multiple steps.
*
* @param {Object} props - The props object containing the following properties:
* - steps {string[]} - An array of strings representing the step labels.
* - currentStep {number} - The index of the currently active step.
* - variant {string} (optional) - The variant of the stepper component (default: 'default').
**/
export function Stepper(props: {
steps: string[];
currentStep: number;
variant?: Variant;
}) {
const variant = props.variant ?? 'default';
const Steps = useCallback(() => {
return props.steps.map((labelOrKey, index) => {
const selected = props.currentStep === index;
const complete = props.currentStep > index;
const className = classNameBuilder({
selected,
variant,
complete,
});
const isNumberVariant = variant === 'numbers';
const isDotsVariant = variant === 'dots';
const labelClassName = cn({
['px-1.5 py-2 text-xs']: !isNumberVariant,
['hidden']: isDotsVariant,
});
const { label, number } = getStepLabel(labelOrKey, index);
return (
<Fragment key={index}>
<div aria-selected={selected} className={className}>
<span className={labelClassName}>
{number}
<If condition={!isNumberVariant}>. {label}</If>
</span>
</div>
<If condition={isNumberVariant}>
<StepDivider selected={selected} complete={complete}>
{label}
</StepDivider>
</If>
</Fragment>
);
});
}, [props.steps, props.currentStep, variant]);
// If there are no steps, don't render anything.
if (props.steps.length < 2) {
return null;
}
const containerClassName = cn('w-full', {
['flex justify-between']: variant === 'numbers',
['flex space-x-0.5']: variant === 'default',
['flex gap-x-4 self-center']: variant === 'dots',
});
return (
<div className={containerClassName}>
<Steps />
</div>
);
}
function getClassNameBuilder() {
return cva(``, {
variants: {
variant: {
default: `flex h-[2.5px] w-full flex-col transition-all duration-500`,
numbers:
'flex h-9 w-9 items-center justify-center rounded-full border text-sm font-bold',
dots: 'bg-muted h-2.5 w-2.5 rounded-full transition-colors',
},
selected: {
true: '',
false: 'hidden sm:flex',
},
complete: {
true: '',
false: '',
},
},
compoundVariants: [
{
variant: 'default',
selected: false,
className: 'text-muted-foreground',
},
{
variant: 'default',
selected: true,
className: 'bg-primary font-medium',
},
{
variant: 'default',
selected: false,
complete: false,
className: 'bg-muted',
},
{
variant: 'default',
selected: false,
complete: true,
className: 'bg-primary',
},
{
variant: 'numbers',
selected: false,
complete: true,
className: 'border-primary text-primary',
},
{
variant: 'numbers',
selected: true,
className: 'border-primary bg-primary text-primary-foreground',
},
{
variant: 'numbers',
selected: false,
className: 'text-muted-foreground',
},
{
variant: 'dots',
selected: true,
complete: true,
className: 'bg-primary',
},
{
variant: 'dots',
selected: false,
complete: true,
className: 'bg-primary',
},
{
variant: 'dots',
selected: true,
complete: false,
className: 'bg-primary',
},
{
variant: 'dots',
selected: false,
complete: false,
className: 'bg-muted',
},
],
defaultVariants: {
variant: 'default',
selected: false,
},
});
}
function StepDivider({
selected,
complete,
children,
}: React.PropsWithChildren<{
selected: boolean;
complete: boolean;
}>) {
const spanClassName = cn('min-w-max text-sm font-medium', {
['text-muted-foreground hidden sm:flex']: !selected,
['text-secondary-foreground']: selected || complete,
['font-medium']: selected,
});
const className = cn(
'flex h-9 flex-1 items-center justify-center last:flex-[0_0_0]' +
' group flex w-full items-center space-x-3 px-3',
);
return (
<div className={className}>
<span className={spanClassName}>{children}</span>
<div
className={
'divider h-[1px] w-full bg-gray-200 transition-colors' +
' dark:bg-border hidden group-last:hidden sm:flex'
}
/>
</div>
);
}
function getStepLabel(labelOrKey: string, index: number) {
const number = (index + 1).toString();
return {
number,
label: <Trans i18nKey={labelOrKey} defaults={labelOrKey} />,
};
}

View File

@@ -0,0 +1,40 @@
'use client';
import { createRef, useEffect } from 'react';
import type { LoadingBarRef } from 'react-top-loading-bar';
import LoadingBar from 'react-top-loading-bar';
let running = false;
export function TopLoadingBarIndicator() {
const ref = createRef<LoadingBarRef>();
useEffect(() => {
if (!ref.current || running) {
return;
}
running = true;
const loadingBarRef = ref.current;
loadingBarRef.continuousStart(0, 300);
return () => {
loadingBarRef.complete();
running = false;
};
}, [ref]);
return (
<LoadingBar
className={'bg-primary'}
height={4}
waitingTime={0}
shadow
color={''}
ref={ref}
/>
);
}

View File

@@ -0,0 +1,5 @@
import { Trans as TransComponent } from 'react-i18next/TransWithoutContext';
export function Trans(props: React.ComponentProps<typeof TransComponent>) {
return <TransComponent {...props} />;
}

View File

@@ -0,0 +1,118 @@
'use client';
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { RocketIcon } from 'lucide-react';
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '../shadcn/alert-dialog';
import { Button } from '../shadcn/button';
import { Trans } from './trans';
/**
* Current version of the app that is running
*/
let version: string | null = null;
/**
* Default interval time in seconds to check for new version
* By default, it is set to 120 seconds
*/
const DEFAULT_REFETCH_INTERVAL = 120;
/**
* Default interval time in seconds to check for new version
*/
const VERSION_UPDATER_REFETCH_INTERVAL_SECONDS =
process.env.NEXT_PUBLIC_VERSION_UPDATER_REFETCH_INTERVAL_SECONDS;
export function VersionUpdater(props: { intervalTimeInSecond?: number }) {
const { data } = useVersionUpdater(props);
const [dismissed, setDismissed] = useState(false);
const [showDialog, setShowDialog] = useState<boolean>(false);
useEffect(() => {
setShowDialog(data?.didChange ?? false);
}, [data?.didChange]);
if (!data?.didChange || dismissed) {
return null;
}
return (
<AlertDialog open={showDialog} onOpenChange={setShowDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className={'flex items-center gap-x-2'}>
<RocketIcon className={'h-4'} />
<span>
<Trans i18nKey="common:newVersionAvailable" />
</span>
</AlertDialogTitle>
<AlertDialogDescription>
<Trans i18nKey="common:newVersionAvailableDescription" />
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<Button
variant={'outline'}
onClick={() => {
setShowDialog(false);
setDismissed(true);
}}
>
<Trans i18nKey="common:back" />
</Button>
<Button onClick={() => window.location.reload()}>
<Trans i18nKey="common:newVersionSubmitButton" />
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
function useVersionUpdater(props: { intervalTimeInSecond?: number } = {}) {
const interval = VERSION_UPDATER_REFETCH_INTERVAL_SECONDS
? Number(VERSION_UPDATER_REFETCH_INTERVAL_SECONDS)
: DEFAULT_REFETCH_INTERVAL;
const refetchInterval = (props.intervalTimeInSecond ?? interval) * 1000;
// start fetching new version after half of the interval time
const staleTime = refetchInterval / 2;
return useQuery({
queryKey: ['version-updater'],
staleTime,
gcTime: refetchInterval,
refetchIntervalInBackground: true,
refetchInterval,
initialData: null,
queryFn: async () => {
const response = await fetch('/version');
const currentVersion = await response.text();
const oldVersion = version;
version = currentVersion;
const didChange = oldVersion !== null && currentVersion !== oldVersion;
return {
currentVersion,
oldVersion,
didChange,
};
},
});
}

View File

@@ -0,0 +1,49 @@
'use client';
import * as React from 'react';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDownIcon } from '@radix-ui/react-icons';
import { cn } from '../lib/utils';
const Accordion = AccordionPrimitive.Root;
const AccordionItem: React.FC<
React.ComponentPropsWithRef<typeof AccordionPrimitive.Item>
> = ({ className, ...props }) => (
<AccordionPrimitive.Item className={cn('border-b', className)} {...props} />
);
AccordionItem.displayName = 'AccordionItem';
const AccordionTrigger: React.FC<
React.ComponentPropsWithRef<typeof AccordionPrimitive.Trigger>
> = ({ className, children, ...props }) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
className={cn(
'flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
className,
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent: React.FC<
React.ComponentPropsWithRef<typeof AccordionPrimitive.Content>
> = ({ className, children, ...props }) => (
<AccordionPrimitive.Content
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn('pt-0 pb-4', className)}>{children}</div>
</AccordionPrimitive.Content>
);
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -0,0 +1,127 @@
'use client';
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '../lib/utils';
import { buttonVariants } from './button';
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay: React.FC<
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
> = ({ className, ...props }) => (
<AlertDialogPrimitive.Overlay
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80',
className,
)}
{...props}
/>
);
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent: React.FC<
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
> = ({ className, ...props }) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
className={cn(
'bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',
className,
)}
{...props}
/>
</AlertDialogPortal>
);
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col gap-y-3 text-center sm:text-left', className)}
{...props}
/>
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className,
)}
{...props}
/>
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle: React.FC<
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
> = ({ className, ...props }) => (
<AlertDialogPrimitive.Title
className={cn('text-lg font-semibold', className)}
{...props}
/>
);
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription: React.FC<
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
> = ({ className, ...props }) => (
<AlertDialogPrimitive.Description
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction: React.FC<
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
> = ({ className, ...props }) => (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
);
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel: React.FC<
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
> = ({ className, ...props }) => (
<AlertDialogPrimitive.Cancel
className={cn(
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
className,
)}
{...props}
/>
);
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -0,0 +1,61 @@
import * as React from 'react';
import { type VariantProps, cva } from 'class-variance-authority';
import { cn } from '../lib/utils';
const alertVariants = cva(
'[&>svg]:text-foreground relative flex w-full flex-col gap-y-2 rounded-lg border bg-linear-to-r px-4 py-3.5 text-sm [&>svg]:absolute [&>svg]:top-4 [&>svg]:left-4 [&>svg+div]:translate-y-[-3px] [&>svg~*]:pl-7',
{
variants: {
variant: {
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
success:
'border-green-600/50 text-green-600 dark:border-green-600 [&>svg]:text-green-600',
warning:
'border-orange-600/50 text-orange-600 dark:border-orange-600 [&>svg]:text-orange-600',
info: 'border-blue-600/50 text-blue-600 dark:border-blue-600 [&>svg]:text-blue-600',
},
},
defaultVariants: {
variant: 'default',
},
},
);
const Alert: React.FC<
React.ComponentPropsWithRef<'div'> & VariantProps<typeof alertVariants>
> = ({ className, variant, ...props }) => (
<div
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
Alert.displayName = 'Alert';
const AlertTitle: React.FC<React.ComponentPropsWithRef<'h5'>> = ({
className,
...props
}) => (
<h5
className={cn('leading-none font-bold tracking-tight', className)}
{...props}
/>
);
AlertTitle.displayName = 'AlertTitle';
const AlertDescription: React.FC<React.ComponentPropsWithRef<'div'>> = ({
className,
...props
}) => (
<div
className={cn('text-sm font-normal [&_p]:leading-relaxed', className)}
{...props}
/>
);
AlertDescription.displayName = 'AlertDescription';
export { Alert, AlertTitle, AlertDescription };

View File

@@ -0,0 +1,45 @@
'use client';
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '../lib/utils';
const Avatar: React.FC<
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
> = ({ className, ...props }) => (
<AvatarPrimitive.Root
className={cn(
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
className,
)}
{...props}
/>
);
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage: React.FC<
React.ComponentPropsWithRef<typeof AvatarPrimitive.Image>
> = ({ className, ...props }) => (
<AvatarPrimitive.Image
className={cn('aspect-square h-full w-full object-cover', className)}
{...props}
/>
);
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback: React.FC<
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
> = ({ className, ...props }) => (
<AvatarPrimitive.Fallback
className={cn(
'bg-muted flex h-full w-full items-center justify-center rounded-full',
className,
)}
{...props}
/>
);
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -0,0 +1,37 @@
import * as React from 'react';
import { type VariantProps, cva } from 'class-variance-authority';
import { cn } from '../lib/utils';
const badgeVariants = cva(
'focus:ring-ring inline-flex items-center rounded-md border px-1.5 py-0.5 text-xs font-semibold transition-colors focus:ring-2 focus:ring-offset-2 focus:outline-hidden',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground border-transparent',
secondary: 'bg-secondary text-secondary-foreground border-transparent',
destructive: 'text-destructive border-destructive',
outline: 'text-foreground',
success: 'border-green-500 text-green-500',
warning: 'border-orange-500 text-orange-500',
info: 'border-blue-500 text-blue-500',
},
},
defaultVariants: {
variant: 'default',
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,113 @@
import * as React from 'react';
import { ChevronRightIcon, DotsHorizontalIcon } from '@radix-ui/react-icons';
import { Slot } from '@radix-ui/react-slot';
import { cn } from '../lib/utils';
const Breadcrumb: React.FC<
React.ComponentPropsWithoutRef<'nav'> & {
separator?: React.ReactNode;
}
> = ({ ...props }) => <nav aria-label="breadcrumb" {...props} />;
Breadcrumb.displayName = 'Breadcrumb';
const BreadcrumbList: React.FC<React.ComponentPropsWithRef<'ol'>> = ({
className,
...props
}) => (
<ol
className={cn(
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words',
className,
)}
{...props}
/>
);
BreadcrumbList.displayName = 'BreadcrumbList';
const BreadcrumbItem: React.FC<React.ComponentPropsWithRef<'li'>> = ({
className,
...props
}) => (
<li
className={cn('inline-flex items-center gap-1.5', className)}
{...props}
/>
);
BreadcrumbItem.displayName = 'BreadcrumbItem';
const BreadcrumbLink: React.FC<
React.ComponentPropsWithoutRef<'a'> & {
asChild?: boolean;
}
> = ({ asChild, className, ...props }) => {
const Comp = asChild ? Slot : 'a';
return (
<Comp
className={cn(
'text-foreground transition-colors hover:underline',
className,
)}
{...props}
/>
);
};
BreadcrumbLink.displayName = 'BreadcrumbLink';
const BreadcrumbPage: React.FC<React.ComponentPropsWithoutRef<'span'>> = ({
className,
...props
}) => (
<span
role="link"
aria-disabled="true"
aria-current="page"
className={cn('text-foreground font-normal', className)}
{...props}
/>
);
BreadcrumbPage.displayName = 'BreadcrumbPage';
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<'li'>) => (
<li
role="presentation"
aria-hidden="true"
className={cn('[&>svg]:size-3.5', className)}
{...props}
>
{children ?? <ChevronRightIcon />}
</li>
);
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator';
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<'span'>) => (
<span
role="presentation"
aria-hidden="true"
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<DotsHorizontalIcon className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis';
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@@ -0,0 +1,64 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
import { cn } from '../lib/utils';
const buttonVariants = cva(
'focus-visible:ring-ring inline-flex items-center justify-center rounded-md text-sm font-medium whitespace-nowrap transition-colors focus-visible:ring-1 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-xs',
outline:
'border-input bg-background hover:bg-accent hover:text-accent-foreground border shadow-xs',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'decoration-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ComponentPropsWithRef<'button'>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button: React.FC<ButtonProps> = ({
className,
variant,
size,
asChild = false,
...props
}) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
};
Button.displayName = 'Button';
export { Button, buttonVariants };

View File

@@ -0,0 +1,70 @@
'use client';
import * as React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { DayPicker } from 'react-day-picker';
import { cn } from '../lib/utils';
import { buttonVariants } from './button';
export type { DateRange } from 'react-day-picker';
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn('p-3', className)}
classNames={{
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
month: 'space-y-4',
caption: 'flex justify-center pt-1 relative items-center',
caption_label: 'text-sm font-medium',
nav: 'gap-x-2 flex items-center',
nav_button: cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
),
nav_button_previous: 'absolute left-1',
nav_button_next: 'absolute right-1',
table: 'w-full border-collapse space-y-1',
head_row: 'flex',
head_cell:
'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
row: 'flex w-full mt-2',
cell: 'text-center text-sm p-0 relative [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
day: cn(
buttonVariants({ variant: 'ghost' }),
'h-9 w-9 p-0 font-normal aria-selected:opacity-100',
),
day_selected:
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
day_today: 'bg-accent text-accent-foreground',
day_outside: 'text-muted-foreground opacity-50',
day_disabled: 'text-muted-foreground opacity-50',
day_range_middle:
'aria-selected:bg-accent aria-selected:text-accent-foreground',
day_hidden: 'invisible',
...classNames,
}}
components={{
IconLeft: ({ ...props }) => (
<ChevronLeft className="h-4 w-4" {...props} />
),
IconRight: ({ ...props }) => (
<ChevronRight className="h-4 w-4" {...props} />
),
}}
{...props}
/>
);
}
Calendar.displayName = 'Calendar';
export { Calendar };

View File

@@ -0,0 +1,64 @@
import * as React from 'react';
import { cn } from '../lib/utils';
const Card: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
...props
}) => (
<div
className={cn('bg-card text-card-foreground rounded-xl border', className)}
{...props}
/>
);
Card.displayName = 'Card';
const CardHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
...props
}) => (
<div className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
);
CardHeader.displayName = 'CardHeader';
const CardTitle: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = ({
className,
...props
}) => (
<h3
className={cn('leading-none font-semibold tracking-tight', className)}
{...props}
/>
);
CardTitle.displayName = 'CardTitle';
const CardDescription: React.FC<React.HTMLAttributes<HTMLParagraphElement>> = ({
className,
...props
}) => (
<p className={cn('text-muted-foreground text-sm', className)} {...props} />
);
CardDescription.displayName = 'CardDescription';
const CardContent: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
...props
}) => <div className={cn('p-6 pt-0', className)} {...props} />;
CardContent.displayName = 'CardContent';
const CardFooter: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
...props
}) => (
<div className={cn('flex items-center p-6 pt-0', className)} {...props} />
);
CardFooter.displayName = 'CardFooter';
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View File

@@ -0,0 +1,361 @@
'use client';
import * as React from 'react';
import * as RechartsPrimitive from 'recharts';
import { cn } from '../lib/utils';
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: '', dark: '.dark' } as const;
export type ChartConfig = Record<
string,
{
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
>;
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error('useChart must be used within a <ChartContainer />');
}
return context;
}
const ChartContainer: React.FC<
React.ComponentProps<'div'> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>['children'];
}
> = ({ id, className, children, config, ...props }) => {
const uniqueId = React.useId();
const chartId = `chart-${id ?? uniqueId.replace(/:/g, '')}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
};
ChartContainer.displayName = 'Chart';
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([_, config]) => config.theme ?? config.color,
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join('\n')}
}
`,
)
.join('\n'),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent: React.FC<
React.ComponentPropsWithRef<typeof RechartsPrimitive.Tooltip> &
React.ComponentPropsWithRef<'div'> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: 'line' | 'dot' | 'dashed';
nameKey?: string;
labelKey?: string;
}
> = ({
ref,
active,
payload,
className,
indicator = 'dot',
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel ?? !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === 'string'
? (config[label]?.label ?? label)
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn('font-medium', labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== 'dot';
return (
<div
ref={ref}
className={cn(
'border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey ?? item.name ?? item.dataKey ?? 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color ?? item.payload.fill ?? item.color;
return (
<div
key={item.dataKey}
className={cn(
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
indicator === 'dot' && 'items-center',
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
'shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)',
{
'h-2.5 w-2.5': indicator === 'dot',
'w-1': indicator === 'line',
'w-0 border-[1.5px] border-dashed bg-transparent':
indicator === 'dashed',
'my-0.5': nestLabel && indicator === 'dashed',
},
)}
style={
{
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
'flex flex-1 justify-between leading-none',
nestLabel ? 'items-end' : 'items-center',
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label ?? item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
};
ChartTooltipContent.displayName = 'ChartTooltip';
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent: React.FC<
React.ComponentPropsWithRef<'div'> &
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
hideIcon?: boolean;
nameKey?: string;
}
> = ({
className,
hideIcon = false,
payload,
verticalAlign = 'bottom',
nameKey,
ref,
}) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn(
'flex items-center justify-center gap-4',
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
className,
)}
>
{payload.map((item) => {
const key = `${nameKey ?? item.dataKey ?? 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
'[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3',
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
};
ChartLegendContent.displayName = 'ChartLegend';
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== 'object' || !payload) {
return undefined;
}
const payloadPayload =
'payload' in payload &&
typeof payload.payload === 'object' &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === 'string'
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View File

@@ -0,0 +1,29 @@
'use client';
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { CheckIcon } from '@radix-ui/react-icons';
import { cn } from '../lib/utils';
const Checkbox: React.FC<
React.ComponentPropsWithRef<typeof CheckboxPrimitive.Root>
> = ({ className, ...props }) => (
<CheckboxPrimitive.Root
className={cn(
'peer border-primary focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground h-4 w-4 shrink-0 rounded-xs border shadow-xs focus-visible:ring-1 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn('flex items-center justify-center text-current')}
>
<CheckIcon className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@@ -0,0 +1,11 @@
'use client';
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -0,0 +1,139 @@
'use client';
import * as React from 'react';
import { type DialogProps } from '@radix-ui/react-dialog';
import { MagnifyingGlassIcon } from '@radix-ui/react-icons';
import { Command as CommandPrimitive } from 'cmdk';
import { cn } from '../lib/utils';
import { Dialog, DialogContent } from './dialog';
const Command: React.FC<
React.ComponentPropsWithRef<typeof CommandPrimitive>
> = ({ className, ...props }) => (
<CommandPrimitive
className={cn(
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
className,
)}
{...props}
/>
);
Command.displayName = CommandPrimitive.displayName;
type CommandDialogProps = DialogProps;
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput: React.FC<
React.ComponentPropsWithRef<typeof CommandPrimitive.Input>
> = ({ className, ...props }) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<MagnifyingGlassIcon className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
className={cn(
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
</div>
);
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList: React.FC<
React.ComponentPropsWithRef<typeof CommandPrimitive.List>
> = ({ className, ...props }) => (
<CommandPrimitive.List
className={cn('max-h-[300px] overflow-x-hidden overflow-y-auto', className)}
{...props}
/>
);
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty: React.FC<
React.ComponentPropsWithRef<typeof CommandPrimitive.Empty>
> = (props) => (
<CommandPrimitive.Empty className="py-6 text-center text-sm" {...props} />
);
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup: React.FC<
React.ComponentPropsWithRef<typeof CommandPrimitive.Group>
> = ({ className, ...props }) => (
<CommandPrimitive.Group
className={cn(
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
className,
)}
{...props}
/>
);
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator: React.FC<
React.ComponentPropsWithRef<typeof CommandPrimitive.Separator>
> = ({ className, ...props }) => (
<CommandPrimitive.Separator
className={cn('bg-border -mx-1 h-px', className)}
{...props}
/>
);
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem: React.FC<
React.ComponentPropsWithRef<typeof CommandPrimitive.Item>
> = ({ className, ...props }) => (
<CommandPrimitive.Item
className={cn(
"aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default items-center rounded-xs px-2 py-1.5 text-sm outline-hidden select-none data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50",
className,
)}
{...props}
/>
);
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className,
)}
{...props}
/>
);
};
CommandShortcut.displayName = 'CommandShortcut';
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -0,0 +1,86 @@
'use client';
import type { ColumnDef } from '@tanstack/react-table';
import {
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import { Trans } from '../makerkit/trans';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from './table';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function DataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
// TODO: remove when https://github.com/TanStack/table/issues/5567 gets fixed
'use no memo';
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-row-id={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
<Trans i18nKey={'common:noData'} />
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,112 @@
'use client';
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { Cross2Icon } from '@radix-ui/react-icons';
import { cn } from '../lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay: React.FC<
React.ComponentPropsWithRef<typeof DialogPrimitive.Overlay>
> = ({ className, ...props }) => (
<DialogPrimitive.Overlay
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80',
className,
)}
{...props}
/>
);
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent: React.FC<
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
> = ({ className, children, ...props }) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
'bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col space-y-1.5 text-left', className)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className,
)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle: React.FC<
React.ComponentPropsWithRef<typeof DialogPrimitive.Title>
> = ({ className, ...props }) => (
<DialogPrimitive.Title
className={cn(
'text-lg leading-none font-semibold tracking-tight',
className,
)}
{...props}
/>
);
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription: React.FC<
React.ComponentPropsWithRef<typeof DialogPrimitive.Description>
> = ({ className, ...props }) => (
<DialogPrimitive.Description
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -0,0 +1,190 @@
'use client';
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import {
CheckIcon,
ChevronRightIcon,
DotFilledIcon,
} from '@radix-ui/react-icons';
import { cn } from '../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.FC<
React.ComponentPropsWithRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
> = ({ className, inset, children, ...props }) => (
<DropdownMenuPrimitive.SubTrigger
className={cn(
'focus:bg-accent data-[state=open]:bg-accent flex cursor-default items-center rounded-xs px-2 py-1.5 text-sm outline-hidden select-none',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
);
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent: React.FC<
React.ComponentPropsWithRef<typeof DropdownMenuPrimitive.SubContent>
> = ({ className, ...props }) => (
<DropdownMenuPrimitive.SubContent
className={cn(
'bg-popover text-popover-foreground 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 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg',
className,
)}
{...props}
/>
);
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent: React.FC<
React.ComponentPropsWithRef<typeof DropdownMenuPrimitive.Content>
> = ({ className, sideOffset = 4, ...props }) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 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',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem: React.FC<
React.ComponentPropsWithRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
> = ({ className, inset, ...props }) => (
<DropdownMenuPrimitive.Item
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-xs px-2 py-1.5 text-sm outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50',
inset && 'pl-8',
className,
)}
{...props}
/>
);
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem: React.FC<
React.ComponentPropsWithRef<typeof DropdownMenuPrimitive.CheckboxItem>
> = ({ className, children, checked, ...props }) => (
<DropdownMenuPrimitive.CheckboxItem
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50',
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem: React.FC<
React.ComponentPropsWithRef<typeof DropdownMenuPrimitive.RadioItem>
> = ({ className, children, ...props }) => (
<DropdownMenuPrimitive.RadioItem
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<DotFilledIcon className="h-4 w-4 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel: React.FC<
React.ComponentPropsWithRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
> = ({ className, inset, ...props }) => (
<DropdownMenuPrimitive.Label
className={cn(
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className,
)}
{...props}
/>
);
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator: React.FC<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
> = ({ className, ...props }) => (
<DropdownMenuPrimitive.Separator
className={cn('bg-muted -mx-1 my-1 h-px', 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,174 @@
'use client';
import * as React from 'react';
import type * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import type { ControllerProps, FieldPath, FieldValues } from 'react-hook-form';
import { Controller, FormProvider, useFormContext } from 'react-hook-form';
import { cn } from '../lib/utils';
import { Trans } from '../makerkit/trans';
import { Label } from './label';
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>');
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
);
const FormItem: React.FC<React.ComponentPropsWithRef<'div'>> = ({
className,
...props
}) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div className={cn('flex flex-col gap-y-2', className)} {...props} />
</FormItemContext.Provider>
);
};
FormItem.displayName = 'FormItem';
const FormLabel: React.FC<
React.ComponentPropsWithRef<typeof LabelPrimitive.Root>
> = ({ className, ...props }) => {
const { error, formItemId } = useFormField();
return (
<Label
className={cn(error && 'text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
);
};
FormLabel.displayName = 'FormLabel';
const FormControl: React.FC<React.ComponentPropsWithoutRef<typeof Slot>> = ({
...props
}) => {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
};
FormControl.displayName = 'FormControl';
const FormDescription: React.FC<React.ComponentPropsWithRef<'p'>> = ({
className,
...props
}) => {
const { formDescriptionId } = useFormField();
return (
<p
id={formDescriptionId}
className={cn('text-muted-foreground text-[0.8rem]', className)}
{...props}
/>
);
};
FormDescription.displayName = 'FormDescription';
const FormMessage: React.FC<React.ComponentPropsWithRef<'p'>> = ({
className,
children,
...props
}) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (
<p
id={formMessageId}
className={cn('text-destructive text-[0.8rem] font-medium', className)}
{...props}
>
{typeof body === 'string' ? (
<Trans i18nKey={body} defaults={body} />
) : (
body
)}
</p>
);
};
FormMessage.displayName = 'FormMessage';
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};

View File

@@ -0,0 +1,81 @@
import { cn } from '../lib/utils';
type Level = 1 | 2 | 3 | 4 | 5 | 6;
export function Heading({
level,
children,
className,
}: React.PropsWithChildren<{ level?: Level; className?: string }>) {
switch (level) {
case 1:
return (
<h1
className={cn(
`font-heading scroll-m-20 text-3xl font-bold tracking-tight lg:text-4xl dark:text-white`,
className,
)}
>
{children}
</h1>
);
case 2:
return (
<h2
className={cn(
`font-heading scroll-m-20 pb-2 text-2xl font-semibold tracking-tight transition-colors first:mt-0 lg:text-3xl`,
className,
)}
>
{children}
</h2>
);
case 3:
return (
<h3
className={cn(
'font-heading scroll-m-20 text-xl font-semibold tracking-tight lg:text-2xl',
className,
)}
>
{children}
</h3>
);
case 4:
return (
<h4
className={cn(
'font-heading scroll-m-20 text-lg font-semibold tracking-tight lg:text-xl',
className,
)}
>
{children}
</h4>
);
case 5:
return (
<h5
className={cn(
'font-heading scroll-m-20 text-base font-medium lg:text-lg',
className,
)}
>
{children}
</h5>
);
case 6:
return (
<h6
className={cn(
'font-heading scroll-m-20 text-base font-medium',
className,
)}
>
{children}
</h6>
);
default:
return <Heading level={1}>{children}</Heading>;
}
}

View File

@@ -0,0 +1 @@
export { cn } from '../lib/utils';

View File

@@ -0,0 +1,74 @@
'use client';
import * as React from 'react';
import { DashIcon } from '@radix-ui/react-icons';
import { OTPInput, OTPInputContext } from 'input-otp';
import { cn } from '../lib/utils';
const InputOTP: React.FC<React.ComponentPropsWithoutRef<typeof OTPInput>> = ({
className,
containerClassName,
...props
}) => (
<OTPInput
containerClassName={cn(
'flex items-center gap-2 has-disabled:opacity-50',
containerClassName,
)}
className={cn('disabled:cursor-not-allowed', className)}
{...props}
/>
);
InputOTP.displayName = 'InputOTP';
const InputOTPGroup: React.FC<React.ComponentPropsWithoutRef<'div'>> = ({
className,
...props
}) => <div className={cn('flex items-center', className)} {...props} />;
InputOTPGroup.displayName = 'InputOTPGroup';
const InputOTPSlot: React.FC<
React.ComponentPropsWithRef<'div'> & { index: number }
> = ({ index, className, ...props }) => {
const inputOTPContext = React.useContext(OTPInputContext);
const slot = inputOTPContext.slots[index];
if (!slot) {
return null;
}
const { char, isActive, hasFakeCaret } = slot;
return (
<div
className={cn(
'border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all first:rounded-l-md first:border-l last:rounded-r-md',
isActive && 'ring-ring z-10 ring-1',
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
);
};
InputOTPSlot.displayName = 'InputOTPSlot';
const InputOTPSeparator: React.FC<React.ComponentPropsWithoutRef<'div'>> = ({
...props
}) => (
<div role="separator" {...props}>
<DashIcon />
</div>
);
InputOTPSeparator.displayName = 'InputOTPSeparator';
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@@ -0,0 +1,26 @@
import * as React from 'react';
import { cn } from '../lib/utils';
export type InputProps = React.ComponentPropsWithRef<'input'>;
const Input: React.FC<InputProps> = ({
className,
type = 'text',
...props
}) => {
return (
<input
type={type}
className={cn(
'border-input file:text-foreground hover:border-ring/50 placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-base shadow-2xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
{...props}
/>
);
};
Input.displayName = 'Input';
export { Input };

View File

@@ -0,0 +1,22 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { type VariantProps, cva } from 'class-variance-authority';
import { cn } from '../lib/utils';
const labelVariants = cva(
'text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
);
const Label: React.FC<
React.ComponentPropsWithRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
> = ({ className, ...props }) => (
<LabelPrimitive.Root className={cn(labelVariants(), className)} {...props} />
);
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@@ -0,0 +1,119 @@
'use client';
import * as React from 'react';
import { ChevronDownIcon } from '@radix-ui/react-icons';
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
import { cva } from 'class-variance-authority';
import { cn } from '../lib/utils';
const NavigationMenu: React.FC<
React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.Root>
> = ({ className, children, ...props }) => (
<NavigationMenuPrimitive.Root
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.FC<
React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.List>
> = ({ className, ...props }) => (
<NavigationMenuPrimitive.List
className={cn(
'group flex flex-1 list-none items-center justify-center space-x-1',
className,
)}
{...props}
/>
);
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
const NavigationMenuItem = NavigationMenuPrimitive.Item;
const navigationMenuTriggerStyle = cva(
'group bg-background hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-active:bg-accent/50 data-[state=open]:bg-accent/50 inline-flex h-9 w-max items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors focus:outline-hidden disabled:pointer-events-none disabled:opacity-50',
);
const NavigationMenuTrigger: React.FC<
React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.Trigger>
> = ({ className, children, ...props }) => (
<NavigationMenuPrimitive.Trigger
className={cn(navigationMenuTriggerStyle(), 'group', className)}
{...props}
>
{children}{' '}
<ChevronDownIcon
className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
);
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
const NavigationMenuContent: React.FC<
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
> = ({ className, ...props }) => (
<NavigationMenuPrimitive.Content
className={cn(
'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 top-0 left-0 w-full md:absolute md:w-auto',
className,
)}
{...props}
/>
);
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
const NavigationMenuLink = NavigationMenuPrimitive.Link;
const NavigationMenuViewport: React.FC<
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
> = ({ className, ...props }) => (
<div className={cn('absolute top-full left-0 flex justify-center')}>
<NavigationMenuPrimitive.Viewport
className={cn(
'origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow-xs md:w-[var(--radix-navigation-menu-viewport-width)]',
className,
)}
{...props}
/>
</div>
);
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName;
const NavigationMenuIndicator: React.FC<
React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.Indicator>
> = ({ className, ...props }) => (
<NavigationMenuPrimitive.Indicator
className={cn(
'data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-1 flex h-1.5 items-end justify-center overflow-hidden',
className,
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
);
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName;
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
};

View File

@@ -0,0 +1,33 @@
'use client';
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '../lib/utils';
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent: React.FC<
React.ComponentProps<typeof PopoverPrimitive.Content>
> = ({ className, align = 'center', sideOffset = 4, ...props }) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align={align}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground 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 z-50 w-72 rounded-md border p-4 shadow-md outline-hidden',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@@ -0,0 +1,28 @@
'use client';
import * as React from 'react';
import * as ProgressPrimitive from '@radix-ui/react-progress';
import { cn } from '../lib/utils';
const Progress: React.FC<
React.ComponentProps<typeof ProgressPrimitive.Root>
> = ({ className, value, ...props }) => (
<ProgressPrimitive.Root
className={cn(
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

View File

@@ -0,0 +1,66 @@
'use client';
import * as React from 'react';
import { CheckIcon } from '@radix-ui/react-icons';
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
import { cn } from '../lib/utils';
const RadioGroup: React.FC<
React.ComponentPropsWithRef<typeof RadioGroupPrimitive.Root>
> = ({ className, ...props }) => {
return (
<RadioGroupPrimitive.Root
className={cn('grid gap-2', className)}
{...props}
/>
);
};
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem: React.FC<
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
> = ({ className, ...props }) => {
return (
<RadioGroupPrimitive.Item
className={cn(
'border-primary text-primary focus-visible:ring-ring aspect-square h-4 w-4 rounded-full border shadow-xs focus:outline-hidden focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<CheckIcon className="fill-primary h-3.5 w-3.5" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
};
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
const RadioGroupItemLabel = (
props: React.PropsWithChildren<{
className?: string;
selected?: boolean;
}>,
) => {
return (
<label
className={cn(
props.className,
'flex cursor-pointer rounded-md' +
' border-input items-center space-x-4 border' +
' transition-duration-500 focus-within:border-primary p-4 text-sm transition-all',
{
[`bg-muted`]: props.selected,
[`hover:bg-muted`]: !props.selected,
},
)}
>
{props.children}
</label>
);
};
RadioGroupItemLabel.displayName = 'RadioGroupItemLabel';
export { RadioGroup, RadioGroupItem, RadioGroupItemLabel };

View File

@@ -0,0 +1,45 @@
'use client';
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '../lib/utils';
const ScrollArea: React.FC<
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
> = ({ className, children, ...props }) => (
<ScrollAreaPrimitive.Root
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar: React.FC<
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
> = ({ className, orientation = 'vertical', ...props }) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
orientation={orientation}
className={cn(
'flex touch-none transition-colors select-none',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' &&
'h-2.5 border-t border-t-transparent p-[1px]',
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="bg-border relative flex-1 rounded-full" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };

View File

@@ -0,0 +1,151 @@
'use client';
import * as React from 'react';
import {
CaretSortIcon,
CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
} from '@radix-ui/react-icons';
import * as SelectPrimitive from '@radix-ui/react-select';
import { cn } from '../lib/utils';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger: React.FC<
React.ComponentPropsWithRef<typeof SelectPrimitive.Trigger>
> = ({ className, children, ...props }) => (
<SelectPrimitive.Trigger
className={cn(
'border-input ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-9 w-full items-center justify-between rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-2xs focus:ring-1 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<CaretSortIcon className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton: React.FC<
React.ComponentPropsWithRef<typeof SelectPrimitive.ScrollUpButton>
> = ({ className, ...props }) => (
<SelectPrimitive.ScrollUpButton
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpButton>
);
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton: React.FC<
React.ComponentPropsWithRef<typeof SelectPrimitive.ScrollDownButton>
> = ({ className, ...props }) => (
<SelectPrimitive.ScrollDownButton
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownButton>
);
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent: React.FC<
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
> = ({ className, children, position = 'popper', ...props }) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
className={cn(
'bg-popover text-popover-foreground 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 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md',
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}
>
<SelectScrollUpButton />
<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>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel: React.FC<
React.ComponentPropsWithRef<typeof SelectPrimitive.Label>
> = ({ className, ...props }) => (
<SelectPrimitive.Label
className={cn('px-2 py-1.5 text-sm font-semibold', className)}
{...props}
/>
);
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem: React.FC<
React.ComponentPropsWithRef<typeof SelectPrimitive.Item>
> = ({ className, children, ...props }) => (
<SelectPrimitive.Item
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center rounded-xs py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50',
className,
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator: React.FC<
React.ComponentPropsWithRef<typeof SelectPrimitive.Separator>
> = ({ className, ...props }) => (
<SelectPrimitive.Separator
className={cn('bg-muted -mx-1 my-1 h-px', className)}
{...props}
/>
);
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View File

@@ -0,0 +1,31 @@
'use client';
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '../lib/utils';
const Separator: React.FC<
React.ComponentPropsWithRef<typeof SeparatorPrimitive.Root>
> = ({
className,
orientation = 'horizontal',
decorative = true,
...props
}) => (
<SeparatorPrimitive.Root
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className,
)}
{...props}
/>
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View File

@@ -0,0 +1,133 @@
'use client';
import * as React from 'react';
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { Cross2Icon } from '@radix-ui/react-icons';
import { type VariantProps, cva } from 'class-variance-authority';
import { cn } from '../lib/utils';
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay: React.FC<
React.ComponentPropsWithRef<typeof SheetPrimitive.Overlay>
> = ({ className, ...props }) => (
<SheetPrimitive.Overlay
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80',
className,
)}
{...props}
/>
);
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 gap-4 p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
{
variants: {
side: {
top: 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 border-b',
bottom:
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 border-t',
left: 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
right:
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
},
},
defaultVariants: {
side: 'right',
},
},
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent: React.FC<SheetContentProps> = ({
side = 'right',
className,
children,
...props
}) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
);
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col gap-y-3 text-center sm:text-left', className)}
{...props}
/>
);
SheetHeader.displayName = 'SheetHeader';
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className,
)}
{...props}
/>
);
SheetFooter.displayName = 'SheetFooter';
const SheetTitle: React.FC<
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
> = ({ className, ...props }) => (
<SheetPrimitive.Title
className={cn('text-foreground text-lg font-semibold', className)}
{...props}
/>
);
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription: React.FC<
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
> = ({ className, ...props }) => (
<SheetPrimitive.Description
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
import { cn } from '../lib/utils';
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('bg-primary/10 animate-pulse rounded-md', className)}
{...props}
/>
);
}
export { Skeleton };

View File

@@ -0,0 +1,31 @@
'use client';
import { useTheme } from 'next-themes';
import { Toaster as Sonner, toast } from 'sonner';
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme();
return (
<Sonner
theme={theme as ToasterProps['theme']}
className="toaster group"
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton:
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton:
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
},
}}
{...props}
/>
);
};
export { Toaster, toast };

View File

@@ -0,0 +1,28 @@
'use client';
import * as React from 'react';
import * as SwitchPrimitives from '@radix-ui/react-switch';
import { cn } from '../lib/utils';
const Switch: React.FC<
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
> = ({ className, ...props }) => (
<SwitchPrimitives.Root
className={cn(
'peer focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-xs transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<SwitchPrimitives.Thumb
className={cn(
'bg-background pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitives.Root>
);
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View File

@@ -0,0 +1,108 @@
import * as React from 'react';
import { cn } from '../lib/utils';
const Table: React.FC<React.HTMLAttributes<HTMLTableElement>> = ({
className,
...props
}) => (
<div className="relative w-full overflow-auto">
<table
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
);
Table.displayName = 'Table';
const TableHeader: React.FC<React.HTMLAttributes<HTMLTableSectionElement>> = ({
className,
...props
}) => <thead className={cn('[&_tr]:border-b', className)} {...props} />;
TableHeader.displayName = 'TableHeader';
const TableBody: React.FC<React.HTMLAttributes<HTMLTableSectionElement>> = ({
className,
...props
}) => (
<tbody className={cn('[&_tr:last-child]:border-0', className)} {...props} />
);
TableBody.displayName = 'TableBody';
const TableFooter: React.FC<React.HTMLAttributes<HTMLTableSectionElement>> = ({
className,
...props
}) => (
<tfoot
className={cn(
'bg-muted/50 border-t font-medium last:[&>tr]:border-b-0',
className,
)}
{...props}
/>
);
TableFooter.displayName = 'TableFooter';
const TableRow: React.FC<React.HTMLAttributes<HTMLTableRowElement>> = ({
className,
...props
}) => (
<tr
className={cn(
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
className,
)}
{...props}
/>
);
TableRow.displayName = 'TableRow';
const TableHead: React.FC<React.ThHTMLAttributes<HTMLTableCellElement>> = ({
className,
...props
}) => (
<th
className={cn(
'text-muted-foreground h-10 px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
);
TableHead.displayName = 'TableHead';
const TableCell: React.FC<React.TdHTMLAttributes<HTMLTableCellElement>> = ({
className,
...props
}) => (
<td
className={cn(
'p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
);
TableCell.displayName = 'TableCell';
const TableCaption: React.FC<React.HTMLAttributes<HTMLTableCaptionElement>> = ({
className,
...props
}) => (
<caption
className={cn('text-muted-foreground mt-4 text-sm', className)}
{...props}
/>
);
TableCaption.displayName = 'TableCaption';
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

@@ -0,0 +1,50 @@
'use client';
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '../lib/utils';
const Tabs = TabsPrimitive.Root;
const TabsList: React.FC<
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
> = ({ className, ...props }) => (
<TabsPrimitive.List
className={cn(
'bg-muted text-muted-foreground inline-flex h-10 items-center justify-center rounded-md p-1',
className,
)}
{...props}
/>
);
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger: React.FC<
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
> = ({ className, ...props }) => (
<TabsPrimitive.Trigger
className={cn(
'ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex items-center justify-center rounded-xs px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-xs',
className,
)}
{...props}
/>
);
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent: React.FC<
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
> = ({ className, ...props }) => (
<TabsPrimitive.Content
className={cn(
'ring-offset-background focus-visible:ring-ring mt-2 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden',
className,
)}
{...props}
/>
);
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from '../lib/utils';
export type TextareaProps = React.ComponentPropsWithRef<'textarea'>;
const Textarea: React.FC<TextareaProps> = ({ className, ...props }) => {
return (
<textarea
className={cn(
'border-input placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[60px] w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs focus-visible:ring-1 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
);
};
Textarea.displayName = 'Textarea';
export { Textarea };

View File

@@ -0,0 +1,29 @@
'use client';
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '../lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent: React.FC<
React.ComponentPropsWithRef<typeof TooltipPrimitive.Content>
> = ({ className, sideOffset = 4, ...props }) => (
<TooltipPrimitive.Content
sideOffset={sideOffset}
className={cn(
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 z-50 overflow-hidden rounded-md px-3 py-1.5 text-xs',
className,
)}
{...props}
/>
);
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };