diff --git a/app/drop/page.tsx b/app/drop/page.tsx new file mode 100644 index 0000000..b904bf8 --- /dev/null +++ b/app/drop/page.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from "next"; +import { DropTrap } from "@/components/landing/drop-trap"; +import { HeroSection } from "@/components/landing/hero-section"; + +export const metadata: Metadata = { + title: "Drop", + robots: { + index: false, + follow: false, + }, + alternates: { + canonical: "/drop", + }, +}; + +export default function DropPage() { + return ( + <> + + + + ); +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index 4998583..4e1cd17 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,4 @@ import { Geist, Geist_Mono, Inter, Doto } from "next/font/google"; -import { AppDialRoot } from "@/components/dial-root"; -import "dialkit/styles.css"; import "./globals.css"; import { JsonLd } from "@/components/seo/json-ld"; import { cn } from "@/lib/utils"; @@ -58,7 +56,6 @@ export default function RootLayout({ {children} - ); diff --git a/app/page.tsx b/app/page.tsx index 09ec749..9a4be40 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -24,4 +24,4 @@ export const metadata: Metadata = { export default function HomePage() { return ; -} +} \ No newline at end of file diff --git a/app/playground/layout.tsx b/app/playground/layout.tsx new file mode 100644 index 0000000..e4040be --- /dev/null +++ b/app/playground/layout.tsx @@ -0,0 +1,15 @@ +import { AppDialRoot } from "@/components/dial-root"; +import "dialkit/styles.css"; + +export default function PlaygroundLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + <> + {children} + + + ); +} \ No newline at end of file diff --git a/app/playground/page.tsx b/app/playground/page.tsx new file mode 100644 index 0000000..c7519d7 --- /dev/null +++ b/app/playground/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; +import { PlaygroundPage as Playground } from "@/components/landing/playground-page"; + +export const metadata: Metadata = { + title: "Playground", + alternates: { + canonical: "/playground", + }, + openGraph: { + url: "/playground", + images: [ + { + url: "/og-image.png", + width: 1200, + height: 630, + alt: "Evil Buttons", + }, + ], + }, + twitter: { + card: "summary_large_image", + images: ["/og-image.png"], + }, +}; + +export default function PlaygroundPage() { + return ; +} \ No newline at end of file diff --git a/app/sitemap.ts b/app/sitemap.ts index 8cfbbbc..a6dbb71 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -24,6 +24,28 @@ export default function sitemap(): MetadataRoute.Sitemap { }); } + const staticEntries: MetadataRoute.Sitemap = [ + { + url: absoluteUrl("/"), + lastModified: now, + changeFrequency: "weekly", + priority: 1, + }, + { + url: absoluteUrl("/playground"), + lastModified: now, + changeFrequency: "weekly", + priority: 0.9, + }, + ]; + + for (const entry of staticEntries) { + if (!seen.has(entry.url)) { + docEntries.unshift(entry); + seen.add(entry.url); + } + } + return docEntries; } diff --git a/components/landing/drop-trap.tsx b/components/landing/drop-trap.tsx new file mode 100644 index 0000000..394699a --- /dev/null +++ b/components/landing/drop-trap.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +export function DropTrap() { + const router = useRouter(); + + useEffect(() => { + window.history.pushState({ dropTrap: true }, "", "/drop"); + + const onPopState = () => { + window.history.pushState({ dropTrap: true }, "", "/drop"); + router.replace("/drop"); + }; + + window.addEventListener("popstate", onPopState); + return () => window.removeEventListener("popstate", onPopState); + }, [router]); + + return null; +} \ No newline at end of file diff --git a/components/landing/hero-section.tsx b/components/landing/hero-section.tsx new file mode 100644 index 0000000..cc2482d --- /dev/null +++ b/components/landing/hero-section.tsx @@ -0,0 +1,154 @@ +"use client"; + +import Gravity, { MatterBody } from "@/components/physics/gravity"; +import { ButtonPreview } from "@/components/landing/previews"; +import { showcase } from "@/components/landing/showcase"; +import { ThemeSync } from "@/components/theme-sync"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { siteConfig } from "@/lib/seo"; +import { ArrowUpRight } from "@phosphor-icons/react"; +import Link from "next/link"; + +const matterOptions = { + friction: 0.45, + restitution: 0.3, + density: 0.001, + isStatic: false, +} as const; + +function getDropPosition(index: number) { + const cols = 6; + const col = index % cols; + const row = Math.floor(index / cols); + const x = `${6 + (col / Math.max(cols - 1, 1)) * 88}%`; + const y = `${-8 - row * 12}%`; + const angle = ((index * 41) % 56) - 28; + + return { x, y, angle }; +} + +type HeroSectionProps = { + trapped?: boolean; +}; + +export function HeroSection({ trapped = false }: HeroSectionProps) { + return ( +
+ + +
+
+
+ {trapped ? ( +
+ {siteConfig.name} + + Evil Buttons + +
+ ) : ( + + {siteConfig.name} + + Evil Buttons + + + )} +

+ {trapped ? "You clicked it. There is no undo." : siteConfig.tagline} +

+
+ +
+ + {!trapped ? ( + <> + + Playground + + + Docs + + + + ) : null} +
+
+
+ + + {showcase.map((item, index) => { + const { x, y, angle } = getDropPosition(index); + + return ( + +
+ +
+
+ ); + })} +
+ +
+

+ {trapped ? "no escape" : `${showcase.length} components`} +

+ {!trapped ? ( +
+ + Playground + + + GitHub + +
+ ) : ( +

+ nice try +

+ )} +
+
+ ); +} \ No newline at end of file diff --git a/components/landing/landing-page.tsx b/components/landing/landing-page.tsx index a26e9a2..6e2a478 100644 --- a/components/landing/landing-page.tsx +++ b/components/landing/landing-page.tsx @@ -1,106 +1,390 @@ "use client"; +import { DeferredMount } from "@/components/landing/deferred-mount"; import { FitToContainer } from "@/components/landing/fit-to-container"; -import { ButtonPreview } from "@/components/landing/previews"; -import { showcase } from "@/components/landing/showcase"; import { ThemeSync } from "@/components/theme-sync"; -import { ThemeToggle } from "@/components/theme-toggle"; -import { useAppTheme } from "@/hooks/use-app-theme"; import { siteConfig } from "@/lib/seo"; -import { ArrowUpRight } from "@phosphor-icons/react"; +import { ArrowUpRight, Copy } from "@phosphor-icons/react"; import Link from "next/link"; -import { useState } from "react"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { useRouter } from "next/navigation"; +import { type ReactNode, useEffect, useRef, useState } from "react"; -export function LandingPage() { - const theme = useAppTheme(); - const [selected, setSelected] = useState(showcase[0].registryName); - const active = - showcase.find((item) => item.registryName === selected) ?? showcase[0]; +import { AquaButton } from "@/components/evil-buttons/aqua-button"; +import { BrutalButton } from "@/components/evil-buttons/brutal-button"; +import { CaptchaButton } from "@/components/evil-buttons/captcha-button"; +import ChromeButton from "@/components/evil-buttons/chrome-button"; +import { ClickPowerUp } from "@/components/evil-buttons/click-powerup"; +import { CommandButton } from "@/components/evil-buttons/command-button"; +import { CooldownButton } from "@/components/evil-buttons/cooldown-button"; +import { CopyButton } from "@/components/evil-buttons/copy-button"; +import { DoubtButton } from "@/components/evil-buttons/doubt-button"; +import DitherButton from "@/components/evil-buttons/dither-button"; +import EvilEyeButton from "@/components/evil-buttons/evil-eye-button"; +import { FrameButton } from "@/components/evil-buttons/frame-button"; +import GlitchButton from "@/components/evil-buttons/glitch-button"; +import GridButton from "@/components/evil-buttons/grid-button"; +import { HighlightButton } from "@/components/evil-buttons/highlight-button"; +import { HoldButton } from "@/components/evil-buttons/hold-button"; +import MinimalButton from "@/components/evil-buttons/minimal"; +import { MorphStatusButton } from "@/components/evil-buttons/morph-status-button"; +import MoviePassButton from "@/components/evil-buttons/movie-pass"; +import { RevealButton } from "@/components/evil-buttons/reveal-button"; +import ShinyButton from "@/components/evil-buttons/shiny-button"; +import { SlideToDetonate } from "@/components/evil-buttons/slide-to-detonate"; +import StickyButton from "@/components/evil-buttons/sticky"; +import { ThreeDButton } from "@/components/evil-buttons/3d-button"; +import TrollButton from "@/components/evil-buttons/troll-button"; +import { DemonicButton } from "@/components/evil-buttons/demonic-button"; +import { PillButton } from "@/components/evil-buttons/pill-button"; +import { ConfettiButton } from "@/components/evil-buttons/confetti-button"; +import { HoldConfirmButton } from "@/components/evil-buttons/hold-confirm-button"; + +type ButtonShowcase = { + name: string; + href: string; + registryName: string; + render: () => ReactNode; +}; +// Static stand-in shown for the always-on WebGL buttons until their grid cell +// scrolls into view. Approximates the real button's footprint (rounded, dark +// pill) so swapping in the live shader causes no layout shift. +function WebGLPlaceholder({ label }: { label: string }) { return ( -
- + + {label} + + ); +} + +const showcase: ButtonShowcase[] = [ + { + name: "RevealButton", + href: "/docs/reveal-button", + registryName: "reveal-button", + render: () => , + }, + { + name: "CommandButton", + href: "/docs/command-button", + registryName: "command-button", + render: () => Save, + }, + { + name: "CopyButton", + href: "/docs/copy-button", + registryName: "copy-button", + render: () => , + }, + { + name: "ClickPowerUp", + href: "/docs/click-power-up", + registryName: "click-powerup", + render: () => Doom, + }, + { + name: "DitherButton", + href: "/docs/dither-button", + registryName: "dither-button", + render: () => Run It, + }, + { + name: "HoldButton", + href: "/docs/hold-button", + registryName: "hold-button", + render: () => , + }, + { + name: "DemonicButton", + href: "/docs/demonic-button", + registryName: "demonic-button", + render: () => , + }, + { + name: "EvilEyeButton", + href: "/docs/evil-eye-button", + registryName: "evil-eye-button", + render: () => ( + }> + Doom + + ), + }, + { + name: "AquaButton", + href: "/docs/aqua-button", + registryName: "aqua-button", + render: () => Deploy Doom, + }, + { + name: "BrutalButton", + href: "/docs/brutal-button", + registryName: "brutal-button", + render: () => Click Me, + }, + { + name: "ChromeButton", + href: "/docs/chrome-button", + registryName: "chrome-button", + render: () => ( + }> + Chromy + + ), + }, + { + name: "FrameButton", + href: "/docs/frame-button", + registryName: "frame-button", + render: () => Deploy, + }, + { + name: "GlitchButton", + href: "/docs/glitch-button", + registryName: "glitch-button", + render: () => Launch, + }, + { + name: "GridButton", + href: "/docs/grid-button", + registryName: "grid-button", + render: () => Click, + }, + { + name: "HighlightButton", + href: "/docs/highlight-button", + registryName: "highlight-button", + render: () => Send, + }, + { + name: "MinimalButton", + href: "/docs/minimal-button", + registryName: "minimal", + render: () => Apply, + }, + { + name: "MoviePassButton", + href: "/docs/movie-pass", + registryName: "movie-pass", + render: () => Deploy Doom, + }, + { + name: "ShinyButton", + href: "/docs/shiny-button", + registryName: "shiny-button", + render: () => Search, + }, + { + name: "StickyButton", + href: "/docs/sticky-button", + registryName: "sticky", + render: () => Try to Click, + }, + { + name: "ThreeDButton", + href: "/docs/3d-button", + registryName: "3d-button", + render: () => Continue, + }, + { + name: "TrollButton", + href: "/docs/troll-button", + registryName: "troll-button", + render: () => Click Me, + }, + { + name: "CaptchaButton", + href: "/docs/captcha-button", + registryName: "captcha-button", + render: () => Deploy Doom, + }, + { + name: "DoubtButton", + href: "/docs/doubt-button", + registryName: "doubt-button", + render: () => Delete everything, + }, + { + name: "SlideToDetonate", + href: "/docs/slide-to-detonate", + registryName: "slide-to-detonate", + render: () => Slide to detonate, + }, + { + name: "MorphStatusButton", + href: "/docs/morph-status-button", + registryName: "morph-status-button", + render: () => ( + new Promise((resolve) => setTimeout(resolve, 1200))} + > + Save changes + + ), + }, + { + name: "CooldownButton", + href: "/docs/cooldown-button", + registryName: "cooldown-button", + render: () => Send it, + }, + { + name: "PillButton", + href: "/docs/pill-button", + registryName: "pill-button", + render: () => ( + + ), + }, + { + name: "ConfettiButton", + href: "/docs/confetti-button", + registryName: "confetti-button", + render: () => Celebrate, + }, + { + name: "HoldConfirmButton", + href: "/docs/hold-confirm-button", + registryName: "hold-confirm-button", + render: () => , + }, +]; + +function ButtonCell({ item }: { item: ButtonShowcase }) { + const [copied, setCopied] = useState(false); + const timerRef = useRef(null); + + useEffect(() => { + return () => { + if (timerRef.current !== null) clearTimeout(timerRef.current); + }; + }, []); -
+ const command = `npx shadcn@latest add @evilbuttons/${item.registryName}`; + + async function handleCopy(e: React.MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + await navigator.clipboard.writeText(command); + setCopied(true); + if (timerRef.current !== null) clearTimeout(timerRef.current); + timerRef.current = window.setTimeout(() => setCopied(false), 2000); + } + + return ( +
+
+ {item.name} +
+
+ {item.render()} +
+
- {siteConfig.name} - - Evil Buttons - + + Docs + +
+
+ ); +} -
- - +export function LandingPage() { + const router = useRouter(); + + return ( +
+ +
+
- Docs - +
+ {siteConfig.name} +

+ Evil
Buttons +

+
+

+ Animated buttons, built with an evil touch. +

+

+ A shadcn/ui registry of {showcase.length} interactive button + components. Live previews, copy-paste docs, one-command CLI + installs. +

+
+ + Browse Docs + + + GitHub + + router.push("/drop")} + className="w-full min-w-0 rounded-none font-doto text-sm font-black uppercase tracking-tight" + /> +
-
-
- - - -
+
+

+ {showcase.length} Components +

+

+ © {new Date().getFullYear()} {siteConfig.author.name} +

+
+
-
-

- {showcase.length} components -

-
- - Browse Docs - - - GitHub - +
+
+ {showcase.map((item) => ( + + ))}
-
+
); -} \ No newline at end of file +} diff --git a/components/landing/playground-page.tsx b/components/landing/playground-page.tsx new file mode 100644 index 0000000..262f6be --- /dev/null +++ b/components/landing/playground-page.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { AppDialRoot } from "@/components/dial-root"; +import { FitToContainer } from "@/components/landing/fit-to-container"; +import { ButtonPreview } from "@/components/landing/previews"; +import { showcase } from "@/components/landing/showcase"; +import { ThemeSync } from "@/components/theme-sync"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { useAppTheme } from "@/hooks/use-app-theme"; +import { siteConfig } from "@/lib/seo"; +import { ArrowUpRight } from "@phosphor-icons/react"; +import "dialkit/styles.css"; +import Link from "next/link"; +import { useState } from "react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +export function PlaygroundPage() { + const theme = useAppTheme(); + const [selected, setSelected] = useState(showcase[0].registryName); + const active = + showcase.find((item) => item.registryName === selected) ?? showcase[0]; + + return ( +
+ + +
+ + {siteConfig.name} + + Evil Buttons + + + +
+ + + + + Docs + + +
+
+ +
+ + + +
+ +
+

