@zmzai/theme 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +76 -0
- package/package.json +91 -0
- package/src/brand/index.ts +3 -0
- package/src/brand/logo/Logo.tsx +46 -0
- package/src/brand/logo/favicon.png +0 -0
- package/src/brand/logo/index.ts +2 -0
- package/src/brand/logo/zmzai-logo.png +0 -0
- package/src/brand/wordmark/Wordmark.tsx +48 -0
- package/src/brand/wordmark/index.ts +2 -0
- package/src/components/aceternity-sidebar/AceternitySidebar.tsx +133 -0
- package/src/components/aceternity-sidebar/index.ts +2 -0
- package/src/components/animated-tabs/AnimatedTabs.tsx +60 -0
- package/src/components/animated-tabs/index.ts +2 -0
- package/src/components/animated-tooltip/AnimatedTooltip.tsx +93 -0
- package/src/components/animated-tooltip/index.ts +2 -0
- package/src/components/avatar/Avatar.tsx +59 -0
- package/src/components/avatar/index.ts +2 -0
- package/src/components/badge/Badge.tsx +45 -0
- package/src/components/badge/index.ts +2 -0
- package/src/components/bento-grid/BentoGrid.tsx +85 -0
- package/src/components/bento-grid/index.ts +2 -0
- package/src/components/button/Button.tsx +66 -0
- package/src/components/button/button.styles.ts +41 -0
- package/src/components/button/index.ts +4 -0
- package/src/components/card/Card.tsx +87 -0
- package/src/components/card/card.styles.ts +35 -0
- package/src/components/card/index.ts +4 -0
- package/src/components/card-3d/Card3D.tsx +84 -0
- package/src/components/card-3d/index.ts +2 -0
- package/src/components/card-hover-effect/CardHoverEffect.tsx +83 -0
- package/src/components/card-hover-effect/index.ts +2 -0
- package/src/components/card-spotlight/CardSpotlight.tsx +63 -0
- package/src/components/card-spotlight/index.ts +2 -0
- package/src/components/carousel/Carousel.tsx +79 -0
- package/src/components/carousel/index.ts +2 -0
- package/src/components/code-block/CodeBlock.tsx +153 -0
- package/src/components/code-block/index.ts +2 -0
- package/src/components/comet-card/CometCard.tsx +106 -0
- package/src/components/comet-card/index.ts +2 -0
- package/src/components/compare/Compare.tsx +128 -0
- package/src/components/compare/index.ts +2 -0
- package/src/components/dialog/Dialog.tsx +79 -0
- package/src/components/dialog/index.ts +10 -0
- package/src/components/dropdown-menu/DropdownMenu.tsx +76 -0
- package/src/components/dropdown-menu/index.ts +8 -0
- package/src/components/empty-state/EmptyState.tsx +78 -0
- package/src/components/empty-state/index.ts +2 -0
- package/src/components/encrypt-text/EncryptText.tsx +102 -0
- package/src/components/encrypt-text/index.ts +2 -0
- package/src/components/evervault-card/EvervaultCard.tsx +114 -0
- package/src/components/evervault-card/index.ts +2 -0
- package/src/components/expandable-card/ExpandableCard.tsx +91 -0
- package/src/components/expandable-card/index.ts +2 -0
- package/src/components/file-upload/FileUpload.tsx +171 -0
- package/src/components/file-upload/index.ts +2 -0
- package/src/components/flip-words/FlipWords.tsx +70 -0
- package/src/components/flip-words/index.ts +2 -0
- package/src/components/floating-navbar/FloatingNavbar.tsx +82 -0
- package/src/components/floating-navbar/index.ts +2 -0
- package/src/components/focus-cards/FocusCards.tsx +93 -0
- package/src/components/focus-cards/index.ts +2 -0
- package/src/components/glare-card/GlareCard.tsx +66 -0
- package/src/components/glare-card/index.ts +2 -0
- package/src/components/glowing-effect/GlowingEffect.tsx +68 -0
- package/src/components/glowing-effect/index.ts +2 -0
- package/src/components/hover-border-gradient/HoverBorderGradient.tsx +80 -0
- package/src/components/hover-border-gradient/index.ts +2 -0
- package/src/components/index.ts +71 -0
- package/src/components/infinite-moving-cards/InfiniteMovingCards.tsx +84 -0
- package/src/components/infinite-moving-cards/index.ts +2 -0
- package/src/components/input/Input.tsx +35 -0
- package/src/components/input/index.ts +4 -0
- package/src/components/input/input.styles.ts +34 -0
- package/src/components/keyboard/Keyboard.tsx +38 -0
- package/src/components/keyboard/index.ts +2 -0
- package/src/components/layout-grid/LayoutGrid.tsx +121 -0
- package/src/components/layout-grid/index.ts +2 -0
- package/src/components/link-preview/LinkPreview.tsx +121 -0
- package/src/components/link-preview/index.ts +2 -0
- package/src/components/loader/Loader.tsx +130 -0
- package/src/components/loader/index.ts +2 -0
- package/src/components/magnetic-button/MagneticButton.tsx +79 -0
- package/src/components/magnetic-button/index.ts +2 -0
- package/src/components/moving-border/MovingBorder.tsx +72 -0
- package/src/components/moving-border/index.ts +2 -0
- package/src/components/multi-step-loader/MultiStepLoader.tsx +95 -0
- package/src/components/multi-step-loader/index.ts +2 -0
- package/src/components/notch/Notch.tsx +47 -0
- package/src/components/notch/index.ts +2 -0
- package/src/components/select/Select.tsx +100 -0
- package/src/components/select/index.ts +10 -0
- package/src/components/skeleton/Skeleton.tsx +34 -0
- package/src/components/skeleton/index.ts +2 -0
- package/src/components/stateful-button/StatefulButton.tsx +115 -0
- package/src/components/stateful-button/index.ts +2 -0
- package/src/components/sticky-banner/StickyBanner.tsx +74 -0
- package/src/components/sticky-banner/index.ts +2 -0
- package/src/components/sticky-scroll-reveal/StickyScrollReveal.tsx +141 -0
- package/src/components/sticky-scroll-reveal/index.ts +2 -0
- package/src/components/terminal/Terminal.tsx +55 -0
- package/src/components/terminal/index.ts +2 -0
- package/src/components/text-generate/TextGenerate.tsx +59 -0
- package/src/components/text-generate/index.ts +2 -0
- package/src/components/text-hover-effect/TextHoverEffect.tsx +90 -0
- package/src/components/text-hover-effect/index.ts +2 -0
- package/src/components/textarea/Textarea.tsx +39 -0
- package/src/components/textarea/index.ts +2 -0
- package/src/components/timeline/Timeline.tsx +106 -0
- package/src/components/timeline/index.ts +3 -0
- package/src/components/tooltip/Tooltip.tsx +51 -0
- package/src/components/tooltip/index.ts +2 -0
- package/src/components/tooltip-card/TooltipCard.tsx +130 -0
- package/src/components/tooltip-card/index.ts +2 -0
- package/src/components/tracing-beam/TracingBeam.tsx +110 -0
- package/src/components/tracing-beam/index.ts +2 -0
- package/src/components/typewriter/Typewriter.tsx +69 -0
- package/src/components/typewriter/index.ts +2 -0
- package/src/components/vanish-input/VanishInput.tsx +170 -0
- package/src/components/vanish-input/index.ts +2 -0
- package/src/components/wobble-card/WobbleCard.tsx +77 -0
- package/src/components/wobble-card/index.ts +2 -0
- package/src/index.ts +21 -0
- package/src/tokens/fonts.css +42 -0
- package/src/tokens/theme.css +120 -0
- package/src/utils/cn.ts +13 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/motion.ts +47 -0
- package/src/vite-env.d.ts +6 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
type MouseEvent,
|
|
8
|
+
} from "react";
|
|
9
|
+
import {
|
|
10
|
+
motion,
|
|
11
|
+
useMotionValue,
|
|
12
|
+
useSpring,
|
|
13
|
+
AnimatePresence,
|
|
14
|
+
} from "framer-motion";
|
|
15
|
+
import { cn } from "../../utils/cn";
|
|
16
|
+
|
|
17
|
+
export interface AnimatedTooltipProps {
|
|
18
|
+
/** Tooltip body content */
|
|
19
|
+
content: ReactNode;
|
|
20
|
+
/** The trigger element */
|
|
21
|
+
children: ReactNode;
|
|
22
|
+
className?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* AnimatedTooltip — a custom tooltip that slides in following the cursor.
|
|
27
|
+
*
|
|
28
|
+
* Unlike the Radix-based tooltip, this is built purely with framer-motion:
|
|
29
|
+
* on hover, a small card slides in from the side with spring physics and
|
|
30
|
+
* tracks the mouse X position. Based on Aceternity's Animated Tooltip,
|
|
31
|
+
* adapted to the zmzai monochrome palette.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* <AnimatedTooltip content="复制到剪贴板">
|
|
35
|
+
* <button>复制</button>
|
|
36
|
+
* </AnimatedTooltip>
|
|
37
|
+
*/
|
|
38
|
+
export function AnimatedTooltip({
|
|
39
|
+
content,
|
|
40
|
+
children,
|
|
41
|
+
className,
|
|
42
|
+
}: AnimatedTooltipProps) {
|
|
43
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
44
|
+
const [hovered, setHovered] = useState(false);
|
|
45
|
+
|
|
46
|
+
const mouseX = useMotionValue(0);
|
|
47
|
+
const mouseY = useMotionValue(0);
|
|
48
|
+
|
|
49
|
+
const springConfig = { stiffness: 200, damping: 22 };
|
|
50
|
+
const x = useSpring(mouseX, springConfig);
|
|
51
|
+
const y = useSpring(mouseY, springConfig);
|
|
52
|
+
|
|
53
|
+
const handleMouseMove = (e: MouseEvent<HTMLDivElement>) => {
|
|
54
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
55
|
+
mouseX.set(e.clientX - rect.left);
|
|
56
|
+
mouseY.set(e.clientY - rect.top);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div
|
|
61
|
+
ref={ref}
|
|
62
|
+
onMouseEnter={() => setHovered(true)}
|
|
63
|
+
onMouseLeave={() => setHovered(false)}
|
|
64
|
+
onMouseMove={handleMouseMove}
|
|
65
|
+
className={cn("relative inline-flex", className)}
|
|
66
|
+
>
|
|
67
|
+
{children}
|
|
68
|
+
|
|
69
|
+
<AnimatePresence>
|
|
70
|
+
{hovered && (
|
|
71
|
+
<motion.div
|
|
72
|
+
initial={{ opacity: 0, scale: 0.8, x: -10 }}
|
|
73
|
+
animate={{ opacity: 1, scale: 1, x: 0 }}
|
|
74
|
+
exit={{ opacity: 0, scale: 0.8, x: -10 }}
|
|
75
|
+
transition={{ type: "spring", stiffness: 350, damping: 24 }}
|
|
76
|
+
style={{
|
|
77
|
+
position: "absolute",
|
|
78
|
+
top: 0,
|
|
79
|
+
left: 0,
|
|
80
|
+
x,
|
|
81
|
+
// position above the cursor, centered horizontally
|
|
82
|
+
translateY: "-120%",
|
|
83
|
+
translateX: "-50%",
|
|
84
|
+
}}
|
|
85
|
+
className="pointer-events-none z-50 whitespace-nowrap rounded-lg border border-line bg-bg px-3 py-1.5 text-xs font-medium text-ink shadow-md"
|
|
86
|
+
>
|
|
87
|
+
{content}
|
|
88
|
+
</motion.div>
|
|
89
|
+
)}
|
|
90
|
+
</AnimatePresence>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type HTMLAttributes } from "react";
|
|
4
|
+
import { cn } from "../../utils/cn";
|
|
5
|
+
|
|
6
|
+
export interface AvatarProps extends HTMLAttributes<HTMLDivElement> {
|
|
7
|
+
/** Image source URL */
|
|
8
|
+
src?: string;
|
|
9
|
+
/** Alt text / fallback initials */
|
|
10
|
+
alt?: string;
|
|
11
|
+
/** Size in px (default 32) */
|
|
12
|
+
size?: number;
|
|
13
|
+
/** Shape (default circle) */
|
|
14
|
+
shape?: "circle" | "square";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Avatar — user/agent avatar with image + initials fallback.
|
|
19
|
+
*
|
|
20
|
+
* If `src` fails to load, shows initials from `alt` on a neutral background.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* <Avatar src="/me.png" alt="牧之" size={32} />
|
|
24
|
+
* <Avatar alt="AI" size={28} shape="square" />
|
|
25
|
+
*/
|
|
26
|
+
export function Avatar({
|
|
27
|
+
src,
|
|
28
|
+
alt = "",
|
|
29
|
+
size = 32,
|
|
30
|
+
shape = "circle",
|
|
31
|
+
className,
|
|
32
|
+
...props
|
|
33
|
+
}: AvatarProps) {
|
|
34
|
+
const [error, setError] = useState(false);
|
|
35
|
+
const initials = alt.slice(0, 2).toUpperCase();
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
className={cn(
|
|
40
|
+
"flex flex-shrink-0 items-center justify-center overflow-hidden bg-surface-2 text-ink-2 font-semibold",
|
|
41
|
+
shape === "circle" ? "rounded-full" : "rounded-lg",
|
|
42
|
+
className
|
|
43
|
+
)}
|
|
44
|
+
style={{ width: size, height: size, fontSize: size * 0.38 }}
|
|
45
|
+
{...props}
|
|
46
|
+
>
|
|
47
|
+
{src && !error ? (
|
|
48
|
+
<img
|
|
49
|
+
src={src}
|
|
50
|
+
alt={alt}
|
|
51
|
+
className="h-full w-full object-cover"
|
|
52
|
+
onError={() => setError(true)}
|
|
53
|
+
/>
|
|
54
|
+
) : (
|
|
55
|
+
<span>{initials}</span>
|
|
56
|
+
)}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { type HTMLAttributes } from "react";
|
|
4
|
+
import { cva } from "class-variance-authority";
|
|
5
|
+
import { cn } from "../../utils/cn";
|
|
6
|
+
|
|
7
|
+
export const badgeVariants = cva(
|
|
8
|
+
"inline-flex items-center gap-1.5 rounded-full font-medium text-xs whitespace-nowrap",
|
|
9
|
+
{
|
|
10
|
+
variants: {
|
|
11
|
+
variant: {
|
|
12
|
+
solid: "bg-ink text-white",
|
|
13
|
+
outline: "border border-line text-ink-2",
|
|
14
|
+
success: "border border-success/30 bg-success/10 text-success",
|
|
15
|
+
warning: "border border-warning/30 bg-warning/10 text-warning",
|
|
16
|
+
danger: "border border-danger/30 bg-danger/10 text-danger",
|
|
17
|
+
accent: "border border-accent/30 bg-accent/10 text-accent",
|
|
18
|
+
},
|
|
19
|
+
size: {
|
|
20
|
+
sm: "px-2 py-0.5 text-[10px]",
|
|
21
|
+
md: "px-2.5 py-0.5 text-xs",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
defaultVariants: { variant: "outline", size: "md" },
|
|
25
|
+
}
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
|
29
|
+
variant?: "solid" | "outline" | "success" | "warning" | "danger" | "accent";
|
|
30
|
+
size?: "sm" | "md";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Badge — small status/label indicator.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* <Badge variant="solid">活跃</Badge>
|
|
38
|
+
* <Badge variant="success">● 完成</Badge>
|
|
39
|
+
* <Badge variant="accent">需要审批</Badge>
|
|
40
|
+
*/
|
|
41
|
+
export function Badge({ className, variant, size, ...props }: BadgeProps) {
|
|
42
|
+
return (
|
|
43
|
+
<span className={cn(badgeVariants({ variant, size }), className)} {...props} />
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { type ReactNode } from "react";
|
|
4
|
+
import { motion } from "framer-motion";
|
|
5
|
+
import { cn } from "../../utils/cn";
|
|
6
|
+
|
|
7
|
+
export type BentoSpan = "1x1" | "2x1" | "1x2" | "2x2";
|
|
8
|
+
|
|
9
|
+
export interface BentoGridItem {
|
|
10
|
+
title: string;
|
|
11
|
+
description: string;
|
|
12
|
+
icon?: ReactNode;
|
|
13
|
+
/** Custom className to override/extend the card styling */
|
|
14
|
+
className?: string;
|
|
15
|
+
/** Grid span control. Default "1x1". Format: "cols x rows" */
|
|
16
|
+
span?: BentoSpan;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface BentoGridProps {
|
|
20
|
+
items: BentoGridItem[];
|
|
21
|
+
className?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Map span tokens to Tailwind grid-column / grid-row utilities */
|
|
25
|
+
const spanClassMap: Record<BentoSpan, string> = {
|
|
26
|
+
"1x1": "col-span-1 row-span-1",
|
|
27
|
+
"2x1": "col-span-2 row-span-1",
|
|
28
|
+
"1x2": "col-span-1 row-span-2",
|
|
29
|
+
"2x2": "col-span-2 row-span-2",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* BentoGrid — asymmetric grid layout (like Apple's bento).
|
|
34
|
+
*
|
|
35
|
+
* CSS grid with 2 columns + auto-rows. Each item's `span` controls its
|
|
36
|
+
* footprint. Items lift on hover via framer-motion.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* <BentoGrid items={[
|
|
40
|
+
* { title: "性能", description: "极速响应", span: "2x1" },
|
|
41
|
+
* { title: "安全", description: "端到端加密", span: "1x1" },
|
|
42
|
+
* ]} />
|
|
43
|
+
*/
|
|
44
|
+
export function BentoGrid({ items, className }: BentoGridProps) {
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
className={cn(
|
|
48
|
+
"grid auto-rows-[minmax(140px,1fr)] grid-cols-1 gap-3 sm:grid-cols-2",
|
|
49
|
+
className
|
|
50
|
+
)}
|
|
51
|
+
>
|
|
52
|
+
{items.map((item, index) => (
|
|
53
|
+
<BentoCard key={index} item={item} />
|
|
54
|
+
))}
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function BentoCard({ item }: { item: BentoGridItem }) {
|
|
60
|
+
const span = item.span ?? "1x1";
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<motion.div
|
|
64
|
+
whileHover={{ y: -4 }}
|
|
65
|
+
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
|
66
|
+
className={cn(
|
|
67
|
+
"group flex flex-col justify-between rounded-xl border border-line bg-bg p-5 shadow-sm transition-colors hover:border-line-strong",
|
|
68
|
+
spanClassMap[span],
|
|
69
|
+
item.className
|
|
70
|
+
)}
|
|
71
|
+
>
|
|
72
|
+
{item.icon && (
|
|
73
|
+
<div className="mb-3 flex h-8 w-8 items-center justify-center text-ink">
|
|
74
|
+
{item.icon}
|
|
75
|
+
</div>
|
|
76
|
+
)}
|
|
77
|
+
<div className="mt-auto">
|
|
78
|
+
<h3 className="text-base font-semibold text-ink">{item.title}</h3>
|
|
79
|
+
<p className="mt-1 text-sm leading-relaxed text-ink-2">
|
|
80
|
+
{item.description}
|
|
81
|
+
</p>
|
|
82
|
+
</div>
|
|
83
|
+
</motion.div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef, type ButtonHTMLAttributes } from "react";
|
|
4
|
+
import { motion, type HTMLMotionProps } from "framer-motion";
|
|
5
|
+
import { cn } from "../../utils/cn";
|
|
6
|
+
import { buttonVariants, type ButtonVariant, type ButtonSize } from "./button.styles";
|
|
7
|
+
|
|
8
|
+
export interface ButtonProps
|
|
9
|
+
extends Omit<HTMLMotionProps<"button">, "ref" | "children"> {
|
|
10
|
+
variant?: ButtonVariant;
|
|
11
|
+
size?: ButtonSize;
|
|
12
|
+
loading?: boolean;
|
|
13
|
+
children?: React.ReactNode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Button — primary interactive element.
|
|
18
|
+
*
|
|
19
|
+
* - Uses framer-motion for hover/active micro-interactions
|
|
20
|
+
* - Pill radius on all variants
|
|
21
|
+
* - `loading` prop shows a spinner and disables
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* <Button variant="primary" size="md">发送</Button>
|
|
25
|
+
* <Button variant="secondary" loading>保存中…</Button>
|
|
26
|
+
*/
|
|
27
|
+
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
28
|
+
({ className, variant, size, loading, children, disabled, ...props }, ref) => {
|
|
29
|
+
return (
|
|
30
|
+
<motion.button
|
|
31
|
+
ref={ref}
|
|
32
|
+
whileHover={disabled || loading ? undefined : { scale: 1.03 }}
|
|
33
|
+
whileTap={disabled || loading ? undefined : { scale: 0.98 }}
|
|
34
|
+
transition={{ duration: 0.15, ease: [0.16, 1, 0.3, 1] }}
|
|
35
|
+
className={cn(buttonVariants({ variant, size }), className)}
|
|
36
|
+
disabled={disabled || loading}
|
|
37
|
+
{...props}
|
|
38
|
+
>
|
|
39
|
+
{loading && (
|
|
40
|
+
<svg
|
|
41
|
+
className="h-4 w-4 animate-spin"
|
|
42
|
+
viewBox="0 0 24 24"
|
|
43
|
+
fill="none"
|
|
44
|
+
>
|
|
45
|
+
<circle
|
|
46
|
+
className="opacity-25"
|
|
47
|
+
cx="12"
|
|
48
|
+
cy="12"
|
|
49
|
+
r="10"
|
|
50
|
+
stroke="currentColor"
|
|
51
|
+
strokeWidth="3"
|
|
52
|
+
/>
|
|
53
|
+
<path
|
|
54
|
+
className="opacity-90"
|
|
55
|
+
fill="currentColor"
|
|
56
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
|
57
|
+
/>
|
|
58
|
+
</svg>
|
|
59
|
+
)}
|
|
60
|
+
{children}
|
|
61
|
+
</motion.button>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
Button.displayName = "Button";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { cva } from "class-variance-authority";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Button variant styles.
|
|
5
|
+
*
|
|
6
|
+
* Design:
|
|
7
|
+
* - All variants use pill radius (rounded-full)
|
|
8
|
+
* - primary: black fill, white text, hover scale + shadow
|
|
9
|
+
* - secondary: transparent, border, hover fill
|
|
10
|
+
* - ghost: no border, subtle hover bg
|
|
11
|
+
*/
|
|
12
|
+
export const buttonVariants = cva(
|
|
13
|
+
"inline-flex cursor-pointer items-center justify-center gap-2 rounded-full font-semibold transition-colors transition-transform disabled:pointer-events-none disabled:opacity-40 select-none",
|
|
14
|
+
{
|
|
15
|
+
variants: {
|
|
16
|
+
variant: {
|
|
17
|
+
primary:
|
|
18
|
+
"bg-ink text-white hover:shadow-md active:scale-[0.98]",
|
|
19
|
+
secondary:
|
|
20
|
+
"border border-line text-ink hover:border-ink hover:bg-surface-2",
|
|
21
|
+
ghost:
|
|
22
|
+
"text-ink-2 hover:bg-surface-2 hover:text-ink",
|
|
23
|
+
danger:
|
|
24
|
+
"bg-danger text-white hover:shadow-md active:scale-[0.98]",
|
|
25
|
+
},
|
|
26
|
+
size: {
|
|
27
|
+
sm: "h-8 px-4 text-xs",
|
|
28
|
+
md: "h-10 px-6 text-sm",
|
|
29
|
+
lg: "h-12 px-8 text-base",
|
|
30
|
+
icon: "h-10 w-10 p-0",
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
defaultVariants: {
|
|
34
|
+
variant: "primary",
|
|
35
|
+
size: "md",
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
|
|
41
|
+
export type ButtonSize = "sm" | "md" | "lg" | "icon";
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef, type HTMLAttributes } from "react";
|
|
4
|
+
import { motion, type HTMLMotionProps } from "framer-motion";
|
|
5
|
+
import { cn } from "../../utils/cn";
|
|
6
|
+
import { cardVariants, type CardVariant, type CardPadding } from "./card.styles";
|
|
7
|
+
|
|
8
|
+
export interface CardProps extends Omit<HTMLMotionProps<"div">, "ref"> {
|
|
9
|
+
variant?: CardVariant;
|
|
10
|
+
padding?: CardPadding;
|
|
11
|
+
/** Enable framer-motion hover lift (for interactive cards) */
|
|
12
|
+
animatedHover?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Card — surface container.
|
|
17
|
+
*
|
|
18
|
+
* - `default`: static white card with light border
|
|
19
|
+
* - `interactive`: hover border darkens + shadow + lift (via framer-motion if animatedHover)
|
|
20
|
+
* - `surface`: slightly grey background for nested panels
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* <Card padding="lg">内容</Card>
|
|
24
|
+
* <Card variant="interactive" animatedHover>可点击卡片</Card>
|
|
25
|
+
*/
|
|
26
|
+
export const Card = forwardRef<HTMLDivElement, CardProps>(
|
|
27
|
+
({ className, variant, padding, animatedHover, children, ...props }, ref) => {
|
|
28
|
+
const shouldAnimate = variant === "interactive" && animatedHover;
|
|
29
|
+
|
|
30
|
+
const MotionDiv = motion.div;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<MotionDiv
|
|
34
|
+
ref={ref}
|
|
35
|
+
whileHover={
|
|
36
|
+
shouldAnimate
|
|
37
|
+
? { y: -2, transition: { duration: 0.2, ease: [0.16, 1, 0.3, 1] } }
|
|
38
|
+
: undefined
|
|
39
|
+
}
|
|
40
|
+
className={cn(cardVariants({ variant, padding }), className)}
|
|
41
|
+
{...props}
|
|
42
|
+
>
|
|
43
|
+
{children}
|
|
44
|
+
</MotionDiv>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
Card.displayName = "Card";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Card.Header — top section of a card (often with title + actions).
|
|
53
|
+
*/
|
|
54
|
+
export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
|
55
|
+
({ className, ...props }, ref) => (
|
|
56
|
+
<div
|
|
57
|
+
ref={ref}
|
|
58
|
+
className={cn("flex items-center justify-between border-b border-line px-4 py-3", className)}
|
|
59
|
+
{...props}
|
|
60
|
+
/>
|
|
61
|
+
)
|
|
62
|
+
);
|
|
63
|
+
CardHeader.displayName = "CardHeader";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Card.Body — main content area.
|
|
67
|
+
*/
|
|
68
|
+
export const CardBody = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
|
69
|
+
({ className, ...props }, ref) => (
|
|
70
|
+
<div ref={ref} className={cn("px-4 py-3", className)} {...props} />
|
|
71
|
+
)
|
|
72
|
+
);
|
|
73
|
+
CardBody.displayName = "CardBody";
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Card.Footer — bottom section.
|
|
77
|
+
*/
|
|
78
|
+
export const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
|
79
|
+
({ className, ...props }, ref) => (
|
|
80
|
+
<div
|
|
81
|
+
ref={ref}
|
|
82
|
+
className={cn("flex items-center gap-2 border-t border-line px-4 py-3", className)}
|
|
83
|
+
{...props}
|
|
84
|
+
/>
|
|
85
|
+
)
|
|
86
|
+
);
|
|
87
|
+
CardFooter.displayName = "CardFooter";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { cva } from "class-variance-authority";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Card variant styles.
|
|
5
|
+
*
|
|
6
|
+
* Design:
|
|
7
|
+
* - default: white bg, light border, no hover
|
|
8
|
+
* - interactive: hover lifts up + shadow + border darkens
|
|
9
|
+
*/
|
|
10
|
+
export const cardVariants = cva(
|
|
11
|
+
"bg-bg border rounded-xl transition-colors transition-transform duration-200",
|
|
12
|
+
{
|
|
13
|
+
variants: {
|
|
14
|
+
variant: {
|
|
15
|
+
default: "border-line",
|
|
16
|
+
interactive:
|
|
17
|
+
"border-line hover:border-ink hover:-translate-y-0.5 hover:shadow-[0_8px_24px_rgba(0,0,0,0.08)] cursor-pointer",
|
|
18
|
+
surface: "bg-surface border-line",
|
|
19
|
+
},
|
|
20
|
+
padding: {
|
|
21
|
+
none: "",
|
|
22
|
+
sm: "p-3",
|
|
23
|
+
md: "p-4",
|
|
24
|
+
lg: "p-6",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
defaultVariants: {
|
|
28
|
+
variant: "default",
|
|
29
|
+
padding: "md",
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
export type CardVariant = "default" | "interactive" | "surface";
|
|
35
|
+
export type CardPadding = "none" | "sm" | "md" | "lg";
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef, type ReactNode, type MouseEvent } from "react";
|
|
4
|
+
import {
|
|
5
|
+
motion,
|
|
6
|
+
useMotionValue,
|
|
7
|
+
useSpring,
|
|
8
|
+
useTransform,
|
|
9
|
+
} from "framer-motion";
|
|
10
|
+
import { cn } from "../../utils/cn";
|
|
11
|
+
|
|
12
|
+
export interface Card3DProps {
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const MAX_TILT = 10; // deg
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Card3D — a card that tilts in 3D space following the cursor, with
|
|
21
|
+
* depth-shifted content and a slight lift on hover.
|
|
22
|
+
*
|
|
23
|
+
* The container sets CSS `perspective`; on mouse move the cursor's
|
|
24
|
+
* normalized position maps to `rotateX`/`rotateY` (max ±10°) smoothed via
|
|
25
|
+
* `useSpring`. Children are wrapped in a layer translated along Z for a
|
|
26
|
+
* parallax pop, and the whole card scales up slightly on hover.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* <Card3D className="p-8">
|
|
30
|
+
* <h3>立体卡片</h3>
|
|
31
|
+
* </Card3D>
|
|
32
|
+
*/
|
|
33
|
+
export function Card3D({ children, className }: Card3DProps) {
|
|
34
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
35
|
+
const mx = useMotionValue(0);
|
|
36
|
+
const my = useMotionValue(0);
|
|
37
|
+
|
|
38
|
+
const rotateX = useSpring(
|
|
39
|
+
useTransform(my, [-0.5, 0.5], [MAX_TILT, -MAX_TILT]),
|
|
40
|
+
{ stiffness: 150, damping: 18 }
|
|
41
|
+
);
|
|
42
|
+
const rotateY = useSpring(
|
|
43
|
+
useTransform(mx, [-0.5, 0.5], [-MAX_TILT, MAX_TILT]),
|
|
44
|
+
{ stiffness: 150, damping: 18 }
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
function handleMouseMove(e: MouseEvent<HTMLDivElement>) {
|
|
48
|
+
const el = ref.current;
|
|
49
|
+
if (!el) return;
|
|
50
|
+
const rect = el.getBoundingClientRect();
|
|
51
|
+
mx.set((e.clientX - rect.left) / rect.width - 0.5);
|
|
52
|
+
my.set((e.clientY - rect.top) / rect.height - 0.5);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function handleMouseLeave() {
|
|
56
|
+
mx.set(0);
|
|
57
|
+
my.set(0);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
ref={ref}
|
|
63
|
+
onMouseMove={handleMouseMove}
|
|
64
|
+
onMouseLeave={handleMouseLeave}
|
|
65
|
+
style={{ perspective: 1000 }}
|
|
66
|
+
className={cn("relative", className)}
|
|
67
|
+
>
|
|
68
|
+
<motion.div
|
|
69
|
+
style={{ rotateX, rotateY, transformStyle: "preserve-3d" }}
|
|
70
|
+
whileHover={{ scale: 1.03 }}
|
|
71
|
+
transition={{ type: "spring", stiffness: 200, damping: 20 }}
|
|
72
|
+
className="relative rounded-xl border border-line bg-surface"
|
|
73
|
+
>
|
|
74
|
+
{/* Depth-shifted content layer */}
|
|
75
|
+
<div
|
|
76
|
+
className="relative z-10 p-8"
|
|
77
|
+
style={{ transform: "translateZ(50px)" }}
|
|
78
|
+
>
|
|
79
|
+
{children}
|
|
80
|
+
</div>
|
|
81
|
+
</motion.div>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|