🎉 chore(init): project initialization
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' />;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { HTMLEditor } from './html';
|
||||
export { JSONEditor } from './json';
|
||||
export { MarkdownEditor } from './markdown';
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ProList, type ProListActions, type ProListProps } from './pro-list';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ProTable, type ProTableActions, type ProTableProps } from './pro-table';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user