forked from admin/hair-keeper
Hair Keeper v1.0.0:一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑
This commit is contained in:
65
src/components/ai-elements/actions.tsx
Normal file
65
src/components/ai-elements/actions.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type ActionsProps = ComponentProps<"div">;
|
||||
|
||||
export const Actions = ({ className, children, ...props }: ActionsProps) => (
|
||||
<div className={cn("flex items-center gap-1", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const Action = ({
|
||||
tooltip,
|
||||
children,
|
||||
label,
|
||||
className,
|
||||
variant = "ghost",
|
||||
size = "sm",
|
||||
...props
|
||||
}: ActionProps) => {
|
||||
const button = (
|
||||
<Button
|
||||
className={cn(
|
||||
"relative size-9 p-1.5 text-muted-foreground hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
size={size}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<span className="sr-only">{label || tooltip}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
};
|
||||
97
src/components/ai-elements/conversation.tsx
Normal file
97
src/components/ai-elements/conversation.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
||||
|
||||
export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn("relative flex-1 overflow-y-auto", className)}
|
||||
initial="smooth"
|
||||
resize="smooth"
|
||||
role="log"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationContentProps = ComponentProps<
|
||||
typeof StickToBottom.Content
|
||||
>;
|
||||
|
||||
export const ConversationContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationContentProps) => (
|
||||
<StickToBottom.Content className={cn("p-4", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ConversationEmptyState = ({
|
||||
className,
|
||||
title = "No messages yet",
|
||||
description = "Start a conversation to see messages here",
|
||||
icon,
|
||||
children,
|
||||
...props
|
||||
}: ConversationEmptyStateProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ConversationScrollButton = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationScrollButtonProps) => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
scrollToBottom();
|
||||
}, [scrollToBottom]);
|
||||
|
||||
return (
|
||||
!isAtBottom && (
|
||||
<Button
|
||||
className={cn(
|
||||
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full",
|
||||
className
|
||||
)}
|
||||
onClick={handleScrollToBottom}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
{...props}
|
||||
>
|
||||
<ArrowDownIcon className="size-4" />
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
};
|
||||
451
src/components/ai-elements/message.tsx
Normal file
451
src/components/ai-elements/message.tsx
Normal file
@@ -0,0 +1,451 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ButtonGroup,
|
||||
ButtonGroupText,
|
||||
} from "@/components/ui/button-group";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FileUIPart, UIMessage } from "ai";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
PaperclipIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||
import { createContext, memo, useContext, useEffect, useState, useMemo } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
};
|
||||
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex w-full max-w-[80%] flex-col gap-2",
|
||||
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"is-user:dark flex w-fit flex-col gap-2 overflow-hidden text-sm",
|
||||
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
|
||||
"group-[.is-assistant]:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionsProps = ComponentProps<"div">;
|
||||
|
||||
export const MessageActions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageActionsProps) => (
|
||||
<div className={cn("flex items-center gap-1", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const MessageAction = ({
|
||||
tooltip,
|
||||
children,
|
||||
label,
|
||||
variant = "ghost",
|
||||
size = "icon",
|
||||
...props
|
||||
}: MessageActionProps) => {
|
||||
const button = (
|
||||
<Button size={size} type="button" variant={variant} {...props}>
|
||||
{children}
|
||||
<span className="sr-only">{label || tooltip}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
type MessageBranchContextType = {
|
||||
currentBranch: number;
|
||||
totalBranches: number;
|
||||
goToPrevious: () => void;
|
||||
goToNext: () => void;
|
||||
branches: ReactElement[];
|
||||
setBranches: (branches: ReactElement[]) => void;
|
||||
};
|
||||
|
||||
const MessageBranchContext = createContext<MessageBranchContextType | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const useMessageBranch = () => {
|
||||
const context = useContext(MessageBranchContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"MessageBranch components must be used within MessageBranch"
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
|
||||
defaultBranch?: number;
|
||||
onBranchChange?: (branchIndex: number) => void;
|
||||
};
|
||||
|
||||
export const MessageBranch = ({
|
||||
defaultBranch = 0,
|
||||
onBranchChange,
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchProps) => {
|
||||
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
|
||||
const [branches, setBranches] = useState<ReactElement[]>([]);
|
||||
|
||||
const handleBranchChange = (newBranch: number) => {
|
||||
setCurrentBranch(newBranch);
|
||||
onBranchChange?.(newBranch);
|
||||
};
|
||||
|
||||
const goToPrevious = () => {
|
||||
const newBranch =
|
||||
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
const newBranch =
|
||||
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const contextValue: MessageBranchContextType = {
|
||||
currentBranch,
|
||||
totalBranches: branches.length,
|
||||
goToPrevious,
|
||||
goToNext,
|
||||
branches,
|
||||
setBranches,
|
||||
};
|
||||
|
||||
return (
|
||||
<MessageBranchContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
</MessageBranchContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageBranchContent = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchContentProps) => {
|
||||
const { currentBranch, setBranches, branches } = useMessageBranch();
|
||||
const childrenArray = useMemo(
|
||||
() => (Array.isArray(children) ? children : [children]),
|
||||
[children]
|
||||
);
|
||||
|
||||
// Use useEffect to update branches when they change
|
||||
useEffect(() => {
|
||||
if (branches.length !== childrenArray.length) {
|
||||
setBranches(childrenArray);
|
||||
}
|
||||
}, [childrenArray, branches, setBranches]);
|
||||
|
||||
return childrenArray.map((branch, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-2 overflow-hidden [&>div]:pb-0",
|
||||
index === currentBranch ? "block" : "hidden"
|
||||
)}
|
||||
key={branch.key}
|
||||
{...props}
|
||||
>
|
||||
{branch}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
export type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
};
|
||||
|
||||
export const MessageBranchSelector = ({
|
||||
className,
|
||||
from,
|
||||
...props
|
||||
}: MessageBranchSelectorProps) => {
|
||||
const { totalBranches } = useMessageBranch();
|
||||
|
||||
// Don't render if there's only one branch
|
||||
if (totalBranches <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ButtonGroup
|
||||
className="[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md"
|
||||
orientation="horizontal"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MessageBranchPrevious = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchPreviousProps) => {
|
||||
const { goToPrevious, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Previous branch"
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToPrevious}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronLeftIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchNextProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MessageBranchNext = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchNextProps) => {
|
||||
const { goToNext, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Next branch"
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToNext}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const MessageBranchPage = ({
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchPageProps) => {
|
||||
const { currentBranch, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<ButtonGroupText
|
||||
className={cn(
|
||||
"border-none bg-transparent text-muted-foreground shadow-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{currentBranch + 1} of {totalBranches}
|
||||
</ButtonGroupText>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
||||
|
||||
export const MessageResponse = memo(
|
||||
({ className, ...props }: MessageResponseProps) => (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
(prevProps, nextProps) => prevProps.children === nextProps.children
|
||||
);
|
||||
|
||||
MessageResponse.displayName = "MessageResponse";
|
||||
|
||||
export type MessageAttachmentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
data: FileUIPart;
|
||||
className?: string;
|
||||
onRemove?: () => void;
|
||||
};
|
||||
|
||||
export function MessageAttachment({
|
||||
data,
|
||||
className,
|
||||
onRemove,
|
||||
...props
|
||||
}: MessageAttachmentProps) {
|
||||
const filename = data.filename || "";
|
||||
const mediaType =
|
||||
data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
|
||||
const isImage = mediaType === "image";
|
||||
const attachmentLabel = filename || (isImage ? "Image" : "Attachment");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative size-24 overflow-hidden rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{isImage ? (
|
||||
<>
|
||||
<img
|
||||
alt={filename || "attachment"}
|
||||
className="size-full object-cover"
|
||||
height={100}
|
||||
src={data.url}
|
||||
width={100}
|
||||
/>
|
||||
{onRemove && (
|
||||
<Button
|
||||
aria-label="Remove attachment"
|
||||
className="absolute top-2 right-2 size-6 rounded-full bg-background/80 p-0 opacity-0 backdrop-blur-sm transition-opacity hover:bg-background group-hover:opacity-100 [&>svg]:size-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex size-full shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<PaperclipIcon className="size-4" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{attachmentLabel}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{onRemove && (
|
||||
<Button
|
||||
aria-label="Remove attachment"
|
||||
className="size-6 shrink-0 rounded-full p-0 opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100 [&>svg]:size-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type MessageAttachmentsProps = ComponentProps<"div">;
|
||||
|
||||
export function MessageAttachments({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageAttachmentsProps) {
|
||||
if (!children) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"ml-auto flex w-fit flex-wrap items-start gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type MessageToolbarProps = ComponentProps<"div">;
|
||||
|
||||
export const MessageToolbar = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageToolbarProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 flex w-full items-center justify-between gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
205
src/components/ai-elements/model-selector.tsx
Normal file
205
src/components/ai-elements/model-selector.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactNode, ComponentProps } from "react";
|
||||
|
||||
export type ModelSelectorProps = ComponentProps<typeof Dialog>;
|
||||
|
||||
export const ModelSelector = (props: ModelSelectorProps) => (
|
||||
<Dialog {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>;
|
||||
|
||||
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
|
||||
<DialogTrigger {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
|
||||
title?: ReactNode;
|
||||
};
|
||||
|
||||
export const ModelSelectorContent = ({
|
||||
className,
|
||||
children,
|
||||
title = "Model Selector",
|
||||
...props
|
||||
}: ModelSelectorContentProps) => (
|
||||
<DialogContent className={cn("p-0", className)} {...props}>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
);
|
||||
|
||||
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>;
|
||||
|
||||
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
|
||||
<CommandDialog {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>;
|
||||
|
||||
export const ModelSelectorInput = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorInputProps) => (
|
||||
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorListProps = ComponentProps<typeof CommandList>;
|
||||
|
||||
export const ModelSelectorList = (props: ModelSelectorListProps) => (
|
||||
<CommandList {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
|
||||
|
||||
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
|
||||
<CommandEmpty {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>;
|
||||
|
||||
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
|
||||
<CommandGroup {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>;
|
||||
|
||||
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
|
||||
<CommandItem {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>;
|
||||
|
||||
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
|
||||
<CommandShortcut {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorSeparatorProps = ComponentProps<
|
||||
typeof CommandSeparator
|
||||
>;
|
||||
|
||||
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
|
||||
<CommandSeparator {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorLogoProps = Omit<
|
||||
ComponentProps<"img">,
|
||||
"src" | "alt"
|
||||
> & {
|
||||
provider:
|
||||
| "moonshotai-cn"
|
||||
| "lucidquery"
|
||||
| "moonshotai"
|
||||
| "zai-coding-plan"
|
||||
| "alibaba"
|
||||
| "xai"
|
||||
| "vultr"
|
||||
| "nvidia"
|
||||
| "upstage"
|
||||
| "groq"
|
||||
| "github-copilot"
|
||||
| "mistral"
|
||||
| "vercel"
|
||||
| "nebius"
|
||||
| "deepseek"
|
||||
| "alibaba-cn"
|
||||
| "google-vertex-anthropic"
|
||||
| "venice"
|
||||
| "chutes"
|
||||
| "cortecs"
|
||||
| "github-models"
|
||||
| "togetherai"
|
||||
| "azure"
|
||||
| "baseten"
|
||||
| "huggingface"
|
||||
| "opencode"
|
||||
| "fastrouter"
|
||||
| "google"
|
||||
| "google-vertex"
|
||||
| "cloudflare-workers-ai"
|
||||
| "inception"
|
||||
| "wandb"
|
||||
| "openai"
|
||||
| "zhipuai-coding-plan"
|
||||
| "perplexity"
|
||||
| "openrouter"
|
||||
| "zenmux"
|
||||
| "v0"
|
||||
| "iflowcn"
|
||||
| "synthetic"
|
||||
| "deepinfra"
|
||||
| "zhipuai"
|
||||
| "submodel"
|
||||
| "zai"
|
||||
| "inference"
|
||||
| "requesty"
|
||||
| "morph"
|
||||
| "lmstudio"
|
||||
| "anthropic"
|
||||
| "aihubmix"
|
||||
| "fireworks-ai"
|
||||
| "modelscope"
|
||||
| "llama"
|
||||
| "scaleway"
|
||||
| "amazon-bedrock"
|
||||
| "cerebras"
|
||||
| (string & {});
|
||||
};
|
||||
|
||||
export const ModelSelectorLogo = ({
|
||||
provider,
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoProps) => (
|
||||
<img
|
||||
{...props}
|
||||
alt={`${provider} logo`}
|
||||
className={cn("size-3", className)}
|
||||
height={12}
|
||||
src={`https://models.dev/logos/${provider}.svg`}
|
||||
width={12}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ModelSelectorLogoGroupProps = ComponentProps<"div">;
|
||||
|
||||
export const ModelSelectorLogoGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoGroupProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"-space-x-1 flex shrink-0 items-center [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 [&>img]:ring-border",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ModelSelectorNameProps = ComponentProps<"span">;
|
||||
|
||||
export const ModelSelectorName = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorNameProps) => (
|
||||
<span className={cn("flex-1 truncate text-left", className)} {...props} />
|
||||
);
|
||||
1190
src/components/ai-elements/prompt-input.tsx
Normal file
1190
src/components/ai-elements/prompt-input.tsx
Normal file
File diff suppressed because it is too large
Load Diff
178
src/components/ai-elements/reasoning.tsx
Normal file
178
src/components/ai-elements/reasoning.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { createContext, memo, useContext, useEffect, useState } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
import { Shimmer } from "./shimmer";
|
||||
|
||||
type ReasoningContextValue = {
|
||||
isStreaming: boolean;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
duration: number | undefined;
|
||||
};
|
||||
|
||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
||||
|
||||
const useReasoning = () => {
|
||||
const context = useContext(ReasoningContext);
|
||||
if (!context) {
|
||||
throw new Error("Reasoning components must be used within Reasoning");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
const AUTO_CLOSE_DELAY = 1000;
|
||||
const MS_IN_S = 1000;
|
||||
|
||||
export const Reasoning = memo(
|
||||
({
|
||||
className,
|
||||
isStreaming = false,
|
||||
open,
|
||||
defaultOpen = true,
|
||||
onOpenChange,
|
||||
duration: durationProp,
|
||||
children,
|
||||
...props
|
||||
}: ReasoningProps) => {
|
||||
const [isOpen, setIsOpen] = useControllableState({
|
||||
prop: open,
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
});
|
||||
const [duration, setDuration] = useControllableState({
|
||||
prop: durationProp,
|
||||
defaultProp: undefined,
|
||||
});
|
||||
|
||||
const [hasAutoClosed, setHasAutoClosed] = useState(false);
|
||||
const [startTime, setStartTime] = useState<number | null>(null);
|
||||
|
||||
// Track duration when streaming starts and ends
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
if (startTime === null) {
|
||||
setStartTime(Date.now());
|
||||
}
|
||||
} else if (startTime !== null) {
|
||||
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S));
|
||||
setStartTime(null);
|
||||
}
|
||||
}, [isStreaming, startTime, setDuration]);
|
||||
|
||||
// Auto-open when streaming starts, auto-close when streaming ends (once only)
|
||||
useEffect(() => {
|
||||
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
|
||||
// Add a small delay before closing to allow user to see the content
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setHasAutoClosed(true);
|
||||
}, AUTO_CLOSE_DELAY);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed]);
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setIsOpen(newOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<ReasoningContext.Provider
|
||||
value={{ isStreaming, isOpen, setIsOpen, duration }}
|
||||
>
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4", className)}
|
||||
onOpenChange={handleOpenChange}
|
||||
open={isOpen}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
</ReasoningContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
|
||||
|
||||
const getThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||
if (isStreaming || duration === 0) {
|
||||
return <Shimmer duration={1}>思考中...</Shimmer>;
|
||||
}
|
||||
if (duration === undefined) {
|
||||
return <p>正在推理</p>;
|
||||
}
|
||||
return <p>推理时间: {duration} 秒</p>;
|
||||
};
|
||||
|
||||
export const ReasoningTrigger = memo(
|
||||
({ className, children, ...props }: ReasoningTriggerProps) => {
|
||||
const { isStreaming, isOpen, duration } = useReasoning();
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BrainIcon className="size-4" />
|
||||
{getThinkingMessage(isStreaming, duration)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ReasoningContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-4 text-sm",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Streamdown {...props}>{children}</Streamdown>
|
||||
</CollapsibleContent>
|
||||
)
|
||||
);
|
||||
|
||||
Reasoning.displayName = "Reasoning";
|
||||
ReasoningTrigger.displayName = "ReasoningTrigger";
|
||||
ReasoningContent.displayName = "ReasoningContent";
|
||||
22
src/components/ai-elements/response.tsx
Normal file
22
src/components/ai-elements/response.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type ComponentProps, memo } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
type ResponseProps = ComponentProps<typeof Streamdown>;
|
||||
|
||||
export const Response = memo(
|
||||
({ className, ...props }: ResponseProps) => (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
(prevProps, nextProps) => prevProps.children === nextProps.children
|
||||
);
|
||||
|
||||
Response.displayName = "Response";
|
||||
64
src/components/ai-elements/shimmer.tsx
Normal file
64
src/components/ai-elements/shimmer.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ElementType,
|
||||
type JSX,
|
||||
memo,
|
||||
useMemo,
|
||||
} from "react";
|
||||
|
||||
export type TextShimmerProps = {
|
||||
children: string;
|
||||
as?: ElementType;
|
||||
className?: string;
|
||||
duration?: number;
|
||||
spread?: number;
|
||||
};
|
||||
|
||||
const ShimmerComponent = ({
|
||||
children,
|
||||
as: Component = "p",
|
||||
className,
|
||||
duration = 2,
|
||||
spread = 2,
|
||||
}: TextShimmerProps) => {
|
||||
const MotionComponent = motion.create(
|
||||
Component as keyof JSX.IntrinsicElements
|
||||
);
|
||||
|
||||
const dynamicSpread = useMemo(
|
||||
() => (children?.length ?? 0) * spread,
|
||||
[children, spread]
|
||||
);
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
animate={{ backgroundPosition: "0% center" }}
|
||||
className={cn(
|
||||
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
|
||||
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
|
||||
className
|
||||
)}
|
||||
initial={{ backgroundPosition: "100% center" }}
|
||||
style={
|
||||
{
|
||||
"--spread": `${dynamicSpread}px`,
|
||||
backgroundImage:
|
||||
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
|
||||
} as CSSProperties
|
||||
}
|
||||
transition={{
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration,
|
||||
ease: "linear",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MotionComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export const Shimmer = memo(ShimmerComponent);
|
||||
215
src/components/common/advanced-select-provider.tsx
Normal file
215
src/components/common/advanced-select-provider.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
"use client"
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback, useMemo, useEffect, type ReactNode } from 'react'
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
/** 选项接口 */
|
||||
export interface SelectOption {
|
||||
id: string
|
||||
name: string
|
||||
[key: string]: any // 允许额外的属性
|
||||
}
|
||||
|
||||
/** Context 值类型定义 */
|
||||
export interface AdvancedSelectContextValue {
|
||||
// 状态
|
||||
value: string[]
|
||||
open: boolean
|
||||
searchValue: string
|
||||
displayCount: number
|
||||
disabled: boolean
|
||||
singleSelectMode: boolean
|
||||
|
||||
// 选项相关
|
||||
options: SelectOption[]
|
||||
filteredOptions: SelectOption[]
|
||||
displayedOptions: SelectOption[]
|
||||
selectedOptions: SelectOption[]
|
||||
|
||||
// 配置
|
||||
limit: number
|
||||
replaceOnLimit: boolean
|
||||
|
||||
// 操作函数
|
||||
setValue: (value: string[]) => void
|
||||
setOpen: (open: boolean) => void
|
||||
setSearchValue: (searchValue: string) => void
|
||||
setDisplayCount: (count: number | ((prev: number) => number)) => void
|
||||
select: (value: string) => void
|
||||
remove: (value: string) => void
|
||||
clear: () => void
|
||||
|
||||
// 状态查询函数
|
||||
isSelected: (value: string) => boolean
|
||||
isLimitReached: () => boolean
|
||||
}
|
||||
|
||||
// ==================== Context ====================
|
||||
|
||||
const AdvancedSelectContext = createContext<AdvancedSelectContextValue | undefined>(undefined)
|
||||
|
||||
export const useAdvancedSelectContext = () => {
|
||||
const context = useContext(AdvancedSelectContext)
|
||||
if (!context) {
|
||||
throw new Error('AdvancedSelect 子组件必须在 AdvancedSelectProvider 组件内使用')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// ==================== Provider 组件 ====================
|
||||
|
||||
export interface AdvancedSelectProviderProps {
|
||||
value?: string[]
|
||||
onChange?: (value: string[]) => void
|
||||
options?: SelectOption[]
|
||||
filterFunction?: (option: SelectOption, searchValue: string) => boolean
|
||||
initialDisplayCount?: number
|
||||
limit?: number // 最大选择数量,0 表示无限制
|
||||
replaceOnLimit?: boolean // 当达到 limit 时,是否用新值替换旧值
|
||||
disabled?: boolean // 是否禁用
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function AdvancedSelectProvider({
|
||||
value = [],
|
||||
onChange,
|
||||
options = [],
|
||||
filterFunction,
|
||||
initialDisplayCount = 99999999, // 默认无限制
|
||||
limit = 0, // 默认无限制
|
||||
replaceOnLimit = false, // 默认不替换
|
||||
disabled = false, // 默认不禁用
|
||||
children,
|
||||
}: AdvancedSelectProviderProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchValue, setSearchValue] = useState("")
|
||||
const [displayCount, setDisplayCount] = useState(initialDisplayCount)
|
||||
const singleSelectMode = limit === 1 && replaceOnLimit === true
|
||||
|
||||
// 默认筛选函数:搜索name字段
|
||||
const defaultFilterFunction = useCallback((option: SelectOption, search: string) => {
|
||||
return option.name.toLowerCase().includes(search.toLowerCase())
|
||||
}, [])
|
||||
|
||||
// 筛选选项
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!searchValue) return options
|
||||
const filter = filterFunction || defaultFilterFunction
|
||||
return options.filter(option => filter(option, searchValue))
|
||||
}, [options, searchValue, filterFunction, defaultFilterFunction])
|
||||
|
||||
// 当前显示的选项(限制数量)
|
||||
const displayedOptions = useMemo(() => {
|
||||
return filteredOptions.slice(0, displayCount)
|
||||
}, [filteredOptions, displayCount])
|
||||
|
||||
// 获取当前选中的选项列表
|
||||
const selectedOptions = useMemo(() => {
|
||||
const optionMap = new Map(options.map(opt => [opt.id, opt]))
|
||||
return value
|
||||
.map(id => optionMap.get(id))
|
||||
.filter((option): option is SelectOption => option !== undefined)
|
||||
}, [options, value])
|
||||
|
||||
// 判断是否已选中
|
||||
const isSelected = useCallback((optionValue: string) => {
|
||||
return value.includes(optionValue)
|
||||
}, [value])
|
||||
|
||||
// 判断是否达到选择上限
|
||||
const isLimitReached = useCallback(() => {
|
||||
// limit 为 0 表示无限制
|
||||
if (limit === 0) return false
|
||||
return value.length >= limit
|
||||
}, [value.length, limit])
|
||||
|
||||
// 处理选择/取消选择
|
||||
const handleSelect = useCallback((selectedValue: string) => {
|
||||
const option = options.find(opt => opt.id === selectedValue)
|
||||
if (!option || disabled) return
|
||||
|
||||
const isCurrentlySelected = isSelected(option.id)
|
||||
|
||||
if (isCurrentlySelected) {
|
||||
// 取消选择
|
||||
if (!singleSelectMode) {
|
||||
onChange?.(value.filter(v => v !== option.id))
|
||||
}
|
||||
} else {
|
||||
// 添加选择
|
||||
if (limit === 0) {
|
||||
// 无限制
|
||||
onChange?.([...value, option.id])
|
||||
} else if (value.length < limit) {
|
||||
// 未达到限制
|
||||
onChange?.([...value, option.id])
|
||||
} else if (replaceOnLimit) {
|
||||
// 达到限制且启用替换:移除第一个,添加新的
|
||||
onChange?.([...value.slice(1), option.id])
|
||||
}
|
||||
// 达到限制且不替换:不做任何操作
|
||||
}
|
||||
|
||||
// 单选模式(limit=1)时自动关闭
|
||||
if (singleSelectMode) {
|
||||
setOpen(false)
|
||||
}
|
||||
}, [options, disabled, isSelected, singleSelectMode, onChange, value, limit, replaceOnLimit])
|
||||
|
||||
// 处理移除单个选项
|
||||
const handleRemove = useCallback((optionValue: string) => {
|
||||
if (!disabled) {
|
||||
onChange?.(value.filter(v => v !== optionValue))
|
||||
}
|
||||
}, [disabled, onChange, value])
|
||||
|
||||
// 处理清空所有
|
||||
const handleClear = useCallback(() => {
|
||||
if (!disabled) {
|
||||
onChange?.([])
|
||||
}
|
||||
}, [disabled, onChange])
|
||||
|
||||
// 重置搜索时重置显示数量
|
||||
useEffect(() => {
|
||||
setDisplayCount(initialDisplayCount)
|
||||
}, [searchValue, initialDisplayCount])
|
||||
|
||||
const contextValue: AdvancedSelectContextValue = {
|
||||
// 状态
|
||||
value,
|
||||
open,
|
||||
searchValue,
|
||||
displayCount,
|
||||
singleSelectMode,
|
||||
|
||||
// 选项相关
|
||||
options,
|
||||
filteredOptions,
|
||||
displayedOptions,
|
||||
selectedOptions,
|
||||
|
||||
// 配置
|
||||
limit,
|
||||
replaceOnLimit,
|
||||
disabled,
|
||||
|
||||
// 操作函数
|
||||
setValue: onChange || (() => {}),
|
||||
setOpen,
|
||||
setSearchValue,
|
||||
setDisplayCount,
|
||||
select: handleSelect,
|
||||
remove: handleRemove,
|
||||
clear: handleClear,
|
||||
isSelected,
|
||||
isLimitReached
|
||||
}
|
||||
|
||||
return (
|
||||
<AdvancedSelectContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</AdvancedSelectContext.Provider>
|
||||
)
|
||||
}
|
||||
399
src/components/common/advanced-select.tsx
Normal file
399
src/components/common/advanced-select.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ChevronsUpDownIcon, X } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
useAdvancedSelectContext,
|
||||
AdvancedSelectProvider,
|
||||
type SelectOption,
|
||||
} from "./advanced-select-provider"
|
||||
|
||||
// ==================== Popover 组件 ====================
|
||||
export interface SelectPopoverProps {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function SelectPopover({
|
||||
children,
|
||||
}: SelectPopoverProps) {
|
||||
const context = useAdvancedSelectContext()
|
||||
|
||||
return (
|
||||
<Popover open={context.open} onOpenChange={context.setOpen}>
|
||||
{children}
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== 选项列表触发器组件 ====================
|
||||
export interface SelectTriggerProps {
|
||||
placeholder?: string
|
||||
className?: string
|
||||
clearable?: boolean
|
||||
onClear?: () => void
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export const SelectTrigger = React.forwardRef<HTMLButtonElement, SelectTriggerProps>(
|
||||
({ placeholder = "请选择", className, clearable = false, onClear, children }, ref) => {
|
||||
const context = useAdvancedSelectContext()
|
||||
|
||||
const handleClear = React.useCallback((e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
e.stopPropagation()
|
||||
onClear?.()
|
||||
context.clear()
|
||||
}, [onClear, context])
|
||||
|
||||
const hasValue = context.value.length > 0
|
||||
|
||||
return (
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
ref={ref}
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={context.open}
|
||||
className={cn("w-full justify-between", className)}
|
||||
disabled={context.disabled}
|
||||
onClick={() => context.setOpen(!context.open)}
|
||||
>
|
||||
<span className={cn(!hasValue && "opacity-60", "truncate")}>
|
||||
{hasValue ? children : placeholder}
|
||||
</span>
|
||||
|
||||
<div className="ml-2 flex items-center gap-1 shrink-0">
|
||||
<ChevronsUpDownIcon className="h-4 w-4 opacity-50" />
|
||||
{clearable && hasValue && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleClear}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleClear(e)
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 cursor-pointer"
|
||||
aria-label="清空"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
)
|
||||
}
|
||||
)
|
||||
SelectTrigger.displayName = "SelectTrigger"
|
||||
|
||||
// ==================== 选项列表展示容器组件 ====================
|
||||
export interface SelectContentProps {
|
||||
className?: string
|
||||
align?: "start" | "center" | "end"
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const SelectContent = React.forwardRef<HTMLDivElement, SelectContentProps>(
|
||||
({ className, align = "start", children }, ref) => {
|
||||
return (
|
||||
<PopoverContent
|
||||
ref={ref}
|
||||
className={cn("p-0 z-[60] min-w-[12.5rem] max-w-[min(30rem,80vw)]", className)}
|
||||
align={align}
|
||||
onWheel={(e) => {
|
||||
// 确保滚动行为独立,不影响背后的页面。
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
{children}
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
)
|
||||
}
|
||||
)
|
||||
SelectContent.displayName = "SelectContent"
|
||||
|
||||
// ==================== 选项列表过滤器类型组件 ====================
|
||||
export interface SelectInputProps {
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function SelectInput({
|
||||
placeholder = "搜索...",
|
||||
}: SelectInputProps) {
|
||||
const context = useAdvancedSelectContext()
|
||||
|
||||
return (
|
||||
<CommandInput
|
||||
placeholder={placeholder}
|
||||
value={context.searchValue}
|
||||
onValueChange={context.setSearchValue}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== 选项列表展示类组件 ====================
|
||||
export interface SelectItemListProps {
|
||||
emptyText?: React.ReactNode
|
||||
className?: string
|
||||
stepDisplayCount?: number
|
||||
children?: (option: SelectOption) => React.ReactNode
|
||||
}
|
||||
|
||||
export function SelectItemList({
|
||||
emptyText = "未找到相关选项",
|
||||
className,
|
||||
stepDisplayCount = 20,
|
||||
children
|
||||
}: SelectItemListProps) {
|
||||
const context = useAdvancedSelectContext()
|
||||
|
||||
const handleScroll = React.useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const target = e.currentTarget
|
||||
const { scrollTop, scrollHeight, clientHeight } = target
|
||||
|
||||
// 当滚动到底部附近时加载更多(距离底部50px时触发)
|
||||
if (scrollHeight - scrollTop - clientHeight < 50 && context.displayedOptions.length < context.filteredOptions.length) {
|
||||
context.setDisplayCount((prev: number) => Math.min(prev + stepDisplayCount, context.filteredOptions.length))
|
||||
}
|
||||
}, [stepDisplayCount, context])
|
||||
|
||||
if (context.filteredOptions.length === 0) {
|
||||
return (
|
||||
<CommandList>
|
||||
<CommandEmpty>{emptyText}</CommandEmpty>
|
||||
</CommandList>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandList
|
||||
className={cn("max-h-[200px] overflow-auto", className)}
|
||||
onScroll={handleScroll}
|
||||
onWheel={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<CommandGroup>
|
||||
{context.displayedOptions.map((option) => {
|
||||
const isSelected = context.isSelected(option.id)
|
||||
const shouldDisable = context.disabled || (context.isLimitReached() && !context.replaceOnLimit && !isSelected)
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
onSelect={context.select}
|
||||
disabled={shouldDisable}
|
||||
>
|
||||
{children ? children(option) : option.name}
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto",
|
||||
isSelected ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
{context.displayedOptions.length < context.filteredOptions.length && (
|
||||
<div className="px-2 py-1 text-xs text-muted-foreground text-center">
|
||||
继续滚动查看更多选项...
|
||||
</div>
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== Badge 选项展示组件 ====================
|
||||
export interface SelectedBadgesProps {
|
||||
maxDisplay?: number
|
||||
onRemove?: (id: string) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SelectedBadges({
|
||||
maxDisplay = 3,
|
||||
onRemove,
|
||||
className,
|
||||
}: SelectedBadgesProps) {
|
||||
const context = useAdvancedSelectContext()
|
||||
const displayedOptions = context.selectedOptions.slice(0, maxDisplay)
|
||||
const remainingCount = context.selectedOptions.length - maxDisplay
|
||||
|
||||
const handleRemove = React.useCallback((optionId: string, e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
e.stopPropagation()
|
||||
context.remove(optionId)
|
||||
onRemove?.(optionId)
|
||||
}, [context, onRemove])
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-wrap gap-1", className)}>
|
||||
{displayedOptions.map((option) => (
|
||||
<Badge
|
||||
key={option.id}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
>
|
||||
<span className="truncate max-w-[120px]">{option.name}</span>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => handleRemove(option.id, e)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleRemove(option.id, e)
|
||||
}
|
||||
}}
|
||||
className="ml-0.5 rounded-sm opacity-70 hover:opacity-100 focus:outline-none cursor-pointer"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
{remainingCount > 0 && (
|
||||
<Badge variant="secondary" size="sm">
|
||||
+{remainingCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== 字符串拼接选项展示组件 ====================
|
||||
export interface SelectedNameProps {
|
||||
separator?: string
|
||||
maxLength?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SelectedName({
|
||||
separator = ", ",
|
||||
maxLength,
|
||||
className,
|
||||
}: SelectedNameProps) {
|
||||
const context = useAdvancedSelectContext()
|
||||
const names = context.selectedOptions.map(option => option.name).join(separator)
|
||||
const displayText = maxLength && names.length > maxLength
|
||||
? `${names.slice(0, maxLength)}...`
|
||||
: names
|
||||
|
||||
return (
|
||||
<span className={cn("truncate", className)}>
|
||||
{displayText}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== 组合式 API 组件,封装 Provider,提供简单易用的API ====================
|
||||
export interface AdvancedSelectProps {
|
||||
value?: string | number | string[] | number[] | null
|
||||
onChange?: (value: any) => void
|
||||
options?: SelectOption[]
|
||||
disabled?: boolean
|
||||
multiple?: {
|
||||
enable?: boolean // 多选模式,默认为单选
|
||||
limit?: number // 多选模式下,限制选择上限,0表示不显示
|
||||
replaceOnLimit?: boolean // 多选模式下,选择达到上限时,新的选项会替换掉最开始选择的
|
||||
}
|
||||
filterFunction?: (option: SelectOption, searchValue: string) => boolean
|
||||
initialDisplayCount?: number, // 初始显示的选项数量,默认最多显示50个
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function AdvancedSelect({
|
||||
value,
|
||||
onChange,
|
||||
options = [],
|
||||
disabled = false,
|
||||
multiple = {},
|
||||
filterFunction,
|
||||
initialDisplayCount = 50,
|
||||
children,
|
||||
}: AdvancedSelectProps) {
|
||||
const { limit, replaceOnLimit } = multiple.enable ?
|
||||
{ limit: multiple.limit || 0, replaceOnLimit: !!multiple.replaceOnLimit } :
|
||||
{ limit: 1, replaceOnLimit: true }
|
||||
const singleSelectMode = !multiple.enable
|
||||
|
||||
// 标准化 value 为字符串数组格式(context 使用),context内部统一为字符串数组
|
||||
const normalizedValue = React.useMemo(() => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(v => String(v))
|
||||
}
|
||||
return value !== undefined && value !== null && value !== "" ? [String(value)] : []
|
||||
}, [value])
|
||||
|
||||
// 标准化 onChange 为适配原始类型
|
||||
const normalizedOnChange = React.useCallback((newValue: string[]) => {
|
||||
if (!onChange) return
|
||||
if (singleSelectMode) {
|
||||
// 单选模式:返回单个值或 undefined
|
||||
if (newValue.length === 0) {
|
||||
onChange(null) // react-hook-form中null表示清空,undefined表示重置
|
||||
return
|
||||
}
|
||||
|
||||
// 根据原始 value 的类型返回对应类型
|
||||
if (typeof value === 'number') {
|
||||
onChange(Number(newValue[0]))
|
||||
} else {
|
||||
onChange(newValue[0])
|
||||
}
|
||||
} else {
|
||||
// 多选模式:返回数组
|
||||
if (newValue.length === 0) {
|
||||
onChange(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 根据原始 value 的类型返回对应类型的数组
|
||||
if (Array.isArray(value) && value.length > 0 && typeof value[0] === 'number') {
|
||||
onChange(newValue.map(v => Number(v)))
|
||||
} else {
|
||||
onChange(newValue)
|
||||
}
|
||||
}
|
||||
}, [onChange, singleSelectMode, value])
|
||||
|
||||
return (
|
||||
<AdvancedSelectProvider
|
||||
value={normalizedValue}
|
||||
onChange={normalizedOnChange}
|
||||
options={options}
|
||||
filterFunction={filterFunction}
|
||||
initialDisplayCount={initialDisplayCount}
|
||||
limit={limit}
|
||||
replaceOnLimit={replaceOnLimit}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</AdvancedSelectProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// 导出类型
|
||||
export type { SelectOption }
|
||||
271
src/components/common/card-select.tsx
Normal file
271
src/components/common/card-select.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckCircle2, ExternalLink, ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
// 卡片选项接口
|
||||
export interface CardSelectOption {
|
||||
id: string | number
|
||||
name: string
|
||||
description?: string
|
||||
url?: string
|
||||
websiteUrl?: string
|
||||
type?: string
|
||||
[key: string]: any // 允许额外的属性
|
||||
}
|
||||
|
||||
// 卡片选项项组件属性
|
||||
export interface CardSelectItemProps {
|
||||
option: CardSelectOption
|
||||
selected?: boolean
|
||||
onSelect?: (id: string | number) => void
|
||||
showCheckbox?: boolean
|
||||
showExternalLink?: boolean
|
||||
showBadge?: boolean
|
||||
renderExtra?: (option: CardSelectOption) => React.ReactNode
|
||||
renderActions?: (option: CardSelectOption) => React.ReactNode
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片选项项组件
|
||||
* 用于展示单个卡片选项,支持复选框、外部链接、徽章等
|
||||
*/
|
||||
export function CardSelectItem({
|
||||
option,
|
||||
selected = false,
|
||||
onSelect,
|
||||
showCheckbox = false,
|
||||
showExternalLink = false,
|
||||
showBadge = false,
|
||||
renderExtra,
|
||||
renderActions,
|
||||
className,
|
||||
disabled = false
|
||||
}: CardSelectItemProps) {
|
||||
const handleClick = React.useCallback(() => {
|
||||
if (!disabled) {
|
||||
onSelect?.(option.id)
|
||||
}
|
||||
}, [onSelect, option.id, disabled])
|
||||
|
||||
const handleCheckboxChange = React.useCallback(() => {
|
||||
if (!disabled) {
|
||||
onSelect?.(option.id)
|
||||
}
|
||||
}, [onSelect, option.id, disabled])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start space-x-3 p-3 rounded-lg border bg-background transition-all",
|
||||
showCheckbox && "cursor-pointer hover:shadow-sm hover:border-primary/50",
|
||||
selected && "border-primary bg-primary/5 shadow-sm",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
className
|
||||
)}
|
||||
onClick={showCheckbox ? handleClick : undefined}
|
||||
>
|
||||
{showCheckbox && (
|
||||
<Checkbox
|
||||
id={`card-select-${option.id}`}
|
||||
checked={selected}
|
||||
onCheckedChange={handleCheckboxChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="mt-0.5"
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 space-y-1.5 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label
|
||||
htmlFor={showCheckbox ? `card-select-${option.id}` : undefined}
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
showCheckbox && "cursor-pointer"
|
||||
)}
|
||||
onClick={(e) => showCheckbox && e.stopPropagation()}
|
||||
>
|
||||
{option.name}
|
||||
</Label>
|
||||
{selected && showCheckbox && (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-primary flex-shrink-0" />
|
||||
)}
|
||||
{showBadge && option.type && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{option.type}
|
||||
</Badge>
|
||||
)}
|
||||
{showExternalLink && option.websiteUrl && (
|
||||
<a
|
||||
href={option.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-muted-foreground hover:text-primary transition-colors ml-auto"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{option.description && (
|
||||
<p className="text-xs text-muted-foreground break-all line-clamp-3">
|
||||
{option.description}
|
||||
</p>
|
||||
)}
|
||||
{option.url && !option.description && (
|
||||
<p className="text-xs text-muted-foreground break-all line-clamp-2">
|
||||
{option.url}
|
||||
</p>
|
||||
)}
|
||||
{renderExtra?.(option)}
|
||||
</div>
|
||||
{renderActions && (
|
||||
<div className="shrink-0">
|
||||
{renderActions(option)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface CardSelectProps {
|
||||
value?: (string | number)[]
|
||||
onChange?: (value: (string | number)[]) => void
|
||||
options?: CardSelectOption[]
|
||||
className?: string
|
||||
containerClassName?: string
|
||||
disabled?: boolean
|
||||
multiple?: boolean
|
||||
showCheckbox?: boolean
|
||||
showExternalLink?: boolean
|
||||
showBadge?: boolean
|
||||
renderExtra?: (option: CardSelectOption) => React.ReactNode
|
||||
renderActions?: (option: CardSelectOption) => React.ReactNode
|
||||
maxHeight?: string
|
||||
// 分页相关属性
|
||||
enablePagination?: boolean
|
||||
pageSize?: number
|
||||
showPaginationInfo?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片选择组件
|
||||
* 支持单选和多选模式,支持分页展示
|
||||
*/
|
||||
export function CardSelect({
|
||||
value = [],
|
||||
onChange,
|
||||
options = [],
|
||||
className,
|
||||
containerClassName = "space-y-2 max-h-64 overflow-y-auto rounded-lg border bg-muted/30 p-3",
|
||||
disabled = false,
|
||||
multiple = true,
|
||||
showCheckbox = true,
|
||||
showExternalLink = false,
|
||||
showBadge = false,
|
||||
renderExtra,
|
||||
renderActions,
|
||||
maxHeight,
|
||||
enablePagination = false,
|
||||
pageSize = 3,
|
||||
showPaginationInfo = true
|
||||
}: CardSelectProps) {
|
||||
const [currentPage, setCurrentPage] = React.useState(1)
|
||||
|
||||
const handleSelect = React.useCallback((id: string | number) => {
|
||||
if (disabled) return
|
||||
|
||||
if (multiple) {
|
||||
const newValue = value.includes(id)
|
||||
? value.filter(v => v !== id)
|
||||
: [...value, id]
|
||||
onChange?.(newValue)
|
||||
} else {
|
||||
onChange?.([id])
|
||||
}
|
||||
}, [value, onChange, disabled, multiple])
|
||||
|
||||
// 计算分页数据
|
||||
const totalPages = enablePagination ? Math.ceil(options.length / pageSize) : 1
|
||||
const startIndex = enablePagination ? (currentPage - 1) * pageSize : 0
|
||||
const endIndex = enablePagination ? startIndex + pageSize : options.length
|
||||
const displayOptions = enablePagination ? options.slice(startIndex, endIndex) : options
|
||||
|
||||
// 重置页码当选项变化时
|
||||
React.useEffect(() => {
|
||||
if (enablePagination && currentPage > totalPages && totalPages > 0) {
|
||||
setCurrentPage(1)
|
||||
}
|
||||
}, [options.length, enablePagination, currentPage, totalPages])
|
||||
|
||||
const handlePrevPage = React.useCallback(() => {
|
||||
setCurrentPage(prev => Math.max(1, prev - 1))
|
||||
}, [])
|
||||
|
||||
const handleNextPage = React.useCallback(() => {
|
||||
setCurrentPage(prev => Math.min(totalPages, prev + 1))
|
||||
}, [totalPages])
|
||||
|
||||
const containerStyle = maxHeight ? { maxHeight } : undefined
|
||||
|
||||
return (
|
||||
<div className="space-y-3 overflow-auto">
|
||||
<div className={cn(containerClassName, className)} style={containerStyle}>
|
||||
{displayOptions.map((option) => (
|
||||
<CardSelectItem
|
||||
key={option.id}
|
||||
option={option}
|
||||
selected={value.includes(option.id)}
|
||||
onSelect={handleSelect}
|
||||
showCheckbox={showCheckbox}
|
||||
showExternalLink={showExternalLink}
|
||||
showBadge={showBadge}
|
||||
renderExtra={renderExtra}
|
||||
renderActions={renderActions}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{enablePagination && totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-2">
|
||||
{showPaginationInfo && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
第 {startIndex + 1}-{Math.min(endIndex, options.length)} 项,共 {options.length} 项
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePrevPage}
|
||||
disabled={currentPage === 1}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="text-xs text-muted-foreground min-w-[60px] text-center">
|
||||
{currentPage} / {totalPages}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={currentPage === totalPages}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
67
src/components/common/checkbox-group.tsx
Normal file
67
src/components/common/checkbox-group.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
export interface CheckboxOption {
|
||||
id: number | string
|
||||
name: string
|
||||
[key: string]: any // 允许额外的属性
|
||||
}
|
||||
|
||||
export interface CheckboxGroupProps {
|
||||
options: CheckboxOption[]
|
||||
value?: (number | string)[]
|
||||
onChange?: (value: (number | string)[]) => void
|
||||
className?: string
|
||||
itemClassName?: string
|
||||
labelClassName?: string
|
||||
idPrefix?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function CheckboxGroup({
|
||||
options,
|
||||
value = [],
|
||||
onChange,
|
||||
className = "space-y-2",
|
||||
itemClassName = "flex items-center space-x-2",
|
||||
labelClassName = "text-sm",
|
||||
idPrefix = "checkbox",
|
||||
disabled = false,
|
||||
}: CheckboxGroupProps) {
|
||||
const handleToggle = (optionId: number | string, checked: boolean) => {
|
||||
const newValue = checked
|
||||
? [...value, optionId]
|
||||
: value.filter((id) => id !== optionId)
|
||||
onChange?.(newValue)
|
||||
}
|
||||
|
||||
if (!options || options.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{options.map((option) => (
|
||||
<div key={option.id} className={itemClassName}>
|
||||
<Checkbox
|
||||
id={`${idPrefix}-${option.id}`}
|
||||
checked={value.includes(option.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleToggle(option.id, checked as boolean)
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`${idPrefix}-${option.id}`}
|
||||
className={labelClassName}
|
||||
>
|
||||
{option.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
121
src/components/common/date-picker.tsx
Normal file
121
src/components/common/date-picker.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
|
||||
import * as React from "react"
|
||||
import { format, parse, isValid } from "date-fns"
|
||||
import { Calendar as CalendarIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
|
||||
export interface DatePickerProps {
|
||||
value?: Date
|
||||
onChange?: (date: Date | undefined) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
buttonClassName?: string
|
||||
inputClassName?: string
|
||||
popoverClassName?: string
|
||||
calendarClassName?: string
|
||||
formatString?: string
|
||||
inputFormat?: string
|
||||
}
|
||||
|
||||
export function DatePicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "选择日期",
|
||||
disabled = false,
|
||||
className,
|
||||
buttonClassName,
|
||||
inputClassName,
|
||||
popoverClassName,
|
||||
calendarClassName,
|
||||
inputFormat = "yyyy-MM-dd"
|
||||
}: DatePickerProps) {
|
||||
const [inputValue, setInputValue] = React.useState("")
|
||||
const [isOpen, setIsOpen] = React.useState(false)
|
||||
|
||||
// 同步外部value到input
|
||||
React.useEffect(() => {
|
||||
if (value) {
|
||||
setInputValue(format(value, inputFormat))
|
||||
} else {
|
||||
setInputValue("")
|
||||
}
|
||||
}, [value, inputFormat])
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value
|
||||
setInputValue(newValue)
|
||||
|
||||
// 尝试解析输入的日期
|
||||
if (newValue) {
|
||||
const parsedDate = parse(newValue, inputFormat, new Date())
|
||||
if (isValid(parsedDate)) {
|
||||
onChange?.(parsedDate)
|
||||
}
|
||||
} else {
|
||||
onChange?.(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputBlur = () => {
|
||||
// 输入框失焦时,如果日期无效,重置为当前value的格式
|
||||
if (inputValue && value) {
|
||||
const parsedDate = parse(inputValue, inputFormat, new Date())
|
||||
if (!isValid(parsedDate)) {
|
||||
setInputValue(format(value, inputFormat))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleCalendarSelect = (date: Date | undefined) => {
|
||||
onChange?.(date)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<Input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleInputBlur}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className={cn("flex-1", inputClassName)}
|
||||
/>
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
className={cn("shrink-0", buttonClassName)}
|
||||
>
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className={cn("w-auto p-0", popoverClassName)}
|
||||
align="end"
|
||||
>
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={value}
|
||||
onSelect={handleCalendarSelect}
|
||||
disabled={disabled}
|
||||
className={calendarClassName}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
118
src/components/common/date-range-picker.tsx
Normal file
118
src/components/common/date-range-picker.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { format } from 'date-fns'
|
||||
import { zhCN } from 'date-fns/locale'
|
||||
import { Calendar as CalendarIcon, X } from 'lucide-react'
|
||||
import { DateRange } from 'react-day-picker'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
|
||||
// 日期范围类型
|
||||
export interface DateRangeValue {
|
||||
from?: Date
|
||||
to?: Date
|
||||
}
|
||||
|
||||
export interface DateRangePickerProps {
|
||||
value?: DateRangeValue
|
||||
onChange?: (value: DateRangeValue | undefined) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
numberOfMonths?: number
|
||||
clearable?: boolean
|
||||
}
|
||||
|
||||
export function DateRangePicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = '选择日期范围',
|
||||
className,
|
||||
disabled = false,
|
||||
numberOfMonths = 2,
|
||||
clearable = false
|
||||
}: DateRangePickerProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [isHovered, setIsHovered] = React.useState(false)
|
||||
|
||||
const handleSelect = React.useCallback((range: DateRange | undefined) => {
|
||||
if (range) {
|
||||
onChange?.({
|
||||
from: range.from,
|
||||
to: range.to
|
||||
})
|
||||
} else {
|
||||
onChange?.(undefined)
|
||||
}
|
||||
}, [onChange])
|
||||
|
||||
const handleClear = React.useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onChange?.(undefined)
|
||||
}, [onChange])
|
||||
|
||||
const formatDateRange = React.useCallback((dateRange?: DateRangeValue) => {
|
||||
if (!dateRange?.from) {
|
||||
return placeholder
|
||||
}
|
||||
|
||||
if (dateRange.to) {
|
||||
return `${format(dateRange.from, 'yyyy-MM-dd', { locale: zhCN })} 至 ${format(dateRange.to, 'yyyy-MM-dd', { locale: zhCN })}`
|
||||
}
|
||||
|
||||
return format(dateRange.from, 'yyyy-MM-dd', { locale: zhCN })
|
||||
}, [placeholder])
|
||||
|
||||
return (
|
||||
<div className={cn('grid gap-2', className)}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<Button
|
||||
id="date"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'w-full justify-start text-left font-normal',
|
||||
!value?.from && 'text-muted-foreground',
|
||||
clearable && value?.from && 'pr-8'
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{formatDateRange(value)}
|
||||
</Button>
|
||||
{clearable && value?.from && isHovered && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
|
||||
disabled={disabled}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">清空</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
autoFocus
|
||||
mode="range"
|
||||
defaultMonth={value?.from}
|
||||
selected={value ? { from: value.from, to: value.to } : undefined}
|
||||
onSelect={handleSelect}
|
||||
numberOfMonths={numberOfMonths}
|
||||
locale={zhCN}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
968
src/components/common/file-preview.tsx
Normal file
968
src/components/common/file-preview.tsx
Normal file
@@ -0,0 +1,968 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import useEmblaCarousel from 'embla-carousel-react';
|
||||
import { useGesture } from '@use-gesture/react';
|
||||
import { motion, useMotionValue, useSpring, animate, useMotionValueEvent } from 'framer-motion';
|
||||
import {
|
||||
X,
|
||||
FileText,
|
||||
FileArchive,
|
||||
FileSpreadsheet,
|
||||
Image as ImageIcon,
|
||||
Video,
|
||||
Music,
|
||||
File,
|
||||
Download,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
RotateCw,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Maximize2,
|
||||
HelpCircle,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Plus,
|
||||
Minus,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Kbd } from '@/components/ui/kbd';
|
||||
import { cn, downloadFromFile } from '@/lib/utils';
|
||||
import { formatBytes } from '@/lib/format';
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
|
||||
/**
|
||||
* 根据文件 MIME 类型返回对应的图标
|
||||
*/
|
||||
export const getFileIcon = (type: string | undefined) => {
|
||||
if (!!type) {
|
||||
if (type.startsWith('image/')) return <ImageIcon className="size-6" />;
|
||||
if (type.startsWith('video/')) return <Video className="size-6" />;
|
||||
if (type.startsWith('audio/')) return <Music className="size-6" />;
|
||||
if (type.includes('pdf')) return <FileText className="size-6" />;
|
||||
if (type.includes('word') || type.includes('doc')) return <FileText className="size-6" />;
|
||||
if (type.includes('excel') || type.includes('sheet')) return <FileSpreadsheet className="size-6" />;
|
||||
if (type.includes('zip') || type.includes('rar') || type.includes('7z')) return <FileArchive className="size-6" />;
|
||||
}
|
||||
return <File className="size-6" />;
|
||||
};
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
/**
|
||||
* 文件预览项的基础接口
|
||||
*/
|
||||
export interface FilePreviewItem {
|
||||
/** 文件唯一标识 */
|
||||
id: string;
|
||||
/** 文件名称 */
|
||||
name: string;
|
||||
/** 文件大小(字节) */
|
||||
size: number;
|
||||
/** 文件 MIME 类型 */
|
||||
type?: string;
|
||||
/** 预览 URL(用于图片预览) */
|
||||
preview?: string;
|
||||
/** 上传进度(0-100),undefined 表示未上传或已完成 */
|
||||
progress?: number;
|
||||
/** 浏览器 File 对象(可选,用于下载功能) */
|
||||
file?: File;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件卡片预览组件的属性
|
||||
*/
|
||||
export interface FileCardPreviewProps {
|
||||
/** 文件列表 */
|
||||
files: FilePreviewItem[];
|
||||
/** 外层容器的自定义类名 */
|
||||
className?: string;
|
||||
/** 网格容器的自定义类名 */
|
||||
gridClassName?: string;
|
||||
/** 是否禁用操作按钮 */
|
||||
disabled?: boolean;
|
||||
/** 是否显示下载按钮 */
|
||||
showDownload?: boolean;
|
||||
/** 是否显示删除按钮 */
|
||||
showRemove?: boolean;
|
||||
/** 是否显示文件信息(文件名和大小) */
|
||||
showFileInfo?: boolean;
|
||||
/** 删除文件的回调函数 */
|
||||
onRemove?: (id: string, file: FilePreviewItem) => void;
|
||||
/** 下载文件的回调函数(如果不提供,将使用默认的 downloadFromFile) */
|
||||
onDownload?: (id: string, file: FilePreviewItem) => void;
|
||||
/** 点击文件卡片的回调函数 */
|
||||
onClick?: (id: string, file: FilePreviewItem) => void;
|
||||
}
|
||||
|
||||
// ==================== 文件卡片预览组件 ====================
|
||||
|
||||
/**
|
||||
* 文件卡片预览组件
|
||||
*
|
||||
* 用于以卡片网格形式展示文件列表,支持图片预览、文件信息显示、下载和删除操作。
|
||||
*
|
||||
* 特性:
|
||||
* - 响应式网格布局(移动端 1 列,平板 2 列,桌面 3 列)
|
||||
* - 图片文件显示预览图,其他文件显示对应图标
|
||||
* - 支持上传进度显示(圆形进度条)
|
||||
* - PC 端悬停显示操作按钮和文件信息
|
||||
* - 移动端点击激活显示操作按钮和文件信息
|
||||
* - 可自定义是否显示下载、删除按钮和文件信息
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <FileCardPreview
|
||||
* files={files}
|
||||
* showDownload
|
||||
* showRemove
|
||||
* onRemove={(id) => console.log('Remove', id)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function FileCardPreview({
|
||||
files,
|
||||
className,
|
||||
gridClassName,
|
||||
disabled = false,
|
||||
showDownload = true,
|
||||
showRemove = true,
|
||||
showFileInfo = true,
|
||||
onRemove,
|
||||
onDownload,
|
||||
onClick,
|
||||
}: FileCardPreviewProps) {
|
||||
const [activeFileId, setActiveFileId] = useState<string | null>(null);
|
||||
const [carouselOpen, setCarouselOpen] = useState(false);
|
||||
const [carouselIndex, setCarouselIndex] = useState(0);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const fileItem = files.find((f) => f.id === id);
|
||||
if (fileItem && onRemove) {
|
||||
onRemove(id, fileItem);
|
||||
}
|
||||
setActiveFileId(null);
|
||||
},
|
||||
[files, onRemove]
|
||||
);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
(id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const fileItem = files.find((f) => f.id === id);
|
||||
if (fileItem) {
|
||||
if (onDownload) {
|
||||
onDownload(id, fileItem);
|
||||
} else if (fileItem.file) {
|
||||
downloadFromFile(fileItem.file);
|
||||
}
|
||||
}
|
||||
},
|
||||
[files, onDownload]
|
||||
);
|
||||
|
||||
const handlePreview = useCallback(
|
||||
(id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const index = files.findIndex((f) => f.id === id);
|
||||
if (index !== -1) {
|
||||
setCarouselIndex(index);
|
||||
setCarouselOpen(true);
|
||||
}
|
||||
},
|
||||
[files]
|
||||
);
|
||||
|
||||
const handleFileClick = useCallback(
|
||||
(id: string) => {
|
||||
const fileItem = files.find((f) => f.id === id);
|
||||
if (fileItem && onClick) {
|
||||
onClick(id, fileItem);
|
||||
}
|
||||
setActiveFileId((prev) => (prev === id ? null : id));
|
||||
},
|
||||
[files, onClick]
|
||||
);
|
||||
|
||||
if (files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-1 gap-1 sm:grid-cols-2 lg:grid-cols-3',
|
||||
gridClassName
|
||||
)}
|
||||
>
|
||||
{files.map((fileItem) => (
|
||||
<div
|
||||
key={fileItem.id}
|
||||
className="group relative aspect-square cursor-pointer p-1"
|
||||
onClick={() => handleFileClick(fileItem.id)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative h-full overflow-hidden rounded-lg border bg-card transition-all',
|
||||
activeFileId === fileItem.id
|
||||
? 'border-primary ring-2 ring-primary ring-offset-2 shadow-lg'
|
||||
: 'md:group-hover:border-primary/50 md:group-hover:shadow-md'
|
||||
)}
|
||||
>
|
||||
{/* 文件预览区域 */}
|
||||
{fileItem.type?.startsWith('image/') && fileItem.preview ? (
|
||||
<img
|
||||
src={fileItem.preview}
|
||||
alt={fileItem.name}
|
||||
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center bg-muted text-muted-foreground">
|
||||
{getFileIcon(fileItem.type)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传进度指示器 */}
|
||||
{fileItem.progress !== undefined && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<div className="relative size-16">
|
||||
<svg className="size-full -rotate-90" viewBox="0 0 100 100">
|
||||
{/* 背景圆环 */}
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
className="text-white/20"
|
||||
/>
|
||||
{/* 进度圆环 */}
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${2 * Math.PI * 45}`}
|
||||
strokeDashoffset={`${2 * Math.PI * 45 * (1 - fileItem.progress / 100)}`}
|
||||
className="text-primary transition-all duration-300"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{Math.round(fileItem.progress)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PC端悬停或移动端点击后显示的操作按钮 */}
|
||||
{(showDownload || showRemove || fileItem.type?.startsWith('image/')) && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center justify-center gap-2 rounded-lg bg-black/50 transition-opacity',
|
||||
activeFileId === fileItem.id ? 'opacity-100' : 'opacity-0 md:group-hover:opacity-100 pointer-events-none md:group-hover:pointer-events-auto'
|
||||
)}
|
||||
>
|
||||
{fileItem.type?.startsWith('image/') && (
|
||||
<Button
|
||||
onClick={(e) => handlePreview(fileItem.id, e)}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'size-7',
|
||||
activeFileId === fileItem.id && 'pointer-events-auto'
|
||||
)}
|
||||
title="预览"
|
||||
>
|
||||
<ZoomIn className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{showDownload && (fileItem.file || onDownload) && (
|
||||
<Button
|
||||
onClick={(e) => handleDownload(fileItem.id, e)}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'size-7',
|
||||
activeFileId === fileItem.id && 'pointer-events-auto'
|
||||
)}
|
||||
title="下载"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{showRemove && onRemove && (
|
||||
<Button
|
||||
onClick={(e) => handleRemove(fileItem.id, e)}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'size-7',
|
||||
activeFileId === fileItem.id && 'pointer-events-auto'
|
||||
)}
|
||||
title="删除"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PC端悬停或移动端点击后显示的文件信息 */}
|
||||
{showFileInfo && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute bottom-0 left-0 right-0 rounded-b-lg bg-gradient-to-t from-black/80 via-black/60 to-transparent p-2 pt-8 text-white transition-opacity',
|
||||
activeFileId === fileItem.id ? 'opacity-100' : 'opacity-0 md:group-hover:opacity-100'
|
||||
)}
|
||||
>
|
||||
<p className="truncate text-xs font-medium" title={fileItem.name}>
|
||||
{fileItem.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-300">
|
||||
{formatBytes(fileItem.size)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 轮播预览 */}
|
||||
<FileCarouselPreview
|
||||
files={files}
|
||||
initialIndex={carouselIndex}
|
||||
open={carouselOpen}
|
||||
onClose={() => setCarouselOpen(false)}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ==================== 轮播预览组件 ====================
|
||||
|
||||
/**
|
||||
* 文件轮播预览组件的属性
|
||||
*/
|
||||
export interface FileCarouselPreviewProps {
|
||||
/** 文件列表 */
|
||||
files: FilePreviewItem[];
|
||||
/** 初始显示的文件索引 */
|
||||
initialIndex?: number;
|
||||
/** 是否打开预览 */
|
||||
open: boolean;
|
||||
/** 关闭预览的回调 */
|
||||
onClose: () => void;
|
||||
/** 下载文件的回调函数 */
|
||||
onDownload?: (id: string, file: FilePreviewItem) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件轮播预览组件
|
||||
*
|
||||
* 全屏图片查看器,支持缩放、旋转、拖拽、键盘导航等功能。
|
||||
*
|
||||
* 特性:
|
||||
* - Portal 渲染(避免 z-index 问题)
|
||||
* - ESC 键关闭,缩放、旋转、拖拽均有快捷键支持
|
||||
* - 缩放(按钮 + 滚轮)、旋转、拖拽移动(放大后)
|
||||
* - 下载图片
|
||||
* - 平滑动画
|
||||
* - 响应式设计
|
||||
* - 触摸友好
|
||||
* - ARIA 属性
|
||||
* - 键盘导航
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const [open, setOpen] = useState(false);
|
||||
* const [index, setIndex] = useState(0);
|
||||
*
|
||||
* <FileCarouselPreview
|
||||
* files={files}
|
||||
* initialIndex={index}
|
||||
* open={open}
|
||||
* onClose={() => setOpen(false)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function FileCarouselPreview({
|
||||
files,
|
||||
initialIndex = 0,
|
||||
open,
|
||||
onClose,
|
||||
onDownload,
|
||||
}: FileCarouselPreviewProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||
startIndex: initialIndex,
|
||||
loop: false,
|
||||
duration: 20, // 设置切换动画时长(毫秒)
|
||||
});
|
||||
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const [rotation, setRotation] = useState(0);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [currentScale, setCurrentScale] = useState(1);
|
||||
|
||||
// 使用 framer-motion 的 motion values 和 spring 动画
|
||||
const scaleMotion = useMotionValue(1);
|
||||
const xMotion = useMotionValue(0);
|
||||
const yMotion = useMotionValue(0);
|
||||
|
||||
// 使用 useSpring 包装 motion values,添加弹性物理效果
|
||||
const scale = useSpring(scaleMotion, { stiffness: 300, damping: 30 });
|
||||
const x = useSpring(xMotion, { stiffness: 300, damping: 30 });
|
||||
const y = useSpring(yMotion, { stiffness: 300, damping: 30 });
|
||||
|
||||
// 监听 scale 变化,更新 state 以触发按钮状态更新
|
||||
useMotionValueEvent(scale, "change", (latest) => {
|
||||
setCurrentScale(latest);
|
||||
});
|
||||
|
||||
const imageRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const thumbnailRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 确保组件在客户端挂载
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// 监听 embla 选中事件
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
|
||||
const onSelect = () => {
|
||||
setCurrentIndex(emblaApi.selectedScrollSnap());
|
||||
// 切换图片时重置变换
|
||||
animate(scaleMotion, 1, { duration: 0.2 });
|
||||
animate(xMotion, 0, { duration: 0.2 });
|
||||
animate(yMotion, 0, { duration: 0.2 });
|
||||
setRotation(0);
|
||||
};
|
||||
|
||||
emblaApi.on('select', onSelect);
|
||||
onSelect();
|
||||
|
||||
return () => {
|
||||
emblaApi.off('select', onSelect);
|
||||
};
|
||||
}, [emblaApi, scaleMotion, xMotion, yMotion]);
|
||||
|
||||
// 禁用背景滚动
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 缩放功能
|
||||
const handleZoomIn = useCallback(() => {
|
||||
const current = scaleMotion.get();
|
||||
animate(scaleMotion, Math.min(current + 0.25, 5), { duration: 0.2 });
|
||||
}, [scaleMotion]);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
const current = scaleMotion.get();
|
||||
animate(scaleMotion, Math.max(current - 0.25, 0.5), { duration: 0.2 });
|
||||
}, [scaleMotion]);
|
||||
|
||||
const handleRotate = useCallback(() => {
|
||||
setRotation((prev) => (prev + 90) % 360);
|
||||
}, []);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
animate(scaleMotion, 1, { duration: 0.2 });
|
||||
animate(xMotion, 0, { duration: 0.2 });
|
||||
animate(yMotion, 0, { duration: 0.2 });
|
||||
setRotation(0);
|
||||
}, [scaleMotion, xMotion, yMotion]);
|
||||
|
||||
|
||||
// 键盘事件处理
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// 阻止事件冒泡,避免关闭父对话框
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
emblaApi?.scrollPrev();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
emblaApi?.scrollNext();
|
||||
break;
|
||||
case '+':
|
||||
case '=':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleZoomIn();
|
||||
break;
|
||||
case '-':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleZoomOut();
|
||||
break;
|
||||
case 'r':
|
||||
case 'R':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleRotate();
|
||||
break;
|
||||
case '0':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleReset();
|
||||
break;
|
||||
case '?':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowHelp((prev) => !prev);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// 使用 capture 阶段捕获事件,优先级更高
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [open, emblaApi, onClose, handleZoomIn, handleZoomOut, handleRotate, handleReset]);
|
||||
|
||||
// 使用 use-gesture 处理所有手势
|
||||
// 注意:必须使用 target 选项才能正确使用 preventDefault
|
||||
useGesture(
|
||||
{
|
||||
// 拖拽手势 - 用于移动图片或切换图片
|
||||
onDrag: ({ offset: [ox, oy], active }) => {
|
||||
const currentScale = scale.get();
|
||||
|
||||
// 如果图片放大了,拖拽用于移动图片
|
||||
if (currentScale > 1) {
|
||||
x.set(ox);
|
||||
y.set(oy);
|
||||
}
|
||||
else if (!active) {
|
||||
// 重置位置,切换的逻辑embla-carousel-react处理,这里不用管
|
||||
animate(x, 0, { duration: 0.3 });
|
||||
animate(y, 0, { duration: 0.3 });
|
||||
}
|
||||
},
|
||||
|
||||
// 滚轮手势 - 用于缩放
|
||||
onWheel: ({ event, delta: [, dy], last }) => {
|
||||
// 避免在最后一个事件中访问 event(debounced)
|
||||
if (!last && event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
const currentScale = scaleMotion.get();
|
||||
const scaleDelta = dy > 0 ? -0.1 : 0.1;
|
||||
const newScale = Math.max(0.5, Math.min(5, currentScale + scaleDelta));
|
||||
if (!last) {
|
||||
scaleMotion.set(newScale);
|
||||
}
|
||||
},
|
||||
|
||||
// 双指缩放手势 - 移动端
|
||||
onPinch: ({ offset: [s], origin: [ox, oy], first, memo, last }) => {
|
||||
if (first) {
|
||||
const currentScale = scaleMotion.get();
|
||||
const currentX = xMotion.get();
|
||||
const currentY = yMotion.get();
|
||||
return [currentScale, currentX, currentY, ox, oy];
|
||||
}
|
||||
|
||||
const [initialScale, initialX, initialY, initialOx, initialOy] = memo;
|
||||
const newScale = Math.max(0.5, Math.min(5, s));
|
||||
|
||||
// 计算缩放中心偏移
|
||||
if (imageRef.current) {
|
||||
const rect = imageRef.current.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
|
||||
// 相对于图片中心的偏移
|
||||
const offsetX = ox - centerX;
|
||||
const offsetY = oy - centerY;
|
||||
|
||||
// 根据缩放调整位置,使缩放中心保持在手指位置
|
||||
const scaleRatio = newScale / initialScale;
|
||||
xMotion.set(initialX + offsetX * (1 - scaleRatio));
|
||||
yMotion.set(initialY + offsetY * (1 - scaleRatio));
|
||||
}
|
||||
|
||||
if (!last) {
|
||||
scaleMotion.set(newScale);
|
||||
}
|
||||
return memo;
|
||||
},
|
||||
},
|
||||
{
|
||||
target: imageRef,
|
||||
drag: {
|
||||
from: () => [x.get(), y.get()],
|
||||
bounds: (state) => {
|
||||
const currentScale = scale.get();
|
||||
if (currentScale <= 1) {
|
||||
// 未放大时,允许左右拖拽切换
|
||||
return { left: -200, right: 200, top: 0, bottom: 0 };
|
||||
}
|
||||
// 放大时不限制边界
|
||||
return { left: -Infinity, right: Infinity, top: -Infinity, bottom: Infinity };
|
||||
},
|
||||
},
|
||||
pinch: {
|
||||
scaleBounds: { min: 0.5, max: 5 },
|
||||
eventOptions: { passive: false },
|
||||
},
|
||||
wheel: {
|
||||
eventOptions: { passive: false },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// 缩略图容器的拖动手势
|
||||
useGesture(
|
||||
{
|
||||
onDrag: ({ movement: [mx], memo = thumbnailRef.current?.scrollLeft ?? 0 }) => {
|
||||
if (thumbnailRef.current) {
|
||||
thumbnailRef.current.scrollLeft = memo - mx;
|
||||
}
|
||||
return memo;
|
||||
},
|
||||
},
|
||||
{
|
||||
target: thumbnailRef,
|
||||
drag: {
|
||||
axis: 'x',
|
||||
filterTaps: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// 下载当前文件
|
||||
const handleDownloadCurrent = useCallback(() => {
|
||||
const currentFile = files[currentIndex];
|
||||
if (currentFile) {
|
||||
if (onDownload) {
|
||||
onDownload(currentFile.id, currentFile);
|
||||
} else if (currentFile.file) {
|
||||
downloadFromFile(currentFile.file);
|
||||
}
|
||||
}
|
||||
}, [files, currentIndex, onDownload]);
|
||||
|
||||
|
||||
if (!mounted || !open) return null;
|
||||
|
||||
const currentFile = files[currentIndex];
|
||||
const canScrollPrev = emblaApi?.canScrollPrev() ?? false;
|
||||
const canScrollNext = emblaApi?.canScrollNext() ?? false;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="fixed inset-0 z-70 flex items-center justify-center bg-black/95 pointer-events-auto"
|
||||
onKeyDown={(e) => {
|
||||
// 阻止键盘事件冒泡到父组件
|
||||
e.stopPropagation();
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="文件预览"
|
||||
>
|
||||
{/* 顶部工具栏 */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 z-10 flex items-start gap-2 p-4 bg-gradient-to-b from-black/50 to-transparent pointer-events-none"
|
||||
>
|
||||
<span className="text-sm font-medium text-white shrink-0">
|
||||
{currentIndex + 1} / {files.length}
|
||||
</span>
|
||||
|
||||
{currentFile && (
|
||||
<span className="text-sm text-gray-300 break-words flex-1 min-w-0">
|
||||
{currentFile.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0 pointer-events-auto">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
setShowHelp((prev) => !prev);
|
||||
}}
|
||||
className="text-white hover:bg-white/20"
|
||||
title="快捷键帮助 (?)"
|
||||
>
|
||||
<HelpCircle className='size-5'/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
onClose();
|
||||
}}
|
||||
className="text-white hover:bg-white/20"
|
||||
title="关闭 (ESC)"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 快捷键帮助面板 */}
|
||||
{showHelp && (
|
||||
<div
|
||||
className="absolute top-20 right-4 z-20 bg-black/90 text-white p-4 rounded-lg text-sm space-y-3 max-w-xs pointer-events-none"
|
||||
>
|
||||
<h3 className="font-semibold mb-2">键盘快捷键</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center gap-4">
|
||||
<Kbd className="bg-white/10 text-white border border-white/20">ESC</Kbd>
|
||||
<span className="text-gray-300">关闭</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center gap-4">
|
||||
<div className="flex gap-1">
|
||||
<Kbd className="bg-white/10 text-white border border-white/20">
|
||||
<ArrowLeft className="size-3" />
|
||||
</Kbd>
|
||||
<Kbd className="bg-white/10 text-white border border-white/20">
|
||||
<ArrowRight className="size-3" />
|
||||
</Kbd>
|
||||
</div>
|
||||
<span className="text-gray-300">切换图片</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center gap-4">
|
||||
<div className="flex gap-1">
|
||||
<Kbd className="bg-white/10 text-white border border-white/20">
|
||||
<Plus className="size-3" />
|
||||
</Kbd>
|
||||
<Kbd className="bg-white/10 text-white border border-white/20">
|
||||
<Minus className="size-3" />
|
||||
</Kbd>
|
||||
</div>
|
||||
<span className="text-gray-300">缩放</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center gap-4">
|
||||
<Kbd className="bg-white/10 text-white border border-white/20">R</Kbd>
|
||||
<span className="text-gray-300">旋转</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center gap-4">
|
||||
<Kbd className="bg-white/10 text-white border border-white/20">0</Kbd>
|
||||
<span className="text-gray-300">重置</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center gap-4">
|
||||
<Kbd className="bg-white/10 text-white border border-white/20">?</Kbd>
|
||||
<span className="text-gray-300">显示/隐藏帮助</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 左侧工具栏 */}
|
||||
<div className="absolute left-4 top-1/2 -translate-y-1/2 z-10 flex flex-col gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
handleZoomIn();
|
||||
}}
|
||||
disabled={currentScale >= 5}
|
||||
title="放大 (+)"
|
||||
className="bg-black/50 hover:bg-black/70 text-white"
|
||||
>
|
||||
<ZoomIn className="size-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
handleZoomOut();
|
||||
}}
|
||||
disabled={currentScale <= 0.5}
|
||||
title="缩小 (-)"
|
||||
className="bg-black/50 hover:bg-black/70 text-white"
|
||||
>
|
||||
<ZoomOut className="size-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
handleRotate();
|
||||
}}
|
||||
title="旋转 (R)"
|
||||
className="bg-black/50 hover:bg-black/70 text-white"
|
||||
>
|
||||
<RotateCw className="size-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
handleReset();
|
||||
}}
|
||||
title="重置 (0)"
|
||||
className="bg-black/50 hover:bg-black/70 text-white"
|
||||
>
|
||||
<Maximize2 className="size-5" />
|
||||
</Button>
|
||||
{(currentFile?.file || onDownload) && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
handleDownloadCurrent();
|
||||
}}
|
||||
title="下载"
|
||||
className="bg-black/50 hover:bg-black/70 text-white"
|
||||
>
|
||||
<Download className="size-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 轮播容器 */}
|
||||
<div className="relative w-full h-full flex items-center justify-center">
|
||||
<div ref={emblaRef} className="overflow-hidden w-full h-full">
|
||||
<div className="flex h-full gap-8">
|
||||
{files.map((file, index) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex-[0_0_100%] min-w-0 flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
ref={index === currentIndex ? imageRef : null}
|
||||
className="relative flex items-center justify-center w-full h-full touch-none"
|
||||
style={{
|
||||
cursor: index === currentIndex && scale.get() > 1 ? 'grab' : 'default',
|
||||
}}
|
||||
>
|
||||
{file.type?.startsWith('image/') && file.preview ? (
|
||||
<motion.img
|
||||
src={file.preview}
|
||||
alt={file.name}
|
||||
className="max-w-full max-h-full object-contain select-none"
|
||||
style={
|
||||
index === currentIndex
|
||||
? {
|
||||
scale,
|
||||
x,
|
||||
y,
|
||||
rotate: rotation,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-4 text-white">
|
||||
{getFileIcon(file.type)}
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-medium">{file.name}</p>
|
||||
<p className="text-sm text-gray-400">{formatBytes(file.size)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* 底部导航区域:左右切换按钮 + 缩略图 */}
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2">
|
||||
{/* 左切换按钮 */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={() => emblaApi?.scrollPrev()}
|
||||
disabled={!canScrollPrev}
|
||||
className="size-10 rounded-full bg-black/50 hover:bg-black/70 text-white flex-shrink-0 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="上一张 (←)"
|
||||
>
|
||||
<ChevronLeft className="size-5" />
|
||||
</Button>
|
||||
|
||||
{/* 缩略图导航 */}
|
||||
<div
|
||||
ref={thumbnailRef}
|
||||
className="flex gap-2 max-w-[70vw] overflow-x-auto scrollbar-muted p-2 bg-black/50 rounded-lg cursor-grab active:cursor-grabbing touch-pan-x"
|
||||
>
|
||||
{files.map((file, index) => (
|
||||
<button
|
||||
key={file.id}
|
||||
onClick={() => emblaApi?.scrollTo(index)}
|
||||
className={cn(
|
||||
'relative size-16 flex-shrink-0 rounded overflow-hidden border-2 transition-all cursor-pointer select-none',
|
||||
index === currentIndex
|
||||
? 'border-primary ring-2 ring-primary'
|
||||
: 'border-transparent hover:border-white/50'
|
||||
)}
|
||||
title={file.name}
|
||||
>
|
||||
{file.type?.startsWith('image/') && file.preview ? (
|
||||
<img
|
||||
src={file.preview}
|
||||
alt={file.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-muted text-muted-foreground">
|
||||
{getFileIcon(file.type)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 右切换按钮 */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={() => emblaApi?.scrollNext()}
|
||||
disabled={!canScrollNext}
|
||||
className="size-10 rounded-full bg-black/50 hover:bg-black/70 text-white flex-shrink-0 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="下一张 (→)"
|
||||
>
|
||||
<ChevronRight className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
508
src/components/common/file-upload-provider.tsx
Normal file
508
src/components/common/file-upload-provider.tsx
Normal file
@@ -0,0 +1,508 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback, type ReactNode, useEffect, useImperativeHandle, forwardRef, type Ref, useRef } from 'react';
|
||||
import type { FileRejection } from 'react-dropzone';
|
||||
import imageCompression from 'browser-image-compression';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatBytes } from '@/lib/format';
|
||||
import { useCallbackRef } from '@/hooks/use-callback-ref';
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
|
||||
const translateErrorMessage = (message: string, code?: string): string => {
|
||||
switch (code) {
|
||||
case 'file-invalid-type':
|
||||
return '文件类型不支持';
|
||||
case 'file-too-large':
|
||||
return '文件过大';
|
||||
case 'file-too-small':
|
||||
return '文件过小';
|
||||
case 'too-many-files':
|
||||
return '文件数量超过限制';
|
||||
default:
|
||||
return message;
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
export interface UploadFileItem {
|
||||
id: string; // 客户端本地维护的文件唯一标识
|
||||
file?: File; // 浏览器文件对象
|
||||
preview?: string; // URL.createObjectURL 创建的预览URL
|
||||
progress?: number; // 文件上传服务器进度
|
||||
objectName?: string; // 文件上传之后服务器上的文件标识,用于表单提交
|
||||
}
|
||||
|
||||
export interface ImageCompressionOptions {
|
||||
compress?: boolean;
|
||||
maxWidthOrHeight?: number;
|
||||
fileType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 暴露给父组件的 ref 方法接口
|
||||
*/
|
||||
export interface FileUploadRef {
|
||||
/** 获取当前已上传的文件列表 */
|
||||
getFiles: () => UploadFileItem[];
|
||||
/** 获取上传过程中产生的错误信息列表 */
|
||||
getErrors: () => string[];
|
||||
/** 添加文件到上传列表(会处理图片压缩和预览生成) */
|
||||
addFiles: (files: File[]) => void;
|
||||
/** 从上传列表中移除指定 ID 列表的文件 */
|
||||
removeFiles: (ids: string[]) => void;
|
||||
/** 清空所有错误信息 */
|
||||
clearErrors: () => void;
|
||||
/** 清空所有上传的文件,同时清空错误信息 */
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件上传组件的 Context 值类型定义
|
||||
*/
|
||||
export interface FileUploadContextValue {
|
||||
/** 当前已上传的文件列表 */
|
||||
files: UploadFileItem[];
|
||||
/** 上传过程中产生的错误信息列表 */
|
||||
errors: string[];
|
||||
/** 允许上传的最大文件数量 */
|
||||
maxFiles: number;
|
||||
/** 单个文件的最大大小(字节) */
|
||||
maxSize: number;
|
||||
/** 允许上传的文件类型,MIME 类型列表,例如 ['image/png', 'image/jpeg'] */
|
||||
accept?: string[];
|
||||
/** 是否允许多文件上传 */
|
||||
multiple: boolean;
|
||||
/** 是否禁用整个上传组件 */
|
||||
disabled: boolean;
|
||||
/** 上传按钮是否应该被禁用(基于文件数量限制和 disabled 状态计算得出) */
|
||||
isUploadDisabled: boolean;
|
||||
/** 图片压缩选项配置 */
|
||||
imageOptions?: ImageCompressionOptions;
|
||||
/** 添加文件到上传列表(会处理图片压缩和预览生成) */
|
||||
addFiles: (files: File[]) => void;
|
||||
/** 从上传列表中移除指定 ID 列表的文件 */
|
||||
removeFiles: (ids: string[]) => void;
|
||||
/** 添加一条错误信息到错误列表 */
|
||||
addError: (error: string) => void;
|
||||
/** 清空所有错误信息 */
|
||||
clearErrors: () => void;
|
||||
/** 清空所有上传的文件 */
|
||||
clear: () => void;
|
||||
/** 处理文件拖放事件,包含文件验证和错误处理逻辑 */
|
||||
handleFileDrop: (acceptedFiles: File[], fileRejections: FileRejection[]) => Promise<void>;
|
||||
}
|
||||
|
||||
// ==================== Context ====================
|
||||
|
||||
const FileUploadContext = createContext<FileUploadContextValue | undefined>(undefined);
|
||||
|
||||
export const useFileUploadContext = () => {
|
||||
const context = useContext(FileUploadContext);
|
||||
if (!context) {
|
||||
throw new Error('FileUpload 子组件必须在 FileUpload 组件内使用');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// ==================== 组件 ====================
|
||||
|
||||
export interface FileUploadProps {
|
||||
maxFiles?: number;
|
||||
maxSize?: number;
|
||||
accept?: string[];
|
||||
multiple?: boolean;
|
||||
disabled?: boolean;
|
||||
imageOptions?: ImageCompressionOptions;
|
||||
onFilesChange?: (files: UploadFileItem[]) => void;
|
||||
/**
|
||||
* 可选的文件上传处理器函数,用于将文件上传到服务器
|
||||
*
|
||||
* @param files - 待上传的文件映射对象,key 为文件的唯一标识 ID,value 为 File 对象
|
||||
* @param onProgress - 进度回调函数,用于更新每个文件的上传进度
|
||||
* - key: 文件的唯一标识 ID(与 files 参数中的 key 对应)
|
||||
* - progress: 上传进度百分比(0-100)
|
||||
* @returns 当文件上传完毕时返回上传结果映射对象
|
||||
* - key: 文件的唯一标识 ID(与 files 参数中的 key 对应)
|
||||
* - value: 上传成功后服务器返回的文件标识(objectName),用于后续访问文件
|
||||
* ```
|
||||
*/
|
||||
uploadFilesHandler?: (
|
||||
files: Record<string, File>,
|
||||
onProgress: (key: string, progress: number) => void
|
||||
) => Promise<Record<string, string>>;
|
||||
/**
|
||||
* 可选的文件下载处理器函数,用于从服务器下载文件
|
||||
*
|
||||
* @param objectNames - 待下载的文件标识列表
|
||||
* @param onProgress - 进度回调函数,用于更新每个文件的下载进度
|
||||
* - objectName: 文件的服务器标识
|
||||
* - progress: 下载进度百分比(0-100)
|
||||
* @returns 当文件下载完毕时返回 File 对象映射,key 为 objectName,value 为 File 对象
|
||||
*/
|
||||
downloadFilesHandler?: (
|
||||
objectNames: string[],
|
||||
onProgress: (objectName: string, progress: number) => void
|
||||
) => Promise<Record<string, File>>;
|
||||
value?: string[]; // 已上传的objectName列表,用于和表单集成,需要配合downloadFilesHandler使用
|
||||
onChange?: (value: string[]) => void; // 变更回调,参数为已上传文件的objectname列表,用于和表单集成
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const FileUpload = forwardRef(function FileUpload({
|
||||
maxFiles = 10,
|
||||
maxSize = 50 * 1024 * 1024,
|
||||
accept,
|
||||
multiple = true,
|
||||
disabled = false,
|
||||
imageOptions,
|
||||
onFilesChange,
|
||||
uploadFilesHandler,
|
||||
downloadFilesHandler,
|
||||
value,
|
||||
onChange,
|
||||
children,
|
||||
className,
|
||||
}: FileUploadProps, ref: Ref<FileUploadRef>) {
|
||||
const [files, setFiles] = useState<UploadFileItem[]>([]);
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
// 用于跟踪处理中的 value,避免重复添加
|
||||
const processingValueRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const addError = useCallback((error: string) => {
|
||||
setErrors((prev) => [...prev, error]);
|
||||
}, []);
|
||||
|
||||
const clearErrors = useCallback(() => {
|
||||
setErrors([]);
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
// 清理所有预览 URL
|
||||
files.forEach((f) => {
|
||||
if (f.preview) {
|
||||
URL.revokeObjectURL(f.preview);
|
||||
}
|
||||
});
|
||||
setFiles([]);
|
||||
setErrors([]);
|
||||
onFilesChange?.([]);
|
||||
onChange?.([]);
|
||||
}, [files, onFilesChange, onChange]);
|
||||
|
||||
const removeFiles = useCallback(
|
||||
(ids: string[]) => {
|
||||
setFiles((prev) => {
|
||||
const filesToRemove = prev.filter((f) => ids.includes(f.id));
|
||||
// 清理预览 URL
|
||||
filesToRemove.forEach((f) => {
|
||||
if (f.preview) {
|
||||
URL.revokeObjectURL(f.preview);
|
||||
}
|
||||
});
|
||||
const updated = prev.filter((f) => !ids.includes(f.id));
|
||||
onFilesChange?.(updated);
|
||||
|
||||
// 调用 onChange 回调,返回 objectName 列表
|
||||
if (onChange) {
|
||||
const objectNames = updated
|
||||
.map((f) => f.objectName)
|
||||
.filter((name): name is string => name !== undefined);
|
||||
queueMicrotask(() => onChange(objectNames));
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
[onFilesChange, onChange]
|
||||
);
|
||||
|
||||
const addFiles = useCallback(
|
||||
async (newFiles: File[]) => {
|
||||
// 确定要添加的文件
|
||||
let filesToAdd: File[];
|
||||
let shouldReplaceAll = false;
|
||||
|
||||
if (!multiple) {
|
||||
// 当 multiple 为 false 时,清空现有文件并只保留第一个新文件
|
||||
filesToAdd = newFiles.slice(0, 1);
|
||||
shouldReplaceAll = true;
|
||||
|
||||
// 清理现有文件的预览 URL
|
||||
files.forEach((f) => {
|
||||
if (f.preview) {
|
||||
URL.revokeObjectURL(f.preview);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// multiple 为 true 时,检查文件数量限制
|
||||
const remainingSlots = maxFiles - files.length;
|
||||
if (remainingSlots <= 0) {
|
||||
return; // 已达到最大文件数量
|
||||
}
|
||||
// 限制新文件数量不超过剩余槽位
|
||||
filesToAdd = newFiles.slice(0, remainingSlots);
|
||||
}
|
||||
|
||||
const processedFiles = await Promise.all(
|
||||
filesToAdd.map(async (file) => {
|
||||
// 对图片文件进行压缩处理
|
||||
if (file.type.startsWith('image/') && imageOptions?.compress) {
|
||||
try {
|
||||
const options = {
|
||||
maxSizeMB: maxSize / (1024 * 1024), // 转换为 MB
|
||||
maxWidthOrHeight: imageOptions.maxWidthOrHeight ?? 1920,
|
||||
fileType: imageOptions.fileType ?? 'image/webp',
|
||||
useWebWorker: true,
|
||||
libURL: '/js/browser-image-compression.js',
|
||||
};
|
||||
|
||||
const compressedFile = await imageCompression(file, options);
|
||||
return compressedFile;
|
||||
} catch (error) {
|
||||
console.error('图片压缩失败:', error);
|
||||
return file; // 压缩失败时返回原文件
|
||||
}
|
||||
}
|
||||
return file;
|
||||
})
|
||||
);
|
||||
|
||||
// 创建上传文件项(统一逻辑)
|
||||
const uploadFiles: UploadFileItem[] = processedFiles.map((file) => ({
|
||||
id: `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
file,
|
||||
preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined,
|
||||
}));
|
||||
|
||||
// 更新文件列表
|
||||
const updatedFiles = shouldReplaceAll ? uploadFiles : [...files, ...uploadFiles];
|
||||
setFiles(updatedFiles);
|
||||
onFilesChange?.(updatedFiles);
|
||||
|
||||
|
||||
// 如果提供了 uploadFilesHandler,则调用它上传文件
|
||||
if (uploadFilesHandler) {
|
||||
// 定义进度更新回调
|
||||
const onProgress = (key: string, progress: number) => {
|
||||
setFiles((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === key ? { ...item, progress } : item
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// 构建文件映射对象,并更新progress为0
|
||||
const filesMap: Record<string, File> = {};
|
||||
uploadFiles.forEach((item) => {
|
||||
filesMap[item.id] = item.file!;
|
||||
onProgress(item.id, 0)
|
||||
});
|
||||
|
||||
try {
|
||||
// 调用上传处理器
|
||||
const objectNames = await uploadFilesHandler(filesMap, onProgress);
|
||||
|
||||
// 更新文件的 objectName,上传成功后清除进度
|
||||
setFiles((prev) => {
|
||||
const updated = prev.map((item) =>
|
||||
objectNames[item.id]
|
||||
? { ...item, objectName: objectNames[item.id], progress: undefined }
|
||||
: item
|
||||
);
|
||||
onFilesChange?.(updated);
|
||||
|
||||
// 调用 onChange 回调,返回 objectName 列表
|
||||
if (onChange) {
|
||||
const uploadedObjectNames = updated
|
||||
.map((f) => f.objectName)
|
||||
.filter((name): name is string => name !== undefined);
|
||||
queueMicrotask(() => onChange(uploadedObjectNames));
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
} catch (error) {
|
||||
// 上传失败时,添加错误信息并移除失败的文件
|
||||
const errorMessage = error instanceof Error ? error.message : '文件上传失败';
|
||||
addError(errorMessage);
|
||||
|
||||
// 移除上传失败的文件
|
||||
removeFiles(uploadFiles.map((uf) => uf.id));
|
||||
}
|
||||
}
|
||||
},
|
||||
[files, onFilesChange, maxSize, imageOptions, maxFiles, multiple, uploadFilesHandler, addError, removeFiles, onChange]
|
||||
);
|
||||
|
||||
// 使用 useCallbackRef 包装需要访问最新 files 的逻辑
|
||||
const processValue = useCallbackRef(() => {
|
||||
if (!downloadFilesHandler) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取当前 files 中的 objectName 集合
|
||||
const currentObjectNames = new Set(
|
||||
files.map((f) => f.objectName).filter((name): name is string => name !== undefined)
|
||||
);
|
||||
|
||||
// 如果 value 是数组,删除 files 中不在 value 中的文件
|
||||
if (Array.isArray(value)) {
|
||||
const valueSet = new Set(value);
|
||||
const filesToRemove = files.filter(
|
||||
(f) => f.objectName && !valueSet.has(f.objectName)
|
||||
);
|
||||
|
||||
if (filesToRemove.length > 0) {
|
||||
removeFiles(filesToRemove.map((f) => f.id));
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
// 找出需要下载的文件(在 value 中但不在 files 和已处理记录中)
|
||||
const missingValues = value.filter(
|
||||
(name) => !currentObjectNames.has(name) && !processingValueRef.current.has(name)
|
||||
);
|
||||
|
||||
if (missingValues.length === 0) {
|
||||
return;
|
||||
}
|
||||
missingValues.forEach((name) => processingValueRef.current.add(name));
|
||||
|
||||
// 立即为缺失的文件创建占位项
|
||||
const placeholderItems: UploadFileItem[] = missingValues.map((objectName) => ({
|
||||
id: objectName,
|
||||
objectName,
|
||||
progress: 0,
|
||||
}));
|
||||
|
||||
setFiles((prev) => [...prev, ...placeholderItems]);
|
||||
|
||||
// 定义下载进度回调
|
||||
const onProgress = (objectName: string, progress: number) => {
|
||||
setFiles((prev) =>
|
||||
prev.map((item) =>
|
||||
item.objectName === objectName ? { ...item, progress } : item
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// 异步下载文件
|
||||
const downloadFiles = async () => {
|
||||
try {
|
||||
// 调用下载处理器
|
||||
const downloadedFilesMap = await downloadFilesHandler(missingValues, onProgress);
|
||||
|
||||
// 更新文件对象和预览,使用 objectName 匹配
|
||||
setFiles((prev) => {
|
||||
const updated = prev.map((item) => {
|
||||
if (!item.objectName || !downloadedFilesMap[item.objectName]) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const file = downloadedFilesMap[item.objectName];
|
||||
return {
|
||||
...item,
|
||||
file,
|
||||
preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined,
|
||||
progress: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
onFilesChange?.(updated);
|
||||
return updated;
|
||||
});
|
||||
} catch (error) {
|
||||
// 下载失败时,移除占位项
|
||||
const errorMessage = error instanceof Error ? error.message : '文件下载失败';
|
||||
addError(errorMessage);
|
||||
|
||||
setFiles((prev) => {
|
||||
const updated = prev.filter((item) => !missingValues.includes(item.objectName!));
|
||||
onFilesChange?.(updated);
|
||||
return updated;
|
||||
});
|
||||
} finally {
|
||||
missingValues.forEach((name) => processingValueRef.current.delete(name));
|
||||
}
|
||||
};
|
||||
|
||||
void downloadFiles();
|
||||
});
|
||||
|
||||
// 处理 value,通过副作用实现受控模式
|
||||
useEffect(() => {
|
||||
processValue();
|
||||
}, [value, processValue]);
|
||||
|
||||
// 共享的文件处理逻辑
|
||||
const handleFileDrop = useCallback(
|
||||
async (acceptedFiles: File[], fileRejections: FileRejection[]) => {
|
||||
clearErrors();
|
||||
|
||||
if (fileRejections.length > 0) {
|
||||
const error = fileRejections.at(0)?.errors.at(0);
|
||||
const translatedMessage = translateErrorMessage(error?.message || '', error?.code);
|
||||
addError(translatedMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// 自定义文件大小校验(针对非压缩图片或非图片文件)
|
||||
const oversizedFiles = acceptedFiles.filter((file) => {
|
||||
// 如果是图片且启用了压缩,跳过大小检查(压缩后会符合要求)
|
||||
if (file.type.startsWith('image/') && imageOptions?.compress) {
|
||||
return false;
|
||||
}
|
||||
return file.size > maxSize;
|
||||
});
|
||||
|
||||
if (oversizedFiles.length > 0) {
|
||||
addError(`文件过大: ${oversizedFiles[0].name} (${formatBytes(oversizedFiles[0].size)})`);
|
||||
return;
|
||||
}
|
||||
|
||||
await addFiles(acceptedFiles);
|
||||
},
|
||||
[addFiles, addError, clearErrors, maxSize, imageOptions]
|
||||
);
|
||||
|
||||
// 计算上传按钮是否应该被禁用
|
||||
const isUploadDisabled = disabled || (!multiple && files.length >= 1) || (multiple && files.length >= maxFiles);
|
||||
|
||||
const contextValue: FileUploadContextValue = {
|
||||
files,
|
||||
errors,
|
||||
maxFiles,
|
||||
maxSize,
|
||||
accept,
|
||||
multiple,
|
||||
disabled,
|
||||
isUploadDisabled,
|
||||
imageOptions,
|
||||
addFiles,
|
||||
removeFiles,
|
||||
addError,
|
||||
clearErrors,
|
||||
clear,
|
||||
handleFileDrop,
|
||||
};
|
||||
|
||||
// 暴露指定方法给父组件
|
||||
useImperativeHandle(ref, () => ({
|
||||
getFiles: () => files,
|
||||
getErrors: () => errors,
|
||||
addFiles,
|
||||
removeFiles,
|
||||
clearErrors,
|
||||
clear,
|
||||
}), [files, errors, addFiles, removeFiles, clearErrors, clear]);
|
||||
|
||||
return (
|
||||
<FileUploadContext.Provider value={contextValue}>
|
||||
<div className={cn('w-full space-y-4', className)}>{children}</div>
|
||||
</FileUploadContext.Provider>
|
||||
);
|
||||
});
|
||||
335
src/components/common/file-upload.tsx
Normal file
335
src/components/common/file-upload.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, forwardRef, type ReactNode, type ComponentPropsWithoutRef, type Ref } from 'react';
|
||||
import type { DropEvent, FileRejection } from 'react-dropzone';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import {
|
||||
Upload,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FileUpload, FileUploadRef, type UploadFileItem, useFileUploadContext } from './file-upload-provider';
|
||||
import { formatBytes } from '@/lib/format';
|
||||
import { FileCardPreview, type FilePreviewItem } from './file-preview';
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
|
||||
/**
|
||||
* 将 MIME 类型数组转换为 react-dropzone 需要的 accept 格式
|
||||
* @param mimeTypes MIME 类型数组,例如 ['image/png', 'image/jpeg']
|
||||
* @returns react-dropzone 的 accept 对象,例如 { 'image/png': [], 'image/jpeg': [] }
|
||||
*/
|
||||
const convertAcceptFormat = (mimeTypes?: string[]): Record<string, string[]> | undefined => {
|
||||
if (!mimeTypes || mimeTypes.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return mimeTypes.reduce((acc, mimeType) => {
|
||||
acc[mimeType] = [];
|
||||
return acc;
|
||||
}, {} as Record<string, string[]>);
|
||||
};
|
||||
|
||||
// ==================== 约束信息组件 ====================
|
||||
|
||||
export interface UploadConstraintsProps {
|
||||
className?: string;
|
||||
formatter?: (params: {
|
||||
accept?: string[];
|
||||
maxSize: number;
|
||||
maxFiles: number;
|
||||
multiple: boolean;
|
||||
}) => string;
|
||||
}
|
||||
|
||||
export function UploadConstraints({ className, formatter }: UploadConstraintsProps) {
|
||||
const { maxFiles, maxSize, accept, multiple } = useFileUploadContext();
|
||||
|
||||
// 默认格式化函数
|
||||
const defaultFormatter = useCallback(
|
||||
(params: {
|
||||
accept?: string[];
|
||||
maxSize: number;
|
||||
maxFiles: number;
|
||||
multiple: boolean;
|
||||
}) => {
|
||||
let text = '';
|
||||
if (params.accept && params.accept.length > 0) {
|
||||
text += '支持 ';
|
||||
text += new Intl.ListFormat('zh-CN').format(params.accept);
|
||||
text += ' • ';
|
||||
}
|
||||
text += `最大 ${formatBytes(params.maxSize)}`;
|
||||
if (params.multiple) {
|
||||
text += ` • 最多 ${params.maxFiles} 个文件`;
|
||||
}
|
||||
return text;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const constraintsText = (formatter || defaultFormatter)({
|
||||
accept,
|
||||
maxSize,
|
||||
maxFiles,
|
||||
multiple,
|
||||
});
|
||||
|
||||
return <p className={cn('text-xs text-muted-foreground whitespace-normal break-words', className)}>{constraintsText}</p>;
|
||||
}
|
||||
|
||||
// ==================== 上传按钮组件 ====================
|
||||
|
||||
export interface UploadDropzoneButtonProps {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
constraints?: ReactNode;
|
||||
}
|
||||
|
||||
export function UploadDropzoneButton({
|
||||
className,
|
||||
children,
|
||||
constraints,
|
||||
}: UploadDropzoneButtonProps) {
|
||||
const { maxFiles, accept, multiple, isUploadDisabled, handleFileDrop } = useFileUploadContext();
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (acceptedFiles: File[], fileRejections: FileRejection[], _event: DropEvent) => {
|
||||
await handleFileDrop(acceptedFiles, fileRejections);
|
||||
},
|
||||
[handleFileDrop]
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
accept: convertAcceptFormat(accept),
|
||||
maxFiles,
|
||||
multiple,
|
||||
disabled: isUploadDisabled,
|
||||
onDrop: handleDrop,
|
||||
// 不传递 maxSize 给 useDropzone,使用自定义校验
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
'relative h-auto w-full flex-col overflow-hidden p-4 lg:p-6 xl:p-8',
|
||||
isDragActive && 'outline-none ring-1 ring-ring',
|
||||
className
|
||||
)}
|
||||
disabled={isUploadDisabled}
|
||||
type="button"
|
||||
variant="outline"
|
||||
{...getRootProps()}
|
||||
>
|
||||
<input {...getInputProps()} disabled={isUploadDisabled} />
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
|
||||
<Upload className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="w-full space-y-1 text-center">
|
||||
<p className="text-sm font-medium">拖拽文件到此处或点击上传</p>
|
||||
{constraints}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 简单上传按钮组件 ====================
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface UploadButtonProps extends Omit<ComponentPropsWithoutRef<typeof Button>, 'type'> {}
|
||||
|
||||
export function UploadButton({
|
||||
className,
|
||||
children,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
...props
|
||||
}: UploadButtonProps) {
|
||||
const { maxFiles, accept, multiple, isUploadDisabled, handleFileDrop } = useFileUploadContext();
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (acceptedFiles: File[], fileRejections: FileRejection[], _event: DropEvent) => {
|
||||
await handleFileDrop(acceptedFiles, fileRejections);
|
||||
},
|
||||
[handleFileDrop]
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
accept: convertAcceptFormat(accept),
|
||||
maxFiles,
|
||||
multiple,
|
||||
disabled: isUploadDisabled,
|
||||
onDrop: handleDrop,
|
||||
noClick: false,
|
||||
noDrag: true, // 移动端禁用拖拽功能
|
||||
// 不传递 maxSize 给 useDropzone,使用自定义校验
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={className}
|
||||
disabled={isUploadDisabled}
|
||||
type="button"
|
||||
variant={variant}
|
||||
size={size}
|
||||
{...props}
|
||||
{...getRootProps()}
|
||||
>
|
||||
<input {...getInputProps()} disabled={isUploadDisabled} />
|
||||
{children || (
|
||||
<>
|
||||
<Upload className="mr-2 size-4" />
|
||||
上传文件
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 预览区域组件 ====================
|
||||
|
||||
export interface UploadCardPreviewProps {
|
||||
className?: string;
|
||||
gridClassName?: string;
|
||||
onRemove?: (id: string, file: UploadFileItem) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件卡片预览组件
|
||||
*
|
||||
* 基于 FileCardPreview 组件,集成了 FileUpload Context 的状态管理。
|
||||
* 用于在文件上传组件中展示已上传的文件列表。
|
||||
*/
|
||||
export function UploadCardPreview({
|
||||
className,
|
||||
gridClassName,
|
||||
onRemove,
|
||||
}: UploadCardPreviewProps) {
|
||||
const { files, disabled, removeFiles } = useFileUploadContext();
|
||||
|
||||
// 将 UploadFileItem 转换为 FilePreviewItem
|
||||
const previewFiles: FilePreviewItem[] = files.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.file?.name || '',
|
||||
size: item.file?.size || 0,
|
||||
type: item.file?.type,
|
||||
preview: item.preview,
|
||||
progress: item.progress,
|
||||
file: item.file,
|
||||
}));
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(id: string, file: FilePreviewItem) => {
|
||||
const fileItem = files.find((f) => f.id === id);
|
||||
if (fileItem && onRemove) {
|
||||
onRemove(id, fileItem);
|
||||
}
|
||||
removeFiles([id]);
|
||||
},
|
||||
[files, removeFiles, onRemove]
|
||||
);
|
||||
|
||||
return (
|
||||
<FileCardPreview
|
||||
files={previewFiles}
|
||||
className={className}
|
||||
gridClassName={gridClassName}
|
||||
disabled={disabled}
|
||||
showDownload
|
||||
showRemove
|
||||
showFileInfo
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 错误消息组件 ====================
|
||||
|
||||
export interface UploadAlertProps {
|
||||
className?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function UploadAlert({ className, title = '上传错误' }: UploadAlertProps) {
|
||||
const { errors } = useFileUploadContext();
|
||||
|
||||
if (errors.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert variant="destructive" className={className}>
|
||||
<AlertCircle className="size-4" />
|
||||
<AlertTitle>{title}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{errors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
))}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 开箱即用的组合好的组件 ====================
|
||||
|
||||
export interface CardUploadProps extends Omit<ComponentPropsWithoutRef<typeof FileUpload>, 'children'> {
|
||||
/** 约束信息的自定义格式化函数 */
|
||||
constraintsFormatter?: UploadConstraintsProps['formatter'];
|
||||
/** 预览区域的自定义类名 */
|
||||
previewClassName?: string;
|
||||
/** 预览区域网格的自定义类名 */
|
||||
previewGridClassName?: string;
|
||||
/** 错误提示的自定义标题 */
|
||||
alertTitle?: string;
|
||||
/** 移除文件时的回调 */
|
||||
onRemove?: (id: string, file: UploadFileItem) => void;
|
||||
/** 上传按钮的自定义内容 */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const CardUpload = forwardRef<FileUploadRef, CardUploadProps>(
|
||||
function CardUpload(
|
||||
{
|
||||
constraintsFormatter,
|
||||
previewClassName,
|
||||
previewGridClassName,
|
||||
alertTitle,
|
||||
onRemove,
|
||||
className,
|
||||
children,
|
||||
...fileUploadProps
|
||||
},
|
||||
ref: Ref<FileUploadRef>
|
||||
) {
|
||||
return (
|
||||
<FileUpload ref={ref} className={className} {...fileUploadProps}>
|
||||
{/* 移动端显示 UploadButton,桌面端显示 UploadDropzoneButton */}
|
||||
<div className="md:hidden space-y-2">
|
||||
<UploadButton>{children}</UploadButton>
|
||||
<UploadConstraints formatter={constraintsFormatter} />
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<UploadDropzoneButton
|
||||
constraints={<UploadConstraints formatter={constraintsFormatter} />}
|
||||
>
|
||||
{children}
|
||||
</UploadDropzoneButton>
|
||||
</div>
|
||||
<UploadAlert title={alertTitle} />
|
||||
<UploadCardPreview
|
||||
className={previewClassName}
|
||||
gridClassName={previewGridClassName}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</FileUpload>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
316
src/components/common/form-dialog.tsx
Normal file
316
src/components/common/form-dialog.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
'use client'
|
||||
|
||||
import React, { createContext, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { UseFormReturn, ControllerRenderProps } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
} from '@/components/ui/drawer'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
// FormDialog Context
|
||||
export interface FormDialogContextValue {
|
||||
form: UseFormReturn<any>
|
||||
close: () => void
|
||||
fields: FormFieldConfig[]
|
||||
}
|
||||
|
||||
const FormDialogContext = createContext<FormDialogContextValue | null>(null)
|
||||
|
||||
export function useFormDialogContext() {
|
||||
const context = useContext(FormDialogContext)
|
||||
if (!context) {
|
||||
throw new Error('useFormDialogContext must be used within FormDialog')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// 字段配置类型定义
|
||||
export interface FormFieldConfig {
|
||||
name: string
|
||||
label: string
|
||||
required?: boolean
|
||||
render: (props: { field: ControllerRenderProps<any, any> }) => React.ReactNode // 将...field传递给UI控件交给react-hook-form管理
|
||||
className?: string // 允许为单个字段指定自定义样式
|
||||
}
|
||||
|
||||
// 取消按钮组件
|
||||
export interface FormCancelActionProps extends Omit<React.ComponentProps<typeof Button>, 'onClick' | 'type'> {
|
||||
children?: React.ReactNode
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export function FormCancelAction({ children = '取消', variant = 'outline', onCancel, ...props }: FormCancelActionProps) {
|
||||
const { close } = useFormDialogContext()
|
||||
|
||||
const handleClick = () => {
|
||||
if (onCancel) {
|
||||
onCancel()
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button type="button" variant={variant} onClick={handleClick} {...props}>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// 重置按钮组件
|
||||
export interface FormResetActionProps extends Omit<React.ComponentProps<typeof Button>, 'onClick' | 'type'> {
|
||||
children?: React.ReactNode
|
||||
onReset?: () => void
|
||||
confirmTitle?: string
|
||||
confirmDescription?: string
|
||||
}
|
||||
|
||||
export function FormResetAction({
|
||||
children = '重置',
|
||||
variant = 'outline',
|
||||
onReset,
|
||||
confirmTitle = '确认重置',
|
||||
confirmDescription = '确定要重置表单吗?表单将回到打开时的状态。',
|
||||
...props
|
||||
}: FormResetActionProps) {
|
||||
const { form } = useFormDialogContext()
|
||||
const [showConfirm, setShowConfirm] = useState(false)
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (onReset) {
|
||||
onReset()
|
||||
} else {
|
||||
form.reset()
|
||||
}
|
||||
setShowConfirm(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button type="button" variant={variant} onClick={() => setShowConfirm(true)} {...props}>
|
||||
{children}
|
||||
</Button>
|
||||
|
||||
<AlertDialog open={showConfirm} onOpenChange={setShowConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{confirmTitle}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{confirmDescription}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirm}>确认</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// 提交按钮组件
|
||||
export interface FormSubmitActionProps extends Omit<React.ComponentProps<typeof Button>, 'onClick' | 'type'> {
|
||||
onSubmit: (data: any) => Promise<void> | void
|
||||
children?: React.ReactNode
|
||||
disabled?: boolean
|
||||
isSubmitting?: boolean
|
||||
showSpinningLoader?: boolean
|
||||
}
|
||||
|
||||
export function FormSubmitAction({
|
||||
onSubmit,
|
||||
children = '提交',
|
||||
disabled = false,
|
||||
isSubmitting = false,
|
||||
showSpinningLoader = true,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: FormSubmitActionProps) {
|
||||
const { form } = useFormDialogContext()
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
onClick={form.handleSubmit(onSubmit)}
|
||||
disabled={isSubmitting || disabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{isSubmitting && showSpinningLoader && <Loader2 className="ml-2 h-4 w-4 animate-spin" />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// 操作按钮栏组件
|
||||
export interface FormActionBarProps {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function FormActionBar({ children }: FormActionBarProps) {
|
||||
return (
|
||||
<div className="flex justify-end space-x-2">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// FormGridContent 组件
|
||||
export interface FormGridContentProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FormGridContent({ className = 'grid grid-cols-1 gap-4' }: FormGridContentProps) {
|
||||
const { form, fields } = useFormDialogContext()
|
||||
|
||||
return (
|
||||
<div className={cn("p-1", className)}>
|
||||
{fields.map((fieldConfig) => (
|
||||
<FormField
|
||||
key={fieldConfig.name}
|
||||
control={form.control}
|
||||
name={fieldConfig.name}
|
||||
render={({ field }) => (
|
||||
<FormItem className={fieldConfig.className || ''}>
|
||||
<FormLabel className="flex items-center gap-1">
|
||||
{fieldConfig.label}
|
||||
{fieldConfig.required && <span className="text-red-500">*</span>}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
{fieldConfig.render({ field })}
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface FormDialogProps {
|
||||
isOpen: boolean
|
||||
title: string
|
||||
description: string
|
||||
form: UseFormReturn<any>
|
||||
fields: FormFieldConfig[]
|
||||
onClose: () => void
|
||||
className?: string // 允许自定义对话框内容样式,可控制宽度
|
||||
formClassName?: string // 允许自定义表格样式
|
||||
children: React.ReactNode // 操作按钮区域内容
|
||||
}
|
||||
|
||||
export function FormDialog({
|
||||
isOpen,
|
||||
title,
|
||||
description,
|
||||
form,
|
||||
fields,
|
||||
onClose,
|
||||
className = 'max-w-md',
|
||||
formClassName,
|
||||
children,
|
||||
}: FormDialogProps) {
|
||||
const isMobile = useIsMobile()
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
|
||||
// 当对话框打开时,自动聚焦到第一个表单输入控件
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// 使当前拥有焦点的元素(通常是用来触发打开这个drawer的控件)失去焦点,不然控制台会警告焦点在一个要被隐藏于屏幕阅读器的控件上
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
// 使用 setTimeout 确保 DOM 已完全渲染
|
||||
const timer = setTimeout(() => {
|
||||
if (formRef.current) {
|
||||
// 查找第一个可聚焦的输入元素
|
||||
const firstInput = formRef.current.querySelector<HTMLElement>(
|
||||
'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled])'
|
||||
)
|
||||
if (firstInput) {
|
||||
firstInput.focus()
|
||||
}
|
||||
}
|
||||
}, 100)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const close = () => {
|
||||
onClose()
|
||||
}
|
||||
|
||||
// Context 值
|
||||
const contextValue: FormDialogContextValue = {
|
||||
form,
|
||||
close,
|
||||
fields
|
||||
}
|
||||
|
||||
// 表单内容组件,在 Dialog 和 Drawer 中复用
|
||||
const formContent = (
|
||||
<FormDialogContext.Provider value={contextValue}>
|
||||
<Form {...form}>
|
||||
<form ref={formRef} className={cn("space-y-6", formClassName)}>
|
||||
{children}
|
||||
</form>
|
||||
</Form>
|
||||
</FormDialogContext.Provider>
|
||||
)
|
||||
|
||||
// 根据设备类型渲染不同的组件
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={isOpen} onOpenChange={(open) => !open && close()}>
|
||||
<DrawerContent className={className}>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{title}</DrawerTitle>
|
||||
<DrawerDescription>{description}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="px-4 pb-4 overflow-y-auto max-h-[70vh]">
|
||||
{formContent}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && close()}>
|
||||
<DialogContent className={className} showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='overflow-y-auto max-h-[70vh]'>
|
||||
{formContent}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
326
src/components/common/multi-step-form-dialog.tsx
Normal file
326
src/components/common/multi-step-form-dialog.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
'use client'
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, useRef } from 'react'
|
||||
import { UseFormReturn, ControllerRenderProps } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
} from '@/components/ui/drawer'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// MultiStepFormDialog Context
|
||||
export interface MultiStepFormDialogContextValue {
|
||||
onCancel: () => void
|
||||
onPrevious: () => void
|
||||
onNext: () => void
|
||||
isSubmitting: boolean
|
||||
isValidating: boolean
|
||||
submitButtonText: string
|
||||
isFirstStep: boolean
|
||||
isLastStep: boolean
|
||||
}
|
||||
|
||||
const MultiStepFormDialogContext = createContext<MultiStepFormDialogContextValue | null>(null)
|
||||
|
||||
export function useMultiStepFormDialogContext() {
|
||||
const context = useContext(MultiStepFormDialogContext)
|
||||
if (!context) {
|
||||
throw new Error('useMultiStepFormDialogContext must be used within MultiStepFormDialog')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// 字段配置类型定义
|
||||
export interface FormFieldConfig {
|
||||
name: string
|
||||
label: string
|
||||
required?: boolean
|
||||
render: (props: { field: ControllerRenderProps<any, any> }) => React.ReactNode // 将...field传递给UI控件交给react-hook-form管理
|
||||
className?: string // 允许为单个字段指定自定义样式
|
||||
}
|
||||
|
||||
// 步骤配置类型定义
|
||||
export interface StepConfig {
|
||||
title: string
|
||||
description?: string
|
||||
fields: FormFieldConfig[]
|
||||
}
|
||||
|
||||
// 多步骤表单操作按钮栏组件
|
||||
export function MultiStepFormActionBar() {
|
||||
const {
|
||||
onCancel,
|
||||
onPrevious,
|
||||
onNext,
|
||||
isSubmitting,
|
||||
isValidating,
|
||||
submitButtonText,
|
||||
isFirstStep,
|
||||
isLastStep
|
||||
} = useMultiStepFormDialogContext()
|
||||
|
||||
return (
|
||||
<div className="flex justify-between pt-6 border-t">
|
||||
<div className="flex space-x-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
{!isFirstStep && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onPrevious}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
上一步
|
||||
</Button>
|
||||
)}
|
||||
{!isLastStep && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
disabled={isSubmitting || isValidating}
|
||||
>
|
||||
{isValidating ? '验证中...' : '下一步'}
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={!isLastStep ? 'hidden' : ''}
|
||||
>
|
||||
{isSubmitting ? `${submitButtonText}中...` : submitButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface MultiStepFormDialogProps {
|
||||
isOpen: boolean
|
||||
title: string
|
||||
description: string
|
||||
form: UseFormReturn<any>
|
||||
onSubmit: (data: any) => Promise<void> | void
|
||||
steps: StepConfig[]
|
||||
contentClassName?: string
|
||||
gridClassName?: string
|
||||
|
||||
/* action */
|
||||
onClose: () => void
|
||||
isSubmitting: boolean
|
||||
submitButtonText: string
|
||||
}
|
||||
|
||||
export function MultiStepFormDialog({
|
||||
isOpen,
|
||||
title,
|
||||
description,
|
||||
form,
|
||||
onSubmit,
|
||||
steps,
|
||||
contentClassName = 'max-w-4xl',
|
||||
gridClassName = 'grid grid-cols-1 md:grid-cols-2 gap-4',
|
||||
onClose,
|
||||
isSubmitting,
|
||||
submitButtonText,
|
||||
}: MultiStepFormDialogProps) {
|
||||
const isMobile = useIsMobile()
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [isValidating, setIsValidating] = useState(false)
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
|
||||
// 当对话框打开或步骤改变时,自动聚焦到第一个表单输入控件
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// 使当前拥有焦点的元素(通常是用来触发打开这个drawer的控件)失去焦点,不然控制台会警告焦点在一个要被隐藏于屏幕阅读器的控件上
|
||||
(document.activeElement as HTMLElement)?.blur()
|
||||
// 使用 setTimeout 确保 DOM 已完全渲染
|
||||
const timer = setTimeout(() => {
|
||||
if (formRef.current) {
|
||||
// 查找第一个可聚焦的输入元素
|
||||
const firstInput = formRef.current.querySelector<HTMLElement>(
|
||||
'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled])'
|
||||
)
|
||||
if (firstInput) {
|
||||
firstInput.focus()
|
||||
}
|
||||
}
|
||||
}, 100)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isOpen, currentStep])
|
||||
|
||||
const handleSubmit = async (data: any) => {
|
||||
await onSubmit(data)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setCurrentStep(0)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleNext = async () => {
|
||||
if (currentStep < steps.length - 1) {
|
||||
setIsValidating(true)
|
||||
try {
|
||||
// 验证当前步骤的字段,只有验证通过才能跳转到下一步
|
||||
const currentStepFields = currentStepConfig.fields.map(field => field.name)
|
||||
const isValid = await form.trigger(currentStepFields)
|
||||
if (isValid) {
|
||||
setCurrentStep(currentStep + 1)
|
||||
}
|
||||
} finally {
|
||||
setIsValidating(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep(currentStep - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const isFirstStep = currentStep === 0
|
||||
const isLastStep = currentStep === steps.length - 1
|
||||
const currentStepConfig = steps[currentStep]
|
||||
|
||||
// 步骤指示器组件
|
||||
const stepIndicator = (
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
{steps.map((step, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
index <= currentStep
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="ml-2 text-sm">
|
||||
<div className={index <= currentStep ? 'text-primary font-medium' : 'text-muted-foreground'}>
|
||||
{step.title}
|
||||
</div>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={`w-12 h-0.5 mx-4 ${index < currentStep ? 'bg-primary' : 'bg-muted'}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Context 值
|
||||
const contextValue: MultiStepFormDialogContextValue = {
|
||||
onCancel: handleClose,
|
||||
onPrevious: handlePrevious,
|
||||
onNext: handleNext,
|
||||
isSubmitting,
|
||||
isValidating,
|
||||
submitButtonText,
|
||||
isFirstStep,
|
||||
isLastStep
|
||||
}
|
||||
|
||||
// 表单内容组件
|
||||
const formContent = (
|
||||
<MultiStepFormDialogContext.Provider value={contextValue}>
|
||||
<div className="space-y-4">
|
||||
<Form {...form}>
|
||||
<form ref={formRef} onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
|
||||
{/* 当前步骤标题和描述 */}
|
||||
<div className="border-b pb-4">
|
||||
<h3 className="text-lg font-medium">{currentStepConfig.title}</h3>
|
||||
{currentStepConfig.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{currentStepConfig.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 当前步骤的字段 */}
|
||||
<div className={cn("p-1", gridClassName)}>
|
||||
{currentStepConfig.fields.map((fieldConfig) => (
|
||||
<FormField
|
||||
key={fieldConfig.name}
|
||||
control={form.control}
|
||||
name={fieldConfig.name}
|
||||
render={({ field }) => (
|
||||
<FormItem className={fieldConfig.className || ''}>
|
||||
<FormLabel className="flex items-center gap-1">
|
||||
{fieldConfig.label}
|
||||
{fieldConfig.required && <span className="text-red-500">*</span>}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
{fieldConfig.render({ field })}
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<MultiStepFormActionBar />
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</MultiStepFormDialogContext.Provider>
|
||||
)
|
||||
|
||||
// 根据设备类型渲染不同的组件
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={isOpen} onOpenChange={handleClose}>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{title}</DrawerTitle>
|
||||
<DrawerDescription>{description}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="px-4 pb-4 overflow-y-auto max-h-[70vh]">
|
||||
{stepIndicator}
|
||||
{formContent}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className={contentClassName}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{stepIndicator}
|
||||
<div className='overflow-y-auto max-h-[70vh]'>
|
||||
{formContent}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
88
src/components/common/number-range-input.tsx
Normal file
88
src/components/common/number-range-input.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// 数值范围类型
|
||||
export interface NumberRange {
|
||||
min?: number
|
||||
max?: number
|
||||
}
|
||||
|
||||
export interface NumberRangeInputProps {
|
||||
value?: NumberRange
|
||||
onChange?: (value: NumberRange) => void
|
||||
placeholder?: {
|
||||
min?: string
|
||||
max?: string
|
||||
}
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
step?: number
|
||||
min?: number
|
||||
max?: number
|
||||
}
|
||||
|
||||
export function NumberRangeInput({
|
||||
value = {},
|
||||
onChange,
|
||||
placeholder = { min: '最小值', max: '最大值' },
|
||||
className,
|
||||
disabled = false,
|
||||
step = 0.01,
|
||||
min,
|
||||
max
|
||||
}: NumberRangeInputProps) {
|
||||
const handleMinChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const inputValue = e.target.value
|
||||
const newMin = inputValue === '' ? undefined : parseFloat(inputValue)
|
||||
|
||||
if (inputValue === '' || !isNaN(newMin!)) {
|
||||
onChange?.({
|
||||
...value,
|
||||
min: newMin
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleMaxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const inputValue = e.target.value
|
||||
const newMax = inputValue === '' ? undefined : parseFloat(inputValue)
|
||||
|
||||
if (inputValue === '' || !isNaN(newMax!)) {
|
||||
onChange?.({
|
||||
...value,
|
||||
max: newMax
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
<Input
|
||||
type="number"
|
||||
step={step}
|
||||
min={min}
|
||||
max={max}
|
||||
value={value.min ?? ''}
|
||||
onChange={handleMinChange}
|
||||
placeholder={placeholder.min}
|
||||
disabled={disabled}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">至</span>
|
||||
<Input
|
||||
type="number"
|
||||
step={step}
|
||||
min={min}
|
||||
max={max}
|
||||
value={value.max ?? ''}
|
||||
onChange={handleMaxChange}
|
||||
placeholder={placeholder.max}
|
||||
disabled={disabled}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
68
src/components/common/preview-card.tsx
Normal file
68
src/components/common/preview-card.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PreviewCardProps {
|
||||
children: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
description: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
delayDuration?: number;
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
align?: "start" | "center" | "end";
|
||||
}
|
||||
|
||||
/**
|
||||
* 鼠标悬停显示预览信息的卡片组件
|
||||
* 桌面端:鼠标悬停展示(进入后delayDuration延迟显示,离开后200ms关闭)
|
||||
* 移动端:@radix-ui/react-hover-card 原生支持触摸交互
|
||||
*/
|
||||
export function PreviewCard({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
disabled = false,
|
||||
className,
|
||||
delayDuration = 300,
|
||||
side = "right",
|
||||
align = "start",
|
||||
}: PreviewCardProps) {
|
||||
// 如果禁用,只渲染children
|
||||
if (disabled) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverCardPrimitive.Root openDelay={delayDuration} closeDelay={200}>
|
||||
<HoverCardPrimitive.Trigger asChild>
|
||||
<div className="inline-block">{children}</div>
|
||||
</HoverCardPrimitive.Trigger>
|
||||
<HoverCardPrimitive.Portal>
|
||||
<HoverCardPrimitive.Content
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={5}
|
||||
className={cn(
|
||||
"z-50 w-80 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none max-w-[90vw]",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
|
||||
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="font-semibold text-sm leading-tight">{title}</div>
|
||||
<div className="text-xs text-muted-foreground leading-relaxed">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardPrimitive.Content>
|
||||
</HoverCardPrimitive.Portal>
|
||||
</HoverCardPrimitive.Root>
|
||||
);
|
||||
}
|
||||
254
src/components/common/responsive-tabs.tsx
Normal file
254
src/components/common/responsive-tabs.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { PreviewCard } from "@/components/common/preview-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
// 标签项类型定义
|
||||
export type ResponsiveTabItem<T extends string = string> = {
|
||||
id: T;
|
||||
name: string;
|
||||
description?: string;
|
||||
count?: number;
|
||||
badge?: ReactNode;
|
||||
};
|
||||
|
||||
// 根组件属性类型
|
||||
type ResponsiveTabsProps<T extends string = string> = {
|
||||
/** 标签项列表 */
|
||||
items: ResponsiveTabItem<T>[];
|
||||
/** 当前激活的标签值 */
|
||||
value: T;
|
||||
/** 标签值变化回调 */
|
||||
onValueChange: (value: T) => void;
|
||||
/** 容器类名 */
|
||||
className?: string;
|
||||
/** 是否在桌面端显示标签ID徽章 */
|
||||
showIdBadge?: boolean;
|
||||
/** 是否在标签上显示计数徽章 */
|
||||
showCountBadge?: boolean;
|
||||
/** 子组件 */
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
// 骨架屏组件属性类型
|
||||
type ResponsiveTabsSkeletonProps = {
|
||||
/** 容器类名 */
|
||||
className?: string;
|
||||
/** 骨架屏内容 */
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* 响应式标签页组件
|
||||
*
|
||||
* 在桌面端(lg及以上)显示垂直布局的标签页,在移动端和平板显示水平布局的标签页。
|
||||
* 桌面端标签支持预览卡片功能,移动端标签支持横向滚动。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ResponsiveTabs items={items} value={value} onValueChange={setValue}>
|
||||
* <ResponsiveTabs.Content value="tab1">
|
||||
* <div>Tab 1 content</div>
|
||||
* </ResponsiveTabs.Content>
|
||||
* <ResponsiveTabs.Content value="tab2">
|
||||
* <div>Tab 2 content</div>
|
||||
* </ResponsiveTabs.Content>
|
||||
* </ResponsiveTabs>
|
||||
* ```
|
||||
*/
|
||||
export function ResponsiveTabs<T extends string = string>({
|
||||
items,
|
||||
value,
|
||||
onValueChange,
|
||||
className = "",
|
||||
showIdBadge = false,
|
||||
showCountBadge = true,
|
||||
children,
|
||||
}: ResponsiveTabsProps<T>) {
|
||||
// 移动端:水平标签页布局
|
||||
const mobileLayout = (
|
||||
<div className={className}>
|
||||
<div className="space-y-4">
|
||||
<Tabs value={value} onValueChange={onValueChange as (value: string) => void}>
|
||||
<div className="overflow-x-auto pb-2 scrollbar-hide">
|
||||
<TabsList variant="default" size="sm" className="inline-flex w-auto min-w-full mt-0">
|
||||
{items.map((item) => (
|
||||
<PreviewCard
|
||||
key={item.id}
|
||||
title={
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span>{item.name}</span>
|
||||
{showIdBadge && (
|
||||
<Badge variant="secondary" size="xs" appearance="light" className="font-mono text-xs">
|
||||
{item.id}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
description={item.description}
|
||||
disabled={!item.description}
|
||||
side="bottom"
|
||||
align="center"
|
||||
>
|
||||
<TabsTrigger
|
||||
value={item.id}
|
||||
className="flex-shrink-0 gap-1.5"
|
||||
>
|
||||
<span className="whitespace-nowrap text-xs">{item.name}</span>
|
||||
{showCountBadge && item.count !== undefined && (
|
||||
<Badge variant="secondary" size="xs" appearance="ghost" className="text-xs">
|
||||
{item.count}
|
||||
</Badge>
|
||||
)}
|
||||
{item.badge}
|
||||
</TabsTrigger>
|
||||
</PreviewCard>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 桌面端:垂直标签页布局
|
||||
const desktopLayout = (
|
||||
<div className={className}>
|
||||
<Tabs value={value} onValueChange={onValueChange as (value: string) => void} orientation="vertical" className="flex gap-5">
|
||||
<TabsList variant="default" className="flex-col items-stretch w-45 2xl:w-52 h-auto shrink-0 self-start bg-muted/30 rounded-lg p-1.5">
|
||||
{items.map((item) => (
|
||||
<PreviewCard
|
||||
key={item.id}
|
||||
title={
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span>{item.name}</span>
|
||||
{showIdBadge && (
|
||||
<Badge variant="secondary" size="xs" appearance="light" className="font-mono text-xs">
|
||||
{item.id}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
description={item.description}
|
||||
>
|
||||
<TabsTrigger
|
||||
value={item.id}
|
||||
className="justify-between w-full px-3 py-2 data-[state=active]:bg-background/90 data-[state=active]:shadow-sm rounded-md"
|
||||
>
|
||||
<span className="font-medium text-sm">{item.name}</span>
|
||||
{showCountBadge && item.count !== undefined && (
|
||||
<Badge variant="secondary" size="sm" appearance="light" className="text-xs">
|
||||
{item.count}
|
||||
</Badge>
|
||||
)}
|
||||
{item.badge}
|
||||
</TabsTrigger>
|
||||
</PreviewCard>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{children}
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 使用 CSS 媒体查询来避免客户端渲染闪烁
|
||||
return (
|
||||
<>
|
||||
<div className="lg:hidden">{mobileLayout}</div>
|
||||
<div className="hidden lg:block">{desktopLayout}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标签内容组件
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ResponsiveTabs.Content value="tab1">
|
||||
* <div>Content for tab 1</div>
|
||||
* </ResponsiveTabs.Content>
|
||||
* ```
|
||||
*/
|
||||
ResponsiveTabs.Content = function ResponsiveTabsContent({
|
||||
value,
|
||||
children,
|
||||
className = "mt-0",
|
||||
}: {
|
||||
value: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<TabsContent value={value} className={className}>
|
||||
{children}
|
||||
</TabsContent>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 响应式标签页骨架屏组件
|
||||
*
|
||||
* 显示加载状态的骨架屏,在移动端和桌面端有不同的布局。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ResponsiveTabs.Skeleton>
|
||||
* <Skeleton className="h-10 w-full" />
|
||||
* <Skeleton className="h-48 w-full" />
|
||||
* </ResponsiveTabs.Skeleton>
|
||||
* ```
|
||||
*/
|
||||
ResponsiveTabs.Skeleton = function ResponsiveTabsSkeleton({
|
||||
className = "",
|
||||
children,
|
||||
}: ResponsiveTabsSkeletonProps) {
|
||||
// 桌面端和移动端共用的内容骨架屏
|
||||
const contentSkeleton = <div className="space-y-4">{children}</div>;
|
||||
|
||||
// 移动端骨架屏
|
||||
const mobileSkeleton = (
|
||||
<div className={className}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-hide">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} className="h-9 w-24 shrink-0 rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
{contentSkeleton}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 桌面端骨架屏
|
||||
const desktopSkeleton = (
|
||||
<div className={className}>
|
||||
<div className="flex gap-5">
|
||||
<div className="flex-col space-y-2 w-45 2xl:w-52 shrink-0">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} className="h-12 w-full rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 space-y-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 使用 CSS 媒体查询来避免客户端渲染闪烁
|
||||
return (
|
||||
<>
|
||||
<div className="lg:hidden">{mobileSkeleton}</div>
|
||||
<div className="hidden lg:block">{desktopSkeleton}</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
61
src/components/common/search-input.tsx
Normal file
61
src/components/common/search-input.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Search, X } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group'
|
||||
|
||||
export interface SearchInputProps {
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
onClear?: () => void
|
||||
}
|
||||
|
||||
export function SearchInput({
|
||||
value = '',
|
||||
onChange,
|
||||
placeholder = '搜索...',
|
||||
className,
|
||||
disabled = false,
|
||||
onClear
|
||||
}: SearchInputProps) {
|
||||
const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange?.(e.target.value)
|
||||
}, [onChange])
|
||||
|
||||
const handleClear = React.useCallback(() => {
|
||||
onChange?.('')
|
||||
onClear?.()
|
||||
}, [onChange, onClear])
|
||||
|
||||
return (
|
||||
<InputGroup className={cn('w-full', className)}>
|
||||
<InputGroupAddon align="inline-start">
|
||||
<Search className="size-4 text-muted-foreground" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{value && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
onClick={handleClear}
|
||||
disabled={disabled}
|
||||
aria-label="清空搜索"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
)
|
||||
}
|
||||
166
src/components/common/stats-card-group.tsx
Normal file
166
src/components/common/stats-card-group.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
type CarouselApi,
|
||||
} from '@/components/ui/carousel'
|
||||
import { LucideIcon } from 'lucide-react'
|
||||
|
||||
export interface StatsCardItem {
|
||||
id: string
|
||||
/** 卡片标题 */
|
||||
title: string
|
||||
/** 卡片描述(可选) */
|
||||
description?: string
|
||||
/** 图标组件(可选) */
|
||||
icon?: LucideIcon
|
||||
/** 自定义内容,如果提供则会替代默认的标题/描述/图标布局 */
|
||||
content?: React.ReactNode
|
||||
}
|
||||
|
||||
export interface StatsCardGroupProps {
|
||||
items: StatsCardItem[]
|
||||
/** 桌面端的网格类名,例如 "md:grid-cols-2 lg:grid-cols-4" */
|
||||
gridClassName?: string
|
||||
/** 卡片容器的额外类名 */
|
||||
className?: string
|
||||
/** 自定义卡片渲染函数 */
|
||||
renderCard?: (item: StatsCardItem) => React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认的统计卡片渲染组件
|
||||
*/
|
||||
function DefaultStatsCard({ item }: { item: StatsCardItem }) {
|
||||
const Icon = item.icon
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{item.title}</CardTitle>
|
||||
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{item.description && (
|
||||
<p className="text-xs text-muted-foreground">{item.description}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 响应式统计卡片组组件
|
||||
* - 移动端:使用轮播,一次显示一张卡片,支持拖动切换
|
||||
* - 桌面端:使用网格布局,由 gridClassName 控制列数
|
||||
* - 所有卡片高度一致
|
||||
*/
|
||||
export function StatsCardGroup({
|
||||
items,
|
||||
gridClassName = 'md:grid-cols-2 lg:grid-cols-4',
|
||||
className,
|
||||
renderCard,
|
||||
}: StatsCardGroupProps) {
|
||||
const [api, setApi] = React.useState<CarouselApi>()
|
||||
const [current, setCurrent] = React.useState(0)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) return
|
||||
|
||||
setCurrent(api.selectedScrollSnap())
|
||||
|
||||
api.on('select', () => {
|
||||
setCurrent(api.selectedScrollSnap())
|
||||
})
|
||||
}, [api])
|
||||
|
||||
if (items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 渲染单个卡片
|
||||
const renderItem = (item: StatsCardItem) => {
|
||||
if (renderCard) {
|
||||
return renderCard(item)
|
||||
}
|
||||
if (item.content) {
|
||||
return item.content
|
||||
}
|
||||
return <DefaultStatsCard item={item} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
{/* 移动端:轮播模式 */}
|
||||
<div className="md:hidden relative">
|
||||
<Carousel
|
||||
setApi={setApi}
|
||||
opts={{
|
||||
align: 'start',
|
||||
loop: false,
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<CarouselContent>
|
||||
{items.map((item) => (
|
||||
<CarouselItem key={item.id}>
|
||||
<div className="h-full">{renderItem(item)}</div>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
|
||||
{/* 轮播指示器 - 悬浮在底部 */}
|
||||
{items.length > 1 && (
|
||||
<div className="absolute bottom-4 left-0 right-0 flex justify-center gap-2 pointer-events-none">
|
||||
<div className="flex gap-2 pointer-events-auto">
|
||||
{items.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={cn(
|
||||
'h-2 rounded-full transition-all',
|
||||
current === index
|
||||
? 'w-8 bg-primary'
|
||||
: 'w-2 bg-muted-foreground/30'
|
||||
)}
|
||||
onClick={() => api?.scrollTo(index)}
|
||||
aria-label={`跳转到第 ${index + 1} 张卡片`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 桌面端:网格布局 */}
|
||||
<div className={cn('hidden md:grid gap-4', gridClassName)}>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="h-full">
|
||||
{renderItem(item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计卡片包装器 - 确保所有卡片高度一致
|
||||
* 当需要自定义卡片内容时使用
|
||||
*/
|
||||
export function StatsCardWrapper({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<Card className={cn('h-full flex flex-col', className)}>{children}</Card>
|
||||
)
|
||||
}
|
||||
322
src/components/common/task-dialog.tsx
Normal file
322
src/components/common/task-dialog.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, ReactNode } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { CheckCircle2, XCircle, Loader2, StopCircle, Minimize2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import type { BaseTaskProgress, TaskState } from '@/server/routers/jobs'
|
||||
|
||||
/**
|
||||
* TaskDialog 组件属性
|
||||
*/
|
||||
export interface TaskDialogProps<T extends BaseTaskProgress = BaseTaskProgress> {
|
||||
/** 对话框是否打开 */
|
||||
open: boolean
|
||||
/** 对话框关闭回调 */
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** 任务 ID */
|
||||
jobId: string | null
|
||||
/** tRPC subscription hook */
|
||||
useSubscription: (
|
||||
input: { jobId: string },
|
||||
opts: {
|
||||
enabled: boolean
|
||||
onData: (data: { data: T }) => void
|
||||
onError: (error: unknown) => void
|
||||
}
|
||||
) => void
|
||||
/** 对话框标题 */
|
||||
title?: string
|
||||
/** 对话框描述 */
|
||||
description?: string
|
||||
/** 取消任务的回调函数 */
|
||||
onCancelTask?: (jobId: string) => void | Promise<void>
|
||||
/** 任务完成的回调函数 */
|
||||
onTaskCompleted?: () => void
|
||||
/** 是否正在取消任务 */
|
||||
isCancelling?: boolean
|
||||
/** 自定义状态消息渲染函数 */
|
||||
renderStatusMessage?: (progress: T) => string
|
||||
/** 自定义详细信息渲染函数 */
|
||||
renderDetails?: (progress: T) => ReactNode
|
||||
/** 任务完成后自动关闭对话框的延迟时间(毫秒),设为 0 则不自动关闭 */
|
||||
autoCloseDelay?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用任务监控对话框组件
|
||||
*
|
||||
* 用于显示后台任务的执行进度,支持:
|
||||
* - 通过 tRPC subscription 实时获取任务进度
|
||||
* - 显示进度条和状态信息
|
||||
* - 取消正在执行的任务
|
||||
* - 自定义进度信息展示
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <TaskDialog
|
||||
* open={isOpen}
|
||||
* onOpenChange={setIsOpen}
|
||||
* jobId={jobId}
|
||||
* useSubscription={trpc.jobs.subscribeSyncAssetsProgress.useSubscription}
|
||||
* title="数据同步"
|
||||
* description="正在同步数据..."
|
||||
* onCancelTask={handleCancel}
|
||||
* onTaskCompleted={handleCompleted}
|
||||
* renderStatusMessage={(progress) => `处理中: ${progress.current}/${progress.total}`}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function TaskDialog<T extends BaseTaskProgress = BaseTaskProgress>({
|
||||
open,
|
||||
onOpenChange,
|
||||
jobId,
|
||||
useSubscription,
|
||||
title = '任务进度',
|
||||
description = '正在执行任务,请稍候...',
|
||||
onCancelTask,
|
||||
onTaskCompleted,
|
||||
isCancelling = false,
|
||||
renderStatusMessage,
|
||||
renderDetails,
|
||||
autoCloseDelay = 2000,
|
||||
}: TaskDialogProps<T>) {
|
||||
const [progress, setProgress] = useState<T | null>(null)
|
||||
const [showCloseConfirm, setShowCloseConfirm] = useState(false)
|
||||
const [showCancelConfirm, setShowCancelConfirm] = useState(false)
|
||||
|
||||
// 使用传入的subscription hook
|
||||
useSubscription(
|
||||
{ jobId: jobId || '' },
|
||||
{
|
||||
enabled: open && !!jobId,
|
||||
onData: ({ data }) => {
|
||||
setProgress(data)
|
||||
|
||||
// 如果任务完成,显示提示并延迟关闭
|
||||
if (data.state === 'completed') {
|
||||
toast.success('任务完成!')
|
||||
if (autoCloseDelay > 0) {
|
||||
setTimeout(() => {
|
||||
onOpenChange(false)
|
||||
onTaskCompleted?.()
|
||||
}, autoCloseDelay)
|
||||
} else {
|
||||
onTaskCompleted?.()
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('订阅错误:', error)
|
||||
toast.error('连接进度服务失败')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 当对话框打开时重置进度
|
||||
useEffect(() => {
|
||||
if (open && jobId) {
|
||||
setProgress(null)
|
||||
}
|
||||
}, [open, jobId])
|
||||
|
||||
// 处理对话框关闭
|
||||
const handleDialogClose = () => {
|
||||
// 如果任务还在运行,提示用户
|
||||
if (progress?.state === 'active' || progress?.state === 'waiting') {
|
||||
setShowCloseConfirm(true)
|
||||
return
|
||||
}
|
||||
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
// 确认关闭对话框
|
||||
const confirmClose = () => {
|
||||
setShowCloseConfirm(false)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
// 处理取消任务
|
||||
const handleCancelTask = () => {
|
||||
if (!progress?.jobId || !onCancelTask) return
|
||||
setShowCancelConfirm(true)
|
||||
}
|
||||
|
||||
// 确认取消任务
|
||||
const confirmCancelTask = async () => {
|
||||
if (!progress?.jobId || !onCancelTask) return
|
||||
setShowCancelConfirm(false)
|
||||
await onCancelTask(progress.jobId)
|
||||
}
|
||||
|
||||
// 渲染进度状态图标
|
||||
const renderStatusIcon = () => {
|
||||
if (!progress) return null
|
||||
|
||||
switch (progress.state) {
|
||||
case 'waiting':
|
||||
case 'active':
|
||||
return <Loader2 className="h-6 w-6 animate-spin text-blue-500" />
|
||||
case 'completed':
|
||||
return <CheckCircle2 className="h-6 w-6 text-green-500" />
|
||||
case 'failed':
|
||||
return <XCircle className="h-6 w-6 text-red-500" />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 获取默认状态消息
|
||||
const getDefaultStatusMessage = () => {
|
||||
if (!progress) return ''
|
||||
|
||||
switch (progress.state) {
|
||||
case 'waiting':
|
||||
return '任务等待中...'
|
||||
case 'active':
|
||||
return '正在处理任务...'
|
||||
case 'completed':
|
||||
return '任务完成!'
|
||||
case 'failed':
|
||||
return progress.error || '任务失败'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// 获取状态消息
|
||||
const getStatusMessage = () => {
|
||||
if (!progress) return ''
|
||||
return renderStatusMessage ? renderStatusMessage(progress) : getDefaultStatusMessage()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleDialogClose}>
|
||||
<DialogContent className="sm:max-w-md" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{renderStatusIcon()}
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 进度条 */}
|
||||
{progress && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">进度</span>
|
||||
<span className="font-medium">{Math.round(progress.progressPercent)}%</span>
|
||||
</div>
|
||||
<Progress value={progress.progressPercent} className="h-2" />
|
||||
</div>
|
||||
|
||||
{/* 状态消息 */}
|
||||
<Alert>
|
||||
<AlertDescription className="break-all">
|
||||
{getStatusMessage()}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* 自定义详细信息 */}
|
||||
{renderDetails && (
|
||||
<div className="overflow-x-auto">
|
||||
{renderDetails(progress)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误信息 */}
|
||||
{progress.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="overflow-x-auto">
|
||||
{progress.error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex gap-2">
|
||||
{progress?.state === 'completed' || progress?.state === 'failed' ? (
|
||||
<Button onClick={() => handleDialogClose()} className="w-full">
|
||||
关闭
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{onCancelTask && (
|
||||
<Button
|
||||
onClick={handleCancelTask}
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
disabled={isCancelling}
|
||||
>
|
||||
<StopCircle className="mr-2 h-4 w-4" />
|
||||
{isCancelling ? '停止中...' : '停止任务'}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => handleDialogClose()} variant="outline" className="flex-1">
|
||||
<Minimize2 className="mr-2 h-4 w-4" />
|
||||
后台运行
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 关闭确认对话框 */}
|
||||
<AlertDialog open={showCloseConfirm} onOpenChange={setShowCloseConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认关闭</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
任务正在进行中,确定要关闭进度窗口吗?任务将继续在后台运行。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmClose}>确定</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 取消任务确认对话框 */}
|
||||
<AlertDialog open={showCancelConfirm} onOpenChange={setShowCancelConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认停止任务</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要停止当前任务吗?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmCancelTask}>确定</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// 导出类型以便使用
|
||||
export type { BaseTaskProgress, TaskState }
|
||||
221
src/components/common/theme-toggle-button.tsx
Normal file
221
src/components/common/theme-toggle-button.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type AnimationVariant =
|
||||
| 'circle'
|
||||
| 'circle-blur'
|
||||
| 'gif'
|
||||
| 'polygon';
|
||||
|
||||
type StartPosition =
|
||||
| 'center'
|
||||
| 'top-left'
|
||||
| 'top-right'
|
||||
| 'bottom-left'
|
||||
| 'bottom-right';
|
||||
|
||||
export interface ThemeToggleButtonProps {
|
||||
theme?: 'light' | 'dark';
|
||||
showLabel?: boolean;
|
||||
variant?: AnimationVariant;
|
||||
start?: StartPosition;
|
||||
url?: string; // For gif variant
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const ThemeToggleButton = ({
|
||||
theme = 'light',
|
||||
showLabel = false,
|
||||
variant = 'circle',
|
||||
start = 'center',
|
||||
url,
|
||||
className,
|
||||
onClick,
|
||||
}: ThemeToggleButtonProps) => {
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
// Inject animation styles for this specific transition
|
||||
const styleId = `theme-transition-${Date.now()}`;
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
|
||||
// Generate animation CSS based on variant
|
||||
let css = '';
|
||||
const positions = {
|
||||
center: 'center',
|
||||
'top-left': 'top left',
|
||||
'top-right': 'top right',
|
||||
'bottom-left': 'bottom left',
|
||||
'bottom-right': 'bottom right',
|
||||
};
|
||||
|
||||
if (variant === 'circle') {
|
||||
const cx = start === 'center' ? '50' : start.includes('left') ? '0' : '100';
|
||||
const cy = start === 'center' ? '50' : start.includes('top') ? '0' : '100';
|
||||
css = `
|
||||
@supports (view-transition-name: root) {
|
||||
::view-transition-old(root) {
|
||||
animation: none;
|
||||
}
|
||||
::view-transition-new(root) {
|
||||
animation: circle-expand 0.4s ease-out;
|
||||
transform-origin: ${positions[start]};
|
||||
}
|
||||
@keyframes circle-expand {
|
||||
from {
|
||||
clip-path: circle(0% at ${cx}% ${cy}%);
|
||||
}
|
||||
to {
|
||||
clip-path: circle(150% at ${cx}% ${cy}%);
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
} else if (variant === 'circle-blur') {
|
||||
const cx = start === 'center' ? '50' : start.includes('left') ? '0' : '100';
|
||||
const cy = start === 'center' ? '50' : start.includes('top') ? '0' : '100';
|
||||
css = `
|
||||
@supports (view-transition-name: root) {
|
||||
::view-transition-old(root) {
|
||||
animation: none;
|
||||
}
|
||||
::view-transition-new(root) {
|
||||
animation: circle-blur-expand 0.5s ease-out;
|
||||
transform-origin: ${positions[start]};
|
||||
filter: blur(0);
|
||||
}
|
||||
@keyframes circle-blur-expand {
|
||||
from {
|
||||
clip-path: circle(0% at ${cx}% ${cy}%);
|
||||
filter: blur(4px);
|
||||
}
|
||||
to {
|
||||
clip-path: circle(150% at ${cx}% ${cy}%);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
} else if (variant === 'gif' && url) {
|
||||
css = `
|
||||
@supports (view-transition-name: root) {
|
||||
::view-transition-old(root) {
|
||||
animation: fade-out 0.4s ease-out;
|
||||
}
|
||||
::view-transition-new(root) {
|
||||
animation: gif-reveal 2.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
mask-image: url('${url}');
|
||||
mask-size: 0%;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
}
|
||||
@keyframes fade-out {
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes gif-reveal {
|
||||
0% {
|
||||
mask-size: 0%;
|
||||
}
|
||||
20% {
|
||||
mask-size: 35%;
|
||||
}
|
||||
60% {
|
||||
mask-size: 35%;
|
||||
}
|
||||
100% {
|
||||
mask-size: 300%;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
} else if (variant === 'polygon') {
|
||||
css = `
|
||||
@supports (view-transition-name: root) {
|
||||
::view-transition-old(root) {
|
||||
animation: none;
|
||||
}
|
||||
::view-transition-new(root) {
|
||||
animation: ${theme === 'light' ? 'wipe-in-dark' : 'wipe-in-light'} 0.4s ease-out;
|
||||
}
|
||||
@keyframes wipe-in-dark {
|
||||
from {
|
||||
clip-path: polygon(0 0, 0 0, 0 100%, 0 100%);
|
||||
}
|
||||
to {
|
||||
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
|
||||
}
|
||||
}
|
||||
@keyframes wipe-in-light {
|
||||
from {
|
||||
clip-path: polygon(100% 0, 100% 0, 100% 100%, 100% 100%);
|
||||
}
|
||||
to {
|
||||
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
if (css) {
|
||||
style.textContent = css;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Clean up animation styles after transition
|
||||
setTimeout(() => {
|
||||
const styleEl = document.getElementById(styleId);
|
||||
if (styleEl) {
|
||||
styleEl.remove();
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Call the onClick handler if provided
|
||||
onClick?.();
|
||||
}, [onClick, variant, start, url, theme]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size={showLabel ? 'default' : 'icon'}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'relative overflow-hidden transition-all',
|
||||
showLabel && 'gap-2',
|
||||
className
|
||||
)}
|
||||
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} theme`}
|
||||
>
|
||||
{theme === 'light' ? (
|
||||
<Sun className="h-[1.2rem] w-[1.2rem]" />
|
||||
) : (
|
||||
<Moon className="h-[1.2rem] w-[1.2rem]" />
|
||||
)}
|
||||
{showLabel && (
|
||||
<span className="text-sm">
|
||||
{theme === 'light' ? 'Light' : 'Dark'}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
// Export a helper hook for using with View Transitions API
|
||||
export const useThemeTransition = () => {
|
||||
const startTransition = useCallback((updateFn: () => void) => {
|
||||
if ('startViewTransition' in document) {
|
||||
(document as any).startViewTransition(updateFn);
|
||||
} else {
|
||||
updateFn();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { startTransition };
|
||||
};
|
||||
93
src/components/common/triple-column-adaptive-drawer.tsx
Normal file
93
src/components/common/triple-column-adaptive-drawer.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client'
|
||||
|
||||
import { ReactNode } from 'react'
|
||||
import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
DrawerTrigger,
|
||||
} from '@/components/ui/drawer'
|
||||
import { CarouselLayout, CarouselColumn } from '@/components/layout/carousel-layout'
|
||||
|
||||
export interface TripleColumnConfig {
|
||||
/** 列的唯一标识 */
|
||||
id: string
|
||||
/** 列的标题 */
|
||||
title: string
|
||||
/** 列的内容 */
|
||||
content: ReactNode
|
||||
}
|
||||
|
||||
export interface TripleColumnAdaptiveDrawerProps {
|
||||
/** 触发器元素 */
|
||||
trigger: ReactNode
|
||||
/** 抽屉标题(用于无障碍访问) */
|
||||
drawerTitle: string
|
||||
/** 抽屉描述(用于无障碍访问) */
|
||||
drawerDescription: string
|
||||
/** 三列配置 */
|
||||
columns: [TripleColumnConfig, TripleColumnConfig, TripleColumnConfig]
|
||||
/** 默认激活的列(移动端) */
|
||||
defaultActiveColumn?: 0 | 1 | 2
|
||||
/** 抽屉高度类名 */
|
||||
heightClassName?: string
|
||||
/** 是否打开(受控) */
|
||||
open: boolean
|
||||
/** 打开状态变化回调 */
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 三栏自适应抽屉组件
|
||||
*
|
||||
* 在桌面端显示三栏并排布局,在移动端通过拖拽左右切换栏目
|
||||
*/
|
||||
export function TripleColumnAdaptiveDrawer({
|
||||
trigger,
|
||||
drawerTitle,
|
||||
drawerDescription,
|
||||
columns,
|
||||
defaultActiveColumn = 1,
|
||||
heightClassName = 'h-[85vh] md:h-[70vh] 2xl:h-[50vh]',
|
||||
open,
|
||||
onOpenChange,
|
||||
}: TripleColumnAdaptiveDrawerProps) {
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (newOpen) {
|
||||
// 使当前拥有焦点的元素(通常是用来触发打开这个drawer的控件)失去焦点,不然控制台会警告焦点在一个要被隐藏于屏幕阅读器的控件上
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}
|
||||
onOpenChange(newOpen)
|
||||
}
|
||||
|
||||
// 转换为 CarouselColumn 格式
|
||||
const carouselColumns: CarouselColumn[] = columns.map((column) => ({
|
||||
id: column.id,
|
||||
title: column.title,
|
||||
content: column.content,
|
||||
desktopClassName: 'flex-1 p-4',
|
||||
mobileClassName: 'p-4',
|
||||
}))
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={handleOpenChange}>
|
||||
<DrawerTrigger asChild>
|
||||
{trigger}
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className={heightClassName}>
|
||||
<VisuallyHidden>
|
||||
<DrawerTitle>{drawerTitle}</DrawerTitle>
|
||||
<DrawerDescription>{drawerDescription}</DrawerDescription>
|
||||
</VisuallyHidden>
|
||||
<CarouselLayout
|
||||
columns={carouselColumns}
|
||||
defaultActiveIndex={defaultActiveColumn}
|
||||
showDesktopDivider={true}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
91
src/components/data-details/detail-badge-list.tsx
Normal file
91
src/components/data-details/detail-badge-list.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Badge, badgeVariants } from '@/components/ui/badge'
|
||||
import { type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface DetailBadgeListProps {
|
||||
items: Array<{ label: string; variant?: VariantProps<typeof badgeVariants>['variant'] }>
|
||||
maxVisible?: number
|
||||
onBadgeClick?: (label: string) => void
|
||||
grouped?: boolean
|
||||
groups?: Record<string, Array<{ label: string; variant?: VariantProps<typeof badgeVariants>['variant'] }>>
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge列表组件
|
||||
* 展示Badge列表,支持分组和折叠
|
||||
*/
|
||||
export function DetailBadgeList({
|
||||
items,
|
||||
maxVisible,
|
||||
onBadgeClick,
|
||||
grouped = false,
|
||||
groups,
|
||||
className,
|
||||
}: DetailBadgeListProps) {
|
||||
if (grouped && groups) {
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{Object.entries(groups).map(([groupName, groupItems]) => (
|
||||
<div key={groupName} className="space-y-2.5">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
{groupName}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{groupItems.map((item, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant={item.variant || 'secondary'}
|
||||
className={cn(
|
||||
'text-xs px-2.5 py-1',
|
||||
onBadgeClick && 'cursor-pointer hover:opacity-80 transition-opacity'
|
||||
)}
|
||||
onClick={() => onBadgeClick?.(item.label)}
|
||||
>
|
||||
{item.label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const visibleItems = maxVisible ? items.slice(0, maxVisible) : items
|
||||
const remainingCount = maxVisible ? items.length - maxVisible : 0
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className={cn('text-sm text-muted-foreground', className)}>
|
||||
无
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-wrap gap-2', className)}>
|
||||
{visibleItems.map((item, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant={item.variant || 'secondary'}
|
||||
className={cn(
|
||||
'text-xs px-2.5 py-1',
|
||||
onBadgeClick && 'cursor-pointer hover:opacity-80 transition-opacity'
|
||||
)}
|
||||
onClick={() => onBadgeClick?.(item.label)}
|
||||
>
|
||||
{item.label}
|
||||
</Badge>
|
||||
))}
|
||||
{remainingCount > 0 && (
|
||||
<Badge variant="outline" className="text-xs px-2.5 py-1">
|
||||
+{remainingCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
184
src/components/data-details/detail-code-block.tsx
Normal file
184
src/components/data-details/detail-code-block.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Copy, Check, Maximize2 } from 'lucide-react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { Highlight, themes } from 'prism-react-renderer'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
export interface DetailCodeBlockProps {
|
||||
code: string
|
||||
language?: string
|
||||
title?: string
|
||||
copyable?: boolean
|
||||
showLineNumbers?: boolean
|
||||
maxHeight?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 代码块组件
|
||||
* 展示代码或JSON数据,支持复制和行号显示功能
|
||||
*/
|
||||
export function DetailCodeBlock({
|
||||
code,
|
||||
language = 'text',
|
||||
title,
|
||||
copyable = true,
|
||||
showLineNumbers = true,
|
||||
maxHeight = '400px',
|
||||
className,
|
||||
}: DetailCodeBlockProps) {
|
||||
const [copied, setCopied] = React.useState(false)
|
||||
const [fullscreenOpen, setFullscreenOpen] = React.useState(false)
|
||||
const { theme } = useTheme()
|
||||
|
||||
const handleCopy = () => {
|
||||
const success = copy(code)
|
||||
if (success) {
|
||||
setCopied(true)
|
||||
toast.success('已复制到剪贴板')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} else {
|
||||
toast.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 根据主题选择合适的代码高亮主题
|
||||
const prismTheme = theme === 'dark' ? themes.vsDark : themes.vsLight
|
||||
|
||||
// 渲染代码高亮内容
|
||||
const renderCodeContent = (isFullscreen = false) => (
|
||||
<Highlight
|
||||
theme={prismTheme}
|
||||
code={code}
|
||||
language={language as any}
|
||||
>
|
||||
{({ className: highlightClassName, style, tokens, getLineProps, getTokenProps }) => {
|
||||
// 提取背景色用于外层容器
|
||||
const backgroundColor = style?.backgroundColor
|
||||
// 计算行号的最大宽度
|
||||
const lineNumberWidth = String(tokens.length).length
|
||||
|
||||
return (
|
||||
<div
|
||||
className="overflow-auto p-4"
|
||||
style={{
|
||||
maxHeight: isFullscreen ? undefined : maxHeight,
|
||||
backgroundColor
|
||||
}}
|
||||
>
|
||||
<pre
|
||||
className={cn(
|
||||
'text-sm leading-relaxed',
|
||||
isFullscreen && 'whitespace-pre',
|
||||
highlightClassName
|
||||
)}
|
||||
style={{ ...style, backgroundColor: 'transparent' }}
|
||||
>
|
||||
{tokens.map((line, i) => (
|
||||
<div key={i} {...getLineProps({ line })} className="table-row">
|
||||
{showLineNumbers && (
|
||||
<span
|
||||
className="table-cell select-none pr-4 text-right opacity-50"
|
||||
style={{
|
||||
width: `${lineNumberWidth + 1}ch`,
|
||||
minWidth: `${lineNumberWidth + 1}ch`,
|
||||
}}
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
)}
|
||||
<span className="table-cell">
|
||||
{line.map((token, key) => (
|
||||
<span key={key} {...getTokenProps({ token })} />
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Highlight>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn('relative rounded-lg border bg-muted/50', className)}>
|
||||
{(title || copyable) && (
|
||||
<div className="flex items-center justify-between gap-3 border-b px-4 py-3 bg-muted/30">
|
||||
{title && (
|
||||
<div className="text-sm font-semibold flex-1 break-words min-w-0 self-center">{title}</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-3"
|
||||
onClick={() => setFullscreenOpen(true)}
|
||||
>
|
||||
<Maximize2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
全屏
|
||||
</Button>
|
||||
{copyable && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-3"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3.5 w-3.5 mr-1.5" />
|
||||
已复制
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3.5 w-3.5 mr-1.5" />
|
||||
复制
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{renderCodeContent()}
|
||||
</div>
|
||||
|
||||
{/* 全屏对话框 */}
|
||||
<Dialog open={fullscreenOpen} onOpenChange={setFullscreenOpen}>
|
||||
<DialogContent className="p-0" variant="fullscreen">
|
||||
<DialogHeader className="pt-5 pb-3 m-0 border-b border-border">
|
||||
<DialogTitle className="px-6 text-base">{title || '代码查看'}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
全屏查看代码
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody className="overflow-auto">
|
||||
{renderCodeContent(true)}
|
||||
</DialogBody>
|
||||
<DialogFooter className="px-6 py-4 border-t border-border">
|
||||
<DialogClose asChild>
|
||||
<Button type="button">关闭</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
93
src/components/data-details/detail-copyable.tsx
Normal file
93
src/components/data-details/detail-copyable.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Copy, Check } from 'lucide-react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export interface DetailCopyableProps {
|
||||
value: string
|
||||
label?: string
|
||||
truncate?: boolean
|
||||
maxLength?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 可复制文本组件
|
||||
* 带复制按钮的文本展示,支持截断和悬停显示完整内容
|
||||
*/
|
||||
export function DetailCopyable({
|
||||
value,
|
||||
label,
|
||||
truncate = false,
|
||||
maxLength = 50,
|
||||
className,
|
||||
}: DetailCopyableProps) {
|
||||
const [copied, setCopied] = React.useState(false)
|
||||
|
||||
const handleCopy = () => {
|
||||
const success = copy(value)
|
||||
if (success) {
|
||||
setCopied(true)
|
||||
toast.success('已复制到剪贴板')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} else {
|
||||
toast.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
const displayValue = truncate && value.length > maxLength
|
||||
? `${value.slice(0, maxLength)}...`
|
||||
: value
|
||||
|
||||
const content = (
|
||||
<div className={cn('flex items-center gap-2 group', className)}>
|
||||
{label && (
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{label}:
|
||||
</span>
|
||||
)}
|
||||
<code className="flex-1 text-sm bg-muted px-2 py-1 rounded font-mono break-all">
|
||||
{displayValue}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 flex-shrink-0"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (truncate && value.length > maxLength) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{content}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-md break-all">
|
||||
<code className="text-xs">{value}</code>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
32
src/components/data-details/detail-field-group.tsx
Normal file
32
src/components/data-details/detail-field-group.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface DetailFieldGroupProps {
|
||||
columns?: 1 | 2 | 3
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 字段组组件
|
||||
* 将多个字段组织在一起,提供网格布局
|
||||
*/
|
||||
export function DetailFieldGroup({
|
||||
columns = 2,
|
||||
children,
|
||||
className,
|
||||
}: DetailFieldGroupProps) {
|
||||
const gridCols = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-1 md:grid-cols-2',
|
||||
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||
}
|
||||
|
||||
return (
|
||||
<dl className={cn('grid gap-6', gridCols[columns], className)}>
|
||||
{children}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
91
src/components/data-details/detail-field.tsx
Normal file
91
src/components/data-details/detail-field.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Copy } from 'lucide-react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export interface DetailFieldProps {
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
direction?: 'horizontal' | 'vertical'
|
||||
copyable?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 字段展示组件
|
||||
* 展示标签-值对,支持水平和垂直布局
|
||||
*/
|
||||
export function DetailField({
|
||||
label,
|
||||
value,
|
||||
direction = 'vertical',
|
||||
copyable = false,
|
||||
className,
|
||||
}: DetailFieldProps) {
|
||||
const handleCopy = () => {
|
||||
if (typeof value === 'string') {
|
||||
const success = copy(value)
|
||||
if (success) {
|
||||
toast.success('已复制到剪贴板')
|
||||
} else {
|
||||
toast.error('复制失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isEmpty = value === null || value === undefined || value === ''
|
||||
|
||||
if (direction === 'horizontal') {
|
||||
return (
|
||||
<div className={cn('flex items-start gap-6 py-2', className)}>
|
||||
<dt className="text-sm font-medium text-muted-foreground min-w-[120px] flex-shrink-0">
|
||||
{label}
|
||||
</dt>
|
||||
<dd className="text-sm flex-1 min-w-0 flex items-start gap-2">
|
||||
{isEmpty ? (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
) : (
|
||||
<span className="break-words leading-relaxed">{value}</span>
|
||||
)}
|
||||
{copyable && !isEmpty && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 flex-shrink-0"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<dt className="text-sm font-medium text-muted-foreground">{label}</dt>
|
||||
<dd className="text-sm flex items-start gap-2">
|
||||
{isEmpty ? (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
) : (
|
||||
<span className="break-words flex-1 leading-relaxed">{value}</span>
|
||||
)}
|
||||
{copyable && !isEmpty && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 flex-shrink-0"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
src/components/data-details/detail-header.tsx
Normal file
47
src/components/data-details/detail-header.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface DetailHeaderProps {
|
||||
title: React.ReactNode
|
||||
subtitle?: React.ReactNode
|
||||
icon?: React.ReactNode
|
||||
actions?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情头部组件
|
||||
* 展示标题、副标题、图标和操作按钮
|
||||
*/
|
||||
export function DetailHeader({
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
actions,
|
||||
className,
|
||||
}: DetailHeaderProps) {
|
||||
return (
|
||||
<div className={cn('flex items-start justify-between gap-6 pb-6 border-b', className)}>
|
||||
<div className="flex items-start gap-4 flex-1 min-w-0">
|
||||
{icon && (
|
||||
<div className="flex-shrink-0 mt-1.5 text-muted-foreground">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0 space-y-3">
|
||||
<h3 className="text-2xl font-bold leading-tight tracking-tight">
|
||||
{title}
|
||||
</h3>
|
||||
{subtitle && (
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-2 flex-wrap">
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="flex-shrink-0">{actions}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
105
src/components/data-details/detail-list.tsx
Normal file
105
src/components/data-details/detail-list.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Search, type LucideIcon } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface DetailListProps {
|
||||
items: Array<{
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
icon?: LucideIcon
|
||||
onClick?: () => void
|
||||
}>
|
||||
searchable?: boolean
|
||||
emptyText?: string
|
||||
maxHeight?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用列表组件
|
||||
* 展示项目列表,支持图标、描述、操作和搜索
|
||||
*/
|
||||
export function DetailList({
|
||||
items,
|
||||
searchable = false,
|
||||
emptyText = '暂无数据',
|
||||
maxHeight = '300px',
|
||||
className,
|
||||
}: DetailListProps) {
|
||||
const [searchQuery, setSearchQuery] = React.useState('')
|
||||
|
||||
const filteredItems = React.useMemo(() => {
|
||||
if (!searchQuery) return items
|
||||
const query = searchQuery.toLowerCase()
|
||||
return items.filter(
|
||||
(item) =>
|
||||
item.label.toLowerCase().includes(query) ||
|
||||
item.description?.toLowerCase().includes(query)
|
||||
)
|
||||
}, [items, searchQuery])
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className={cn('text-sm text-muted-foreground text-center py-4', className)}>
|
||||
{emptyText}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-3', className)}>
|
||||
{searchable && (
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="space-y-1.5 overflow-y-auto rounded-md border bg-muted/30 p-2"
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground text-center py-8">
|
||||
未找到匹配项
|
||||
</div>
|
||||
) : (
|
||||
filteredItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-md p-3 text-sm transition-colors',
|
||||
item.onClick &&
|
||||
'cursor-pointer hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
{Icon && (
|
||||
<Icon className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium break-words leading-relaxed">{item.label}</div>
|
||||
{item.description && (
|
||||
<div className="text-xs text-muted-foreground mt-1 break-words leading-relaxed">
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
90
src/components/data-details/detail-section.tsx
Normal file
90
src/components/data-details/detail-section.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { ChevronDown, type LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface DetailSectionProps {
|
||||
title?: string
|
||||
description?: string
|
||||
icon?: LucideIcon
|
||||
collapsible?: boolean
|
||||
defaultOpen?: boolean
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情分区组件
|
||||
* 支持标题、描述、图标和折叠功能
|
||||
*/
|
||||
export function DetailSection({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
collapsible = false,
|
||||
defaultOpen = true,
|
||||
children,
|
||||
className,
|
||||
onOpenChange,
|
||||
}: DetailSectionProps) {
|
||||
const [isOpen, setIsOpen] = React.useState(defaultOpen)
|
||||
|
||||
const handleOpenChange = React.useCallback((open: boolean) => {
|
||||
setIsOpen(open)
|
||||
onOpenChange?.(open)
|
||||
}, [onOpenChange])
|
||||
|
||||
const header = title && (
|
||||
<div className="flex items-center gap-2.5 mb-4">
|
||||
{Icon && <Icon className="h-5 w-5 text-muted-foreground" />}
|
||||
<h4 className="text-base font-semibold">{title}</h4>
|
||||
</div>
|
||||
)
|
||||
|
||||
const desc = description && (
|
||||
<p className="text-sm text-muted-foreground mb-4">{description}</p>
|
||||
)
|
||||
|
||||
if (collapsible && title) {
|
||||
return (
|
||||
<div className={cn('space-y-4 py-1', className)}>
|
||||
<Collapsible open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<CollapsibleTrigger className="flex items-center justify-between w-full group hover:opacity-80 transition-opacity">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{Icon && <Icon className="h-5 w-5 text-muted-foreground" />}
|
||||
<h4 className="text-base font-semibold">{title}</h4>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-5 w-5 text-muted-foreground transition-transform duration-200',
|
||||
isOpen && 'transform rotate-180'
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
{desc}
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<Separator />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4 py-1', className)}>
|
||||
{header}
|
||||
{desc}
|
||||
{children}
|
||||
<Separator />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
src/components/data-details/detail-sheet.tsx
Normal file
65
src/components/data-details/detail-sheet.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface DetailSheetProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
children: React.ReactNode
|
||||
width?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
|
||||
header: string | React.ReactNode
|
||||
description: string | React.ReactNode
|
||||
}
|
||||
|
||||
const widthClasses = {
|
||||
sm: 'sm:max-w-sm',
|
||||
md: 'sm:max-w-md',
|
||||
lg: 'sm:max-w-lg',
|
||||
xl: 'sm:max-w-xl',
|
||||
full: 'sm:max-w-full',
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情展示Sheet容器
|
||||
* 提供基础的Sheet开关和布局,不包含任何业务逻辑
|
||||
*/
|
||||
export function DetailSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
width = 'lg',
|
||||
header = '详情',
|
||||
description = "",
|
||||
}: DetailSheetProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
className={cn('overflow-y-auto p-6', widthClasses[width])}
|
||||
side="right"
|
||||
>
|
||||
<SheetHeader className="space-y-3">
|
||||
{typeof header === 'string' ? (
|
||||
<SheetTitle className="text-xl">{header}</SheetTitle>
|
||||
) : (
|
||||
header
|
||||
)}
|
||||
{typeof description === 'string' ? (
|
||||
<SheetDescription className="text-base">{description}</SheetDescription>
|
||||
) : (
|
||||
description
|
||||
)}
|
||||
</SheetHeader>
|
||||
{children}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
394
src/components/data-details/detail-timeline.tsx
Normal file
394
src/components/data-details/detail-timeline.tsx
Normal file
@@ -0,0 +1,394 @@
|
||||
|
||||
"use client"
|
||||
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { LucideIcon } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { formatDate } from '@/lib/format'
|
||||
|
||||
// ==================== Context ====================
|
||||
|
||||
/** Timeline Context 值类型 */
|
||||
interface TimelineContextValue {
|
||||
/** 时间轴项总数 */
|
||||
totalItems: number
|
||||
}
|
||||
|
||||
const TimelineContext = React.createContext<TimelineContextValue | undefined>(undefined)
|
||||
|
||||
const useTimelineContext = () => {
|
||||
const context = React.useContext(TimelineContext)
|
||||
if (!context) {
|
||||
throw new Error('Timeline 子组件必须在 Timeline 组件内使用')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
/** TimelineItem Context */
|
||||
interface TimelineItemContextValue {
|
||||
/** 是否是最后一项 */
|
||||
isLast: boolean
|
||||
/** 当前项索引 */
|
||||
index: number
|
||||
}
|
||||
|
||||
const TimelineItemContext = React.createContext<TimelineItemContextValue | undefined>(undefined)
|
||||
|
||||
const useTimelineItemContext = () => {
|
||||
return React.useContext(TimelineItemContext)
|
||||
}
|
||||
|
||||
// ==================== Timeline 根容器 ====================
|
||||
|
||||
export interface TimelineProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间轴根容器组件
|
||||
* 提供时间轴的整体布局和 Context
|
||||
*/
|
||||
export function Timeline({ children, className }: TimelineProps) {
|
||||
// 统计 TimelineItem 子组件数量
|
||||
const items = React.Children.toArray(children).filter(
|
||||
(child) => React.isValidElement(child) && child.type === TimelineItem
|
||||
)
|
||||
|
||||
const contextValue: TimelineContextValue = {
|
||||
totalItems: items.length,
|
||||
}
|
||||
|
||||
return (
|
||||
<TimelineContext.Provider value={contextValue}>
|
||||
<div className={cn('relative space-y-4', className)}>
|
||||
{children}
|
||||
</div>
|
||||
</TimelineContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== TimelineEmpty 空状态 ====================
|
||||
|
||||
export interface TimelineEmptyProps {
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间轴空状态组件
|
||||
*/
|
||||
export function TimelineEmpty({ children = '暂无记录', className }: TimelineEmptyProps) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center py-8 text-sm text-muted-foreground', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== TimelineItem 时间轴项容器 ====================
|
||||
|
||||
export interface TimelineItemProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间轴单项容器
|
||||
*/
|
||||
export function TimelineItem({ children, className }: TimelineItemProps) {
|
||||
const context = useTimelineContext()
|
||||
|
||||
// 动态计算索引
|
||||
const itemRef = React.useRef<HTMLDivElement>(null)
|
||||
const [itemIndex, setItemIndex] = React.useState(0)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (itemRef.current) {
|
||||
const parent = itemRef.current.parentElement
|
||||
if (parent) {
|
||||
const allItems = Array.from(parent.children)
|
||||
const currentIndex = allItems.indexOf(itemRef.current)
|
||||
setItemIndex(currentIndex)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const isLast = itemIndex === context.totalItems - 1
|
||||
|
||||
const itemContextValue: TimelineItemContextValue = {
|
||||
isLast,
|
||||
index: itemIndex,
|
||||
}
|
||||
|
||||
return (
|
||||
<TimelineItemContext.Provider value={itemContextValue}>
|
||||
<div ref={itemRef} className={cn('relative flex gap-4', className)}>
|
||||
{children}
|
||||
</div>
|
||||
</TimelineItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== TimelineConnector 连接线 ====================
|
||||
|
||||
export interface TimelineConnectorProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间轴连接线组件
|
||||
* 自动判断是否为最后一项来决定是否显示
|
||||
*/
|
||||
export function TimelineConnector({ className }: TimelineConnectorProps) {
|
||||
const itemContext = useTimelineItemContext()
|
||||
|
||||
if (!itemContext || itemContext.isLast) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('absolute left-[15px] top-8 h-full w-[2px] bg-border', className)} />
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== TimelineNode 节点(图标) ====================
|
||||
|
||||
export interface TimelineNodeProps {
|
||||
/** 图标组件 */
|
||||
icon?: LucideIcon
|
||||
/** 节点样式 */
|
||||
className?: string
|
||||
/** 图标样式 */
|
||||
iconClassName?: string
|
||||
/** 是否显示默认圆点(当无图标时) */
|
||||
showDot?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间轴节点组件
|
||||
* 可以显示图标或圆点
|
||||
*/
|
||||
export function TimelineNode({
|
||||
icon: Icon,
|
||||
className,
|
||||
iconClassName,
|
||||
showDot = true,
|
||||
}: TimelineNodeProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-2 border-border bg-background',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{Icon ? (
|
||||
<Icon className={cn('h-4 w-4 text-muted-foreground', iconClassName)} />
|
||||
) : showDot ? (
|
||||
<div className="h-2 w-2 rounded-full bg-muted-foreground" />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== TimelineContent 内容区域 ====================
|
||||
|
||||
export interface TimelineContentProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间轴内容区域容器
|
||||
*/
|
||||
export function TimelineContent({ children, className }: TimelineContentProps) {
|
||||
return (
|
||||
<div className={cn('flex-1 space-y-2 pb-4', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== TimelineHeader 头部容器 ====================
|
||||
|
||||
export interface TimelineHeaderProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间轴头部容器(包含标题、徽章、操作等)
|
||||
*/
|
||||
export function TimelineHeader({ children, className }: TimelineHeaderProps) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-between gap-2', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== TimelineTitleArea 标题区域 ====================
|
||||
|
||||
export interface TimelineTitleAreaProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间轴标题区域容器(包含标题和徽章)
|
||||
*/
|
||||
export function TimelineTitleArea({ children, className }: TimelineTitleAreaProps) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2 flex-1 min-w-0', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== TimelineTitle 标题 ====================
|
||||
|
||||
export interface TimelineTitleProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间轴标题组件
|
||||
*/
|
||||
export function TimelineTitle({ children, className }: TimelineTitleProps) {
|
||||
return (
|
||||
<h4 className={cn('text-sm font-medium leading-none', className)}>
|
||||
{children}
|
||||
</h4>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== TimelineBadge 徽章 ====================
|
||||
|
||||
export interface TimelineBadgeProps {
|
||||
children: React.ReactNode
|
||||
variant?: 'default' | 'secondary' | 'destructive' | 'outline'
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间轴徽章组件
|
||||
*/
|
||||
export function TimelineBadge({
|
||||
children,
|
||||
variant = 'default',
|
||||
className
|
||||
}: TimelineBadgeProps) {
|
||||
return (
|
||||
<Badge variant={variant} className={cn('text-xs shrink-0', className)}>
|
||||
{children}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== TimelineActions 操作区域 ====================
|
||||
|
||||
export interface TimelineActionsProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间轴操作区域容器
|
||||
*/
|
||||
export function TimelineActions({ children, className }: TimelineActionsProps) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== TimelineTimestamp 时间戳 ====================
|
||||
|
||||
export interface TimelineTimestampProps {
|
||||
/** 时间戳(Date 对象或字符串) */
|
||||
timestamp: Date | string
|
||||
/** 自定义格式化函数 */
|
||||
format?: (timestamp: Date | string) => string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间轴时间戳组件
|
||||
*/
|
||||
export function TimelineTimestamp({
|
||||
timestamp,
|
||||
format,
|
||||
className
|
||||
}: TimelineTimestampProps) {
|
||||
const displayText = format
|
||||
? format(timestamp)
|
||||
: typeof timestamp === 'string'
|
||||
? timestamp
|
||||
: formatDate(timestamp, 'PPP HH:mm:ss')
|
||||
|
||||
return (
|
||||
<p className={cn('text-xs text-muted-foreground', className)}>
|
||||
{displayText}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== TimelineDescription 描述 ====================
|
||||
|
||||
export interface TimelineDescriptionProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间轴描述组件
|
||||
*/
|
||||
export function TimelineDescription({ children, className }: TimelineDescriptionProps) {
|
||||
return (
|
||||
<p className={cn('text-sm text-muted-foreground', className)}>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== TimelineMetadata 元数据 ====================
|
||||
|
||||
export interface TimelineMetadataItem {
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
className?: string
|
||||
labelClassName?: string
|
||||
valueClassName?: string
|
||||
}
|
||||
|
||||
export interface TimelineMetadataProps {
|
||||
/** 元数据列表 */
|
||||
items: TimelineMetadataItem[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间轴元数据组件
|
||||
*/
|
||||
export function TimelineMetadata({ items, className }: TimelineMetadataProps) {
|
||||
if (!items || items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('mt-2 space-y-1', className)}>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn("flex items-center gap-2 text-xs text-muted-foreground", item.className)}
|
||||
>
|
||||
<span className={cn('font-medium', item.labelClassName)}>{item.label}:</span>
|
||||
<span className={cn('font-mono', item.valueClassName)}>{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
67
src/components/data-details/index.ts
Normal file
67
src/components/data-details/index.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 通用对象详情展示框架
|
||||
*
|
||||
* 基于"小而美"的原子组件设计理念,提供一套可自由组装的详情展示组件库。
|
||||
* 每个组件职责单一,无复杂配置,通过组合而非配置实现灵活性。
|
||||
*/
|
||||
|
||||
export { DetailSheet } from './detail-sheet'
|
||||
export type { DetailSheetProps } from './detail-sheet'
|
||||
|
||||
export { DetailHeader } from './detail-header'
|
||||
export type { DetailHeaderProps } from './detail-header'
|
||||
|
||||
export { DetailSection } from './detail-section'
|
||||
export type { DetailSectionProps } from './detail-section'
|
||||
|
||||
export { DetailField } from './detail-field'
|
||||
export type { DetailFieldProps } from './detail-field'
|
||||
|
||||
export { DetailFieldGroup } from './detail-field-group'
|
||||
export type { DetailFieldGroupProps } from './detail-field-group'
|
||||
|
||||
export { DetailBadgeList } from './detail-badge-list'
|
||||
export type { DetailBadgeListProps } from './detail-badge-list'
|
||||
|
||||
export { DetailList } from './detail-list'
|
||||
export type { DetailListProps } from './detail-list'
|
||||
|
||||
export { DetailCodeBlock } from './detail-code-block'
|
||||
export type { DetailCodeBlockProps } from './detail-code-block'
|
||||
|
||||
export { DetailCopyable } from './detail-copyable'
|
||||
export type { DetailCopyableProps } from './detail-copyable'
|
||||
|
||||
export {
|
||||
Timeline,
|
||||
TimelineEmpty,
|
||||
TimelineItem,
|
||||
TimelineConnector,
|
||||
TimelineNode,
|
||||
TimelineContent,
|
||||
TimelineHeader,
|
||||
TimelineTitleArea,
|
||||
TimelineTitle,
|
||||
TimelineBadge,
|
||||
TimelineActions,
|
||||
TimelineTimestamp,
|
||||
TimelineDescription,
|
||||
TimelineMetadata,
|
||||
} from './detail-timeline'
|
||||
export type {
|
||||
TimelineProps,
|
||||
TimelineEmptyProps,
|
||||
TimelineItemProps,
|
||||
TimelineConnectorProps,
|
||||
TimelineNodeProps,
|
||||
TimelineContentProps,
|
||||
TimelineHeaderProps,
|
||||
TimelineTitleAreaProps,
|
||||
TimelineTitleProps,
|
||||
TimelineBadgeProps,
|
||||
TimelineActionsProps,
|
||||
TimelineTimestampProps,
|
||||
TimelineDescriptionProps,
|
||||
TimelineMetadataProps,
|
||||
TimelineMetadataItem,
|
||||
} from './detail-timeline'
|
||||
178
src/components/data-table/action-bar.tsx
Normal file
178
src/components/data-table/action-bar.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import type { Table } from "@tanstack/react-table";
|
||||
import { Loader, X } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import * as React from "react";
|
||||
import * as ReactDOM from "react-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DataTableActionBarProps<TData>
|
||||
extends React.ComponentProps<typeof motion.div> {
|
||||
table: Table<TData>;
|
||||
visible?: boolean;
|
||||
container?: Element | DocumentFragment | null;
|
||||
}
|
||||
|
||||
function DataTableActionBar<TData>({
|
||||
table,
|
||||
visible: visibleProp,
|
||||
container: containerProp,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: DataTableActionBarProps<TData>) {
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
table.toggleAllRowsSelected(false);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [table]);
|
||||
|
||||
const container =
|
||||
containerProp ?? (mounted ? globalThis.document?.body : null);
|
||||
|
||||
if (!container) return null;
|
||||
|
||||
const visible =
|
||||
visibleProp ?? table.getFilteredSelectedRowModel().rows.length > 0;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.div
|
||||
role="toolbar"
|
||||
aria-orientation="horizontal"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-6 z-50 mx-auto flex w-fit flex-wrap items-center justify-center gap-2 rounded-md border bg-background p-2 text-foreground shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
container,
|
||||
);
|
||||
}
|
||||
|
||||
interface DataTableActionBarActionProps
|
||||
extends React.ComponentProps<typeof Button> {
|
||||
tooltip?: string;
|
||||
isPending?: boolean;
|
||||
}
|
||||
|
||||
function DataTableActionBarAction({
|
||||
size = "sm",
|
||||
tooltip,
|
||||
isPending,
|
||||
disabled,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: DataTableActionBarActionProps) {
|
||||
const trigger = (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size={size}
|
||||
className={cn(
|
||||
"gap-1.5 border border-secondary bg-secondary/50 hover:bg-secondary/70 [&>svg]:size-3.5",
|
||||
size === "icon" ? "size-7" : "h-7",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled || isPending}
|
||||
{...props}
|
||||
>
|
||||
{isPending ? <Loader className="animate-spin" /> : children}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!tooltip) return trigger;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
sideOffset={6}
|
||||
className="border bg-accent font-semibold text-foreground dark:bg-zinc-900 [&>span]:hidden"
|
||||
>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
interface DataTableActionBarSelectionProps<TData> {
|
||||
table: Table<TData>;
|
||||
}
|
||||
|
||||
function DataTableActionBarSelection<TData>({
|
||||
table,
|
||||
}: DataTableActionBarSelectionProps<TData>) {
|
||||
const onClearSelection = React.useCallback(() => {
|
||||
table.toggleAllRowsSelected(false);
|
||||
}, [table]);
|
||||
|
||||
return (
|
||||
<div className="flex h-7 items-center rounded-md border pr-1 pl-2.5">
|
||||
<span className="whitespace-nowrap text-xs">
|
||||
{table.getFilteredSelectedRowModel().rows.length} selected
|
||||
</span>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mr-1 ml-2 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-5"
|
||||
onClick={onClearSelection}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
sideOffset={10}
|
||||
className="flex items-center gap-2 border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900 [&>span]:hidden"
|
||||
>
|
||||
<p>Clear selection</p>
|
||||
<kbd className="select-none rounded border bg-background px-1.5 py-px font-mono font-normal text-[0.7rem] text-foreground shadow-xs">
|
||||
<abbr title="Escape" className="no-underline">
|
||||
Esc
|
||||
</abbr>
|
||||
</kbd>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DataTableActionBar,
|
||||
DataTableActionBarAction,
|
||||
DataTableActionBarSelection,
|
||||
};
|
||||
99
src/components/data-table/column-header.tsx
Normal file
99
src/components/data-table/column-header.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import type { Column } from "@tanstack/react-table";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
ChevronUp,
|
||||
EyeOff,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DataTableColumnHeaderProps<TData, TValue>
|
||||
extends React.ComponentProps<typeof DropdownMenuTrigger> {
|
||||
column: Column<TData, TValue>;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function DataTableColumnHeader<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
...props
|
||||
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||
if (!column.getCanSort() && !column.getCanHide()) {
|
||||
return <div className={cn(className)}>{title}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
"-ml-1.5 flex h-8 items-center gap-1.5 rounded-md px-2 py-1.5 hover:bg-accent focus:outline-none focus:ring-1 focus:ring-ring data-[state=open]:bg-accent [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{title}
|
||||
{column.getCanSort() &&
|
||||
(column.getIsSorted() === "desc" ? (
|
||||
<ChevronDown />
|
||||
) : column.getIsSorted() === "asc" ? (
|
||||
<ChevronUp />
|
||||
) : (
|
||||
<ChevronsUpDown />
|
||||
))}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-28">
|
||||
{column.getCanSort() && (
|
||||
<>
|
||||
<DropdownMenuCheckboxItem
|
||||
className="relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground"
|
||||
checked={column.getIsSorted() === "asc"}
|
||||
onClick={() => column.toggleSorting(false)}
|
||||
>
|
||||
<ChevronUp />
|
||||
升序
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
className="relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground"
|
||||
checked={column.getIsSorted() === "desc"}
|
||||
onClick={() => column.toggleSorting(true)}
|
||||
>
|
||||
<ChevronDown />
|
||||
降序
|
||||
</DropdownMenuCheckboxItem>
|
||||
{column.getIsSorted() && (
|
||||
<DropdownMenuItem
|
||||
className="pl-2 [&_svg]:text-muted-foreground"
|
||||
onClick={() => column.clearSorting()}
|
||||
>
|
||||
<X />
|
||||
重置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{column.getCanHide() && (
|
||||
<DropdownMenuCheckboxItem
|
||||
className="relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground"
|
||||
checked={!column.getIsVisible()}
|
||||
onClick={() => column.toggleVisibility(false)}
|
||||
>
|
||||
<EyeOff />
|
||||
隐藏
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
143
src/components/data-table/data-table.tsx
Normal file
143
src/components/data-table/data-table.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { flexRender, type Table as TanstackTable, Column } from "@tanstack/react-table";
|
||||
import type * as React from "react";
|
||||
|
||||
import { DataTablePagination } from "@/components/data-table/pagination";
|
||||
import { DataTableSkeleton } from "@/components/data-table/table-skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DataTableProps<TData> extends React.ComponentProps<"div"> {
|
||||
table: TanstackTable<TData>;
|
||||
actionBar?: React.ReactNode;
|
||||
className?: string,
|
||||
tableClassName?: string,
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function getGridStyles<TData>({
|
||||
column,
|
||||
header
|
||||
}: {
|
||||
column: Column<TData>;
|
||||
header: boolean
|
||||
}): { className: string; style: React.CSSProperties } {
|
||||
const isPinned = column.getIsPinned();
|
||||
|
||||
return {
|
||||
className: cn(
|
||||
isPinned ? "sticky" : "relative",
|
||||
isPinned && "z-[10]",
|
||||
// 被固定的列头添加背景色和更深的颜色
|
||||
...(header ? [
|
||||
isPinned ? "bg-muted" : "bg-muted",
|
||||
] : [
|
||||
isPinned && "bg-background"
|
||||
]),
|
||||
),
|
||||
style: {
|
||||
left: isPinned === "left" ? `${column.getStart("left")}px` : undefined,
|
||||
right: isPinned === "right" ? `${column.getAfter("right")}px` : undefined,
|
||||
width: column.getSize(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function DataTable<TData>({
|
||||
table,
|
||||
actionBar,
|
||||
children,
|
||||
className,
|
||||
tableClassName,
|
||||
isLoading = false,
|
||||
...props
|
||||
}: DataTableProps<TData>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex w-full flex-col gap-2.5 overflow-auto", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{
|
||||
!isLoading ?
|
||||
<>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<Table className={tableClassName}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
{...getGridStyles({ column: header.column, header: true })}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</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}
|
||||
{...getGridStyles({ column: cell.column, header: false })}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={table.getAllColumns().length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<DataTablePagination table={table} />
|
||||
{actionBar &&
|
||||
table.getFilteredSelectedRowModel().rows.length > 0 &&
|
||||
actionBar}
|
||||
</div>
|
||||
</> :
|
||||
<DataTableSkeleton
|
||||
columnCount={table.getVisibleFlatColumns().length}
|
||||
rowCount={table.getState().pagination.pageSize}
|
||||
withViewOptions={false}
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
245
src/components/data-table/filters/date-filter.tsx
Normal file
245
src/components/data-table/filters/date-filter.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
|
||||
import type { Column } from "@tanstack/react-table";
|
||||
import { CalendarIcon, XCircle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import type { DateRange } from "react-day-picker";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { formatDate } from "@/lib/format";
|
||||
|
||||
type DateSelection = Date[] | DateRange;
|
||||
|
||||
function getIsDateRange(value: DateSelection): value is DateRange {
|
||||
return value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseAsDate(value: Date | string | undefined): Date | undefined {
|
||||
if (!value) return undefined;
|
||||
if (value instanceof Date) return value;
|
||||
const date = new Date(value);
|
||||
return !Number.isNaN(date.getTime()) ? date : undefined;
|
||||
}
|
||||
|
||||
function parseColumnFilterValue(value: unknown): (Date | undefined)[] {
|
||||
if (value === null || value === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => {
|
||||
if (item instanceof Date) {
|
||||
return item;
|
||||
}
|
||||
if (typeof item === "string") {
|
||||
return parseAsDate(item);
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return [value];
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const date = parseAsDate(value);
|
||||
return date ? [date] : [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
interface DataTableDateFilterProps<TData> {
|
||||
column: Column<TData, unknown>;
|
||||
title?: string;
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
export function DataTableDateFilter<TData>({
|
||||
column,
|
||||
title,
|
||||
multiple,
|
||||
}: DataTableDateFilterProps<TData>) {
|
||||
const columnFilterValue = column.getFilterValue();
|
||||
|
||||
const selectedDates = React.useMemo<DateSelection>(() => {
|
||||
if (!columnFilterValue) {
|
||||
return multiple ? { from: undefined, to: undefined } : [];
|
||||
}
|
||||
|
||||
if (multiple) {
|
||||
const timestamps = parseColumnFilterValue(columnFilterValue);
|
||||
return {
|
||||
from: parseAsDate(timestamps[0]),
|
||||
to: parseAsDate(timestamps[1]),
|
||||
};
|
||||
}
|
||||
|
||||
const timestamps = parseColumnFilterValue(columnFilterValue);
|
||||
const date = parseAsDate(timestamps[0]);
|
||||
return date ? [date] : [];
|
||||
}, [columnFilterValue, multiple]);
|
||||
|
||||
const onSelect = React.useCallback(
|
||||
(date: Date | DateRange | undefined) => {
|
||||
if (!date) {
|
||||
column.setFilterValue(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (multiple && !("getTime" in date)) {
|
||||
const from = date.from;
|
||||
const to = date.to;
|
||||
column.setFilterValue(from || to ? [from, to] : undefined);
|
||||
} else if (!multiple && "getTime" in date) {
|
||||
column.setFilterValue(date);
|
||||
}
|
||||
},
|
||||
[column, multiple],
|
||||
);
|
||||
|
||||
const onReset = React.useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
column.setFilterValue(undefined);
|
||||
},
|
||||
[column],
|
||||
);
|
||||
|
||||
const hasValue = React.useMemo(() => {
|
||||
if (multiple) {
|
||||
if (!getIsDateRange(selectedDates)) return false;
|
||||
return selectedDates.from || selectedDates.to;
|
||||
}
|
||||
if (!Array.isArray(selectedDates)) return false;
|
||||
return selectedDates.length > 0;
|
||||
}, [multiple, selectedDates]);
|
||||
|
||||
const formatDateRange = React.useCallback((range: DateRange) => {
|
||||
if (!range.from && !range.to) return "";
|
||||
if (range.from && range.to) {
|
||||
return `${formatDate(range.from)} - ${formatDate(range.to)}`;
|
||||
}
|
||||
return formatDate(range.from ?? range.to);
|
||||
}, []);
|
||||
|
||||
const label = React.useMemo(() => {
|
||||
if (multiple) {
|
||||
if (!getIsDateRange(selectedDates)) return null;
|
||||
|
||||
const hasSelectedDates = selectedDates.from || selectedDates.to;
|
||||
const dateText = hasSelectedDates
|
||||
? formatDateRange(selectedDates)
|
||||
: "选择日期范围";
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{title}</span>
|
||||
{hasSelectedDates && (
|
||||
<>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mx-0.5 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<span>{dateText}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (getIsDateRange(selectedDates)) return null;
|
||||
|
||||
const hasSelectedDate = selectedDates.length > 0;
|
||||
const dateText = hasSelectedDate
|
||||
? formatDate(selectedDates[0])
|
||||
: "选择日期";
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{title}</span>
|
||||
{hasSelectedDate && (
|
||||
<>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mx-0.5 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<span>{dateText}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}, [selectedDates, multiple, formatDateRange, title]);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="border-dashed">
|
||||
{hasValue ? (
|
||||
<div
|
||||
role="button"
|
||||
aria-label={`Clear ${title} filter`}
|
||||
tabIndex={0}
|
||||
onClick={onReset}
|
||||
className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<XCircle />
|
||||
</div>
|
||||
) : (
|
||||
<CalendarIcon />
|
||||
)}
|
||||
{label}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<div className="flex flex-col">
|
||||
{multiple ? (
|
||||
<Calendar
|
||||
captionLayout="dropdown"
|
||||
mode="range"
|
||||
selected={
|
||||
getIsDateRange(selectedDates)
|
||||
? selectedDates
|
||||
: { from: undefined, to: undefined }
|
||||
}
|
||||
onSelect={onSelect}
|
||||
locale={zhCN}
|
||||
/>
|
||||
) : (
|
||||
<Calendar
|
||||
captionLayout="dropdown"
|
||||
mode="single"
|
||||
selected={
|
||||
!getIsDateRange(selectedDates) ? selectedDates[0] : undefined
|
||||
}
|
||||
onSelect={onSelect}
|
||||
locale={zhCN}
|
||||
/>
|
||||
)}
|
||||
{hasValue && (
|
||||
<div className="p-3">
|
||||
<Button
|
||||
aria-label={`Clear ${title} filter`}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onReset}
|
||||
className="border-t w-full"
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
249
src/components/data-table/filters/faceted-filter.tsx
Normal file
249
src/components/data-table/filters/faceted-filter.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import type { Column } from "@tanstack/react-table";
|
||||
import { Check, PlusCircle, XCircle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Option } from "@/types/data-table";
|
||||
|
||||
interface DataTableFacetedFilterProps<TData, TValue> {
|
||||
column?: Column<TData, TValue>;
|
||||
title?: string;
|
||||
options: Option[];
|
||||
multiple?: boolean;
|
||||
hideSearch?: boolean;
|
||||
}
|
||||
|
||||
export function DataTableFacetedFilter<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
options,
|
||||
multiple,
|
||||
hideSearch = false,
|
||||
}: DataTableFacetedFilterProps<TData, TValue>) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [searchValue, setSearchValue] = React.useState("");
|
||||
const [displayCount, setDisplayCount] = React.useState(20); // 初始显示20条
|
||||
|
||||
const columnFilterValue = column?.getFilterValue();
|
||||
const selectedValues = React.useMemo(() => new Set(
|
||||
Array.isArray(columnFilterValue) ? columnFilterValue : [],
|
||||
), [columnFilterValue]);
|
||||
|
||||
// 筛选选项(基于搜索)
|
||||
const filteredOptions = React.useMemo(() => {
|
||||
if (!searchValue) return options;
|
||||
return options.filter(option =>
|
||||
option.name.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
}, [options, searchValue]);
|
||||
|
||||
// 当前显示的选项(限制数量)
|
||||
const displayedOptions = React.useMemo(() => {
|
||||
return filteredOptions.slice(0, displayCount);
|
||||
}, [filteredOptions, displayCount]);
|
||||
|
||||
// 是否还有更多选项可以加载
|
||||
const hasMore = displayedOptions.length < filteredOptions.length;
|
||||
|
||||
// 处理滚动事件,检测是否需要加载更多
|
||||
const handleScroll = React.useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const target = e.currentTarget;
|
||||
const { scrollTop, scrollHeight, clientHeight } = target;
|
||||
|
||||
// 当滚动到底部附近时加载更多(距离底部50px时触发)
|
||||
if (scrollHeight - scrollTop - clientHeight < 50 && hasMore) {
|
||||
setDisplayCount(prev => Math.min(prev + 20, filteredOptions.length));
|
||||
}
|
||||
}, [hasMore, filteredOptions.length]);
|
||||
|
||||
// 重置搜索时重置显示数量
|
||||
React.useEffect(() => {
|
||||
setDisplayCount(20);
|
||||
}, [searchValue]);
|
||||
|
||||
const onItemSelect = React.useCallback(
|
||||
(option: Option, isSelected: boolean) => {
|
||||
if (!column) return;
|
||||
|
||||
if (multiple) {
|
||||
const newSelectedValues = new Set(selectedValues);
|
||||
if (isSelected) {
|
||||
newSelectedValues.delete(option.id);
|
||||
} else {
|
||||
newSelectedValues.add(option.id);
|
||||
}
|
||||
const filterValues = Array.from(newSelectedValues);
|
||||
column.setFilterValue(filterValues.length ? filterValues : undefined);
|
||||
} else {
|
||||
column.setFilterValue(isSelected ? undefined : [option.id]);
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[column, multiple, selectedValues],
|
||||
);
|
||||
|
||||
const onReset = React.useCallback(
|
||||
(event?: React.MouseEvent) => {
|
||||
event?.stopPropagation();
|
||||
column?.setFilterValue(undefined);
|
||||
},
|
||||
[column],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="border-dashed">
|
||||
{selectedValues?.size > 0 ? (
|
||||
<div
|
||||
role="button"
|
||||
aria-label={`Clear ${title} filter`}
|
||||
tabIndex={0}
|
||||
onClick={onReset}
|
||||
className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<XCircle />
|
||||
</div>
|
||||
) : (
|
||||
<PlusCircle />
|
||||
)}
|
||||
{title}
|
||||
{selectedValues?.size > 0 && (
|
||||
<>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mx-0.5 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-sm px-1 font-normal lg:hidden"
|
||||
>
|
||||
{selectedValues.size}
|
||||
</Badge>
|
||||
<div className="hidden items-center gap-1 lg:flex">
|
||||
{selectedValues.size > 2 ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-sm px-1 font-normal"
|
||||
>
|
||||
{selectedValues.size}项
|
||||
</Badge>
|
||||
) : (
|
||||
options
|
||||
.filter((option) => selectedValues.has(option.id))
|
||||
.map((option) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
key={option.id}
|
||||
className="rounded-sm px-1 font-normal"
|
||||
>
|
||||
{option.name}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="min-w-[12.5rem] max-w-[30rem] w-fit p-0" align="start">
|
||||
<Command shouldFilter={false}>
|
||||
{!hideSearch && (
|
||||
<CommandInput
|
||||
placeholder={"搜索" + title + "..."}
|
||||
value={searchValue}
|
||||
onValueChange={setSearchValue}
|
||||
/>
|
||||
)}
|
||||
<CommandList className="max-h-full">
|
||||
<CommandEmpty>未找到相关选项</CommandEmpty>
|
||||
<CommandGroup
|
||||
className="max-h-[18.75rem] overflow-y-auto overflow-x-hidden"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{displayedOptions.map((option) => {
|
||||
const isSelected = selectedValues.has(option.id);
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.id}
|
||||
onSelect={() => onItemSelect(option, isSelected)}
|
||||
>
|
||||
{
|
||||
multiple ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-4 items-center justify-center rounded-sm border border-primary",
|
||||
isSelected
|
||||
? "bg-primary"
|
||||
: "opacity-50 [&_svg]:invisible",
|
||||
)}
|
||||
>
|
||||
<Check className="text-white" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-4 items-center justify-center",
|
||||
isSelected ? "" : "opacity-50 [&_svg]:invisible",
|
||||
)}
|
||||
>
|
||||
<Check />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{option.icon && <option.icon />}
|
||||
<span className="truncate">{option.name}</span>
|
||||
{option.count != null && (
|
||||
<span className="ml-auto font-mono text-xs">
|
||||
{option.count}
|
||||
</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
{hasMore && (
|
||||
<div className="px-2 py-1 text-xs text-muted-foreground text-center">
|
||||
继续滚动查看更多选项...
|
||||
</div>
|
||||
)}
|
||||
</CommandGroup>
|
||||
{selectedValues.size > 0 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={() => onReset()}
|
||||
className="justify-center text-center"
|
||||
>
|
||||
清除
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
122
src/components/data-table/filters/range-filter.tsx
Normal file
122
src/components/data-table/filters/range-filter.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import type { Column } from "@tanstack/react-table";
|
||||
import * as React from "react";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ExtendedColumnFilter } from "@/types/data-table";
|
||||
|
||||
interface DataTableRangeFilterProps<TData> extends React.ComponentProps<"div"> {
|
||||
filter: ExtendedColumnFilter<TData>;
|
||||
column: Column<TData>;
|
||||
inputId: string;
|
||||
onFilterUpdate: (
|
||||
filterId: string,
|
||||
updates: Partial<Omit<ExtendedColumnFilter<TData>, "filterId">>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function DataTableRangeFilter<TData>({
|
||||
filter,
|
||||
column,
|
||||
inputId,
|
||||
onFilterUpdate,
|
||||
className,
|
||||
...props
|
||||
}: DataTableRangeFilterProps<TData>) {
|
||||
const meta = column.columnDef.meta;
|
||||
|
||||
const [min, max] = React.useMemo(() => {
|
||||
const range = column.columnDef.meta?.filter?.range;
|
||||
if (range) return range;
|
||||
|
||||
const values = column.getFacetedMinMaxValues();
|
||||
if (!values) return [0, 100];
|
||||
|
||||
return [values[0], values[1]];
|
||||
}, [column]);
|
||||
|
||||
const formatValue = React.useCallback(
|
||||
(value: string | number | undefined) => {
|
||||
if (value === undefined || value === "") return "";
|
||||
const numValue = Number(value);
|
||||
return Number.isNaN(numValue)
|
||||
? ""
|
||||
: numValue.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const value = React.useMemo(() => {
|
||||
if (Array.isArray(filter.value)) return filter.value.map(formatValue);
|
||||
return [formatValue(filter.value), ""];
|
||||
}, [filter.value, formatValue]);
|
||||
|
||||
const onRangeValueChange = React.useCallback(
|
||||
(value: string, isMin?: boolean) => {
|
||||
const numValue = Number(value);
|
||||
const currentValues = Array.isArray(filter.value)
|
||||
? filter.value
|
||||
: ["", ""];
|
||||
const otherValue = isMin
|
||||
? (currentValues[1] ?? "")
|
||||
: (currentValues[0] ?? "");
|
||||
|
||||
if (
|
||||
value === "" ||
|
||||
(!Number.isNaN(numValue) &&
|
||||
(isMin
|
||||
? numValue >= min && numValue <= (Number(otherValue) || max)
|
||||
: numValue <= max && numValue >= (Number(otherValue) || min)))
|
||||
) {
|
||||
onFilterUpdate(filter.filterId, {
|
||||
value: isMin ? [value, otherValue] : [otherValue, value],
|
||||
});
|
||||
}
|
||||
},
|
||||
[filter.filterId, filter.value, min, max, onFilterUpdate],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="range"
|
||||
className={cn("flex w-full items-center gap-2", className)}
|
||||
{...props}
|
||||
>
|
||||
<Input
|
||||
id={`${inputId}-min`}
|
||||
type="number"
|
||||
aria-label={`${meta?.label} minimum value`}
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
data-slot="range-min"
|
||||
inputMode="numeric"
|
||||
placeholder={min.toString()}
|
||||
min={min}
|
||||
max={max}
|
||||
className="h-8 w-full rounded"
|
||||
defaultValue={value[0]}
|
||||
onChange={(event) => onRangeValueChange(event.target.value, true)}
|
||||
/>
|
||||
<span className="sr-only shrink-0 text-muted-foreground">to</span>
|
||||
<Input
|
||||
id={`${inputId}-max`}
|
||||
type="number"
|
||||
aria-label={`${meta?.label} maximum value`}
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
data-slot="range-max"
|
||||
inputMode="numeric"
|
||||
placeholder={max.toString()}
|
||||
min={min}
|
||||
max={max}
|
||||
className="h-8 w-full rounded"
|
||||
defaultValue={value[1]}
|
||||
onChange={(event) => onRangeValueChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
251
src/components/data-table/filters/slider-filter.tsx
Normal file
251
src/components/data-table/filters/slider-filter.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
"use client";
|
||||
|
||||
import type { Column } from "@tanstack/react-table";
|
||||
import { PlusCircle, XCircle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Range {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
type RangeValue = [number, number];
|
||||
|
||||
function getIsValidRange(value: unknown): value is RangeValue {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length === 2 &&
|
||||
typeof value[0] === "number" &&
|
||||
typeof value[1] === "number"
|
||||
);
|
||||
}
|
||||
|
||||
interface DataTableSliderFilterProps<TData> {
|
||||
column: Column<TData, unknown>;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function DataTableSliderFilter<TData>({
|
||||
column,
|
||||
title,
|
||||
}: DataTableSliderFilterProps<TData>) {
|
||||
const id = React.useId();
|
||||
|
||||
const columnFilterValue = getIsValidRange(column.getFilterValue())
|
||||
? (column.getFilterValue() as RangeValue)
|
||||
: undefined;
|
||||
|
||||
const defaultRange = column.columnDef.meta?.filter?.range;
|
||||
const unit = column.columnDef.meta?.filter?.unit;
|
||||
|
||||
const { min, max, step } = React.useMemo<Range & { step: number }>(() => {
|
||||
let minValue = 0;
|
||||
let maxValue = 100;
|
||||
|
||||
if (defaultRange && getIsValidRange(defaultRange)) {
|
||||
[minValue, maxValue] = defaultRange;
|
||||
} else {
|
||||
const values = column.getFacetedMinMaxValues();
|
||||
if (values && Array.isArray(values) && values.length === 2) {
|
||||
const [facetMinValue, facetMaxValue] = values;
|
||||
if (
|
||||
typeof facetMinValue === "number" &&
|
||||
typeof facetMaxValue === "number"
|
||||
) {
|
||||
minValue = facetMinValue;
|
||||
maxValue = facetMaxValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rangeSize = maxValue - minValue;
|
||||
const step =
|
||||
rangeSize <= 20
|
||||
? 1
|
||||
: rangeSize <= 100
|
||||
? Math.ceil(rangeSize / 20)
|
||||
: Math.ceil(rangeSize / 50);
|
||||
|
||||
return { min: minValue, max: maxValue, step };
|
||||
}, [column, defaultRange]);
|
||||
|
||||
const range = React.useMemo((): RangeValue => {
|
||||
return columnFilterValue ?? [min, max];
|
||||
}, [columnFilterValue, min, max]);
|
||||
|
||||
const formatValue = React.useCallback((value: number) => {
|
||||
return value.toLocaleString(undefined, { maximumFractionDigits: 0 });
|
||||
}, []);
|
||||
|
||||
const onFromInputChange = React.useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const numValue = Number(event.target.value);
|
||||
if (!Number.isNaN(numValue)) {
|
||||
column.setFilterValue([numValue, range[1]]);
|
||||
}
|
||||
},
|
||||
[column, range],
|
||||
);
|
||||
|
||||
const onFromInputBlur = React.useCallback(
|
||||
(event: React.FocusEvent<HTMLInputElement>) => {
|
||||
const numValue = Number(event.target.value);
|
||||
if (!Number.isNaN(numValue) && numValue > range[1]) {
|
||||
column.setFilterValue([range[1], range[1]]);
|
||||
}
|
||||
},
|
||||
[column, range],
|
||||
);
|
||||
|
||||
const onToInputChange = React.useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const numValue = Number(event.target.value);
|
||||
if (!Number.isNaN(numValue)) {
|
||||
column.setFilterValue([range[0], numValue]);
|
||||
}
|
||||
},
|
||||
[column, range],
|
||||
);
|
||||
|
||||
const onToInputBlur = React.useCallback(
|
||||
(event: React.FocusEvent<HTMLInputElement>) => {
|
||||
const numValue = Number(event.target.value);
|
||||
if (!Number.isNaN(numValue) && numValue < range[0]) {
|
||||
column.setFilterValue([range[0], range[0]]);
|
||||
}
|
||||
},
|
||||
[column, range],
|
||||
);
|
||||
|
||||
const onSliderValueChange = React.useCallback(
|
||||
(value: RangeValue) => {
|
||||
if (Array.isArray(value) && value.length === 2) {
|
||||
column.setFilterValue(value);
|
||||
}
|
||||
},
|
||||
[column],
|
||||
);
|
||||
|
||||
const onReset = React.useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
if (event.target instanceof HTMLDivElement) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
column.setFilterValue(undefined);
|
||||
},
|
||||
[column],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="border-dashed">
|
||||
{columnFilterValue ? (
|
||||
<div
|
||||
role="button"
|
||||
aria-label={`Clear ${title} filter`}
|
||||
tabIndex={0}
|
||||
className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
onClick={onReset}
|
||||
>
|
||||
<XCircle />
|
||||
</div>
|
||||
) : (
|
||||
<PlusCircle />
|
||||
)}
|
||||
<span>{title}</span>
|
||||
{columnFilterValue ? (
|
||||
<>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mx-0.5 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
{formatValue(columnFilterValue[0])} -{" "}
|
||||
{formatValue(columnFilterValue[1])}
|
||||
{unit ? ` ${unit}` : ""}
|
||||
</>
|
||||
) : null}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="flex w-auto flex-col gap-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
{title}
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<Label htmlFor={`${id}-from`} className="sr-only">
|
||||
From
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={`${id}-from`}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder={min.toString()}
|
||||
value={range[0]?.toString()}
|
||||
onChange={onFromInputChange}
|
||||
onBlur={onFromInputBlur}
|
||||
className={cn("h-8 w-24", unit && "pr-8")}
|
||||
/>
|
||||
{unit && (
|
||||
<span className="absolute top-0 right-0 bottom-0 flex items-center rounded-r-md bg-accent px-2 text-muted-foreground text-sm">
|
||||
{unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Label htmlFor={`${id}-to`} className="sr-only">
|
||||
to
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={`${id}-to`}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder={max.toString()}
|
||||
value={range[1]?.toString()}
|
||||
onChange={onToInputChange}
|
||||
onBlur={onToInputBlur}
|
||||
className={cn("h-8 w-24", unit && "pr-8")}
|
||||
/>
|
||||
{unit && (
|
||||
<span className="absolute top-0 right-0 bottom-0 flex items-center rounded-r-md bg-accent px-2 text-muted-foreground text-sm">
|
||||
{unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Label htmlFor={`${id}-slider`} className="sr-only">
|
||||
{title} slider
|
||||
</Label>
|
||||
<Slider
|
||||
id={`${id}-slider`}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={range}
|
||||
onValueChange={onSliderValueChange}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
aria-label={`Clear ${title} filter`}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onReset}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
128
src/components/data-table/pagination.tsx
Normal file
128
src/components/data-table/pagination.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { Table } from "@tanstack/react-table";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DataTablePaginationProps<TData> extends React.ComponentProps<"div"> {
|
||||
table: Table<TData>;
|
||||
pageSizeOptions?: number[];
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
table,
|
||||
pageSizeOptions = [5, 10, 20, 30, 40, 50, 100],
|
||||
className,
|
||||
...props
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const selectedRowCount = Object.keys(table.getState().rowSelection).length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-col-reverse items-center justify-between gap-4 overflow-auto p-1 sm:flex-row sm:gap-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex flex-1 items-center gap-2 whitespace-nowrap text-muted-foreground text-sm">
|
||||
<span>共 {table.getRowCount()} 行</span>
|
||||
{selectedRowCount > 0 && (
|
||||
<>
|
||||
<span>已选中 {selectedRowCount} 行</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-5"
|
||||
onClick={() => table.resetRowSelection()}
|
||||
aria-label="取消选中"
|
||||
title="取消选中"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col-reverse items-center gap-4 sm:flex-row sm:gap-6 lg:gap-8">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="whitespace-nowrap font-medium text-sm">每页行数</p>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[4.6rem] [&[data-size]]:h-8">
|
||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{pageSizeOptions.map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
aria-label="Go to first page"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="hidden size-8 lg:flex"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronsLeft />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Go to previous page"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<div className="flex items-center justify-center font-medium text-sm">
|
||||
{table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Go to next page"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Go to last page"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="hidden size-8 lg:flex"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronsRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
408
src/components/data-table/sort-list.tsx
Normal file
408
src/components/data-table/sort-list.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
"use client";
|
||||
|
||||
import type { ColumnSort, SortDirection, Table } from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowDownUp,
|
||||
ChevronsUpDown,
|
||||
GripVertical,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Sortable,
|
||||
SortableContent,
|
||||
SortableItem,
|
||||
SortableItemHandle,
|
||||
SortableOverlay,
|
||||
} from "@/components/ui/sortable";
|
||||
import { dataTableConfig } from "@/constants/data-table";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const OPEN_MENU_SHORTCUT = "s";
|
||||
const REMOVE_SORT_SHORTCUTS = ["backspace", "delete"];
|
||||
|
||||
interface DataTableSortListProps<TData>
|
||||
extends React.ComponentProps<typeof PopoverContent> {
|
||||
table: Table<TData>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个排序构建组件,可以自由添加多个自定义排序规则,支持拖拽调整顺序
|
||||
*/
|
||||
export function DataTableSortList<TData>({
|
||||
table,
|
||||
...props
|
||||
}: DataTableSortListProps<TData>) {
|
||||
const id = React.useId();
|
||||
const labelId = React.useId();
|
||||
const descriptionId = React.useId();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const addButtonRef = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
const sorting = table.getState().sorting;
|
||||
const onSortingChange = table.setSorting;
|
||||
|
||||
const { columnLabels, columns } = React.useMemo(() => {
|
||||
const labels = new Map<string, string>();
|
||||
const sortingIds = new Set(sorting.map((s) => s.id));
|
||||
const availableColumns: { id: string; label: string }[] = [];
|
||||
|
||||
for (const column of table.getAllColumns()) {
|
||||
if (!column.getCanSort()) continue;
|
||||
|
||||
const label = column.columnDef.meta?.label ?? column.id;
|
||||
labels.set(column.id, label);
|
||||
|
||||
if (!sortingIds.has(column.id)) {
|
||||
availableColumns.push({ id: column.id, label });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
columnLabels: labels,
|
||||
columns: availableColumns,
|
||||
};
|
||||
}, [sorting, table]);
|
||||
|
||||
const onSortAdd = React.useCallback(() => {
|
||||
const firstColumn = columns[0];
|
||||
if (!firstColumn) return;
|
||||
|
||||
onSortingChange((prevSorting) => [
|
||||
...prevSorting,
|
||||
{ id: firstColumn.id, desc: false },
|
||||
]);
|
||||
}, [columns, onSortingChange]);
|
||||
|
||||
const onSortUpdate = React.useCallback(
|
||||
(sortId: string, updates: Partial<ColumnSort>) => {
|
||||
onSortingChange((prevSorting) => {
|
||||
if (!prevSorting) return prevSorting;
|
||||
return prevSorting.map((sort) =>
|
||||
sort.id === sortId ? { ...sort, ...updates } : sort,
|
||||
);
|
||||
});
|
||||
},
|
||||
[onSortingChange],
|
||||
);
|
||||
|
||||
const onSortRemove = React.useCallback(
|
||||
(sortId: string) => {
|
||||
onSortingChange((prevSorting) =>
|
||||
prevSorting.filter((item) => item.id !== sortId),
|
||||
);
|
||||
},
|
||||
[onSortingChange],
|
||||
);
|
||||
|
||||
const onSortingReset = React.useCallback(
|
||||
() => onSortingChange(table.initialState.sorting),
|
||||
[onSortingChange, table.initialState.sorting],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (
|
||||
event.target instanceof HTMLInputElement ||
|
||||
event.target instanceof HTMLTextAreaElement
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.key.toLowerCase() === OPEN_MENU_SHORTCUT &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.shiftKey
|
||||
) {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
if (
|
||||
event.key.toLowerCase() === OPEN_MENU_SHORTCUT &&
|
||||
event.shiftKey &&
|
||||
sorting.length > 0
|
||||
) {
|
||||
event.preventDefault();
|
||||
onSortingReset();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [sorting.length, onSortingReset]);
|
||||
|
||||
const onTriggerKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (
|
||||
REMOVE_SORT_SHORTCUTS.includes(event.key.toLowerCase()) &&
|
||||
sorting.length > 0
|
||||
) {
|
||||
event.preventDefault();
|
||||
onSortingReset();
|
||||
}
|
||||
},
|
||||
[sorting.length, onSortingReset],
|
||||
);
|
||||
|
||||
return (
|
||||
<Sortable
|
||||
value={sorting}
|
||||
onValueChange={onSortingChange}
|
||||
getItemValue={(item) => item.id}
|
||||
>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" onKeyDown={onTriggerKeyDown}>
|
||||
<ArrowDownUp />
|
||||
排序
|
||||
{sorting.length > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-[18.24px] rounded-[3.2px] px-[5.12px] font-mono font-normal text-[10.4px]"
|
||||
>
|
||||
{sorting.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
aria-labelledby={labelId}
|
||||
aria-describedby={descriptionId}
|
||||
className="flex w-full max-w-[var(--radix-popover-content-available-width)] origin-[var(--radix-popover-content-transform-origin)] flex-col gap-3.5 p-4 sm:min-w-[380px]"
|
||||
{...props}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 id={labelId} className="font-medium leading-none">
|
||||
{sorting.length > 0 ? "排序规则" : "未指定排序规则"}
|
||||
</h4>
|
||||
<p
|
||||
id={descriptionId}
|
||||
className={cn(
|
||||
"text-muted-foreground text-sm"
|
||||
)}
|
||||
>
|
||||
{sorting.length > 0
|
||||
? "存在多个排序规则时,以先后顺序为优先级进行排序"
|
||||
: "对数据进行排序以便查看"}
|
||||
</p>
|
||||
</div>
|
||||
{sorting.length > 0 && (
|
||||
<SortableContent asChild>
|
||||
<ul className="flex max-h-[300px] flex-col gap-2 overflow-y-auto p-1">
|
||||
{sorting.map((sort) => (
|
||||
<DataTableSortItem
|
||||
key={sort.id}
|
||||
sort={sort}
|
||||
sortItemId={`${id}-sort-${sort.id}`}
|
||||
columns={columns}
|
||||
columnLabels={columnLabels}
|
||||
onSortUpdate={onSortUpdate}
|
||||
onSortRemove={onSortRemove}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</SortableContent>
|
||||
)}
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Button
|
||||
className="rounded"
|
||||
ref={addButtonRef}
|
||||
onClick={onSortAdd}
|
||||
disabled={columns.length === 0}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
{sorting.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded"
|
||||
onClick={onSortingReset}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<SortableOverlay>
|
||||
{({ value }) => {
|
||||
const sort = sorting.find((s) => s.id === value);
|
||||
if (!sort) return null;
|
||||
|
||||
return (
|
||||
<DataTableSortItem
|
||||
sort={sort}
|
||||
sortItemId={`${id}-sort-${sort.id}`}
|
||||
columns={columns}
|
||||
columnLabels={columnLabels}
|
||||
onSortUpdate={onSortUpdate}
|
||||
onSortRemove={onSortRemove}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</SortableOverlay>
|
||||
</Sortable>
|
||||
);
|
||||
}
|
||||
|
||||
interface DataTableSortItemProps {
|
||||
sort: ColumnSort;
|
||||
sortItemId: string;
|
||||
columns: { id: string; label: string }[];
|
||||
columnLabels: Map<string, string>;
|
||||
onSortUpdate: (sortId: string, updates: Partial<ColumnSort>) => void;
|
||||
onSortRemove: (sortId: string) => void;
|
||||
}
|
||||
|
||||
function DataTableSortItem({
|
||||
sort,
|
||||
sortItemId,
|
||||
columns,
|
||||
columnLabels,
|
||||
onSortUpdate,
|
||||
onSortRemove,
|
||||
}: DataTableSortItemProps) {
|
||||
const fieldListboxId = `${sortItemId}-field-listbox`;
|
||||
const fieldTriggerId = `${sortItemId}-field-trigger`;
|
||||
const directionListboxId = `${sortItemId}-direction-listbox`;
|
||||
|
||||
const [showFieldSelector, setShowFieldSelector] = React.useState(false);
|
||||
const [showDirectionSelector, setShowDirectionSelector] =
|
||||
React.useState(false);
|
||||
|
||||
const onItemKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLLIElement>) => {
|
||||
if (
|
||||
event.target instanceof HTMLInputElement ||
|
||||
event.target instanceof HTMLTextAreaElement
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (showFieldSelector || showDirectionSelector) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (REMOVE_SORT_SHORTCUTS.includes(event.key.toLowerCase())) {
|
||||
event.preventDefault();
|
||||
onSortRemove(sort.id);
|
||||
}
|
||||
},
|
||||
[sort.id, showFieldSelector, showDirectionSelector, onSortRemove],
|
||||
);
|
||||
|
||||
return (
|
||||
<SortableItem value={sort.id} asChild>
|
||||
<li
|
||||
id={sortItemId}
|
||||
tabIndex={-1}
|
||||
className="flex items-center gap-2 data-[dragging]:invisible"
|
||||
onKeyDown={onItemKeyDown}
|
||||
>
|
||||
<Popover open={showFieldSelector} onOpenChange={setShowFieldSelector}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={fieldTriggerId}
|
||||
aria-controls={fieldListboxId}
|
||||
variant="outline"
|
||||
className="w-44 justify-between rounded font-normal"
|
||||
>
|
||||
<span className="truncate">{columnLabels.get(sort.id)}</span>
|
||||
<ChevronsUpDown className="opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
id={fieldListboxId}
|
||||
className="w-[var(--radix-popover-trigger-width)] origin-[var(--radix-popover-content-transform-origin)] p-0"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="搜索列..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>未找到指定列</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{columns.map((column) => (
|
||||
<CommandItem
|
||||
key={column.id}
|
||||
value={column.id}
|
||||
onSelect={(value) => onSortUpdate(sort.id, { id: value })}
|
||||
>
|
||||
<span className="truncate">{column.label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Select
|
||||
open={showDirectionSelector}
|
||||
onOpenChange={setShowDirectionSelector}
|
||||
value={sort.desc ? "desc" : "asc"}
|
||||
onValueChange={(value: SortDirection) =>
|
||||
onSortUpdate(sort.id, { desc: value === "desc" })
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-controls={directionListboxId}
|
||||
className="h-8 w-24 rounded [&[data-size]]:h-8"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
id={directionListboxId}
|
||||
className="min-w-[var(--radix-select-trigger-width)] origin-[var(--radix-select-content-transform-origin)]"
|
||||
>
|
||||
{dataTableConfig.sortOrders.map((order) => (
|
||||
<SelectItem key={order.value} value={order.value}>
|
||||
{order.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
aria-controls={sortItemId}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-8 shrink-0 rounded"
|
||||
onClick={() => onSortRemove(sort.id)}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
<SortableItemHandle asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-8 shrink-0 rounded"
|
||||
>
|
||||
<GripVertical />
|
||||
</Button>
|
||||
</SortableItemHandle>
|
||||
</li>
|
||||
</SortableItem>
|
||||
);
|
||||
}
|
||||
118
src/components/data-table/table-skeleton.tsx
Normal file
118
src/components/data-table/table-skeleton.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DataTableSkeletonProps extends React.ComponentProps<"div"> {
|
||||
columnCount: number;
|
||||
rowCount?: number;
|
||||
filterCount?: number;
|
||||
cellWidths?: string[];
|
||||
withViewOptions?: boolean;
|
||||
withPagination?: boolean;
|
||||
shrinkZero?: boolean;
|
||||
}
|
||||
|
||||
export function DataTableSkeleton({
|
||||
columnCount,
|
||||
rowCount = 10,
|
||||
filterCount = 0,
|
||||
cellWidths = ["auto"],
|
||||
withViewOptions = true,
|
||||
withPagination = true,
|
||||
shrinkZero = false,
|
||||
className,
|
||||
...props
|
||||
}: DataTableSkeletonProps) {
|
||||
const cozyCellWidths = Array.from(
|
||||
{ length: columnCount },
|
||||
(_, index) => cellWidths[index % cellWidths.length] ?? "auto",
|
||||
);
|
||||
const toolbarDisplay = withViewOptions || filterCount > 0
|
||||
return (
|
||||
<div
|
||||
className={cn("flex w-full flex-col gap-2.5 overflow-auto", className)}
|
||||
{...props}
|
||||
>
|
||||
{ toolbarDisplay ??
|
||||
<div className="flex w-full items-center justify-between gap-2 overflow-auto p-1">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
{filterCount > 0
|
||||
? Array.from({ length: filterCount }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-7 w-[4.5rem] border-dashed" />
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
{withViewOptions ? (
|
||||
<Skeleton className="ml-auto hidden h-7 w-[4.5rem] lg:flex" />
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{Array.from({ length: 1 }).map((_, i) => (
|
||||
<TableRow key={i} className="hover:bg-transparent">
|
||||
{Array.from({ length: columnCount }).map((_, j) => (
|
||||
<TableHead
|
||||
key={j}
|
||||
style={{
|
||||
width: cozyCellWidths[j],
|
||||
minWidth: shrinkZero ? cozyCellWidths[j] : "auto",
|
||||
}}
|
||||
>
|
||||
<Skeleton className="h-6" style={{ width: cozyCellWidths[j] === "auto" ? "100px" : cozyCellWidths[j] }} />
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: rowCount }).map((_, i) => (
|
||||
<TableRow key={i} className="hover:bg-transparent">
|
||||
{Array.from({ length: columnCount }).map((_, j) => (
|
||||
<TableCell
|
||||
key={j}
|
||||
style={{
|
||||
width: cozyCellWidths[j],
|
||||
minWidth: shrinkZero ? cozyCellWidths[j] : "auto",
|
||||
}}
|
||||
>
|
||||
<Skeleton className="h-6" style={{ width: cozyCellWidths[j] === "auto" ? "100px" : cozyCellWidths[j] }} />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{withPagination ? (
|
||||
<div className="flex w-full items-center justify-between gap-4 overflow-auto p-1 sm:gap-8 overflow-hidden">
|
||||
<Skeleton className="h-7 w-40 shrink-0" />
|
||||
<div className="flex items-center gap-4 sm:gap-6 lg:gap-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-7 w-24" />
|
||||
<Skeleton className="h-7 w-[4.5rem]" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center font-medium text-sm">
|
||||
<Skeleton className="h-7 w-20" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="hidden size-7 lg:block" />
|
||||
<Skeleton className="size-7" />
|
||||
<Skeleton className="size-7" />
|
||||
<Skeleton className="hidden size-7 lg:block" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
src/components/data-table/toolbar.tsx
Normal file
165
src/components/data-table/toolbar.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import type { Column, Table } from "@tanstack/react-table";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { DataTableDateFilter } from "@/components/data-table/filters/date-filter";
|
||||
import { DataTableFacetedFilter } from "@/components/data-table/filters/faceted-filter";
|
||||
import { DataTableSliderFilter } from "@/components/data-table/filters/slider-filter";
|
||||
import { DataTableViewOptions } from "@/components/data-table/view-options";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DataTableToolbarProps<TData> extends React.ComponentProps<"div"> {
|
||||
table: Table<TData>;
|
||||
}
|
||||
|
||||
export function DataTableToolbar<TData>({
|
||||
table,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: DataTableToolbarProps<TData>) {
|
||||
const isFiltered = table.getState().columnFilters.length > 0;
|
||||
|
||||
const tableAllColumns = table.getAllColumns()
|
||||
|
||||
const columns = React.useMemo(
|
||||
() => tableAllColumns.filter((column) => column.getCanFilter()),
|
||||
[tableAllColumns],
|
||||
);
|
||||
|
||||
const onReset = React.useCallback(() => {
|
||||
table.resetColumnFilters();
|
||||
}, [table]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-orientation="horizontal"
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-start gap-2 p-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex min-w-[300px] flex-1 flex-wrap items-center gap-2">
|
||||
{columns.map((column) => (
|
||||
<DataTableToolbarFilter key={column.id} column={column} />
|
||||
))}
|
||||
{isFiltered && (
|
||||
<Button
|
||||
aria-label="Reset filters"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-dashed"
|
||||
onClick={onReset}
|
||||
>
|
||||
<X />
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||||
{children}
|
||||
<DataTableViewOptions table={table} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
interface DataTableToolbarFilterProps<TData> {
|
||||
column: Column<TData>;
|
||||
}
|
||||
|
||||
function DataTableToolbarFilter<TData>({
|
||||
column,
|
||||
}: DataTableToolbarFilterProps<TData>) {
|
||||
{
|
||||
const columnMeta = column.columnDef.meta;
|
||||
|
||||
const onFilterRender = React.useCallback(() => {
|
||||
if (!columnMeta?.filter?.variant) return null;
|
||||
|
||||
switch (columnMeta.filter?.variant) {
|
||||
case "text":
|
||||
return (
|
||||
<Input
|
||||
placeholder={columnMeta.filter?.placeholder ?? columnMeta.label}
|
||||
value={(column.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) => column.setFilterValue(event.target.value)}
|
||||
className="h-8 w-40 lg:w-56"
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder={columnMeta.filter?.placeholder ?? columnMeta.label}
|
||||
value={(column.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) => column.setFilterValue(event.target.value)}
|
||||
className={cn("h-8 w-[120px]", columnMeta.filter?.unit && "pr-8")}
|
||||
/>
|
||||
{columnMeta.filter?.unit && (
|
||||
<span className="absolute top-0 right-0 bottom-0 flex items-center rounded-r-md bg-accent px-2 text-muted-foreground text-sm">
|
||||
{columnMeta.filter?.unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "range":
|
||||
return (
|
||||
<DataTableSliderFilter
|
||||
column={column}
|
||||
title={columnMeta.label ?? column.id}
|
||||
/>
|
||||
);
|
||||
|
||||
case "date":
|
||||
case "dateRange":
|
||||
return (
|
||||
<DataTableDateFilter
|
||||
column={column}
|
||||
title={columnMeta.label ?? column.id}
|
||||
multiple={columnMeta.filter?.variant === "dateRange"}
|
||||
/>
|
||||
);
|
||||
|
||||
case "select":
|
||||
case "multiSelect":
|
||||
return (
|
||||
<DataTableFacetedFilter
|
||||
column={column}
|
||||
title={columnMeta.label ?? column.id}
|
||||
options={columnMeta.filter?.options ?? []}
|
||||
multiple={columnMeta.filter?.variant === "multiSelect"}
|
||||
/>
|
||||
);
|
||||
|
||||
case "boolean":
|
||||
return (
|
||||
<DataTableFacetedFilter
|
||||
column={column}
|
||||
title={columnMeta.label ?? column.id}
|
||||
options={[
|
||||
{ id: "true", name: "是" },
|
||||
{ id: "false", name: "否" },
|
||||
]}
|
||||
multiple={false}
|
||||
hideSearch={true}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [column, columnMeta]);
|
||||
|
||||
return onFilterRender();
|
||||
}
|
||||
}
|
||||
84
src/components/data-table/view-options.tsx
Normal file
84
src/components/data-table/view-options.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import type { Table } from "@tanstack/react-table";
|
||||
import { Check, ChevronsUpDown, Settings2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DataTableViewOptionsProps<TData> {
|
||||
table: Table<TData>;
|
||||
}
|
||||
|
||||
export function DataTableViewOptions<TData>({
|
||||
table,
|
||||
}: DataTableViewOptionsProps<TData>) {
|
||||
const columns = React.useMemo(
|
||||
() =>
|
||||
table
|
||||
.getAllColumns()
|
||||
.filter(
|
||||
(column) =>
|
||||
typeof column.accessorFn !== "undefined" && column.getCanHide(),
|
||||
),
|
||||
[table],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
aria-label="Toggle columns"
|
||||
role="combobox"
|
||||
variant="outline"
|
||||
className="h-8 shrink-0"
|
||||
>
|
||||
<Settings2 />
|
||||
显示列
|
||||
<ChevronsUpDown className="ml-auto opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-44 p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="搜索列..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>未找到指定列</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{columns.map((column) => (
|
||||
<CommandItem
|
||||
key={column.id}
|
||||
onSelect={() =>
|
||||
column.toggleVisibility(!column.getIsVisible())
|
||||
}
|
||||
>
|
||||
<span className="truncate">
|
||||
{column.columnDef.meta?.label ?? column.id}
|
||||
</span>
|
||||
<Check
|
||||
className={cn(
|
||||
"ml-auto size-4 shrink-0",
|
||||
column.getIsVisible() ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
499
src/components/features/adaptive-graph.tsx
Normal file
499
src/components/features/adaptive-graph.tsx
Normal file
@@ -0,0 +1,499 @@
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useMemo, useState, useRef } from 'react'
|
||||
import {
|
||||
ReactFlow,
|
||||
Node,
|
||||
Edge,
|
||||
Controls,
|
||||
Background,
|
||||
MiniMap,
|
||||
Panel,
|
||||
useReactFlow,
|
||||
ReactFlowProvider,
|
||||
NodeTypes,
|
||||
MarkerType,
|
||||
ReactFlowProps,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
} from '@xyflow/react'
|
||||
import '@xyflow/react/dist/style.css'
|
||||
import { Search, Maximize2 } from 'lucide-react'
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from '@/components/ui/input-group'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import dagre from 'dagre'
|
||||
import { useDebouncedCallback } from '@/hooks/use-debounced-callback'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// 布局配置类型
|
||||
export interface LayoutConfig {
|
||||
direction?: 'TB' | 'LR'
|
||||
ranksep?: number
|
||||
nodesep?: number
|
||||
nodeWidth?: number
|
||||
nodeHeight?: number
|
||||
}
|
||||
|
||||
// 自动布局函数
|
||||
const getLayoutedElements = (
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
config: LayoutConfig = {}
|
||||
) => {
|
||||
const {
|
||||
direction = 'TB',
|
||||
ranksep = 100,
|
||||
nodesep = 80,
|
||||
nodeWidth = 200,
|
||||
nodeHeight = 80,
|
||||
} = config
|
||||
|
||||
const dagreGraph = new dagre.graphlib.Graph()
|
||||
dagreGraph.setDefaultEdgeLabel(() => ({}))
|
||||
dagreGraph.setGraph({ rankdir: direction, ranksep, nodesep })
|
||||
|
||||
nodes.forEach((node) => {
|
||||
dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight })
|
||||
})
|
||||
|
||||
edges.forEach((edge) => {
|
||||
dagreGraph.setEdge(edge.source, edge.target)
|
||||
})
|
||||
|
||||
dagre.layout(dagreGraph)
|
||||
|
||||
const layoutedNodes = nodes.map((node) => {
|
||||
const nodeWithPosition = dagreGraph.node(node.id)
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: nodeWithPosition.x - nodeWidth / 2,
|
||||
y: nodeWithPosition.y - nodeHeight / 2,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return { nodes: layoutedNodes, edges }
|
||||
}
|
||||
|
||||
// 通用图组件 Props
|
||||
export interface AdaptiveGraphProps<TNodeData = any> {
|
||||
// 原始节点数据
|
||||
nodes: TNodeData[]
|
||||
// 原始边数据
|
||||
edges: Array<{ source: string; target: string; label?: string }>
|
||||
// 节点类型定义
|
||||
nodeTypes: NodeTypes
|
||||
// 过滤函数:根据搜索查询返回过滤后的节点 ID 集合和高亮节点 ID 集合(可选)
|
||||
onFilter?: (nodes: TNodeData[], query: string) => {
|
||||
filteredNodeIds: Set<string>
|
||||
highlightedNodeIds: Set<string>
|
||||
}
|
||||
// 节点转换函数:将原始节点数据转换为 ReactFlow 节点
|
||||
transformNode: (
|
||||
node: TNodeData,
|
||||
options: {
|
||||
isHighlighted: boolean
|
||||
isDimmed: boolean
|
||||
}
|
||||
) => Node
|
||||
// 边转换函数:将原始边数据转换为 ReactFlow 边(可选)
|
||||
transformEdge?: (
|
||||
edge: { source: string; target: string; label?: string },
|
||||
index: number,
|
||||
options: {
|
||||
isHighlighted: boolean
|
||||
}
|
||||
) => Edge
|
||||
// MiniMap 节点颜色函数(可选)
|
||||
getNodeColor?: (node: Node) => string
|
||||
// 节点点击回调
|
||||
onNodeClick?: (node: TNodeData) => void
|
||||
// 搜索占位符文本
|
||||
searchPlaceholder?: string
|
||||
// 统计信息渲染函数(可选)
|
||||
renderStats?: (filteredCount: number, totalCount: number, matchedCount: number) => React.ReactNode
|
||||
// 布局配置
|
||||
layoutConfig?: LayoutConfig
|
||||
// 容器样式类名(用于控制高度等样式)
|
||||
className?: string
|
||||
// ReactFlow 额外属性(可选)
|
||||
reactFlowProps?: Partial<ReactFlowProps>
|
||||
}
|
||||
|
||||
/**
|
||||
* GraphContent - 图表渲染核心组件
|
||||
*
|
||||
* 这是一个独立的图表渲染组件,负责实际的 ReactFlow 图表展示和交互。
|
||||
* 它必须在 ReactFlowProvider 内部使用,因为它依赖 useReactFlow hook。
|
||||
*
|
||||
* 职责:
|
||||
* - 渲染 ReactFlow 图表(节点、边、控件、小地图等)
|
||||
* - 管理节点和边的交互状态(拖拽、选择等)
|
||||
* - 处理搜索输入和全屏按钮的 UI
|
||||
* - 自动调整视野以适应过滤后的节点
|
||||
*
|
||||
* 与 AdaptiveGraph 的关系:
|
||||
* - AdaptiveGraph 是外层容器组件,负责数据处理、状态管理和布局计算
|
||||
* - GraphContent 是内层渲染组件,接收处理好的数据并负责图表的实际展示
|
||||
* - AdaptiveGraph 会在两个地方使用 GraphContent:普通视图和全屏对话框
|
||||
* - 两者通过 ReactFlowProvider 隔离,确保每个实例有独立的 ReactFlow 上下文
|
||||
*/
|
||||
function GraphContent<TNodeData = any>({
|
||||
flowNodes,
|
||||
flowEdges,
|
||||
handleNodeClick,
|
||||
nodeTypes,
|
||||
getNodeColor,
|
||||
onFilter,
|
||||
searchPlaceholder,
|
||||
inputValue,
|
||||
handleInputChange,
|
||||
isFullscreen,
|
||||
setFullscreenOpen,
|
||||
renderStats,
|
||||
filteredNodeIds,
|
||||
rawNodes,
|
||||
highlightedNodeIds,
|
||||
reactFlowProps,
|
||||
filteredNodeIdsForFitView,
|
||||
}: {
|
||||
flowNodes: Node[]
|
||||
flowEdges: Edge[]
|
||||
handleNodeClick: (event: React.MouseEvent, node: Node) => void
|
||||
nodeTypes: NodeTypes
|
||||
getNodeColor?: (node: Node) => string
|
||||
onFilter?: any
|
||||
searchPlaceholder: string
|
||||
inputValue: string
|
||||
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
isFullscreen: boolean
|
||||
setFullscreenOpen?: (open: boolean) => void
|
||||
renderStats?: (filteredCount: number, totalCount: number, matchedCount: number) => React.ReactNode
|
||||
filteredNodeIds: Set<string>
|
||||
rawNodes: TNodeData[]
|
||||
highlightedNodeIds: Set<string>
|
||||
reactFlowProps: Partial<ReactFlowProps>
|
||||
filteredNodeIdsForFitView: Set<string>
|
||||
}) {
|
||||
const { fitView } = useReactFlow()
|
||||
const fitViewTimeoutRef = useRef<number>(0)
|
||||
const previousFilteredNodeIdsRef = useRef<Set<string>>(new Set())
|
||||
// 使用 useNodesState 和 useEdgesState 来管理节点和边的状态,支持拖拽等交互
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(flowNodes)
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(flowEdges)
|
||||
const { theme, resolvedTheme } = useTheme() // Tailwind CSS的样式变化无法自动传递到ReactFlow内部,需要显式监听网站主题的变化
|
||||
const themeIsDark = resolvedTheme === 'dark' || (theme === 'system' && resolvedTheme === 'dark')
|
||||
|
||||
// 当 flowNodes 或 flowEdges 变化时,更新内部状态
|
||||
React.useEffect(() => {
|
||||
setNodes(flowNodes)
|
||||
}, [flowNodes, setNodes])
|
||||
|
||||
React.useEffect(() => {
|
||||
setEdges(flowEdges)
|
||||
}, [flowEdges, setEdges])
|
||||
|
||||
|
||||
// 当过滤节点变化时,自动调整视野
|
||||
React.useEffect(() => {
|
||||
// 比较 filteredNodeIds 的实际内容是否发生变化
|
||||
const previousIds = previousFilteredNodeIdsRef.current
|
||||
const currentIds = filteredNodeIdsForFitView
|
||||
|
||||
// 检查节点ID集合是否真正发生了变化
|
||||
const hasChanged =
|
||||
previousIds.size !== currentIds.size ||
|
||||
Array.from(currentIds).some(id => !previousIds.has(id))
|
||||
|
||||
if (hasChanged) {
|
||||
// 更新引用
|
||||
previousFilteredNodeIdsRef.current = new Set(currentIds)
|
||||
|
||||
// 清除之前的定时器
|
||||
if (fitViewTimeoutRef.current) {
|
||||
window.clearTimeout(fitViewTimeoutRef.current)
|
||||
}
|
||||
|
||||
// 延迟执行 fitView,确保节点已经渲染
|
||||
fitViewTimeoutRef.current = window.setTimeout(() => {
|
||||
if (flowNodes.length > 0) {
|
||||
fitView({
|
||||
padding: 0.2,
|
||||
duration: 300,
|
||||
maxZoom: 1.5,
|
||||
minZoom: 0.1,
|
||||
})
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (fitViewTimeoutRef.current) {
|
||||
window.clearTimeout(fitViewTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [filteredNodeIdsForFitView, fitView, flowNodes.length])
|
||||
|
||||
// 默认统计信息渲染
|
||||
const defaultRenderStats = (filteredCount: number, totalCount: number, matchedCount: number) => (
|
||||
onFilter ?
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
显示 {filteredCount} / {totalCount} 个节点
|
||||
{matchedCount > 0 && ` (${matchedCount} 个匹配)`}
|
||||
</div> : <></>
|
||||
)
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={handleNodeClick}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
minZoom={0.1}
|
||||
maxZoom={2}
|
||||
defaultEdgeOptions={{
|
||||
type: 'smoothstep',
|
||||
animated: false,
|
||||
}}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
colorMode={themeIsDark ? 'dark' : 'light'}
|
||||
{...reactFlowProps}
|
||||
>
|
||||
<Background />
|
||||
<Controls />
|
||||
<MiniMap
|
||||
nodeColor={getNodeColor || (() => '#cbd5e1')}
|
||||
maskColor="rgba(0, 0, 0, 0.1)"
|
||||
/>
|
||||
<Panel position="top-left" className="bg-background/95 backdrop-blur-sm p-3 rounded-lg shadow-lg border">
|
||||
<div className="flex items-center gap-2">
|
||||
{onFilter && (
|
||||
<InputGroup className="min-w-[300px]">
|
||||
<InputGroupAddon>
|
||||
<InputGroupText>
|
||||
<Search className="h-4 w-4" />
|
||||
</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder={searchPlaceholder}
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</InputGroup>
|
||||
)}
|
||||
{!isFullscreen && setFullscreenOpen && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 px-3"
|
||||
title="全屏显示"
|
||||
onClick={() => setFullscreenOpen(true)}
|
||||
>
|
||||
<Maximize2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{(renderStats || defaultRenderStats)(filteredNodeIds.size, rawNodes.length, highlightedNodeIds.size)}
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* AdaptiveGraph - 自适应图表容器组件
|
||||
*
|
||||
* 这是一个高度可配置的图表容器组件,提供完整的图表展示解决方案。
|
||||
* 它处理数据转换、过滤、布局计算和状态管理,然后将处理好的数据传递给 GraphContent 进行渲染。
|
||||
*
|
||||
* 主要功能:
|
||||
* - 接收原始节点和边数据,通过 transformNode/transformEdge 转换为 ReactFlow 格式
|
||||
* - 支持自定义过滤逻辑(搜索、高亮)
|
||||
* - 使用 dagre 算法自动计算节点布局
|
||||
* - 提供普通视图和全屏对话框两种展示模式
|
||||
* - 管理搜索状态和防抖处理
|
||||
* ```
|
||||
*/
|
||||
export function AdaptiveGraph<TNodeData = any>({
|
||||
nodes: rawNodes,
|
||||
edges: rawEdges,
|
||||
nodeTypes,
|
||||
onFilter,
|
||||
transformNode,
|
||||
transformEdge,
|
||||
getNodeColor,
|
||||
onNodeClick,
|
||||
searchPlaceholder = '搜索...',
|
||||
renderStats,
|
||||
layoutConfig,
|
||||
className = 'h-[800px] max-h-[calc(100vh-12rem)]',
|
||||
reactFlowProps = {},
|
||||
}: AdaptiveGraphProps<TNodeData>) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [fullscreenOpen, setFullscreenOpen] = useState(false)
|
||||
|
||||
// 使用外部提供的过滤函数,如果未提供则显示所有节点
|
||||
const { filteredNodeIds, highlightedNodeIds } = useMemo(() => {
|
||||
if (!onFilter) {
|
||||
// 未提供过滤函数时,显示所有节点且不高亮
|
||||
const allNodeIds = new Set(
|
||||
rawNodes.map((node) => {
|
||||
const transformed = transformNode(node, { isHighlighted: false, isDimmed: false })
|
||||
return transformed.id
|
||||
})
|
||||
)
|
||||
return {
|
||||
filteredNodeIds: allNodeIds,
|
||||
highlightedNodeIds: new Set<string>(),
|
||||
}
|
||||
}
|
||||
return onFilter(rawNodes, searchQuery)
|
||||
}, [rawNodes, searchQuery, onFilter, transformNode])
|
||||
|
||||
// 转换为 ReactFlow 节点
|
||||
const nodes = useMemo(() => {
|
||||
return rawNodes
|
||||
.filter((node) => {
|
||||
const nodeData = transformNode(node, { isHighlighted: false, isDimmed: false })
|
||||
return filteredNodeIds.has(nodeData.id)
|
||||
})
|
||||
.map((node) => {
|
||||
const nodeData = transformNode(node, { isHighlighted: false, isDimmed: false })
|
||||
return transformNode(node, {
|
||||
isHighlighted: highlightedNodeIds.has(nodeData.id),
|
||||
isDimmed: searchQuery.trim() !== '' && !highlightedNodeIds.has(nodeData.id),
|
||||
})
|
||||
})
|
||||
}, [rawNodes, filteredNodeIds, highlightedNodeIds, searchQuery, transformNode])
|
||||
|
||||
// 转换为 ReactFlow 边
|
||||
const edges = useMemo(() => {
|
||||
const defaultTransform = (
|
||||
edge: { source: string; target: string; label?: string },
|
||||
index: number,
|
||||
options: { isHighlighted: boolean }
|
||||
): Edge => ({
|
||||
id: `${edge.source}-${edge.target}-${index}`,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: 'smoothstep',
|
||||
animated: options.isHighlighted,
|
||||
style: {
|
||||
stroke: options.isHighlighted ? '#3b82f6' : '#94a3b8',
|
||||
strokeWidth: options.isHighlighted ? 2 : 1,
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: options.isHighlighted ? '#3b82f6' : '#94a3b8',
|
||||
},
|
||||
})
|
||||
|
||||
const transform = transformEdge || defaultTransform
|
||||
|
||||
return rawEdges
|
||||
.filter((edge) => filteredNodeIds.has(edge.source) && filteredNodeIds.has(edge.target))
|
||||
.map((edge, index) =>
|
||||
transform(edge, index, {
|
||||
isHighlighted: highlightedNodeIds.has(edge.source) || highlightedNodeIds.has(edge.target),
|
||||
})
|
||||
)
|
||||
}, [rawEdges, filteredNodeIds, highlightedNodeIds, transformEdge])
|
||||
|
||||
// 应用自动布局
|
||||
const { nodes: layoutedNodes, edges: layoutedEdges } = useMemo(() => {
|
||||
return getLayoutedElements(nodes, edges, layoutConfig)
|
||||
}, [nodes, edges, layoutConfig])
|
||||
|
||||
// 防抖更新搜索查询
|
||||
const debouncedSetSearchQuery = useDebouncedCallback((value: string) => {
|
||||
setSearchQuery(value)
|
||||
}, 300)
|
||||
|
||||
// 处理输入变化
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value
|
||||
setInputValue(value)
|
||||
debouncedSetSearchQuery(value)
|
||||
},
|
||||
[debouncedSetSearchQuery]
|
||||
)
|
||||
|
||||
// 处理节点点击
|
||||
const handleNodeClick = useCallback(
|
||||
(_event: React.MouseEvent, node: Node) => {
|
||||
const nodeData = rawNodes.find((n) => {
|
||||
const transformed = transformNode(n, { isHighlighted: false, isDimmed: false })
|
||||
return transformed.id === node.id
|
||||
})
|
||||
if (nodeData && onNodeClick) {
|
||||
onNodeClick(nodeData)
|
||||
}
|
||||
},
|
||||
[rawNodes, transformNode, onNodeClick]
|
||||
)
|
||||
|
||||
// 共享的 GraphContent props
|
||||
const graphContentProps = {
|
||||
flowNodes: layoutedNodes,
|
||||
flowEdges: layoutedEdges,
|
||||
handleNodeClick,
|
||||
nodeTypes,
|
||||
getNodeColor,
|
||||
onFilter,
|
||||
searchPlaceholder,
|
||||
inputValue,
|
||||
handleInputChange,
|
||||
isFullscreen: false,
|
||||
setFullscreenOpen,
|
||||
renderStats,
|
||||
filteredNodeIds,
|
||||
rawNodes,
|
||||
highlightedNodeIds,
|
||||
reactFlowProps,
|
||||
filteredNodeIdsForFitView: filteredNodeIds,
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn('w-full border rounded-lg bg-background', className)}>
|
||||
<ReactFlowProvider>
|
||||
<GraphContent {...graphContentProps} />
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
{/* 全屏对话框 */}
|
||||
<Dialog open={fullscreenOpen} onOpenChange={setFullscreenOpen}>
|
||||
<DialogContent className="p-0" variant="fullscreen">
|
||||
<DialogHeader className="pt-5 pb-3 m-0 border-b border-border">
|
||||
<DialogTitle className="px-6 text-base">图表查看</DialogTitle>
|
||||
<DialogDescription className="sr-only">全屏查看图表</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="h-full my-3 px-6">
|
||||
<ReactFlowProvider>
|
||||
<GraphContent {...graphContentProps} isFullscreen={true} setFullscreenOpen={undefined} />
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
<DialogFooter className="px-6 py-4 border-t border-border">
|
||||
<DialogClose asChild>
|
||||
<Button type="button">关闭</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
301
src/components/features/code-editor-preview.tsx
Normal file
301
src/components/features/code-editor-preview.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Code2, Eye, Maximize2, Loader2, Copy, Check } from 'lucide-react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { LiveProvider, LiveEditor, LiveError, LivePreview } from 'react-live'
|
||||
import { themes } from 'prism-react-renderer'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
export interface CodeEditorPreviewProps {
|
||||
/** 代码内容 */
|
||||
code?: string
|
||||
/** 组件作用域,用于 LiveProvider */
|
||||
scope?: Record<string, any>
|
||||
/** 是否显示编辑器 */
|
||||
showEditor?: boolean
|
||||
/** 是否显示预览 */
|
||||
showPreview?: boolean
|
||||
/** 编辑器标题 */
|
||||
editorTitle?: string
|
||||
/** 预览标题 */
|
||||
previewTitle?: string
|
||||
/** 容器类名 */
|
||||
className?: string
|
||||
/** 编辑器类名 */
|
||||
editorClassName?: string
|
||||
/** 预览容器类名 */
|
||||
previewClassName?: string
|
||||
/** 是否支持全屏 */
|
||||
enableFullscreen?: boolean
|
||||
/** 是否正在加载 */
|
||||
loading?: boolean
|
||||
/** 是否显示工具栏 */
|
||||
showToolbar?: boolean
|
||||
/** 代码变化回调 */
|
||||
onCodeChange?: (code: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 代码编辑器和预览组件
|
||||
* 支持实时编辑和预览,以及全屏查看
|
||||
*/
|
||||
export function CodeEditorPreview({
|
||||
code,
|
||||
scope = {},
|
||||
showEditor = true,
|
||||
showPreview = true,
|
||||
editorTitle = '代码编辑器',
|
||||
previewTitle = '实时预览',
|
||||
className,
|
||||
editorClassName,
|
||||
previewClassName,
|
||||
enableFullscreen = true,
|
||||
loading,
|
||||
showToolbar = true,
|
||||
}: CodeEditorPreviewProps) {
|
||||
const [fullscreenOpen, setFullscreenOpen] = React.useState(false)
|
||||
const [copied, setCopied] = React.useState(false)
|
||||
const [showEditorPanel, setShowEditorPanel] = React.useState(showEditor)
|
||||
const [showPreviewPanel, setShowPreviewPanel] = React.useState(showPreview)
|
||||
const { theme, resolvedTheme } = useTheme()
|
||||
const themeIsDark = resolvedTheme === 'dark' || (theme === 'system' && resolvedTheme === 'dark')
|
||||
const prismTheme = themeIsDark ? themes.vsDark : themes.github // 根据主题选择代码高亮主题
|
||||
|
||||
// 当外部 props 变化时更新内部状态
|
||||
React.useEffect(() => {
|
||||
setShowEditorPanel(showEditor)
|
||||
}, [showEditor])
|
||||
|
||||
React.useEffect(() => {
|
||||
setShowPreviewPanel(showPreview)
|
||||
}, [showPreview])
|
||||
|
||||
// 复制代码
|
||||
const handleCopy = () => {
|
||||
if (!code) return
|
||||
const success = copy(code)
|
||||
if (success) {
|
||||
setCopied(true)
|
||||
toast.success('已复制到剪贴板')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} else {
|
||||
toast.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染加载骨架屏
|
||||
const renderLoadingSkeleton = () => (
|
||||
<div className="flex-1 flex flex-col lg:flex-row min-h-0">
|
||||
{/* 代码编辑器骨架 */}
|
||||
{showEditorPanel && (
|
||||
<div className="flex-1 flex flex-col min-h-0 lg:border-r border-border/50">
|
||||
<div className="bg-muted/60 px-4 py-2.5 text-sm font-medium border-b border-border/50 flex items-center gap-2">
|
||||
<Code2 className="h-4 w-4 text-primary" />
|
||||
{editorTitle}
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin ml-auto text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 p-4 space-y-2 overflow-auto">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 实时预览骨架 */}
|
||||
{showPreviewPanel && (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="bg-muted/60 px-4 py-2.5 text-sm font-medium border-b border-border/50 flex items-center gap-2">
|
||||
<Eye className="h-4 w-4 text-primary" />
|
||||
{previewTitle}
|
||||
</div>
|
||||
<div className="flex-1 p-4 space-y-4 overflow-auto">
|
||||
<Skeleton className="h-8 w-1/2" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-10 w-1/3" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
// 渲染编辑器和预览内容
|
||||
const renderContent = () => (
|
||||
<LiveProvider code={code || ''} scope={scope} noInline language='tsx'>
|
||||
<div className={cn(
|
||||
'flex-1 flex flex-col min-h-0',
|
||||
showEditorPanel && showPreviewPanel && 'lg:flex-row'
|
||||
)}>
|
||||
{/* 代码编辑器 */}
|
||||
{showEditorPanel && (
|
||||
<div className={cn(
|
||||
'flex-1 flex flex-col min-h-0',
|
||||
showPreviewPanel && 'lg:border-r border-border/50',
|
||||
editorClassName
|
||||
)}>
|
||||
<div className="bg-muted/60 px-4 py-2.5 text-sm font-medium border-b border-border/50 flex items-center gap-2">
|
||||
<Code2 className="h-4 w-4 text-primary" />
|
||||
{editorTitle}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<LiveEditor
|
||||
className="font-mono text-sm h-full"
|
||||
theme={prismTheme}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 实时预览 */}
|
||||
{showPreviewPanel && (
|
||||
<div className={cn(
|
||||
'flex-1 flex flex-col min-h-0',
|
||||
previewClassName
|
||||
)}>
|
||||
<div className="bg-muted/60 px-4 py-2.5 text-sm font-medium border-b border-border/50 flex items-center gap-2">
|
||||
<Eye className="h-4 w-4 text-primary" />
|
||||
{previewTitle}
|
||||
</div>
|
||||
<div className="flex-1 p-4 overflow-auto bg-background">
|
||||
<LivePreview />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<LiveError className="p-4 bg-destructive/10 text-destructive text-sm font-mono border-t border-border/50" />
|
||||
</LiveProvider>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn('h-full overflow-hidden flex flex-col shadow-inner', className)}>
|
||||
{/* 工具栏 */}
|
||||
{showToolbar && !loading && code && (
|
||||
<div className="bg-muted/30 px-4 py-2 border-b border-border/50 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground font-medium">
|
||||
代码编辑器与预览
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-3"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5 mr-1.5" />
|
||||
视图
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuLabel>显示面板</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={showEditorPanel}
|
||||
onCheckedChange={setShowEditorPanel}
|
||||
disabled={!showPreviewPanel}
|
||||
>
|
||||
<Code2 className="h-3.5 w-3.5 mr-2" />
|
||||
代码编辑器
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={showPreviewPanel}
|
||||
onCheckedChange={setShowPreviewPanel}
|
||||
disabled={!showEditorPanel}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5 mr-2" />
|
||||
实时预览
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-3"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3.5 w-3.5 mr-1.5" />
|
||||
已复制
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3.5 w-3.5 mr-1.5" />
|
||||
复制
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{enableFullscreen && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-3"
|
||||
onClick={() => setFullscreenOpen(true)}
|
||||
>
|
||||
<Maximize2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
全屏
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? renderLoadingSkeleton() : code ? renderContent() : null}
|
||||
</div>
|
||||
|
||||
{/* 全屏对话框 */}
|
||||
{enableFullscreen && (
|
||||
<Dialog open={fullscreenOpen} onOpenChange={setFullscreenOpen}>
|
||||
<DialogContent className="p-0" variant="fullscreen">
|
||||
<DialogHeader className="pt-5 pb-3 m-0 border-b border-border">
|
||||
<DialogTitle className="px-6 text-base">代码编辑器与预览</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
全屏查看代码编辑器和实时预览
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="h-full my-3 ps-6 pe-5 me-1 overflow-hidden flex flex-col">
|
||||
{renderContent()}
|
||||
</div>
|
||||
<DialogFooter className="px-6 py-4 border-t border-border">
|
||||
<DialogClose asChild>
|
||||
<Button type="button">关闭</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
187
src/components/icons/code-lang.tsx
Normal file
187
src/components/icons/code-lang.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
|
||||
import { LucideProps, FileJson, FileCode2, Palette, Database, ImageIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// 编程/开发相关图标,从 https://devicon.dev/ 获得,增强灵活性,fill改成currentColor并支持传入LucideProps
|
||||
|
||||
// React 图标 (tsx, jsx)
|
||||
export const ReactIcon = ({ className, color = "#61DAFB", ...props }: LucideProps & { color?: string }) => (
|
||||
<svg viewBox="0 0 128 128" className={cn("w-4 h-4", className)} {...props}>
|
||||
<g fill={color}>
|
||||
<circle cx="64" cy="64" r="11.4"></circle>
|
||||
<path d="M107.3 45.2c-2.2-.8-4.5-1.6-6.9-2.3.6-2.4 1.1-4.8 1.5-7.1 2.1-13.2-.2-22.5-6.6-26.1-1.9-1.1-4-1.6-6.4-1.6-7 0-15.9 5.2-24.9 13.9-9-8.7-17.9-13.9-24.9-13.9-2.4 0-4.5.5-6.4 1.6-6.4 3.7-8.7 13-6.6 26.1.4 2.3.9 4.7 1.5 7.1-2.4.7-4.7 1.4-6.9 2.3C8.2 50 1.4 56.6 1.4 64s6.9 14 19.3 18.8c2.2.8 4.5 1.6 6.9 2.3-.6 2.4-1.1 4.8-1.5 7.1-2.1 13.2.2 22.5 6.6 26.1 1.9 1.1 4 1.6 6.4 1.6 7.1 0 16-5.2 24.9-13.9 9 8.7 17.9 13.9 24.9 13.9 2.4 0 4.5-.5 6.4-1.6 6.4-3.7 8.7-13 6.6-26.1-.4-2.3-.9-4.7-1.5-7.1 2.4-.7 4.7-1.4 6.9-2.3 12.5-4.8 19.3-11.4 19.3-18.8s-6.8-14-19.3-18.8zM92.5 14.7c4.1 2.4 5.5 9.8 3.8 20.3-.3 2.1-.8 4.3-1.4 6.6-5.2-1.2-10.7-2-16.5-2.5-3.4-4.8-6.9-9.1-10.4-13 7.4-7.3 14.9-12.3 21-12.3 1.3 0 2.5.3 3.5.9zM81.3 74c-1.8 3.2-3.9 6.4-6.1 9.6-3.7.3-7.4.4-11.2.4-3.9 0-7.6-.1-11.2-.4-2.2-3.2-4.2-6.4-6-9.6-1.9-3.3-3.7-6.7-5.3-10 1.6-3.3 3.4-6.7 5.3-10 1.8-3.2 3.9-6.4 6.1-9.6 3.7-.3 7.4-.4 11.2-.4 3.9 0 7.6.1 11.2.4 2.2 3.2 4.2 6.4 6 9.6 1.9 3.3 3.7 6.7 5.3 10-1.7 3.3-3.4 6.6-5.3 10zm8.3-3.3c1.5 3.5 2.7 6.9 3.8 10.3-3.4.8-7 1.4-10.8 1.9 1.2-1.9 2.5-3.9 3.6-6 1.2-2.1 2.3-4.2 3.4-6.2zM64 97.8c-2.4-2.6-4.7-5.4-6.9-8.3 2.3.1 4.6.2 6.9.2 2.3 0 4.6-.1 6.9-.2-2.2 2.9-4.5 5.7-6.9 8.3zm-18.6-15c-3.8-.5-7.4-1.1-10.8-1.9 1.1-3.3 2.3-6.8 3.8-10.3 1.1 2 2.2 4.1 3.4 6.1 1.2 2.2 2.4 4.1 3.6 6.1zm-7-25.5c-1.5-3.5-2.7-6.9-3.8-10.3 3.4-.8 7-1.4 10.8-1.9-1.2 1.9-2.5 3.9-3.6 6-1.2 2.1-2.3 4.2-3.4 6.2zM64 30.2c2.4 2.6 4.7 5.4 6.9 8.3-2.3-.1-4.6-.2-6.9-.2-2.3 0-4.6.1-6.9.2 2.2-2.9 4.5-5.7 6.9-8.3zm22.2 21l-3.6-6c3.8.5 7.4 1.1 10.8 1.9-1.1 3.3-2.3 6.8-3.8 10.3-1.1-2.1-2.2-4.2-3.4-6.2zM31.7 35c-1.7-10.5-.3-17.9 3.8-20.3 1-.6 2.2-.9 3.5-.9 6 0 13.5 4.9 21 12.3-3.5 3.8-7 8.2-10.4 13-5.8.5-11.3 1.4-16.5 2.5-.6-2.3-1-4.5-1.4-6.6zM7 64c0-4.7 5.7-9.7 15.7-13.4 2-.8 4.2-1.5 6.4-2.1 1.6 5 3.6 10.3 6 15.6-2.4 5.3-4.5 10.5-6 15.5C15.3 75.6 7 69.6 7 64zm28.5 49.3c-4.1-2.4-5.5-9.8-3.8-20.3.3-2.1.8-4.3 1.4-6.6 5.2 1.2 10.7 2 16.5 2.5 3.4 4.8 6.9 9.1 10.4 13-7.4 7.3-14.9 12.3-21 12.3-1.3 0-2.5-.3-3.5-.9zM96.3 93c1.7 10.5.3 17.9-3.8 20.3-1 .6-2.2.9-3.5.9-6 0-13.5-4.9-21-12.3 3.5-3.8 7-8.2 10.4-13 5.8-.5 11.3-1.4 16.5-2.5.6 2.3 1 4.5 1.4 6.6zm9-15.6c-2 .8-4.2 1.5-6.4 2.1-1.6-5-3.6-10.3-6-15.6 2.4-5.3 4.5-10.5 6-15.5 13.8 4 22.1 10 22.1 15.6 0 4.7-5.8 9.7-15.7 13.4z"></path>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// TypeScript 图标
|
||||
export const TypeScriptIcon = ({ className, color = "#007acc", ...props }: LucideProps & { color?: string }) => (
|
||||
<svg viewBox="0 0 128 128" className={cn("w-4 h-4", className)} {...props}>
|
||||
<path fill="currentColor" d="M22.67 47h99.67v73.67H22.67z" opacity="0.1"></path>
|
||||
<path fill={color} d="M1.5 63.91v62.5h125v-125H1.5zm100.73-5a15.56 15.56 0 017.82 4.5 20.58 20.58 0 013 4c0 .16-5.4 3.81-8.69 5.85-.12.08-.6-.44-1.13-1.23a7.09 7.09 0 00-5.87-3.53c-3.79-.26-6.23 1.73-6.21 5a4.58 4.58 0 00.54 2.34c.83 1.73 2.38 2.76 7.24 4.86 8.95 3.85 12.78 6.39 15.16 10 2.66 4 3.25 10.46 1.45 15.24-2 5.2-6.9 8.73-13.83 9.9a38.32 38.32 0 01-9.52-.1 23 23 0 01-12.72-6.63c-1.15-1.27-3.39-4.58-3.25-4.82a9.34 9.34 0 011.15-.73L82 101l3.59-2.08.75 1.11a16.78 16.78 0 004.74 4.54c4 2.1 9.46 1.81 12.16-.62a5.43 5.43 0 00.69-6.92c-1-1.39-3-2.56-8.59-5-6.45-2.78-9.23-4.5-11.77-7.24a16.48 16.48 0 01-3.43-6.25 25 25 0 01-.22-8c1.33-6.23 6-10.58 12.82-11.87a31.66 31.66 0 019.49.26zm-29.34 5.24v5.12H56.66v46.23H45.15V69.26H28.88v-5a49.19 49.19 0 01.12-5.17C29.08 59 39 59 51 59h21.83z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// JavaScript 图标
|
||||
export const JavaScriptIcon = ({ className, color = "#F0DB4F", ...props }: LucideProps & { color?: string }) => {
|
||||
// 当使用 currentColor 时,使用简化的双色设计
|
||||
if (color === "currentColor") {
|
||||
return (
|
||||
<svg viewBox="0 0 128 128" className={cn("w-4 h-4", className)} {...props}>
|
||||
<rect x="2" y="2" width="124" height="124" rx="8" fill="currentColor" opacity="0.15" />
|
||||
<path fill="currentColor" d="M116.347 96.736c-.917-5.711-4.641-10.508-15.672-14.981-3.832-1.761-8.104-3.022-9.377-5.926-.452-1.69-.512-2.642-.226-3.665.821-3.32 4.784-4.355 7.925-3.403 2.023.678 3.938 2.237 5.093 4.724 5.402-3.498 5.391-3.475 9.163-5.879-1.381-2.141-2.118-3.129-3.022-4.045-3.249-3.629-7.676-5.498-14.756-5.355l-3.688.477c-3.534.893-6.902 2.748-8.877 5.235-5.926 6.724-4.236 18.492 2.975 23.335 7.104 5.332 17.54 6.545 18.873 11.531 1.297 6.104-4.486 8.08-10.234 7.378-4.236-.881-6.592-3.034-9.139-6.949-4.688 2.713-4.688 2.713-9.508 5.485 1.143 2.499 2.344 3.63 4.26 5.795 9.068 9.198 31.76 8.746 35.83-5.176.165-.478 1.261-3.666.38-8.581zM69.462 58.943H57.753l-.048 30.272c0 6.438.333 12.34-.714 14.149-1.713 3.558-6.152 3.117-8.175 2.427-2.059-1.012-3.106-2.451-4.319-4.485-.333-.584-.583-1.036-.667-1.071l-9.52 5.83c1.583 3.249 3.915 6.069 6.902 7.901 4.462 2.678 10.459 3.499 16.731 2.059 4.082-1.189 7.604-3.652 9.448-7.401 2.666-4.915 2.094-10.864 2.07-17.444.06-10.735.001-21.468.001-32.237z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// 使用原始彩色设计
|
||||
return (
|
||||
<svg viewBox="0 0 128 128" className={cn("w-4 h-4", className)} {...props}>
|
||||
<path fill={color} d="M1.408 1.408h125.184v125.185H1.408z"></path>
|
||||
<path fill="#323330" d="M116.347 96.736c-.917-5.711-4.641-10.508-15.672-14.981-3.832-1.761-8.104-3.022-9.377-5.926-.452-1.69-.512-2.642-.226-3.665.821-3.32 4.784-4.355 7.925-3.403 2.023.678 3.938 2.237 5.093 4.724 5.402-3.498 5.391-3.475 9.163-5.879-1.381-2.141-2.118-3.129-3.022-4.045-3.249-3.629-7.676-5.498-14.756-5.355l-3.688.477c-3.534.893-6.902 2.748-8.877 5.235-5.926 6.724-4.236 18.492 2.975 23.335 7.104 5.332 17.54 6.545 18.873 11.531 1.297 6.104-4.486 8.08-10.234 7.378-4.236-.881-6.592-3.034-9.139-6.949-4.688 2.713-4.688 2.713-9.508 5.485 1.143 2.499 2.344 3.63 4.26 5.795 9.068 9.198 31.76 8.746 35.83-5.176.165-.478 1.261-3.666.38-8.581zM69.462 58.943H57.753l-.048 30.272c0 6.438.333 12.34-.714 14.149-1.713 3.558-6.152 3.117-8.175 2.427-2.059-1.012-3.106-2.451-4.319-4.485-.333-.584-.583-1.036-.667-1.071l-9.52 5.83c1.583 3.249 3.915 6.069 6.902 7.901 4.462 2.678 10.459 3.499 16.731 2.059 4.082-1.189 7.604-3.652 9.448-7.401 2.666-4.915 2.094-10.864 2.07-17.444.06-10.735.001-21.468.001-32.237z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// Markdown 图标
|
||||
export const MarkdownIcon = ({ className, color = "currentColor", ...props }: LucideProps & { color?: string }) => (
|
||||
<svg viewBox="0 0 128 128" className={cn("w-4 h-4", className)} {...props}>
|
||||
<path fill={color} d="M11.95 24.348c-5.836 0-10.618 4.867-10.618 10.681v57.942c0 5.814 4.782 10.681 10.617 10.681h104.102c5.835 0 10.617-4.867 10.617-10.681V35.03c0-5.814-4.783-10.681-10.617-10.681H14.898l-.002-.002H11.95zm-.007 9.543h104.108c.625 0 1.076.423 1.076 1.14v57.94c0 .717-.453 1.14-1.076 1.14H11.949c-.623 0-1.076-.423-1.076-1.14V35.029c0-.715.451-1.135 1.07-1.138z" color="currentColor" fontWeight="400" fontFamily="sans-serif" overflow="visible"></path>
|
||||
<path fill={color} d="M20.721 84.1V43.9H32.42l11.697 14.78L55.81 43.9h11.696v40.2H55.81V61.044l-11.694 14.78-11.698-14.78V84.1H20.722zm73.104 0L76.28 64.591h11.697V43.9h11.698v20.69h11.698zm0 0"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Html 图标
|
||||
export const HtmlIcon = ({ className, color = "#E44D26", ...props }: LucideProps & { color?: string }) => {
|
||||
// 当使用 currentColor 时,使用简化的双色设计
|
||||
if (color === "currentColor") {
|
||||
return (
|
||||
<svg viewBox="0 0 128 128" className={cn("w-4 h-4", className)} {...props}>
|
||||
<path fill="currentColor" opacity="0.2" d="M19.037 113.876L9.032 1.661h109.936l-10.016 112.198-45.019 12.48z"></path>
|
||||
<path fill="currentColor" opacity="0.5" d="M64 116.8l36.378-10.086 8.559-95.878H64z"></path>
|
||||
<path fill="currentColor" opacity="0.7" d="M64 52.455H45.788L44.53 38.361H64V24.599H29.489l.33 3.692 3.382 37.927H64zm0 35.743l-.061.017-15.327-4.14-.979-10.975H33.816l1.928 21.609 28.193 7.826.063-.017z"></path>
|
||||
<path fill="currentColor" opacity="0.95" d="M63.952 52.455v13.763h16.947l-1.597 17.849-15.35 4.143v14.319l28.215-7.82.207-2.325 3.234-36.233.335-3.696h-3.708zm0-27.856v13.762h33.244l.276-3.092.628-6.978.329-3.692z"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// 使用原始彩色设计
|
||||
return (
|
||||
<svg viewBox="0 0 128 128" className={cn("w-4 h-4", className)} {...props}>
|
||||
<path fill={color || "#E44D26"} opacity={color ? 0.3: 1} d="M19.037 113.876L9.032 1.661h109.936l-10.016 112.198-45.019 12.48z"></path>
|
||||
<path fill={color ||"#F16529"} opacity={color ? 0.6: 1} d="M64 116.8l36.378-10.086 8.559-95.878H64z"></path>
|
||||
<path fill={color ||"#EBEBEB"} opacity={color ? 0.8: 1} d="M64 52.455H45.788L44.53 38.361H64V24.599H29.489l.33 3.692 3.382 37.927H64zm0 35.743l-.061.017-15.327-4.14-.979-10.975H33.816l1.928 21.609 28.193 7.826.063-.017z"></path>
|
||||
<path fill={color ||"#fff"} opacity={color ? 1: 1} d="M63.952 52.455v13.763h16.947l-1.597 17.849-15.35 4.143v14.319l28.215-7.82.207-2.325 3.234-36.233.335-3.696h-3.708zm0-27.856v13.762h33.244l.276-3.092.628-6.978.329-3.692z"></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
// Python 图标
|
||||
export const PythonIcon = ({ className, color, ...props }: LucideProps & { color?: string }) => {
|
||||
// 当使用 currentColor 时,使用简化的双色设计
|
||||
if (color === "currentColor") {
|
||||
return (
|
||||
<svg viewBox="0 0 128 128" className={cn("w-4 h-4", className)} {...props}>
|
||||
<path fill="currentColor" opacity="0.7" d="M63.391 1.988c-4.222.02-8.252.379-11.8 1.007-10.45 1.846-12.346 5.71-12.346 12.837v9.411h24.693v3.137H29.977c-7.176 0-13.46 4.313-15.426 12.521-2.268 9.405-2.368 15.275 0 25.096 1.755 7.311 5.947 12.519 13.124 12.519h8.491V67.234c0-8.151 7.051-15.34 15.426-15.34h24.665c6.866 0 12.346-5.654 12.346-12.548V15.833c0-6.693-5.646-11.72-12.346-12.837-4.244-.706-8.645-1.027-12.866-1.008zM50.037 9.557c2.55 0 4.634 2.117 4.634 4.721 0 2.593-2.083 4.69-4.634 4.69-2.56 0-4.633-2.097-4.633-4.69-.001-2.604 2.073-4.721 4.633-4.721z" transform="translate(0 10.26)"></path>
|
||||
<path fill="currentColor" opacity="0.4" d="M91.682 28.38v10.966c0 8.5-7.208 15.655-15.426 15.655H51.591c-6.756 0-12.346 5.783-12.346 12.549v23.515c0 6.691 5.818 10.628 12.346 12.547 7.816 2.297 15.312 2.713 24.665 0 6.216-1.801 12.346-5.423 12.346-12.547v-9.412H63.938v-3.138h37.012c7.176 0 9.852-5.005 12.348-12.519 2.578-7.735 2.467-15.174 0-25.096-1.774-7.145-5.161-12.521-12.348-12.521h-9.268zM77.809 87.927c2.561 0 4.634 2.097 4.634 4.692 0 2.602-2.074 4.719-4.634 4.719-2.55 0-4.633-2.117-4.633-4.719 0-2.595 2.083-4.692 4.633-4.692z" transform="translate(0 10.26)"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// 使用原始彩色渐变设计
|
||||
return (
|
||||
<svg viewBox="0 0 128 128" className={cn("w-4 h-4", className)} {...props}>
|
||||
<defs>
|
||||
<linearGradient id="python-gradient-a" gradientUnits="userSpaceOnUse" x1="70.252" y1="1237.476" x2="170.659" y2="1151.089" gradientTransform="matrix(.563 0 0 -.568 -29.215 707.817)">
|
||||
<stop offset="0" stopColor={color || "#5A9FD4"}></stop>
|
||||
<stop offset="1" stopColor={color || "#306998"}></stop>
|
||||
</linearGradient>
|
||||
<linearGradient id="python-gradient-b" gradientUnits="userSpaceOnUse" x1="209.474" y1="1098.811" x2="173.62" y2="1149.537" gradientTransform="matrix(.563 0 0 -.568 -29.215 707.817)">
|
||||
<stop offset="0" stopColor={color || "#FFD43B"}></stop>
|
||||
<stop offset="1" stopColor={color || "#FFE873"}></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#python-gradient-a)" d="M63.391 1.988c-4.222.02-8.252.379-11.8 1.007-10.45 1.846-12.346 5.71-12.346 12.837v9.411h24.693v3.137H29.977c-7.176 0-13.46 4.313-15.426 12.521-2.268 9.405-2.368 15.275 0 25.096 1.755 7.311 5.947 12.519 13.124 12.519h8.491V67.234c0-8.151 7.051-15.34 15.426-15.34h24.665c6.866 0 12.346-5.654 12.346-12.548V15.833c0-6.693-5.646-11.72-12.346-12.837-4.244-.706-8.645-1.027-12.866-1.008zM50.037 9.557c2.55 0 4.634 2.117 4.634 4.721 0 2.593-2.083 4.69-4.634 4.69-2.56 0-4.633-2.097-4.633-4.69-.001-2.604 2.073-4.721 4.633-4.721z" transform="translate(0 10.26)"></path>
|
||||
<path fill="url(#python-gradient-b)" d="M91.682 28.38v10.966c0 8.5-7.208 15.655-15.426 15.655H51.591c-6.756 0-12.346 5.783-12.346 12.549v23.515c0 6.691 5.818 10.628 12.346 12.547 7.816 2.297 15.312 2.713 24.665 0 6.216-1.801 12.346-5.423 12.346-12.547v-9.412H63.938v-3.138h37.012c7.176 0 9.852-5.005 12.348-12.519 2.578-7.735 2.467-15.174 0-25.096-1.774-7.145-5.161-12.521-12.348-12.521h-9.268zM77.809 87.927c2.561 0 4.634 2.097 4.634 4.692 0 2.602-2.074 4.719-4.634 4.719-2.55 0-4.633-2.117-4.633-4.719 0-2.595 2.083-4.692 4.633-4.692z" transform="translate(0 10.26)"></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据文件扩展名返回对应的源文件图标
|
||||
* @param extension - 文件扩展名(如 'tsx', 'js', 'py' 等)
|
||||
* @param color - 图标颜色,传入 'currentColor' 可使用黑白线条风格
|
||||
* @param props - Lucide 图标属性
|
||||
* @returns 对应的图标组件
|
||||
*/
|
||||
export const SourceFileIcon = ({
|
||||
extension,
|
||||
className,
|
||||
color,
|
||||
...props
|
||||
}: LucideProps & { extension: string; color?: string }) => {
|
||||
const ext = extension.toLowerCase().replace(/^\./, ''); // 移除开头的点
|
||||
|
||||
switch (ext) {
|
||||
case 'tsx':
|
||||
case 'jsx':
|
||||
return <ReactIcon className={className} color={color} {...props} />;
|
||||
|
||||
case 'ts':
|
||||
return <TypeScriptIcon className={className} color={color} {...props} />;
|
||||
|
||||
case 'js':
|
||||
case 'mjs':
|
||||
case 'cjs':
|
||||
return <JavaScriptIcon className={className} color={color} {...props} />;
|
||||
|
||||
case 'md':
|
||||
case 'mdx':
|
||||
return <MarkdownIcon className={className} color={color} {...props} />;
|
||||
|
||||
case 'html':
|
||||
case 'htm':
|
||||
return <HtmlIcon className={className} color={color} {...props} />;
|
||||
|
||||
case 'py':
|
||||
case 'pyw':
|
||||
case 'pyi':
|
||||
return <PythonIcon className={className} color={color} {...props} />;
|
||||
|
||||
case 'json':
|
||||
case 'jsonc':
|
||||
return <FileJson className={cn("w-4 h-4", className)} {...props} />;
|
||||
|
||||
case 'css':
|
||||
case 'scss':
|
||||
case 'sass':
|
||||
case 'less':
|
||||
return <Palette className={cn("w-4 h-4", className)} {...props} />;
|
||||
|
||||
case 'sql':
|
||||
case 'mysql':
|
||||
case 'pgsql':
|
||||
case 'psql':
|
||||
case 'plsql':
|
||||
case 'sqlite':
|
||||
case 'db':
|
||||
return <Database className={cn("w-4 h-4", className)} {...props} />;
|
||||
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'png':
|
||||
case 'gif':
|
||||
case 'bmp':
|
||||
case 'webp':
|
||||
case 'svg':
|
||||
case 'ico':
|
||||
case 'tiff':
|
||||
case 'tif':
|
||||
case 'avif':
|
||||
return <ImageIcon className={cn("w-4 h-4", className)} {...props} />;
|
||||
|
||||
default:
|
||||
return <FileCode2 className={cn("w-4 h-4", className)} {...props} />;
|
||||
}
|
||||
};
|
||||
169
src/components/icons/pku.tsx
Normal file
169
src/components/icons/pku.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
// 北京大学图标 https://www.pku.edu.cn/detail/1096.html
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LucideProps } from "lucide-react";
|
||||
|
||||
export const PkuIcon = ({ className, color = "#94070A", ...props }: LucideProps & { color?: string }) => (
|
||||
<svg viewBox="0 0 3600 3600" className={cn("w-4 h-4", className)} fill={color} stroke="none" {...props}>
|
||||
<g transform="scale(1, -1) translate(0, -3600)">
|
||||
<path d="M1561 3584 l-103 -14 -98 -25 -99 -25 -83 -31 -83 -30 -94 -46 -93
|
||||
-46 -87 -57 -86 -56 -60 -49 -60 -48 -68 -66 -69 -66 -62 -75 -63 -75 -63 -95
|
||||
-62 -95 -48 -100 -48 -100 -26 -73 -26 -72 -21 -83 -21 -82 -14 -80 -15 -80
|
||||
-6 -140 -6 -140 12 -110 12 -110 21 -95 20 -95 29 -90 30 -90 59 -123 59 -123
|
||||
54 -82 53 -82 77 -92 77 -92 68 -62 67 -61 75 -58 75 -57 70 -43 70 -43 100
|
||||
-48 100 -48 105 -35 105 -35 102 -21 102 -20 130 -11 131 -12 131 12 130 11
|
||||
102 20 102 21 105 35 105 35 100 48 100 48 70 43 70 43 75 57 75 58 67 61 68
|
||||
62 77 92 77 92 53 82 54 82 59 123 59 123 30 90 29 90 20 95 21 95 12 110 12
|
||||
110 -6 140 -6 140 -15 80 -14 80 -21 82 -21 83 -26 72 -26 73 -48 100 -48 100
|
||||
-62 95 -63 95 -63 75 -62 75 -69 66 -68 66 -60 48 -60 49 -86 56 -87 57 -93
|
||||
46 -94 46 -83 30 -83 31 -97 25 -97 24 -118 16 -118 15 -122 -1 -122 -1 -104
|
||||
-14z m429 -54 l85 -10 80 -16 80 -16 95 -30 95 -30 115 -54 114 -55 111 -74
|
||||
110 -74 90 -79 90 -79 74 -90 73 -90 49 -74 50 -74 53 -105 54 -105 32 -90 31
|
||||
-90 20 -84 20 -83 14 -107 15 -106 0 -112 0 -112 -15 -114 -15 -115 -25 -98
|
||||
-26 -98 -40 -100 -39 -101 -48 -88 -47 -87 -65 -92 -65 -92 -100 -105 -100
|
||||
-104 -90 -70 -90 -69 -93 -55 -94 -55 -87 -38 -87 -38 -98 -31 -99 -32 -116
|
||||
-21 -116 -21 -185 0 -185 0 -116 21 -116 21 -99 32 -98 31 -87 38 -87 38 -94
|
||||
55 -93 55 -90 69 -90 70 -100 104 -100 105 -65 92 -65 92 -47 87 -48 88 -39
|
||||
101 -40 100 -26 98 -25 98 -15 115 -15 114 0 112 0 112 15 106 14 107 20 83
|
||||
20 84 31 90 32 90 54 105 53 105 50 74 49 74 73 90 74 90 90 79 90 79 110 74
|
||||
111 74 114 54 115 54 80 27 80 26 70 16 70 16 95 13 95 13 120 1 121 1 84 -10z"/>
|
||||
<path d="M1706 3425 l26 -26 -6 -15 -6 -16 0 -65 0 -65 -20 -5 -20 -5 0 -9 0
|
||||
-9 50 0 50 0 0 5 0 5 -20 20 -20 20 0 65 0 65 4 0 4 0 71 -90 71 -90 15 0 15
|
||||
0 0 94 0 94 6 16 6 16 14 0 14 0 0 10 0 10 -52 0 -53 0 23 -18 23 -18 -3 -59
|
||||
-3 -60 -60 78 -59 77 -48 0 -47 0 25 -25z"/>
|
||||
<path d="M1453 3417 l-43 -10 0 -8 0 -9 19 0 19 0 9 -12 10 -13 15 -61 15 -60
|
||||
-8 -21 -8 -22 -21 -11 -22 -12 -22 6 -23 6 -11 24 -11 25 -16 62 -16 61 18 14
|
||||
18 13 -8 1 -8 0 -47 -11 -47 -12 -10 -8 -9 -9 17 0 16 0 15 -52 15 -53 6 -27
|
||||
7 -27 19 -17 19 -17 45 1 46 0 25 17 25 16 9 24 9 24 -20 78 -20 77 15 12 16
|
||||
11 0 7 0 6 -7 -1 -8 -1 -42 -11z"/>
|
||||
<path d="M2163 3392 l17 -18 -21 -75 -20 -74 -8 -15 -8 -15 -12 -1 -11 -1 -7
|
||||
-2 -8 -2 65 -19 65 -18 4 4 5 4 -17 13 -16 12 22 90 23 89 25 6 24 7 -57 16
|
||||
-56 17 -13 0 -12 0 16 -18z"/>
|
||||
<path d="M2450 3305 l13 -15 -7 -98 -7 -97 -5 -21 -5 -21 16 -7 16 -6 92 76
|
||||
92 75 18 -5 17 -4 0 6 0 7 -41 23 -41 24 -6 -6 -6 -6 17 -13 17 -12 -8 -10 -7
|
||||
-10 -49 -43 -49 -42 -6 0 -7 0 7 68 8 67 0 13 1 12 23 0 22 0 -54 30 -54 30
|
||||
-10 0 -9 0 12 -15z"/>
|
||||
<path d="M830 3130 l0 -10 -14 0 -14 0 -33 -27 -32 -27 -13 -32 -14 -33 7 -31
|
||||
6 -32 32 -34 32 -34 42 0 42 0 29 15 29 15 25 33 26 34 0 15 0 16 -16 13 -16
|
||||
13 5 23 4 23 -6 0 -6 -1 -45 -40 -45 -40 21 6 20 5 24 -25 23 -25 -23 -25 -23
|
||||
-25 -19 0 -18 0 -39 35 -39 35 -12 29 -12 29 4 23 3 23 27 15 27 15 35 -17 36
|
||||
-17 0 13 0 14 -23 21 -23 22 -7 0 -7 0 0 -10z"/>
|
||||
<path d="M1595 3116 l-80 -13 -75 -21 -75 -21 -88 -36 -88 -37 -89 -57 -90
|
||||
-56 -75 -63 -75 -63 -68 -79 -67 -79 -54 -85 -53 -85 -38 -89 -39 -89 -25 -94
|
||||
-25 -94 -12 -90 -12 -90 6 -130 5 -130 16 -75 16 -75 26 -80 27 -80 43 -88 43
|
||||
-88 45 -67 44 -67 59 -67 59 -66 49 -45 50 -44 68 -51 68 -51 97 -49 97 -50
|
||||
73 -26 72 -26 103 -22 102 -22 165 0 165 0 102 22 103 22 72 26 73 27 80 40
|
||||
80 40 70 47 70 47 65 58 65 58 58 66 59 66 43 65 43 65 43 85 42 85 28 82 28
|
||||
83 18 82 18 83 0 175 0 175 -18 82 -17 81 -26 78 -25 77 -35 75 -34 76 -67
|
||||
100 -67 101 -96 95 -95 96 -101 67 -100 67 -76 34 -75 35 -77 25 -76 25 -73
|
||||
16 -72 16 -160 4 -160 4 -80 -13z m465 -50 l65 -15 69 -21 68 -21 75 -35 75
|
||||
-34 101 -66 101 -66 97 -97 97 -97 66 -101 66 -101 35 -76 34 -75 25 -83 25
|
||||
-83 17 -100 16 -100 -5 -135 -5 -135 -17 -80 -17 -80 -24 -74 -25 -74 -45 -91
|
||||
-46 -90 -51 -75 -52 -75 -80 -82 -80 -81 -81 -60 -81 -59 -90 -47 -90 -46 -89
|
||||
-30 -89 -30 -85 -18 -85 -17 -155 0 -155 0 -85 17 -85 18 -89 30 -89 30 -90
|
||||
46 -90 47 -81 59 -81 60 -80 81 -80 82 -52 75 -51 75 -46 90 -45 91 -25 74
|
||||
-25 74 -18 85 -18 85 0 170 0 170 13 60 13 60 25 83 25 83 34 75 35 76 66 101
|
||||
66 101 97 97 97 97 101 66 101 66 76 35 75 34 83 25 83 25 60 10 60 11 25 4
|
||||
25 5 160 -4 160 -4 65 -15z"/>
|
||||
<path d="M1555 3000 l-90 -19 -75 -27 -75 -26 -60 -30 -60 -30 -84 -56 -84
|
||||
-55 -92 -92 -92 -92 -56 -85 -57 -85 -39 -84 -40 -84 -25 -86 -24 -86 -13 -83
|
||||
-12 -82 6 -152 5 -151 26 -96 25 -96 40 -91 39 -91 56 -86 56 -86 87 -90 88
|
||||
-91 65 -49 65 -49 96 -50 96 -49 89 -30 90 -30 94 -16 95 -15 105 0 105 0 95
|
||||
15 94 16 90 30 89 30 96 49 96 50 65 49 65 49 88 91 87 90 56 86 56 86 39 91
|
||||
40 91 26 96 27 96 0 205 0 205 -26 96 -26 95 -32 77 -32 77 -52 85 -51 85 -63
|
||||
75 -63 74 -74 63 -75 63 -85 51 -85 52 -77 32 -77 32 -95 25 -96 26 -180 3
|
||||
-180 4 -90 -20z m135 -205 l25 -25 0 -340 0 -339 -21 -22 -21 -21 -89 -29 -89
|
||||
-30 -67 -40 -68 -40 -75 -61 -76 -61 -69 -77 -68 -78 -56 -80 -56 -79 -12 -10
|
||||
-13 -10 -23 -6 -23 -5 -29 10 -29 9 -15 24 -16 24 0 29 0 28 51 75 51 74 54
|
||||
66 54 67 79 75 78 74 74 53 74 52 89 44 90 44 13 0 13 0 0 111 0 110 -22 -6
|
||||
-23 -7 -81 -32 -81 -32 -75 -43 -75 -43 -93 -69 -93 -69 -31 0 -32 0 -21 15
|
||||
-21 15 -12 34 -11 35 15 30 16 30 57 44 58 44 80 53 80 52 90 43 90 43 92 29
|
||||
91 28 4 81 3 82 20 22 20 22 20 9 20 8 30 -4 30 -5 25 -25z m360 0 l25 -25 3
|
||||
-83 4 -82 91 -28 92 -29 90 -43 90 -43 80 -52 80 -53 58 -44 57 -44 16 -30 15
|
||||
-30 -11 -35 -12 -34 -21 -15 -21 -15 -32 0 -31 0 -93 69 -93 69 -75 43 -75 43
|
||||
-81 32 -81 32 -22 7 -23 6 0 -110 0 -111 13 0 13 0 90 -44 89 -44 74 -52 74
|
||||
-53 78 -74 79 -75 54 -67 54 -66 51 -74 51 -75 0 -28 0 -29 -16 -24 -15 -24
|
||||
-29 -9 -29 -10 -23 5 -23 6 -13 10 -12 10 -56 79 -56 80 -68 78 -69 77 -76 61
|
||||
-75 61 -68 40 -67 40 -89 30 -89 29 -21 21 -21 22 0 338 0 339 20 22 20 22 20
|
||||
9 20 8 30 -4 30 -5 25 -25z m-199 -815 l19 -11 15 -29 15 -29 0 -35 0 -35 57
|
||||
-16 57 -17 56 -28 57 -29 72 -58 73 -58 65 -80 65 -80 58 -98 58 -97 27 -65
|
||||
27 -64 -6 -26 -7 -27 -24 -24 -24 -24 -31 0 -31 0 -19 10 -19 10 -62 121 -61
|
||||
120 -58 77 -57 77 -54 51 -55 51 -39 25 -40 24 -42 17 -43 17 0 -91 0 -90 10
|
||||
-20 11 -19 31 -16 32 -16 53 -51 53 -50 40 -63 40 -64 21 -52 21 -53 19 -65
|
||||
19 -65 -6 -27 -6 -28 -25 -20 -26 -20 -37 0 -37 0 -21 23 -21 22 -30 95 -30
|
||||
95 -32 50 -31 50 -27 28 -26 29 -37 19 -37 19 -21 0 -21 0 -37 -19 -37 -19
|
||||
-26 -29 -27 -28 -31 -50 -32 -50 -30 -95 -30 -95 -21 -22 -21 -23 -37 0 -37 0
|
||||
-26 20 -25 20 -6 28 -6 27 19 65 19 65 21 53 21 52 40 64 40 63 53 50 53 51
|
||||
32 16 31 16 11 19 10 20 0 90 0 91 -42 -17 -43 -17 -40 -24 -39 -25 -55 -51
|
||||
-54 -51 -57 -77 -58 -77 -61 -120 -62 -121 -19 -10 -19 -10 -31 0 -31 0 -24
|
||||
24 -24 24 -7 27 -6 26 27 64 27 65 58 97 58 98 65 80 65 80 73 58 72 58 57 29
|
||||
56 28 57 17 57 16 0 35 0 35 15 29 15 29 17 10 18 10 33 1 33 0 20 -10z"/>
|
||||
<path d="M2829 3071 l11 -19 -65 -70 -65 -70 -22 6 -22 5 84 -77 83 -76 24 22
|
||||
23 21 0 8 0 8 -30 -10 -29 -11 -31 22 -30 21 0 10 0 10 26 35 27 35 19 -18 19
|
||||
-17 -6 -23 -5 -23 9 0 9 0 28 37 27 36 -23 -6 -24 -6 -18 16 -18 16 27 29 27
|
||||
28 8 0 8 0 20 -20 20 -20 0 -32 0 -32 23 23 22 23 -75 69 -75 69 -8 0 -8 0 10
|
||||
-19z"/>
|
||||
<path d="M507 2830 l-28 -40 30 6 30 6 47 -33 47 -34 -34 -2 -34 -3 -70 -6
|
||||
-70 -5 -23 -34 -22 -35 0 -5 0 -5 25 12 24 11 68 -50 68 -49 -1 -22 -1 -22 8
|
||||
0 8 0 25 37 25 37 -4 4 -5 5 -15 -13 -15 -12 -49 31 -49 32 -11 13 -11 13 112
|
||||
7 113 7 10 10 10 11 -24 16 -24 16 -61 46 -61 46 1 22 1 22 -6 0 -6 0 -28 -40z"/>
|
||||
<path d="M3113 2782 l5 -23 -76 -50 -76 -50 -23 6 -22 5 31 -50 31 -50 7 0 8
|
||||
0 -2 16 -1 16 27 21 28 21 6 -7 7 -7 -22 -56 -21 -56 25 -41 25 -42 0 5 1 5 3
|
||||
35 3 35 16 38 16 38 22 -11 21 -12 24 6 25 7 11 24 11 24 -36 64 -36 64 -21
|
||||
24 -22 24 5 -23z m57 -92 l11 -20 -11 -19 -10 -20 -20 -6 -19 -6 -21 11 -20
|
||||
11 0 9 0 9 37 26 38 25 2 0 2 0 11 -20z"/>
|
||||
<path d="M283 2450 l-11 -25 -15 -37 -16 -38 9 0 9 0 7 11 6 11 27 -5 26 -6
|
||||
69 -30 68 -31 -6 -15 -5 -15 8 0 8 0 22 56 21 57 -3 4 -4 3 -12 -14 -12 -14
|
||||
-89 36 -89 35 -4 21 -3 21 -11 -25z"/>
|
||||
<path d="M3263 2388 l-22 -11 -10 -30 -11 -30 0 -43 0 -43 -20 -6 -21 -7 -14
|
||||
12 -15 12 0 33 0 32 21 27 21 27 -5 5 -5 5 -38 -16 -38 -15 12 -12 12 -12 0
|
||||
-37 0 -36 21 -27 22 -27 24 -10 24 -9 22 11 22 12 7 11 8 11 0 42 0 42 10 25
|
||||
10 26 14 0 15 0 15 -16 16 -15 0 -18 0 -18 -26 -28 -25 -27 5 -5 5 -5 35 13
|
||||
35 13 -5 25 -6 26 -10 32 -9 33 -26 20 -26 20 -11 -1 -11 0 -22 -11z"/>
|
||||
<path d="M187 2160 l-5 -15 -6 -37 -5 -38 10 0 9 0 0 13 1 12 39 -44 40 -44 0
|
||||
-8 0 -9 -14 0 -14 0 -31 6 -31 7 0 21 -1 21 -8 -15 -8 -15 -7 -55 -6 -55 14
|
||||
18 14 18 92 -13 92 -12 8 -20 8 -21 5 15 6 15 6 50 7 50 -16 -15 -16 -16 -40
|
||||
4 -40 4 0 7 0 6 59 33 59 32 6 23 6 22 0 25 -1 25 -12 -17 -13 -16 -59 -36
|
||||
-60 -36 -10 0 -10 0 -25 37 -25 36 -6 26 -6 26 -6 -15z"/>
|
||||
<path d="M3420 2028 l0 -17 -10 -6 -9 -6 -88 -10 -88 -10 -12 18 -13 17 1 -25
|
||||
0 -24 8 -45 7 -45 7 22 8 21 37 6 37 5 58 6 57 6 6 -15 6 -16 7 0 8 0 -5 58
|
||||
-4 57 -9 10 -9 9 0 -16z"/>
|
||||
<path d="M340 1739 l-35 -6 13 -2 13 -1 24 -25 25 -24 0 -29 0 -30 -16 -6 -16
|
||||
-6 -34 0 -34 0 0 17 0 17 23 24 22 25 -34 -7 -34 -6 -21 0 -21 -1 28 -20 27
|
||||
-21 0 -18 0 -18 -45 -4 -44 -3 -7 29 -6 28 21 29 22 29 -32 0 -32 0 6 -37 6
|
||||
-38 7 -67 7 -68 8 0 8 0 3 18 3 17 93 11 92 10 16 -20 15 -21 -5 40 -5 40 -7
|
||||
73 -7 73 -6 2 -6 3 -35 -7z"/>
|
||||
<path d="M3380 1725 l0 -7 25 -20 25 -19 0 -21 0 -20 -67 6 -68 7 -34 6 -34 6
|
||||
-5 21 -6 21 -7 -30 -7 -30 -1 -39 -1 -40 13 18 12 17 95 -11 95 -12 3 -3 4 -3
|
||||
-6 -20 -6 -19 -28 -13 -27 -12 20 -9 20 -8 15 0 16 -1 12 111 13 112 -3 2 -3
|
||||
3 -32 7 -33 7 0 -7z"/>
|
||||
<path d="M3123 1307 l-23 -59 0 -10 0 -11 18 16 17 15 31 -13 32 -13 41 -55
|
||||
41 -54 0 -16 0 -17 8 0 8 0 12 33 12 33 0 8 0 8 -15 -12 -16 -13 -29 34 -29
|
||||
34 -1 13 0 12 19 0 19 0 31 6 31 7 0 -24 0 -23 9 10 9 9 21 58 21 57 -6 0 -5
|
||||
0 -14 -13 -13 -14 -71 -12 -71 -11 -30 15 -29 15 -3 22 -3 23 -22 -58z"/>
|
||||
<path d="M270 1337 l-25 -12 -3 -28 -3 -29 27 -74 27 -74 8 0 9 0 0 18 0 17
|
||||
87 33 87 32 10 -10 10 -10 7 0 7 0 -21 55 -20 55 -8 0 -9 0 0 -15 0 -14 -30
|
||||
-16 -31 -16 -9 6 -10 6 0 12 0 12 -23 27 -22 26 -20 6 -20 6 -25 -13z m84 -63
|
||||
l16 -15 0 -9 0 -8 -40 -16 -40 -16 -10 0 -10 0 0 24 0 25 22 15 22 16 13 0 12
|
||||
0 15 -16z"/>
|
||||
<path d="M1130 505 l0 -14 29 -3 29 -3 -39 -83 -38 -83 -28 7 -28 7 10 -10 10
|
||||
-10 58 -26 57 -26 0 8 0 9 -15 6 -15 5 0 12 0 12 45 95 45 95 -2 2 -3 2 -57 6
|
||||
-58 6 0 -14z"/>
|
||||
<path d="M2339 491 l-29 -29 0 -21 0 -21 19 -19 19 -18 12 -5 12 -4 -13 -26
|
||||
-12 -27 17 -26 16 -25 41 0 41 0 29 29 29 29 0 21 0 20 -10 25 -9 25 -25 7
|
||||
-25 6 5 27 6 28 -17 16 -16 17 -30 0 -31 0 -29 -29z m72 -1 l19 -11 0 -25 0
|
||||
-25 -37 3 -38 3 -4 19 -3 20 18 13 18 13 4 0 3 0 20 -10z m57 -122 l12 -12 0
|
||||
-23 0 -23 -24 -11 -23 -11 -22 11 -21 12 0 34 0 35 33 0 33 0 12 -12z"/>
|
||||
<path d="M1562 400 l-22 -9 -12 -27 -12 -26 16 -29 17 -28 -20 -6 -19 -6 -6
|
||||
-25 -7 -25 11 -24 11 -24 38 -10 37 -11 35 18 35 17 9 28 9 29 -19 26 -19 27
|
||||
18 18 18 17 0 18 0 19 -18 16 -18 17 -30 5 -29 4 -23 -9z m68 -28 l23 -18 -12
|
||||
-22 -12 -22 -10 0 -10 0 -20 10 -19 11 0 24 0 23 13 5 12 5 6 1 7 1 22 -18z
|
||||
m-20 -120 l23 -19 -6 -25 -7 -26 -14 -5 -14 -6 -21 6 -20 5 -7 20 -6 19 6 25
|
||||
6 24 19 0 19 0 22 -18z"/>
|
||||
<path d="M1980 403 l-15 -6 -22 -20 -23 -20 0 -37 0 -37 18 -16 19 -17 31 0
|
||||
31 0 6 10 6 10 10 0 10 0 -6 -22 -6 -23 -11 -22 -11 -22 -31 -12 -31 -12 18
|
||||
-4 17 -5 32 19 31 18 20 27 20 27 1 43 1 43 -13 31 -13 31 -25 12 -25 11 -12
|
||||
-1 -12 -1 -15 -5z m44 -35 l16 -22 0 -28 0 -27 -22 -7 -22 -7 -18 23 -18 22 0
|
||||
15 0 14 10 20 11 19 14 0 14 0 15 -22z"/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
157
src/components/layout/app-sidebar.tsx
Normal file
157
src/components/layout/app-sidebar.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { Package, ChevronRight } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarRail,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import type { MenuItem } from '@/constants/menu'
|
||||
import { getMenuIcon } from '@/constants/menu-icons'
|
||||
import { SITE_NAME, SITE_VERSION } from '@/constants/site'
|
||||
|
||||
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||
menuItems: MenuItem[]
|
||||
}
|
||||
|
||||
export function AppSidebar({ menuItems, ...props }: AppSidebarProps) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const { isMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
// 移动端点击菜单项后关闭侧边栏,延迟导航避免闪动
|
||||
const handleMenuItemClick = React.useCallback((e: React.MouseEvent<HTMLAnchorElement>, href: string) => {
|
||||
if (isMobile) {
|
||||
e.preventDefault() // 阻止默认跳转
|
||||
setOpenMobile(false) // 先关闭侧边栏
|
||||
// 等待侧边栏关闭动画完成后再导航
|
||||
setTimeout(() => {
|
||||
router.push(href)
|
||||
}, 300)
|
||||
}
|
||||
}, [isMobile, setOpenMobile, router])
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" {...props}>
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<Link href="/">
|
||||
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
|
||||
<Package className="size-4" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{SITE_NAME}</span>
|
||||
<span className="truncate text-xs">{SITE_VERSION}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarMenu className="gap-2">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = getMenuIcon(item.icon)
|
||||
const isActive = pathname === item.href || pathname.startsWith(item.href + "/")
|
||||
const hasActiveChild = item.children?.some(
|
||||
(child) => pathname === child.href || pathname.startsWith(item.href + "/")
|
||||
)
|
||||
|
||||
// 如果有子菜单,使用 Collapsible
|
||||
if (item.children?.length) {
|
||||
return (
|
||||
<Collapsible
|
||||
key={item.title}
|
||||
asChild
|
||||
defaultOpen={hasActiveChild}
|
||||
className="group/collapsible"
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
isActive={isActive || hasActiveChild}
|
||||
>
|
||||
{Icon && <Icon />}
|
||||
<span className="truncate">{item.title}</span>
|
||||
{item.badge && (
|
||||
<Badge variant="secondary" className="ml-auto shrink-0">
|
||||
{item.badge}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub className="mt-1">
|
||||
{item.children.map((child) => {
|
||||
const ChildIcon = getMenuIcon(child.icon)
|
||||
const childIsActive = pathname === child.href || pathname.startsWith(child.href + "/")
|
||||
return (
|
||||
<SidebarMenuSubItem key={child.title}>
|
||||
<SidebarMenuSubButton asChild isActive={childIsActive}>
|
||||
<Link href={child.href || '#'} onClick={(e) => handleMenuItemClick(e, child.href || '#')}>
|
||||
{ChildIcon && <ChildIcon className="h-4 w-4" />}
|
||||
<span className="truncate">{child.title}</span>
|
||||
{child.badge && (
|
||||
<Badge variant="secondary" className="ml-auto shrink-0">
|
||||
{child.badge}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
// 没有子菜单的普通菜单项
|
||||
return (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild isActive={isActive} tooltip={item.title}>
|
||||
<Link href={item.href || '#'} onClick={(e) => handleMenuItemClick(e, item.href || '#')}>
|
||||
{Icon && <Icon />}
|
||||
<span className="truncate">{item.title}</span>
|
||||
{item.badge && (
|
||||
<Badge variant="secondary" className="ml-auto shrink-0">
|
||||
{item.badge}
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
89
src/components/layout/breadcrumb-nav.tsx
Normal file
89
src/components/layout/breadcrumb-nav.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { menuItems, MenuItem } from '@/constants/menu'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb'
|
||||
|
||||
export function BreadcrumbNav() {
|
||||
const pathname = usePathname()
|
||||
|
||||
// 将 useMemo 移到条件判断之前,确保所有 Hooks 都被调用
|
||||
const breadcrumbs = useMemo(() => {
|
||||
// 错误页面返回空数组
|
||||
if (pathname.startsWith('/error')) {
|
||||
return []
|
||||
}
|
||||
|
||||
const segments = pathname.split('/').filter(Boolean)
|
||||
const crumbs = [{ title: '首页', href: '/' }]
|
||||
|
||||
let accumulated = ''
|
||||
segments.forEach(segment => {
|
||||
accumulated += `/${segment}`
|
||||
|
||||
// 递归查找匹配的菜单项并返回完整路径(支持无限级菜单)
|
||||
const findMenuPath = (items: MenuItem[], currentPath: string, ancestors: MenuItem[] = []): MenuItem[] | null => {
|
||||
for (const item of items) {
|
||||
if (item.href === currentPath) {
|
||||
return [...ancestors, item]
|
||||
}
|
||||
if (item.children) {
|
||||
const result = findMenuPath(item.children, currentPath, [...ancestors, item])
|
||||
if (result) return result
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const menuPath = findMenuPath(menuItems, accumulated)
|
||||
|
||||
// 添加路径中的所有菜单项(排除首页和已添加的项)
|
||||
if (menuPath) {
|
||||
menuPath.forEach(item => {
|
||||
if (item.href && item.href !== '/' && !crumbs.find(c => c.href === item.href)) {
|
||||
crumbs.push({ title: item.title, href: item.href })
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return crumbs
|
||||
}, [pathname])
|
||||
|
||||
// 错误页面和首页不显示面包屑
|
||||
if (breadcrumbs.length === 0 || pathname == "/") {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{breadcrumbs.map((crumb, idx) => (
|
||||
<div key={crumb.href} className="flex items-center">
|
||||
<BreadcrumbItem>
|
||||
{idx === breadcrumbs.length - 1 ? (
|
||||
<BreadcrumbPage>{crumb.title}</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href={crumb.href}>{crumb.title}</Link>
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{idx < breadcrumbs.length - 1 && <BreadcrumbSeparator />}
|
||||
</div>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
176
src/components/layout/carousel-layout.tsx
Normal file
176
src/components/layout/carousel-layout.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, ReactNode } from 'react'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import useEmblaCarousel from 'embla-carousel-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface CarouselColumn {
|
||||
/** 列的唯一标识 */
|
||||
id: string
|
||||
/** 列的标题(移动端导航栏显示) */
|
||||
title: string
|
||||
/** 列的内容 */
|
||||
content: ReactNode
|
||||
/** 桌面端列的类名(用于自定义宽度等) */
|
||||
desktopClassName?: string
|
||||
/** 移动端列的类名 */
|
||||
mobileClassName?: string
|
||||
}
|
||||
|
||||
export interface CarouselLayoutProps {
|
||||
/** 列配置数组 */
|
||||
columns: CarouselColumn[]
|
||||
/** 默认激活的列索引(移动端) */
|
||||
defaultActiveIndex?: number
|
||||
/** 桌面端容器类名 */
|
||||
desktopContainerClassName?: string
|
||||
/** 移动端容器类名 */
|
||||
mobileContainerClassName?: string
|
||||
/** 是否显示移动端导航栏 */
|
||||
showMobileNav?: boolean
|
||||
/** 移动端导航栏类名 */
|
||||
mobileNavClassName?: string
|
||||
/** 桌面端是否显示分隔线 */
|
||||
showDesktopDivider?: boolean
|
||||
/** 容器类名 */
|
||||
className?: string
|
||||
/** 激活列变化回调 */
|
||||
onActiveIndexChange?: (index: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用的 Carousel 布局组件
|
||||
*
|
||||
* 在桌面端显示多栏并排布局,在移动端通过拖拽左右切换栏目
|
||||
* 支持任意数量的列,灵活配置每列的样式
|
||||
*/
|
||||
export function CarouselLayout({
|
||||
columns,
|
||||
defaultActiveIndex = 0,
|
||||
desktopContainerClassName,
|
||||
mobileContainerClassName = 'h-full',
|
||||
showMobileNav = true,
|
||||
mobileNavClassName,
|
||||
showDesktopDivider = false,
|
||||
className,
|
||||
onActiveIndexChange,
|
||||
}: CarouselLayoutProps) {
|
||||
const [activeIndex, setActiveIndex] = useState(defaultActiveIndex)
|
||||
|
||||
// 使用 Embla Carousel 实现流畅的拖拽
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||
startIndex: defaultActiveIndex,
|
||||
loop: false,
|
||||
axis: 'x',
|
||||
dragFree: false,
|
||||
containScroll: 'trimSnaps',
|
||||
})
|
||||
|
||||
// 监听 Embla 的选择变化
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return
|
||||
|
||||
const onSelect = () => {
|
||||
const newIndex = emblaApi.selectedScrollSnap()
|
||||
setActiveIndex(newIndex)
|
||||
onActiveIndexChange?.(newIndex)
|
||||
}
|
||||
|
||||
emblaApi.on('select', onSelect)
|
||||
onSelect() // 初始化
|
||||
|
||||
return () => {
|
||||
emblaApi.off('select', onSelect)
|
||||
}
|
||||
}, [emblaApi, onActiveIndexChange])
|
||||
|
||||
// 导航到上一栏
|
||||
const navigatePrev = () => {
|
||||
emblaApi?.scrollPrev()
|
||||
}
|
||||
|
||||
// 导航到下一栏
|
||||
const navigateNext = () => {
|
||||
emblaApi?.scrollNext()
|
||||
}
|
||||
|
||||
// 判断是否可以滚动
|
||||
const canScrollPrev = activeIndex > 0
|
||||
const canScrollNext = activeIndex < columns.length - 1
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col', className)}>
|
||||
{/* 移动端导航按钮 */}
|
||||
{showMobileNav && (
|
||||
<div className={cn(
|
||||
'flex md:hidden items-center justify-between p-2 border-b',
|
||||
mobileNavClassName
|
||||
)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={navigatePrev}
|
||||
disabled={!canScrollPrev}
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<span className="text-sm font-medium">
|
||||
{columns[activeIndex]?.title}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={navigateNext}
|
||||
disabled={!canScrollNext}
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{/* 桌面端:多栏并排 */}
|
||||
<div className={cn(
|
||||
'hidden md:flex h-full',
|
||||
showDesktopDivider && 'divide-x',
|
||||
desktopContainerClassName
|
||||
)}>
|
||||
{columns.map((column) => (
|
||||
<div
|
||||
key={column.id}
|
||||
className={cn(
|
||||
'h-full overflow-y-auto',
|
||||
column.desktopClassName
|
||||
)}
|
||||
>
|
||||
{column.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 移动端:使用 Embla Carousel 实现流畅拖拽 */}
|
||||
<div className={cn(
|
||||
'md:hidden overflow-hidden',
|
||||
mobileContainerClassName
|
||||
)} ref={emblaRef}>
|
||||
<div className="flex h-full">
|
||||
{columns.map((column) => (
|
||||
<div
|
||||
key={column.id}
|
||||
className={cn(
|
||||
'flex-[0_0_100%] min-w-0 h-full overflow-y-auto',
|
||||
column.mobileClassName
|
||||
)}
|
||||
>
|
||||
{column.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
99
src/components/layout/change-password-dialog.tsx
Normal file
99
src/components/layout/change-password-dialog.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { changePasswordSchema, type ChangePasswordInput } from '@/lib/schema/user'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { toast } from 'sonner'
|
||||
import { FormDialog, FormActionBar, FormGridContent, FormCancelAction, FormSubmitAction, type FormFieldConfig } from '@/components/common/form-dialog'
|
||||
|
||||
const changePasswordDefaultValues: ChangePasswordInput = {
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
}
|
||||
|
||||
interface ChangePasswordDialogProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ChangePasswordDialog({
|
||||
isOpen,
|
||||
onClose
|
||||
}: ChangePasswordDialogProps) {
|
||||
// react-hook-form 管理表单
|
||||
const form = useForm<ChangePasswordInput>({
|
||||
resolver: zodResolver(changePasswordSchema),
|
||||
defaultValues: changePasswordDefaultValues,
|
||||
})
|
||||
|
||||
// 修改密码 mutation
|
||||
const changePasswordMutation = trpc.users.changePassword.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('密码修改成功')
|
||||
form.reset(changePasswordDefaultValues)
|
||||
onClose()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || '密码修改失败')
|
||||
},
|
||||
})
|
||||
|
||||
// 定义字段配置
|
||||
const formFields: FormFieldConfig[] = React.useMemo(() => [
|
||||
{
|
||||
name: 'oldPassword',
|
||||
label: '旧密码',
|
||||
required: true,
|
||||
render: ({ field }) => (
|
||||
<Input {...field} type="password" placeholder="请输入旧密码" />
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'newPassword',
|
||||
label: '新密码',
|
||||
required: true,
|
||||
render: ({ field }) => (
|
||||
<Input {...field} type="password" placeholder="请输入新密码(至少6个字符)" />
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'confirmPassword',
|
||||
label: '确认新密码',
|
||||
required: true,
|
||||
render: ({ field }) => (
|
||||
<Input {...field} type="password" placeholder="请再次输入新密码" />
|
||||
),
|
||||
},
|
||||
], [])
|
||||
|
||||
const handleSubmit = async (data: ChangePasswordInput) => {
|
||||
changePasswordMutation.mutate(data)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
form.reset(changePasswordDefaultValues)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<FormDialog
|
||||
isOpen={isOpen}
|
||||
title="修改密码"
|
||||
description="请输入旧密码和新密码。新密码至少6个字符。"
|
||||
form={form}
|
||||
fields={formFields}
|
||||
onClose={handleClose}
|
||||
className="md:max-w-md"
|
||||
>
|
||||
<FormGridContent />
|
||||
<FormActionBar>
|
||||
<FormCancelAction />
|
||||
<FormSubmitAction onSubmit={handleSubmit} isSubmitting={changePasswordMutation.isPending}>修改密码</FormSubmitAction>
|
||||
</FormActionBar>
|
||||
</FormDialog>
|
||||
)
|
||||
}
|
||||
134
src/components/layout/header.tsx
Normal file
134
src/components/layout/header.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { User, LogOut, KeyRound } from 'lucide-react'
|
||||
import { DevPanel } from '@/app/(main)/dev/panel'
|
||||
import { ChangePasswordDialog } from '@/components/layout/change-password-dialog'
|
||||
import { UserProfileDialog } from '@/components/layout/user-profile-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar'
|
||||
import { signOut } from 'next-auth/react'
|
||||
import { useRouter, usePathname } from 'next/navigation'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { ThemeToggleButton, useThemeTransition } from '@/components/common/theme-toggle-button'
|
||||
import { getMenuTitle } from '@/constants/menu'
|
||||
import type { User as AppUser } from '@/types/user'
|
||||
|
||||
interface HeaderProps {
|
||||
user?: AppUser
|
||||
}
|
||||
|
||||
export function Header({ user }: HeaderProps) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const { startTransition } = useThemeTransition()
|
||||
const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false)
|
||||
const [isUserProfileOpen, setIsUserProfileOpen] = useState(false)
|
||||
|
||||
const pageTitle = getMenuTitle(pathname, 2) // 只匹配到第二级菜单
|
||||
|
||||
const handleThemeToggle = () => {
|
||||
startTransition(() => {
|
||||
setTheme(theme === 'dark' ? 'light' : 'dark')
|
||||
})
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await signOut({ redirect: false })
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
// 如果没有用户信息,不显示Header(应该被中间件重定向)
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
|
||||
<SidebarTrigger />
|
||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||
<div className="flex items-center justify-between flex-1">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{pageTitle}
|
||||
</h2>
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* 主题切换按钮 */}
|
||||
<ThemeToggleButton
|
||||
theme={theme === 'dark' ? 'dark' : 'light'}
|
||||
variant="polygon"
|
||||
onClick={handleThemeToggle}
|
||||
className="border-0 shadow-none"
|
||||
/>
|
||||
|
||||
{/* 开发者工具按钮 - 仅开发环境 */}
|
||||
{process.env.NODE_ENV === 'development' && user.isSuperAdmin && <DevPanel />}
|
||||
|
||||
{/* 用户菜单 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="flex items-center space-x-2 px-3">
|
||||
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<User className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div className="text-left hidden sm:block">
|
||||
<p className="text-sm font-medium">{user.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{user.isSuperAdmin ? '超级管理员' : (Array.isArray(user.roles) ? user.roles.join('、') : user.roles)}
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>我的账户</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setIsUserProfileOpen(true)}>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
个人资料
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setIsChangePasswordOpen(true)}>
|
||||
<KeyRound className="mr-2 h-4 w-4" />
|
||||
修改密码
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>角色</DropdownMenuLabel>
|
||||
<DropdownMenuItem disabled>
|
||||
{user.isSuperAdmin ? '超级管理员' : (Array.isArray(user.roles) ? user.roles.join('、') : user.roles)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-red-600 cursor-pointer"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
退出登录
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 修改密码对话框 */}
|
||||
<ChangePasswordDialog
|
||||
isOpen={isChangePasswordOpen}
|
||||
onClose={() => setIsChangePasswordOpen(false)}
|
||||
/>
|
||||
|
||||
{/* 用户资料对话框 */}
|
||||
<UserProfileDialog
|
||||
isOpen={isUserProfileOpen}
|
||||
onClose={() => setIsUserProfileOpen(false)}
|
||||
/>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
32
src/components/layout/main-layout.tsx
Normal file
32
src/components/layout/main-layout.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { AppSidebar } from './app-sidebar'
|
||||
import { Header } from './header'
|
||||
import { BreadcrumbNav } from './breadcrumb-nav'
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
} from '@/components/ui/sidebar'
|
||||
import type { MenuItem } from '@/constants/menu'
|
||||
import type { User } from '@/types/user'
|
||||
|
||||
interface MainLayoutProps {
|
||||
children: ReactNode
|
||||
user?: User
|
||||
menuItems: MenuItem[]
|
||||
}
|
||||
|
||||
export function MainLayout({ children, user, menuItems }: MainLayoutProps) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar menuItems={menuItems} />
|
||||
{/* 不设置min-w-0的话 里面的元素会在宽度不够的时候撑开容器 */}
|
||||
<SidebarInset className="min-w-0">
|
||||
<Header user={user} />
|
||||
<BreadcrumbNav />
|
||||
<main className="flex-1 p-0 md:p-3 lg:p-4 2xl:p-6">
|
||||
{children}
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
69
src/components/layout/sub-menu-layout.tsx
Normal file
69
src/components/layout/sub-menu-layout.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { getSubMenus, getActiveSubMenuValue } from "@/constants/menu";
|
||||
import { getMenuIcon } from "@/constants/menu-icons";
|
||||
|
||||
interface SubMenuLayoutProps {
|
||||
/** 父菜单的 href,例如 "/dev/arch" */
|
||||
parentHref: string;
|
||||
/** 子内容 */
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用的子菜单布局组件
|
||||
* 自动从 menu.ts 中获取子菜单配置并渲染 Tabs
|
||||
*/
|
||||
export function SubMenuLayout({ parentHref, children }: SubMenuLayoutProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
// 获取子菜单配置
|
||||
const subMenus = getSubMenus(parentHref);
|
||||
|
||||
// 从路径中提取当前激活的 tab
|
||||
const activeTab = getActiveSubMenuValue(pathname, parentHref);
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
router.push(`${parentHref}/${value}`);
|
||||
};
|
||||
|
||||
// 如果没有子菜单,直接渲染子内容
|
||||
if (subMenus.length === 0) {
|
||||
return <div className="w-full">{children}</div>;
|
||||
}
|
||||
|
||||
const gridColsClass = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-2',
|
||||
3: 'grid-cols-3',
|
||||
4: 'grid-cols-4',
|
||||
}[subMenus.length] || 'grid-cols-2';
|
||||
|
||||
// 发现Radix-UI的一些组件在非常新的NextJS中会有水合问题,比如说这里的tabs
|
||||
// 主要是组件在服务器端和客户端生成的id不一致,https://github.com/radix-ui/primitives/issues/3700
|
||||
// 综合考虑我把next从15.5.2回退到了~15.4.0
|
||||
// 根据子菜单数量动态设置grid-cols类名
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||
<TabsList className={`grid w-full max-w-md ${gridColsClass}`} variant="line">
|
||||
{subMenus.map((menu) => {
|
||||
const Icon = getMenuIcon(menu.icon);
|
||||
return (
|
||||
<TabsTrigger key={menu.href} value={menu.href?.split('/').pop() || ''} className="gap-2">
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{menu.title}</span>
|
||||
</TabsTrigger>
|
||||
);
|
||||
})}
|
||||
</TabsList>
|
||||
<TabsContent value={activeTab}>
|
||||
{children}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/components/layout/sub-menu-redirect.tsx
Normal file
28
src/components/layout/sub-menu-redirect.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { getDefaultSubMenuHref } from "@/constants/menu";
|
||||
|
||||
interface SubMenuRedirectProps {
|
||||
/** 父菜单的 href,例如 "/dev/arch" */
|
||||
parentHref: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用的子菜单重定向组件
|
||||
* 自动重定向到父菜单下的第一个子菜单
|
||||
*/
|
||||
export function SubMenuRedirect({ parentHref }: SubMenuRedirectProps) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
// 重定向到默认的子菜单页面
|
||||
const defaultHref = getDefaultSubMenuHref(parentHref);
|
||||
if (defaultHref) {
|
||||
router.replace(defaultHref);
|
||||
}
|
||||
}, [router, parentHref]);
|
||||
|
||||
return null;
|
||||
}
|
||||
246
src/components/layout/user-profile-dialog.tsx
Normal file
246
src/components/layout/user-profile-dialog.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
} from '@/components/ui/drawer'
|
||||
import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { User, Shield, Calendar, Building2, Clock } from 'lucide-react'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
|
||||
interface UserProfileDialogProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function UserProfileDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: UserProfileDialogProps) {
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
// 获取当前用户的完整资料
|
||||
const { data: userProfile, isLoading } = trpc.users.getCurrentUserProfile.useQuery(undefined, {
|
||||
enabled: isOpen,
|
||||
})
|
||||
|
||||
// 获取用户名首字符
|
||||
const userInitial = userProfile?.name?.charAt(0).toUpperCase() || 'U'
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
// 使当前拥有焦点的元素(通常是用来触发打开这个drawer的控件)失去焦点,不然控制台会警告焦点在一个要被隐藏于屏幕阅读器的控件上
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// 内容组件,在 Dialog 和 Drawer 中复用
|
||||
const profileContent = (
|
||||
<div className={isMobile ? "px-4 pb-4" : ""}>
|
||||
<ScrollArea className={isMobile ? "max-h-[70vh]" : "max-h-[calc(80vh-8rem)] pr-4"}>
|
||||
{isLoading ? (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="w-20 h-20 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-24" />
|
||||
<div className="grid grid-cols-2 gap-4 pl-6">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : userProfile ? (
|
||||
<div className="space-y-6">
|
||||
{/* 用户头像和基本信息 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-20 h-20 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<span className="text-3xl font-semibold text-primary">
|
||||
{userInitial}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-2xl font-semibold">{userProfile.name || '未命名用户'}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
用户ID: {userProfile.id}
|
||||
</p>
|
||||
{userProfile.isSuperAdmin && (
|
||||
<Badge variant="default" className="mt-2">
|
||||
超级管理员
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 基本信息 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<User className="h-4 w-4" />
|
||||
<span>基本信息</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pl-6">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">姓名</p>
|
||||
<p className="text-sm font-medium mt-1">{userProfile.name || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">状态</p>
|
||||
<div className="mt-1">
|
||||
<Badge variant={userProfile.status === '在校' ? 'default' : 'secondary'}>
|
||||
{userProfile.status || '未知'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{userProfile.deptFullName && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<Building2 className="h-3 w-3" />
|
||||
所属院系
|
||||
</p>
|
||||
<p className="text-sm font-medium mt-1">{userProfile.deptCode + ' ' + userProfile.deptFullName}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
最近登录
|
||||
</p>
|
||||
<p className="text-sm font-medium mt-1">
|
||||
{userProfile.lastLoginAt ? formatDate(userProfile.lastLoginAt) : '从未登录'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 角色信息 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Shield className="h-4 w-4" />
|
||||
<span>角色</span>
|
||||
</div>
|
||||
|
||||
<div className="pl-6">
|
||||
{userProfile.isSuperAdmin ? (
|
||||
<Badge variant="default" className="mr-2">
|
||||
超级管理员
|
||||
</Badge>
|
||||
) : userProfile.roles && userProfile.roles.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{userProfile.roles.map((role) => (
|
||||
<Badge key={role.id} variant="secondary">
|
||||
{role.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">暂无角色</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 权限信息 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Shield className="h-4 w-4" />
|
||||
<span>权限列表</span>
|
||||
</div>
|
||||
|
||||
<div className="pl-6">
|
||||
{userProfile.isSuperAdmin ? (
|
||||
<div className="space-y-2">
|
||||
<Badge variant="default">
|
||||
所有权限
|
||||
</Badge>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
超级管理员拥有系统所有权限
|
||||
</p>
|
||||
</div>
|
||||
) : userProfile.permissions && userProfile.permissions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{userProfile.permissions.map((permission) => (
|
||||
<Badge key={permission.id} variant="outline" className="text-xs">
|
||||
{permission.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">暂无权限</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
|
||||
// 根据设备类型渲染不同的组件
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={isOpen} onOpenChange={onClose}>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<VisuallyHidden>
|
||||
<DrawerTitle>个人资料</DrawerTitle>
|
||||
</VisuallyHidden>
|
||||
<VisuallyHidden>
|
||||
<DrawerDescription>
|
||||
查看当前用户的详细信息、角色和权限
|
||||
</DrawerDescription>
|
||||
</VisuallyHidden>
|
||||
</DrawerHeader>
|
||||
{profileContent}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>个人资料</DialogTitle>
|
||||
<VisuallyHidden>
|
||||
<DialogDescription>
|
||||
查看当前用户的详细信息、角色和权限
|
||||
</DialogDescription>
|
||||
</VisuallyHidden>
|
||||
</DialogHeader>
|
||||
{profileContent}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
7
src/components/providers/session-provider.tsx
Normal file
7
src/components/providers/session-provider.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { SessionProvider as NextAuthSessionProvider } from "next-auth/react"
|
||||
|
||||
export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||
return <NextAuthSessionProvider>{children}</NextAuthSessionProvider>
|
||||
}
|
||||
17
src/components/providers/theme-provider.tsx
Normal file
17
src/components/providers/theme-provider.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { ThemeProvider } from 'next-themes';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export function AppThemeProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
45
src/components/providers/trpc-provider.tsx
Normal file
45
src/components/providers/trpc-provider.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { httpBatchLink, httpSubscriptionLink, splitLink } from '@trpc/client'
|
||||
import { useState } from 'react'
|
||||
import superjson from 'superjson'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
|
||||
function getBaseUrl() {
|
||||
if (typeof window !== 'undefined') return '' // 浏览器环境
|
||||
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}` // Vercel环境
|
||||
return `http://localhost:${process.env.PORT ?? 3000}` // 开发环境
|
||||
}
|
||||
|
||||
export function TRPCProvider({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(() => new QueryClient())
|
||||
const [trpcClient] = useState(() =>
|
||||
trpc.createClient({
|
||||
links: [
|
||||
// 使用splitLink根据操作类型选择不同的link
|
||||
splitLink({
|
||||
condition: (op) => op.type === 'subscription',
|
||||
// subscription使用httpSubscriptionLink (SSE)
|
||||
true: httpSubscriptionLink({
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
transformer: superjson,
|
||||
}),
|
||||
// 其他操作使用httpBatchLink
|
||||
false: httpBatchLink({
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
transformer: superjson,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</trpc.Provider>
|
||||
)
|
||||
}
|
||||
157
src/components/ui/alert-dialog.tsx
Normal file
157
src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
66
src/components/ui/alert.tsx
Normal file
66
src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
53
src/components/ui/avatar.tsx
Normal file
53
src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
248
src/components/ui/badge.tsx
Normal file
248
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { Slot as SlotPrimitive } from 'radix-ui';
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {
|
||||
asChild?: boolean;
|
||||
dotClassName?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface BadgeButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeButtonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
export type BadgeDotProps = React.HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center whitespace-nowrap justify-center border border-transparent font-medium focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 [&_svg]:-ms-px [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground',
|
||||
primary: 'bg-primary text-primary-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground',
|
||||
success:
|
||||
'bg-[var(--color-success-accent,var(--color-green-500))] text-[var(--color-success-foreground,var(--color-white))]',
|
||||
warning:
|
||||
'bg-[var(--color-warning-accent,var(--color-yellow-500))] text-[var(--color-warning-foreground,var(--color-white))]',
|
||||
info: 'bg-[var(--color-info-accent,var(--color-violet-500))] text-[var(--color-info-foreground,var(--color-white))]',
|
||||
outline: 'bg-transparent border border-border text-secondary-foreground',
|
||||
destructive: 'bg-destructive text-[var(--color-destructive-foreground,var(--color-white))]',
|
||||
},
|
||||
appearance: {
|
||||
default: '',
|
||||
light: '',
|
||||
outline: '',
|
||||
ghost: 'border-transparent bg-transparent',
|
||||
},
|
||||
disabled: {
|
||||
true: 'opacity-50 pointer-events-none',
|
||||
},
|
||||
size: {
|
||||
lg: 'rounded-md px-[0.5rem] h-7 min-w-7 gap-1.5 text-xs [&_svg]:size-3.5',
|
||||
md: 'rounded-md px-[0.45rem] h-6 min-w-6 gap-1.5 text-xs [&_svg]:size-3.5 ',
|
||||
sm: 'rounded-sm px-[0.325rem] h-5 min-w-5 gap-1 text-[0.6875rem] leading-[0.75rem] [&_svg]:size-3',
|
||||
xs: 'rounded-sm px-[0.25rem] h-4 min-w-4 gap-1 text-[0.625rem] leading-[0.5rem] [&_svg]:size-3',
|
||||
},
|
||||
shape: {
|
||||
default: '',
|
||||
circle: 'rounded-full',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
/* Light */
|
||||
{
|
||||
variant: 'default',
|
||||
appearance: 'light',
|
||||
className:
|
||||
'text-[var(--color-primary-accent,var(--color-blue-700))] bg-[var(--color-primary-soft,var(--color-blue-50))] dark:bg-[var(--color-primary-soft,var(--color-blue-950))] dark:text-[var(--color-primary-soft,var(--color-blue-600))]',
|
||||
},
|
||||
{
|
||||
variant: 'primary',
|
||||
appearance: 'light',
|
||||
className:
|
||||
'text-[var(--color-primary-accent,var(--color-blue-700))] bg-[var(--color-primary-soft,var(--color-blue-50))] dark:bg-[var(--color-primary-soft,var(--color-blue-950))] dark:text-[var(--color-primary-soft,var(--color-blue-600))]',
|
||||
},
|
||||
{
|
||||
variant: 'secondary',
|
||||
appearance: 'light',
|
||||
className: 'bg-secondary dark:bg-secondary/50 text-secondary-foreground',
|
||||
},
|
||||
{
|
||||
variant: 'success',
|
||||
appearance: 'light',
|
||||
className:
|
||||
'text-[var(--color-success-accent,var(--color-green-800))] bg-[var(--color-success-soft,var(--color-green-100))] dark:bg-[var(--color-success-soft,var(--color-green-950))] dark:text-[var(--color-success-soft,var(--color-green-600))]',
|
||||
},
|
||||
{
|
||||
variant: 'warning',
|
||||
appearance: 'light',
|
||||
className:
|
||||
'text-[var(--color-warning-accent,var(--color-yellow-700))] bg-[var(--color-warning-soft,var(--color-yellow-100))] dark:bg-[var(--color-warning-soft,var(--color-yellow-950))] dark:text-[var(--color-warning-soft,var(--color-yellow-600))]',
|
||||
},
|
||||
{
|
||||
variant: 'info',
|
||||
appearance: 'light',
|
||||
className:
|
||||
'text-[var(--color-info-accent,var(--color-violet-700))] bg-[var(--color-info-soft,var(--color-violet-100))] dark:bg-[var(--color-info-soft,var(--color-violet-950))] dark:text-[var(--color-info-soft,var(--color-violet-400))]',
|
||||
},
|
||||
{
|
||||
variant: 'destructive',
|
||||
appearance: 'light',
|
||||
className:
|
||||
'text-[var(--color-destructive-accent,var(--color-red-700))] bg-[var(--color-destructive-soft,var(--color-red-50))] dark:bg-[var(--color-destructive-soft,var(--color-red-950))] dark:text-[var(--color-destructive-soft,var(--color-red-600))]',
|
||||
},
|
||||
/* Outline */
|
||||
{
|
||||
variant: 'default',
|
||||
appearance: 'outline',
|
||||
className:
|
||||
'text-[var(--color-primary-accent,var(--color-blue-700))] border-[var(--color-primary-soft,var(--color-blue-100))] bg-[var(--color-primary-soft,var(--color-blue-50))] dark:bg-[var(--color-primary-soft,var(--color-blue-950))] dark:border-[var(--color-primary-soft,var(--color-blue-900))] dark:text-[var(--color-primary-soft,var(--color-blue-600))]',
|
||||
},
|
||||
{
|
||||
variant: 'primary',
|
||||
appearance: 'outline',
|
||||
className:
|
||||
'text-[var(--color-primary-accent,var(--color-blue-700))] border-[var(--color-primary-soft,var(--color-blue-100))] bg-[var(--color-primary-soft,var(--color-blue-50))] dark:bg-[var(--color-primary-soft,var(--color-blue-950))] dark:border-[var(--color-primary-soft,var(--color-blue-900))] dark:text-[var(--color-primary-soft,var(--color-blue-600))]',
|
||||
},
|
||||
{
|
||||
variant: 'success',
|
||||
appearance: 'outline',
|
||||
className:
|
||||
'text-[var(--color-success-accent,var(--color-green-700))] border-[var(--color-success-soft,var(--color-green-200))] bg-[var(--color-success-soft,var(--color-green-50))] dark:bg-[var(--color-success-soft,var(--color-green-950))] dark:border-[var(--color-success-soft,var(--color-green-900))] dark:text-[var(--color-success-soft,var(--color-green-600))]',
|
||||
},
|
||||
{
|
||||
variant: 'warning',
|
||||
appearance: 'outline',
|
||||
className:
|
||||
'text-[var(--color-warning-accent,var(--color-yellow-700))] border-[var(--color-warning-soft,var(--color-yellow-200))] bg-[var(--color-warning-soft,var(--color-yellow-50))] dark:bg-[var(--color-warning-soft,var(--color-yellow-950))] dark:border-[var(--color-warning-soft,var(--color-yellow-900))] dark:text-[var(--color-warning-soft,var(--color-yellow-600))]',
|
||||
},
|
||||
{
|
||||
variant: 'info',
|
||||
appearance: 'outline',
|
||||
className:
|
||||
'text-[var(--color-info-accent,var(--color-violet-700))] border-[var(--color-info-soft,var(--color-violet-100))] bg-[var(--color-info-soft,var(--color-violet-50))] dark:bg-[var(--color-info-soft,var(--color-violet-950))] dark:border-[var(--color-info-soft,var(--color-violet-900))] dark:text-[var(--color-info-soft,var(--color-violet-400))]',
|
||||
},
|
||||
{
|
||||
variant: 'destructive',
|
||||
appearance: 'outline',
|
||||
className:
|
||||
'text-[var(--color-destructive-accent,var(--color-red-700))] border-[var(--color-destructive-soft,var(--color-red-100))] bg-[var(--color-destructive-soft,var(--color-red-50))] dark:bg-[var(--color-destructive-soft,var(--color-red-950))] dark:border-[var(--color-destructive-soft,var(--color-red-900))] dark:text-[var(--color-destructive-soft,var(--color-red-600))]',
|
||||
},
|
||||
/* Ghost */
|
||||
{
|
||||
variant: 'default',
|
||||
appearance: 'ghost',
|
||||
className: 'text-primary',
|
||||
},
|
||||
{
|
||||
variant: 'primary',
|
||||
appearance: 'ghost',
|
||||
className: 'text-primary',
|
||||
},
|
||||
{
|
||||
variant: 'secondary',
|
||||
appearance: 'ghost',
|
||||
className: 'text-secondary-foreground',
|
||||
},
|
||||
{
|
||||
variant: 'success',
|
||||
appearance: 'ghost',
|
||||
className: 'text-[var(--color-success-accent,var(--color-green-500))]',
|
||||
},
|
||||
{
|
||||
variant: 'warning',
|
||||
appearance: 'ghost',
|
||||
className: 'text-[var(--color-warning-accent,var(--color-yellow-500))]',
|
||||
},
|
||||
{
|
||||
variant: 'info',
|
||||
appearance: 'ghost',
|
||||
className: 'text-[var(--color-info-accent,var(--color-violet-500))]',
|
||||
},
|
||||
{
|
||||
variant: 'destructive',
|
||||
appearance: 'ghost',
|
||||
className: 'text-destructive',
|
||||
},
|
||||
|
||||
{ size: 'lg', appearance: 'ghost', className: 'px-0' },
|
||||
{ size: 'md', appearance: 'ghost', className: 'px-0' },
|
||||
{ size: 'sm', appearance: 'ghost', className: 'px-0' },
|
||||
{ size: 'xs', appearance: 'ghost', className: 'px-0' },
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'primary',
|
||||
appearance: 'default',
|
||||
size: 'md',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const badgeButtonVariants = cva(
|
||||
'cursor-pointer transition-all inline-flex items-center justify-center leading-none size-3.5 [&>svg]:opacity-100! [&>svg]:size-3.5! p-0 rounded-md -me-0.5 opacity-60 hover:opacity-100',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
appearance,
|
||||
shape,
|
||||
asChild = false,
|
||||
disabled,
|
||||
...props
|
||||
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? SlotPrimitive.Slot : 'span';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant, size, appearance, shape, disabled }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BadgeButton({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> & VariantProps<typeof badgeButtonVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? SlotPrimitive.Slot : 'span';
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge-button"
|
||||
className={cn(badgeButtonVariants({ variant, className }))}
|
||||
role="button"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BadgeDot({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="badge-dot"
|
||||
className={cn('size-1.5 rounded-full bg-[currentColor] opacity-75', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, BadgeButton, BadgeDot, badgeVariants };
|
||||
109
src/components/ui/breadcrumb.tsx
Normal file
109
src/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
83
src/components/ui/button-group.tsx
Normal file
83
src/components/ui/button-group.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
|
||||
vertical:
|
||||
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
buttonGroupVariants,
|
||||
}
|
||||
470
src/components/ui/button.tsx
Normal file
470
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,470 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { ChevronDown, LucideIcon } from 'lucide-react';
|
||||
import { Slot as SlotPrimitive } from 'radix-ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'cursor-pointer group whitespace-nowrap focus-visible:outline-hidden inline-flex items-center justify-center has-data-[arrow=true]:justify-between whitespace-nowrap text-sm font-medium ring-offset-background transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-60 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90 data-[state=open]:bg-primary/90',
|
||||
primary: 'bg-primary text-primary-foreground hover:bg-primary/90 data-[state=open]:bg-primary/90',
|
||||
mono: 'bg-zinc-950 text-white dark:bg-zinc-300 dark:text-black hover:bg-zinc-950/90 dark:hover:bg-zinc-300/90 data-[state=open]:bg-zinc-950/90 dark:data-[state=open]:bg-zinc-300/90',
|
||||
destructive:
|
||||
'bg-destructive text-[var(--color-destructive-foreground,var(--color-white))] hover:bg-destructive/90 data-[state=open]:bg-destructive/90',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/90 data-[state=open]:bg-secondary/90',
|
||||
outline: 'bg-background text-accent-foreground border border-input hover:bg-accent data-[state=open]:bg-accent',
|
||||
dashed:
|
||||
'text-accent-foreground border border-input border-dashed bg-background hover:bg-accent hover:text-accent-foreground data-[state=open]:text-accent-foreground',
|
||||
ghost:
|
||||
'text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
|
||||
dim: 'text-muted-foreground hover:text-foreground data-[state=open]:text-foreground',
|
||||
foreground: '',
|
||||
inverse: '',
|
||||
},
|
||||
appearance: {
|
||||
default: '',
|
||||
ghost: '',
|
||||
},
|
||||
underline: {
|
||||
solid: '',
|
||||
dashed: '',
|
||||
},
|
||||
underlined: {
|
||||
solid: '',
|
||||
dashed: '',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-3 gap-1.5 text-sm [&_svg:not([class*=size-])]:size-4',
|
||||
lg: 'h-10 px-4 text-sm gap-1.5 [&_svg:not([class*=size-])]:size-4',
|
||||
md: 'h-9 px-3 gap-1.5 text-sm [&_svg:not([class*=size-])]:size-4',
|
||||
sm: 'h-8 px-2.5 gap-1.25 text-xs [&_svg:not([class*=size-])]:size-3.5',
|
||||
xs: 'h-7 px-2 gap-1 text-xs [&_svg:not([class*=size-])]:size-3.5',
|
||||
icon: 'size-9 [&_svg:not([class*=size-])]:size-4 shrink-0',
|
||||
},
|
||||
autoHeight: {
|
||||
true: '',
|
||||
false: '',
|
||||
},
|
||||
radius: {
|
||||
md: 'rounded-md',
|
||||
full: 'rounded-full',
|
||||
},
|
||||
mode: {
|
||||
default: 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
icon: 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 shrink-0',
|
||||
link: 'text-primary h-auto p-0 bg-transparent rounded-none hover:bg-transparent data-[state=open]:bg-transparent',
|
||||
input: `
|
||||
justify-start font-normal hover:bg-background [&_svg]:transition-colors [&_svg]:hover:text-foreground data-[state=open]:bg-background
|
||||
focus-visible:border-ring focus-visible:outline-hidden focus-visible:ring-[3px] focus-visible:ring-ring/30
|
||||
[[data-state=open]>&]:border-ring [[data-state=open]>&]:outline-hidden [[data-state=open]>&]:ring-[3px]
|
||||
[[data-state=open]>&]:ring-ring/30
|
||||
aria-invalid:border-destructive/60 aria-invalid:ring-destructive/10 dark:aria-invalid:border-destructive dark:aria-invalid:ring-destructive/20
|
||||
in-data-[invalid=true]:border-destructive/60 in-data-[invalid=true]:ring-destructive/10 dark:in-data-[invalid=true]:border-destructive dark:in-data-[invalid=true]:ring-destructive/20
|
||||
`,
|
||||
},
|
||||
placeholder: {
|
||||
true: 'text-muted-foreground',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
// Icons opacity for default mode
|
||||
{
|
||||
variant: 'ghost',
|
||||
mode: 'default',
|
||||
className: '[&_svg:not([role=img]):not([class*=text-]):not([class*=opacity-])]:opacity-60',
|
||||
},
|
||||
{
|
||||
variant: 'outline',
|
||||
mode: 'default',
|
||||
className: '[&_svg:not([role=img]):not([class*=text-]):not([class*=opacity-])]:opacity-60',
|
||||
},
|
||||
{
|
||||
variant: 'dashed',
|
||||
mode: 'default',
|
||||
className: '[&_svg:not([role=img]):not([class*=text-]):not([class*=opacity-])]:opacity-60',
|
||||
},
|
||||
{
|
||||
variant: 'secondary',
|
||||
mode: 'default',
|
||||
className: '[&_svg:not([role=img]):not([class*=text-]):not([class*=opacity-])]:opacity-60',
|
||||
},
|
||||
|
||||
// Icons opacity for default mode
|
||||
{
|
||||
variant: 'outline',
|
||||
mode: 'input',
|
||||
className: '[&_svg:not([role=img]):not([class*=text-]):not([class*=opacity-])]:opacity-60',
|
||||
},
|
||||
{
|
||||
variant: 'outline',
|
||||
mode: 'icon',
|
||||
className: '[&_svg:not([role=img]):not([class*=text-]):not([class*=opacity-])]:opacity-60',
|
||||
},
|
||||
|
||||
// Auto height
|
||||
{
|
||||
size: 'xs',
|
||||
autoHeight: true,
|
||||
className: 'h-auto min-h-7',
|
||||
},
|
||||
{
|
||||
size: 'md',
|
||||
autoHeight: true,
|
||||
className: 'h-auto min-h-9',
|
||||
},
|
||||
{
|
||||
size: 'sm',
|
||||
autoHeight: true,
|
||||
className: 'h-auto min-h-8',
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
autoHeight: true,
|
||||
className: 'h-auto min-h-10',
|
||||
},
|
||||
|
||||
// Shadow support
|
||||
{
|
||||
variant: 'default',
|
||||
mode: 'default',
|
||||
appearance: 'default',
|
||||
className: 'shadow-xs shadow-black/5',
|
||||
},
|
||||
{
|
||||
variant: 'primary',
|
||||
mode: 'default',
|
||||
appearance: 'default',
|
||||
className: 'shadow-xs shadow-black/5',
|
||||
},
|
||||
{
|
||||
variant: 'mono',
|
||||
mode: 'default',
|
||||
appearance: 'default',
|
||||
className: 'shadow-xs shadow-black/5',
|
||||
},
|
||||
{
|
||||
variant: 'secondary',
|
||||
mode: 'default',
|
||||
appearance: 'default',
|
||||
className: 'shadow-xs shadow-black/5',
|
||||
},
|
||||
{
|
||||
variant: 'outline',
|
||||
mode: 'default',
|
||||
appearance: 'default',
|
||||
className: 'shadow-xs shadow-black/5',
|
||||
},
|
||||
{
|
||||
variant: 'dashed',
|
||||
mode: 'default',
|
||||
appearance: 'default',
|
||||
className: 'shadow-xs shadow-black/5',
|
||||
},
|
||||
{
|
||||
variant: 'destructive',
|
||||
mode: 'default',
|
||||
appearance: 'default',
|
||||
className: 'shadow-xs shadow-black/5',
|
||||
},
|
||||
|
||||
// Shadow support
|
||||
{
|
||||
variant: 'default',
|
||||
mode: 'icon',
|
||||
appearance: 'default',
|
||||
className: 'shadow-xs shadow-black/5',
|
||||
},
|
||||
{
|
||||
variant: 'primary',
|
||||
mode: 'icon',
|
||||
appearance: 'default',
|
||||
className: 'shadow-xs shadow-black/5',
|
||||
},
|
||||
{
|
||||
variant: 'mono',
|
||||
mode: 'icon',
|
||||
appearance: 'default',
|
||||
className: 'shadow-xs shadow-black/5',
|
||||
},
|
||||
{
|
||||
variant: 'secondary',
|
||||
mode: 'icon',
|
||||
appearance: 'default',
|
||||
className: 'shadow-xs shadow-black/5',
|
||||
},
|
||||
{
|
||||
variant: 'outline',
|
||||
mode: 'icon',
|
||||
appearance: 'default',
|
||||
className: 'shadow-xs shadow-black/5',
|
||||
},
|
||||
{
|
||||
variant: 'dashed',
|
||||
mode: 'icon',
|
||||
appearance: 'default',
|
||||
className: 'shadow-xs shadow-black/5',
|
||||
},
|
||||
{
|
||||
variant: 'destructive',
|
||||
mode: 'icon',
|
||||
appearance: 'default',
|
||||
className: 'shadow-xs shadow-black/5',
|
||||
},
|
||||
|
||||
// Link
|
||||
{
|
||||
variant: 'default',
|
||||
mode: 'link',
|
||||
underline: 'solid',
|
||||
className:
|
||||
'font-medium text-primary hover:text-primary/90 [&_svg:not([role=img]):not([class*=text-])]:opacity-60 hover:underline hover:underline-offset-4 hover:decoration-solid',
|
||||
},
|
||||
{
|
||||
variant: 'default',
|
||||
mode: 'link',
|
||||
underline: 'dashed',
|
||||
className:
|
||||
'font-medium text-primary hover:text-primary/90 [&_svg:not([role=img]):not([class*=text-])]:opacity-60 hover:underline hover:underline-offset-4 hover:decoration-dashed decoration-1',
|
||||
},
|
||||
{
|
||||
variant: 'default',
|
||||
mode: 'link',
|
||||
underlined: 'solid',
|
||||
className:
|
||||
'font-medium text-primary hover:text-primary/90 [&_svg:not([role=img]):not([class*=text-])]:opacity-60 underline underline-offset-4 decoration-solid',
|
||||
},
|
||||
{
|
||||
variant: 'default',
|
||||
mode: 'link',
|
||||
underlined: 'dashed',
|
||||
className:
|
||||
'font-medium text-primary hover:text-primary/90 [&_svg]:opacity-60 underline underline-offset-4 decoration-dashed decoration-1',
|
||||
},
|
||||
{
|
||||
variant: 'primary',
|
||||
mode: 'link',
|
||||
underline: 'solid',
|
||||
className:
|
||||
'font-medium text-primary hover:text-primary/90 [&_svg:not([role=img]):not([class*=text-])]:opacity-60 hover:underline hover:underline-offset-4 hover:decoration-solid',
|
||||
},
|
||||
{
|
||||
variant: 'primary',
|
||||
mode: 'link',
|
||||
underline: 'dashed',
|
||||
className:
|
||||
'font-medium text-primary hover:text-primary/90 [&_svg:not([role=img]):not([class*=text-])]:opacity-60 hover:underline hover:underline-offset-4 hover:decoration-dashed decoration-1',
|
||||
},
|
||||
{
|
||||
variant: 'primary',
|
||||
mode: 'link',
|
||||
underlined: 'solid',
|
||||
className:
|
||||
'font-medium text-primary hover:text-primary/90 [&_svg:not([role=img]):not([class*=text-])]:opacity-60 underline underline-offset-4 decoration-solid',
|
||||
},
|
||||
{
|
||||
variant: 'primary',
|
||||
mode: 'link',
|
||||
underlined: 'dashed',
|
||||
className:
|
||||
'font-medium text-primary hover:text-primary/90 [&_svg]:opacity-60 underline underline-offset-4 decoration-dashed decoration-1',
|
||||
},
|
||||
|
||||
{
|
||||
variant: 'inverse',
|
||||
mode: 'link',
|
||||
underline: 'solid',
|
||||
className:
|
||||
'font-medium text-inherit [&_svg:not([role=img]):not([class*=text-])]:opacity-60 hover:underline hover:underline-offset-4 hover:decoration-solid',
|
||||
},
|
||||
{
|
||||
variant: 'inverse',
|
||||
mode: 'link',
|
||||
underline: 'dashed',
|
||||
className:
|
||||
'font-medium text-inherit [&_svg:not([role=img]):not([class*=text-])]:opacity-60 hover:underline hover:underline-offset-4 hover:decoration-dashed decoration-1',
|
||||
},
|
||||
{
|
||||
variant: 'inverse',
|
||||
mode: 'link',
|
||||
underlined: 'solid',
|
||||
className:
|
||||
'font-medium text-inherit [&_svg:not([role=img]):not([class*=text-])]:opacity-60 underline underline-offset-4 decoration-solid',
|
||||
},
|
||||
{
|
||||
variant: 'inverse',
|
||||
mode: 'link',
|
||||
underlined: 'dashed',
|
||||
className:
|
||||
'font-medium text-inherit [&_svg:not([role=img]):not([class*=text-])]:opacity-60 underline underline-offset-4 decoration-dashed decoration-1',
|
||||
},
|
||||
|
||||
{
|
||||
variant: 'foreground',
|
||||
mode: 'link',
|
||||
underline: 'solid',
|
||||
className:
|
||||
'font-medium text-foreground [&_svg:not([role=img]):not([class*=text-])]:opacity-60 hover:underline hover:underline-offset-4 hover:decoration-solid',
|
||||
},
|
||||
{
|
||||
variant: 'foreground',
|
||||
mode: 'link',
|
||||
underline: 'dashed',
|
||||
className:
|
||||
'font-medium text-foreground [&_svg:not([role=img]):not([class*=text-])]:opacity-60 hover:underline hover:underline-offset-4 hover:decoration-dashed decoration-1',
|
||||
},
|
||||
{
|
||||
variant: 'foreground',
|
||||
mode: 'link',
|
||||
underlined: 'solid',
|
||||
className:
|
||||
'font-medium text-foreground [&_svg:not([role=img]):not([class*=text-])]:opacity-60 underline underline-offset-4 decoration-solid',
|
||||
},
|
||||
{
|
||||
variant: 'foreground',
|
||||
mode: 'link',
|
||||
underlined: 'dashed',
|
||||
className:
|
||||
'font-medium text-foreground [&_svg:not([role=img]):not([class*=text-])]:opacity-60 underline underline-offset-4 decoration-dashed decoration-1',
|
||||
},
|
||||
|
||||
// Ghost
|
||||
{
|
||||
variant: 'default',
|
||||
appearance: 'ghost',
|
||||
className: 'bg-transparent text-primary/90 hover:bg-primary/5 data-[state=open]:bg-primary/5',
|
||||
},
|
||||
{
|
||||
variant: 'primary',
|
||||
appearance: 'ghost',
|
||||
className: 'bg-transparent text-primary/90 hover:bg-primary/5 data-[state=open]:bg-primary/5',
|
||||
},
|
||||
{
|
||||
variant: 'destructive',
|
||||
appearance: 'ghost',
|
||||
className: 'bg-transparent text-destructive/90 hover:bg-destructive/5 data-[state=open]:bg-destructive/5',
|
||||
},
|
||||
{
|
||||
variant: 'ghost',
|
||||
mode: 'icon',
|
||||
className: 'text-muted-foreground',
|
||||
},
|
||||
|
||||
// Size
|
||||
{
|
||||
size: 'xs',
|
||||
mode: 'icon',
|
||||
className: 'w-7 h-7 p-0 [[&_svg:not([class*=size-])]:size-3.5',
|
||||
},
|
||||
{
|
||||
size: 'sm',
|
||||
mode: 'icon',
|
||||
className: 'w-8 h-8 p-0 [[&_svg:not([class*=size-])]:size-3.5',
|
||||
},
|
||||
{
|
||||
size: 'md',
|
||||
mode: 'icon',
|
||||
className: 'w-9 h-9 p-0 [&_svg:not([class*=size-])]:size-4',
|
||||
},
|
||||
{
|
||||
size: 'icon',
|
||||
className: 'w-9 h-9 p-0 [&_svg:not([class*=size-])]:size-4',
|
||||
},
|
||||
{
|
||||
size: 'lg',
|
||||
mode: 'icon',
|
||||
className: 'w-10 h-10 p-0 [&_svg:not([class*=size-])]:size-4',
|
||||
},
|
||||
|
||||
// Input mode
|
||||
{
|
||||
mode: 'input',
|
||||
placeholder: true,
|
||||
variant: 'outline',
|
||||
className: 'font-normal text-muted-foreground',
|
||||
},
|
||||
{
|
||||
mode: 'input',
|
||||
variant: 'outline',
|
||||
size: 'sm',
|
||||
className: 'gap-1.25',
|
||||
},
|
||||
{
|
||||
mode: 'input',
|
||||
variant: 'outline',
|
||||
size: 'md',
|
||||
className: 'gap-1.5',
|
||||
},
|
||||
{
|
||||
mode: 'input',
|
||||
variant: 'outline',
|
||||
size: 'lg',
|
||||
className: 'gap-1.5',
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'primary',
|
||||
mode: 'default',
|
||||
size: 'md',
|
||||
radius: 'md',
|
||||
appearance: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
selected,
|
||||
variant,
|
||||
radius,
|
||||
appearance,
|
||||
mode,
|
||||
size,
|
||||
autoHeight,
|
||||
underlined,
|
||||
underline,
|
||||
asChild = false,
|
||||
placeholder = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
selected?: boolean;
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? SlotPrimitive.Slot : 'button';
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant,
|
||||
size,
|
||||
radius,
|
||||
appearance,
|
||||
mode,
|
||||
autoHeight,
|
||||
placeholder,
|
||||
underlined,
|
||||
underline,
|
||||
className,
|
||||
}),
|
||||
asChild && props.disabled && 'pointer-events-none opacity-50',
|
||||
)}
|
||||
{...(selected && { 'data-state': 'open' })}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ButtonArrowProps extends React.SVGProps<SVGSVGElement> {
|
||||
icon?: LucideIcon; // Allows passing any Lucide icon
|
||||
}
|
||||
|
||||
function ButtonArrow({ icon: Icon = ChevronDown, className, ...props }: ButtonArrowProps) {
|
||||
return <Icon data-slot="button-arrow" className={cn('ms-auto -me-1', className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Button, ButtonArrow, buttonVariants };
|
||||
213
src/components/ui/calendar.tsx
Normal file
213
src/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react"
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"flex gap-4 flex-col md:flex-row relative",
|
||||
defaultClassNames.months
|
||||
),
|
||||
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn(
|
||||
"absolute bg-popover inset-0 opacity-0",
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"select-none w-(--cell-size)",
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
"text-[0.8rem] select-none text-muted-foreground",
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
"rounded-l-md bg-accent",
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
241
src/components/ui/carousel.tsx
Normal file
241
src/components/ui/carousel.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react"
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1]
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||
type CarouselOptions = UseCarouselParameters[0]
|
||||
type CarouselPlugin = UseCarouselParameters[1]
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions
|
||||
plugins?: CarouselPlugin
|
||||
orientation?: "horizontal" | "vertical"
|
||||
setApi?: (api: CarouselApi) => void
|
||||
}
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: boolean
|
||||
canScrollNext: boolean
|
||||
} & CarouselProps
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function Carousel({
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & CarouselProps) {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins
|
||||
)
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) return
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}, [])
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev()
|
||||
}, [api])
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext()
|
||||
}, [api])
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) return
|
||||
setApi(api)
|
||||
}, [api, setApi])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) return
|
||||
onSelect(api)
|
||||
api.on("reInit", onSelect)
|
||||
api.on("select", onSelect)
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect)
|
||||
}
|
||||
}, [api, onSelect])
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
data-slot="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { carouselRef, orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="overflow-hidden"
|
||||
data-slot="carousel-content"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
data-slot="carousel-item"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselPrevious({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-previous"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -left-12 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselNext({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-next"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -right-12 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
}
|
||||
47
src/components/ui/checkbox.tsx
Normal file
47
src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cva, VariantProps } from 'class-variance-authority';
|
||||
import { Check, Minus } from 'lucide-react';
|
||||
import { Checkbox as CheckboxPrimitive } from 'radix-ui';
|
||||
|
||||
// Define the variants for the Checkbox using cva.
|
||||
const checkboxVariants = cva(
|
||||
`
|
||||
group peer bg-background shrink-0 rounded-md border border-input ring-offset-background focus-visible:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50
|
||||
aria-invalid:border-destructive/60 aria-invalid:ring-destructive/10 dark:aria-invalid:border-destructive dark:aria-invalid:ring-destructive/20
|
||||
[[data-invalid=true]_&]:border-destructive/60 [[data-invalid=true]_&]:ring-destructive/10 dark:[[data-invalid=true]_&]:border-destructive dark:[[data-invalid=true]_&]:ring-destructive/20,
|
||||
data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:bg-primary data-[state=indeterminate]:border-primary data-[state=indeterminate]:text-primary-foreground
|
||||
`,
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'size-4.5 [&_svg]:size-3',
|
||||
md: 'size-5 [&_svg]:size-3.5',
|
||||
lg: 'size-5.5 [&_svg]:size-4',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root> & VariantProps<typeof checkboxVariants>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root data-slot="checkbox" className={cn(checkboxVariants({ size }), className)} {...props}>
|
||||
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
|
||||
<Check className="group-data-[state=indeterminate]:hidden" />
|
||||
<Minus className="hidden group-data-[state=indeterminate]:block" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
33
src/components/ui/collapsible.tsx
Normal file
33
src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
184
src/components/ui/command.tsx
Normal file
184
src/components/ui/command.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn("overflow-hidden p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
139
src/components/ui/dialog.tsx
Normal file
139
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cva, VariantProps } from 'class-variance-authority';
|
||||
import { X } from 'lucide-react';
|
||||
import { Dialog as DialogPrimitive } from 'radix-ui';
|
||||
|
||||
const dialogContentVariants = cva(
|
||||
'flex flex-col fixed outline-0 z-50 border border-border bg-background p-6 shadow-lg shadow-black/5 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'left-[50%] top-[50%] max-w-lg translate-x-[-50%] translate-y-[-50%] w-full',
|
||||
fullscreen: 'inset-0 md:inset-5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/30 [backdrop-filter:blur(4px)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
overlay = true,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> &
|
||||
VariantProps<typeof dialogContentVariants> & {
|
||||
showCloseButton?: boolean;
|
||||
overlay?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
{overlay && <DialogOverlay />}
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(dialogContentVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogClose className="cursor-pointer outline-0 absolute end-5 top-5 rounded-sm opacity-60 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
export default DialogContent;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn('flex flex-col space-y-1 text-center sm:text-start mb-5', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end pt-5 sm:space-x-2.5', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const DialogBody = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div data-slot="dialog-body" className={cn('grow', className)} {...props} />
|
||||
);
|
||||
|
||||
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
135
src/components/ui/drawer.tsx
Normal file
135
src/components/ui/drawer.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Drawer as DrawerPrimitive } from "vaul"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Drawer({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DrawerPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
||||
}
|
||||
|
||||
function DrawerClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
||||
}
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal data-slot="drawer-portal">
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn(
|
||||
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
257
src/components/ui/dropdown-menu.tsx
Normal file
257
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
286
src/components/ui/faceted.tsx
Normal file
286
src/components/ui/faceted.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
"use client";
|
||||
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Option } from "@/types/data-table";
|
||||
|
||||
type FacetedValue<Multiple extends boolean> = Multiple extends true
|
||||
? string[]
|
||||
: string;
|
||||
|
||||
interface FacetedContextValue<Multiple extends boolean = boolean> {
|
||||
value?: FacetedValue<Multiple>;
|
||||
onItemSelect?: (value: string) => void;
|
||||
multiple?: Multiple;
|
||||
}
|
||||
|
||||
const FacetedContext = React.createContext<FacetedContextValue<boolean> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
function useFacetedContext(name: string) {
|
||||
const context = React.useContext(FacetedContext);
|
||||
if (!context) {
|
||||
throw new Error(`\`${name}\` must be within Faceted`);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface FacetedProps<Multiple extends boolean = false>
|
||||
extends React.ComponentProps<typeof Popover> {
|
||||
value?: FacetedValue<Multiple>;
|
||||
onValueChange?: (value: FacetedValue<Multiple> | undefined) => void;
|
||||
children?: React.ReactNode;
|
||||
multiple?: Multiple;
|
||||
}
|
||||
|
||||
function Faceted<Multiple extends boolean = false>(
|
||||
props: FacetedProps<Multiple>,
|
||||
) {
|
||||
const {
|
||||
open: openProp,
|
||||
onOpenChange: onOpenChangeProp,
|
||||
value,
|
||||
onValueChange,
|
||||
children,
|
||||
multiple = false,
|
||||
...facetedProps
|
||||
} = props;
|
||||
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false);
|
||||
const isControlled = openProp !== undefined;
|
||||
const open = isControlled ? openProp : uncontrolledOpen;
|
||||
|
||||
const onOpenChange = React.useCallback(
|
||||
(newOpen: boolean) => {
|
||||
if (!isControlled) {
|
||||
setUncontrolledOpen(newOpen);
|
||||
}
|
||||
onOpenChangeProp?.(newOpen);
|
||||
},
|
||||
[isControlled, onOpenChangeProp],
|
||||
);
|
||||
|
||||
const onItemSelect = React.useCallback(
|
||||
(selectedValue: string) => {
|
||||
if (!onValueChange) return;
|
||||
|
||||
if (multiple) {
|
||||
const currentValue = (Array.isArray(value) ? value : []) as string[];
|
||||
const newValue = currentValue.includes(selectedValue)
|
||||
? currentValue.filter((v) => v !== selectedValue)
|
||||
: [...currentValue, selectedValue];
|
||||
onValueChange(newValue as FacetedValue<Multiple>);
|
||||
} else {
|
||||
if (value === selectedValue) {
|
||||
onValueChange(undefined);
|
||||
} else {
|
||||
onValueChange(selectedValue as FacetedValue<Multiple>);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => onOpenChange(false));
|
||||
}
|
||||
},
|
||||
[multiple, value, onValueChange, onOpenChange],
|
||||
);
|
||||
|
||||
const contextValue = React.useMemo<FacetedContextValue<typeof multiple>>(
|
||||
() => ({ value, onItemSelect, multiple }),
|
||||
[value, onItemSelect, multiple],
|
||||
);
|
||||
|
||||
return (
|
||||
<FacetedContext.Provider value={contextValue}>
|
||||
<Popover open={open} onOpenChange={onOpenChange} {...facetedProps}>
|
||||
{children}
|
||||
</Popover>
|
||||
</FacetedContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function FacetedTrigger(props: React.ComponentProps<typeof PopoverTrigger>) {
|
||||
const { className, children, ...triggerProps } = props;
|
||||
|
||||
return (
|
||||
<PopoverTrigger
|
||||
{...triggerProps}
|
||||
className={cn("justify-between text-left", className)}
|
||||
>
|
||||
{children}
|
||||
</PopoverTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
interface FacetedBadgeListProps extends React.ComponentProps<"div"> {
|
||||
options?: Option[];
|
||||
max?: number;
|
||||
badgeClassName?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
function FacetedBadgeList(props: FacetedBadgeListProps) {
|
||||
const {
|
||||
options = [],
|
||||
max = 2,
|
||||
placeholder = "Select options...",
|
||||
className,
|
||||
badgeClassName,
|
||||
...badgeListProps
|
||||
} = props;
|
||||
|
||||
const context = useFacetedContext("FacetedBadgeList");
|
||||
const values = Array.isArray(context.value)
|
||||
? context.value
|
||||
: ([context.value].filter(Boolean) as string[]);
|
||||
|
||||
const getLabel = React.useCallback(
|
||||
(value: string) => {
|
||||
const option = options.find((opt) => opt.id === value);
|
||||
return option?.name ?? value;
|
||||
},
|
||||
[options],
|
||||
);
|
||||
|
||||
if (!values || values.length === 0) {
|
||||
return (
|
||||
<div
|
||||
{...badgeListProps}
|
||||
className="flex w-full items-center gap-1 text-muted-foreground"
|
||||
>
|
||||
{placeholder}
|
||||
<ChevronsUpDown className="ml-auto size-4 shrink-0 opacity-50" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...badgeListProps}
|
||||
className={cn("flex flex-wrap items-center gap-1", className)}
|
||||
>
|
||||
{values.length > max ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn("rounded-sm px-1 font-normal", badgeClassName)}
|
||||
>
|
||||
{values.length} selected
|
||||
</Badge>
|
||||
) : (
|
||||
values.map((value) => (
|
||||
<Badge
|
||||
key={value}
|
||||
variant="secondary"
|
||||
className={cn("rounded-sm px-1 font-normal", badgeClassName)}
|
||||
>
|
||||
<span className="truncate">{getLabel(value)}</span>
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FacetedContent(props: React.ComponentProps<typeof PopoverContent>) {
|
||||
const { className, children, ...contentProps } = props;
|
||||
|
||||
return (
|
||||
<PopoverContent
|
||||
{...contentProps}
|
||||
align="start"
|
||||
className={cn(
|
||||
"w-[200px] origin-(--radix-popover-content-transform-origin) p-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Command>{children}</Command>
|
||||
</PopoverContent>
|
||||
);
|
||||
}
|
||||
|
||||
const FacetedInput = CommandInput;
|
||||
|
||||
const FacetedList = CommandList;
|
||||
|
||||
const FacetedEmpty = CommandEmpty;
|
||||
|
||||
const FacetedGroup = CommandGroup;
|
||||
|
||||
interface FacetedItemProps extends React.ComponentProps<typeof CommandItem> {
|
||||
value: string;
|
||||
}
|
||||
|
||||
function FacetedItem(props: FacetedItemProps) {
|
||||
const { value, onSelect, className, children, ...itemProps } = props;
|
||||
const context = useFacetedContext("FacetedItem");
|
||||
|
||||
const isSelected = context.multiple
|
||||
? Array.isArray(context.value) && context.value.includes(value)
|
||||
: context.value === value;
|
||||
|
||||
const { onItemSelect: onItemSelectProp } = context
|
||||
|
||||
const onItemSelect = React.useCallback(
|
||||
(currentValue: string) => {
|
||||
if (onSelect) {
|
||||
onSelect(currentValue);
|
||||
} else if (onItemSelectProp) {
|
||||
onItemSelectProp(currentValue);
|
||||
}
|
||||
},
|
||||
[onSelect, onItemSelectProp],
|
||||
);
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
aria-selected={isSelected}
|
||||
data-selected={isSelected}
|
||||
className={cn("gap-2", className)}
|
||||
onSelect={() => onItemSelect(value)}
|
||||
{...itemProps}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-4 items-center justify-center rounded-sm border border-primary",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible",
|
||||
)}
|
||||
>
|
||||
<Check className="size-4" />
|
||||
</span>
|
||||
{children}
|
||||
</CommandItem>
|
||||
);
|
||||
}
|
||||
|
||||
const FacetedSeparator = CommandSeparator;
|
||||
|
||||
export {
|
||||
Faceted,
|
||||
FacetedBadgeList,
|
||||
FacetedContent,
|
||||
FacetedEmpty,
|
||||
FacetedGroup,
|
||||
FacetedInput,
|
||||
FacetedItem,
|
||||
FacetedList,
|
||||
FacetedSeparator,
|
||||
FacetedTrigger,
|
||||
};
|
||||
167
src/components/ui/form.tsx
Normal file
167
src/components/ui/form.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : props.children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
170
src/components/ui/input-group.tsx
Normal file
170
src/components/ui/input-group.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
|
||||
"h-9 min-w-0 has-[>textarea]:h-auto",
|
||||
|
||||
// Variants based on alignment.
|
||||
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
|
||||
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
|
||||
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
|
||||
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
||||
|
||||
// Focus state.
|
||||
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
|
||||
|
||||
// Error state.
|
||||
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
|
||||
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start":
|
||||
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
|
||||
"inline-end":
|
||||
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
|
||||
"block-start":
|
||||
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
|
||||
"block-end":
|
||||
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"text-sm shadow-none flex gap-2 items-center",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
|
||||
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||
VariantProps<typeof inputGroupButtonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
28
src/components/ui/kbd.tsx
Normal file
28
src/components/ui/kbd.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none",
|
||||
"[&_svg:not([class*='size-'])]:size-3",
|
||||
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd-group"
|
||||
className={cn("inline-flex items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Kbd, KbdGroup }
|
||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
48
src/components/ui/popover.tsx
Normal file
48
src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
31
src/components/ui/progress.tsx
Normal file
31
src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
45
src/components/ui/radio-group.tsx
Normal file
45
src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn("grid gap-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="relative flex items-center justify-center"
|
||||
>
|
||||
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
53
src/components/ui/scroll-area.tsx
Normal file
53
src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui';
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
viewportClassName,
|
||||
children,
|
||||
viewportRef,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root> & {
|
||||
viewportRef?: React.Ref<HTMLDivElement>;
|
||||
viewportClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root data-slot="scroll-area" className={cn('relative overflow-hidden', className)} {...props}>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
ref={viewportRef}
|
||||
className={cn('h-full w-full rounded-[inherit]', viewportClassName)}
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
orientation === 'vertical' && 'h-full w-2 border-l border-l-transparent p-[1px]',
|
||||
orientation === 'horizontal' && 'h-2 flex-col border-t border-t-transparent p-[1px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
185
src/components/ui/select.tsx
Normal file
185
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
28
src/components/ui/separator.tsx
Normal file
28
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
139
src/components/ui/sheet.tsx
Normal file
139
src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
726
src/components/ui/sidebar.tsx
Normal file
726
src/components/ui/sidebar.tsx
Normal file
@@ -0,0 +1,726 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, VariantProps } from "class-variance-authority"
|
||||
import { PanelLeftIcon } from "lucide-react"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open]
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer text-sidebar-foreground hidden md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("size-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"bg-background relative flex w-full flex-1 flex-col",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const { isMobile, state } = useSidebar()
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!tooltip) {
|
||||
return button
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
showOnHover?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
13
src/components/ui/skeleton.tsx
Normal file
13
src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
63
src/components/ui/slider.tsx
Normal file
63
src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
defaultValue,
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
const _values = React.useMemo(
|
||||
() =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(defaultValue)
|
||||
? defaultValue
|
||||
: [min, max],
|
||||
[value, defaultValue, min, max]
|
||||
)
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
data-slot="slider"
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
data-slot="slider-track"
|
||||
className={cn(
|
||||
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
|
||||
)}
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
className={cn(
|
||||
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
|
||||
)}
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
{Array.from({ length: _values.length }, (_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Slider }
|
||||
587
src/components/ui/sortable.tsx
Normal file
587
src/components/ui/sortable.tsx
Normal file
@@ -0,0 +1,587 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type Announcements,
|
||||
closestCenter,
|
||||
closestCorners,
|
||||
DndContext,
|
||||
type DndContextProps,
|
||||
type DragEndEvent,
|
||||
type DraggableAttributes,
|
||||
type DraggableSyntheticListeners,
|
||||
DragOverlay,
|
||||
type DragStartEvent,
|
||||
type DropAnimation,
|
||||
defaultDropAnimationSideEffects,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
type ScreenReaderInstructions,
|
||||
TouchSensor,
|
||||
type UniqueIdentifier,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
restrictToHorizontalAxis,
|
||||
restrictToParentElement,
|
||||
restrictToVerticalAxis,
|
||||
} from "@dnd-kit/modifiers";
|
||||
import {
|
||||
arrayMove,
|
||||
horizontalListSortingStrategy,
|
||||
SortableContext,
|
||||
type SortableContextProps,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import * as React from "react";
|
||||
import * as ReactDOM from "react-dom";
|
||||
|
||||
import { useComposedRefs } from "@/hooks/use-composed-refs";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const orientationConfig = {
|
||||
vertical: {
|
||||
modifiers: [restrictToVerticalAxis, restrictToParentElement],
|
||||
strategy: verticalListSortingStrategy,
|
||||
collisionDetection: closestCenter,
|
||||
},
|
||||
horizontal: {
|
||||
modifiers: [restrictToHorizontalAxis, restrictToParentElement],
|
||||
strategy: horizontalListSortingStrategy,
|
||||
collisionDetection: closestCenter,
|
||||
},
|
||||
mixed: {
|
||||
modifiers: [restrictToParentElement],
|
||||
strategy: undefined,
|
||||
collisionDetection: closestCorners,
|
||||
},
|
||||
};
|
||||
|
||||
const ROOT_NAME = "Sortable";
|
||||
const CONTENT_NAME = "SortableContent";
|
||||
const ITEM_NAME = "SortableItem";
|
||||
const ITEM_HANDLE_NAME = "SortableItemHandle";
|
||||
const OVERLAY_NAME = "SortableOverlay";
|
||||
|
||||
interface SortableRootContextValue<T> {
|
||||
id: string;
|
||||
items: UniqueIdentifier[];
|
||||
modifiers: DndContextProps["modifiers"];
|
||||
strategy: SortableContextProps["strategy"];
|
||||
activeId: UniqueIdentifier | null;
|
||||
setActiveId: (id: UniqueIdentifier | null) => void;
|
||||
getItemValue: (item: T) => UniqueIdentifier;
|
||||
flatCursor: boolean;
|
||||
}
|
||||
|
||||
const SortableRootContext =
|
||||
React.createContext<SortableRootContextValue<unknown> | null>(null);
|
||||
SortableRootContext.displayName = ROOT_NAME;
|
||||
|
||||
function useSortableContext(consumerName: string) {
|
||||
const context = React.useContext(SortableRootContext);
|
||||
if (!context) {
|
||||
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface GetItemValue<T> {
|
||||
/**
|
||||
* Callback that returns a unique identifier for each sortable item. Required for array of objects.
|
||||
* @example getItemValue={(item) => item.id}
|
||||
*/
|
||||
getItemValue: (item: T) => UniqueIdentifier;
|
||||
}
|
||||
|
||||
type SortableRootProps<T> = DndContextProps & {
|
||||
value: T[];
|
||||
onValueChange?: (items: T[]) => void;
|
||||
onMove?: (
|
||||
event: DragEndEvent & { activeIndex: number; overIndex: number },
|
||||
) => void;
|
||||
strategy?: SortableContextProps["strategy"];
|
||||
orientation?: "vertical" | "horizontal" | "mixed";
|
||||
flatCursor?: boolean;
|
||||
} & (T extends object ? GetItemValue<T> : Partial<GetItemValue<T>>);
|
||||
|
||||
function SortableRoot<T>(props: SortableRootProps<T>) {
|
||||
const {
|
||||
value,
|
||||
onValueChange,
|
||||
collisionDetection,
|
||||
modifiers,
|
||||
strategy,
|
||||
onMove,
|
||||
orientation = "vertical",
|
||||
flatCursor = false,
|
||||
getItemValue: getItemValueProp,
|
||||
accessibility,
|
||||
...sortableProps
|
||||
} = props;
|
||||
|
||||
const id = React.useId();
|
||||
const [activeId, setActiveId] = React.useState<UniqueIdentifier | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor),
|
||||
useSensor(TouchSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
const config = React.useMemo(
|
||||
() => orientationConfig[orientation],
|
||||
[orientation],
|
||||
);
|
||||
|
||||
const getItemValue = React.useCallback(
|
||||
(item: T): UniqueIdentifier => {
|
||||
if (typeof item === "object" && !getItemValueProp) {
|
||||
throw new Error("getItemValue is required when using array of objects");
|
||||
}
|
||||
return getItemValueProp
|
||||
? getItemValueProp(item)
|
||||
: (item as UniqueIdentifier);
|
||||
},
|
||||
[getItemValueProp],
|
||||
);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
return value.map((item) => getItemValue(item));
|
||||
}, [value, getItemValue]);
|
||||
|
||||
const { onDragStart: onDragStartProp, onDragEnd: onDragEndProp, onDragCancel: onDragCancelProp } = sortableProps;
|
||||
|
||||
const onDragStart = React.useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
onDragStartProp?.(event);
|
||||
|
||||
if (event.activatorEvent.defaultPrevented) return;
|
||||
|
||||
setActiveId(event.active.id);
|
||||
},
|
||||
[onDragStartProp],
|
||||
);
|
||||
|
||||
const onDragEnd = React.useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
onDragEndProp?.(event);
|
||||
|
||||
if (event.activatorEvent.defaultPrevented) return;
|
||||
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over?.id) {
|
||||
const activeIndex = value.findIndex(
|
||||
(item) => getItemValue(item) === active.id,
|
||||
);
|
||||
const overIndex = value.findIndex(
|
||||
(item) => getItemValue(item) === over.id,
|
||||
);
|
||||
|
||||
if (onMove) {
|
||||
onMove({ ...event, activeIndex, overIndex });
|
||||
} else {
|
||||
onValueChange?.(arrayMove(value, activeIndex, overIndex));
|
||||
}
|
||||
}
|
||||
setActiveId(null);
|
||||
},
|
||||
[value, onValueChange, onMove, getItemValue, onDragEndProp],
|
||||
);
|
||||
|
||||
const onDragCancel = React.useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
onDragCancelProp?.(event);
|
||||
|
||||
if (event.activatorEvent.defaultPrevented) return;
|
||||
|
||||
setActiveId(null);
|
||||
},
|
||||
[onDragCancelProp],
|
||||
);
|
||||
|
||||
const announcements: Announcements = React.useMemo(
|
||||
() => ({
|
||||
onDragStart({ active }) {
|
||||
const activeValue = active.id.toString();
|
||||
return `Grabbed sortable item "${activeValue}". Current position is ${active.data.current?.sortable.index + 1} of ${value.length}. Use arrow keys to move, space to drop.`;
|
||||
},
|
||||
onDragOver({ active, over }) {
|
||||
if (over) {
|
||||
const overIndex = over.data.current?.sortable.index ?? 0;
|
||||
const activeIndex = active.data.current?.sortable.index ?? 0;
|
||||
const moveDirection = overIndex > activeIndex ? "down" : "up";
|
||||
const activeValue = active.id.toString();
|
||||
return `Sortable item "${activeValue}" moved ${moveDirection} to position ${overIndex + 1} of ${value.length}.`;
|
||||
}
|
||||
return "Sortable item is no longer over a droppable area. Press escape to cancel.";
|
||||
},
|
||||
onDragEnd({ active, over }) {
|
||||
const activeValue = active.id.toString();
|
||||
if (over) {
|
||||
const overIndex = over.data.current?.sortable.index ?? 0;
|
||||
return `Sortable item "${activeValue}" dropped at position ${overIndex + 1} of ${value.length}.`;
|
||||
}
|
||||
return `Sortable item "${activeValue}" dropped. No changes were made.`;
|
||||
},
|
||||
onDragCancel({ active }) {
|
||||
const activeIndex = active.data.current?.sortable.index ?? 0;
|
||||
const activeValue = active.id.toString();
|
||||
return `Sorting cancelled. Sortable item "${activeValue}" returned to position ${activeIndex + 1} of ${value.length}.`;
|
||||
},
|
||||
onDragMove({ active, over }) {
|
||||
if (over) {
|
||||
const overIndex = over.data.current?.sortable.index ?? 0;
|
||||
const activeIndex = active.data.current?.sortable.index ?? 0;
|
||||
const moveDirection = overIndex > activeIndex ? "down" : "up";
|
||||
const activeValue = active.id.toString();
|
||||
return `Sortable item "${activeValue}" is moving ${moveDirection} to position ${overIndex + 1} of ${value.length}.`;
|
||||
}
|
||||
return "Sortable item is no longer over a droppable area. Press escape to cancel.";
|
||||
},
|
||||
}),
|
||||
[value],
|
||||
);
|
||||
|
||||
const screenReaderInstructions: ScreenReaderInstructions = React.useMemo(
|
||||
() => ({
|
||||
draggable: `
|
||||
To pick up a sortable item, press space or enter.
|
||||
While dragging, use the ${orientation === "vertical" ? "up and down" : orientation === "horizontal" ? "left and right" : "arrow"} keys to move the item.
|
||||
Press space or enter again to drop the item in its new position, or press escape to cancel.
|
||||
`,
|
||||
}),
|
||||
[orientation],
|
||||
);
|
||||
|
||||
const contextValue = React.useMemo(
|
||||
() => ({
|
||||
id,
|
||||
items,
|
||||
modifiers: modifiers ?? config.modifiers,
|
||||
strategy: strategy ?? config.strategy,
|
||||
activeId,
|
||||
setActiveId,
|
||||
getItemValue,
|
||||
flatCursor,
|
||||
}),
|
||||
[
|
||||
id,
|
||||
items,
|
||||
modifiers,
|
||||
strategy,
|
||||
config.modifiers,
|
||||
config.strategy,
|
||||
activeId,
|
||||
getItemValue,
|
||||
flatCursor,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<SortableRootContext.Provider
|
||||
value={contextValue as SortableRootContextValue<unknown>}
|
||||
>
|
||||
<DndContext
|
||||
collisionDetection={collisionDetection ?? config.collisionDetection}
|
||||
modifiers={modifiers ?? config.modifiers}
|
||||
sensors={sensors}
|
||||
{...sortableProps}
|
||||
id={id}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragCancel={onDragCancel}
|
||||
accessibility={{
|
||||
announcements,
|
||||
screenReaderInstructions,
|
||||
...accessibility,
|
||||
}}
|
||||
/>
|
||||
</SortableRootContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const SortableContentContext = React.createContext<boolean>(false);
|
||||
SortableContentContext.displayName = CONTENT_NAME;
|
||||
|
||||
interface SortableContentProps extends React.ComponentPropsWithoutRef<"div"> {
|
||||
strategy?: SortableContextProps["strategy"];
|
||||
children: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
withoutSlot?: boolean;
|
||||
}
|
||||
|
||||
const SortableContent = React.forwardRef<HTMLDivElement, SortableContentProps>(
|
||||
(props, forwardedRef) => {
|
||||
const {
|
||||
strategy: strategyProp,
|
||||
asChild,
|
||||
withoutSlot,
|
||||
children,
|
||||
...contentProps
|
||||
} = props;
|
||||
|
||||
const context = useSortableContext(CONTENT_NAME);
|
||||
|
||||
const ContentPrimitive = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<SortableContentContext.Provider value={true}>
|
||||
<SortableContext
|
||||
items={context.items}
|
||||
strategy={strategyProp ?? context.strategy}
|
||||
>
|
||||
{withoutSlot ? (
|
||||
children
|
||||
) : (
|
||||
<ContentPrimitive
|
||||
data-slot="sortable-content"
|
||||
{...contentProps}
|
||||
ref={forwardedRef}
|
||||
>
|
||||
{children}
|
||||
</ContentPrimitive>
|
||||
)}
|
||||
</SortableContext>
|
||||
</SortableContentContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
SortableContent.displayName = CONTENT_NAME;
|
||||
|
||||
interface SortableItemContextValue {
|
||||
id: string;
|
||||
attributes: DraggableAttributes;
|
||||
listeners: DraggableSyntheticListeners | undefined;
|
||||
setActivatorNodeRef: (node: HTMLElement | null) => void;
|
||||
isDragging?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const SortableItemContext =
|
||||
React.createContext<SortableItemContextValue | null>(null);
|
||||
SortableItemContext.displayName = ITEM_NAME;
|
||||
|
||||
function useSortableItemContext(consumerName: string) {
|
||||
const context = React.useContext(SortableItemContext);
|
||||
if (!context) {
|
||||
throw new Error(`\`${consumerName}\` must be used within \`${ITEM_NAME}\``);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface SortableItemProps extends React.ComponentPropsWithoutRef<"div"> {
|
||||
value: UniqueIdentifier;
|
||||
asHandle?: boolean;
|
||||
asChild?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const SortableItem = React.forwardRef<HTMLDivElement, SortableItemProps>(
|
||||
(props, forwardedRef) => {
|
||||
const {
|
||||
value,
|
||||
style,
|
||||
asHandle,
|
||||
asChild,
|
||||
disabled,
|
||||
className,
|
||||
...itemProps
|
||||
} = props;
|
||||
|
||||
const inSortableContent = React.useContext(SortableContentContext);
|
||||
const inSortableOverlay = React.useContext(SortableOverlayContext);
|
||||
|
||||
if (!inSortableContent && !inSortableOverlay) {
|
||||
throw new Error(
|
||||
`\`${ITEM_NAME}\` must be used within \`${CONTENT_NAME}\` or \`${OVERLAY_NAME}\``,
|
||||
);
|
||||
}
|
||||
|
||||
if (value === "") {
|
||||
throw new Error(`\`${ITEM_NAME}\` value cannot be an empty string`);
|
||||
}
|
||||
|
||||
const context = useSortableContext(ITEM_NAME);
|
||||
const id = React.useId();
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: value, disabled });
|
||||
|
||||
const composedRef = useComposedRefs(forwardedRef, (node) => {
|
||||
if (disabled) return;
|
||||
setNodeRef(node);
|
||||
if (asHandle) setActivatorNodeRef(node);
|
||||
});
|
||||
|
||||
const composedStyle = React.useMemo<React.CSSProperties>(() => {
|
||||
return {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
...style,
|
||||
};
|
||||
}, [transform, transition, style]);
|
||||
|
||||
const itemContext = React.useMemo<SortableItemContextValue>(
|
||||
() => ({
|
||||
id,
|
||||
attributes,
|
||||
listeners,
|
||||
setActivatorNodeRef,
|
||||
isDragging,
|
||||
disabled,
|
||||
}),
|
||||
[id, attributes, listeners, setActivatorNodeRef, isDragging, disabled],
|
||||
);
|
||||
|
||||
const ItemPrimitive = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<SortableItemContext.Provider value={itemContext}>
|
||||
<ItemPrimitive
|
||||
id={id}
|
||||
data-disabled={disabled}
|
||||
data-dragging={isDragging ? "" : undefined}
|
||||
data-slot="sortable-item"
|
||||
{...itemProps}
|
||||
{...(asHandle && !disabled ? attributes : {})}
|
||||
{...(asHandle && !disabled ? listeners : {})}
|
||||
ref={composedRef}
|
||||
style={composedStyle}
|
||||
className={cn(
|
||||
"focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1",
|
||||
{
|
||||
"touch-none select-none": asHandle,
|
||||
"cursor-default": context.flatCursor,
|
||||
"data-dragging:cursor-grabbing": !context.flatCursor,
|
||||
"cursor-grab": !isDragging && asHandle && !context.flatCursor,
|
||||
"opacity-50": isDragging,
|
||||
"pointer-events-none opacity-50": disabled,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
</SortableItemContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
SortableItem.displayName = ITEM_NAME;
|
||||
|
||||
interface SortableItemHandleProps
|
||||
extends React.ComponentPropsWithoutRef<"button"> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const SortableItemHandle = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
SortableItemHandleProps
|
||||
>((props, forwardedRef) => {
|
||||
const { asChild, disabled, className, ...itemHandleProps } = props;
|
||||
|
||||
const context = useSortableContext(ITEM_HANDLE_NAME);
|
||||
const itemContext = useSortableItemContext(ITEM_HANDLE_NAME);
|
||||
|
||||
const isDisabled = disabled ?? itemContext.disabled;
|
||||
|
||||
const composedRef = useComposedRefs(forwardedRef, (node) => {
|
||||
if (!isDisabled) return;
|
||||
itemContext.setActivatorNodeRef(node);
|
||||
});
|
||||
|
||||
const HandlePrimitive = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<HandlePrimitive
|
||||
type="button"
|
||||
aria-controls={itemContext.id}
|
||||
data-disabled={isDisabled}
|
||||
data-dragging={itemContext.isDragging ? "" : undefined}
|
||||
data-slot="sortable-item-handle"
|
||||
{...itemHandleProps}
|
||||
{...(isDisabled ? {} : itemContext.attributes)}
|
||||
{...(isDisabled ? {} : itemContext.listeners)}
|
||||
ref={composedRef}
|
||||
className={cn(
|
||||
"select-none disabled:pointer-events-none disabled:opacity-50",
|
||||
context.flatCursor
|
||||
? "cursor-default"
|
||||
: "cursor-grab data-dragging:cursor-grabbing",
|
||||
className,
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SortableItemHandle.displayName = ITEM_HANDLE_NAME;
|
||||
|
||||
const SortableOverlayContext = React.createContext(false);
|
||||
SortableOverlayContext.displayName = OVERLAY_NAME;
|
||||
|
||||
const dropAnimation: DropAnimation = {
|
||||
sideEffects: defaultDropAnimationSideEffects({
|
||||
styles: {
|
||||
active: {
|
||||
opacity: "0.4",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
interface SortableOverlayProps
|
||||
extends Omit<React.ComponentPropsWithoutRef<typeof DragOverlay>, "children"> {
|
||||
container?: Element | DocumentFragment | null;
|
||||
children?:
|
||||
| ((params: { value: UniqueIdentifier }) => React.ReactNode)
|
||||
| React.ReactNode;
|
||||
}
|
||||
|
||||
function SortableOverlay(props: SortableOverlayProps) {
|
||||
const { container: containerProp, children, ...overlayProps } = props;
|
||||
|
||||
const context = useSortableContext(OVERLAY_NAME);
|
||||
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
React.useLayoutEffect(() => setMounted(true), []);
|
||||
|
||||
const container =
|
||||
containerProp ?? (mounted ? globalThis.document?.body : null);
|
||||
|
||||
if (!container) return null;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<DragOverlay
|
||||
dropAnimation={dropAnimation}
|
||||
modifiers={context.modifiers}
|
||||
className={cn(!context.flatCursor && "cursor-grabbing")}
|
||||
{...overlayProps}
|
||||
>
|
||||
<SortableOverlayContext.Provider value={true}>
|
||||
{context.activeId
|
||||
? typeof children === "function"
|
||||
? children({ value: context.activeId })
|
||||
: children
|
||||
: null}
|
||||
</SortableOverlayContext.Provider>
|
||||
</DragOverlay>,
|
||||
container,
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
SortableRoot as Sortable,
|
||||
SortableContent,
|
||||
SortableItem,
|
||||
SortableItemHandle,
|
||||
SortableOverlay
|
||||
};
|
||||
161
src/components/ui/svg-text.tsx
Normal file
161
src/components/ui/svg-text.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ElementType, ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SvgTextProps {
|
||||
/**
|
||||
* The SVG content to display inside the text
|
||||
*/
|
||||
svg: ReactNode;
|
||||
/**
|
||||
* The content to display (will have the SVG "inside" it)
|
||||
*/
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Additional className for the container
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Font size for the text mask (in viewport width units or CSS units)
|
||||
* @default "20vw"
|
||||
*/
|
||||
fontSize?: string | number;
|
||||
/**
|
||||
* Font weight for the text mask
|
||||
* @default "bold"
|
||||
*/
|
||||
fontWeight?: string | number;
|
||||
/**
|
||||
* The element type to render for the container
|
||||
* @default "div"
|
||||
*/
|
||||
as?: ElementType;
|
||||
}
|
||||
|
||||
/**
|
||||
* SvgText displays content with an SVG background fill effect.
|
||||
* The SVG is masked by the content, creating a dynamic text look.
|
||||
*/
|
||||
export function SvgText({
|
||||
svg,
|
||||
children,
|
||||
className = '',
|
||||
fontSize = '20vw',
|
||||
fontWeight = 'bold',
|
||||
as: Component = 'div',
|
||||
}: SvgTextProps) {
|
||||
const textRef = useRef<HTMLDivElement>(null);
|
||||
const [textDimensions, setTextDimensions] = useState({ width: 0, height: 0 });
|
||||
const content = React.Children.toArray(children).join('');
|
||||
const maskId = React.useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!textRef.current) return;
|
||||
|
||||
const updateDimensions = () => {
|
||||
const rect = textRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
setTextDimensions({
|
||||
width: Math.max(rect.width, 200),
|
||||
height: Math.max(rect.height, 100),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Initial measurement
|
||||
updateDimensions();
|
||||
|
||||
// Use ResizeObserver for better performance
|
||||
const resizeObserver = new ResizeObserver(updateDimensions);
|
||||
resizeObserver.observe(textRef.current);
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [content, fontSize, fontWeight]);
|
||||
|
||||
return (
|
||||
<Component className={cn('relative inline-block', className)}>
|
||||
{/* Hidden text for measuring */}
|
||||
<div
|
||||
ref={textRef}
|
||||
className="opacity-0 absolute pointer-events-none font-bold whitespace-nowrap"
|
||||
style={{
|
||||
fontSize: typeof fontSize === 'number' ? `${fontSize}px` : fontSize,
|
||||
fontWeight,
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
|
||||
{/* SVG with text mask */}
|
||||
<svg
|
||||
className="block"
|
||||
width={textDimensions.width}
|
||||
height={textDimensions.height}
|
||||
viewBox={`0 0 ${textDimensions.width} ${textDimensions.height}`}
|
||||
style={{
|
||||
fontSize: typeof fontSize === 'number' ? `${fontSize}px` : fontSize,
|
||||
fontWeight,
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<mask id={maskId}>
|
||||
<rect width="100%" height="100%" fill="black" />
|
||||
<text
|
||||
x="50%"
|
||||
y="50%"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fill="white"
|
||||
style={{
|
||||
fontSize: typeof fontSize === 'number' ? `${fontSize}px` : fontSize,
|
||||
fontWeight,
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</text>
|
||||
</mask>
|
||||
</defs>
|
||||
|
||||
{/* Background SVG with proper scaling */}
|
||||
<g mask={`url(#${maskId})`}>
|
||||
<foreignObject
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${textDimensions.width}px`,
|
||||
height: `${textDimensions.height}px`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '400px',
|
||||
height: '200px',
|
||||
transform: `scale(${Math.max(textDimensions.width / 400, textDimensions.height / 200)})`,
|
||||
transformOrigin: 'center',
|
||||
}}
|
||||
>
|
||||
{svg}
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Screen reader text */}
|
||||
<span className="sr-only">{content}</span>
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user