🎉 chore(init): project initialization

This commit is contained in:
web@ppanel
2024-11-14 01:22:43 +07:00
commit 829edfa824
479 changed files with 61413 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
'use client';
import { Button } from '@shadcn/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@shadcn/ui/command';
import { cn } from '@shadcn/ui/lib/utils';
import { Popover, PopoverContent, PopoverTrigger } from '@shadcn/ui/popover';
import { BoxIcon, CheckIcon, ChevronsUpDownIcon } from 'lucide-react';
import * as React from 'react';
export type Option<T = string> = {
value: T;
label: string;
};
// Conditional types to determine the value type for onChange
type OnChangeType<T, M extends boolean> = M extends true ? T[] : T;
type ComboboxProps<T = string, M extends boolean = false> = {
multiple?: M;
options?: Option<T>[];
placeholder?: string;
value?: OnChangeType<T, M>;
onChange: (value: OnChangeType<T, M>) => void;
className?: string;
};
export function Combobox<T, M extends boolean = false>({
multiple = false as M,
options = [],
placeholder = 'Select...',
value,
onChange,
className,
}: ComboboxProps<T, M>) {
const [open, setOpen] = React.useState(false);
const handleSelect = (selectedValue: T) => {
if (multiple) {
const newValue = Array.isArray(value) ? [...value] : [];
if (newValue.includes(selectedValue)) {
newValue.splice(newValue.indexOf(selectedValue), 1);
onChange(newValue as OnChangeType<T, M>);
} else {
onChange([...newValue, selectedValue] as OnChangeType<T, M>);
}
} else {
const newValue = selectedValue === value ? ('' as T) : selectedValue;
onChange(newValue as OnChangeType<T, M>);
setOpen(false);
}
};
const renderButtonLabel = () => {
if (multiple && Array.isArray(value) && value.length > 0) {
const selectedLabels = options
.filter((option) => value.includes(option.value))
.map((option) => option.label)
.join(', ');
return selectedLabels;
} else if (!multiple) {
const selectedOption = options.find((option) => option.value === value);
return selectedOption ? selectedOption.label : placeholder;
}
return placeholder;
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className={cn('w-full items-center justify-between', className)}
>
<span className='truncate'>{renderButtonLabel()}</span>
<ChevronsUpDownIcon className='ml-2 size-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-fit p-0' align='start'>
<Command>
<CommandInput placeholder='Search...' className='h-9' />
<CommandEmpty>
<BoxIcon />
</CommandEmpty>
<CommandGroup>
<CommandList>
{options.map((option) => (
<CommandItem
key={String(option.label)}
value={option.label}
onSelect={() => handleSelect(option.value)}
>
{option.label}
<CheckIcon
className={cn(
'ml-auto h-4 w-4',
multiple
? Array.isArray(value) && value.includes(option.value)
? 'opacity-100'
: 'opacity-0'
: value === option.value
? 'opacity-100'
: 'opacity-0',
)}
/>
</CommandItem>
))}
</CommandList>
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
);
}
+48
View File
@@ -0,0 +1,48 @@
'use client';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@shadcn/ui/alert-dialog';
import React, { ReactNode } from 'react';
interface ConfirmationButtonProps {
trigger: ReactNode;
title: string;
description: string;
onConfirm: () => void | Promise<void>;
cancelText?: string;
confirmText?: string;
}
export const ConfirmButton: React.FC<ConfirmationButtonProps> = ({
trigger,
title,
description,
onConfirm,
cancelText = 'Cancel',
confirmText = 'Confirm',
}) => {
return (
<AlertDialog>
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{cancelText}</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>{confirmText}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
+46
View File
@@ -0,0 +1,46 @@
'use client';
import { CalendarIcon } from '@radix-ui/react-icons';
import { Button } from '@shadcn/ui/button';
import { Calendar, CalendarProps } from '@shadcn/ui/calendar';
import { intlFormat } from '@shadcn/ui/lib/date-fns';
import { cn } from '@shadcn/ui/lib/utils';
import { Popover, PopoverContent, PopoverTrigger } from '@shadcn/ui/popover';
import * as React from 'react';
export function DatePicker({
placeholder,
value,
onChange,
...props
}: CalendarProps & {
placeholder?: string;
value?: number;
onChange?: (value?: number) => void;
}) {
const [date, setDate] = React.useState<Date | undefined>(value ? new Date(value) : undefined);
const handleSelect = (selectedDate: Date | undefined) => {
setDate(selectedDate);
if (onChange) {
onChange(selectedDate?.getTime());
}
};
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
className={cn('w-full justify-between font-normal', !value && 'text-muted-foreground')}
>
{value ? intlFormat(value) : <span>{placeholder}</span>}
<CalendarIcon className='size-4' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-auto p-0' align='start'>
<Calendar {...props} mode='single' selected={date} onSelect={handleSelect} initialFocus />
</PopoverContent>
</Popover>
);
}
+145
View File
@@ -0,0 +1,145 @@
import { Button } from '@shadcn/ui/button';
import { CircleMinusIcon, CirclePlusIcon } from 'lucide-react';
import { useState } from 'react';
import { Combobox } from './combobox';
import { EnhancedInput } from './enhanced-input';
interface FieldConfig {
name: string;
type: 'text' | 'number' | 'select';
placeholder?: string;
prefix?: string;
suffix?: string;
min?: number;
max?: number;
options?: { label: string; value: string }[];
}
interface ObjectInputProps<T> {
value: T;
onChange: (value: T) => void;
fields: FieldConfig[];
}
export function ObjectInput<T extends Record<string, any>>({
value,
onChange,
fields,
}: ObjectInputProps<T>) {
const updateField = (key: keyof T, fieldValue: string | number) => {
onChange({ ...value, [key]: fieldValue });
};
return (
<div className='flex flex-1 gap-4'>
{fields.map(({ name, type, options, ...fieldProps }) => (
<div key={name} className='flex-1'>
{type === 'select' && options ? (
<Combobox<string, false>
placeholder={fieldProps.placeholder}
options={options}
value={value[name]}
onChange={(fieldValue) => {
updateField(name, fieldValue);
}}
/>
) : (
<EnhancedInput
value={value[name]}
onValueChange={(fieldValue) => updateField(name, fieldValue)}
type={type}
{...fieldProps}
/>
)}
</div>
))}
</div>
);
}
interface ArrayInputProps<T> {
value?: T[];
onChange: (value: T[]) => void;
fields: FieldConfig[];
}
export function ArrayInput<T extends Record<string, any>>({
value = [],
onChange,
fields,
}: ArrayInputProps<T>) {
const initializeDefaultItem = (): T =>
fields.reduce((acc, field) => {
acc[field.name as keyof T] = undefined as T[keyof T];
return acc;
}, {} as T);
const [displayItems, setDisplayItems] = useState<T[]>(() => {
return value.length > 0 ? value : [initializeDefaultItem()];
});
const isItemModified = (item: T): boolean =>
fields.some((field) => {
const val = item[field.name];
return val !== undefined && val !== null && val !== '';
});
const handleItemChange = (index: number, updatedItem: T) => {
const newDisplayItems = [...displayItems];
newDisplayItems[index] = updatedItem;
setDisplayItems(newDisplayItems);
const modifiedItems = newDisplayItems.filter(isItemModified);
onChange(modifiedItems);
};
const createField = () => {
setDisplayItems([...displayItems, initializeDefaultItem()]);
};
const deleteField = (index: number) => {
const newDisplayItems = displayItems.filter((_, i) => i !== index);
setDisplayItems(newDisplayItems);
const modifiedItems = newDisplayItems.filter(isItemModified);
onChange(modifiedItems);
};
return (
<div className='flex flex-col gap-4'>
{displayItems.map((item, index) => (
<div key={index} className='flex items-center gap-4'>
<ObjectInput
value={item}
onChange={(updatedItem) => handleItemChange(index, updatedItem)}
fields={fields}
/>
<div className='flex min-w-20 items-center'>
{displayItems.length > 1 && (
<Button
variant='ghost'
size='icon'
type='button'
className='text-destructive p-0 text-lg'
onClick={() => deleteField(index)}
>
<CircleMinusIcon />
</Button>
)}
{index === displayItems.length - 1 && (
<Button
variant='ghost'
size='icon'
type='button'
className='text-primary p-0 text-lg'
onClick={createField}
>
<CirclePlusIcon />
</Button>
)}
</div>
</div>
))}
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
'use client';
import { useEffect, useRef } from 'react';
import { MonacoEditor, MonacoEditorProps } from './monaco-editor';
export function HTMLEditor(props: MonacoEditorProps) {
return (
<MonacoEditor
title='HTML Editor'
description='Support HTML'
{...props}
language='markdown'
render={(value) => <HTMLPreview value={value} />}
/>
);
}
interface HTMLPreviewProps {
value?: string;
}
function HTMLPreview({ value }: HTMLPreviewProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
useEffect(() => {
const iframeDocument = iframeRef.current?.contentDocument;
if (iframeDocument) {
iframeDocument.open();
iframeDocument.write(value || '');
iframeDocument.close();
}
}, [value]);
return <iframe ref={iframeRef} title='HTML Preview' className='h-full w-full border-0' />;
}
+3
View File
@@ -0,0 +1,3 @@
export { HTMLEditor } from './html';
export { JSONEditor } from './json';
export { MarkdownEditor } from './markdown';
+68
View File
@@ -0,0 +1,68 @@
'use client';
import { useMemo } from 'react';
import { MonacoEditor, MonacoEditorProps } from './monaco-editor';
interface JSONEditorProps extends Omit<MonacoEditorProps, 'placeholder'> {
schema?: Record<string, unknown>;
placeholder?: Record<string, unknown>;
}
export function JSONEditor(props: JSONEditorProps) {
const { schema, placeholder = {}, ...rest } = props;
const editorKey = useMemo(() => JSON.stringify({ schema, placeholder }), [schema, placeholder]);
return (
<MonacoEditor
key={editorKey}
title='Edit JSON'
{...rest}
placeholder={JSON.stringify(placeholder, null, 2)}
language='json'
onMount={(editor, monaco) => {
if (props.onMount) props.onMount(editor, monaco);
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
schemas: [
{
uri: '',
fileMatch: ['*'],
schema: schema || {
type: 'object',
properties: generateSchema(placeholder),
},
},
],
});
}}
/>
);
}
const generateSchema = (obj: Record<string, unknown>): Record<string, SchemaProperty> => {
const properties: Record<string, SchemaProperty> = {};
for (const [key, value] of Object.entries(obj)) {
if (Array.isArray(value)) {
properties[key] = {
type: 'array',
items: value.length > 0 ? generateSchema({ item: value[0] }).item : { type: 'null' },
};
} else if (typeof value === 'object' && value !== null) {
properties[key] = {
type: 'object',
properties: generateSchema(value as Record<string, unknown>),
};
} else {
properties[key] = { type: typeof value as SchemaType };
}
}
return properties;
};
type SchemaType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'null';
interface SchemaProperty {
type: SchemaType;
items?: SchemaProperty;
properties?: Record<string, SchemaProperty>;
}
+16
View File
@@ -0,0 +1,16 @@
'use client';
import { Markdown } from '../markdown';
import { MonacoEditor, MonacoEditorProps } from './monaco-editor';
export function MarkdownEditor(props: MonacoEditorProps) {
return (
<MonacoEditor
title='Markdown Editor'
description='Support markdwon and html syntax'
{...props}
language='markdown'
render={(value) => <Markdown>{value || ''}</Markdown>}
/>
);
}
+140
View File
@@ -0,0 +1,140 @@
'use client';
import Editor, { OnMount } from '@monaco-editor/react';
import { Button } from '@shadcn/ui/button';
import { cn } from '@shadcn/ui/lib/utils';
import { useSize } from 'ahooks';
import { EyeIcon, EyeOff, FullscreenIcon, MinimizeIcon } from 'lucide-react';
import { useRef, useState } from 'react';
export interface MonacoEditorProps {
value?: string;
onChange?: (value: string | undefined) => void;
onBlur?: (value: string | undefined) => void;
title?: string;
description?: string;
placeholder?: string;
render?: (value?: string) => React.ReactNode;
onMount?: OnMount;
language?: string;
className?: string;
}
export function MonacoEditor({
value,
onChange,
onBlur,
title = 'Editor Title',
description,
placeholder = 'Start typing...',
render,
onMount,
language = 'markdown',
className,
}: MonacoEditorProps) {
const [isFullscreen, setIsFullscreen] = useState(false);
const [isPreviewVisible, setIsPreviewVisible] = useState(false);
const toggleFullscreen = () => setIsFullscreen(!isFullscreen);
const togglePreview = () => setIsPreviewVisible(!isPreviewVisible);
const handleEditorDidMount: OnMount = (editor, monaco) => {
if (onMount) onMount(editor, monaco);
editor.onDidBlurEditorWidget(() => {
if (onBlur) {
onBlur(editor.getValue());
}
});
};
const ref = useRef<HTMLDivElement>(null);
const size = useSize(ref);
return (
<div ref={ref} className='size-full'>
<div style={size}>
<div
className={cn('flex size-full min-h-96 flex-col rounded-md border', className, {
'bg-background fixed inset-0 z-50 !mt-0 h-screen': isFullscreen,
})}
>
<div className='flex items-center justify-between border-b p-2'>
<div>
<h1 className='text-left text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'>
{title}
</h1>
<p className='text-muted-foreground text-[0.8rem]'>{description}</p>
</div>
<div className='flex items-center space-x-2'>
{render && (
<Button variant='outline' size='icon' type='button' onClick={togglePreview}>
{isPreviewVisible ? <EyeOff /> : <EyeIcon />}
</Button>
)}
<Button variant='outline' size='icon' type='button' onClick={toggleFullscreen}>
{isFullscreen ? <MinimizeIcon /> : <FullscreenIcon />}
</Button>
</div>
</div>
<div className={cn('relative flex flex-1 overflow-hidden')}>
<div
className={cn('flex-1 overflow-hidden p-4 invert dark:invert-0', {
'w-1/2': isPreviewVisible,
})}
>
<Editor
language={language}
value={value}
onChange={onChange}
onMount={handleEditorDidMount}
className=''
options={{
automaticLayout: true,
contextmenu: false,
folding: false,
fontSize: 14,
formatOnPaste: true,
formatOnType: true,
glyphMargin: false,
lineNumbers: 'off',
minimap: { enabled: false },
overviewRulerLanes: 0,
renderLineHighlight: 'none',
scrollBeyondLastLine: false,
scrollbar: {
useShadows: false,
vertical: 'hidden',
},
tabSize: 2,
wordWrap: 'off',
}}
theme='transparentTheme'
beforeMount={(monaco) => {
monaco.editor.defineTheme('transparentTheme', {
base: 'vs-dark',
inherit: true,
rules: [],
colors: {
'editor.background': '#00000000',
},
});
}}
/>
{!value && placeholder && (
<pre
className='text-muted-foreground pointer-events-none absolute left-7 top-4 text-sm'
style={{ userSelect: 'none' }}
>
{placeholder}
</pre>
)}
</div>
{render && isPreviewVisible && (
<div className='w-1/2 flex-1 overflow-auto border-l p-4'>{render(value)}</div>
)}
</div>
</div>
</div>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
export default function Empty() {
return (
<div className='flex flex-col items-center justify-center p-6 py-16'>
<svg
width='64'
height='41'
viewBox='0 0 64 41'
xmlns='http://www.w3.org/2000/svg'
fill='currentColor'
stroke='currentColor'
className='text-background'
>
<g transform='translate(0 1)' fill='none' fillRule='evenodd'>
<ellipse cx='32' cy='33' rx='32' ry='7' fill='currentColor' opacity={0.8}></ellipse>
<g fillRule='nonzero' stroke='#d9d9d9'>
<path d='M55 12.76L44.854 1.258C44.367.474 43.656 0 42.907 0H21.093c-.749 0-1.46.474-1.947 1.257L9 12.761V22h46v-9.24z'></path>
<path
d='M41.613 15.931c0-1.605.994-2.93 2.227-2.931H55v18.137C55 33.26 53.68 35 52.05 35h-40.1C10.32 35 9 33.259 9 31.137V13h11.16c1.233 0 2.227 1.323 2.227 2.928v.022c0 1.605 1.005 2.901 2.237 2.901h14.752c1.232 0 2.237-1.308 2.237-2.913v-.007z'
fill='currentColor'
></path>
</g>
</g>
</svg>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
import { Input } from '@shadcn/ui/input';
import { cn } from '@shadcn/ui/lib/utils';
import { ChangeEvent, ReactNode, useEffect, useState } from 'react';
interface EnhancedInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'prefix'> {
prefix?: ReactNode;
suffix?: ReactNode;
formatInput?: (value: string | number) => string;
formatOutput?: (value: string | number) => string | number;
onValueChange?: (value: string | number) => void;
onValueBlur?: (value: string | number) => void;
min?: number;
max?: number;
}
export function EnhancedInput({
suffix,
prefix,
formatInput,
formatOutput,
value: initialValue,
className,
onValueChange,
onValueBlur,
...props
}: EnhancedInputProps) {
const getProcessedValue = (inputValue: unknown) => {
const newValue = String(inputValue ?? '');
return formatInput ? formatInput(newValue) : newValue;
};
const [value, setValue] = useState<string>(() => getProcessedValue(initialValue));
useEffect(() => {
if (initialValue !== value) {
const newValue = getProcessedValue(initialValue);
if (value !== newValue) setValue(newValue);
}
}, [initialValue, formatInput]);
const processValue = (inputValue: string) => {
let processedValue: number | string = inputValue?.trim();
if (processedValue && props.type === 'number') processedValue = Number(processedValue);
return formatOutput ? formatOutput(processedValue) : processedValue;
};
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
let inputValue = e.target.value;
if (props.type === 'number') {
const numericValue = Number(inputValue);
if (!isNaN(numericValue)) {
const min = Number.isFinite(props.min) ? props.min : -Infinity;
const max = Number.isFinite(props.max) ? props.max : Infinity;
inputValue = String(Math.max(min!, Math.min(max!, numericValue)));
}
}
setValue(inputValue);
const outputValue = processValue(inputValue);
onValueChange?.(outputValue);
};
const handleBlur = () => {
const outputValue = processValue(value);
if ((initialValue || '') !== outputValue) {
onValueBlur?.(outputValue);
}
};
return (
<div className={cn('border-input flex w-full items-center rounded-md border', className)}>
{prefix && <div className='bg-muted mr-px flex h-9 items-center px-3'>{prefix}</div>}
<Input
{...props}
value={value}
className='border-none'
onChange={handleChange}
onBlur={handleBlur}
/>
{suffix && <div className='bg-muted ml-px flex h-9 items-center px-3'>{suffix}</div>}
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { BorderBeam } from '@shadcn/ui/border-beam';
import Ripple from '@shadcn/ui/ripple';
import { LoadingIcon } from './lotties';
export function Loading() {
return (
<div className='relative flex size-full items-end justify-center overflow-hidden'>
<BorderBeam />
<Ripple />
<LoadingIcon className='my-24 w-64' />
</div>
);
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+58
View File
@@ -0,0 +1,58 @@
'use client';
import Lottie, { LottieComponentProps } from 'lottie-react';
import gift from './gift.json';
import globalMap from './global-map.json';
import loading from './loading.json';
import locations from './locations.json';
import login from './login.json';
import moon from './moon.json';
import networkSecurity from './network-security.json';
import rocket from './rocket.json';
import servers from './servers.json';
import sun from './sun.json';
import users from './users.json';
export function RocketLoadingIcon(props: Omit<LottieComponentProps, 'animationData'>) {
return <Lottie {...props} loop animationData={rocket} />;
}
export function LoadingIcon(props: Omit<LottieComponentProps, 'animationData'>) {
return <Lottie {...props} loop animationData={loading} />;
}
export function SunIcon(props: Omit<LottieComponentProps, 'animationData'>) {
return <Lottie {...props} loop animationData={sun} />;
}
export function MoonIcon(props: Omit<LottieComponentProps, 'animationData'>) {
return <Lottie {...props} loop animationData={moon} />;
}
export function NetworkSecurityIcon(props: Omit<LottieComponentProps, 'animationData'>) {
return <Lottie {...props} loop animationData={networkSecurity} />;
}
export function UsersIcon(props: Omit<LottieComponentProps, 'animationData'>) {
return <Lottie {...props} loop animationData={users} />;
}
export function LocationsIcon(props: Omit<LottieComponentProps, 'animationData'>) {
return <Lottie {...props} loop animationData={locations} />;
}
export function ServersIcon(props: Omit<LottieComponentProps, 'animationData'>) {
return <Lottie {...props} loop animationData={servers} />;
}
export function GlobalMapIcon(props: Omit<LottieComponentProps, 'animationData'>) {
return <Lottie {...props} loop animationData={globalMap} />;
}
export function GiftIcon(props: Omit<LottieComponentProps, 'animationData'>) {
return <Lottie {...props} loop animationData={gift} />;
}
export function LoginIcon(props: Omit<LottieComponentProps, 'animationData'>) {
return <Lottie {...props} loop animationData={login} />;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+209
View File
@@ -0,0 +1,209 @@
'use client';
import { Button } from '@shadcn/ui/button';
import { cn } from '@shadcn/ui/lib/utils';
import { ScrollArea } from '@shadcn/ui/scroll-area';
import 'katex/dist/katex.min.css';
import { Check, Copy } from 'lucide-react';
import { useCallback, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
import rehypeKatex from 'rehype-katex';
import rehypeRaw from 'rehype-raw';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import remarkToc from 'remark-toc';
interface CodeBlockProps {
className?: string;
children?: React.ReactNode;
[key: string]: unknown;
}
function CodeBlock({ className, children, dark, ...props }: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const match = className?.startsWith('language-') ? /language-(\w+)/.exec(className) : null;
const handleCopy = useCallback((text: string) => {
navigator.clipboard
.writeText(text)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 3000);
})
.catch(() => {
alert('Failed to copy text. Please try again.');
});
}, []);
if (match) {
return (
<div className='group relative w-full'>
<div className='bg-muted flex items-center justify-between gap-4 rounded-t-lg px-4 py-2 text-sm font-semibold'>
<span className='lowercase [&>span]:text-xs'>{match[1]}</span>
<Button
variant='ghost'
size='sm'
onClick={() => handleCopy(String(children).replace(/\n$/, ''))}
className='absolute right-2 top-2 z-20 opacity-0 transition-opacity duration-200 group-hover:opacity-100'
>
{copied ? <Check size={16} /> : <Copy size={16} />}
</Button>
</div>
<ScrollArea className='max-h-96 w-full overflow-auto'>
<SyntaxHighlighter
{...props}
PreTag='div'
language={match[1]}
style={dark ? oneDark : oneLight}
showLineNumbers
customStyle={{
margin: 0,
}}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
</ScrollArea>
</div>
);
}
return (
<code {...props} className={cn(className, 'bg-muted rounded border font-semibold')}>
{children}
</code>
);
}
interface MarkdownProps {
children: string;
dark?: false;
}
export function Markdown({ children, dark }: MarkdownProps) {
return (
<ReactMarkdown
className='prose dark:prose-invert w-full max-w-[unset] break-words'
remarkPlugins={[remarkGfm, remarkToc, remarkMath]}
rehypePlugins={[rehypeRaw, rehypeKatex]}
components={{
h1: ({ node, className, ...props }) => (
<h1
className={cn(
'mb-8 scroll-m-20 text-4xl font-extrabold tracking-tight last:mb-0',
className,
)}
{...props}
/>
),
h2: ({ node, className, ...props }) => (
<h2
className={cn(
'mb-4 mt-8 scroll-m-20 text-3xl font-semibold tracking-tight first:mt-0 last:mb-0',
className,
)}
{...props}
/>
),
h3: ({ node, className, ...props }) => (
<h3
className={cn(
'mb-4 mt-6 scroll-m-20 text-2xl font-semibold tracking-tight first:mt-0 last:mb-0',
className,
)}
{...props}
/>
),
h4: ({ node, className, ...props }) => (
<h4
className={cn(
'mb-4 mt-6 scroll-m-20 text-xl font-semibold tracking-tight first:mt-0 last:mb-0',
className,
)}
{...props}
/>
),
h5: ({ node, className, ...props }) => (
<h5
className={cn('my-4 text-lg font-semibold first:mt-0 last:mb-0', className)}
{...props}
/>
),
h6: ({ node, className, ...props }) => (
<h6 className={cn('my-4 font-semibold first:mt-0 last:mb-0', className)} {...props} />
),
p: ({ node, className, ...props }) => (
<p className={cn('mb-5 mt-5 leading-7 first:mt-0 last:mb-0', className)} {...props} />
),
a: ({ node, className, ...props }) => (
<a
target='_blank'
className={cn('text-primary font-medium underline underline-offset-4', className)}
{...props}
/>
),
blockquote: ({ node, className, ...props }) => (
<blockquote className={cn('border-l-2 pl-6 italic', className)} {...props} />
),
ul: ({ node, className, ...props }) => (
<ul className={cn('my-5 ml-6 list-disc [&>li]:mt-2', className)} {...props} />
),
ol: ({ node, className, ...props }) => (
<ol className={cn('my-5 ml-6 list-decimal [&>li]:mt-2', className)} {...props} />
),
hr: ({ node, className, ...props }) => (
<hr className={cn('my-5 border-b', className)} {...props} />
),
table: ({ node, className, ...props }) => (
<table
className={cn(
'my-5 w-full border-separate border-spacing-0 overflow-y-auto',
className,
)}
{...props}
/>
),
th: ({ node, className, ...props }) => (
<th
className={cn(
'bg-muted px-4 py-2 text-left font-bold first:rounded-tl-lg last:rounded-tr-lg [&[align=center]]:text-center [&[align=right]]:text-right',
className,
)}
{...props}
/>
),
td: ({ node, className, ...props }) => (
<td
className={cn(
'border-b border-l px-4 py-2 text-left last:border-r [&[align=center]]:text-center [&[align=right]]:text-right',
className,
)}
{...props}
/>
),
tr: ({ node, className, ...props }) => (
<tr
className={cn(
'm-0 border-b p-0 first:border-t [&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg',
className,
)}
{...props}
/>
),
sup: ({ node, className, ...props }) => (
<sup className={cn('[&>a]:text-xs [&>a]:no-underline', className)} {...props} />
),
pre: ({ node, className, ...props }) => (
<pre className={cn('overflow-x-auto rounded-b-lg p-0', className)} {...props} />
),
code(props) {
return <CodeBlock {...(props as CodeBlockProps)} dark={dark} />;
},
}}
>
{children}
</ReactMarkdown>
);
}
@@ -0,0 +1,58 @@
'use client';
import { Input } from '@shadcn/ui/input';
import { Table } from '@tanstack/react-table';
import { Combobox } from '../combobox';
export interface IParams {
key: string;
placeholder?: string;
options?: { label: string; value: string }[];
}
interface ColumnFilterProps<TData> {
table: Table<TData>;
params: IParams[];
filters?: any;
}
export function ColumnFilter<TData>({ table, params, filters }: ColumnFilterProps<TData>) {
const updateFilter = (key: string, value: any) => {
table.setColumnFilters((prev) => {
const newFilters = prev.filter((filter) => filter.id !== key);
if (value) {
newFilters.push({ id: key, value });
}
return newFilters;
});
};
return (
<div className='flex gap-2'>
{params.map((param) => {
if (param.options) {
return (
<Combobox
key={param.key}
className='w-32'
placeholder={param.placeholder || 'Choose...'}
value={filters[param.key] || ''}
onChange={(value) => {
updateFilter(param.key, value);
}}
options={param.options}
/>
);
}
return (
<Input
key={param.key}
className='w-32'
placeholder={param.placeholder || 'Search...'}
value={filters[param.key] || ''}
onChange={(event) => updateFilter(param.key, event.target.value)}
/>
);
})}
</div>
);
}
+1
View File
@@ -0,0 +1 @@
export { ProList, type ProListActions, type ProListProps } from './pro-list';
+102
View File
@@ -0,0 +1,102 @@
import {
ChevronLeftIcon,
ChevronRightIcon,
DoubleArrowLeftIcon,
DoubleArrowRightIcon,
} from '@radix-ui/react-icons';
import { Button } from '@shadcn/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@shadcn/ui/select';
import { Table } from '@tanstack/react-table';
interface PaginationText {
textRowsPerPage?: string;
textPageOf?: (pageIndex: number, pageCount: number) => string;
}
interface PaginationProps<TData> {
table: Table<TData>;
text?: PaginationText;
}
export function Pagination<TData>({ table, text }: PaginationProps<TData>) {
return (
<div className='flex flex-col items-center justify-between gap-2 sm:flex-row'>
<div className='text-muted-foreground flex-1 whitespace-nowrap text-center text-sm sm:text-left'>
{text?.textPageOf?.(table.getState().pagination.pageIndex + 1, table.getPageCount()) ||
`Page ${table.getState().pagination.pageIndex + 1} of ${table.getPageCount()}`}
</div>
<div className='m-auto flex items-center gap-2'>
<div className='flex items-center space-x-2'>
<p className='font-medium'>{text?.textRowsPerPage || 'Rows per page'}</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
>
<SelectTrigger className='h-8 w-[70px]'>
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side='top'>
{[10, 20, 50, 100, 200].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
variant='outline'
className='hidden h-8 w-8 p-2 lg:flex'
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>Go to first page</span>
<DoubleArrowLeftIcon className='h-4 w-4' />
</Button>
<Button
variant='outline'
className='h-8 w-8 p-2'
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>Go to previous page</span>
<ChevronLeftIcon className='h-4 w-4' />
</Button>
<Select
value={`${table.getState().pagination.pageIndex + 1}`}
onValueChange={(value) => table.setPageIndex(Number(value) - 1)}
>
<SelectTrigger className='h-8 w-[70px]'>
<SelectValue placeholder='Select page number' />
</SelectTrigger>
<SelectContent className='w-12'>
{Array.from({ length: table.getPageCount() }, (_, i) => (
<SelectItem key={i} value={`${i + 1}`}>
{i + 1}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant='outline'
className='h-8 w-8 p-2'
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>Go to next page</span>
<ChevronRightIcon className='h-4 w-4' />
</Button>
<Button
variant='outline'
className='hidden h-8 w-8 p-2 lg:flex'
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>Go to last page</span>
<DoubleArrowRightIcon className='h-4 w-4' />
</Button>
</div>
</div>
);
}
+204
View File
@@ -0,0 +1,204 @@
'use client';
import { Alert, AlertDescription, AlertTitle } from '@shadcn/ui/alert';
import { Button } from '@shadcn/ui/button';
import { Checkbox } from '@shadcn/ui/checkbox';
import {
ColumnFiltersState,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
useReactTable,
} from '@tanstack/react-table';
import { ListRestart, Loader, RefreshCcw } from 'lucide-react';
import React, { useEffect, useImperativeHandle, useState } from 'react';
import Empty from '../empty';
import { ColumnFilter, IParams } from './column-filter';
import { Pagination } from './pagination';
export interface ProListProps<TData, TValue> {
request: (
pagination: {
page: number;
size: number;
},
filter: TValue,
) => Promise<{ list: TData[]; total: number }>;
params?: IParams[];
header?: {
title?: React.ReactNode;
toolbar?: React.ReactNode | React.ReactNode[];
};
batchRender?: (rows: TData[]) => React.ReactNode[];
renderItem: (item: TData, checkbox: React.ReactNode) => React.ReactNode;
action?: React.Ref<ProListActions | undefined>;
texts?: Partial<{
textRowsPerPage: string;
textPageOf: (current: number, total: number) => string;
selectedRowsText: (total: number) => string;
}>;
}
export interface ProListActions {
refresh: () => void;
reset: () => void;
}
export function ProList<TData, TValue extends Record<string, unknown>>({
request,
params,
header,
batchRender,
renderItem,
action,
texts,
}: ProListProps<TData, TValue>) {
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [rowSelection, setRowSelection] = useState<{ [key: number]: boolean }>({});
const [data, setData] = useState<TData[]>([]);
const [rowCount, setRowCount] = useState<number>(0);
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 10,
});
const [loading, setLoading] = useState(false);
const table = useReactTable({
data,
columns: [],
onPaginationChange: setPagination,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onRowSelectionChange: setRowSelection,
state: {
columnFilters,
rowSelection,
pagination,
},
manualPagination: true,
manualFiltering: true,
rowCount: rowCount,
});
const fetchData = async () => {
setLoading(true);
try {
const response = await request(
{
page: pagination.pageIndex + 1,
size: pagination.pageSize,
},
Object.fromEntries(columnFilters.map((item) => [item.id, item.value])) as TValue,
);
setData(response.list);
setRowCount(response.total);
} catch (error) {
console.log('Fetch data error:', error);
} finally {
setLoading(false);
}
};
const reset = async () => {
table.resetColumnFilters();
table.resetGlobalFilter(true);
table.resetColumnVisibility();
setRowSelection({});
table.resetPagination();
};
useImperativeHandle(action, () => ({
refresh: fetchData,
reset,
}));
useEffect(() => {
fetchData();
}, [pagination.pageIndex, pagination.pageSize, columnFilters]);
const handleSelectionChange = (index: number, isSelected: boolean) => {
setRowSelection((prevSelection) => ({
...prevSelection,
[index]: isSelected,
}));
};
const selectedRows = data.filter((_, index) => rowSelection[index]);
const selectedCount = selectedRows.length;
return (
<div className='flex max-w-full flex-col gap-4 overflow-hidden'>
<div className='flex flex-wrap-reverse items-center justify-between gap-4'>
<div>
{params ? (
<ColumnFilter
table={table}
params={params}
filters={Object.fromEntries(columnFilters.map((item) => [item.id, item.value]))}
/>
) : (
header?.title
)}
</div>
<div className='flex flex-1 items-center justify-end gap-2'>
<Button variant='outline' className='h-8 w-8 p-2' onClick={fetchData}>
<RefreshCcw className='h-4 w-4' />
</Button>
<Button variant='outline' className='h-8 w-8 p-2' onClick={reset}>
<ListRestart className='h-4 w-4' />
</Button>
{header?.toolbar}
</div>
</div>
{selectedCount > 0 && batchRender && (
<Alert className='flex items-center justify-between'>
<AlertTitle className='m-0'>
{texts?.selectedRowsText?.(selectedCount) || `Selected ${selectedCount} rows`}
</AlertTitle>
<AlertDescription className='flex gap-2'>{batchRender(selectedRows)}</AlertDescription>
</Alert>
)}
<div className='relative overflow-x-auto'>
<div className='grid grid-cols-1 gap-4'>
{data.length ? (
data.map((item, index) => {
const isSelected = !!rowSelection[index];
const checkbox = (
<Checkbox
checked={isSelected}
onCheckedChange={(value) => handleSelectionChange(index, !!value)}
aria-label='Select row'
/>
);
return <div key={index}>{renderItem(item, checkbox)}</div>;
})
) : (
<div className='flex items-center justify-center py-24'>
<Empty />
</div>
)}
</div>
{loading && (
<div className='bg-muted/80 absolute top-0 z-20 flex h-full w-full items-center justify-center'>
<Loader className='h-4 w-4 animate-spin' />
</div>
)}
</div>
{rowCount > 0 && (
<Pagination
table={table}
text={{
textRowsPerPage: texts?.textRowsPerPage,
textPageOf: texts?.textPageOf,
}}
/>
)}
</div>
);
}
@@ -0,0 +1,58 @@
'use client';
import { Input } from '@shadcn/ui/input';
import { Table } from '@tanstack/react-table';
import { Combobox } from '../combobox';
export interface IParams {
key: string;
placeholder?: string;
options?: { label: string; value: string }[];
}
interface ColumnFilterProps<TData> {
table: Table<TData>;
params: IParams[];
filters?: any;
}
export function ColumnFilter<TData>({ table, params, filters }: ColumnFilterProps<TData>) {
const updateFilter = (key: string, value: any) => {
table.setColumnFilters((prev) => {
const newFilters = prev.filter((filter) => filter.id !== key);
if (value) {
newFilters.push({ id: key, value });
}
return newFilters;
});
};
return (
<div className='flex gap-2'>
{params.map((param) => {
if (param.options) {
return (
<Combobox
key={param.key}
className='w-32'
placeholder={param.placeholder || 'Choose...'}
value={filters[param.key] || ''}
onChange={(value) => {
updateFilter(param.key, value);
}}
options={param.options}
/>
);
}
return (
<Input
key={param.key}
className='w-32'
placeholder={param.placeholder || 'Search...'}
value={filters[param.key] || ''}
onChange={(event) => updateFilter(param.key, event.target.value)}
/>
);
})}
</div>
);
}
@@ -0,0 +1,76 @@
import { ArrowDownIcon, ArrowUpIcon, CaretSortIcon, EyeNoneIcon } from '@radix-ui/react-icons';
import { Button } from '@shadcn/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@shadcn/ui/dropdown-menu';
import { cn } from '@shadcn/ui/lib/utils';
import { flexRender, Header } from '@tanstack/react-table';
interface ColumnHeaderProps<TData, TValue> extends React.HTMLAttributes<HTMLDivElement> {
header: Header<TData, TValue>;
text?: Partial<{
asc: string;
desc: string;
hide: string;
}>;
}
export function ColumnHeader<TData, TValue>({
header,
className,
text,
}: ColumnHeaderProps<TData, TValue>) {
const column = header.column;
const title = header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext());
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>;
}
return (
<div className={cn('flex w-full items-center', className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
className='flex h-8 w-full justify-start !bg-transparent p-0 text-sm'
>
<span>{title}</span>
{column.getIsSorted() === 'desc' ? (
<ArrowDownIcon className='ml-2 h-4 w-4' />
) : column.getIsSorted() === 'asc' ? (
<ArrowUpIcon className='ml-2 h-4 w-4' />
) : (
<CaretSortIcon className='ml-2 h-4 w-4' />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUpIcon className='text-muted-foreground/70 mr-2 h-3.5 w-3.5' />
{text?.asc || 'ASC'}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDownIcon className='text-muted-foreground/70 mr-2 h-3.5 w-3.5' />
{text?.desc || 'DESC'}
</DropdownMenuItem>
{column.getCanHide() && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
<EyeNoneIcon className='text-muted-foreground/70 mr-2 h-3.5 w-3.5' />
Hide
{text?.hide || 'Hide'}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
@@ -0,0 +1,47 @@
'use client';
import { MixerHorizontalIcon } from '@radix-ui/react-icons';
import { Button } from '@shadcn/ui/button';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@shadcn/ui/dropdown-menu';
import { Table } from '@tanstack/react-table';
import { ReactNode } from 'react';
interface ColumnToggleProps<TData> {
table: Table<TData>;
}
export function ColumnToggle<TData>({ table }: ColumnToggleProps<TData>) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline' size='icon'>
<MixerHorizontalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[150px]'>
{table
.getAllColumns()
.filter((column) => typeof column.accessorFn !== 'undefined' && column.getCanHide())
.map((column) => {
const columns = table.getAllColumns().filter((item) => item.getIsVisible());
return (
<DropdownMenuCheckboxItem
key={column.id}
className='capitalize'
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
disabled={columns.length === 1 && columns?.[0]?.id === column.id}
>
{column.columnDef.header as ReactNode}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
+1
View File
@@ -0,0 +1 @@
export { ProTable, type ProTableActions, type ProTableProps } from './pro-table';
+105
View File
@@ -0,0 +1,105 @@
import {
ChevronLeftIcon,
ChevronRightIcon,
DoubleArrowLeftIcon,
DoubleArrowRightIcon,
} from '@radix-ui/react-icons';
import { Button } from '@shadcn/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@shadcn/ui/select';
import { Table } from '@tanstack/react-table';
interface PaginationText {
textRowsPerPage?: string;
textPageOf?: (pageIndex: number, pageCount: number) => string;
}
interface PaginationProps<TData> {
table: Table<TData>;
text?: PaginationText;
}
export function Pagination<TData>({ table, text }: PaginationProps<TData>) {
return (
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='text-muted-foreground flex-1 whitespace-nowrap'>
{text?.textPageOf?.(table.getState().pagination.pageIndex + 1, table.getPageCount()) ||
`Page ${table.getState().pagination.pageIndex + 1} of ${table.getPageCount()}`}
</div>
<div className='flex items-center gap-2'>
<div className='flex items-center space-x-2'>
<p className='font-medium'>{text?.textRowsPerPage || 'Rows per page'}</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
>
<SelectTrigger className='h-8 w-[70px]'>
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side='top'>
{[10, 20, 50, 100, 200].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
variant='outline'
size='icon'
className='hidden lg:flex'
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>Go to first page</span>
<DoubleArrowLeftIcon className='h-4 w-4' />
</Button>
<Button
variant='outline'
size='icon'
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>Go to previous page</span>
<ChevronLeftIcon className='h-4 w-4' />
</Button>
<Select
value={`${table.getState().pagination.pageIndex + 1}`}
onValueChange={(value) => table.setPageIndex(Number(value) - 1)}
>
<SelectTrigger className='w-[70px]'>
<SelectValue placeholder='Select page number' />
</SelectTrigger>
<SelectContent className='w-12'>
{Array.from({ length: table.getPageCount() }, (_, i) => (
<SelectItem key={i} value={`${i + 1}`}>
{i + 1}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant='outline'
size='icon'
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>Go to next page</span>
<ChevronRightIcon className='h-4 w-4' />
</Button>
<Button
variant='outline'
size='icon'
className='hidden lg:flex'
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>Go to last page</span>
<DoubleArrowRightIcon className='h-4 w-4' />
</Button>
</div>
</div>
);
}
+308
View File
@@ -0,0 +1,308 @@
'use client';
import { Alert, AlertDescription, AlertTitle } from '@shadcn/ui/alert';
import { Button } from '@shadcn/ui/button';
import { Checkbox } from '@shadcn/ui/checkbox';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@shadcn/ui/table';
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
SortingState,
useReactTable,
VisibilityState,
} from '@tanstack/react-table';
import { useSize } from 'ahooks';
import { ListRestart, Loader, RefreshCcw } from 'lucide-react';
import React, { Fragment, useEffect, useImperativeHandle, useRef, useState } from 'react';
import Empty from '../empty';
import { ColumnFilter, IParams } from './column-filter';
import { ColumnHeader } from './column-header';
import { ColumnToggle } from './column-toggle';
import { Pagination } from './pagination';
export interface ProTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
request: (
pagination: {
page: number;
size: number;
},
filter: TValue,
) => Promise<{ list: TData[]; total: number }>;
params?: IParams[];
header?: {
title?: React.ReactNode;
toolbar?: React.ReactNode | React.ReactNode[];
};
actions?: {
render?: (row: TData) => React.ReactNode[];
batchRender?: (rows: TData[]) => React.ReactNode[];
};
action?: React.Ref<ProTableActions | undefined>;
texts?: Partial<{
actions: string;
asc: string;
desc: string;
hide: string;
textRowsPerPage: string;
textPageOf: (current: number, total: number) => string;
selectedRowsText: (total: number) => string;
}>;
}
export interface ProTableActions {
refresh: () => void;
reset: () => void;
}
export function ProTable<TData, TValue extends Record<string, unknown>>({
columns,
request,
params,
header,
actions,
action,
texts,
}: ProTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState({});
const [data, setData] = useState<TData[]>([]);
const [rowCount, setRowCount] = useState<number>(0);
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 50,
});
const [loading, setLoading] = useState(false);
const table = useReactTable({
data,
columns: [
...(actions?.batchRender ? [createSelectColumn<TData, TValue>()] : []),
...columns,
...(actions?.render
? ([
{
id: 'actions',
header: texts?.actions,
cell: ({ row }) => (
<div className='flex items-center justify-end gap-2'>
{actions
?.render?.(row.original)
.map((item, index) => <Fragment key={index}>{item}</Fragment>)}
</div>
),
enableSorting: false,
enableHiding: false,
},
] as ColumnDef<TData, TValue>[])
: []),
],
onPaginationChange: setPagination,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
pagination,
},
manualPagination: true,
manualFiltering: true,
rowCount: rowCount,
});
const fetchData = async () => {
setLoading(true);
try {
const response = await request(
{
page: pagination.pageIndex + 1,
size: pagination.pageSize,
},
Object.fromEntries(columnFilters.map((item) => [item.id, item.value])) as TValue,
);
setData(response.list);
setRowCount(response.total);
} catch (error) {
console.log('Fetch data error:', error);
} finally {
setLoading(false);
}
};
const reset = async () => {
table.resetSorting();
table.resetColumnFilters();
table.resetGlobalFilter(true);
table.resetColumnVisibility();
table.resetRowSelection();
table.resetPagination();
};
const ref = useRef<HTMLDivElement>(null);
const size = useSize(ref);
useImperativeHandle(action, () => ({
refresh: fetchData,
reset,
}));
useEffect(() => {
fetchData();
}, [pagination.pageIndex, pagination.pageSize, columnFilters]);
const selectedRows = table.getSelectedRowModel().flatRows.map((row) => row.original);
const selectedCount = selectedRows.length;
return (
<div className='flex flex-col gap-4' ref={ref}>
<div className='flex flex-wrap-reverse items-center justify-between gap-4'>
<div>
{params ? (
<ColumnFilter
table={table}
params={params}
filters={Object.fromEntries(columnFilters.map((item) => [item.id, item.value]))}
/>
) : (
header?.title
)}
</div>
<div className='flex flex-1 items-center justify-end gap-2'>
<Button variant='outline' size='icon' onClick={fetchData}>
<RefreshCcw />
</Button>
<ColumnToggle table={table} />
<Button variant='outline' size='icon' onClick={reset}>
<ListRestart />
</Button>
{header?.toolbar}
</div>
</div>
{selectedCount > 0 && actions?.batchRender && (
<Alert className='flex items-center justify-between'>
<AlertTitle className='m-0'>
{texts?.selectedRowsText?.(selectedCount) || `Selected ${selectedCount} rows`}
</AlertTitle>
<AlertDescription className='flex gap-2'>
{actions.batchRender(selectedRows)}
</AlertDescription>
</Alert>
)}
<div
className='relative w-auto overflow-x-auto rounded-md border'
style={{
width: size?.width,
}}
>
<Table className='w-full'>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className={getTableHeaderClass(header.column.id)}>
<ColumnHeader
header={header}
text={{
asc: texts?.asc,
desc: texts?.desc,
hide: texts?.hide,
}}
/>
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel()?.rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className={getTableCellClass(cell.column.id)}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length + 2} className='py-24'>
<Empty />
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{loading && (
<div className='bg-muted/80 absolute top-0 z-20 flex h-full w-full items-center justify-center'>
<Loader className='h-4 w-4 animate-spin' />
</div>
)}
</div>
{rowCount > 0 && (
<Pagination
table={table}
text={{
textRowsPerPage: texts?.textRowsPerPage,
textPageOf: texts?.textPageOf,
}}
/>
)}
</div>
);
}
function createSelectColumn<TData, TValue>(): ColumnDef<TData, TValue> {
return {
id: 'selected',
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && 'indeterminate')
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label='Select all'
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label='Select row'
/>
),
enableSorting: false,
enableHiding: false,
};
}
function getTableHeaderClass(columnId: string) {
if (columnId === 'selected') {
return 'sticky left-0 z-10 bg-background shadow-[2px_0_5px_-2px_rgba(0,0,0,0.1)] [&:has([role=checkbox])]:pr-2';
} else if (columnId === 'actions') {
return 'sticky right-0 z-10 text-right bg-background shadow-[-2px_0_5px_-2px_rgba(0,0,0,0.1)]';
}
return 'truncate';
}
function getTableCellClass(columnId: string) {
if (columnId === 'selected') {
return 'sticky left-0 bg-background shadow-[2px_0_5px_-2px_rgba(0,0,0,0.1)]';
} else if (columnId === 'actions') {
return 'sticky right-0 bg-background shadow-[-2px_0_5px_-2px_rgba(0,0,0,0.1)]';
}
return 'truncate';
}
+73
View File
@@ -0,0 +1,73 @@
import { Badge } from '@shadcn/ui/badge';
import { Input } from '@shadcn/ui/input';
import { X } from 'lucide-react';
import React, { useEffect, useState } from 'react';
interface TagInputProps {
value?: string[];
onChange?: (tags: string[]) => void;
placeholder?: string;
}
export function TagInput({ value = [], onChange, placeholder }: TagInputProps) {
const [inputValue, setInputValue] = useState('');
const [tags, setTags] = useState<string[]>(value);
useEffect(() => {
setTags(value.map((tag) => tag.trim()).filter((tag) => tag));
}, [value]);
function addTag() {
const newTag = inputValue.trim();
if (newTag && !tags.includes(newTag)) {
const newTags = [...tags, newTag];
updateTags(newTags);
}
setInputValue('');
}
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter') {
event.preventDefault();
addTag();
} else if (event.key === 'Backspace' && inputValue === '') {
event.preventDefault();
handleRemoveTag(tags.length - 1);
}
}
function handleRemoveTag(index: number) {
const newTags = tags.filter((_, i) => i !== index);
updateTags(newTags);
}
function updateTags(newTags: string[]) {
setTags(newTags);
onChange?.(newTags);
}
return (
<div className='border-input focus-within:ring-primary flex min-h-9 w-full flex-wrap items-center gap-2 rounded-md border bg-transparent p-2 shadow-sm transition-colors focus-within:ring-1'>
{tags.map((tag, index) => (
<Badge
key={tag}
variant='outline'
className='border-primary bg-primary/10 flex items-center gap-1 px-1'
>
{tag}
<X className='size-4 cursor-pointer' onClick={() => handleRemoveTag(index)} />
</Badge>
))}
<Input
className='h-full min-w-48 flex-1 border-none bg-transparent p-0 shadow-none !ring-0'
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={addTag}
placeholder={placeholder}
/>
</div>
);
}
export default TagInput;
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
import { intlFormat } from '@shadcn/ui/lib/date-fns';
export function formatBytes(bytes: number) {
if (bytes === 0) return '0 B';
const k = 1000, // or 1024
sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'],
i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toPrecision(3) + ' ' + sizes[i];
}
export function formatDate(date?: Date | number, showTime: boolean = true) {
if (!date) return;
return intlFormat(date, {
year: 'numeric',
month: 'numeric',
day: 'numeric',
...(showTime && {
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
}),
hour12: false,
});
}
+50
View File
@@ -0,0 +1,50 @@
import { startOfMonth } from '@shadcn/ui/lib/date-fns';
export * from './countries';
export * from './formatting';
export * from './unit-conversions';
export const isBrowser = () => typeof window !== 'undefined';
export function getNextResetDate(startDate: Date | number) {
let time = new Date(startDate);
const resetDay = time.getDate();
const currentDate = new Date();
if (isNaN(time.getTime())) {
throw new Error('Invalid start date');
}
if (currentDate.getDate() >= resetDay) {
const startOfMonthNextReset = startOfMonth(currentDate);
startOfMonthNextReset.setMonth(startOfMonthNextReset.getMonth() + 1);
startOfMonthNextReset.setDate(resetDay);
startOfMonthNextReset.setHours(time.getHours());
startOfMonthNextReset.setMinutes(time.getMinutes());
startOfMonthNextReset.setSeconds(time.getSeconds());
return startOfMonthNextReset;
} else {
time.setMonth(currentDate.getMonth());
return time;
}
}
export function extractDomain(url: string): string | null {
try {
const hostname = new URL(url).hostname;
if (hostname.match(/^\d{1,3}(\.\d{1,3}){3}$/)) {
return hostname;
}
const domainParts = hostname.split('.').filter(Boolean);
if (domainParts.length >= 2) {
const topLevelDomain = domainParts.slice(-2).join('.');
return topLevelDomain;
}
return hostname;
} catch (error) {
console.error('Invalid URL:', error);
return null;
}
}
+24
View File
@@ -0,0 +1,24 @@
import { evaluate } from 'mathjs';
export function unitConversion(
type: 'centsToDollars' | 'dollarsToCents' | 'bitsToMb' | 'mbToBits' | 'bytesToGb' | 'gbToBytes',
value?: number | string,
) {
if (!value) return;
switch (type) {
case 'centsToDollars':
return evaluate(`${value} / 100`);
case 'dollarsToCents':
return evaluate(`${value} * 100`);
case 'bitsToMb':
return evaluate(`${value} / 1000 / 1000`);
case 'mbToBits':
return evaluate(`${value} * 1000 * 1000`);
case 'bytesToGb':
return evaluate(`${value} / 1000 / 1000 / 1000`);
case 'gbToBytes':
return evaluate(`${value} * 1000 * 1000 * 1000`);
default:
throw new Error('Invalid conversion type');
}
}