🎉 feat: initialization

This commit is contained in:
web
2025-11-26 19:56:16 -08:00
commit a801849fb2
553 changed files with 213088 additions and 0 deletions
@@ -0,0 +1,134 @@
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@workspace/ui/components/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@workspace/ui/components/popover";
import { Icon } from "@workspace/ui/composed/icon";
import { cn } from "@workspace/ui/lib/utils";
import { countries, type ICountry } from "@workspace/ui/utils/countries";
import { BoxIcon, Check, ChevronsUpDown } from "lucide-react";
import { useEffect, useState } from "react";
interface AreaCodeSelectProps {
value?: string;
onChange?: (value: ICountry) => void;
className?: string;
placeholder?: string;
simple?: boolean;
whitelist?: string[];
}
const filterItems = (whitelist?: string[]) => {
const baseItems = countries
.filter((item) => !!item.phone)
.flatMap((item) => {
const phones = item.phone!.split(",");
if (phones.length > 1) {
return [...phones].map((phone) => ({
...item,
phone,
}));
}
return item;
});
if (!whitelist?.length) return baseItems;
return baseItems.filter((item) => whitelist.includes(item.phone!));
};
export const AreaCodeSelect = ({
value,
onChange,
className,
placeholder = "Select Area Code",
simple = false,
whitelist,
}: AreaCodeSelectProps) => {
const [open, setOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState<ICountry | undefined>();
const items = filterItems(whitelist);
useEffect(() => {
if (value !== selectedItem?.phone) {
const found = items.find((item) => item.phone === value);
setSelectedItem(found);
}
}, [selectedItem?.phone, value, items]);
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>
<Button
aria-expanded={open}
className={cn("justify-between", className)}
role="combobox"
variant="outline"
>
{selectedItem ? (
<div className="flex items-center gap-2">
<Icon
className="!size-5"
icon={`flagpack:${selectedItem.alpha2.toLowerCase()}`}
/>
+{selectedItem.phone}
{!simple && `(${selectedItem.name})`}
</div>
) : (
placeholder
)}
<ChevronsUpDown className="ml-2 h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="p-0">
<Command>
<CommandInput placeholder="Search area code..." />
<CommandList>
<CommandEmpty>
<BoxIcon className="inline-block text-slate-500" />
</CommandEmpty>
<CommandGroup>
{items.map((item) => (
<CommandItem
key={`${item.alpha2}-${item.phone}`}
onSelect={() => {
setSelectedItem(item);
onChange?.(item);
setOpen(false);
}}
value={`${item.phone}-${item.name}`}
>
<div className="flex items-center gap-2">
<Icon
className="!size-5"
icon={`flagpack:${item.alpha2.toLowerCase()}`}
/>
+{item.phone} ({item.name})
</div>
<Check
className={cn(
"ml-auto h-4 w-4",
selectedItem?.phone === item.phone
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
+134
View File
@@ -0,0 +1,134 @@
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@workspace/ui/components/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@workspace/ui/components/popover";
import { cn } from "@workspace/ui/lib/utils";
import { BoxIcon, CheckIcon, ChevronsUpDownIcon } from "lucide-react";
import * as React from "react";
export type Option<T = string> = {
value: T;
label: string;
children?: React.ReactNode;
};
// 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;
}
if (!multiple) {
const selectedOption = options.find((option) => option.value === value);
return selectedOption
? selectedOption.children || selectedOption.label
: placeholder;
}
return placeholder;
};
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>
<Button
aria-expanded={open}
className={cn("w-full items-center justify-between", className)}
role="combobox"
variant="outline"
>
<span className="truncate">{renderButtonLabel()}</span>
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-fit p-0">
<Command>
<CommandInput className="h-9" placeholder="Search..." />
<CommandEmpty>
<BoxIcon className="inline-block text-slate-500" />
</CommandEmpty>
<CommandGroup>
<CommandList>
{options.map((option) => (
<CommandItem
key={String(option.label + option.value)}
onSelect={() => handleSelect(option.value)}
value={option.label + option.value}
>
{option.children || 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,47 @@
"use client";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@workspace/ui/components/alert-dialog";
import type React from "react";
import type { 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",
}) => (
<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>
);
+62
View File
@@ -0,0 +1,62 @@
"use client";
import { Button } from "@workspace/ui/components/button";
import { Calendar } from "@workspace/ui/components/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@workspace/ui/components/popover";
import { cn } from "@workspace/ui/lib/utils";
import { intlFormat } from "date-fns";
import { CalendarIcon } from "lucide-react";
import * as React from "react";
import type { DayPicker } from "react-day-picker";
export function DatePicker({
placeholder,
value,
onChange,
...props
}: React.ComponentProps<typeof DayPicker> & {
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() || 0);
}
};
return (
<Popover>
<PopoverTrigger asChild>
<Button
className={cn(
"w-full justify-between font-normal",
!value && "text-muted-foreground"
)}
variant="outline"
>
{value ? intlFormat(value) : <span>{placeholder}</span>}
<CalendarIcon className="size-4" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-auto p-0">
<Calendar
{...props}
initialFocus
mode="single"
onSelect={handleSelect}
selected={date}
/>
</PopoverContent>
</Popover>
);
}
+213
View File
@@ -0,0 +1,213 @@
import { Button } from "@workspace/ui/components/button";
import { Label } from "@workspace/ui/components/label";
import { Switch } from "@workspace/ui/components/switch";
import { Textarea } from "@workspace/ui/components/textarea";
import { Combobox } from "@workspace/ui/composed/combobox";
import {
EnhancedInput,
type EnhancedInputProps,
} from "@workspace/ui/composed/enhanced-input";
import { cn } from "@workspace/ui/lib/utils";
import { CircleMinusIcon, CirclePlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
interface FieldConfig extends Omit<EnhancedInputProps, "type"> {
name: string;
type: "text" | "number" | "select" | "time" | "boolean" | "textarea";
options?: { label: string; value: string }[];
// optional per-item visibility function: returns true to show the field for the given item
visible?: (item: Record<string, any>) => boolean;
}
interface ObjectInputProps<T> {
value: T;
onChange: (value: T) => void;
fields: FieldConfig[];
className?: string;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function ObjectInput<T extends Record<string, any>>({
value,
onChange,
fields,
className,
}: ObjectInputProps<T>) {
const [internalState, setInternalState] = useState<T>(value);
useEffect(() => {
setInternalState(value);
}, [value]);
const updateField = (key: keyof T, fieldValue: string | number | boolean) => {
const updatedInternalState = { ...internalState, [key]: fieldValue };
setInternalState(updatedInternalState);
onChange(updatedInternalState);
};
const renderField = (field: FieldConfig) => {
// if visible callback exists and returns false for current item, don't render
if (field.visible && !field.visible(internalState)) return null;
switch (field.type) {
case "select":
return (
field.options && (
<Combobox<string, false>
onChange={(fieldValue) => updateField(field.name, fieldValue)}
options={field.options}
placeholder={field.placeholder}
value={internalState[field.name]}
/>
)
);
case "boolean":
return (
<div className="flex h-full items-center space-x-2">
<Switch
checked={internalState[field.name] as boolean}
onCheckedChange={(fieldValue) =>
updateField(field.name, fieldValue)
}
/>
{field.placeholder && <Label>{field.placeholder}</Label>}
</div>
);
case "textarea":
return (
<div className="w-full space-y-2">
{field.prefix && (
<Label className="font-medium text-sm">{field.prefix}</Label>
)}
<Textarea
className="min-h-32"
onChange={(e) => updateField(field.name, e.target.value)}
placeholder={field.placeholder}
value={internalState[field.name] || ""}
/>
</div>
);
default:
return (
<EnhancedInput
onValueChange={(fieldValue) => updateField(field.name, fieldValue)}
value={internalState[field.name]}
{...field}
/>
);
}
};
return (
<div className={cn("flex flex-1 flex-wrap gap-4", className)}>
{fields.map((field) => {
const node = renderField(field);
if (node === null) return null; // don't render wrapper if field hidden
return (
<div className={cn("flex-1", field.className)} key={field.name}>
{node}
</div>
);
})}
</div>
);
}
interface ArrayInputProps<T> {
value?: T[];
onChange: (value: T[]) => void;
fields: FieldConfig[];
isReverse?: boolean;
className?: string;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function ArrayInput<T extends Record<string, any>>({
value = [],
onChange,
fields,
isReverse = false,
className,
}: 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[]>(() =>
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 = () => {
if (isReverse) {
setDisplayItems([initializeDefaultItem(), ...displayItems]);
} else {
setDisplayItems([...displayItems, initializeDefaultItem()]);
}
};
const deleteField = (index: number) => {
const newDisplayItems = displayItems.filter((_, i) => i !== index);
setDisplayItems(newDisplayItems);
const modifiedItems = newDisplayItems.filter(isItemModified);
onChange(modifiedItems);
};
useEffect(() => {
if (value.length > 0) {
setDisplayItems(value);
}
}, [value]);
return (
<div className="flex flex-col gap-4">
{displayItems.map((item, index) => (
<div className="flex items-center gap-4" key={index}>
<ObjectInput
className={className}
fields={fields}
onChange={(updatedItem) => handleItemChange(index, updatedItem)}
value={item}
/>
<div className="flex min-w-20 items-center">
{displayItems.length > 1 && (
<Button
className="p-0 text-destructive text-lg"
onClick={() => deleteField(index)}
size="icon"
type="button"
variant="ghost"
>
<CircleMinusIcon />
</Button>
)}
{(isReverse ? index === 0 : index === displayItems.length - 1) && (
<Button
className="p-0 text-lg text-primary"
onClick={createField}
size="icon"
type="button"
variant="ghost"
>
<CirclePlusIcon />
</Button>
)}
</div>
</div>
))}
</div>
);
}
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
"use client";
import {
MonacoEditor,
type MonacoEditorProps,
} from "@workspace/ui/composed/editor/monaco-editor";
import { useEffect, useRef } from "react";
export function HTMLEditor(props: MonacoEditorProps) {
return (
<MonacoEditor
description="Support HTML"
title="HTML Editor"
{...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
className="h-full w-full border-0"
ref={iframeRef}
title="HTML Preview"
/>
);
}
@@ -0,0 +1,5 @@
export { GoTemplateEditor } from "@workspace/ui/composed/editor/go-template";
export { HTMLEditor } from "@workspace/ui/composed/editor/html";
export { JSONEditor } from "@workspace/ui/composed/editor/json";
export { MarkdownEditor } from "@workspace/ui/composed/editor/markdown";
export { MonacoEditor } from "@workspace/ui/composed/editor/monaco-editor";
+102
View File
@@ -0,0 +1,102 @@
"use client";
import {
MonacoEditor,
type MonacoEditorProps,
} from "@workspace/ui/composed/editor/monaco-editor";
import { useMemo } from "react";
interface JSONEditorProps
extends Omit<MonacoEditorProps, "placeholder" | "value" | "onChange"> {
schema?: Record<string, unknown>;
placeholder?: Record<string, unknown>;
value?: Record<string, unknown> | string;
onChange?: (value: Record<string, unknown> | string | undefined) => void;
}
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}
language="json"
onChange={(value) => {
if (props.onChange && typeof value === "string") {
try {
props.onChange(
props.value && typeof props.value === "string"
? value
: JSON.parse(value)
);
} catch (e) {
console.log("Invalid JSON input:", e);
}
}
}}
onMount={(editor: any, monaco: any) => {
if (props.onMount) props.onMount(editor, monaco);
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
schemas: [
{
uri: "",
fileMatch: ["*"],
schema: schema || {
type: "object",
properties: generateSchema(placeholder),
},
},
],
});
}}
placeholder={placeholder ? JSON.stringify(placeholder, null, 2) : ""}
value={
typeof props.value === "string"
? props.value
: props.value
? JSON.stringify(props.value, null, 2)
: ""
}
/>
);
}
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,19 @@
"use client";
import {
MonacoEditor,
type MonacoEditorProps,
} from "@workspace/ui/composed/editor/monaco-editor";
import { Markdown } from "@workspace/ui/composed/markdown";
export function MarkdownEditor(props: MonacoEditorProps) {
return (
<MonacoEditor
description="Support markdwon and html syntax"
title="Markdown Editor"
{...props}
language="markdown"
render={(value) => <Markdown>{value || ""}</Markdown>}
/>
);
}
@@ -0,0 +1,213 @@
"use client";
import { Editor, type Monaco, type OnMount } from "@monaco-editor/react";
import { Button } from "@workspace/ui/components/button";
import { cn } from "@workspace/ui/lib/utils";
import { useSize } from "ahooks";
import { EyeIcon, EyeOff, FullscreenIcon, MinimizeIcon } from "lucide-react";
import DraculaTheme from "monaco-themes/themes/Dracula.json" with {
type: "json",
};
import { useEffect, 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;
beforeMount?: (monaco: Monaco) => void;
language?: string;
className?: string;
showLineNumbers?: boolean;
readOnly?: boolean;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function debounce<T extends (...args: any[]) => void>(func: T, delay: number) {
let timeoutId: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func(...args), delay);
};
}
export function MonacoEditor({
value: propValue,
onChange,
onBlur,
title = "Editor Title",
description,
placeholder = "Start typing...",
render,
onMount,
beforeMount,
language = "markdown",
className,
showLineNumbers = false,
readOnly = false,
}: MonacoEditorProps) {
const [internalValue, setInternalValue] = useState<string | undefined>(
propValue
);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isPreviewVisible, setIsPreviewVisible] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const size = useSize(ref);
useEffect(() => {
setInternalValue(propValue);
}, [propValue]);
const debouncedOnChange = useRef(
debounce((newValue: string | undefined) => {
if (onChange) {
onChange(newValue);
}
}, 300)
).current;
const handleEditorDidMount: OnMount = (editor, monaco) => {
if (onMount) onMount(editor, monaco);
editor.onDidChangeModelContent(() => {
const newValue = editor.getValue();
setInternalValue(newValue);
debouncedOnChange(newValue);
});
editor.onDidBlurEditorWidget(() => {
if (onBlur) {
onBlur(editor.getValue());
}
});
};
const toggleFullscreen = () => setIsFullscreen(!isFullscreen);
const togglePreview = () => setIsPreviewVisible(!isPreviewVisible);
return (
<div className="size-full" ref={ref}>
<div style={size}>
<div
className={cn(
"flex size-full min-h-96 flex-col rounded-md border",
className,
{
"!mt-0 fixed inset-0 z-50 h-screen bg-background": isFullscreen,
}
)}
>
<div className="flex items-center justify-between border-b p-2">
<div>
<h1 className="text-left font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{title}
</h1>
<p className="text-[0.8rem] text-muted-foreground">
{description}
</p>
</div>
<div className="flex items-center space-x-2">
{render && (
<Button
onClick={togglePreview}
size="icon"
type="button"
variant="outline"
>
{isPreviewVisible ? <EyeOff /> : <EyeIcon />}
</Button>
)}
<Button
onClick={toggleFullscreen}
size="icon"
type="button"
variant="outline"
>
{isFullscreen ? <MinimizeIcon /> : <FullscreenIcon />}
</Button>
</div>
</div>
<div className={cn("relative flex flex-1")}>
<div
className={cn("flex-1 overflow-auto p-4 invert dark:invert-0", {
"w-1/2": isPreviewVisible,
})}
>
<Editor
beforeMount={(monaco: Monaco) => {
monaco.editor.defineTheme("transparentTheme", {
base: DraculaTheme.base as "vs" | "vs-dark" | "hc-black",
inherit: DraculaTheme.inherit,
rules: DraculaTheme.rules,
colors: {
...DraculaTheme.colors,
"editor.background": "#00000000",
},
});
if (beforeMount) {
beforeMount(monaco);
}
}}
className=""
language={language}
onChange={(newValue) => {
setInternalValue(newValue);
debouncedOnChange(newValue);
}}
onMount={handleEditorDidMount}
options={{
automaticLayout: true,
contextmenu: false,
folding: false,
fontSize: 14,
formatOnPaste: true,
formatOnType: true,
glyphMargin: false,
lineNumbers: showLineNumbers ? "on" : "off",
minimap: { enabled: false },
overviewRulerLanes: 0,
renderLineHighlight: "none",
scrollBeyondLastLine: false,
scrollbar: {
useShadows: false,
vertical: "auto",
},
tabSize: 2,
wordWrap: "off",
readOnly,
}}
theme="transparentTheme"
value={internalValue}
/>
{!internalValue?.trim() && placeholder && (
<pre
className={cn(
"pointer-events-none absolute top-4 left-7 text-muted-foreground text-sm",
{
"left-16": showLineNumbers,
}
)}
style={{ userSelect: "none" }}
>
{placeholder}
</pre>
)}
</div>
{render && isPreviewVisible && (
<div className="w-1/2 flex-1 overflow-auto border-l p-4">
{render(internalValue)}
</div>
)}
</div>
</div>
</div>
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
import {
Empty as EmptyContainer,
EmptyDescription,
EmptyHeader,
EmptyMedia,
} from "@workspace/ui/components/empty";
import { cn } from "@workspace/ui/lib/utils";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
export default function Empty({
description,
border,
}: {
description?: React.ReactNode;
border?: boolean;
}) {
const { t } = useTranslation("components");
const messages = useMemo(
() => [
t(
"empty.tips.0",
"Imagine this space filled with exciting content! For now, you'll have to use your imagination..."
),
t(
"empty.tips.1",
"This area mysteriously disappeared, but we're summoning it back!"
),
t(
"empty.tips.2",
"Oh no, nothing happened... Feel free to fill in the blank!"
),
t(
"empty.tips.3",
"It's like discovering an empty stage at a concert... Why not go up and perform?"
),
t(
"empty.tips.4",
"You've found a blank canvas! How about building a house?"
),
t(
"empty.tips.5",
"This area is currently empty, but creativity starts here!"
),
t(
"empty.tips.6",
"Nothing here... but don't worry, it's just the beginning!"
),
t(
"empty.tips.7",
"This place was supposed to have a big surprise, but the surprise slipped away!"
),
t(
"empty.tips.8",
"There's nothing here for now, like an empty snack cabinet."
),
t(
"empty.tips.9",
"This empty space is waiting for its protagonist to take the stage!"
),
],
[t]
);
const [index, setIndex] = useState(0);
useEffect(() => {
setIndex(Math.floor(Math.random() * messages.length));
}, [messages]);
return (
<EmptyContainer className={cn(border ? "border" : "border-none")}>
<EmptyMedia>
<svg
className="text-background"
fill="currentColor"
height="41"
stroke="currentColor"
viewBox="0 0 64 41"
width="64"
xmlns="http://www.w3.org/2000/svg"
>
<title>Empty</title>
<g fill="none" fillRule="evenodd" transform="translate(0 1)">
<ellipse
cx="32"
cy="33"
fill="currentColor"
opacity={0.8}
rx="32"
ry="7"
/>
<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
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"
/>
</g>
</g>
</svg>
</EmptyMedia>
<EmptyHeader>
<EmptyDescription>{description || messages[index]}</EmptyDescription>
</EmptyHeader>
</EmptyContainer>
);
}
+174
View File
@@ -0,0 +1,174 @@
import { Input } from "@workspace/ui/components/input";
import { cn } from "@workspace/ui/lib/utils";
import { type ChangeEvent, type ReactNode, useEffect, useState } from "react";
export interface EnhancedInputProps<T = string>
extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"prefix" | "value" | "onChange"
> {
prefix?: string | ReactNode;
suffix?: string | ReactNode;
value?: T;
formatInput?: (value: T) => string | number;
formatOutput?: (value: string | number) => T;
onValueChange?: (value: T) => void;
onValueBlur?: (value: T) => void;
min?: number;
max?: number;
}
export function EnhancedInput<T = string>({
suffix,
prefix,
formatInput,
formatOutput,
value: initialValue,
className,
onValueChange,
onValueBlur,
...props
}: EnhancedInputProps<T>) {
const getProcessedValue = (inputValue: unknown) => {
if (inputValue === "" || inputValue === 0 || inputValue === "0") return "";
const newValue = String(inputValue ?? "");
return formatInput ? formatInput(inputValue as T) : newValue;
};
const [value, setValue] = useState<string | number>(() =>
getProcessedValue(initialValue)
);
const [internalValue, setInternalValue] = useState<T | string | number>(
initialValue ?? ""
);
useEffect(() => {
if (initialValue !== internalValue) {
const newValue = getProcessedValue(initialValue);
if (value !== newValue) {
setValue(newValue);
setInternalValue(initialValue ?? "");
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialValue, formatInput]);
const processValue = (inputValue: string | number): T => {
let processedValue: number | string = inputValue?.toString().trim();
if (processedValue === "0" && props.type === "number") {
return (formatOutput ? formatOutput(0) : 0) as T;
}
if (processedValue && props.type === "number")
processedValue = Number(processedValue);
return formatOutput ? formatOutput(processedValue) : (processedValue as T);
};
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
let inputValue = e.target.value;
if (props.type === "number") {
if (inputValue === "0") {
setValue("");
setInternalValue(0);
onValueChange?.(processValue(0));
return;
}
if (
/^-?\d*\.?\d*$/.test(inputValue) ||
inputValue === "-" ||
inputValue === "."
) {
const numericValue = Number(inputValue);
if (
!Number.isNaN(numericValue) &&
inputValue !== "-" &&
inputValue !== "."
) {
const min = Number.isFinite(props.min)
? props.min
: Number.NEGATIVE_INFINITY;
const max = Number.isFinite(props.max)
? props.max
: Number.POSITIVE_INFINITY;
const constrainedValue = Math.max(min!, Math.min(max!, numericValue));
inputValue = String(constrainedValue);
setInternalValue(constrainedValue);
} else {
setInternalValue(inputValue);
}
setValue(inputValue === "0" ? "" : inputValue);
}
} else {
setValue(inputValue);
setInternalValue(inputValue);
}
const outputValue = processValue(inputValue);
onValueChange?.(outputValue);
};
const handleBlur = () => {
if (props.type === "number" && value) {
if (value === "-" || value === ".") {
setValue("");
setInternalValue("");
onValueBlur?.("" as T);
return;
}
if (value === "0") {
setValue("");
onValueBlur?.(processValue(0));
return;
}
}
const outputValue = processValue(value);
if ((initialValue || "") !== outputValue) {
onValueBlur?.(outputValue);
}
};
const renderPrefix = () =>
typeof prefix === "string" ? (
<div className="relative mr-px flex h-9 items-center text-nowrap bg-muted px-3">
{prefix}
</div>
) : (
prefix
);
const renderSuffix = () =>
typeof suffix === "string" ? (
<div className="relative ml-px flex h-9 items-center text-nowrap bg-muted px-3">
{suffix}
</div>
) : (
suffix
);
return (
<div
className={cn(
"flex w-full items-center overflow-hidden rounded-md border border-input",
className
)}
suppressHydrationWarning
>
{renderPrefix()}
<Input
autoComplete="off"
step={0.01}
{...props}
className="block rounded-none border-none"
onBlur={handleBlur}
onChange={handleChange}
value={value}
/>
{renderSuffix()}
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
"use client";
// import { icons as FlagPack } from '@iconify-json/flagpack';
// import { icons as Logos } from '@iconify-json/logos';
// import { icons as Mdi } from '@iconify-json/mdi';
// import { icons as Simple } from '@iconify-json/simple-icons';
// import { icons as Uil } from '@iconify-json/uil';
import { Icon as Iconify, type IconProps } from "@iconify/react";
// addCollection(FlagPack);
// addCollection(Mdi);
// addCollection(Uil);
// addCollection(Simple);
// addCollection(Logos);
export function Icon(props: IconProps) {
return <Iconify {...props} />;
}
@@ -0,0 +1,58 @@
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import { useLanguage } from "@workspace/ui/integrations/language";
import { Check, Languages } from "lucide-react";
import { useTranslation } from "react-i18next";
const languages = [
{
code: "en-US",
name: "English",
flag: "🇺🇸",
},
{
code: "zh-CN",
name: "中文",
flag: "🇨🇳",
},
];
export function LanguageSwitch() {
const { language, changeLanguage } = useLanguage();
const { t } = useTranslation("components");
const currentLanguage = languages.find((lang) => lang.code === language);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="scale-95 rounded-full" size="icon" variant="ghost">
<Languages className="h-[1.2rem] w-[1.2rem]" />
<span className="sr-only">{t("language", "Language")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{languages.map((language) => (
<DropdownMenuItem
className="flex items-center justify-between"
key={language.code}
onClick={() => changeLanguage(language.code as "en-US" | "zh-CN")}
>
<div className="flex items-center gap-2">
<span>{language.flag}</span>
<span>{language.name}</span>
</div>
{currentLanguage?.code === language.code && (
<Check className="h-4 w-4" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
+89
View File
@@ -0,0 +1,89 @@
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@workspace/ui/components/popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@workspace/ui/components/tooltip";
import { cn } from "@workspace/ui/lib/utils";
import { useEffect, useRef, useState } from "react";
type LongTextProps = {
children: React.ReactNode;
className?: string;
contentClassName?: string;
};
export function LongText({
children,
className = "",
contentClassName = "",
}: LongTextProps) {
const ref = useRef<HTMLDivElement>(null);
const [isOverflown, setIsOverflown] = useState(false);
useEffect(() => {
if (checkOverflow(ref.current)) {
setIsOverflown(true);
return;
}
setIsOverflown(false);
}, []);
if (!isOverflown)
return (
<div className={cn("truncate", className)} ref={ref}>
{children}
</div>
);
return (
<>
<div className="hidden sm:block">
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<div className={cn("truncate", className)} ref={ref}>
{children}
</div>
</TooltipTrigger>
<TooltipContent>
<p className={cn("whitespace-pre-line", contentClassName)}>
{children}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="sm:hidden">
<Popover>
<PopoverTrigger asChild>
<div className={cn("truncate", className)} ref={ref}>
{children}
</div>
</PopoverTrigger>
<PopoverContent
className={cn("w-fit whitespace-pre-line", contentClassName)}
>
<p>{children}</p>
</PopoverContent>
</Popover>
</div>
</>
);
}
const checkOverflow = (textContainer: HTMLDivElement | null) => {
if (textContainer) {
return (
textContainer.offsetHeight < textContainer.scrollHeight ||
textContainer.offsetWidth < textContainer.scrollWidth
);
}
return false;
};
+244
View File
@@ -0,0 +1,244 @@
"use client";
import { Button } from "@workspace/ui/components/button";
import { cn } from "@workspace/ui/lib/utils";
import { Check, Copy } from "lucide-react";
import { useCallback, useState } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } 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, ...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 my-4 w-full overflow-hidden rounded-lg">
<div className="flex items-center justify-between gap-4 bg-muted px-4 py-2 font-semibold text-sm">
<span className="lowercase [&>span]:text-xs">{match[1]}</span>
<Button
className="absolute top-0 right-2 z-20 p-0.5 opacity-0 transition-opacity duration-200 group-hover:opacity-100"
onClick={() => handleCopy(String(children).replace(/\n$/, ""))}
size="icon"
variant="ghost"
>
{copied ? <Check size={16} /> : <Copy size={16} />}
</Button>
</div>
<SyntaxHighlighter
{...props}
customStyle={{
margin: 0,
borderRadius: 0,
}}
language={match[1]}
PreTag="div"
showLineNumbers
style={oneDark}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
</div>
);
}
return (
<code {...props} className={cn(className, "rounded border font-semibold")}>
{children}
</code>
);
}
interface MarkdownProps {
children: string;
components?: Components;
}
export function Markdown({ children, components }: MarkdownProps) {
return (
<div className="prose dark:prose-invert wrap-break-word w-full max-w-[unset]">
<ReactMarkdown
components={{
h1: ({ node, className, ...props }) => (
<h1
className={cn(
"mb-8 scroll-m-20 font-extrabold text-4xl tracking-tight last:mb-0",
className
)}
{...props}
/>
),
h2: ({ node, className, ...props }) => (
<h2
className={cn(
"mt-8 mb-4 scroll-m-20 font-semibold text-3xl tracking-tight first:mt-0 last:mb-0",
className
)}
{...props}
/>
),
h3: ({ node, className, ...props }) => (
<h3
className={cn(
"mt-6 mb-4 scroll-m-20 font-semibold text-2xl tracking-tight first:mt-0 last:mb-0",
className
)}
{...props}
/>
),
h4: ({ node, className, ...props }) => (
<h4
className={cn(
"mt-6 mb-4 scroll-m-20 font-semibold text-xl tracking-tight first:mt-0 last:mb-0",
className
)}
{...props}
/>
),
h5: ({ node, className, ...props }) => (
<h5
className={cn(
"my-4 font-semibold text-lg first:mt-0 last:mb-0",
className
)}
{...props}
/>
),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
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(
"mt-5 mb-5 leading-7 first:mt-0 last:mb-0",
className
)}
{...props}
/>
),
a: ({ node, className, ...props }) => (
<a
className={cn(
"font-medium text-primary underline underline-offset-4",
className
)}
target="_blank"
{...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)} />;
},
...components,
}}
rehypePlugins={[rehypeRaw, rehypeKatex]}
remarkPlugins={[remarkGfm, remarkToc, remarkMath]}
>
{children}
</ReactMarkdown>
</div>
);
}
@@ -0,0 +1,25 @@
import { useRouterState } from "@tanstack/react-router";
import { useEffect, useRef } from "react";
import LoadingBar, { type LoadingBarRef } from "react-top-loading-bar";
export function NavigationProgress() {
const ref = useRef<LoadingBarRef>(null);
const state = useRouterState();
useEffect(() => {
if (state.status === "pending") {
ref.current?.continuousStart();
} else {
ref.current?.complete();
}
}, [state.status]);
return (
<LoadingBar
color="var(--muted-foreground)"
height={2}
ref={ref}
shadow={true}
/>
);
}
@@ -0,0 +1,42 @@
import { Button } from "@workspace/ui/components/button";
import { Input } from "@workspace/ui/components/input";
import { cn } from "@workspace/ui/lib/utils";
import { Eye, EyeOff } from "lucide-react";
import * as React from "react";
type PasswordInputProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"type"
> & {
ref?: React.Ref<HTMLInputElement>;
};
export function PasswordInput({
className,
disabled,
ref,
...props
}: PasswordInputProps) {
const [showPassword, setShowPassword] = React.useState(false);
return (
<div className={cn("relative rounded-md", className)}>
<Input
disabled={disabled}
ref={ref}
type={showPassword ? "text" : "password"}
{...props}
/>
<Button
className="-translate-y-1/2 absolute end-1 top-1/2 h-6 w-6 rounded-md text-muted-foreground"
disabled={disabled}
onClick={() => setShowPassword((prev) => !prev)}
size="icon"
type="button"
variant="ghost"
>
{showPassword ? <Eye size={18} /> : <EyeOff size={18} />}
</Button>
</div>
);
}
@@ -0,0 +1,95 @@
"use client";
import type { Table } from "@tanstack/react-table";
import { Input } from "@workspace/ui/components/input";
import { Combobox } from "@workspace/ui/composed/combobox";
export interface IParams {
key: string;
placeholder?: string;
options?: { label: string; value: string }[];
type?: "text" | "select" | "date";
}
interface ColumnFilterProps<TData> {
table: Table<TData>;
params: IParams[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
filters?: any;
}
export function ColumnFilter<TData>({
table,
params,
filters,
}: ColumnFilterProps<TData>) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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;
});
};
const toDateInput = (d: Date) => {
const pad = (n: number) => String(n).padStart(2, "0");
const yyyy = d.getFullYear();
const MM = pad(d.getMonth() + 1);
const dd = pad(d.getDate());
return `${yyyy}-${MM}-${dd}`;
};
return (
<div className="flex gap-2">
{params.map((param) => {
if (param.options || param.type === "select") {
return (
<Combobox
className="w-32"
key={param.key}
onChange={(value) => {
updateFilter(param.key, value);
}}
options={param.options}
placeholder={param.placeholder || "Choose..."}
value={filters[param.key] || ""}
/>
);
}
if (param.type === "date") {
const raw = filters[param.key];
const inputValue =
typeof raw === "number"
? toDateInput(new Date(raw))
: typeof raw === "string"
? raw
: "";
return (
<Input
className="block w-32"
key={param.key}
onChange={(event) => {
const v = event.target.value;
updateFilter(param.key, v || "");
}}
placeholder={param.placeholder}
type="date"
value={inputValue}
/>
);
}
return (
<Input
className="w-32"
key={param.key}
onChange={(event) => updateFilter(param.key, event.target.value)}
placeholder={param.placeholder || "Search..."}
value={filters[param.key] || ""}
/>
);
})}
</div>
);
}
@@ -0,0 +1,5 @@
export {
ProList,
type ProListActions,
type ProListProps,
} from "@workspace/ui/composed/pro-list/pro-list";
@@ -0,0 +1,112 @@
import type { Table } from "@tanstack/react-table";
import { Button } from "@workspace/ui/components/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronsLeftIcon,
ChevronsRightIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
interface PaginationProps<TData> {
table: Table<TData>;
}
export function Pagination<TData>({ table }: PaginationProps<TData>) {
const { t } = useTranslation("components");
return (
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex-1 whitespace-nowrap text-center text-muted-foreground sm:text-left">
{t("pagination.pageInfo", "Page {{page}} of {{total}}", {
page: table.getState().pagination.pageIndex + 1,
total: table.getPageCount(),
})}
</div>
<div className="flex flex-grow items-center justify-center gap-2 sm:justify-end">
<div className="flex items-center space-x-2">
<p className="font-medium">
{t("pagination.rowsPerPage", "Rows per page")}
</p>
<Select
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
value={`${table.getState().pagination.pageSize}`}
>
<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
className="hidden lg:flex"
disabled={!table.getCanPreviousPage()}
onClick={() => table.setPageIndex(0)}
size="icon"
variant="outline"
>
<span className="sr-only">Go to first page</span>
<ChevronsLeftIcon className="h-4 w-4" />
</Button>
<Button
disabled={!table.getCanPreviousPage()}
onClick={() => table.previousPage()}
size="icon"
variant="outline"
>
<span className="sr-only">Go to previous page</span>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<Select
onValueChange={(value) => table.setPageIndex(Number(value) - 1)}
value={`${table.getState().pagination.pageIndex + 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
disabled={!table.getCanNextPage()}
onClick={() => table.nextPage()}
size="icon"
variant="outline"
>
<span className="sr-only">Go to next page</span>
<ChevronRightIcon className="h-4 w-4" />
</Button>
<Button
className="hidden lg:flex"
disabled={!table.getCanNextPage()}
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
size="icon"
variant="outline"
>
<span className="sr-only">Go to last page</span>
<ChevronsRightIcon className="h-4 w-4" />
</Button>
</div>
</div>
);
}
@@ -0,0 +1,231 @@
"use client";
import {
type ColumnFiltersState,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@workspace/ui/components/alert";
import { Button } from "@workspace/ui/components/button";
import { Checkbox } from "@workspace/ui/components/checkbox";
import Empty from "@workspace/ui/composed/empty";
import {
ColumnFilter,
type IParams,
} from "@workspace/ui/composed/pro-list/column-filter";
import { Pagination } from "@workspace/ui/composed/pro-list/pagination";
import { cn } from "@workspace/ui/lib/utils";
import { ListRestart, Loader, RefreshCcw } from "lucide-react";
import type React from "react";
import { useEffect, useImperativeHandle, useState } from "react";
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;
}>;
empty?: React.ReactNode;
}
export interface ProListActions {
refresh: () => void;
reset: () => void;
}
export function ProList<TData, TValue extends Record<string, unknown>>({
request,
params,
header,
batchRender,
renderItem,
action,
texts,
empty,
}: 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,
});
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();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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
filters={Object.fromEntries(
columnFilters.map((item) => [item.id, item.value])
)}
params={params}
table={table}
/>
) : (
header?.title
)}
</div>
<div className="flex flex-1 items-center justify-end gap-2">
{params && params?.length > 0 && (
<>
<Button
className="h-8 w-8 p-2"
onClick={fetchData}
variant="outline"
>
<RefreshCcw className="h-4 w-4" />
</Button>
<Button className="h-8 w-8 p-2" onClick={reset} variant="outline">
<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={cn("relative overflow-x-auto", {
"rounded-xl border": data.length === 0,
})}
>
<div className="grid grid-cols-1 gap-4">
{data.length ? (
data.map((item, index) => {
const isSelected = !!rowSelection[index];
const checkbox = (
<Checkbox
aria-label="Select row"
checked={isSelected}
onCheckedChange={(value) =>
handleSelectionChange(index, !!value)
}
/>
);
return <div key={index}>{renderItem(item, checkbox)}</div>;
})
) : (
<div className="flex items-center justify-center py-24">
{empty || <Empty />}
</div>
)}
</div>
{loading && (
<div className="absolute top-0 z-20 flex h-full w-full items-center justify-center bg-muted/80">
<Loader className="h-4 w-4 animate-spin" />
</div>
)}
</div>
{rowCount > 0 && <Pagination table={table} />}
</div>
);
}
@@ -0,0 +1,95 @@
"use client";
import type { Table } from "@tanstack/react-table";
import { Input } from "@workspace/ui/components/input";
import { Combobox } from "@workspace/ui/composed/combobox";
export interface IParams {
key: string;
placeholder?: string;
options?: { label: string; value: string }[];
type?: "text" | "select" | "date";
}
interface ColumnFilterProps<TData> {
table: Table<TData>;
params: IParams[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
filters?: any;
}
export function ColumnFilter<TData>({
table,
params,
filters,
}: ColumnFilterProps<TData>) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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;
});
};
const toDateInput = (d: Date) => {
const pad = (n: number) => String(n).padStart(2, "0");
const yyyy = d.getFullYear();
const MM = pad(d.getMonth() + 1);
const dd = pad(d.getDate());
return `${yyyy}-${MM}-${dd}`;
};
return (
<div className="flex gap-2">
{params.map((param) => {
if (param.options || param.type === "select") {
return (
<Combobox
className="min-w-32 max-w-48"
key={param.key}
onChange={(value) => {
updateFilter(param.key, value);
}}
options={param.options}
placeholder={param.placeholder || "Choose..."}
value={filters[param.key] || ""}
/>
);
}
if (param.type === "date") {
const raw = filters[param.key];
const inputValue =
typeof raw === "number"
? toDateInput(new Date(raw))
: typeof raw === "string"
? raw
: "";
return (
<Input
className="block min-w-32"
key={param.key}
onChange={(event) => {
const v = event.target.value;
updateFilter(param.key, v || "");
}}
placeholder={param.placeholder}
type="date"
value={inputValue}
/>
);
}
return (
<Input
className="min-w-32"
key={param.key}
onChange={(event) => updateFilter(param.key, event.target.value)}
placeholder={param.placeholder || "Search..."}
value={filters[param.key] || ""}
/>
);
})}
</div>
);
}
@@ -0,0 +1,81 @@
import { flexRender, type Header } from "@tanstack/react-table";
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import { cn } from "@workspace/ui/lib/utils";
import {
ArrowDownIcon,
ArrowDownUpIcon,
ArrowUpIcon,
EyeOffIcon,
} from "lucide-react";
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
className="!bg-transparent flex h-8 w-full justify-start p-0 text-sm"
variant="ghost"
>
<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" />
) : (
<ArrowDownUpIcon className="ml-2 h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUpIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
{text?.asc || "ASC"}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDownIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
{text?.desc || "DESC"}
</DropdownMenuItem>
{column.getCanHide() && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
<EyeOffIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
{text?.hide || "Hide"}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
@@ -0,0 +1,54 @@
"use client";
import type { Table } from "@tanstack/react-table";
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import { ListTodoIcon } from "lucide-react";
import type { ReactNode } from "react";
interface ColumnToggleProps<TData> {
table: Table<TData>;
}
export function ColumnToggle<TData>({ table }: ColumnToggleProps<TData>) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="outline">
<ListTodoIcon />
</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
checked={column.getIsVisible()}
className="capitalize"
disabled={
columns.length === 1 && columns?.[0]?.id === column.id
}
key={column.id}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{column.columnDef.header as ReactNode}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,5 @@
export {
ProTable,
type ProTableActions,
type ProTableProps,
} from "@workspace/ui/composed/pro-table/pro-table";
@@ -0,0 +1,112 @@
import type { Table } from "@tanstack/react-table";
import { Button } from "@workspace/ui/components/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronsLeftIcon,
ChevronsRightIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
interface PaginationProps<TData> {
table: Table<TData>;
}
export function Pagination<TData>({ table }: PaginationProps<TData>) {
const { t } = useTranslation("components");
return (
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex-1 whitespace-nowrap text-center text-muted-foreground sm:text-left">
{t("pagination.pageInfo", "Page {{page}} of {{total}}", {
page: table.getState().pagination.pageIndex + 1,
total: table.getPageCount(),
})}
</div>
<div className="flex flex-grow items-center justify-center gap-2 sm:justify-end">
<div className="flex items-center space-x-2">
<p className="font-medium">
{t("pagination.rowsPerPage", "Rows per page")}
</p>
<Select
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
value={`${table.getState().pagination.pageSize}`}
>
<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
className="hidden lg:flex"
disabled={!table.getCanPreviousPage()}
onClick={() => table.setPageIndex(0)}
size="icon"
variant="outline"
>
<span className="sr-only">Go to first page</span>
<ChevronsLeftIcon className="h-4 w-4" />
</Button>
<Button
disabled={!table.getCanPreviousPage()}
onClick={() => table.previousPage()}
size="icon"
variant="outline"
>
<span className="sr-only">Go to previous page</span>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<Select
onValueChange={(value) => table.setPageIndex(Number(value) - 1)}
value={`${table.getState().pagination.pageIndex + 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
disabled={!table.getCanNextPage()}
onClick={() => table.nextPage()}
size="icon"
variant="outline"
>
<span className="sr-only">Go to next page</span>
<ChevronRightIcon className="h-4 w-4" />
</Button>
<Button
className="hidden lg:flex"
disabled={!table.getCanNextPage()}
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
size="icon"
variant="outline"
>
<span className="sr-only">Go to last page</span>
<ChevronsRightIcon className="h-4 w-4" />
</Button>
</div>
</div>
);
}
@@ -0,0 +1,437 @@
"use client";
import {
type ColumnDef,
type ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type SortingState,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@workspace/ui/components/alert";
import { Button } from "@workspace/ui/components/button";
import { Checkbox } from "@workspace/ui/components/checkbox";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@workspace/ui/components/table";
import Empty from "@workspace/ui/composed/empty";
import {
ColumnFilter,
type IParams,
} from "@workspace/ui/composed/pro-table/column-filter";
import { ColumnHeader } from "@workspace/ui/composed/pro-table/column-header";
import { ColumnToggle } from "@workspace/ui/composed/pro-table/column-toggle";
import { Pagination } from "@workspace/ui/composed/pro-table/pagination";
import { SortableRow } from "@workspace/ui/composed/pro-table/sortable-row";
import { ProTableWrapper } from "@workspace/ui/composed/pro-table/wrapper";
import { cn } from "@workspace/ui/lib/utils";
import { useSize } from "ahooks";
import { GripVertical, ListRestart, Loader, RefreshCcw } from "lucide-react";
import type React from "react";
import {
Fragment,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
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[];
hidden?: boolean;
};
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;
}>;
empty?: React.ReactNode;
onSort?: (
sourceId: string | number,
targetId: string | number | null,
items: TData[]
) => Promise<TData[]>;
initialFilters?: Record<string, unknown>;
}
export interface ProTableActions {
refresh: () => void;
reset: () => void;
}
export function ProTable<
TData extends Record<string, unknown> & { id?: string | number },
TValue extends Record<string, unknown>,
>({
columns,
request,
params,
header,
actions,
action,
texts,
empty,
onSort,
initialFilters,
}: ProTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>(() => {
if (initialFilters) {
return Object.entries(initialFilters).map(([id, value]) => ({
id,
value,
})) as ColumnFiltersState;
}
return [];
});
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: 10,
});
const loading = useRef(false);
const table = useReactTable({
data,
columns: [
...(onSort
? [
{
id: "sortable",
header: (
<GripVertical className="h-4 w-4 cursor-move text-gray-500 hover:text-gray-700" />
),
enableSorting: false,
enableHiding: false,
},
]
: []),
...(actions?.batchRender ? [createSelectColumn<TData, TValue>()] : []),
...columns.map(
(column) =>
({
enableSorting: false,
...column,
}) as ColumnDef<TData, TValue>
),
...(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>[])
: []),
] 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,
manualSorting: true,
});
const fetchData = async () => {
if (loading.current) return;
loading.current = 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 {
loading.current = 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();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
pagination.pageIndex,
pagination.pageSize,
JSON.stringify(columnFilters),
]);
const selectedRows = table
.getSelectedRowModel()
.flatRows.map((row) => row.original);
const selectedCount = selectedRows.length;
return (
<div className="flex flex-col gap-4" ref={ref}>
{!header?.hidden && (
<div className="flex flex-wrap-reverse items-center justify-between gap-4">
<div>
{params ? (
<ColumnFilter
filters={Object.fromEntries(
columnFilters.map((item) => [item.id, item.value])
)}
params={params}
table={table}
/>
) : (
header?.title
)}
</div>
<div className="flex flex-1 items-center justify-end gap-2">
<Button onClick={fetchData} size="icon" variant="outline">
<RefreshCcw />
</Button>
<ColumnToggle table={table} />
<Button onClick={reset} size="icon" variant="outline">
<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,
}}
>
<ProTableWrapper data={data} onSort={onSort} setData={setData}>
<Table className="w-full">
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
className={cn(
"!z-auto",
getTableHeaderClass(header.column.id)
)}
key={header.id}
>
<ColumnHeader
header={header}
text={{
asc: texts?.asc,
desc: texts?.desc,
hide: texts?.hide,
}}
/>
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel()?.rows?.length ? (
onSort ? (
table.getRowModel().rows.map((row) => (
<SortableRow
data-state={row.getIsSelected() && "selected"}
id={
row.original.id
? String(row.original.id)
: String(row.index)
}
isSortable
key={
row.original.id
? String(row.original.id)
: String(row.index)
}
>
{row
.getVisibleCells()
.filter((cell) => cell.column.id !== "sortable")
.map((cell) => (
<TableCell
className={getTableCellClass(cell.column.id)}
key={cell.id}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</SortableRow>
))
) : (
table.getRowModel().rows.map((row) => (
<TableRow
data-state={row.getIsSelected() && "selected"}
key={row.id}
>
{row.getVisibleCells().map((cell) => (
<TableCell
className={getTableCellClass(cell.column.id)}
key={cell.id}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
)
) : (
<TableRow>
<TableCell className="py-24" colSpan={columns.length + 2}>
{empty || <Empty />}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</ProTableWrapper>
{loading.current && (
<div className="absolute top-0 z-20 flex h-full w-full items-center justify-center bg-muted/80">
<Loader className="h-4 w-4 animate-spin" />
</div>
)}
</div>
{rowCount > 0 && <Pagination table={table} />}
</div>
);
}
function createSelectColumn<TData, TValue>(): ColumnDef<TData, TValue> {
return {
id: "selected",
header: ({ table }) => (
<Checkbox
aria-label="Select all"
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
/>
),
cell: ({ row }) => (
<Checkbox
aria-label="Select row"
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
/>
),
enableSorting: false,
enableHiding: false,
};
}
function getTableHeaderClass(columnId: string) {
if (["sortable", "selected"].includes(columnId)) {
return "sticky left-0 z-10 bg-background shadow-[2px_0_5px_-2px_rgba(0,0,0,0.1)] [&:has([role=checkbox])]:pr-2";
}
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 (["sortable", "selected"].includes(columnId)) {
return "sticky left-0 bg-background shadow-[2px_0_5px_-2px_rgba(0,0,0,0.1)]";
}
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,40 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { TableCell, TableRow } from "@workspace/ui/components/table";
import { GripVertical } from "lucide-react";
import type React from "react";
interface SortableRowProps {
id: string;
children: React.ReactNode;
isSortable: boolean;
}
export function SortableRow({ id, children, isSortable }: SortableRowProps) {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({
id,
disabled: !isSortable,
});
const style = {
transform: CSS.Transform.toString({
x: 0,
y: transform?.y || 0,
scaleX: transform?.scaleX || 1,
scaleY: transform?.scaleY || 1,
}),
transition,
};
return (
<TableRow ref={setNodeRef} style={style}>
{isSortable ? (
<TableCell className="cursor-move" {...listeners} {...attributes}>
<GripVertical className="h-4 w-4 cursor-move text-gray-500 hover:text-gray-700" />
</TableCell>
) : null}
{children}
</TableRow>
);
}
@@ -0,0 +1,58 @@
import {
closestCenter,
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
export function ProTableWrapper<TData extends { id?: string | number }>({
children,
onSort,
data,
setData,
}: {
children: React.ReactNode;
onSort?: (
sourceId: string | number,
targetId: string | number | null,
items: TData[]
) => Promise<TData[]>;
data: TData[];
setData: React.Dispatch<React.SetStateAction<TData[]>>;
}) {
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (onSort) {
const updatedData = await onSort(active.id, over?.id || null, data);
setData(updatedData);
}
};
if (!onSort) return children;
return (
<DndContext
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
sensors={sensors}
>
<SortableContext
items={data.map((item) => String(item.id))}
strategy={verticalListSortingStrategy}
>
{children}
</SortableContext>
</DndContext>
);
}
@@ -0,0 +1,63 @@
import { FormControl } from "@workspace/ui/components/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import { cn } from "@workspace/ui/lib/utils";
import { Loader } from "lucide-react";
import type { ReactNode } from "react";
type SelectDropdownProps = {
onValueChange?: (value: string) => void;
defaultValue: string | undefined;
placeholder?: string;
isPending?: boolean;
items: { label: string | ReactNode; value: string }[] | undefined;
disabled?: boolean;
className?: string;
isControlled?: boolean;
};
export function SelectDropdown({
defaultValue,
onValueChange,
isPending,
items,
placeholder,
disabled,
className = "",
isControlled = false,
}: SelectDropdownProps) {
const defaultState = isControlled
? { value: defaultValue, onValueChange }
: { defaultValue, onValueChange };
return (
<Select {...defaultState}>
<FormControl>
<SelectTrigger className={cn(className)} disabled={disabled}>
<SelectValue placeholder={placeholder ?? "Select"} />
</SelectTrigger>
</FormControl>
<SelectContent>
{isPending ? (
<SelectItem className="h-14" disabled value="loading">
<div className="flex items-center justify-center gap-2">
<Loader className="h-5 w-5 animate-spin" />
{" "}
Loading...
</div>
</SelectItem>
) : (
items?.map(({ label, value }) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))
)}
</SelectContent>
</Select>
);
}
+177
View File
@@ -0,0 +1,177 @@
import { Badge } from "@workspace/ui/components/badge";
import { Input } from "@workspace/ui/components/input";
import { cn } from "@workspace/ui/lib/utils";
import { X } from "lucide-react";
import type React from "react";
import { useEffect, useRef, useState } from "react";
interface TagInputProps {
value?: string[];
onChange?: (tags: string[]) => void;
placeholder?: string;
separator?: string;
className?: string;
options?: string[];
}
export function TagInput({
value = [],
onChange,
placeholder,
separator = ",",
className,
options = [],
}: TagInputProps) {
const [inputValue, setInputValue] = useState("");
const [tags, setTags] = useState<string[]>(value);
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setTags(value.map((tag) => tag.trim()).filter((tag) => tag));
}, [value]);
function normalizeInput(input: string) {
return input.replace(//g, ",");
}
function addTag(tagValue?: string) {
let tagsToAdd: string[] = [];
let shouldKeepOpen = false;
if (tagValue) {
if (!tags.includes(tagValue)) {
tagsToAdd = [tagValue];
shouldKeepOpen = true;
}
} else if (inputValue.trim()) {
const normalizedInput = normalizeInput(inputValue);
tagsToAdd = normalizedInput
.split(separator)
.map((tag) => tag.trim())
.filter((tag) => tag && !tags.includes(tag));
}
if (tagsToAdd.length > 0) {
const updatedTags = [...tags, ...tagsToAdd];
updateTags(updatedTags);
}
setInputValue("");
if (shouldKeepOpen && options.length > 0) {
setTimeout(() => {
setOpen(true);
}, 10);
} else {
setOpen(false);
}
}
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (
event.key === "Enter" ||
event.key === separator ||
event.key === ""
) {
event.preventDefault();
addTag();
} else if (event.key === "Backspace" && inputValue === "") {
event.preventDefault();
handleRemoveTag(tags.length - 1);
} else if (event.key === "Escape") {
setOpen(false);
}
}
function handleInputFocus() {
if (options.length > 0) {
setOpen(true);
}
}
function handleInputBlur() {
if (inputValue.trim()) addTag();
setOpen(false);
}
function handleRemoveTag(index: number) {
const newTags = tags.filter((_, i) => i !== index);
updateTags(newTags);
}
function updateTags(newTags: string[]) {
setTags(newTags);
onChange?.(newTags);
}
const availableOptions = options
.filter((option) => !tags.includes(option))
.filter(
(option) =>
inputValue.trim() === "" ||
option.toLowerCase().includes(inputValue.toLowerCase())
);
return (
<div className={cn("relative", className)}>
<div
className={cn(
"flex min-h-9 w-full cursor-text flex-wrap items-center gap-2 rounded-md border border-input bg-transparent p-2 shadow-sm transition-colors focus-within:ring-0 focus-within:ring-primary"
)}
onClick={() => inputRef.current?.focus()}
>
{tags.map((tag, index) => (
<Badge
className="flex items-center gap-1 border-primary bg-primary/10 px-1"
key={tag}
onClick={(e) => e.stopPropagation()}
variant="outline"
>
{tag}
<X
className="size-4 cursor-pointer rounded-sm hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleRemoveTag(index);
}}
/>
</Badge>
))}
<div className="flex min-w-0 flex-1 items-center gap-2">
<Input
className="!ring-0 h-full min-w-0 flex-1 border-none bg-transparent p-0 shadow-none"
onBlur={handleInputBlur}
onChange={(e) => setInputValue(e.target.value)}
onFocus={handleInputFocus}
onKeyDown={handleKeyDown}
placeholder={placeholder}
ref={inputRef}
value={inputValue}
/>
{open && availableOptions.length > 0 && (
<div className="absolute top-full left-0 z-50 max-h-60 w-full overflow-auto rounded-md border bg-popover text-popover-foreground shadow-md">
{availableOptions.map((option) => (
<div
className="relative flex cursor-pointer select-none items-center px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
key={option}
onMouseDown={(e) => {
e.preventDefault();
addTag(option);
setTimeout(() => {
inputRef.current?.focus();
}, 10);
}}
>
{option}
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
export default TagInput;
+60
View File
@@ -0,0 +1,60 @@
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import { useTheme } from "@workspace/ui/integrations/theme";
import { cn } from "@workspace/ui/lib/utils";
import { Check, Moon, Sun } from "lucide-react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
export function ThemeSwitch() {
const { t } = useTranslation("components");
const { theme, setTheme } = useTheme();
/* Update theme-color meta tag
* when theme is updated */
useEffect(() => {
const themeColor = theme === "dark" ? "#020817" : "#fff";
const metaThemeColor = document.querySelector("meta[name='theme-color']");
if (metaThemeColor) metaThemeColor.setAttribute("content", themeColor);
}, [theme]);
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button className="scale-95 rounded-full" size="icon" variant="ghost">
<Sun className="dark:-rotate-90 size-[1.2rem] rotate-0 scale-100 transition-all dark:scale-0" />
<Moon className="absolute size-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">{t("theme.toggle", "Toggle theme")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
{t("theme.light", "Light")}{" "}
<Check
className={cn("ms-auto", theme !== "light" && "hidden")}
size={14}
/>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
{t("theme.dark", "Dark")}
<Check
className={cn("ms-auto", theme !== "dark" && "hidden")}
size={14}
/>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
{t("theme.system", "System")}
<Check
className={cn("ms-auto", theme !== "system" && "hidden")}
size={14}
/>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
+120
View File
@@ -0,0 +1,120 @@
import { Input } from "@workspace/ui/components/input";
import { Label } from "@workspace/ui/components/label";
import { cn } from "@workspace/ui/lib/utils";
import { Upload } from "lucide-react";
import { useState } from "react";
type ReturnType = "base64" | "file";
interface UploadImageProps {
onChange: (value: string | File) => void;
returnType?: ReturnType;
id?: string;
children?: React.ReactNode;
className?: string;
maxSize?: number; // Maximum file size in MB
}
export const UploadImage = ({
onChange,
returnType = "base64",
id = "image-upload",
children,
className,
maxSize = 1,
}: UploadImageProps) => {
const [isDragging, setIsDragging] = useState(false);
const toBase64 = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result as string);
reader.onerror = (error) => reject(error);
});
const validateFileSize = (file: File): boolean => {
const maxSizeInBytes = maxSize * 1024 * 1024;
if (file.size > maxSizeInBytes) {
alert(`File size exceeds the limit (${maxSize}MB)`);
return false;
}
return true;
};
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
if (!validateFileSize(file)) return;
if (returnType === "base64") {
const base64 = await toBase64(file);
onChange(base64);
} else {
onChange(file);
}
} catch (error) {
console.error(error);
}
};
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = async (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files?.[0];
if (!file) return;
try {
if (!validateFileSize(file)) return;
if (returnType === "base64") {
const base64 = await toBase64(file);
onChange(base64);
} else {
onChange(file);
}
} catch (error) {
console.error(error);
}
};
return (
<>
<Input
accept="image/*"
className="hidden"
id={id}
onChange={handleImageUpload}
type="file"
/>
<Label
className={cn(
"cursor-pointer",
!children &&
"flex items-center justify-center rounded-lg border-2 border-dashed p-4",
isDragging && "border-primary bg-muted/50",
className
)}
htmlFor={id}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{children || <Upload />}
</Label>
</>
);
};