B2B-88: add starter kit structure and elements
This commit is contained in:
21
packages/ui/src/hooks/use-mobile.tsx
Normal file
21
packages/ui/src/hooks/use-mobile.tsx
Normal 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;
|
||||
}
|
||||
7
packages/ui/src/lib/utils/cn.ts
Normal file
7
packages/ui/src/lib/utils/cn.ts
Normal 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));
|
||||
}
|
||||
2
packages/ui/src/lib/utils/index.ts
Normal file
2
packages/ui/src/lib/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './cn';
|
||||
export * from './is-route-active';
|
||||
108
packages/ui/src/lib/utils/is-route-active.ts
Normal file
108
packages/ui/src/lib/utils/is-route-active.ts
Normal 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;
|
||||
}
|
||||
89
packages/ui/src/makerkit/app-breadcrumbs.tsx
Normal file
89
packages/ui/src/makerkit/app-breadcrumbs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
packages/ui/src/makerkit/authenticity-token.tsx
Normal file
17
packages/ui/src/makerkit/authenticity-token.tsx
Normal 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') ?? ''
|
||||
);
|
||||
}
|
||||
69
packages/ui/src/makerkit/bordered-navigation-menu.tsx
Normal file
69
packages/ui/src/makerkit/bordered-navigation-menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
packages/ui/src/makerkit/card-button.tsx
Normal file
117
packages/ui/src/makerkit/card-button.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
11
packages/ui/src/makerkit/context/sidebar.context.ts
Normal file
11
packages/ui/src/makerkit/context/sidebar.context.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
const SidebarContext = createContext<{
|
||||
collapsed: boolean;
|
||||
setCollapsed: (collapsed: boolean) => void;
|
||||
}>({
|
||||
collapsed: false,
|
||||
setCollapsed: (_) => _,
|
||||
});
|
||||
|
||||
export { SidebarContext };
|
||||
118
packages/ui/src/makerkit/cookie-banner.tsx
Normal file
118
packages/ui/src/makerkit/cookie-banner.tsx
Normal 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';
|
||||
}
|
||||
285
packages/ui/src/makerkit/data-table.tsx
Normal file
285
packages/ui/src/makerkit/data-table.tsx
Normal 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],
|
||||
);
|
||||
}
|
||||
79
packages/ui/src/makerkit/empty-state.tsx
Normal file
79
packages/ui/src/makerkit/empty-state.tsx
Normal 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 };
|
||||
36
packages/ui/src/makerkit/global-loader.tsx
Normal file
36
packages/ui/src/makerkit/global-loader.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
29
packages/ui/src/makerkit/if.tsx
Normal file
29
packages/ui/src/makerkit/if.tsx
Normal 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]);
|
||||
}
|
||||
200
packages/ui/src/makerkit/image-upload-input.tsx
Normal file
200
packages/ui/src/makerkit/image-upload-input.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
113
packages/ui/src/makerkit/image-uploader.tsx
Normal file
113
packages/ui/src/makerkit/image-uploader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
packages/ui/src/makerkit/language-selector.tsx
Normal file
79
packages/ui/src/makerkit/language-selector.tsx
Normal 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);
|
||||
}
|
||||
62
packages/ui/src/makerkit/lazy-render.tsx
Normal file
62
packages/ui/src/makerkit/lazy-render.tsx
Normal 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>;
|
||||
}
|
||||
33
packages/ui/src/makerkit/loading-overlay.tsx
Normal file
33
packages/ui/src/makerkit/loading-overlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
packages/ui/src/makerkit/marketing/coming-soon.tsx
Normal file
107
packages/ui/src/makerkit/marketing/coming-soon.tsx
Normal 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,
|
||||
};
|
||||
22
packages/ui/src/makerkit/marketing/cta-button.tsx
Normal file
22
packages/ui/src/makerkit/marketing/cta-button.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
28
packages/ui/src/makerkit/marketing/feature-card.tsx
Normal file
28
packages/ui/src/makerkit/marketing/feature-card.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
18
packages/ui/src/makerkit/marketing/feature-grid.tsx
Normal file
18
packages/ui/src/makerkit/marketing/feature-grid.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
51
packages/ui/src/makerkit/marketing/feature-showcase.tsx
Normal file
51
packages/ui/src/makerkit/marketing/feature-showcase.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
packages/ui/src/makerkit/marketing/footer.tsx
Normal file
98
packages/ui/src/makerkit/marketing/footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
18
packages/ui/src/makerkit/marketing/gradient-text.tsx
Normal file
18
packages/ui/src/makerkit/marketing/gradient-text.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
33
packages/ui/src/makerkit/marketing/header.tsx
Normal file
33
packages/ui/src/makerkit/marketing/header.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
23
packages/ui/src/makerkit/marketing/hero-title.tsx
Normal file
23
packages/ui/src/makerkit/marketing/hero-title.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
90
packages/ui/src/makerkit/marketing/hero.tsx
Normal file
90
packages/ui/src/makerkit/marketing/hero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
packages/ui/src/makerkit/marketing/index.tsx
Normal file
15
packages/ui/src/makerkit/marketing/index.tsx
Normal 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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
71
packages/ui/src/makerkit/marketing/newsletter-signup.tsx
Normal file
71
packages/ui/src/makerkit/marketing/newsletter-signup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
packages/ui/src/makerkit/marketing/pill.tsx
Normal file
59
packages/ui/src/makerkit/marketing/pill.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
42
packages/ui/src/makerkit/marketing/secondary-hero.tsx
Normal file
42
packages/ui/src/makerkit/marketing/secondary-hero.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
72
packages/ui/src/makerkit/mobile-navigation-dropdown.tsx
Normal file
72
packages/ui/src/makerkit/mobile-navigation-dropdown.tsx
Normal 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;
|
||||
77
packages/ui/src/makerkit/mobile-navigation-menu.tsx
Normal file
77
packages/ui/src/makerkit/mobile-navigation-menu.tsx
Normal 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;
|
||||
141
packages/ui/src/makerkit/mode-toggle.tsx
Normal file
141
packages/ui/src/makerkit/mode-toggle.tsx
Normal 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" />;
|
||||
}
|
||||
}
|
||||
435
packages/ui/src/makerkit/multi-step-form.tsx
Normal file
435
packages/ui/src/makerkit/multi-step-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
packages/ui/src/makerkit/navigation-config.schema.ts
Normal file
48
packages/ui/src/makerkit/navigation-config.schema.ts
Normal 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])),
|
||||
});
|
||||
234
packages/ui/src/makerkit/page.tsx
Normal file
234
packages/ui/src/makerkit/page.tsx
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
54
packages/ui/src/makerkit/profile-avatar.tsx
Normal file
54
packages/ui/src/makerkit/profile-avatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
357
packages/ui/src/makerkit/sidebar.tsx
Normal file
357
packages/ui/src/makerkit/sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
30
packages/ui/src/makerkit/spinner.tsx
Normal file
30
packages/ui/src/makerkit/spinner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
217
packages/ui/src/makerkit/stepper.tsx
Normal file
217
packages/ui/src/makerkit/stepper.tsx
Normal 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} />,
|
||||
};
|
||||
}
|
||||
40
packages/ui/src/makerkit/top-loading-bar-indicator.tsx
Normal file
40
packages/ui/src/makerkit/top-loading-bar-indicator.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
5
packages/ui/src/makerkit/trans.tsx
Normal file
5
packages/ui/src/makerkit/trans.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Trans as TransComponent } from 'react-i18next/TransWithoutContext';
|
||||
|
||||
export function Trans(props: React.ComponentProps<typeof TransComponent>) {
|
||||
return <TransComponent {...props} />;
|
||||
}
|
||||
118
packages/ui/src/makerkit/version-updater.tsx
Normal file
118
packages/ui/src/makerkit/version-updater.tsx
Normal 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,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
49
packages/ui/src/shadcn/accordion.tsx
Normal file
49
packages/ui/src/shadcn/accordion.tsx
Normal 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 };
|
||||
127
packages/ui/src/shadcn/alert-dialog.tsx
Normal file
127
packages/ui/src/shadcn/alert-dialog.tsx
Normal 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,
|
||||
};
|
||||
61
packages/ui/src/shadcn/alert.tsx
Normal file
61
packages/ui/src/shadcn/alert.tsx
Normal 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 };
|
||||
45
packages/ui/src/shadcn/avatar.tsx
Normal file
45
packages/ui/src/shadcn/avatar.tsx
Normal 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 };
|
||||
37
packages/ui/src/shadcn/badge.tsx
Normal file
37
packages/ui/src/shadcn/badge.tsx
Normal 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 };
|
||||
113
packages/ui/src/shadcn/breadcrumb.tsx
Normal file
113
packages/ui/src/shadcn/breadcrumb.tsx
Normal 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,
|
||||
};
|
||||
64
packages/ui/src/shadcn/button.tsx
Normal file
64
packages/ui/src/shadcn/button.tsx
Normal 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 };
|
||||
70
packages/ui/src/shadcn/calendar.tsx
Normal file
70
packages/ui/src/shadcn/calendar.tsx
Normal 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 };
|
||||
64
packages/ui/src/shadcn/card.tsx
Normal file
64
packages/ui/src/shadcn/card.tsx
Normal 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,
|
||||
};
|
||||
361
packages/ui/src/shadcn/chart.tsx
Normal file
361
packages/ui/src/shadcn/chart.tsx
Normal 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,
|
||||
};
|
||||
29
packages/ui/src/shadcn/checkbox.tsx
Normal file
29
packages/ui/src/shadcn/checkbox.tsx
Normal 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 };
|
||||
11
packages/ui/src/shadcn/collapsible.tsx
Normal file
11
packages/ui/src/shadcn/collapsible.tsx
Normal 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 };
|
||||
139
packages/ui/src/shadcn/command.tsx
Normal file
139
packages/ui/src/shadcn/command.tsx
Normal 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,
|
||||
};
|
||||
86
packages/ui/src/shadcn/data-table.tsx
Normal file
86
packages/ui/src/shadcn/data-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
packages/ui/src/shadcn/dialog.tsx
Normal file
112
packages/ui/src/shadcn/dialog.tsx
Normal 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,
|
||||
};
|
||||
190
packages/ui/src/shadcn/dropdown-menu.tsx
Normal file
190
packages/ui/src/shadcn/dropdown-menu.tsx
Normal 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,
|
||||
};
|
||||
174
packages/ui/src/shadcn/form.tsx
Normal file
174
packages/ui/src/shadcn/form.tsx
Normal 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,
|
||||
};
|
||||
81
packages/ui/src/shadcn/heading.tsx
Normal file
81
packages/ui/src/shadcn/heading.tsx
Normal 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>;
|
||||
}
|
||||
}
|
||||
1
packages/ui/src/shadcn/index.ts
Normal file
1
packages/ui/src/shadcn/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { cn } from '../lib/utils';
|
||||
74
packages/ui/src/shadcn/input-otp.tsx
Normal file
74
packages/ui/src/shadcn/input-otp.tsx
Normal 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 };
|
||||
26
packages/ui/src/shadcn/input.tsx
Normal file
26
packages/ui/src/shadcn/input.tsx
Normal 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 };
|
||||
22
packages/ui/src/shadcn/label.tsx
Normal file
22
packages/ui/src/shadcn/label.tsx
Normal 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 };
|
||||
119
packages/ui/src/shadcn/navigation-menu.tsx
Normal file
119
packages/ui/src/shadcn/navigation-menu.tsx
Normal 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,
|
||||
};
|
||||
33
packages/ui/src/shadcn/popover.tsx
Normal file
33
packages/ui/src/shadcn/popover.tsx
Normal 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 };
|
||||
28
packages/ui/src/shadcn/progress.tsx
Normal file
28
packages/ui/src/shadcn/progress.tsx
Normal 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 };
|
||||
66
packages/ui/src/shadcn/radio-group.tsx
Normal file
66
packages/ui/src/shadcn/radio-group.tsx
Normal 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 };
|
||||
45
packages/ui/src/shadcn/scroll-area.tsx
Normal file
45
packages/ui/src/shadcn/scroll-area.tsx
Normal 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 };
|
||||
151
packages/ui/src/shadcn/select.tsx
Normal file
151
packages/ui/src/shadcn/select.tsx
Normal 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,
|
||||
};
|
||||
31
packages/ui/src/shadcn/separator.tsx
Normal file
31
packages/ui/src/shadcn/separator.tsx
Normal 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 };
|
||||
133
packages/ui/src/shadcn/sheet.tsx
Normal file
133
packages/ui/src/shadcn/sheet.tsx
Normal 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,
|
||||
};
|
||||
1048
packages/ui/src/shadcn/sidebar.tsx
Normal file
1048
packages/ui/src/shadcn/sidebar.tsx
Normal file
File diff suppressed because it is too large
Load Diff
15
packages/ui/src/shadcn/skeleton.tsx
Normal file
15
packages/ui/src/shadcn/skeleton.tsx
Normal 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 };
|
||||
31
packages/ui/src/shadcn/sonner.tsx
Normal file
31
packages/ui/src/shadcn/sonner.tsx
Normal 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 };
|
||||
28
packages/ui/src/shadcn/switch.tsx
Normal file
28
packages/ui/src/shadcn/switch.tsx
Normal 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 };
|
||||
108
packages/ui/src/shadcn/table.tsx
Normal file
108
packages/ui/src/shadcn/table.tsx
Normal 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,
|
||||
};
|
||||
50
packages/ui/src/shadcn/tabs.tsx
Normal file
50
packages/ui/src/shadcn/tabs.tsx
Normal 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 };
|
||||
21
packages/ui/src/shadcn/textarea.tsx
Normal file
21
packages/ui/src/shadcn/textarea.tsx
Normal 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 };
|
||||
29
packages/ui/src/shadcn/tooltip.tsx
Normal file
29
packages/ui/src/shadcn/tooltip.tsx
Normal 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 };
|
||||
Reference in New Issue
Block a user