This commit is contained in:
giancarlo
2024-03-24 02:23:22 +08:00
parent 648d77b430
commit bce3479368
589 changed files with 37067 additions and 9596 deletions

View File

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

View File

@@ -0,0 +1,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],
);
}

View 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)} />;
}

View 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;
}
}

View 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>
</>
);
}

View 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;

View File

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

View File

@@ -0,0 +1,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;

View 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>
);
}

View 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;
}

View 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;

View File

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

View File

@@ -0,0 +1,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>
);
}

View 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;

View 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;
}

View 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>
);
}

View 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;

View 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;

View 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;

View 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: ``,
},
},
},
);
}

View File

@@ -0,0 +1,5 @@
import { createContext } from 'react';
import type { NavigationMenuProps } from './navigation-menu';
export const NavigationMenuContext = createContext<NavigationMenuProps>({});

View 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`,
},
},
});
}

View 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);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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),
}),
]),
),
});

View 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>
);
})}
</>
);
}

View 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;

View 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} />,
};
}

View 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}
/>
);
}

View File

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