@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,71 @@
|
|
|
1
|
+
// @zmzai/theme — Components barrel (53 components)
|
|
2
|
+
|
|
3
|
+
// === Basic ===
|
|
4
|
+
export * from "./button";
|
|
5
|
+
export * from "./input";
|
|
6
|
+
export * from "./textarea";
|
|
7
|
+
export * from "./card";
|
|
8
|
+
export * from "./badge";
|
|
9
|
+
export * from "./avatar";
|
|
10
|
+
|
|
11
|
+
// === Radix-based ===
|
|
12
|
+
export * from "./tooltip";
|
|
13
|
+
export * from "./dialog";
|
|
14
|
+
export * from "./dropdown-menu";
|
|
15
|
+
export * from "./select";
|
|
16
|
+
|
|
17
|
+
// === Interactive Enhancement ===
|
|
18
|
+
export * from "./magnetic-button";
|
|
19
|
+
export * from "./glowing-effect";
|
|
20
|
+
export * from "./text-hover-effect";
|
|
21
|
+
export * from "./hover-border-gradient";
|
|
22
|
+
export * from "./card-spotlight";
|
|
23
|
+
export * from "./moving-border";
|
|
24
|
+
export * from "./stateful-button";
|
|
25
|
+
|
|
26
|
+
// === Layout & Structure ===
|
|
27
|
+
export * from "./floating-navbar";
|
|
28
|
+
export * from "./aceternity-sidebar";
|
|
29
|
+
export * from "./bento-grid";
|
|
30
|
+
export * from "./expandable-card";
|
|
31
|
+
export * from "./focus-cards";
|
|
32
|
+
export * from "./layout-grid";
|
|
33
|
+
export * from "./animated-tabs";
|
|
34
|
+
|
|
35
|
+
// === Content Display ===
|
|
36
|
+
export * from "./tracing-beam";
|
|
37
|
+
export * from "./sticky-scroll-reveal";
|
|
38
|
+
export * from "./file-upload";
|
|
39
|
+
export * from "./vanish-input";
|
|
40
|
+
export * from "./link-preview";
|
|
41
|
+
export * from "./animated-tooltip";
|
|
42
|
+
export * from "./tooltip-card";
|
|
43
|
+
export * from "./code-block";
|
|
44
|
+
export * from "./multi-step-loader";
|
|
45
|
+
export * from "./timeline";
|
|
46
|
+
|
|
47
|
+
// === Text Effects ===
|
|
48
|
+
export * from "./text-generate";
|
|
49
|
+
export * from "./encrypt-text";
|
|
50
|
+
export * from "./typewriter";
|
|
51
|
+
export * from "./flip-words";
|
|
52
|
+
|
|
53
|
+
// === Card Variants ===
|
|
54
|
+
export * from "./card-hover-effect";
|
|
55
|
+
export * from "./wobble-card";
|
|
56
|
+
export * from "./glare-card";
|
|
57
|
+
export * from "./comet-card";
|
|
58
|
+
export * from "./evervault-card";
|
|
59
|
+
export * from "./card-3d";
|
|
60
|
+
|
|
61
|
+
// === Utilities ===
|
|
62
|
+
export * from "./terminal";
|
|
63
|
+
export * from "./keyboard";
|
|
64
|
+
export * from "./notch";
|
|
65
|
+
export * from "./sticky-banner";
|
|
66
|
+
export * from "./compare";
|
|
67
|
+
export * from "./infinite-moving-cards";
|
|
68
|
+
export * from "./loader";
|
|
69
|
+
export * from "./skeleton";
|
|
70
|
+
export * from "./empty-state";
|
|
71
|
+
export * from "./carousel";
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { type CSSProperties, type HTMLAttributes, type ReactNode } from "react";
|
|
4
|
+
import { cn } from "../../utils/cn";
|
|
5
|
+
|
|
6
|
+
export interface InfiniteMovingCardsProps extends HTMLAttributes<HTMLDivElement> {
|
|
7
|
+
/** Cards to render in the marquee */
|
|
8
|
+
items: ReactNode[];
|
|
9
|
+
/** Scroll speed */
|
|
10
|
+
speed?: "slow" | "normal" | "fast";
|
|
11
|
+
/** Pause the animation on hover */
|
|
12
|
+
pauseOnHover?: boolean;
|
|
13
|
+
/** Scroll direction */
|
|
14
|
+
direction?: "left" | "right";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const speedMap: Record<NonNullable<InfiniteMovingCardsProps["speed"]>, string> = {
|
|
18
|
+
slow: "80s",
|
|
19
|
+
normal: "40s",
|
|
20
|
+
fast: "20s",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* InfiniteMovingCards — a horizontal marquee that loops forever.
|
|
25
|
+
*
|
|
26
|
+
* Renders `items` twice back-to-back inside a CSS keyframe animation so the
|
|
27
|
+
* loop is seamless. Pure CSS (no JS frame loop). The keyframes are injected
|
|
28
|
+
* once via a scoped style tag to keep the component self-contained.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* <InfiniteMovingCards items={logos} speed="slow" direction="left" />
|
|
32
|
+
*/
|
|
33
|
+
export function InfiniteMovingCards({
|
|
34
|
+
items,
|
|
35
|
+
speed = "normal",
|
|
36
|
+
pauseOnHover = true,
|
|
37
|
+
direction = "left",
|
|
38
|
+
className,
|
|
39
|
+
...props
|
|
40
|
+
}: InfiniteMovingCardsProps) {
|
|
41
|
+
const duration = speedMap[speed];
|
|
42
|
+
|
|
43
|
+
const trackStyle: CSSProperties = {
|
|
44
|
+
animationDuration: duration,
|
|
45
|
+
animationDirection: direction === "right" ? "reverse" : "normal",
|
|
46
|
+
animationPlayState: "running",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<>
|
|
51
|
+
<style>{`
|
|
52
|
+
@keyframes zmzai-marquee {
|
|
53
|
+
from { transform: translateX(0); }
|
|
54
|
+
to { transform: translateX(-50%); }
|
|
55
|
+
}
|
|
56
|
+
.zmzai-marquee-track {
|
|
57
|
+
animation: zmzai-marquee var(--marquee-duration, 40s) linear infinite;
|
|
58
|
+
}
|
|
59
|
+
`}</style>
|
|
60
|
+
|
|
61
|
+
<div
|
|
62
|
+
className={cn(
|
|
63
|
+
"group relative flex overflow-hidden",
|
|
64
|
+
className
|
|
65
|
+
)}
|
|
66
|
+
{...props}
|
|
67
|
+
>
|
|
68
|
+
<div
|
|
69
|
+
className={cn(
|
|
70
|
+
"zmzai-marquee-track flex w-max shrink-0 flex-nowrap items-stretch gap-4",
|
|
71
|
+
pauseOnHover && "group-hover:[animation-play-state:paused]"
|
|
72
|
+
)}
|
|
73
|
+
style={{ ...trackStyle, ["--marquee-duration" as string]: duration }}
|
|
74
|
+
>
|
|
75
|
+
{[...items, ...items].map((item, i) => (
|
|
76
|
+
<div key={i} className="shrink-0">
|
|
77
|
+
{item}
|
|
78
|
+
</div>
|
|
79
|
+
))}
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef, type InputHTMLAttributes } from "react";
|
|
4
|
+
import { cn } from "../../utils/cn";
|
|
5
|
+
import { inputVariants, type InputVariant, type InputSize } from "./input.styles";
|
|
6
|
+
|
|
7
|
+
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "size"> {
|
|
8
|
+
variant?: InputVariant;
|
|
9
|
+
size?: InputSize;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Input — text input field.
|
|
14
|
+
*
|
|
15
|
+
* - `default`: light border, focus turns border to ink
|
|
16
|
+
* - `brutal`: 2px ink border + hard offset shadow, focus shifts (neobrutalism)
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* <Input placeholder="搜索…" />
|
|
20
|
+
* <Input variant="brutal" placeholder="描述任务…" />
|
|
21
|
+
*/
|
|
22
|
+
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
|
23
|
+
({ className, variant, size, type = "text", ...props }, ref) => {
|
|
24
|
+
return (
|
|
25
|
+
<input
|
|
26
|
+
ref={ref}
|
|
27
|
+
type={type}
|
|
28
|
+
className={cn(inputVariants({ variant, size }), className)}
|
|
29
|
+
{...props}
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
Input.displayName = "Input";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { cva } from "class-variance-authority";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Input variant styles.
|
|
5
|
+
*
|
|
6
|
+
* Design:
|
|
7
|
+
* - default: light border, focus turns border to ink
|
|
8
|
+
* - brutal: 2px ink border + hard offset shadow (neobrutalism), focus shifts
|
|
9
|
+
*/
|
|
10
|
+
export const inputVariants = cva(
|
|
11
|
+
"w-full bg-bg font-sans text-ink placeholder:text-ink-3 outline-none transition-all",
|
|
12
|
+
{
|
|
13
|
+
variants: {
|
|
14
|
+
variant: {
|
|
15
|
+
default:
|
|
16
|
+
"border border-line rounded-lg px-4 py-2.5 text-sm focus:border-ink",
|
|
17
|
+
brutal:
|
|
18
|
+
"border-2 border-ink rounded-xl px-4 py-3 text-sm shadow-[4px_4px_0_var(--color-ink)] focus:shadow-[6px_6px_0_var(--color-accent)] focus:border-accent focus:-translate-x-px focus:-translate-y-px",
|
|
19
|
+
},
|
|
20
|
+
size: {
|
|
21
|
+
sm: "text-xs py-2 px-3",
|
|
22
|
+
md: "text-sm py-2.5 px-4",
|
|
23
|
+
lg: "text-base py-3 px-5",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
defaultVariants: {
|
|
27
|
+
variant: "default",
|
|
28
|
+
size: "md",
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
export type InputVariant = "default" | "brutal";
|
|
34
|
+
export type InputSize = "sm" | "md" | "lg";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
|
|
4
|
+
import { cn } from "../../utils/cn";
|
|
5
|
+
|
|
6
|
+
export interface KeyboardProps extends HTMLAttributes<HTMLElement> {
|
|
7
|
+
/** Key label(s), e.g. ⌘K */
|
|
8
|
+
children?: ReactNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Keyboard — a single keycap pill (e.g. ⌘K).
|
|
13
|
+
*
|
|
14
|
+
* NOT a full keyboard layout — renders one key with a border, mono font,
|
|
15
|
+
* and a subtle shadow for a physical-key look.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* <Keyboard>⌘K</Keyboard>
|
|
19
|
+
* <Keyboard className="px-3">Shift</Keyboard>
|
|
20
|
+
*/
|
|
21
|
+
export const Keyboard = forwardRef<HTMLElement, KeyboardProps>(
|
|
22
|
+
({ children, className, ...props }, ref) => {
|
|
23
|
+
return (
|
|
24
|
+
<kbd
|
|
25
|
+
ref={ref as React.Ref<HTMLElement>}
|
|
26
|
+
className={cn(
|
|
27
|
+
"inline-flex select-none items-center justify-center whitespace-nowrap rounded-md border border-line bg-surface px-1.5 py-0.5 font-mono text-xs font-medium text-ink-2 shadow-sm",
|
|
28
|
+
className
|
|
29
|
+
)}
|
|
30
|
+
{...props}
|
|
31
|
+
>
|
|
32
|
+
{children}
|
|
33
|
+
</kbd>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
Keyboard.displayName = "Keyboard";
|
|
@@ -0,0 +1,121 @@
|
|
|
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 LayoutGridItem {
|
|
8
|
+
id: string;
|
|
9
|
+
title: string;
|
|
10
|
+
content: ReactNode;
|
|
11
|
+
icon?: ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface LayoutGridProps {
|
|
15
|
+
items: LayoutGridItem[];
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* LayoutGrid — grid where clicking an item animates it to fullscreen.
|
|
21
|
+
*
|
|
22
|
+
* Uses framer-motion `layoutId` so the clicked card smoothly morphs into a
|
|
23
|
+
* fullscreen overlay. Clicking the overlay (or close button) collapses it back.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* <LayoutGrid items={[
|
|
27
|
+
* { id: "1", title: "项目 A", content: <p>详情…</p> },
|
|
28
|
+
* { id: "2", title: "项目 B", content: <p>详情…</p> },
|
|
29
|
+
* ]} />
|
|
30
|
+
*/
|
|
31
|
+
export function LayoutGrid({ items, className }: LayoutGridProps) {
|
|
32
|
+
const [activeId, setActiveId] = useState<string | null>(null);
|
|
33
|
+
const activeItem = items.find((item) => item.id === activeId) ?? null;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<>
|
|
37
|
+
<div
|
|
38
|
+
className={cn(
|
|
39
|
+
"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3",
|
|
40
|
+
className
|
|
41
|
+
)}
|
|
42
|
+
>
|
|
43
|
+
{items.map((item) => (
|
|
44
|
+
<motion.button
|
|
45
|
+
type="button"
|
|
46
|
+
key={item.id}
|
|
47
|
+
layoutId={`layout-card-${item.id}`}
|
|
48
|
+
onClick={() => setActiveId(item.id)}
|
|
49
|
+
whileHover={{ y: -4 }}
|
|
50
|
+
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
|
51
|
+
className={cn(
|
|
52
|
+
"flex min-h-[160px] flex-col rounded-xl border border-line bg-bg p-5 text-left shadow-sm transition-colors hover:border-line-strong"
|
|
53
|
+
)}
|
|
54
|
+
>
|
|
55
|
+
{item.icon && (
|
|
56
|
+
<div className="mb-3 flex h-8 w-8 items-center justify-center text-ink">
|
|
57
|
+
{item.icon}
|
|
58
|
+
</div>
|
|
59
|
+
)}
|
|
60
|
+
<h3 className="mt-auto text-base font-semibold text-ink">
|
|
61
|
+
{item.title}
|
|
62
|
+
</h3>
|
|
63
|
+
</motion.button>
|
|
64
|
+
))}
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
{/* Fullscreen overlay */}
|
|
68
|
+
<AnimatePresence>
|
|
69
|
+
{activeItem && (
|
|
70
|
+
<motion.div
|
|
71
|
+
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-8"
|
|
72
|
+
initial={{ backgroundColor: "rgba(0,0,0,0)" }}
|
|
73
|
+
animate={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
|
74
|
+
exit={{ backgroundColor: "rgba(0,0,0,0)" }}
|
|
75
|
+
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
|
76
|
+
onClick={() => setActiveId(null)}
|
|
77
|
+
>
|
|
78
|
+
<motion.div
|
|
79
|
+
layoutId={`layout-card-${activeItem.id}`}
|
|
80
|
+
onClick={(e) => e.stopPropagation()}
|
|
81
|
+
className="relative max-h-[85vh] w-full max-w-2xl overflow-y-auto rounded-xl border border-line bg-bg p-6 shadow-2xl"
|
|
82
|
+
>
|
|
83
|
+
{/* Close button */}
|
|
84
|
+
<button
|
|
85
|
+
type="button"
|
|
86
|
+
onClick={() => setActiveId(null)}
|
|
87
|
+
aria-label="关闭"
|
|
88
|
+
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full text-ink-2 transition-colors hover:bg-surface-2 hover:text-ink"
|
|
89
|
+
>
|
|
90
|
+
<svg
|
|
91
|
+
className="h-4 w-4"
|
|
92
|
+
viewBox="0 0 24 24"
|
|
93
|
+
fill="none"
|
|
94
|
+
stroke="currentColor"
|
|
95
|
+
strokeWidth="2"
|
|
96
|
+
strokeLinecap="round"
|
|
97
|
+
strokeLinejoin="round"
|
|
98
|
+
>
|
|
99
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
100
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
101
|
+
</svg>
|
|
102
|
+
</button>
|
|
103
|
+
|
|
104
|
+
{activeItem.icon && (
|
|
105
|
+
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-surface-2 text-ink">
|
|
106
|
+
{activeItem.icon}
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
<h2 className="mb-4 pr-10 text-xl font-bold text-ink">
|
|
110
|
+
{activeItem.title}
|
|
111
|
+
</h2>
|
|
112
|
+
<div className="text-sm leading-relaxed text-ink-2">
|
|
113
|
+
{activeItem.content}
|
|
114
|
+
</div>
|
|
115
|
+
</motion.div>
|
|
116
|
+
</motion.div>
|
|
117
|
+
)}
|
|
118
|
+
</AnimatePresence>
|
|
119
|
+
</>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
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 LinkPreviewProps {
|
|
18
|
+
/** The trigger element — typically an <a> or text */
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
url: string;
|
|
21
|
+
title: string;
|
|
22
|
+
description: string;
|
|
23
|
+
/** Optional preview image URL */
|
|
24
|
+
image?: string;
|
|
25
|
+
className?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* LinkPreview — hover a link to reveal a floating preview card.
|
|
30
|
+
*
|
|
31
|
+
* On hover, a card (image + title + description) fades/scales in and
|
|
32
|
+
* follows the cursor horizontally. Based on Aceternity's Link Preview,
|
|
33
|
+
* adapted to the zmzai monochrome palette.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* <LinkPreview
|
|
37
|
+
* url="https://example.com"
|
|
38
|
+
* title="示例标题"
|
|
39
|
+
* description="一段描述文字"
|
|
40
|
+
* image="/og.png"
|
|
41
|
+
* >
|
|
42
|
+
* <a href="https://example.com">悬停查看</a>
|
|
43
|
+
* </LinkPreview>
|
|
44
|
+
*/
|
|
45
|
+
export function LinkPreview({
|
|
46
|
+
children,
|
|
47
|
+
url,
|
|
48
|
+
title,
|
|
49
|
+
description,
|
|
50
|
+
image,
|
|
51
|
+
className,
|
|
52
|
+
}: LinkPreviewProps) {
|
|
53
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
54
|
+
const [hovered, setHovered] = useState(false);
|
|
55
|
+
|
|
56
|
+
// raw mouse position (relative to viewport)
|
|
57
|
+
const mouseX = useMotionValue(0);
|
|
58
|
+
const mouseY = useMotionValue(0);
|
|
59
|
+
|
|
60
|
+
// spring-smoothed for the card
|
|
61
|
+
const springX = useSpring(mouseX, { stiffness: 200, damping: 25 });
|
|
62
|
+
const springY = useSpring(mouseY, { stiffness: 200, damping: 25 });
|
|
63
|
+
|
|
64
|
+
const handleMouseMove = (e: MouseEvent<HTMLDivElement>) => {
|
|
65
|
+
mouseX.set(e.clientX);
|
|
66
|
+
mouseY.set(e.clientY);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div
|
|
71
|
+
ref={ref}
|
|
72
|
+
onMouseEnter={() => setHovered(true)}
|
|
73
|
+
onMouseLeave={() => setHovered(false)}
|
|
74
|
+
onMouseMove={handleMouseMove}
|
|
75
|
+
className={cn("relative inline-block", className)}
|
|
76
|
+
>
|
|
77
|
+
{children}
|
|
78
|
+
|
|
79
|
+
{/* Floating preview card — portal-free, fixed to viewport */}
|
|
80
|
+
<AnimatePresence>
|
|
81
|
+
{hovered && (
|
|
82
|
+
<motion.div
|
|
83
|
+
initial={{ opacity: 0, scale: 0.92, y: 8 }}
|
|
84
|
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
85
|
+
exit={{ opacity: 0, scale: 0.92, y: 8 }}
|
|
86
|
+
transition={{ type: "spring", stiffness: 260, damping: 24 }}
|
|
87
|
+
style={{
|
|
88
|
+
position: "fixed",
|
|
89
|
+
top: 0,
|
|
90
|
+
left: 0,
|
|
91
|
+
// offset so the card sits above the cursor; translate to center
|
|
92
|
+
x: springX,
|
|
93
|
+
y: springY,
|
|
94
|
+
// shift card up & left so it doesn't sit under the cursor
|
|
95
|
+
translateX: "-50%",
|
|
96
|
+
translateY: "-130%",
|
|
97
|
+
}}
|
|
98
|
+
className="pointer-events-none z-50 w-72 origin-bottom overflow-hidden rounded-xl border border-line bg-bg shadow-lg"
|
|
99
|
+
>
|
|
100
|
+
{image && (
|
|
101
|
+
<div className="aspect-video w-full overflow-hidden bg-surface">
|
|
102
|
+
<img
|
|
103
|
+
src={image}
|
|
104
|
+
alt={title}
|
|
105
|
+
className="h-full w-full object-cover"
|
|
106
|
+
/>
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
<div className="space-y-1.5 p-4">
|
|
110
|
+
<h4 className="text-sm font-semibold text-ink">{title}</h4>
|
|
111
|
+
<p className="line-clamp-2 text-xs text-ink-2">
|
|
112
|
+
{description}
|
|
113
|
+
</p>
|
|
114
|
+
<p className="truncate text-[11px] text-ink-3">{url}</p>
|
|
115
|
+
</div>
|
|
116
|
+
</motion.div>
|
|
117
|
+
)}
|
|
118
|
+
</AnimatePresence>
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { type HTMLAttributes } from "react";
|
|
4
|
+
import { motion } from "framer-motion";
|
|
5
|
+
import { cn } from "../../utils/cn";
|
|
6
|
+
|
|
7
|
+
export type LoaderVariant = "spinner" | "dots" | "bars" | "ring" | "pulse";
|
|
8
|
+
|
|
9
|
+
export interface LoaderProps extends HTMLAttributes<HTMLDivElement> {
|
|
10
|
+
/** Spinner style */
|
|
11
|
+
variant?: LoaderVariant;
|
|
12
|
+
/** Size in pixels (default 24) */
|
|
13
|
+
size?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const easeOut = [0.16, 1, 0.3, 1] as const;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Loader — minimal loading indicators.
|
|
20
|
+
*
|
|
21
|
+
* Variants:
|
|
22
|
+
* - `spinner`: rotating arc
|
|
23
|
+
* - `dots`: three bouncing dots
|
|
24
|
+
* - `bars`: four equalizing bars
|
|
25
|
+
* - `ring`: pulsing ring
|
|
26
|
+
* - `pulse`: single pulsing dot
|
|
27
|
+
*
|
|
28
|
+
* Default size 24px, monochrome `text-ink`.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* <Loader variant="dots" size={28} />
|
|
32
|
+
*/
|
|
33
|
+
export function Loader({
|
|
34
|
+
variant = "spinner",
|
|
35
|
+
size = 24,
|
|
36
|
+
className,
|
|
37
|
+
style,
|
|
38
|
+
...props
|
|
39
|
+
}: LoaderProps) {
|
|
40
|
+
const wrapperStyle = { width: size, height: size, ...style };
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div
|
|
44
|
+
className={cn("inline-flex items-center justify-center text-ink", className)}
|
|
45
|
+
style={wrapperStyle}
|
|
46
|
+
role="status"
|
|
47
|
+
aria-label="loading"
|
|
48
|
+
{...props}
|
|
49
|
+
>
|
|
50
|
+
{variant === "spinner" && (
|
|
51
|
+
<motion.svg
|
|
52
|
+
className="h-full w-full"
|
|
53
|
+
viewBox="0 0 24 24"
|
|
54
|
+
fill="none"
|
|
55
|
+
animate={{ rotate: 360 }}
|
|
56
|
+
transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}
|
|
57
|
+
>
|
|
58
|
+
<circle
|
|
59
|
+
className="opacity-25"
|
|
60
|
+
cx="12"
|
|
61
|
+
cy="12"
|
|
62
|
+
r="9"
|
|
63
|
+
stroke="currentColor"
|
|
64
|
+
strokeWidth="3"
|
|
65
|
+
/>
|
|
66
|
+
<path
|
|
67
|
+
className="opacity-90"
|
|
68
|
+
fill="currentColor"
|
|
69
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
|
70
|
+
/>
|
|
71
|
+
</motion.svg>
|
|
72
|
+
)}
|
|
73
|
+
|
|
74
|
+
{variant === "dots" && (
|
|
75
|
+
<div className="flex h-full w-full items-center justify-center gap-[15%]">
|
|
76
|
+
{[0, 1, 2].map((i) => (
|
|
77
|
+
<motion.span
|
|
78
|
+
key={i}
|
|
79
|
+
className="block rounded-full bg-current"
|
|
80
|
+
style={{ width: "22%", height: "22%" }}
|
|
81
|
+
animate={{ y: [0, -size * 0.2, 0], opacity: [0.4, 1, 0.4] }}
|
|
82
|
+
transition={{
|
|
83
|
+
duration: 0.9,
|
|
84
|
+
repeat: Infinity,
|
|
85
|
+
ease: easeOut,
|
|
86
|
+
delay: i * 0.15,
|
|
87
|
+
}}
|
|
88
|
+
/>
|
|
89
|
+
))}
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
|
|
93
|
+
{variant === "bars" && (
|
|
94
|
+
<div className="flex h-full w-full items-end justify-center gap-[15%]">
|
|
95
|
+
{[0, 1, 2, 3].map((i) => (
|
|
96
|
+
<motion.span
|
|
97
|
+
key={i}
|
|
98
|
+
className="block w-[15%] rounded-sm bg-current"
|
|
99
|
+
animate={{ height: ["30%", "100%", "30%"] }}
|
|
100
|
+
transition={{
|
|
101
|
+
duration: 1,
|
|
102
|
+
repeat: Infinity,
|
|
103
|
+
ease: easeOut,
|
|
104
|
+
delay: i * 0.12,
|
|
105
|
+
}}
|
|
106
|
+
/>
|
|
107
|
+
))}
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
|
|
111
|
+
{variant === "ring" && (
|
|
112
|
+
<motion.div
|
|
113
|
+
className="h-full w-full rounded-full border-[3px] border-current"
|
|
114
|
+
style={{ borderTopColor: "transparent" }}
|
|
115
|
+
animate={{ rotate: 360 }}
|
|
116
|
+
transition={{ duration: 0.9, repeat: Infinity, ease: "linear" }}
|
|
117
|
+
/>
|
|
118
|
+
)}
|
|
119
|
+
|
|
120
|
+
{variant === "pulse" && (
|
|
121
|
+
<motion.span
|
|
122
|
+
className="block rounded-full bg-current"
|
|
123
|
+
style={{ width: "100%", height: "100%" }}
|
|
124
|
+
animate={{ scale: [0.4, 1, 0.4], opacity: [0.3, 1, 0.3] }}
|
|
125
|
+
transition={{ duration: 1.2, repeat: Infinity, ease: easeOut }}
|
|
126
|
+
/>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|