Hair Keeper v1.0.0:一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑

This commit is contained in:
2025-11-13 15:24:54 +08:00
commit 42be39b343
249 changed files with 38843 additions and 0 deletions

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

View 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-100undefined 表示未上传或已完成 */
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 }) => {
// 避免在最后一个事件中访问 eventdebounced
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
);
}

View 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 为文件的唯一标识 IDvalue 为 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 为 objectNamevalue 为 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>
);
});

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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