Cleanup
This commit is contained in:
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 };
|
||||
264
packages/ui/src/makerkit/data-table.tsx
Normal file
264
packages/ui/src/makerkit/data-table.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
'use client';
|
||||
|
||||
import { Fragment, useCallback, useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import type {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
PaginationState,
|
||||
Table as ReactTable,
|
||||
Row,
|
||||
SortingState,
|
||||
VisibilityState,
|
||||
} from '@tanstack/react-table';
|
||||
import classNames from 'clsx';
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronsLeftIcon,
|
||||
ChevronsRightIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@kit/ui/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;
|
||||
tableProps?: React.ComponentProps<typeof Table> &
|
||||
Record<`data-${string}`, string>;
|
||||
}
|
||||
|
||||
export function DataTable<T extends object>({
|
||||
data,
|
||||
columns,
|
||||
renderSubComponent,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
pageCount,
|
||||
onPaginationChange,
|
||||
tableProps,
|
||||
}: ReactTableProps<T>) {
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: pageIndex ?? 0,
|
||||
pageSize: pageSize ?? 15,
|
||||
});
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
|
||||
const navigateToPage = useNavigateToNewPage();
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
pageCount,
|
||||
state: {
|
||||
pagination,
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
},
|
||||
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={'dark:border-dark-800 rounded-md border border-gray-50 p-1'}
|
||||
>
|
||||
<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.map((row) => (
|
||||
<Fragment key={row.id}>
|
||||
<TableRow
|
||||
className={classNames({
|
||||
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted':
|
||||
row.getIsExpanded(),
|
||||
})}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
style={{
|
||||
width: cell.column.getSize(),
|
||||
}}
|
||||
key={cell.id}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
|
||||
{renderSubComponent ? (
|
||||
<TableRow key={row.id + '-expanded'}>
|
||||
<TableCell colSpan={columns.length}>
|
||||
{renderSubComponent({ row })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</Fragment>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
<TableFooter>
|
||||
<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 w-full items-center gap-2">
|
||||
<Button
|
||||
size={'icon'}
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronsLeftIcon className={'h-4'} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size={'icon'}
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronLeftIcon className={'h-4'} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size={'icon'}
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronRightIcon className={'h-4'} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size={'icon'}
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronsRightIcon className={'h-4'} />
|
||||
</Button>
|
||||
|
||||
<span className="flex items-center text-sm">
|
||||
<Trans
|
||||
i18nKey={'common:pageOfPages'}
|
||||
values={{
|
||||
page: table.getState().pagination.pageIndex + 1,
|
||||
total: table.getPageCount(),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</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],
|
||||
);
|
||||
}
|
||||
5
packages/ui/src/makerkit/divider.tsx
Normal file
5
packages/ui/src/makerkit/divider.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
export function Divider(props: { className?: string }) {
|
||||
return <div className={cn('h-[1px] w-full bg-border', props.className)} />;
|
||||
}
|
||||
24
packages/ui/src/makerkit/error-boundary.tsx
Normal file
24
packages/ui/src/makerkit/error-boundary.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Component } from 'react';
|
||||
|
||||
export class ErrorBoundary extends Component<{
|
||||
fallback: ReactNode;
|
||||
children: ReactNode;
|
||||
}> {
|
||||
readonly state = { hasError: false, error: null };
|
||||
|
||||
static getDerivedStateFromError(error: unknown) {
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
26
packages/ui/src/makerkit/global-loader.tsx
Normal file
26
packages/ui/src/makerkit/global-loader.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { LoadingOverlay } from './loading-overlay';
|
||||
import { TopLoadingBarIndicator } from './top-loading-bar-indicator';
|
||||
import { Trans } from './trans';
|
||||
|
||||
export function GlobalLoader({
|
||||
children,
|
||||
displayLogo = false,
|
||||
fullPage = false,
|
||||
}: React.PropsWithChildren<{
|
||||
displayLogo?: boolean;
|
||||
fullPage?: boolean;
|
||||
}>) {
|
||||
const Text = children ?? <Trans i18nKey={'common:loading'} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopLoadingBarIndicator />
|
||||
|
||||
<div className={'flex flex-1 flex-col items-center justify-center py-48'}>
|
||||
<LoadingOverlay displayLogo={displayLogo} fullPage={fullPage}>
|
||||
{Text}
|
||||
</LoadingOverlay>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
26
packages/ui/src/makerkit/hooks/use-sidebar-state.ts
Normal file
26
packages/ui/src/makerkit/hooks/use-sidebar-state.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
const SIDEBAR_COLLAPSED_STORAGE_KEY = 'sidebarState';
|
||||
|
||||
function useCollapsible(initialValue?: boolean) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(initialValue);
|
||||
|
||||
const onCollapseChange = useCallback((collapsed: boolean) => {
|
||||
setIsCollapsed(collapsed);
|
||||
storeCollapsibleState(collapsed);
|
||||
}, []);
|
||||
|
||||
return [isCollapsed, onCollapseChange] as [boolean, typeof onCollapseChange];
|
||||
}
|
||||
|
||||
function storeCollapsibleState(collapsed: boolean) {
|
||||
// TODO: implement below
|
||||
/*
|
||||
setCookie(
|
||||
SIDEBAR_COLLAPSED_STORAGE_KEY,
|
||||
collapsed ? 'collapsed' : 'expanded',
|
||||
);
|
||||
*/
|
||||
}
|
||||
|
||||
export default useCollapsible;
|
||||
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]);
|
||||
}
|
||||
202
packages/ui/src/makerkit/image-upload-input.tsx
Normal file
202
packages/ui/src/makerkit/image-upload-input.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
'use client';
|
||||
|
||||
import type { FormEvent, MouseEventHandler } from 'react';
|
||||
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
import cn from 'clsx';
|
||||
import { UploadCloud, XIcon } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Label } from '@kit/ui/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;
|
||||
};
|
||||
|
||||
const IMAGE_SIZE = 22;
|
||||
|
||||
const ImageUploadInput = forwardRef<React.ElementRef<'input'>, Props>(
|
||||
function ImageUploadInputComponent(
|
||||
{
|
||||
children,
|
||||
image,
|
||||
onClear,
|
||||
onInput,
|
||||
onValueChange,
|
||||
visible = true,
|
||||
...props
|
||||
},
|
||||
forwardedRef,
|
||||
) {
|
||||
const localRef = useRef<HTMLInputElement>();
|
||||
|
||||
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];
|
||||
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 (
|
||||
// eslint-disable-next-line jsx-a11y/label-has-associated-control
|
||||
<label
|
||||
id={'image-upload-input'}
|
||||
className={`relative flex h-10 w-full cursor-pointer rounded-md border border-dashed border-input
|
||||
bg-background px-3 py-2 text-sm outline-none ring-primary ring-offset-2 ring-offset-background transition-all file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus:ring-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 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={'dark:text-dark-500 h-5 text-gray-500'} />
|
||||
</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}
|
||||
>
|
||||
<XIcon className="h-4" />
|
||||
</Button>
|
||||
</If>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
},
|
||||
);
|
||||
export default ImageUploadInput;
|
||||
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 from 'next/image';
|
||||
|
||||
import { ImageIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
|
||||
import ImageUploadInput from './image-upload-input';
|
||||
import { Trans } from './trans';
|
||||
|
||||
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'}>
|
||||
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||
<label className={'relative h-20 w-20 animate-in fade-in zoom-in-50'}>
|
||||
<Image fill className={'h-20 w-20 rounded-full'} src={image} alt={''} />
|
||||
|
||||
<Input />
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<Button onClick={onClear} size={'sm'} variant={'ghost'}>
|
||||
<Trans i18nKey={'common:clear'} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImageUploader;
|
||||
|
||||
function FallbackImage(
|
||||
props: React.PropsWithChildren<{
|
||||
descriptionSection?: React.ReactNode;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<div className={'flex items-center space-x-4'}>
|
||||
<label
|
||||
className={
|
||||
'relative flex h-20 w-20 cursor-pointer flex-col items-center justify-center rounded-full border border-border animate-in fade-in zoom-in-50 hover:border-primary'
|
||||
}
|
||||
>
|
||||
<ImageIcon className={'h-8 text-primary'} />
|
||||
|
||||
{props.children}
|
||||
</label>
|
||||
|
||||
{props.descriptionSection}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
packages/ui/src/makerkit/is-route-active.ts
Normal file
75
packages/ui/src/makerkit/is-route-active.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
const ROOT_PATH = '/';
|
||||
|
||||
/**
|
||||
* @name isRouteActive
|
||||
* @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 default function isRouteActive(
|
||||
targetLink: string,
|
||||
currentRoute: string,
|
||||
depth: number,
|
||||
) {
|
||||
// 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;
|
||||
}
|
||||
80
packages/ui/src/makerkit/language-dropdown-switcher.tsx
Normal file
80
packages/ui/src/makerkit/language-dropdown-switcher.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import useRefresh from '@kit/shared/hooks/use-refresh';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@kit/ui/select';
|
||||
|
||||
const LanguageDropdownSwitcher: React.FC<{
|
||||
onChange?: (locale: string) => unknown;
|
||||
}> = ({ onChange }) => {
|
||||
const { i18n } = useTranslation();
|
||||
const refresh = useRefresh();
|
||||
|
||||
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);
|
||||
return refresh();
|
||||
},
|
||||
[i18n, onChange, refresh],
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export default LanguageDropdownSwitcher;
|
||||
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>;
|
||||
}
|
||||
31
packages/ui/src/makerkit/loading-overlay.tsx
Normal file
31
packages/ui/src/makerkit/loading-overlay.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { PropsWithChildren } from 'react';
|
||||
|
||||
import { cn } from '../utils/cn';
|
||||
import Spinner from './spinner';
|
||||
|
||||
export function LoadingOverlay({
|
||||
children,
|
||||
className,
|
||||
fullPage = true,
|
||||
}: PropsWithChildren<{
|
||||
className?: string;
|
||||
fullPage?: boolean;
|
||||
displayLogo?: boolean;
|
||||
}>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center space-y-4',
|
||||
className,
|
||||
{
|
||||
[`fixed left-0 top-0 z-[100] h-screen w-screen bg-background`]:
|
||||
fullPage,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<Spinner className={'text-primary'} />
|
||||
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
packages/ui/src/makerkit/mdx/mdx-components.tsx
Normal file
82
packages/ui/src/makerkit/mdx/mdx-components.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
import { cn } from '../../shadcn';
|
||||
import { LazyRender } from '../lazy-render';
|
||||
|
||||
const NextImage: React.FC<{
|
||||
width: number;
|
||||
height: number;
|
||||
src: string;
|
||||
alt: string;
|
||||
class?: string;
|
||||
}> = (props) => {
|
||||
const className = cn(props.class, `object-cover`);
|
||||
|
||||
return <Image className={className} src={props.src} alt={props.alt} />;
|
||||
};
|
||||
|
||||
const ExternalLink = forwardRef<
|
||||
React.ElementRef<'a'>,
|
||||
React.AnchorHTMLAttributes<unknown>
|
||||
>(function ExternalLink(props, ref) {
|
||||
const href = props.href ?? '';
|
||||
const isRoot = href === '/';
|
||||
|
||||
const isInternalLink =
|
||||
isRoot || href.startsWith(process.env.NEXT_PUBLIC_SITE_URL!);
|
||||
|
||||
if (isInternalLink) {
|
||||
return (
|
||||
<a {...props} ref={ref} href={href}>
|
||||
{props.children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
ref={ref}
|
||||
{...props}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{props.children}
|
||||
</a>
|
||||
);
|
||||
});
|
||||
|
||||
const Video: React.FC<{
|
||||
src: string;
|
||||
width?: string;
|
||||
type?: string;
|
||||
}> = ({ src, type, width }) => {
|
||||
const useType = type ?? 'video/mp4';
|
||||
|
||||
return (
|
||||
<LazyRender rootMargin={'-200px 0px'}>
|
||||
<video
|
||||
className="my-4"
|
||||
width={width ?? `100%`}
|
||||
height="auto"
|
||||
playsInline
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
>
|
||||
<source src={src} type={useType} />
|
||||
</video>
|
||||
</LazyRender>
|
||||
);
|
||||
};
|
||||
|
||||
const Components = {
|
||||
img: NextImage,
|
||||
a: ExternalLink,
|
||||
Video,
|
||||
Image: NextImage,
|
||||
};
|
||||
|
||||
export default Components;
|
||||
97
packages/ui/src/makerkit/mdx/mdx-renderer.module.css
Normal file
97
packages/ui/src/makerkit/mdx/mdx-renderer.module.css
Normal file
@@ -0,0 +1,97 @@
|
||||
.MDX h1 {
|
||||
@apply mt-14 text-4xl font-bold;
|
||||
}
|
||||
|
||||
.MDX h2 {
|
||||
@apply mb-4 mt-12 text-2xl font-semibold lg:text-3xl;
|
||||
}
|
||||
|
||||
.MDX h3 {
|
||||
@apply mt-10 text-2xl font-bold;
|
||||
}
|
||||
|
||||
.MDX h4 {
|
||||
@apply mt-8 text-xl font-bold;
|
||||
}
|
||||
|
||||
.MDX h5 {
|
||||
@apply mt-6 text-lg font-semibold;
|
||||
}
|
||||
|
||||
.MDX h6 {
|
||||
@apply mt-2 text-base font-medium;
|
||||
}
|
||||
|
||||
/**
|
||||
Tailwind "dark" variants do not work with CSS Modules
|
||||
We work it around using :global(.dark)
|
||||
For more info: https://github.com/tailwindlabs/tailwindcss/issues/3258#issuecomment-770215347
|
||||
*/
|
||||
:global(.dark) .MDX h1,
|
||||
:global(.dark) .MDX h2,
|
||||
:global(.dark) .MDX h3,
|
||||
:global(.dark) .MDX h4,
|
||||
:global(.dark) .MDX h5,
|
||||
:global(.dark) .MDX h6 {
|
||||
@apply text-white;
|
||||
}
|
||||
|
||||
.MDX p {
|
||||
@apply mb-4 mt-2 text-base leading-7;
|
||||
}
|
||||
|
||||
.MDX li {
|
||||
@apply relative my-1.5 text-base leading-7;
|
||||
}
|
||||
|
||||
.MDX ul > li:before {
|
||||
content: '-';
|
||||
|
||||
@apply mr-2;
|
||||
}
|
||||
|
||||
.MDX ol > li:before {
|
||||
@apply inline-flex font-medium;
|
||||
|
||||
content: counters(counts, '.') '. ';
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
|
||||
.MDX b,
|
||||
.MDX strong {
|
||||
@apply font-bold;
|
||||
}
|
||||
|
||||
:global(.dark) .MDX b,
|
||||
:global(.dark) .MDX strong {
|
||||
@apply text-white;
|
||||
}
|
||||
|
||||
.MDX img,
|
||||
.MDX video {
|
||||
@apply rounded-md;
|
||||
}
|
||||
|
||||
.MDX ul,
|
||||
.MDX ol {
|
||||
@apply pl-1;
|
||||
}
|
||||
|
||||
.MDX ol > li {
|
||||
counter-increment: counts;
|
||||
}
|
||||
|
||||
.MDX ol > li:before {
|
||||
@apply mr-2 inline-flex font-semibold;
|
||||
|
||||
content: counters(counts, '.') '. ';
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
|
||||
.MDX blockquote {
|
||||
@apply my-4 border-l-4 border-primary bg-muted px-6 py-4 text-lg font-medium text-gray-600;
|
||||
}
|
||||
|
||||
.MDX pre {
|
||||
@apply my-6 text-sm text-current;
|
||||
}
|
||||
19
packages/ui/src/makerkit/mdx/mdx-renderer.tsx
Normal file
19
packages/ui/src/makerkit/mdx/mdx-renderer.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { MDXComponents } from 'mdx/types';
|
||||
import { getMDXComponent } from 'next-contentlayer/hooks';
|
||||
|
||||
import Components from './mdx-components';
|
||||
import styles from './mdx-renderer.module.css';
|
||||
|
||||
export function Mdx({
|
||||
code,
|
||||
}: React.PropsWithChildren<{
|
||||
code: string;
|
||||
}>) {
|
||||
const Component = getMDXComponent(code);
|
||||
|
||||
return (
|
||||
<div className={styles.MDX}>
|
||||
<Component components={Components as unknown as MDXComponents} />
|
||||
</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 { ChevronDownIcon } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
import { Trans } from '@kit/ui/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>
|
||||
|
||||
<ChevronDownIcon 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;
|
||||
73
packages/ui/src/makerkit/mobile-navigation-menu.tsx
Normal file
73
packages/ui/src/makerkit/mobile-navigation-menu.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { ChevronDownIcon } from 'lucide-react';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
const MobileNavigationDropdown: React.FC<{
|
||||
links: {
|
||||
path: string;
|
||||
label: string;
|
||||
}[];
|
||||
}> = ({ links }) => {
|
||||
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>
|
||||
|
||||
<ChevronDownIcon className={'h-5'} />
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>{items}</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileNavigationDropdown;
|
||||
15
packages/ui/src/makerkit/navigation/navigation-container.tsx
Normal file
15
packages/ui/src/makerkit/navigation/navigation-container.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cn } from './utils';
|
||||
|
||||
const NavigationContainer: React.FC<{
|
||||
className?: string;
|
||||
}> = ({ children, className }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(`dark:border-dark-800 border-b border-gray-50`, className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavigationContainer;
|
||||
125
packages/ui/src/makerkit/navigation/navigation-item.tsx
Normal file
125
packages/ui/src/makerkit/navigation/navigation-item.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import { useContext } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { cva } from 'class-variance-authority';
|
||||
|
||||
import isRouteActive from '@kit/generic/is-route-active';
|
||||
|
||||
import Trans from '@/components/app/Trans';
|
||||
|
||||
import { NavigationMenuContext } from './navigation-menu-context';
|
||||
import { cn } from './utils';
|
||||
|
||||
interface Link {
|
||||
path: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const NavigationMenuItem: React.FC<{
|
||||
link: Link;
|
||||
depth?: number;
|
||||
disabled?: boolean;
|
||||
shallow?: boolean;
|
||||
className?: string;
|
||||
}> = ({ link, disabled, shallow, depth, ...props }) => {
|
||||
const pathName = usePathname() ?? '';
|
||||
const active = isRouteActive(link.path, pathName, depth ?? 3);
|
||||
const menuProps = useContext(NavigationMenuContext);
|
||||
const label = link.label;
|
||||
|
||||
const itemClassName = getNavigationMenuItemClassBuilder()({
|
||||
active,
|
||||
...menuProps,
|
||||
});
|
||||
|
||||
const className = cn(itemClassName, props.className ?? ``);
|
||||
|
||||
return (
|
||||
<li className={className}>
|
||||
<Link
|
||||
className={
|
||||
'justify-center transition-transform duration-500 lg:justify-start'
|
||||
}
|
||||
aria-disabled={disabled}
|
||||
href={disabled ? '' : link.path}
|
||||
shallow={shallow ?? active}
|
||||
>
|
||||
<Trans i18nKey={label} defaults={label} />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavigationMenuItem;
|
||||
|
||||
function getNavigationMenuItemClassBuilder() {
|
||||
return cva(
|
||||
[
|
||||
`flex items-center justify-center font-medium lg:justify-start rounded-md text-sm transition colors transform *:active:translate-y-[2px]`,
|
||||
'*:p-1 *:lg:px-2.5 *:s-full *:flex *:items-center',
|
||||
'aria-disabled:cursor-not-allowed aria-disabled:opacity-50',
|
||||
],
|
||||
{
|
||||
compoundVariants: [
|
||||
// not active - shared
|
||||
{
|
||||
active: false,
|
||||
className: `font-medium hover:underline`,
|
||||
},
|
||||
// active - shared
|
||||
{
|
||||
active: true,
|
||||
className: `font-semibold`,
|
||||
},
|
||||
// active - pill
|
||||
{
|
||||
active: true,
|
||||
pill: true,
|
||||
className: `bg-gray-50 text-gray-800 dark:bg-primary-300/10`,
|
||||
},
|
||||
// not active - pill
|
||||
{
|
||||
active: false,
|
||||
pill: true,
|
||||
className: `hover:bg-gray-50 active:bg-gray-100 text-gray-500 dark:text-gray-300 dark:hover:bg-background dark:active:bg-dark-900/90`,
|
||||
},
|
||||
// not active - bordered
|
||||
{
|
||||
active: false,
|
||||
bordered: true,
|
||||
className: `hover:bg-gray-50 active:bg-gray-100 dark:active:bg-dark-800 dark:hover:bg-dark/90 transition-colors rounded-lg border-transparent`,
|
||||
},
|
||||
// active - bordered
|
||||
{
|
||||
active: true,
|
||||
bordered: true,
|
||||
className: `top-[0.4rem] border-b-[0.25rem] rounded-none border-primary bg-transparent pb-[0.55rem] text-primary-700 dark:text-white`,
|
||||
},
|
||||
// active - secondary
|
||||
{
|
||||
active: true,
|
||||
secondary: true,
|
||||
className: `bg-transparent font-semibold`,
|
||||
},
|
||||
],
|
||||
variants: {
|
||||
active: {
|
||||
true: ``,
|
||||
},
|
||||
pill: {
|
||||
true: `[&>*]:py-2`,
|
||||
},
|
||||
bordered: {
|
||||
true: `relative h-10`,
|
||||
},
|
||||
secondary: {
|
||||
true: ``,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
import type { NavigationMenuProps } from './navigation-menu';
|
||||
|
||||
export const NavigationMenuContext = createContext<NavigationMenuProps>({});
|
||||
49
packages/ui/src/makerkit/navigation/navigation-menu.tsx
Normal file
49
packages/ui/src/makerkit/navigation/navigation-menu.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import type { PropsWithChildren } from 'react';
|
||||
|
||||
import { cva } from 'class-variance-authority';
|
||||
|
||||
import { NavigationMenuContext } from './navigation-menu-context';
|
||||
|
||||
type Vertical = {
|
||||
vertical?: boolean;
|
||||
};
|
||||
|
||||
type Bordered = {
|
||||
bordered?: boolean;
|
||||
};
|
||||
|
||||
type Pill = {
|
||||
pill?: boolean;
|
||||
};
|
||||
|
||||
export type NavigationMenuProps = Vertical & (Bordered | Pill);
|
||||
|
||||
function NavigationMenu(props: PropsWithChildren<NavigationMenuProps>) {
|
||||
const className = getNavigationMenuClassBuilder()(props);
|
||||
|
||||
return (
|
||||
<ul className={className}>
|
||||
<NavigationMenuContext.Provider value={props}>
|
||||
{props.children}
|
||||
</NavigationMenuContext.Provider>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default NavigationMenu;
|
||||
|
||||
function getNavigationMenuClassBuilder() {
|
||||
return cva(['w-full dark:text-gray-300 items-center flex-wrap flex'], {
|
||||
variants: {
|
||||
vertical: {
|
||||
true: `flex items-start justify-between space-x-2
|
||||
lg:flex-col lg:justify-start lg:space-x-0 lg:space-y-1.5 [&>li>a]:w-full`,
|
||||
},
|
||||
bordered: {
|
||||
true: `lg:space-x-3 border-b border-gray-100 dark:border-dark-800 pb-1.5`,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
143
packages/ui/src/makerkit/otp-input.tsx
Normal file
143
packages/ui/src/makerkit/otp-input.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { FormEventHandler } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
|
||||
import { Input } from '../shadcn/input';
|
||||
|
||||
const DIGITS = 6;
|
||||
|
||||
export function OtpInput({
|
||||
onValid,
|
||||
onInvalid,
|
||||
}: React.PropsWithChildren<{
|
||||
onValid: (code: string) => void;
|
||||
onInvalid: () => void;
|
||||
}>) {
|
||||
const digitsArray = useMemo(
|
||||
() => Array.from({ length: DIGITS }, (_, i) => i),
|
||||
[],
|
||||
);
|
||||
|
||||
const { control, register, watch, setFocus, formState, setValue } = useForm({
|
||||
mode: 'onChange',
|
||||
reValidateMode: 'onChange',
|
||||
defaultValues: {
|
||||
values: digitsArray.map(() => ({ value: '' })),
|
||||
},
|
||||
});
|
||||
|
||||
useFieldArray({
|
||||
control,
|
||||
name: 'values',
|
||||
shouldUnregister: true,
|
||||
});
|
||||
|
||||
const { values } = watch();
|
||||
const isFormValid = formState.isValid;
|
||||
const code = (values ?? []).map(({ value }) => value).join('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFormValid) {
|
||||
onInvalid();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (code.length === DIGITS) {
|
||||
onValid(code);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
onInvalid();
|
||||
}, [onInvalid, onValid, code, isFormValid]);
|
||||
|
||||
useEffect(() => {
|
||||
setFocus('values.0.value');
|
||||
}, [setFocus]);
|
||||
|
||||
const onInput: FormEventHandler<HTMLInputElement> = useCallback(
|
||||
(target) => {
|
||||
const element = target.currentTarget;
|
||||
const isValid = element.reportValidity();
|
||||
|
||||
if (isValid) {
|
||||
const nextIndex = Number(element.dataset.index) + 1;
|
||||
|
||||
if (nextIndex >= DIGITS) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFocus(`values.${nextIndex}.value`);
|
||||
}
|
||||
},
|
||||
[setFocus],
|
||||
);
|
||||
|
||||
const onPaste = useCallback(
|
||||
(event: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const pasted = event.clipboardData.getData('text/plain');
|
||||
|
||||
// check if value is numeric
|
||||
if (isNumeric(pasted)) {
|
||||
const digits = getDigits(pasted, digitsArray);
|
||||
|
||||
digits.forEach((value, index) => {
|
||||
setValue(`values.${index}.value`, value);
|
||||
setFocus(`values.${index + 1}.value`);
|
||||
});
|
||||
}
|
||||
},
|
||||
[digitsArray, setFocus, setValue],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Backspace') {
|
||||
event.preventDefault();
|
||||
|
||||
const index = Number(event.currentTarget.dataset.inputIndex);
|
||||
|
||||
setValue(`values.${index}.value`, '');
|
||||
setFocus(`values.${index - 1}.value`);
|
||||
}
|
||||
},
|
||||
[setFocus, setValue],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={'flex justify-center space-x-2'}>
|
||||
{digitsArray.map((digit, index) => {
|
||||
const control = { ...register(`values.${digit}.value`) };
|
||||
|
||||
return (
|
||||
<Input
|
||||
autoComplete={'off'}
|
||||
className={'w-10 text-center'}
|
||||
data-index={digit}
|
||||
pattern="[0-9]"
|
||||
required
|
||||
key={digit}
|
||||
maxLength={1}
|
||||
onInput={onInput}
|
||||
onPaste={onPaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
data-input-index={index}
|
||||
{...control}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isNumeric(pasted: string) {
|
||||
const isNumericRegExp = /^-?\d+$/;
|
||||
|
||||
return isNumericRegExp.test(pasted);
|
||||
}
|
||||
|
||||
function getDigits(pasted: string, digitsArray: number[]) {
|
||||
return pasted.split('').slice(0, digitsArray.length);
|
||||
}
|
||||
73
packages/ui/src/makerkit/page.tsx
Normal file
73
packages/ui/src/makerkit/page.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { cn } from '../utils';
|
||||
|
||||
export function Page(
|
||||
props: React.PropsWithChildren<{
|
||||
sidebar?: React.ReactNode;
|
||||
contentContainerClassName?: string;
|
||||
className?: string;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<div className={cn('flex', props.className)}>
|
||||
<div className={'hidden lg:block'}>{props.sidebar}</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
props.contentContainerClassName ??
|
||||
'mx-auto flex h-screen w-full flex-col overflow-y-auto'
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageBody(
|
||||
props: React.PropsWithChildren<{
|
||||
className?: string;
|
||||
}>,
|
||||
) {
|
||||
const className = cn('w-full px-4 flex flex-col flex-1', props.className);
|
||||
|
||||
return <div className={className}>{props.children}</div>;
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
mobileNavigation,
|
||||
}: React.PropsWithChildren<{
|
||||
title: string | React.ReactNode;
|
||||
description?: string | React.ReactNode;
|
||||
mobileNavigation?: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<div className={'flex items-start justify-between p-4'}>
|
||||
<div
|
||||
className={
|
||||
'flex items-center space-x-2 lg:flex-col lg:items-start lg:space-x-0'
|
||||
}
|
||||
>
|
||||
<div className={'flex items-center lg:hidden'}>{mobileNavigation}</div>
|
||||
|
||||
<h1>
|
||||
<span className={'flex items-center space-x-0.5 lg:space-x-2'}>
|
||||
<span className={'text-xl font-semibold dark:text-white'}>
|
||||
{title}
|
||||
</span>
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<h2 className={'hidden lg:block'}>
|
||||
<span className={'text-base font-normal text-muted-foreground'}>
|
||||
{description}
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
307
packages/ui/src/makerkit/pricing-table.tsx
Normal file
307
packages/ui/src/makerkit/pricing-table.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { CheckCircleIcon, SparklesIcon } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import Heading from '@kit/ui/heading';
|
||||
// TODO: pass in from app
|
||||
import pathsConfig from '@kit/web/config/paths.config';
|
||||
import pricingConfig from '@kit/web/config/pricing.config';
|
||||
|
||||
import { cn } from '../utils/cn';
|
||||
import { If } from './if';
|
||||
import { Trans } from './trans';
|
||||
|
||||
interface CheckoutButtonProps {
|
||||
readonly stripePriceId?: string;
|
||||
readonly recommended?: boolean;
|
||||
}
|
||||
|
||||
interface PricingItemProps {
|
||||
selectable: boolean;
|
||||
product: {
|
||||
name: string;
|
||||
features: string[];
|
||||
description: string;
|
||||
recommended?: boolean;
|
||||
badge?: string;
|
||||
};
|
||||
plan: {
|
||||
name: string;
|
||||
stripePriceId?: string;
|
||||
price: string;
|
||||
label?: string;
|
||||
href?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const STRIPE_PRODUCTS = pricingConfig.products;
|
||||
|
||||
const STRIPE_PLANS = STRIPE_PRODUCTS.reduce<string[]>((acc, product) => {
|
||||
product.plans.forEach((plan) => {
|
||||
if (plan.name && !acc.includes(plan.name)) {
|
||||
acc.push(plan.name);
|
||||
}
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
function PricingTable(
|
||||
props: React.PropsWithChildren<{
|
||||
CheckoutButton?: React.ComponentType<CheckoutButtonProps>;
|
||||
}>,
|
||||
) {
|
||||
const [planVariant, setPlanVariant] = useState<string>(STRIPE_PLANS[0]);
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-12'}>
|
||||
<div className={'flex justify-center'}>
|
||||
<PlansSwitcher
|
||||
plans={STRIPE_PLANS}
|
||||
plan={planVariant}
|
||||
setPlan={setPlanVariant}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'flex flex-col items-start space-y-6 lg:space-y-0' +
|
||||
' justify-center lg:flex-row lg:space-x-4'
|
||||
}
|
||||
>
|
||||
{STRIPE_PRODUCTS.map((product) => {
|
||||
const plan =
|
||||
product.plans.find((item) => item.name === planVariant) ??
|
||||
product.plans[0];
|
||||
|
||||
return (
|
||||
<PricingItem
|
||||
selectable
|
||||
key={plan.stripePriceId ?? plan.name}
|
||||
plan={plan}
|
||||
product={product}
|
||||
CheckoutButton={props.CheckoutButton}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PricingTable;
|
||||
|
||||
PricingTable.Item = PricingItem;
|
||||
PricingTable.Price = Price;
|
||||
PricingTable.FeaturesList = FeaturesList;
|
||||
|
||||
function PricingItem(
|
||||
props: React.PropsWithChildren<
|
||||
PricingItemProps & {
|
||||
CheckoutButton?: React.ComponentType<CheckoutButtonProps>;
|
||||
}
|
||||
>,
|
||||
) {
|
||||
const recommended = props.product.recommended ?? false;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-test={'subscription-plan'}
|
||||
className={cn(
|
||||
`
|
||||
relative flex w-full flex-col justify-between space-y-6 rounded-lg
|
||||
p-6 lg:w-4/12 xl:max-w-xs xl:p-8 2xl:w-3/12
|
||||
`,
|
||||
{
|
||||
['border']: !recommended,
|
||||
['border-2 border-primary']: recommended,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className={'flex flex-col space-y-1'}>
|
||||
<div className={'flex items-center space-x-4'}>
|
||||
<Heading level={4}>{props.product.name}</Heading>
|
||||
|
||||
<If condition={props.product.badge}>
|
||||
<div
|
||||
className={cn(
|
||||
`flex space-x-1 rounded-md px-2 py-1 text-xs font-medium`,
|
||||
{
|
||||
['bg-primary text-primary-foreground']: recommended,
|
||||
['bg-muted text-muted-foreground']: !recommended,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<If condition={recommended}>
|
||||
<SparklesIcon className={'mr-1 h-4 w-4'} />
|
||||
</If>
|
||||
|
||||
<span>{props.product.badge}</span>
|
||||
</div>
|
||||
</If>
|
||||
</div>
|
||||
|
||||
<span className={'text-muted-foreground'}>
|
||||
{props.product.description}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={'flex items-end space-x-1'}>
|
||||
<Price>{props.plan.price}</Price>
|
||||
|
||||
<If condition={props.plan.name}>
|
||||
<span className={cn(`text-lg lowercase text-muted-foreground`)}>
|
||||
<span>/</span>
|
||||
<span>{props.plan.name}</span>
|
||||
</span>
|
||||
</If>
|
||||
</div>
|
||||
|
||||
<div className={'text-current'}>
|
||||
<FeaturesList features={props.product.features} />
|
||||
</div>
|
||||
|
||||
<If condition={props.selectable}>
|
||||
<If
|
||||
condition={props.plan.stripePriceId && props.CheckoutButton}
|
||||
fallback={
|
||||
<DefaultCheckoutButton
|
||||
recommended={recommended}
|
||||
plan={props.plan}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(CheckoutButton) => (
|
||||
<CheckoutButton
|
||||
recommended={recommended}
|
||||
stripePriceId={props.plan.stripePriceId}
|
||||
/>
|
||||
)}
|
||||
</If>
|
||||
</If>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturesList(
|
||||
props: React.PropsWithChildren<{
|
||||
features: string[];
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<ul className={'flex flex-col space-y-2'}>
|
||||
{props.features.map((feature) => {
|
||||
return (
|
||||
<ListItem key={feature}>
|
||||
<Trans
|
||||
i18nKey={`common:plans.features.${feature}`}
|
||||
defaults={feature}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function Price({ children }: React.PropsWithChildren) {
|
||||
// little trick to re-animate the price when switching plans
|
||||
const key = Math.random();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`duration-500 animate-in fade-in slide-in-from-left-4`}
|
||||
>
|
||||
<span className={'text-2xl font-bold lg:text-3xl xl:text-4xl'}>
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListItem({ children }: React.PropsWithChildren) {
|
||||
return (
|
||||
<li className={'flex items-center space-x-3 font-medium'}>
|
||||
<div>
|
||||
<CheckCircleIcon className={'h-5 text-green-500'} />
|
||||
</div>
|
||||
|
||||
<span className={'text-sm'}>{children}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function PlansSwitcher(
|
||||
props: React.PropsWithChildren<{
|
||||
plans: string[];
|
||||
plan: string;
|
||||
setPlan: (plan: string) => void;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<div className={'flex'}>
|
||||
{props.plans.map((plan, index) => {
|
||||
const selected = plan === props.plan;
|
||||
|
||||
const className = cn('focus:!ring-0 !outline-none', {
|
||||
'rounded-r-none border-r-transparent': index === 0,
|
||||
'rounded-l-none': index === props.plans.length - 1,
|
||||
['hover:bg-muted']: !selected,
|
||||
['font-bold hover:bg-background hover:text-initial']: selected,
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={plan}
|
||||
variant={'outline'}
|
||||
className={className}
|
||||
onClick={() => props.setPlan(plan)}
|
||||
>
|
||||
<span className={'flex items-center space-x-1'}>
|
||||
<If condition={selected}>
|
||||
<CheckCircleIcon className={'h-4 text-green-500'} />
|
||||
</If>
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={`common:plans.${plan}`} defaults={plan} />
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DefaultCheckoutButton(
|
||||
props: React.PropsWithChildren<{
|
||||
plan: PricingItemProps['plan'];
|
||||
recommended?: boolean;
|
||||
}>,
|
||||
) {
|
||||
const signUpPath = pathsConfig.auth.signUp;
|
||||
|
||||
const linkHref =
|
||||
props.plan.href ?? `${signUpPath}?utm_source=${props.plan.stripePriceId}`;
|
||||
|
||||
const label = props.plan.label ?? 'common:getStarted';
|
||||
|
||||
return (
|
||||
<div className={'bottom-0 left-0 w-full p-0'}>
|
||||
<Button
|
||||
className={'w-full'}
|
||||
variant={props.recommended ? 'default' : 'outline'}
|
||||
>
|
||||
<Link href={linkHref}>
|
||||
<Trans i18nKey={label} defaults={label} />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
packages/ui/src/makerkit/profile-avatar.tsx
Normal file
38
packages/ui/src/makerkit/profile-avatar.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@kit/ui/avatar';
|
||||
|
||||
type SessionProps = {
|
||||
displayName?: string | null;
|
||||
pictureUrl?: string | null;
|
||||
};
|
||||
|
||||
type TextProps = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
type ProfileAvatarProps = SessionProps | TextProps;
|
||||
|
||||
export function ProfileAvatar(props: ProfileAvatarProps) {
|
||||
const avatarClassName = 'mx-auto w-9 h-9 group-focus:ring-2';
|
||||
|
||||
if ('text' in props) {
|
||||
return (
|
||||
<Avatar className={avatarClassName}>
|
||||
<AvatarFallback>
|
||||
<span className={'uppercase'}>{props.text.slice(0, 2)}</span>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
const initials = props.displayName?.slice(0, 2);
|
||||
|
||||
return (
|
||||
<Avatar className={avatarClassName}>
|
||||
<AvatarImage src={props.pictureUrl ?? undefined} />
|
||||
|
||||
<AvatarFallback>
|
||||
<span className={'uppercase'}>{initials}</span>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
30
packages/ui/src/makerkit/sidebar-schema.ts
Normal file
30
packages/ui/src/makerkit/sidebar-schema.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const SidebarConfigSchema = z.object({
|
||||
routes: z.array(
|
||||
z.union([
|
||||
z.object({
|
||||
label: z.string(),
|
||||
path: z.string(),
|
||||
Icon: z.custom<React.ReactNode>(),
|
||||
end: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
label: z.string(),
|
||||
collapsible: z.boolean().optional(),
|
||||
collapsed: z.boolean().optional(),
|
||||
children: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
path: z.string(),
|
||||
Icon: z.custom<React.ReactNode>(),
|
||||
end: z.boolean().optional(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
z.object({
|
||||
divider: z.literal(true),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
});
|
||||
264
packages/ui/src/makerkit/sidebar.tsx
Normal file
264
packages/ui/src/makerkit/sidebar.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
'use client';
|
||||
|
||||
import { useContext, useId, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { ChevronDownIcon } from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '../shadcn/tooltip';
|
||||
import { cn } from '../utils';
|
||||
import { SidebarContext } from './context/sidebar.context';
|
||||
import { If } from './if';
|
||||
import isRouteActive from './is-route-active';
|
||||
import { SidebarConfigSchema } from './sidebar-schema';
|
||||
import { Trans } from './trans';
|
||||
|
||||
export type SidebarConfig = z.infer<typeof SidebarConfigSchema>;
|
||||
|
||||
export function Sidebar(props: {
|
||||
collapsed?: boolean;
|
||||
children:
|
||||
| React.ReactNode
|
||||
| ((props: {
|
||||
collapsed: boolean;
|
||||
setCollapsed: (collapsed: boolean) => void;
|
||||
}) => React.ReactNode);
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(props.collapsed ?? false);
|
||||
|
||||
const className = getClassNameBuilder()({
|
||||
collapsed,
|
||||
});
|
||||
|
||||
const ctx = { collapsed, setCollapsed };
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={ctx}>
|
||||
<div className={className}>
|
||||
{typeof props.children === 'function'
|
||||
? props.children(ctx)
|
||||
: props.children}
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarContent({
|
||||
children,
|
||||
className,
|
||||
}: React.PropsWithChildren<{
|
||||
className?: string;
|
||||
}>) {
|
||||
return (
|
||||
<div className={cn('flex w-full flex-col space-y-1.5 px-4', 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-xs font-semibold uppercase text-muted-foreground'}>
|
||||
{props.children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const Wrapper = () => {
|
||||
const className = cn(
|
||||
'group flex items-center justify-between px-container 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}>
|
||||
<ChevronDownIcon
|
||||
className={cn(`h-3 transition duration-300`, {
|
||||
'rotate-180': !isGroupCollapsed,
|
||||
})}
|
||||
/>
|
||||
</If>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Title>{label}</Title>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-1 py-1'}>
|
||||
<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;
|
||||
}>) {
|
||||
const { collapsed } = useContext(SidebarContext);
|
||||
|
||||
const currentPath = usePathname() ?? '';
|
||||
const active = isRouteActive(path, currentPath, end ? 0 : 3);
|
||||
const variant = active ? 'default' : 'ghost';
|
||||
const size = collapsed ? 'icon' : 'default';
|
||||
|
||||
return (
|
||||
<Link key={path} href={path}>
|
||||
<Button
|
||||
className={cn('flex w-full', {
|
||||
'justify-start space-x-2': !collapsed,
|
||||
})}
|
||||
size={size}
|
||||
variant={variant}
|
||||
>
|
||||
<If condition={collapsed} fallback={Icon}>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>{Icon}</TooltipTrigger>
|
||||
|
||||
<TooltipContent side={'right'} sideOffset={20}>
|
||||
{children}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</If>
|
||||
|
||||
<span
|
||||
className={cn({
|
||||
hidden: collapsed,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function getClassNameBuilder() {
|
||||
return cva(
|
||||
['flex box-content h-screen flex-col border-r border-border relative'],
|
||||
{
|
||||
variants: {
|
||||
collapsed: {
|
||||
true: `w-[6rem]`,
|
||||
false: `w-2/12 lg:w-[17rem]`,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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) => {
|
||||
return (
|
||||
<SidebarItem
|
||||
key={child.path}
|
||||
end={child.end}
|
||||
path={child.path}
|
||||
Icon={child.Icon}
|
||||
>
|
||||
<Trans i18nKey={child.label} defaults={child.label} />
|
||||
</SidebarItem>
|
||||
);
|
||||
})}
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarItem
|
||||
key={item.path}
|
||||
end={item.end}
|
||||
path={item.path}
|
||||
Icon={item.Icon}
|
||||
>
|
||||
<Trans i18nKey={item.label} defaults={item.label} />
|
||||
</SidebarItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
34
packages/ui/src/makerkit/spinner.tsx
Normal file
34
packages/ui/src/makerkit/spinner.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
function Spinner(
|
||||
props: React.PropsWithChildren<{
|
||||
className?: string;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<div role="status">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
`h-8 w-8 animate-spin fill-white text-primary dark:fill-primary dark:text-primary/30`,
|
||||
props.className,
|
||||
)}
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export default Spinner;
|
||||
168
packages/ui/src/makerkit/stepper.tsx
Normal file
168
packages/ui/src/makerkit/stepper.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
'use client';
|
||||
|
||||
import { Fragment, useCallback } from 'react';
|
||||
|
||||
import { cva } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '../utils/cn';
|
||||
import { If } from './if';
|
||||
import { Trans } from './trans';
|
||||
|
||||
type Variant = 'numbers' | 'default';
|
||||
|
||||
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').
|
||||
**/
|
||||
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 className = classNameBuilder({
|
||||
selected,
|
||||
variant,
|
||||
});
|
||||
|
||||
const isNumberVariant = variant === 'numbers';
|
||||
|
||||
const labelClassName = cn({
|
||||
['text-xs px-1.5 py-2']: !isNumberVariant,
|
||||
});
|
||||
|
||||
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}>{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({
|
||||
['flex justify-between']: variant === 'numbers',
|
||||
['flex space-x-1']: variant === 'default',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
<Steps />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Stepper;
|
||||
|
||||
function getClassNameBuilder() {
|
||||
return cva(``, {
|
||||
variants: {
|
||||
variant: {
|
||||
default: `flex flex-col h-[2.5px] w-full transition-colors duration-500`,
|
||||
numbers:
|
||||
'w-9 h-9 font-bold rounded-full flex items-center justify-center' +
|
||||
' text-sm border',
|
||||
},
|
||||
selected: {
|
||||
true: '',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
variant: 'default',
|
||||
selected: false,
|
||||
className: 'text-gray-400 dark:text-gray-500',
|
||||
},
|
||||
{
|
||||
variant: 'default',
|
||||
selected: true,
|
||||
className: 'bg-primary',
|
||||
},
|
||||
{
|
||||
variant: 'default',
|
||||
selected: false,
|
||||
className: 'bg-gray-300 dark:bg-gray-800',
|
||||
},
|
||||
{
|
||||
variant: 'numbers',
|
||||
selected: true,
|
||||
className: 'text-primary border-primary',
|
||||
},
|
||||
{
|
||||
variant: 'numbers',
|
||||
selected: false,
|
||||
className: 'text-gray-400 dark:text-gray-500',
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
selected: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function StepDivider({
|
||||
selected,
|
||||
children,
|
||||
}: React.PropsWithChildren<{
|
||||
selected: boolean;
|
||||
}>) {
|
||||
const spanClassName = cn('font-medium text-sm', {
|
||||
['text-gray-400 dark:text-gray-500']: !selected,
|
||||
['text-primary']: selected,
|
||||
});
|
||||
|
||||
const className = cn(
|
||||
'flex flex-1 last:flex-[0_0_0] items-center h-9 justify-center' +
|
||||
' items-center w-full group px-3 flex space-x-3',
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<span className={spanClassName}>{children}</span>
|
||||
|
||||
<div
|
||||
className={
|
||||
'divider h-[1px] w-full bg-gray-200 transition-colors' +
|
||||
' dark:bg-dark-600 group-last:hidden'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStepLabel(labelOrKey: string, index: number) {
|
||||
const number = (index + 1).toString();
|
||||
|
||||
return {
|
||||
number,
|
||||
label: <Trans i18nKey={labelOrKey} defaults={labelOrKey} />,
|
||||
};
|
||||
}
|
||||
38
packages/ui/src/makerkit/top-loading-bar-indicator.tsx
Normal file
38
packages/ui/src/makerkit/top-loading-bar-indicator.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { createRef, useEffect, useRef } from 'react';
|
||||
|
||||
import type { LoadingBarRef } from 'react-top-loading-bar';
|
||||
import LoadingBar from 'react-top-loading-bar';
|
||||
|
||||
export function TopLoadingBarIndicator() {
|
||||
const ref = createRef<LoadingBarRef>();
|
||||
const runningRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current || runningRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadingBarRef = ref.current;
|
||||
|
||||
loadingBarRef.continuousStart(0, 250);
|
||||
runningRef.current = true;
|
||||
|
||||
return () => {
|
||||
loadingBarRef.complete();
|
||||
runningRef.current = false;
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
return (
|
||||
<LoadingBar
|
||||
height={4}
|
||||
waitingTime={0}
|
||||
shadow
|
||||
className={'bg-primary'}
|
||||
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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user