+ {showcase.length} components +

+
+ + Browse Docs + + + GitHub + +
+
+ + +
+ ); +} \ No newline at end of file diff --git a/components/landing/previews/index.tsx b/components/landing/previews/index.tsx index f659df0..72c1f71 100644 --- a/components/landing/previews/index.tsx +++ b/components/landing/previews/index.tsx @@ -38,8 +38,39 @@ import { ThreeDButtonPreview, TrollButtonPreview, } from "./simple"; +import { + StaticRevealButtonPreview, + StaticHoldButtonPreview, + StaticHoldConfirmButtonPreview, + StaticSlideToDetonatePreview, + StaticDoubtButtonPreview, + StaticCaptchaButtonPreview, + StaticCooldownButtonPreview, + StaticMorphStatusButtonPreview, + StaticBrutalButtonPreview, + StaticDitherButtonPreview, + StaticGlitchButtonPreview, + StaticEvilEyeButtonPreview, + StaticAquaButtonPreview, + StaticFrameButtonPreview, + StaticHighlightButtonPreview, + StaticConfettiButtonPreview, + StaticCommandButtonPreview, + StaticCopyButtonPreview, + StaticClickPowerUpPreview, + StaticPillButtonPreview, + StaticDemonicButtonPreview, + StaticChromeButtonPreview, + StaticGridButtonPreview, + StaticMinimalButtonPreview, + StaticMoviePassButtonPreview, + StaticShinyButtonPreview, + StaticStickyButtonPreview, + StaticThreeDButtonPreview, + StaticTrollButtonPreview, +} from "./static"; -const buttonPreviews: Record = { +const dialButtonPreviews: Record = { "reveal-button": RevealButtonPreview, "command-button": CommandButtonPreview, "copy-button": CopyButtonPreview, @@ -71,8 +102,47 @@ const buttonPreviews: Record = { "hold-confirm-button": HoldConfirmButtonPreview, }; -export function ButtonPreview({ registryName }: { registryName: string }) { - const Preview = buttonPreviews[registryName]; +const staticButtonPreviews: Record = { + "reveal-button": StaticRevealButtonPreview, + "command-button": StaticCommandButtonPreview, + "copy-button": StaticCopyButtonPreview, + "click-powerup": StaticClickPowerUpPreview, + "dither-button": StaticDitherButtonPreview, + "hold-button": StaticHoldButtonPreview, + "demonic-button": StaticDemonicButtonPreview, + "evil-eye-button": StaticEvilEyeButtonPreview, + "aqua-button": StaticAquaButtonPreview, + "brutal-button": StaticBrutalButtonPreview, + "chrome-button": StaticChromeButtonPreview, + "frame-button": StaticFrameButtonPreview, + "glitch-button": StaticGlitchButtonPreview, + "grid-button": StaticGridButtonPreview, + "highlight-button": StaticHighlightButtonPreview, + minimal: StaticMinimalButtonPreview, + "movie-pass": StaticMoviePassButtonPreview, + "shiny-button": StaticShinyButtonPreview, + sticky: StaticStickyButtonPreview, + "3d-button": StaticThreeDButtonPreview, + "troll-button": StaticTrollButtonPreview, + "captcha-button": StaticCaptchaButtonPreview, + "doubt-button": StaticDoubtButtonPreview, + "slide-to-detonate": StaticSlideToDetonatePreview, + "morph-status-button": StaticMorphStatusButtonPreview, + "cooldown-button": StaticCooldownButtonPreview, + "pill-button": StaticPillButtonPreview, + "confetti-button": StaticConfettiButtonPreview, + "hold-confirm-button": StaticHoldConfirmButtonPreview, +}; + +export function ButtonPreview({ + registryName, + dial = false, +}: { + registryName: string; + dial?: boolean; +}) { + const map = dial ? dialButtonPreviews : staticButtonPreviews; + const Preview = map[registryName]; if (!Preview) return null; return ; } \ No newline at end of file diff --git a/components/landing/previews/static.tsx b/components/landing/previews/static.tsx new file mode 100644 index 0000000..a584663 --- /dev/null +++ b/components/landing/previews/static.tsx @@ -0,0 +1,320 @@ +"use client"; + +import { useIsDarkMode } from "@/hooks/use-app-theme"; +import { RevealButton } from "@/components/evil-buttons/reveal-button"; +import { HoldButton } from "@/components/evil-buttons/hold-button"; +import { HoldConfirmButton } from "@/components/evil-buttons/hold-confirm-button"; +import { SlideToDetonate } from "@/components/evil-buttons/slide-to-detonate"; +import { DoubtButton } from "@/components/evil-buttons/doubt-button"; +import { CaptchaButton } from "@/components/evil-buttons/captcha-button"; +import { CooldownButton } from "@/components/evil-buttons/cooldown-button"; +import { MorphStatusButton } from "@/components/evil-buttons/morph-status-button"; +import { BrutalButton } from "@/components/evil-buttons/brutal-button"; +import DitherButton from "@/components/evil-buttons/dither-button"; +import { GlitchButton } from "@/components/evil-buttons/glitch-button"; +import EvilEyeButton from "@/components/evil-buttons/evil-eye-button"; +import { AquaButton } from "@/components/evil-buttons/aqua-button"; +import { FrameButton } from "@/components/evil-buttons/frame-button"; +import { HighlightButton } from "@/components/evil-buttons/highlight-button"; +import { ConfettiButton } from "@/components/evil-buttons/confetti-button"; +import { CommandButton } from "@/components/evil-buttons/command-button"; +import { CopyButton } from "@/components/evil-buttons/copy-button"; +import { ClickPowerUp } from "@/components/evil-buttons/click-powerup"; +import { PillButton } from "@/components/evil-buttons/pill-button"; +import { DemonicButton } from "@/components/evil-buttons/demonic-button"; +import ChromeButton from "@/components/evil-buttons/chrome-button"; +import GridButton from "@/components/evil-buttons/grid-button"; +import MinimalButton from "@/components/evil-buttons/minimal"; +import MoviePassButton from "@/components/evil-buttons/movie-pass"; +import ShinyButton from "@/components/evil-buttons/shiny-button"; +import StickyButton from "@/components/evil-buttons/sticky"; +import { ThreeDButton } from "@/components/evil-buttons/3d-button"; +import TrollButton from "@/components/evil-buttons/troll-button"; +import { DeferredWebGLPreview } from "./shared"; +import { pillClassNames, themeColors } from "./theme"; + +export function StaticRevealButtonPreview() { + return ( + + ); +} + +export function StaticHoldButtonPreview() { + return ( + + ); +} + +export function StaticHoldConfirmButtonPreview() { + const isDark = useIsDarkMode(); + + return ( + + ); +} + +export function StaticSlideToDetonatePreview() { + return ( + + ); +} + +export function StaticDoubtButtonPreview() { + return ( + + ); +} + +export function StaticCaptchaButtonPreview() { + return ( + + ); +} + +export function StaticCooldownButtonPreview() { + return ( + + ); +} + +export function StaticMorphStatusButtonPreview() { + return ( + new Promise((resolve) => setTimeout(resolve, 1200))} + /> + ); +} + +export function StaticBrutalButtonPreview() { + const isDark = useIsDarkMode(); + const colors = isDark ? themeColors.dark : themeColors.light; + + return ( + + Click Me + + ); +} + +export function StaticDitherButtonPreview() { + const isDark = useIsDarkMode(); + + return ( + + Run It + + ); +} + +export function StaticGlitchButtonPreview() { + const isDark = useIsDarkMode(); + + return ( + + Launch + + ); +} + +export function StaticEvilEyeButtonPreview() { + return ( + + + Doom + + + ); +} + +export function StaticAquaButtonPreview() { + return Deploy Doom; +} + +export function StaticFrameButtonPreview() { + return ( + + Deploy + + ); +} + +export function StaticHighlightButtonPreview() { + const isDark = useIsDarkMode(); + const colors = isDark ? themeColors.dark : themeColors.light; + + return ( + + Send + + ); +} + +export function StaticConfettiButtonPreview() { + return ( + + ); +} + +export function StaticCommandButtonPreview() { + return ( + + Save + + ); +} + +export function StaticCopyButtonPreview() { + return ( + + ); +} + +export function StaticClickPowerUpPreview() { + return Doom; +} + +export function StaticPillButtonPreview() { + const isDark = useIsDarkMode(); + const classes = pillClassNames(isDark); + + return ( + + ); +} + +export function StaticDemonicButtonPreview() { + return ; +} + +export function StaticChromeButtonPreview() { + return ( + + Chromy + + ); +} + +export function StaticGridButtonPreview() { + return Click; +} + +export function StaticMinimalButtonPreview() { + return Apply; +} + +export function StaticMoviePassButtonPreview() { + return Deploy Doom; +} + +export function StaticShinyButtonPreview() { + return Search; +} + +export function StaticStickyButtonPreview() { + return Try to Click; +} + +export function StaticThreeDButtonPreview() { + return Continue; +} + +export function StaticTrollButtonPreview() { + return Click Me; +} \ No newline at end of file diff --git a/components/physics/gravity.tsx b/components/physics/gravity.tsx new file mode 100644 index 0000000..4cd796a --- /dev/null +++ b/components/physics/gravity.tsx @@ -0,0 +1,485 @@ +"use client"; + +import { + createContext, + forwardRef, + type ReactNode, + useCallback, + useContext, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; +import { calculatePosition } from "@/lib/calculate-position"; +import { debounce } from "@/lib/debounce"; +import { parsePathToVertices } from "@/lib/svg-path-to-vertices"; +import { cn } from "@/lib/utils"; +import decomp from "poly-decomp"; +import Matter, { + Bodies, + Common, + Engine, + Events, + Mouse, + MouseConstraint, + Query, + Render, + Runner, + World, +} from "matter-js"; + +type GravityProps = { + children: ReactNode; + debug?: boolean; + gravity?: { x: number; y: number }; + resetOnResize?: boolean; + grabCursor?: boolean; + addTopWall?: boolean; + autoStart?: boolean; + className?: string; +}; + +type PhysicsBody = { + element: HTMLElement; + body: Matter.Body; + props: MatterBodyProps; +}; + +type MatterBodyProps = { + children: ReactNode; + matterBodyOptions?: Matter.IBodyDefinition; + isDraggable?: boolean; + bodyType?: "rectangle" | "circle" | "svg"; + sampleLength?: number; + x?: number | string; + y?: number | string; + angle?: number; + className?: string; +}; + +export type GravityRef = { + start: () => void; + stop: () => void; + reset: () => void; +}; + +const GravityContext = createContext<{ + registerElement: ( + id: string, + element: HTMLElement, + props: MatterBodyProps, + ) => void; + unregisterElement: (id: string) => void; +} | null>(null); + +export const MatterBody = ({ + children, + className, + matterBodyOptions = { + friction: 0.1, + restitution: 0.1, + density: 0.001, + isStatic: false, + }, + bodyType = "rectangle", + isDraggable = true, + sampleLength = 15, + x = 0, + y = 0, + angle = 0, + ...props +}: MatterBodyProps) => { + const elementRef = useRef(null); + const idRef = useRef(Math.random().toString(36).substring(7)); + const context = useContext(GravityContext); + + useEffect(() => { + if (!elementRef.current || !context) return; + context.registerElement(idRef.current, elementRef.current, { + children, + matterBodyOptions, + bodyType, + sampleLength, + isDraggable, + x, + y, + angle, + ...props, + }); + + return () => context.unregisterElement(idRef.current); + }, [props, children, matterBodyOptions, isDraggable, bodyType, sampleLength, x, y, angle]); + + return ( +
+ {children} +
+ ); +}; + +const Gravity = forwardRef( + ( + { + children, + debug = false, + gravity = { x: 0, y: 1 }, + grabCursor = true, + resetOnResize = true, + addTopWall = true, + autoStart = true, + className, + ...props + }, + ref, + ) => { + const canvas = useRef(null); + const engine = useRef(Engine.create()); + const render = useRef(undefined); + const runner = useRef(undefined); + const bodiesMap = useRef(new Map()); + const frameId = useRef(undefined); + const mouseConstraint = useRef( + undefined, + ); + const mouseDown = useRef(false); + const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); + + const isRunning = useRef(false); + + const registerElement = useCallback( + (id: string, element: HTMLElement, bodyProps: MatterBodyProps) => { + if (!canvas.current) return; + const width = element.offsetWidth; + const height = element.offsetHeight; + const canvasRect = canvas.current.getBoundingClientRect(); + + const bodyAngle = (bodyProps.angle || 0) * (Math.PI / 180); + + const posX = calculatePosition(bodyProps.x, canvasRect.width, width); + const posY = calculatePosition(bodyProps.y, canvasRect.height, height); + + let body: Matter.Body | undefined; + if (bodyProps.bodyType === "circle") { + const radius = Math.max(width, height) / 2; + body = Bodies.circle(posX, posY, radius, { + ...bodyProps.matterBodyOptions, + angle: bodyAngle, + render: { + fillStyle: debug ? "#888888" : "#00000000", + strokeStyle: debug ? "#333333" : "#00000000", + lineWidth: debug ? 3 : 0, + }, + }); + } else if (bodyProps.bodyType === "svg") { + const paths = element.querySelectorAll("path"); + const vertexSets: Matter.Vector[][] = []; + + paths.forEach((path) => { + const d = path.getAttribute("d"); + if (!d) return; + vertexSets.push(parsePathToVertices(d, bodyProps.sampleLength)); + }); + + body = Bodies.fromVertices(posX, posY, vertexSets, { + ...bodyProps.matterBodyOptions, + angle: bodyAngle, + render: { + fillStyle: debug ? "#888888" : "#00000000", + strokeStyle: debug ? "#333333" : "#00000000", + lineWidth: debug ? 3 : 0, + }, + }); + } else { + body = Bodies.rectangle(posX, posY, width, height, { + ...(bodyProps.matterBodyOptions ?? {}), + angle: bodyAngle, + render: { + fillStyle: debug ? "#888888" : "#00000000", + strokeStyle: debug ? "#333333" : "#00000000", + lineWidth: debug ? 3 : 0, + }, + } as Matter.IChamferableBodyDefinition); + } + + if (body) { + World.add(engine.current.world, [body]); + bodiesMap.current.set(id, { element, body, props: bodyProps }); + } + }, + [debug], + ); + + const unregisterElement = useCallback((id: string) => { + const entry = bodiesMap.current.get(id); + if (entry) { + World.remove(engine.current.world, entry.body); + bodiesMap.current.delete(id); + } + }, []); + + const updateElements = useCallback(() => { + bodiesMap.current.forEach(({ element, body }) => { + const { x, y } = body.position; + const rotation = body.angle * (180 / Math.PI); + + element.style.transform = `translate(${ + x - element.offsetWidth / 2 + }px, ${y - element.offsetHeight / 2}px) rotate(${rotation}deg)`; + }); + + frameId.current = requestAnimationFrame(updateElements); + }, []); + + const initializeRenderer = useCallback(() => { + if (!canvas.current) return; + + const height = canvas.current.offsetHeight; + const width = canvas.current.offsetWidth; + + Common.setDecomp(decomp); + + engine.current.gravity.x = gravity.x; + engine.current.gravity.y = gravity.y; + + render.current = Render.create({ + element: canvas.current, + engine: engine.current, + options: { + width, + height, + wireframes: false, + background: "#00000000", + }, + }); + + const mouse = Mouse.create(render.current.canvas); + mouseConstraint.current = MouseConstraint.create(engine.current, { + mouse, + constraint: { + stiffness: 0.2, + render: { + visible: debug, + }, + }, + }); + + const walls = [ + Bodies.rectangle(width / 2, height + 10, width, 20, { + isStatic: true, + friction: 1, + render: { visible: debug }, + }), + Bodies.rectangle(width + 10, height / 2, 20, height, { + isStatic: true, + friction: 1, + render: { visible: debug }, + }), + Bodies.rectangle(-10, height / 2, 20, height, { + isStatic: true, + friction: 1, + render: { visible: debug }, + }), + ]; + + const topWall = addTopWall + ? Bodies.rectangle(width / 2, -10, width, 20, { + isStatic: true, + friction: 1, + render: { visible: debug }, + }) + : null; + + if (topWall) { + walls.push(topWall); + } + + const touchingMouse = () => + Query.point( + engine.current.world.bodies, + mouseConstraint.current?.mouse.position || { x: 0, y: 0 }, + ).length > 0; + + if (grabCursor) { + Events.on(engine.current, "beforeUpdate", () => { + if (!canvas.current) return; + + if (!mouseDown.current && !touchingMouse()) { + canvas.current.style.cursor = "default"; + } else if (touchingMouse()) { + canvas.current.style.cursor = mouseDown.current + ? "grabbing" + : "grab"; + } + }); + + canvas.current.addEventListener("mousedown", () => { + mouseDown.current = true; + if (!canvas.current) return; + + canvas.current.style.cursor = touchingMouse() + ? "grabbing" + : "default"; + }); + + canvas.current.addEventListener("mouseup", () => { + mouseDown.current = false; + if (!canvas.current) return; + + canvas.current.style.cursor = touchingMouse() ? "grab" : "default"; + }); + } + + World.add(engine.current.world, [mouseConstraint.current, ...walls]); + + render.current.mouse = mouse; + + runner.current = Runner.create(); + Render.run(render.current); + updateElements(); + runner.current.enabled = false; + + if (autoStart) { + runner.current.enabled = true; + startEngine(); + } + }, [updateElements, debug, autoStart, gravity.x, gravity.y, addTopWall, grabCursor]); + + const clearRenderer = useCallback(() => { + if (frameId.current) { + cancelAnimationFrame(frameId.current); + } + + if (mouseConstraint.current) { + World.remove(engine.current.world, mouseConstraint.current); + } + + if (render.current) { + Mouse.clearSourceEvents(render.current.mouse); + Render.stop(render.current); + render.current.canvas.remove(); + } + + if (runner.current) { + Runner.stop(runner.current); + } + + if (engine.current) { + World.clear(engine.current.world, false); + Engine.clear(engine.current); + } + + bodiesMap.current.clear(); + }, []); + + const handleResize = useCallback(() => { + if (!canvas.current || !resetOnResize) return; + + const newWidth = canvas.current.offsetWidth; + const newHeight = canvas.current.offsetHeight; + + setCanvasSize({ width: newWidth, height: newHeight }); + + clearRenderer(); + initializeRenderer(); + }, [clearRenderer, initializeRenderer, resetOnResize]); + + const startEngine = useCallback(() => { + if (runner.current) { + runner.current.enabled = true; + Runner.run(runner.current, engine.current); + } + if (render.current) { + Render.run(render.current); + } + frameId.current = requestAnimationFrame(updateElements); + isRunning.current = true; + }, [updateElements]); + + const stopEngine = useCallback(() => { + if (!isRunning.current) return; + + if (runner.current) { + Runner.stop(runner.current); + } + if (render.current) { + Render.stop(render.current); + } + if (frameId.current) { + cancelAnimationFrame(frameId.current); + } + isRunning.current = false; + }, []); + + const reset = useCallback(() => { + stopEngine(); + bodiesMap.current.forEach(({ element, body, props: bodyProps }) => { + body.angle = (bodyProps.angle || 0) * (Math.PI / 180); + + const x = calculatePosition( + bodyProps.x, + canvasSize.width, + element.offsetWidth, + ); + const y = calculatePosition( + bodyProps.y, + canvasSize.height, + element.offsetHeight, + ); + body.position.x = x; + body.position.y = y; + }); + updateElements(); + handleResize(); + }, [stopEngine, canvasSize.width, canvasSize.height, updateElements, handleResize]); + + useImperativeHandle( + ref, + () => ({ + start: startEngine, + stop: stopEngine, + reset, + }), + [startEngine, stopEngine, reset], + ); + + useEffect(() => { + if (!resetOnResize) return; + + const debouncedResize = debounce(handleResize, 500); + window.addEventListener("resize", debouncedResize); + + return () => { + window.removeEventListener("resize", debouncedResize); + debouncedResize.cancel(); + }; + }, [handleResize, resetOnResize]); + + useEffect(() => { + initializeRenderer(); + return clearRenderer; + }, [initializeRenderer, clearRenderer]); + + return ( + +
+ {children} +
+
+ ); + }, +); + +Gravity.displayName = "Gravity"; +export default Gravity; \ No newline at end of file diff --git a/components/theme-toggle.tsx b/components/theme-toggle.tsx index 792e686..c59a22e 100644 --- a/components/theme-toggle.tsx +++ b/components/theme-toggle.tsx @@ -13,8 +13,8 @@ export function ThemeToggle() { \n );\n },\n);\n\nAquaButton.displayName = \"AquaButton\";\n\nexport default AquaButton;\n" + "content": "\"use client\";\r\n\r\nimport * as React from \"react\";\r\nimport { cn } from \"@/lib/utils\";\r\n\r\nexport type AquaButtonVariant = \"primary\" | \"secondary\";\r\n\r\nexport interface AquaButtonProps\r\n extends React.ButtonHTMLAttributes {\r\n variant?: AquaButtonVariant;\r\n}\r\n\r\nconst EASE = \"cubic-bezier(0.4, 0, 0.2, 1)\";\r\n\r\nconst VARIANTS = {\r\n primary: {\r\n text: \"text-[rgb(20,30,55)]\",\r\n textShadow: \"0 1px 0 rgba(255, 255, 255, 0.35)\",\r\n bg: \"linear-gradient(rgb(95, 160, 230), rgb(50, 115, 205) 55%, rgb(95, 160, 230))\",\r\n shadow:\r\n \"0 0.25em 0.375em rgba(0, 0, 0, 0.25), inset 0 0 0 1px rgba(0, 30, 95, 0.55), inset 0 0.125em 0.25em rgba(0, 20, 80, 0.3)\",\r\n hoverExtra: \", 0 0 0.875em 0.0625em rgba(60, 150, 235, 0.55)\",\r\n focusExtra:\r\n \", 0 0 0 0.125em rgba(255, 255, 255, 0.95), 0 0 0 0.3125em rgba(40, 150, 255, 0.95), 0 0 1.25em 0.1875em rgba(60, 170, 255, 0.7)\",\r\n active:\r\n \"0 0.0625em 0.125em rgba(0, 0, 0, 0.25), inset 0 0 0 1px rgba(0, 30, 95, 0.65), inset 0 0.25em 0.5em rgba(0, 20, 80, 0.55)\",\r\n },\r\n secondary: {\r\n text: \"text-[rgb(35,35,40)] dark:text-white\",\r\n textShadow: \"var(--aqua-text-shadow)\",\r\n bg: \"var(--aqua-bg)\",\r\n shadow: \"var(--aqua-shadow)\",\r\n hoverExtra: \"\",\r\n focusExtra: \"\",\r\n active: \"var(--aqua-shadow-active)\",\r\n },\r\n} as const;\r\n\r\nconst SECONDARY_THEME_CLASSES = [\r\n \"[--aqua-bg:linear-gradient(rgb(225,_226,_228),_rgb(245,_246,_248)_55%,_rgb(230,_230,_232))]\",\r\n \"[--aqua-shadow:0_0.25em_0.375em_rgba(0,_0,_0,_0.18),inset_0_0_0_1px_rgba(120,_122,_130,_0.5),inset_0_0.125em_0.25em_rgba(0,_0,_0,_0.15)]\",\r\n \"[--aqua-shadow-hover:0_0.25em_0.375em_rgba(0,_0,_0,_0.18),inset_0_0_0_1px_rgba(120,_122,_130,_0.5),inset_0_0.125em_0.25em_rgba(0,_0,_0,_0.15),0_0_0.875em_0.0625em_rgba(0,_0,_0,_0.18)]\",\r\n \"[--aqua-shadow-focus:0_0.25em_0.375em_rgba(0,_0,_0,_0.18),inset_0_0_0_1px_rgba(120,_122,_130,_0.5),inset_0_0.125em_0.25em_rgba(0,_0,_0,_0.15),0_0_0_0.125em_rgba(255,_255,_255,_0.95),0_0_0_0.3125em_rgba(40,_150,_255,_0.95),0_0_1.25em_0.1875em_rgba(60,_170,_255,_0.55)]\",\r\n \"[--aqua-shadow-active:0_0.0625em_0.125em_rgba(0,_0,_0,_0.18),inset_0_0_0_1px_rgba(120,_122,_130,_0.6),inset_0_0.25em_0.5em_rgba(0,_0,_0,_0.3)]\",\r\n \"[--aqua-text-shadow:0_1px_0_rgba(255,_255,_255,_0.7)]\",\r\n \"[--aqua-top-highlight:linear-gradient(rgba(255,_255,_255,_0.85),_rgba(255,_255,_255,_0.25))]\",\r\n \"[--aqua-bottom-highlight:linear-gradient(rgba(255,_255,_255,_0.15),_rgba(255,_255,_255,_0.5))]\",\r\n \"dark:[--aqua-bg:linear-gradient(rgb(40,_40,_45),_rgb(22,_22,_28)_55%,_rgb(40,_40,_45))]\",\r\n \"dark:[--aqua-shadow:0_0.25em_0.375em_rgba(0,_0,_0,_0.4),inset_0_0_0_1px_rgba(255,_255,_255,_0.08),inset_0_0.125em_0.25em_rgba(0,_0,_0,_0.3)]\",\r\n \"dark:[--aqua-shadow-hover:0_0.25em_0.375em_rgba(0,_0,_0,_0.4),inset_0_0_0_1px_rgba(255,_255,_255,_0.08),inset_0_0.125em_0.25em_rgba(0,_0,_0,_0.3),0_0_0.875em_0.0625em_rgba(100,_100,_255,_0.2)]\",\r\n \"dark:[--aqua-shadow-focus:0_0.25em_0.375em_rgba(0,_0,_0,_0.4),inset_0_0_0_1px_rgba(255,_255,_255,_0.08),inset_0_0.125em_0.25em_rgba(0,_0,_0,_0.3),0_0_0_0.125em_rgba(255,_255,_255,_0.95),0_0_0_0.3125em_rgba(100,_120,_255,_0.8),0_0_1.25em_0.1875em_rgba(80,_100,_255,_0.5)]\",\r\n \"dark:[--aqua-shadow-active:0_0.0625em_0.125em_rgba(0,_0,_0,_0.4),inset_0_0_0_1px_rgba(255,_255,_255,_0.1),inset_0_0.25em_0.5em_rgba(0,_0,_0,_0.5)]\",\r\n \"dark:[--aqua-text-shadow:0_1px_2px_rgba(0,_0,_0,_0.5)]\",\r\n \"dark:[--aqua-top-highlight:linear-gradient(rgba(255,_255,_255,_0.12),_rgba(255,_255,_255,_0.03))]\",\r\n \"dark:[--aqua-bottom-highlight:linear-gradient(rgba(255,_255,_255,_0.04),_rgba(255,_255,_255,_0.08))]\",\r\n] as const;\r\n\r\nexport const AquaButton = React.forwardRef(\r\n ({ className, variant = \"primary\", style, children, ...props }, ref) => {\r\n const v = VARIANTS[variant];\r\n const isSecondary = variant === \"secondary\";\r\n\r\n return (\r\n \r\n \r\n \r\n \r\n {children}\r\n \r\n \r\n );\r\n },\r\n);\r\n\r\nAquaButton.displayName = \"AquaButton\";\r\n\r\nexport default AquaButton;\r\n" } ], "dependencies": [ diff --git a/public/r/confetti-button.json b/public/r/confetti-button.json index 2d0f965..13a48af 100644 --- a/public/r/confetti-button.json +++ b/public/r/confetti-button.json @@ -9,7 +9,7 @@ "path": "components/evil-buttons/confetti-button.tsx", "type": "registry:ui", "target": "components/evil-buttons/confetti-button.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\";\nimport confetti from \"canvas-confetti\";\nimport { motion, useAnimationControls } from \"motion/react\";\nimport { Button } from \"@/components/ui/button\";\n\nexport interface ConfettiButtonProps\n extends Omit, \"onClick\"> {\n /** Button label. Falls back to `label` when no children are provided. */\n children?: React.ReactNode;\n /** Label used when no children are provided. */\n label?: React.ReactNode;\n /** Confetti particles per burst. */\n particleCount?: number;\n /** Confetti spread in degrees. */\n spread?: number;\n /** Extra vertical launch velocity. */\n startVelocity?: number;\n /** Custom confetti colors. */\n colors?: string[];\n /** Fired after each confetti burst. */\n onCelebrate?: () => void;\n}\n\nfunction burstFromElement(\n element: HTMLElement,\n options: {\n particleCount: number;\n spread: number;\n startVelocity: number;\n colors?: string[];\n },\n) {\n const rect = element.getBoundingClientRect();\n const x = (rect.left + rect.width / 2) / window.innerWidth;\n const y = (rect.top + rect.height / 2) / window.innerHeight;\n\n void confetti({\n particleCount: options.particleCount,\n spread: options.spread,\n startVelocity: options.startVelocity,\n origin: { x, y },\n colors: options.colors,\n disableForReducedMotion: true,\n });\n}\n\nexport const ConfettiButton = React.forwardRef<\n HTMLButtonElement,\n ConfettiButtonProps\n>(\n (\n {\n children,\n label = \"Continue\",\n particleCount = 120,\n spread = 72,\n startVelocity = 38,\n colors,\n onCelebrate,\n className,\n disabled,\n variant,\n size,\n type = \"button\",\n ...props\n },\n ref,\n ) => {\n const buttonRef = React.useRef(null);\n const popControls = useAnimationControls();\n\n const setButtonRef = (node: HTMLButtonElement | null) => {\n buttonRef.current = node;\n if (typeof ref === \"function\") ref(node);\n else if (ref) ref.current = node;\n };\n\n const handleClick = () => {\n if (disabled || !buttonRef.current) return;\n\n burstFromElement(buttonRef.current, {\n particleCount,\n spread,\n startVelocity,\n colors,\n });\n onCelebrate?.();\n\n void popControls\n .start({\n scale: 1.12,\n transition: {\n type: \"spring\",\n stiffness: 520,\n damping: 14,\n mass: 0.55,\n },\n })\n .then(() =>\n popControls.start({\n scale: 1,\n transition: {\n type: \"spring\",\n stiffness: 420,\n damping: 20,\n mass: 0.6,\n },\n }),\n );\n };\n\n const displayLabel = children ?? label;\n\n return (\n \n \n {displayLabel}\n \n \n );\n },\n);\n\nConfettiButton.displayName = \"ConfettiButton\";\n\nexport default ConfettiButton;" + "content": "\"use client\";\r\n\r\nimport * as React from \"react\";\r\nimport confetti from \"canvas-confetti\";\r\nimport { motion, useAnimationControls } from \"motion/react\";\r\nimport { Button } from \"@/components/ui/button\";\r\n\r\nexport interface ConfettiButtonProps\r\n extends Omit, \"onClick\"> {\r\n /** Button label. Falls back to `label` when no children are provided. */\r\n children?: React.ReactNode;\r\n /** Label used when no children are provided. */\r\n label?: React.ReactNode;\r\n /** Confetti particles per burst. */\r\n particleCount?: number;\r\n /** Confetti spread in degrees. */\r\n spread?: number;\r\n /** Extra vertical launch velocity. */\r\n startVelocity?: number;\r\n /** Custom confetti colors. */\r\n colors?: string[];\r\n /** Fired after each confetti burst. */\r\n onCelebrate?: () => void;\r\n}\r\n\r\nfunction burstFromElement(\r\n element: HTMLElement,\r\n options: {\r\n particleCount: number;\r\n spread: number;\r\n startVelocity: number;\r\n colors?: string[];\r\n },\r\n) {\r\n const rect = element.getBoundingClientRect();\r\n const x = (rect.left + rect.width / 2) / window.innerWidth;\r\n const y = (rect.top + rect.height / 2) / window.innerHeight;\r\n\r\n void confetti({\r\n particleCount: options.particleCount,\r\n spread: options.spread,\r\n startVelocity: options.startVelocity,\r\n origin: { x, y },\r\n colors: options.colors,\r\n disableForReducedMotion: true,\r\n });\r\n}\r\n\r\nexport const ConfettiButton = React.forwardRef<\r\n HTMLButtonElement,\r\n ConfettiButtonProps\r\n>(\r\n (\r\n {\r\n children,\r\n label = \"Continue\",\r\n particleCount = 120,\r\n spread = 72,\r\n startVelocity = 38,\r\n colors,\r\n onCelebrate,\r\n className,\r\n disabled,\r\n variant,\r\n size,\r\n type = \"button\",\r\n ...props\r\n },\r\n ref,\r\n ) => {\r\n const buttonRef = React.useRef(null);\r\n const popControls = useAnimationControls();\r\n\r\n const setButtonRef = (node: HTMLButtonElement | null) => {\r\n buttonRef.current = node;\r\n if (typeof ref === \"function\") ref(node);\r\n else if (ref) ref.current = node;\r\n };\r\n\r\n const handleClick = () => {\r\n if (disabled || !buttonRef.current) return;\r\n\r\n burstFromElement(buttonRef.current, {\r\n particleCount,\r\n spread,\r\n startVelocity,\r\n colors,\r\n });\r\n onCelebrate?.();\r\n\r\n void popControls\r\n .start({\r\n scale: 1.12,\r\n transition: {\r\n type: \"spring\",\r\n stiffness: 520,\r\n damping: 14,\r\n mass: 0.55,\r\n },\r\n })\r\n .then(() =>\r\n popControls.start({\r\n scale: 1,\r\n transition: {\r\n type: \"spring\",\r\n stiffness: 420,\r\n damping: 20,\r\n mass: 0.6,\r\n },\r\n }),\r\n );\r\n };\r\n\r\n const displayLabel = children ?? label;\r\n\r\n return (\r\n \r\n \r\n {displayLabel}\r\n \r\n \r\n );\r\n },\r\n);\r\n\r\nConfettiButton.displayName = \"ConfettiButton\";\r\n\r\nexport default ConfettiButton;" } ], "registryDependencies": [ diff --git a/public/r/hold-confirm-button.json b/public/r/hold-confirm-button.json index ff523cb..4ed4739 100644 --- a/public/r/hold-confirm-button.json +++ b/public/r/hold-confirm-button.json @@ -9,7 +9,7 @@ "path": "components/evil-buttons/hold-confirm-button.tsx", "type": "registry:ui", "target": "components/evil-buttons/hold-confirm-button.tsx", - "content": "\"use client\";\r\n\r\nimport * as React from \"react\";\r\nimport {\n animate,\n mapValue,\n motionValue,\n press,\n springValue,\n styleEffect,\n svgEffect,\n} from \"motion\";\nimport { cn } from \"@/lib/utils\";\r\n\r\ntype HoldConfirmState = \"idle\" | \"holding\" | \"success\";\r\n\r\nexport interface HoldConfirmButtonProps\r\n extends Omit<\r\n React.ButtonHTMLAttributes,\r\n \"onClick\" | \"onAbort\"\r\n > {\r\n /** Milliseconds the user must hold before `onConfirm` fires. */\r\n duration?: number;\r\n /** Label shown while idle. */\r\n label?: React.ReactNode;\r\n /** Optional label shown while holding. Falls back to `label`. */\r\n holdingLabel?: React.ReactNode;\r\n /** Label shown after a successful hold. */\r\n successLabel?: React.ReactNode;\r\n /** Smallest scale reached at 100% progress. */\r\n minScale?: number;\r\n /** Diameter of the progress ring in pixels. */\r\n ringSize?: number;\r\n /** Stroke width of the progress ring. */\r\n ringStrokeWidth?: number;\r\n /** Color of the progress ring. */\r\n ringColor?: string;\r\n /** Fired when the hold reaches 100%. */\r\n onConfirm?: () => void;\r\n /** Fired when the hold is released early. Receives progress reached (0-1). */\r\n onAbort?: (progress: number) => void;\r\n /** Milliseconds to stay in the success state before resetting. Set to 0 to stay. */\r\n resetAfter?: number;\r\n}\r\n\r\nexport const HoldConfirmButton = React.forwardRef<\r\n HTMLButtonElement,\r\n HoldConfirmButtonProps\r\n>(\r\n (\r\n {\r\n duration = 2000,\r\n label = \"Hold to confirm\",\r\n holdingLabel,\r\n successLabel = \"Confirmed\",\r\n minScale = 0.9,\r\n ringSize = 280,\r\n ringStrokeWidth = 12,\n ringColor = \"#5eead4\",\n onConfirm,\r\n onAbort,\r\n resetAfter = 1600,\r\n className,\r\n disabled,\r\n type = \"button\",\r\n ...props\r\n },\r\n ref,\r\n ) => {\r\n const [state, setState] = React.useState(\"idle\");\n\n const buttonRef = React.useRef(null);\n const ringRef = React.useRef(null);\n const circleRef = React.useRef(null);\r\n const animationRef = React.useRef | null>(null);\r\n const stateRef = React.useRef(\"idle\");\r\n const resetTimeoutRef = React.useRef(null);\r\n\r\n const setButtonRef = (node: HTMLButtonElement | null) => {\r\n buttonRef.current = node;\r\n if (typeof ref === \"function\") ref(node);\r\n else if (ref) ref.current = node;\r\n };\r\n\r\n React.useEffect(() => {\r\n stateRef.current = state;\r\n }, [state]);\r\n\r\n React.useEffect(() => {\r\n const button = buttonRef.current;\n const ring = ringRef.current;\n const circle = circleRef.current;\n if (!button || !ring || !circle || disabled) return;\n\r\n const progress = motionValue(0);\n const scale = springValue(mapValue(progress, [0, 1], [1, minScale]), {\n stiffness: 420,\n damping: 28,\n mass: 0.6,\n });\n\n const ringOpacity = mapValue(progress, [0, 0.01, 1], [0, 1, 1]);\n\n const cancelStyle = styleEffect(button, { scale });\n const cancelRingOpacity = styleEffect(ring, { opacity: ringOpacity });\n const cancelSvg = svgEffect(circle, { pathLength: progress });\r\n\r\n const stopAnimation = () => {\r\n animationRef.current?.stop();\r\n animationRef.current = null;\r\n };\r\n\r\n const rewind = () => {\n stopAnimation();\n animate(progress, 0, { type: \"spring\", stiffness: 520, damping: 32 });\n };\n\r\n const complete = () => {\r\n stopAnimation();\r\n progress.set(1);\r\n setState(\"success\");\r\n onConfirm?.();\r\n\r\n if (resetAfter > 0) {\r\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n resetTimeoutRef.current = window.setTimeout(() => {\r\n rewind();\r\n setState(\"idle\");\r\n }, resetAfter);\r\n }\r\n };\r\n\r\n const cancelPress = press(button, () => {\r\n if (stateRef.current !== \"idle\") return;\r\n\r\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n\r\n setState(\"holding\");\r\n stopAnimation();\r\n progress.set(0);\r\n\r\n animationRef.current = animate(progress, 1, {\r\n duration: duration / 1000,\r\n ease: \"linear\",\r\n onComplete: complete,\r\n });\r\n\r\n return (_endEvent, { success }) => {\r\n if (stateRef.current === \"success\") return;\r\n\r\n if (!success || progress.get() < 1) {\r\n const reached = progress.get();\r\n setState(\"idle\");\r\n rewind();\r\n onAbort?.(reached);\r\n }\r\n };\r\n });\r\n\r\n return () => {\n cancelPress();\n cancelStyle();\n cancelRingOpacity();\n cancelSvg();\n stopAnimation();\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n };\r\n }, [disabled, duration, minScale, onAbort, onConfirm, resetAfter]);\r\n\r\n const radius = ringSize / 2 - ringStrokeWidth / 2 - 6;\n const center = ringSize / 2;\n const isHolding = state === \"holding\";\r\n const isSuccess = state === \"success\";\r\n const displayLabel = isSuccess\r\n ? successLabel\r\n : isHolding && holdingLabel !== undefined\r\n ? holdingLabel\r\n : label;\r\n\r\n return (\r\n \r\n \n \n \n\r\n \r\n {displayLabel}\r\n \r\n \r\n );\r\n },\r\n);\r\n\r\nHoldConfirmButton.displayName = \"HoldConfirmButton\";\r\n\r\nexport default HoldConfirmButton;" + "content": "\"use client\";\r\n\r\nimport * as React from \"react\";\r\nimport {\r\n animate,\r\n mapValue,\r\n motionValue,\r\n press,\r\n springValue,\r\n styleEffect,\r\n svgEffect,\r\n} from \"motion\";\r\nimport { cn } from \"@/lib/utils\";\r\n\r\ntype HoldConfirmState = \"idle\" | \"holding\" | \"success\";\r\n\r\nexport interface HoldConfirmButtonProps\r\n extends Omit<\r\n React.ButtonHTMLAttributes,\r\n \"onClick\" | \"onAbort\"\r\n > {\r\n /** Milliseconds the user must hold before `onConfirm` fires. */\r\n duration?: number;\r\n /** Label shown while idle. */\r\n label?: React.ReactNode;\r\n /** Optional label shown while holding. Falls back to `label`. */\r\n holdingLabel?: React.ReactNode;\r\n /** Label shown after a successful hold. */\r\n successLabel?: React.ReactNode;\r\n /** Smallest scale reached at 100% progress. */\r\n minScale?: number;\r\n /** Diameter of the progress ring in pixels. */\r\n ringSize?: number;\r\n /** Stroke width of the progress ring. */\r\n ringStrokeWidth?: number;\r\n /** Color of the progress ring. */\r\n ringColor?: string;\r\n /** Fired when the hold reaches 100%. */\r\n onConfirm?: () => void;\r\n /** Fired when the hold is released early. Receives progress reached (0-1). */\r\n onAbort?: (progress: number) => void;\r\n /** Milliseconds to stay in the success state before resetting. Set to 0 to stay. */\r\n resetAfter?: number;\r\n}\r\n\r\nexport const HoldConfirmButton = React.forwardRef<\r\n HTMLButtonElement,\r\n HoldConfirmButtonProps\r\n>(\r\n (\r\n {\r\n duration = 2000,\r\n label = \"Hold to confirm\",\r\n holdingLabel,\r\n successLabel = \"Confirmed\",\r\n minScale = 0.9,\r\n ringSize = 280,\r\n ringStrokeWidth = 12,\r\n ringColor = \"#5eead4\",\r\n onConfirm,\r\n onAbort,\r\n resetAfter = 1600,\r\n className,\r\n disabled,\r\n type = \"button\",\r\n ...props\r\n },\r\n ref,\r\n ) => {\r\n const [state, setState] = React.useState(\"idle\");\r\n\r\n const buttonRef = React.useRef(null);\r\n const ringRef = React.useRef(null);\r\n const circleRef = React.useRef(null);\r\n const animationRef = React.useRef | null>(null);\r\n const stateRef = React.useRef(\"idle\");\r\n const resetTimeoutRef = React.useRef(null);\r\n\r\n const setButtonRef = (node: HTMLButtonElement | null) => {\r\n buttonRef.current = node;\r\n if (typeof ref === \"function\") ref(node);\r\n else if (ref) ref.current = node;\r\n };\r\n\r\n React.useEffect(() => {\r\n stateRef.current = state;\r\n }, [state]);\r\n\r\n React.useEffect(() => {\r\n const button = buttonRef.current;\r\n const ring = ringRef.current;\r\n const circle = circleRef.current;\r\n if (!button || !ring || !circle || disabled) return;\r\n\r\n const progress = motionValue(0);\r\n const scale = springValue(mapValue(progress, [0, 1], [1, minScale]), {\r\n stiffness: 420,\r\n damping: 28,\r\n mass: 0.6,\r\n });\r\n\r\n const ringOpacity = mapValue(progress, [0, 0.01, 1], [0, 1, 1]);\r\n\r\n const cancelStyle = styleEffect(button, { scale });\r\n const cancelRingOpacity = styleEffect(ring, { opacity: ringOpacity });\r\n const cancelSvg = svgEffect(circle, { pathLength: progress });\r\n\r\n const stopAnimation = () => {\r\n animationRef.current?.stop();\r\n animationRef.current = null;\r\n };\r\n\r\n const rewind = () => {\r\n stopAnimation();\r\n animate(progress, 0, { type: \"spring\", stiffness: 520, damping: 32 });\r\n };\r\n\r\n const complete = () => {\r\n stopAnimation();\r\n progress.set(1);\r\n setState(\"success\");\r\n onConfirm?.();\r\n\r\n if (resetAfter > 0) {\r\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n resetTimeoutRef.current = window.setTimeout(() => {\r\n rewind();\r\n setState(\"idle\");\r\n }, resetAfter);\r\n }\r\n };\r\n\r\n const cancelPress = press(button, () => {\r\n if (stateRef.current !== \"idle\") return;\r\n\r\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n\r\n setState(\"holding\");\r\n stopAnimation();\r\n progress.set(0);\r\n\r\n animationRef.current = animate(progress, 1, {\r\n duration: duration / 1000,\r\n ease: \"linear\",\r\n onComplete: complete,\r\n });\r\n\r\n return (_endEvent, { success }) => {\r\n if (stateRef.current === \"success\") return;\r\n\r\n if (!success || progress.get() < 1) {\r\n const reached = progress.get();\r\n setState(\"idle\");\r\n rewind();\r\n onAbort?.(reached);\r\n }\r\n };\r\n });\r\n\r\n return () => {\r\n cancelPress();\r\n cancelStyle();\r\n cancelRingOpacity();\r\n cancelSvg();\r\n stopAnimation();\r\n if (resetTimeoutRef.current !== null) {\r\n window.clearTimeout(resetTimeoutRef.current);\r\n }\r\n };\r\n }, [disabled, duration, minScale, onAbort, onConfirm, resetAfter]);\r\n\r\n const radius = ringSize / 2 - ringStrokeWidth / 2 - 6;\r\n const center = ringSize / 2;\r\n const isHolding = state === \"holding\";\r\n const isSuccess = state === \"success\";\r\n const displayLabel = isSuccess\r\n ? successLabel\r\n : isHolding && holdingLabel !== undefined\r\n ? holdingLabel\r\n : label;\r\n\r\n return (\r\n \r\n \r\n \r\n \r\n\r\n \r\n {displayLabel}\r\n \r\n \r\n );\r\n },\r\n);\r\n\r\nHoldConfirmButton.displayName = \"HoldConfirmButton\";\r\n\r\nexport default HoldConfirmButton;" } ], "dependencies": [ diff --git a/public/r/index.json b/public/r/index.json index 25cb702..28bee77 100644 --- a/public/r/index.json +++ b/public/r/index.json @@ -253,7 +253,7 @@ "description": "A plain shadcn button that pops on click and fires a confetti celebration burst.", "files": [ "components/evil-buttons/confetti-button.tsx" - ] + ] }, { "name": "hold-confirm-button", diff --git a/public/r/pill-button.json b/public/r/pill-button.json index f6dbe1b..7718ee0 100644 --- a/public/r/pill-button.json +++ b/public/r/pill-button.json @@ -9,7 +9,7 @@ "path": "components/evil-buttons/pill-button.tsx", "type": "registry:ui", "target": "components/evil-buttons/pill-button.tsx", - "content": "\"use client\"\n\nimport { useState } from \"react\"\nimport { motion } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\n\ntype RollingLabelProps = {\n label: string\n}\n\nfunction RollingLabel({ label }: RollingLabelProps) {\n return (\n \n \n {label}\n \n {label}\n \n \n \n )\n}\n\ntype PillFaceProps = {\n label: string\n className?: string\n}\n\nfunction PillFace({ label, className }: PillFaceProps) {\n return (\n \n \n \n )\n}\n\ntype PillButtonProps = {\n primaryLabel: string\n secondaryLabel: string\n primaryClassName?: string\n secondaryClassName?: string\n isOpen?: boolean\n defaultOpen?: boolean\n onOpenChange?: (open: boolean) => void\n className?: string\n ariaLabel?: string | ((isOpen: boolean) => string)\n}\n\nfunction PillButton({\n primaryLabel,\n secondaryLabel,\n primaryClassName,\n secondaryClassName,\n isOpen: isOpenProp,\n defaultOpen = false,\n onOpenChange,\n className,\n ariaLabel,\n}: PillButtonProps) {\n const [internalOpen, setInternalOpen] = useState(defaultOpen)\n const isControlled = isOpenProp !== undefined\n const isOpen = isControlled ? isOpenProp : internalOpen\n\n const setOpen = (next: boolean) => {\n if (!isControlled) {\n setInternalOpen(next)\n }\n onOpenChange?.(next)\n }\n\n const toggle = () => setOpen(!isOpen)\n\n const resolvedAriaLabel =\n typeof ariaLabel === \"function\"\n ? ariaLabel(isOpen)\n : ariaLabel ?? (isOpen ? secondaryLabel : primaryLabel)\n\n return (\n {\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault()\n toggle()\n }\n }}\n className={cn(\n \"relative h-10 w-20 cursor-pointer overflow-hidden rounded-full\",\n className\n )}\n aria-expanded={isOpen}\n aria-label={resolvedAriaLabel}\n >\n \n \n \n \n \n )\n}\n\nexport { PillButton, PillFace, RollingLabel }\nexport type { PillButtonProps, PillFaceProps, RollingLabelProps }" + "content": "\"use client\"\r\n\r\nimport { useState } from \"react\"\r\nimport { motion } from \"motion/react\"\r\n\r\nimport { cn } from \"@/lib/utils\"\r\n\r\ntype RollingLabelProps = {\r\n label: string\r\n}\r\n\r\nfunction RollingLabel({ label }: RollingLabelProps) {\r\n return (\r\n \r\n \r\n {label}\r\n \r\n {label}\r\n \r\n \r\n \r\n )\r\n}\r\n\r\ntype PillFaceProps = {\r\n label: string\r\n className?: string\r\n}\r\n\r\nfunction PillFace({ label, className }: PillFaceProps) {\r\n return (\r\n \r\n \r\n \r\n )\r\n}\r\n\r\ntype PillButtonProps = {\r\n primaryLabel: string\r\n secondaryLabel: string\r\n primaryClassName?: string\r\n secondaryClassName?: string\r\n isOpen?: boolean\r\n defaultOpen?: boolean\r\n onOpenChange?: (open: boolean) => void\r\n className?: string\r\n ariaLabel?: string | ((isOpen: boolean) => string)\r\n}\r\n\r\nfunction PillButton({\r\n primaryLabel,\r\n secondaryLabel,\r\n primaryClassName,\r\n secondaryClassName,\r\n isOpen: isOpenProp,\r\n defaultOpen = false,\r\n onOpenChange,\r\n className,\r\n ariaLabel,\r\n}: PillButtonProps) {\r\n const [internalOpen, setInternalOpen] = useState(defaultOpen)\r\n const isControlled = isOpenProp !== undefined\r\n const isOpen = isControlled ? isOpenProp : internalOpen\r\n\r\n const setOpen = (next: boolean) => {\r\n if (!isControlled) {\r\n setInternalOpen(next)\r\n }\r\n onOpenChange?.(next)\r\n }\r\n\r\n const toggle = () => setOpen(!isOpen)\r\n\r\n const resolvedAriaLabel =\r\n typeof ariaLabel === \"function\"\r\n ? ariaLabel(isOpen)\r\n : ariaLabel ?? (isOpen ? secondaryLabel : primaryLabel)\r\n\r\n return (\r\n {\r\n if (e.key === \"Enter\" || e.key === \" \") {\r\n e.preventDefault()\r\n toggle()\r\n }\r\n }}\r\n className={cn(\r\n \"relative h-10 w-20 cursor-pointer overflow-hidden rounded-full\",\r\n className\r\n )}\r\n aria-expanded={isOpen}\r\n aria-label={resolvedAriaLabel}\r\n >\r\n \r\n \r\n \r\n \r\n \r\n )\r\n}\r\n\r\nexport { PillButton, PillFace, RollingLabel }\r\nexport type { PillButtonProps, PillFaceProps, RollingLabelProps }" } ], "dependencies": [ diff --git a/types/poly-decomp.d.ts b/types/poly-decomp.d.ts new file mode 100644 index 0000000..712f32b --- /dev/null +++ b/types/poly-decomp.d.ts @@ -0,0 +1,6 @@ +declare module "poly-decomp" { + import type { Decomp } from "matter-js"; + + const decomp: Decomp; + export default decomp; +} \ No newline at end of file