@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,79 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { type ReactNode } from "react";
|
|
4
|
+
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
5
|
+
import { motion, AnimatePresence } from "framer-motion";
|
|
6
|
+
import { cn } from "../../utils/cn";
|
|
7
|
+
|
|
8
|
+
export const Dialog = DialogPrimitive.Root;
|
|
9
|
+
export const DialogTrigger = DialogPrimitive.Trigger;
|
|
10
|
+
|
|
11
|
+
export interface DialogContentProps {
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* DialogContent — animated modal content (Radix + framer-motion spring).
|
|
18
|
+
*
|
|
19
|
+
* Uses Radix for focus trap / escape / portal, framer-motion for
|
|
20
|
+
* scale+fade spring animation on open/close.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* <Dialog>
|
|
24
|
+
* <DialogTrigger asChild><Button>打开</Button></DialogTrigger>
|
|
25
|
+
* <DialogContent>
|
|
26
|
+
* <DialogHeader>
|
|
27
|
+
* <DialogTitle>确认操作</DialogTitle>
|
|
28
|
+
* <DialogDescription>此操作不可撤销。</DialogDescription>
|
|
29
|
+
* </DialogHeader>
|
|
30
|
+
* <p>内容…</p>
|
|
31
|
+
* <DialogFooter>
|
|
32
|
+
* <DialogClose asChild><Button variant="secondary">取消</Button></DialogClose>
|
|
33
|
+
* <DialogClose asChild><Button>确认</Button></DialogClose>
|
|
34
|
+
* </DialogFooter>
|
|
35
|
+
* </DialogContent>
|
|
36
|
+
* </Dialog>
|
|
37
|
+
*/
|
|
38
|
+
export function DialogContent({ children, className }: DialogContentProps) {
|
|
39
|
+
return (
|
|
40
|
+
<DialogPrimitive.Portal>
|
|
41
|
+
<DialogPrimitive.Overlay asChild>
|
|
42
|
+
<motion.div
|
|
43
|
+
initial={{ opacity: 0 }}
|
|
44
|
+
animate={{ opacity: 1 }}
|
|
45
|
+
exit={{ opacity: 0 }}
|
|
46
|
+
transition={{ duration: 0.2 }}
|
|
47
|
+
className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm"
|
|
48
|
+
/>
|
|
49
|
+
</DialogPrimitive.Overlay>
|
|
50
|
+
<DialogPrimitive.Content asChild>
|
|
51
|
+
<motion.div
|
|
52
|
+
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
53
|
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
54
|
+
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
55
|
+
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
|
56
|
+
className={cn(
|
|
57
|
+
"fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2",
|
|
58
|
+
"rounded-xl border border-line bg-bg p-6 shadow-xl",
|
|
59
|
+
className
|
|
60
|
+
)}
|
|
61
|
+
>
|
|
62
|
+
{children}
|
|
63
|
+
</motion.div>
|
|
64
|
+
</DialogPrimitive.Content>
|
|
65
|
+
</DialogPrimitive.Portal>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function DialogHeader({ children, className }: { children: ReactNode; className?: string }) {
|
|
70
|
+
return <div className={cn("mb-4 flex flex-col gap-1", className)}>{children}</div>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function DialogFooter({ children, className }: { children: ReactNode; className?: string }) {
|
|
74
|
+
return <div className={cn("mt-6 flex justify-end gap-2", className)}>{children}</div>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const DialogTitle = DialogPrimitive.Title;
|
|
78
|
+
export const DialogDescription = DialogPrimitive.Description;
|
|
79
|
+
export const DialogClose = DialogPrimitive.Close;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { type ReactNode } from "react";
|
|
4
|
+
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
|
5
|
+
import { motion } from "framer-motion";
|
|
6
|
+
import { cn } from "../../utils/cn";
|
|
7
|
+
|
|
8
|
+
export const DropdownMenu = DropdownMenuPrimitive.Root;
|
|
9
|
+
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
|
10
|
+
|
|
11
|
+
export function DropdownMenuContent({
|
|
12
|
+
children,
|
|
13
|
+
className,
|
|
14
|
+
align = "start",
|
|
15
|
+
sideOffset = 4,
|
|
16
|
+
}: {
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
className?: string;
|
|
19
|
+
align?: "start" | "center" | "end";
|
|
20
|
+
sideOffset?: number;
|
|
21
|
+
}) {
|
|
22
|
+
return (
|
|
23
|
+
<DropdownMenuPrimitive.Portal>
|
|
24
|
+
<DropdownMenuPrimitive.Content
|
|
25
|
+
align={align}
|
|
26
|
+
sideOffset={sideOffset}
|
|
27
|
+
className={cn(
|
|
28
|
+
"z-50 min-w-[10rem] overflow-hidden rounded-lg border border-line bg-bg p-1 shadow-lg",
|
|
29
|
+
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
|
30
|
+
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
|
31
|
+
className
|
|
32
|
+
)}
|
|
33
|
+
>
|
|
34
|
+
{children}
|
|
35
|
+
</DropdownMenuPrimitive.Content>
|
|
36
|
+
</DropdownMenuPrimitive.Portal>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function DropdownMenuItem({
|
|
41
|
+
children,
|
|
42
|
+
className,
|
|
43
|
+
onSelect,
|
|
44
|
+
destructive,
|
|
45
|
+
}: {
|
|
46
|
+
children: ReactNode;
|
|
47
|
+
className?: string;
|
|
48
|
+
onSelect?: () => void;
|
|
49
|
+
destructive?: boolean;
|
|
50
|
+
}) {
|
|
51
|
+
return (
|
|
52
|
+
<DropdownMenuPrimitive.Item
|
|
53
|
+
onSelect={onSelect}
|
|
54
|
+
className={cn(
|
|
55
|
+
"flex cursor-pointer items-center gap-2 rounded-md px-2.5 py-1.5 text-sm outline-none transition-colors",
|
|
56
|
+
"focus:bg-surface-2 focus:text-ink data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
57
|
+
destructive ? "text-danger focus:bg-danger/10" : "text-ink-2",
|
|
58
|
+
className
|
|
59
|
+
)}
|
|
60
|
+
>
|
|
61
|
+
{children}
|
|
62
|
+
</DropdownMenuPrimitive.Item>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function DropdownMenuSeparator({ className }: { className?: string }) {
|
|
67
|
+
return <div className={cn("my-1 h-px bg-line", className)} />;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function DropdownMenuLabel({ children, className }: { children: ReactNode; className?: string }) {
|
|
71
|
+
return (
|
|
72
|
+
<div className={cn("px-2.5 py-1.5 text-xs font-semibold text-ink-3", className)}>
|
|
73
|
+
{children}
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
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 interface EmptyStateProps {
|
|
8
|
+
/** Optional icon/illustration node (default: a muted dot grid) */
|
|
9
|
+
icon?: ReactNode;
|
|
10
|
+
title: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
/** Optional call-to-action node (button, link, etc.) */
|
|
13
|
+
action?: ReactNode;
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const defaultIcon = (
|
|
18
|
+
<svg
|
|
19
|
+
viewBox="0 0 64 64"
|
|
20
|
+
fill="none"
|
|
21
|
+
className="h-12 w-12 text-ink-3"
|
|
22
|
+
aria-hidden
|
|
23
|
+
>
|
|
24
|
+
<circle cx="32" cy="32" r="28" stroke="currentColor" strokeWidth="2" />
|
|
25
|
+
<path
|
|
26
|
+
d="M22 34c2.5 3 5.5 4.5 10 4.5S39.5 37 42 34"
|
|
27
|
+
stroke="currentColor"
|
|
28
|
+
strokeWidth="2"
|
|
29
|
+
strokeLinecap="round"
|
|
30
|
+
/>
|
|
31
|
+
<circle cx="24" cy="26" r="2" fill="currentColor" />
|
|
32
|
+
<circle cx="40" cy="26" r="2" fill="currentColor" />
|
|
33
|
+
</svg>
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* EmptyState — a designed, centered empty placeholder.
|
|
38
|
+
*
|
|
39
|
+
* Icon + title + description + optional CTA, with generous padding and
|
|
40
|
+
* muted colors. Fades/slides in on mount via framer-motion.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* <EmptyState
|
|
44
|
+
* title="暂无数据"
|
|
45
|
+
* description="创建第一条记录后将会显示在这里"
|
|
46
|
+
* action={<Button>新建</Button>}
|
|
47
|
+
* />
|
|
48
|
+
*/
|
|
49
|
+
export function EmptyState({
|
|
50
|
+
icon,
|
|
51
|
+
title,
|
|
52
|
+
description,
|
|
53
|
+
action,
|
|
54
|
+
className,
|
|
55
|
+
}: EmptyStateProps) {
|
|
56
|
+
return (
|
|
57
|
+
<motion.div
|
|
58
|
+
initial={{ opacity: 0, y: 8 }}
|
|
59
|
+
animate={{ opacity: 1, y: 0 }}
|
|
60
|
+
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
|
61
|
+
className={cn(
|
|
62
|
+
"flex w-full flex-col items-center justify-center px-6 py-16 text-center",
|
|
63
|
+
className
|
|
64
|
+
)}
|
|
65
|
+
>
|
|
66
|
+
<div className="mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-surface-2">
|
|
67
|
+
{icon ?? defaultIcon}
|
|
68
|
+
</div>
|
|
69
|
+
<h3 className="text-base font-semibold text-ink">{title}</h3>
|
|
70
|
+
{description && (
|
|
71
|
+
<p className="mt-2 max-w-sm text-sm leading-relaxed text-ink-2">
|
|
72
|
+
{description}
|
|
73
|
+
</p>
|
|
74
|
+
)}
|
|
75
|
+
{action && <div className="mt-6">{action}</div>}
|
|
76
|
+
</motion.div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState, type HTMLAttributes } from "react";
|
|
4
|
+
import { cn } from "../../utils/cn";
|
|
5
|
+
|
|
6
|
+
export interface EncryptTextProps extends HTMLAttributes<HTMLSpanElement> {
|
|
7
|
+
/** The final decrypted text */
|
|
8
|
+
text: string;
|
|
9
|
+
/** Total decrypt duration in seconds */
|
|
10
|
+
duration?: number;
|
|
11
|
+
/** When to trigger the effect */
|
|
12
|
+
trigger?: "mount" | "hover";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*";
|
|
16
|
+
|
|
17
|
+
function randomChar(): string {
|
|
18
|
+
return CHARS[Math.floor(Math.random() * CHARS.length)];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* EncryptText — text that starts scrambled and "decrypts" to the real text.
|
|
23
|
+
*
|
|
24
|
+
* Uses requestAnimationFrame to cycle random characters, then settles each
|
|
25
|
+
* character to its real value progressively over `duration`.
|
|
26
|
+
*
|
|
27
|
+
* - `trigger="mount"` (default): decrypts on mount
|
|
28
|
+
* - `trigger="hover"`: waits until hovered, then decrypts
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* <EncryptText text="ACCESS_GRANTED" duration={2} />
|
|
32
|
+
* <EncryptText text="Hover me" trigger="hover" />
|
|
33
|
+
*/
|
|
34
|
+
export function EncryptText({
|
|
35
|
+
text,
|
|
36
|
+
duration = 2,
|
|
37
|
+
trigger = "mount",
|
|
38
|
+
className,
|
|
39
|
+
...props
|
|
40
|
+
}: EncryptTextProps) {
|
|
41
|
+
const [display, setDisplay] = useState(
|
|
42
|
+
trigger === "mount" ? text : text
|
|
43
|
+
);
|
|
44
|
+
const [active, setActive] = useState(trigger === "mount");
|
|
45
|
+
const rafRef = useRef<number | null>(null);
|
|
46
|
+
|
|
47
|
+
const run = useCallback(() => {
|
|
48
|
+
const totalChars = text.length;
|
|
49
|
+
const startTime = performance.now();
|
|
50
|
+
const totalMs = duration * 1000;
|
|
51
|
+
|
|
52
|
+
const tick = (now: number) => {
|
|
53
|
+
const elapsed = now - startTime;
|
|
54
|
+
const progress = Math.min(elapsed / totalMs, 1);
|
|
55
|
+
const revealedCount = Math.floor(progress * totalChars);
|
|
56
|
+
|
|
57
|
+
let out = "";
|
|
58
|
+
for (let i = 0; i < totalChars; i++) {
|
|
59
|
+
const ch = text[i];
|
|
60
|
+
if (ch === " ") {
|
|
61
|
+
out += " ";
|
|
62
|
+
} else if (i < revealedCount) {
|
|
63
|
+
out += ch;
|
|
64
|
+
} else {
|
|
65
|
+
out += randomChar();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
setDisplay(out);
|
|
69
|
+
|
|
70
|
+
if (progress < 1) {
|
|
71
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
72
|
+
} else {
|
|
73
|
+
setDisplay(text);
|
|
74
|
+
rafRef.current = null;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
|
79
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
80
|
+
}, [text, duration]);
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (active) run();
|
|
84
|
+
return () => {
|
|
85
|
+
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
|
86
|
+
};
|
|
87
|
+
}, [active, run]);
|
|
88
|
+
|
|
89
|
+
const handleMouseEnter = () => {
|
|
90
|
+
if (trigger === "hover" && !active) setActive(true);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<span
|
|
95
|
+
className={cn("font-mono", trigger === "hover" && "cursor-pointer", className)}
|
|
96
|
+
onMouseEnter={handleMouseEnter}
|
|
97
|
+
{...props}
|
|
98
|
+
>
|
|
99
|
+
{display}
|
|
100
|
+
</span>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
4
|
+
import { motion } from "framer-motion";
|
|
5
|
+
import { cn } from "../../utils/cn";
|
|
6
|
+
|
|
7
|
+
export interface EvervaultCardProps {
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
className?: string;
|
|
10
|
+
/** Text shown scrambled as random chars until hovered (default "ENCRYPTED") */
|
|
11
|
+
encryptedText?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* EvervaultCard — a dark "encrypted" card whose label shows random chars
|
|
18
|
+
* that resolve into the real text on hover.
|
|
19
|
+
*
|
|
20
|
+
* On mount the label scrambles through random characters. When hovered, the
|
|
21
|
+
* scramble resolves character-by-character into `encryptedText` (kept even
|
|
22
|
+
* when not hovered, so the label is meaningful — the scramble is the
|
|
23
|
+
* effect). Children are revealed/focused on hover.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* <EvervaultCard encryptedText="DECRYPTED">
|
|
27
|
+
* <div className="text-white">隐藏内容</div>
|
|
28
|
+
* </EvervaultCard>
|
|
29
|
+
*/
|
|
30
|
+
export function EvervaultCard({
|
|
31
|
+
children,
|
|
32
|
+
className,
|
|
33
|
+
encryptedText = "ENCRYPTED",
|
|
34
|
+
}: EvervaultCardProps) {
|
|
35
|
+
const [hovered, setHovered] = useState(false);
|
|
36
|
+
const [display, setDisplay] = useState(() => scramble(encryptedText.length));
|
|
37
|
+
const frame = useRef<number | null>(null);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
function tick(progress: number) {
|
|
41
|
+
const revealed = Math.floor(progress * encryptedText.length);
|
|
42
|
+
const next = encryptedText
|
|
43
|
+
.split("")
|
|
44
|
+
.map((ch, i) => {
|
|
45
|
+
if (ch === " ") return " ";
|
|
46
|
+
return i < revealed ? ch : randomChar();
|
|
47
|
+
})
|
|
48
|
+
.join("");
|
|
49
|
+
setDisplay(next);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (hovered) {
|
|
53
|
+
// Resolve: animate reveal from 0 → 1 over ~600ms
|
|
54
|
+
cancelAnimationFrame(frame.current ?? 0);
|
|
55
|
+
const start = performance.now();
|
|
56
|
+
const duration = 600;
|
|
57
|
+
const step = (now: number) => {
|
|
58
|
+
const p = Math.min((now - start) / duration, 1);
|
|
59
|
+
tick(p);
|
|
60
|
+
if (p < 1) frame.current = requestAnimationFrame(step);
|
|
61
|
+
};
|
|
62
|
+
frame.current = requestAnimationFrame(step);
|
|
63
|
+
} else {
|
|
64
|
+
// Scramble: keep cycling random chars
|
|
65
|
+
cancelAnimationFrame(frame.current ?? 0);
|
|
66
|
+
const interval = setInterval(() => {
|
|
67
|
+
setDisplay(scramble(encryptedText.length));
|
|
68
|
+
}, 60);
|
|
69
|
+
return () => clearInterval(interval);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return () => {
|
|
73
|
+
if (frame.current) cancelAnimationFrame(frame.current);
|
|
74
|
+
};
|
|
75
|
+
}, [hovered, encryptedText]);
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<motion.div
|
|
79
|
+
onMouseEnter={() => setHovered(true)}
|
|
80
|
+
onMouseLeave={() => setHovered(false)}
|
|
81
|
+
whileHover={{ scale: 1.01 }}
|
|
82
|
+
transition={{ duration: 0.2 }}
|
|
83
|
+
className={cn(
|
|
84
|
+
"group relative flex flex-col items-center gap-6 overflow-hidden rounded-xl border border-dark-line bg-dark-bg p-8",
|
|
85
|
+
className
|
|
86
|
+
)}
|
|
87
|
+
>
|
|
88
|
+
{/* The encrypted / resolving label */}
|
|
89
|
+
<div className="font-mono text-sm tracking-widest text-dark-ink/70 select-none">
|
|
90
|
+
{display}
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{/* Children — fade up on hover */}
|
|
94
|
+
<motion.div
|
|
95
|
+
className="relative z-10 w-full"
|
|
96
|
+
initial={false}
|
|
97
|
+
animate={{ opacity: hovered ? 1 : 0.4, y: hovered ? 0 : 6 }}
|
|
98
|
+
transition={{ duration: 0.3 }}
|
|
99
|
+
>
|
|
100
|
+
{children}
|
|
101
|
+
</motion.div>
|
|
102
|
+
</motion.div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function randomChar(): string {
|
|
107
|
+
return CHARS[Math.floor(Math.random() * CHARS.length)];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function scramble(length: number): string {
|
|
111
|
+
let out = "";
|
|
112
|
+
for (let i = 0; i < length; i++) out += randomChar();
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type ReactNode } from "react";
|
|
4
|
+
import { motion, AnimatePresence } from "framer-motion";
|
|
5
|
+
import { cn } from "../../utils/cn";
|
|
6
|
+
|
|
7
|
+
export interface ExpandableCardProps {
|
|
8
|
+
title: string;
|
|
9
|
+
/** Collapsed content (always visible) */
|
|
10
|
+
children?: ReactNode;
|
|
11
|
+
/** Content revealed when expanded */
|
|
12
|
+
expandedContent?: ReactNode;
|
|
13
|
+
defaultExpanded?: boolean;
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* ExpandableCard — card that expands on click to reveal more content.
|
|
19
|
+
*
|
|
20
|
+
* Uses framer-motion `AnimatePresence` with a height "auto" animation so the
|
|
21
|
+
* card smoothly grows. A chevron icon rotates to indicate state.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* <ExpandableCard title="详情" expandedContent={<p>更多内容…</p>}>
|
|
25
|
+
* 摘要文字
|
|
26
|
+
* </ExpandableCard>
|
|
27
|
+
*/
|
|
28
|
+
export function ExpandableCard({
|
|
29
|
+
title,
|
|
30
|
+
children,
|
|
31
|
+
expandedContent,
|
|
32
|
+
defaultExpanded = false,
|
|
33
|
+
className,
|
|
34
|
+
}: ExpandableCardProps) {
|
|
35
|
+
const [expanded, setExpanded] = useState(defaultExpanded);
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
className={cn(
|
|
40
|
+
"overflow-hidden rounded-xl border border-line bg-bg transition-colors hover:border-line-strong",
|
|
41
|
+
className
|
|
42
|
+
)}
|
|
43
|
+
>
|
|
44
|
+
<button
|
|
45
|
+
type="button"
|
|
46
|
+
onClick={() => setExpanded((prev) => !prev)}
|
|
47
|
+
aria-expanded={expanded}
|
|
48
|
+
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left"
|
|
49
|
+
>
|
|
50
|
+
<span className="text-sm font-semibold text-ink">{title}</span>
|
|
51
|
+
<motion.svg
|
|
52
|
+
animate={{ rotate: expanded ? 180 : 0 }}
|
|
53
|
+
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
|
54
|
+
className="h-4 w-4 flex-shrink-0 text-ink-2"
|
|
55
|
+
viewBox="0 0 24 24"
|
|
56
|
+
fill="none"
|
|
57
|
+
stroke="currentColor"
|
|
58
|
+
strokeWidth="2"
|
|
59
|
+
strokeLinecap="round"
|
|
60
|
+
strokeLinejoin="round"
|
|
61
|
+
>
|
|
62
|
+
<polyline points="6 9 12 15 18 9" />
|
|
63
|
+
</motion.svg>
|
|
64
|
+
</button>
|
|
65
|
+
|
|
66
|
+
{/* Collapsed (always-visible) content */}
|
|
67
|
+
{children && (
|
|
68
|
+
<div className="px-4 pb-3 text-sm leading-relaxed text-ink-2">
|
|
69
|
+
{children}
|
|
70
|
+
</div>
|
|
71
|
+
)}
|
|
72
|
+
|
|
73
|
+
{/* Expanded content with height auto animation */}
|
|
74
|
+
<AnimatePresence initial={false}>
|
|
75
|
+
{expanded && expandedContent && (
|
|
76
|
+
<motion.div
|
|
77
|
+
initial={{ height: 0, opacity: 0 }}
|
|
78
|
+
animate={{ height: "auto", opacity: 1 }}
|
|
79
|
+
exit={{ height: 0, opacity: 0 }}
|
|
80
|
+
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
|
81
|
+
className="overflow-hidden"
|
|
82
|
+
>
|
|
83
|
+
<div className="border-t border-line px-4 py-3 text-sm leading-relaxed text-ink-2">
|
|
84
|
+
{expandedContent}
|
|
85
|
+
</div>
|
|
86
|
+
</motion.div>
|
|
87
|
+
)}
|
|
88
|
+
</AnimatePresence>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|