@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,83 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { type ReactNode } from "react";
|
|
4
|
+
import { motion, AnimatePresence } from "framer-motion";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
import { cn } from "../../utils/cn";
|
|
7
|
+
|
|
8
|
+
export interface CardHoverItem {
|
|
9
|
+
title: string;
|
|
10
|
+
description: string;
|
|
11
|
+
icon?: ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CardHoverEffectProps {
|
|
15
|
+
items: CardHoverItem[];
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* CardHoverEffect — a grid of cards where hovering one slides a shared
|
|
21
|
+
* highlight background between cards using framer-motion `layoutId`.
|
|
22
|
+
*
|
|
23
|
+
* The highlight is a single shared element animated via layoutId, so it
|
|
24
|
+
* smoothly translates from card to card as the cursor moves across the grid.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* <CardHoverEffect
|
|
28
|
+
* items={[
|
|
29
|
+
* { title: "生成", description: "一键生成内容" },
|
|
30
|
+
* { title: "分析", description: "智能数据分析" },
|
|
31
|
+
* ]}
|
|
32
|
+
* />
|
|
33
|
+
*/
|
|
34
|
+
export function CardHoverEffect({ items, className }: CardHoverEffectProps) {
|
|
35
|
+
const [hovered, setHovered] = useState<number | null>(null);
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
className={cn(
|
|
40
|
+
"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3",
|
|
41
|
+
className
|
|
42
|
+
)}
|
|
43
|
+
>
|
|
44
|
+
{items.map((item, idx) => (
|
|
45
|
+
<div
|
|
46
|
+
key={item.title + idx}
|
|
47
|
+
className="group relative"
|
|
48
|
+
onMouseEnter={() => setHovered(idx)}
|
|
49
|
+
onMouseLeave={() => setHovered(null)}
|
|
50
|
+
>
|
|
51
|
+
{/* Shared highlight background animated across cards */}
|
|
52
|
+
<AnimatePresence mode="popLayout">
|
|
53
|
+
{hovered === idx && (
|
|
54
|
+
<motion.span
|
|
55
|
+
layoutId="card-hover-highlight"
|
|
56
|
+
className="absolute inset-0 block rounded-xl bg-surface-2"
|
|
57
|
+
initial={{ opacity: 0 }}
|
|
58
|
+
animate={{ opacity: 1, transition: { duration: 0.15 } }}
|
|
59
|
+
exit={{ opacity: 0, transition: { duration: 0.15 } }}
|
|
60
|
+
/>
|
|
61
|
+
)}
|
|
62
|
+
</AnimatePresence>
|
|
63
|
+
|
|
64
|
+
<div className="relative z-10 h-full rounded-xl border border-line p-6 transition-colors duration-200 group-hover:border-line-strong">
|
|
65
|
+
{item.icon && (
|
|
66
|
+
<div className="mb-4 text-ink [&>svg]:h-6 [&>svg]:w-6">
|
|
67
|
+
{item.icon}
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
<div className="flex flex-col gap-2">
|
|
71
|
+
<h3 className="text-base font-semibold text-ink">
|
|
72
|
+
{item.title}
|
|
73
|
+
</h3>
|
|
74
|
+
<p className="text-sm leading-relaxed text-ink-2">
|
|
75
|
+
{item.description}
|
|
76
|
+
</p>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
))}
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef, type ReactNode, type MouseEvent } from "react";
|
|
4
|
+
import { motion, useMotionValue, useMotionTemplate } from "framer-motion";
|
|
5
|
+
import { cn } from "../../utils/cn";
|
|
6
|
+
|
|
7
|
+
export interface CardSpotlightProps {
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
className?: string;
|
|
10
|
+
/** Spotlight radius in px (default 300) */
|
|
11
|
+
radius?: number;
|
|
12
|
+
/** Spotlight color (default uses accent token via rgba) */
|
|
13
|
+
color?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* CardSpotlight — card with mouse-following radial spotlight.
|
|
18
|
+
*
|
|
19
|
+
* A subtle radial gradient follows the cursor inside the card,
|
|
20
|
+
* revealing a faint highlight. Based on Aceternity's Card Spotlight
|
|
21
|
+
* pattern, adapted to zmzai's monochrome palette.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* <CardSpotlight radius={250}>
|
|
25
|
+
* <h3>生成 PPT</h3>
|
|
26
|
+
* <p>自动生成演示文稿</p>
|
|
27
|
+
* </CardSpotlight>
|
|
28
|
+
*/
|
|
29
|
+
export function CardSpotlight({
|
|
30
|
+
children,
|
|
31
|
+
className,
|
|
32
|
+
radius = 300,
|
|
33
|
+
color = "rgba(0, 0, 0, 0.08)",
|
|
34
|
+
}: CardSpotlightProps) {
|
|
35
|
+
const mouseX = useMotionValue(0);
|
|
36
|
+
const mouseY = useMotionValue(0);
|
|
37
|
+
|
|
38
|
+
function handleMouseMove(e: MouseEvent<HTMLDivElement>) {
|
|
39
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
40
|
+
mouseX.set(e.clientX - rect.left);
|
|
41
|
+
mouseY.set(e.clientY - rect.top);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const spotlight = useMotionTemplate`radial-gradient(${radius}px circle at ${mouseX}px ${mouseY}px, ${color}, transparent 80%)`;
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div
|
|
48
|
+
onMouseMove={handleMouseMove}
|
|
49
|
+
className={cn(
|
|
50
|
+
"group relative overflow-hidden rounded-xl border border-line bg-bg transition-colors hover:border-line-strong",
|
|
51
|
+
className
|
|
52
|
+
)}
|
|
53
|
+
>
|
|
54
|
+
{/* Spotlight overlay */}
|
|
55
|
+
<motion.div
|
|
56
|
+
className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
|
57
|
+
style={{ background: spotlight }}
|
|
58
|
+
/>
|
|
59
|
+
{/* Content */}
|
|
60
|
+
<div className="relative z-10">{children}</div>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
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 CarouselProps {
|
|
8
|
+
items: ReactNode[];
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Carousel — minimal slide carousel with arrow nav + dot indicators.
|
|
14
|
+
*
|
|
15
|
+
* Clean monochrome design: chevron buttons appear on sides,
|
|
16
|
+
* dots at bottom. Slides animate with horizontal slide+fade.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* <Carousel items={[<div>A</div>, <div>B</div>, <div>C</div>]} />
|
|
20
|
+
*/
|
|
21
|
+
export function Carousel({ items, className }: CarouselProps) {
|
|
22
|
+
const [index, setIndex] = useState(0);
|
|
23
|
+
const [direction, setDirection] = useState(0);
|
|
24
|
+
|
|
25
|
+
const go = (next: number) => {
|
|
26
|
+
setDirection(next > index ? 1 : -1);
|
|
27
|
+
setIndex((next + items.length) % items.length);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className={cn("relative", className)}>
|
|
32
|
+
{/* Slide area */}
|
|
33
|
+
<div className="relative overflow-hidden rounded-xl">
|
|
34
|
+
<AnimatePresence mode="wait" custom={direction}>
|
|
35
|
+
<motion.div
|
|
36
|
+
key={index}
|
|
37
|
+
custom={direction}
|
|
38
|
+
initial={{ opacity: 0, x: direction * 60 }}
|
|
39
|
+
animate={{ opacity: 1, x: 0 }}
|
|
40
|
+
exit={{ opacity: 0, x: direction * -60 }}
|
|
41
|
+
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
|
|
42
|
+
>
|
|
43
|
+
{items[index]}
|
|
44
|
+
</motion.div>
|
|
45
|
+
</AnimatePresence>
|
|
46
|
+
|
|
47
|
+
{/* Prev */}
|
|
48
|
+
<button
|
|
49
|
+
onClick={() => go(index - 1)}
|
|
50
|
+
className="absolute left-2 top-1/2 z-10 flex h-8 w-8 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full border border-line bg-bg/80 text-ink-2 backdrop-blur transition-colors hover:border-ink hover:text-ink"
|
|
51
|
+
>
|
|
52
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="15 18 9 12 15 6" /></svg>
|
|
53
|
+
</button>
|
|
54
|
+
|
|
55
|
+
{/* Next */}
|
|
56
|
+
<button
|
|
57
|
+
onClick={() => go(index + 1)}
|
|
58
|
+
className="absolute right-2 top-1/2 z-10 flex h-8 w-8 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full border border-line bg-bg/80 text-ink-2 backdrop-blur transition-colors hover:border-ink hover:text-ink"
|
|
59
|
+
>
|
|
60
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 18 15 12 9 6" /></svg>
|
|
61
|
+
</button>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
{/* Dots */}
|
|
65
|
+
<div className="mt-4 flex justify-center gap-1.5">
|
|
66
|
+
{items.map((_, i) => (
|
|
67
|
+
<button
|
|
68
|
+
key={i}
|
|
69
|
+
onClick={() => go(i)}
|
|
70
|
+
className={cn(
|
|
71
|
+
"h-1.5 cursor-pointer rounded-full transition-all",
|
|
72
|
+
i === index ? "w-6 bg-ink" : "w-1.5 bg-line hover:bg-ink-3"
|
|
73
|
+
)}
|
|
74
|
+
/>
|
|
75
|
+
))}
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type ReactNode } from "react";
|
|
4
|
+
import { cn } from "../../utils/cn";
|
|
5
|
+
|
|
6
|
+
export interface CodeBlockProps {
|
|
7
|
+
/** Code content */
|
|
8
|
+
code: string;
|
|
9
|
+
/** Language label shown in header (e.g. "typescript", "bash") */
|
|
10
|
+
language?: string;
|
|
11
|
+
/** File name shown in header (e.g. "useUsers.ts") */
|
|
12
|
+
filename?: string;
|
|
13
|
+
/** Show copy button (default true) */
|
|
14
|
+
copyable?: boolean;
|
|
15
|
+
/** Max height before scroll (default none) */
|
|
16
|
+
maxHeight?: string;
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* CodeBlock — dark surface code display with syntax highlighting + copy.
|
|
22
|
+
*
|
|
23
|
+
* Uses a monochrome syntax highlight scheme (灰阶 + 粗细 + 斜体),
|
|
24
|
+
* matching zmzai's black-white design language. No rainbow colors.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* <CodeBlock code={`const x = 1;`} language="typescript" filename="index.ts" />
|
|
28
|
+
*/
|
|
29
|
+
export function CodeBlock({
|
|
30
|
+
code,
|
|
31
|
+
language = "text",
|
|
32
|
+
filename,
|
|
33
|
+
copyable = true,
|
|
34
|
+
maxHeight,
|
|
35
|
+
className,
|
|
36
|
+
}: CodeBlockProps) {
|
|
37
|
+
const [copied, setCopied] = useState(false);
|
|
38
|
+
|
|
39
|
+
const handleCopy = async () => {
|
|
40
|
+
try {
|
|
41
|
+
await navigator.clipboard.writeText(code);
|
|
42
|
+
setCopied(true);
|
|
43
|
+
setTimeout(() => setCopied(false), 2000);
|
|
44
|
+
} catch {
|
|
45
|
+
// clipboard not available
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
className={cn(
|
|
52
|
+
"overflow-hidden rounded-xl border border-dark-line bg-dark-bg shadow-md",
|
|
53
|
+
className
|
|
54
|
+
)}
|
|
55
|
+
>
|
|
56
|
+
{/* Header */}
|
|
57
|
+
<div className="flex items-center justify-between border-b border-dark-line bg-dark-surface px-3.5 py-2">
|
|
58
|
+
<div className="flex items-center gap-2">
|
|
59
|
+
{/* Traffic lights */}
|
|
60
|
+
<div className="flex gap-1.5">
|
|
61
|
+
<span className="h-2.5 w-2.5 rounded-full bg-[#ff5f56]" />
|
|
62
|
+
<span className="h-2.5 w-2.5 rounded-full bg-[#ffbd2e]" />
|
|
63
|
+
<span className="h-2.5 w-2.5 rounded-full bg-[#27c93f]" />
|
|
64
|
+
</div>
|
|
65
|
+
<span className="ml-1 font-mono text-xs font-medium text-dark-ink/70">
|
|
66
|
+
{filename || language}
|
|
67
|
+
</span>
|
|
68
|
+
</div>
|
|
69
|
+
{copyable && (
|
|
70
|
+
<button
|
|
71
|
+
onClick={handleCopy}
|
|
72
|
+
className="font-mono text-[11px] text-ink-3 transition-colors hover:text-dark-ink"
|
|
73
|
+
>
|
|
74
|
+
{copied ? "已复制 ✓" : "复制"}
|
|
75
|
+
</button>
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
{/* Body */}
|
|
80
|
+
<div
|
|
81
|
+
className="overflow-x-auto p-3.5"
|
|
82
|
+
style={maxHeight ? { maxHeight, overflowY: "auto" } : undefined}
|
|
83
|
+
>
|
|
84
|
+
<pre className="font-mono text-[13px] leading-relaxed text-dark-ink">
|
|
85
|
+
<code>{highlightCode(code, language)}</code>
|
|
86
|
+
</pre>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Lightweight monochrome syntax highlighter.
|
|
94
|
+
* Uses spans with Tailwind classes: keyword=bold white, string=light gray,
|
|
95
|
+
* comment=medium gray italic, function=light gray italic.
|
|
96
|
+
* Falls back to plain text for unrecognized languages.
|
|
97
|
+
*/
|
|
98
|
+
function highlightCode(code: string, _language: string): ReactNode {
|
|
99
|
+
// Simple regex-based highlighting for common patterns
|
|
100
|
+
// keyword detection
|
|
101
|
+
const keywordRe =
|
|
102
|
+
/\b(import|export|from|const|let|var|function|return|if|else|for|while|class|interface|type|extends|implements|new|async|await|try|catch|throw|typeof|instanceof|void|null|undefined|true|false|this|super|default|switch|case|break|continue|do|in|of|as|enum|public|private|protected|readonly|static|get|set|namespace|declare|abstract|yield|delete|with)\b/g;
|
|
103
|
+
const stringRe = /(["'`])((?:\\.|(?!\1).)*)\1/g;
|
|
104
|
+
const commentRe = /(\/\/.*$|\/\*[\s\S]*?\*\/)/gm;
|
|
105
|
+
|
|
106
|
+
// Escape HTML first
|
|
107
|
+
const escaped = code
|
|
108
|
+
.replace(/&/g, "&")
|
|
109
|
+
.replace(/</g, "<")
|
|
110
|
+
.replace(/>/g, ">");
|
|
111
|
+
|
|
112
|
+
// Apply highlights with placeholder tokens to avoid overlap
|
|
113
|
+
const tokens: { type: string; text: string }[] = [];
|
|
114
|
+
let working = escaped;
|
|
115
|
+
|
|
116
|
+
// Comments first (highest priority)
|
|
117
|
+
working = working.replace(commentRe, (match) => {
|
|
118
|
+
tokens.push({ type: "comment", text: match });
|
|
119
|
+
return `\x00${tokens.length - 1}\x00`;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Strings
|
|
123
|
+
working = working.replace(stringRe, (match) => {
|
|
124
|
+
tokens.push({ type: "string", text: match });
|
|
125
|
+
return `\x00${tokens.length - 1}\x00`;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Keywords
|
|
129
|
+
working = working.replace(keywordRe, (match) => {
|
|
130
|
+
tokens.push({ type: "keyword", text: match });
|
|
131
|
+
return `\x00${tokens.length - 1}\x00`;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Rebuild with spans
|
|
135
|
+
const parts = working.split(/\x00(\d+)\x00/);
|
|
136
|
+
return parts.map((part, i) => {
|
|
137
|
+
if (i % 2 === 1) {
|
|
138
|
+
const token = tokens[parseInt(part, 10)];
|
|
139
|
+
const cls =
|
|
140
|
+
token.type === "keyword"
|
|
141
|
+
? "font-semibold text-dark-ink"
|
|
142
|
+
: token.type === "string"
|
|
143
|
+
? "text-dark-ink/50"
|
|
144
|
+
: "text-dark-ink/40 italic";
|
|
145
|
+
return (
|
|
146
|
+
<span key={i} className={cls}>
|
|
147
|
+
{token.text}
|
|
148
|
+
</span>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
return <span key={i}>{part}</span>;
|
|
152
|
+
});
|
|
153
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef, useState, 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 CometCardProps {
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const MAX_TILT = 8; // deg
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* CometCard — a 3D-perspective tilt card with a "comet" light streak that
|
|
21
|
+
* shoots across the card on hover.
|
|
22
|
+
*
|
|
23
|
+
* The card tilts toward the cursor (CSS `perspective` + rotateX/rotateY).
|
|
24
|
+
* On each mouse enter a small gradient dot animates diagonally across the
|
|
25
|
+
* surface via framer-motion keyframes, leaving a light trail. A `key`
|
|
26
|
+
* counter remounts the comet so the streak replays on every entry.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* <CometCard className="p-8">
|
|
30
|
+
* <h3 className="text-white">悬停触发流星</h3>
|
|
31
|
+
* </CometCard>
|
|
32
|
+
*/
|
|
33
|
+
export function CometCard({ children, className }: CometCardProps) {
|
|
34
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
35
|
+
const [streakKey, setStreakKey] = useState(0);
|
|
36
|
+
|
|
37
|
+
const mx = useMotionValue(0);
|
|
38
|
+
const my = useMotionValue(0);
|
|
39
|
+
|
|
40
|
+
const rotateX = useSpring(
|
|
41
|
+
useTransform(my, [-0.5, 0.5], [MAX_TILT, -MAX_TILT]),
|
|
42
|
+
{ stiffness: 150, damping: 20 }
|
|
43
|
+
);
|
|
44
|
+
const rotateY = useSpring(
|
|
45
|
+
useTransform(mx, [-0.5, 0.5], [-MAX_TILT, MAX_TILT]),
|
|
46
|
+
{ stiffness: 150, damping: 20 }
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
function handleMouseMove(e: MouseEvent<HTMLDivElement>) {
|
|
50
|
+
const el = ref.current;
|
|
51
|
+
if (!el) return;
|
|
52
|
+
const rect = el.getBoundingClientRect();
|
|
53
|
+
mx.set((e.clientX - rect.left) / rect.width - 0.5);
|
|
54
|
+
my.set((e.clientY - rect.top) / rect.height - 0.5);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function handleMouseEnter() {
|
|
58
|
+
// Remount the comet so the streak replays on each entry.
|
|
59
|
+
setStreakKey((k) => k + 1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function handleMouseLeave() {
|
|
63
|
+
mx.set(0);
|
|
64
|
+
my.set(0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div
|
|
69
|
+
ref={ref}
|
|
70
|
+
onMouseMove={handleMouseMove}
|
|
71
|
+
onMouseEnter={handleMouseEnter}
|
|
72
|
+
onMouseLeave={handleMouseLeave}
|
|
73
|
+
style={{ perspective: 1000 }}
|
|
74
|
+
className={cn("group relative", className)}
|
|
75
|
+
>
|
|
76
|
+
<motion.div
|
|
77
|
+
style={{ rotateX, rotateY, transformStyle: "preserve-3d" }}
|
|
78
|
+
className="relative overflow-hidden rounded-xl border border-dark-line bg-dark-bg"
|
|
79
|
+
>
|
|
80
|
+
{/* Comet streak — replays on every mouse-enter via streakKey */}
|
|
81
|
+
<motion.span
|
|
82
|
+
key={streakKey}
|
|
83
|
+
className="pointer-events-none absolute z-20 h-24 w-24 rounded-full blur-2xl"
|
|
84
|
+
style={{
|
|
85
|
+
background:
|
|
86
|
+
"radial-gradient(circle, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.25) 45%, transparent 70%)",
|
|
87
|
+
}}
|
|
88
|
+
initial={{ x: "-25%", y: "-25%", opacity: 0 }}
|
|
89
|
+
animate={{
|
|
90
|
+
x: ["-25%", "125%"],
|
|
91
|
+
y: ["-25%", "125%"],
|
|
92
|
+
opacity: [0, 1, 1, 0],
|
|
93
|
+
}}
|
|
94
|
+
transition={{ duration: 1.1, ease: "easeInOut", times: [0, 0.2, 0.8, 1] }}
|
|
95
|
+
/>
|
|
96
|
+
|
|
97
|
+
<div
|
|
98
|
+
className="relative z-10 p-5"
|
|
99
|
+
style={{ transform: "translateZ(40px)" }}
|
|
100
|
+
>
|
|
101
|
+
{children}
|
|
102
|
+
</div>
|
|
103
|
+
</motion.div>
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
type HTMLAttributes,
|
|
9
|
+
type PointerEvent as ReactPointerEvent,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from "react";
|
|
12
|
+
import { cn } from "../../utils/cn";
|
|
13
|
+
|
|
14
|
+
export interface CompareProps extends HTMLAttributes<HTMLDivElement> {
|
|
15
|
+
/** Shown on the left (the "before") */
|
|
16
|
+
before: ReactNode;
|
|
17
|
+
/** Shown on the right (the "after") */
|
|
18
|
+
after: ReactNode;
|
|
19
|
+
/** Initial position of the handle as a percentage (0–100) */
|
|
20
|
+
initial?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Compare — a draggable before/after slider.
|
|
25
|
+
*
|
|
26
|
+
* Renders `before` and `after` layered in the same box. A vertical drag
|
|
27
|
+
* handle clips the `before` layer to the left of the handle; the `after`
|
|
28
|
+
* layer shows through on the right. The `before` content is sized to the
|
|
29
|
+
* full container width so it does not squish as the handle moves.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* <Compare before={<img src="/old.png" />} after={<img src="/new.png" />} />
|
|
33
|
+
*/
|
|
34
|
+
export function Compare({
|
|
35
|
+
before,
|
|
36
|
+
after,
|
|
37
|
+
initial = 50,
|
|
38
|
+
className,
|
|
39
|
+
...props
|
|
40
|
+
}: CompareProps) {
|
|
41
|
+
const [pos, setPos] = useState(initial);
|
|
42
|
+
const [width, setWidth] = useState(0);
|
|
43
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
44
|
+
const dragging = useRef(false);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const el = containerRef.current;
|
|
48
|
+
if (!el) return;
|
|
49
|
+
const measure = () => setWidth(el.getBoundingClientRect().width);
|
|
50
|
+
measure();
|
|
51
|
+
const ro = new ResizeObserver(measure);
|
|
52
|
+
ro.observe(el);
|
|
53
|
+
return () => ro.disconnect();
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
const updateFromClientX = useCallback((clientX: number) => {
|
|
57
|
+
const el = containerRef.current;
|
|
58
|
+
if (!el) return;
|
|
59
|
+
const rect = el.getBoundingClientRect();
|
|
60
|
+
const pct = ((clientX - rect.left) / rect.width) * 100;
|
|
61
|
+
setPos(Math.min(100, Math.max(0, pct)));
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
65
|
+
dragging.current = true;
|
|
66
|
+
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
|
|
67
|
+
updateFromClientX(e.clientX);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
71
|
+
if (!dragging.current) return;
|
|
72
|
+
updateFromClientX(e.clientX);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const onPointerUp = () => {
|
|
76
|
+
dragging.current = false;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div
|
|
81
|
+
ref={containerRef}
|
|
82
|
+
onPointerDown={onPointerDown}
|
|
83
|
+
onPointerMove={onPointerMove}
|
|
84
|
+
onPointerUp={onPointerUp}
|
|
85
|
+
onPointerLeave={onPointerUp}
|
|
86
|
+
className={cn(
|
|
87
|
+
"relative select-none overflow-hidden rounded-xl border border-line",
|
|
88
|
+
className
|
|
89
|
+
)}
|
|
90
|
+
style={{ touchAction: "none" }}
|
|
91
|
+
{...props}
|
|
92
|
+
>
|
|
93
|
+
{/* After (base layer, full width) */}
|
|
94
|
+
<div className="absolute inset-0">{after}</div>
|
|
95
|
+
|
|
96
|
+
{/* Before (clipped to the left of handle) */}
|
|
97
|
+
<div
|
|
98
|
+
className="absolute inset-y-0 left-0 overflow-hidden"
|
|
99
|
+
style={{ width: `${pos}%` }}
|
|
100
|
+
>
|
|
101
|
+
<div className="h-full" style={{ width: width ? `${width}px` : "100%" }}>
|
|
102
|
+
{before}
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
{/* Drag handle */}
|
|
107
|
+
<div
|
|
108
|
+
className="absolute inset-y-0 z-10 flex w-0.5 cursor-ew-resize items-center justify-center bg-ink"
|
|
109
|
+
style={{ left: `${pos}%`, transform: "translateX(-50%)" }}
|
|
110
|
+
>
|
|
111
|
+
<div className="flex h-9 w-9 items-center justify-center rounded-full border border-line bg-white text-ink shadow-md">
|
|
112
|
+
<svg
|
|
113
|
+
className="h-4 w-4"
|
|
114
|
+
viewBox="0 0 24 24"
|
|
115
|
+
fill="none"
|
|
116
|
+
stroke="currentColor"
|
|
117
|
+
strokeWidth="2"
|
|
118
|
+
strokeLinecap="round"
|
|
119
|
+
strokeLinejoin="round"
|
|
120
|
+
>
|
|
121
|
+
<polyline points="15 18 9 12 15 6" />
|
|
122
|
+
<polyline points="9 18 15 12 9 6" transform="translate(6 0)" />
|
|
123
|
+
</svg>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|