diff --git a/apps/www/__registry__/index.tsx b/apps/www/__registry__/index.tsx index ece4f28..15277b4 100644 --- a/apps/www/__registry__/index.tsx +++ b/apps/www/__registry__/index.tsx @@ -203,6 +203,17 @@ export const Index: Record = { subcategory: "undefined", chunks: [] }, + "lightboard": { + name: "lightboard", + type: "components:ui", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/ui/lightboard")), + source: "", + files: ["registry/default/ui/lightboard.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, "text-animate-demo": { name: "text-animate-demo", type: "components:example", @@ -401,6 +412,17 @@ export const Index: Record = { subcategory: "undefined", chunks: [] }, + "lightboard-demo": { + name: "lightboard-demo", + type: "components:example", + registryDependencies: ["lightboard"], + component: React.lazy(() => import("@/registry/default/example/lightboard-demo")), + source: "", + files: ["registry/default/example/lightboard-demo.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, "authentication-01": { name: "authentication-01", type: "components:block", diff --git a/apps/www/app/(app)/page.tsx b/apps/www/app/(app)/page.tsx index 3733321..41f23b2 100644 --- a/apps/www/app/(app)/page.tsx +++ b/apps/www/app/(app)/page.tsx @@ -1,6 +1,5 @@ -import { Suspense } from "react" import Link from "next/link" -import { ArrowRight, IceCream, SparklesIcon } from "lucide-react" +import { IceCream } from "lucide-react" import { siteConfig } from "@/config/site" import { cn } from "@/lib/utils" @@ -15,12 +14,12 @@ import { TypeScriptIcon, } from "@/components/icons" import { FeaturesSection } from "@/components/landing/feature-section" +import { FeaturedComponent } from "@/components/landing/featured-component" import { PlugCardGrid } from "@/components/landing/plug-grid" +import { TemplateGrid } from "@/components/landing/template-grid" import { PageActions, PageHeader } from "@/components/page-header" import BgNoiseWrapper from "@/components/texture-wrapper" -import DockAnimation from "@/registry/default/example/dock-demo" import { GradientHeading } from "@/registry/default/ui/gradient-heading" -import { TweetGrid } from "@/registry/default/ui/tweet-grid" export default function IndexPage() { return ( @@ -83,54 +82,17 @@ export default function IndexPage() { -
+
- +
-
-
- - {" "} - Latest component - -
-
- - - - -
- Dock - - A Mac OS doc animation - - -
-
-
-
- -
-
+
+
- - {/*
+
-
*/} +
@@ -148,30 +110,8 @@ export default function IndexPage() {
-
- -
- - - -
-
-
) } IndexPage.theme = "light" - -const tweets = [ - "1742983975340327184", - "1743049700583116812", - "1754067409366073443", - "1753968111059861648", - "1754174981897118136", - "1743632296802988387", - "1754110885168021921", - "1760248682828419497", - "1760230134601122153", - "1760184980356088267", -] diff --git a/apps/www/components/landing/assets/cult-directory-dark-home.png b/apps/www/components/landing/assets/cult-directory-dark-home.png new file mode 100644 index 0000000..ae2b314 Binary files /dev/null and b/apps/www/components/landing/assets/cult-directory-dark-home.png differ diff --git a/apps/www/components/landing/assets/cult-logo-gpt.png b/apps/www/components/landing/assets/cult-logo-gpt.png new file mode 100644 index 0000000..a4ebc04 Binary files /dev/null and b/apps/www/components/landing/assets/cult-logo-gpt.png differ diff --git a/apps/www/components/landing/assets/cult-manifest.png b/apps/www/components/landing/assets/cult-manifest.png new file mode 100644 index 0000000..c8b11ae Binary files /dev/null and b/apps/www/components/landing/assets/cult-manifest.png differ diff --git a/apps/www/components/landing/assets/cult-seo-og.png b/apps/www/components/landing/assets/cult-seo-og.png new file mode 100644 index 0000000..8ec91e6 Binary files /dev/null and b/apps/www/components/landing/assets/cult-seo-og.png differ diff --git a/apps/www/components/landing/assets/logo-gpt.png b/apps/www/components/landing/assets/logo-gpt.png new file mode 100644 index 0000000..5ae32f1 Binary files /dev/null and b/apps/www/components/landing/assets/logo-gpt.png differ diff --git a/apps/www/components/landing/assets/manifest-library.png b/apps/www/components/landing/assets/manifest-library.png new file mode 100644 index 0000000..29422b8 Binary files /dev/null and b/apps/www/components/landing/assets/manifest-library.png differ diff --git a/apps/www/components/landing/assets/rune-hero.png b/apps/www/components/landing/assets/rune-hero.png new file mode 100644 index 0000000..d19131b Binary files /dev/null and b/apps/www/components/landing/assets/rune-hero.png differ diff --git a/apps/www/components/landing/assets/travel-stash.png b/apps/www/components/landing/assets/travel-stash.png new file mode 100644 index 0000000..98b7c40 Binary files /dev/null and b/apps/www/components/landing/assets/travel-stash.png differ diff --git a/apps/www/components/landing/featured-component.tsx b/apps/www/components/landing/featured-component.tsx new file mode 100644 index 0000000..3648ac2 --- /dev/null +++ b/apps/www/components/landing/featured-component.tsx @@ -0,0 +1,93 @@ +import Link from "next/link" +import { ArrowRight, SparklesIcon } from "lucide-react" + +import { GradientHeading } from "@/registry/default/ui/gradient-heading" +import { LightBoard } from "@/registry/default/ui/lightboard" + +import { Badge } from "../ui/badge" + +export function FeaturedComponent() { + return ( +
+ + {" "} + Latest component + +
+
+
+ LightBoard + + A retro light board marquee that you can draw on + + +
+
+
+ +
+ {/* Matrix Style */} +
+ +
+ +
+ +
+ + {/* Interactive Neon Board */} +
+ +
+
+
+ ) +} diff --git a/apps/www/components/landing/plug-grid.tsx b/apps/www/components/landing/plug-grid.tsx index 79b52a9..82ae6e6 100644 --- a/apps/www/components/landing/plug-grid.tsx +++ b/apps/www/components/landing/plug-grid.tsx @@ -35,12 +35,12 @@ export function PlugCardGrid() { ] return ( -
+
- {" "} + {" "} Additional goods
@@ -53,7 +53,7 @@ export function PlugCardGrid() { href={card.href} > - + {/* */} {card.title} diff --git a/apps/www/components/landing/template-grid.tsx b/apps/www/components/landing/template-grid.tsx new file mode 100644 index 0000000..4e6be31 --- /dev/null +++ b/apps/www/components/landing/template-grid.tsx @@ -0,0 +1,301 @@ +import { IceCream } from "lucide-react" + +import { cn } from "@/lib/utils" +import cultDirectoryHomeDark from "@/components/landing/assets/cult-directory-dark-home.png" +import cultLogoGPTHome from "@/components/landing/assets/cult-logo-gpt.png" +import cultSeoOg from "@/components/landing/assets/cult-seo-og.png" +import manifestBottomLeft from "@/components/landing/assets/manifest-library.png" +import runeHero from "@/components/landing/assets/rune-hero.png" +import travelStash4 from "@/components/landing/assets/travel-stash.png" +import { + MinimalCard, + MinimalCardDescription, + MinimalCardImage, + MinimalCardTitle, +} from "@/registry/default/ui/minimal-card" + +import { Badge } from "../ui/badge" + +export function TemplateGrid() { + return ( +
+ + {" "} + Templates + +
+ + ) +} + +export const TEMPLATES_GRID = [ + { + name: "Logo GPT", + slug: "https://www.newcult.co/templates/logo-gpt-template", + new: true, + downloadUrl: "cult-logo-gpt-template.zip", + liveUrl: "https://cult-logo.vercel.app", // replace with the actual live URL + meta: "fullstack", + description: + "Dalle Logo Generator. Managing authentication and storage with supabase, and implementing a token-based currency system.", + features: [ + { + name: "Dalle 2 + 3 Image Generation", + description: + "Generate high-quality images using the latest Dalle 2 and Dalle 3 models. Customize images to suit your brand's needs effortlessly.", + icon: "ai", + }, + + { + name: "Image Storage", + description: + "Efficiently store and manage your generated images with Supabase's integrated storage solutions, ensuring your assets are always available and secure.", + icon: "ai", + }, + { + name: "Token-Based Currency", + description: + "Implement a token-based currency system to manage credits for generating images, offering a flexible and scalable solution for your users.", + icon: "supabase", + }, + ], + type: "template", + stack: ["nextjs", "tailwind", "openai", "supabase"], + gradient: "bg-gradient-to-b to-[#DB4EDF] from-[#F8F7F8] via-white", + gif: "https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExajljemljODZlencyYnUzZnlsc2FtZmprbmFvNnlueDhwenp1NXdxdSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/AdRaGoL5xT1SdI6J5v/giphy.gif", + images: [cultLogoGPTHome], + }, + { + name: "Directory", + slug: "https://www.newcult.co/templates/cult-directory-template", + new: true, + downloadUrl: "cult-directory.zip", + liveUrl: "https://nextjs.design", // replace with the actual live URL + meta: "fullstack", + description: + "Ship your own directory startup in 5 minutes with a 3 stage scraping and ai enrichment pipeline. Great for building SEO backlinks and selling ad space. ", + features: [ + { + name: "Scraping", + description: + "Provide an array of urls you want to add to your directory. Run pnpm run enrich-seed. Voila - your directory is filled with as many urls as you want 🤌.", + icon: "scrape", + }, + { + name: "Authentication", + description: + "Dead simple auth via supabase. Password reset flows. PW encryption and all. No need to pay for clerk or auth0.", + icon: "auth", + }, + { + name: "AI Enrichment", + description: + "Not only are there batch AI enrichment jobs but you can also run AI enrichment on user submitted content. Save time and let your directory run itself.", + icon: "ai", + }, + { + name: "Supabase storage", + description: + "Supabase wraps s3 perfectly. No need to buy a third party storage integration or subscription 😤 service. Just use supabase to store images.", + icon: "supabase", + }, + ], + type: "template", + stack: ["nextjs", "tailwind", "claudeAI", "supabase", "web-scrapers"], + gradient: "bg-gradient-to-b to-yellow-300 from-[#F8F7F8] via-white", + gif: "https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExam4xMmVqZGVuaG05cDhxaWM2bDlwaWJ2OXVrN3E2aG54bDdjam1hZSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/7bzrBMHEsgPb20T3C5/giphy.gif", // replace with the actual gif URL + images: [cultDirectoryHomeDark], + }, + { + name: "Travel Stash", + slug: "https://www.newcult.co/templates/cult-offline-travel-stash", + new: false, + downloadUrl: "cult-offline-travel-stash.zip", + liveUrl: "https://dub.sh/travl", // replace with the actual live URL + meta: "fullstack", + description: + "Offline First Travel App - A pwa designed to manage and plan travel goals using claude haiku ai and the rxdb to store data locally in the browser, regardless of connectivity.", + features: [ + { + name: "Offline Capabilities", + description: + "Access and interact with the app even without an internet connection, ensuring reliability anywhere.", + icon: "layers", + }, + { + name: "Real-time Sync", + description: + "Automatic data synchronization when online, keeping your travel plans up-to-date across all devices.", + icon: "server", + }, + { + name: "PWA Support", + description: + "Installable on any device, providing a native app-like experience with smooth interactions.", + icon: "shieldCheck", + }, + ], + type: "template", + stack: ["nextjs", "tailwind", "claudeAI", "pwa"], + gradient: "bg-gradient-to-b to-[#2CCFFF] from-[#F8F7F8] via-white", + gif: "https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExN3YzOTc1NDgxcHhib2o1ZWhpcWVsdDRqOW9hMng3ZnA0bmxzYjZwbyZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/XJjAk4IB8Tgudo0Tqz/giphy.gif", // replace with the actual gif URL + + images: [travelStash4], + }, + + { + name: "Landing Page", + new: false, + meta: "marketing", + downloadUrl: "cult-landing-page.zip", + liveUrl: "https://dub.sh/rune", + gradient: "bg-gradient-to-b from-white/10 to-[#FF9150] via-[#FFD0B7]/30", + slug: "https://www.newcult.co/templates/cult-landing-page", + description: + "Fully designed landing page template. Framer motion animations, unique navigation, and more.", + features: [ + { + name: "Animation", + description: "A landing page that stands out.", + icon: "paint", + }, + { + name: "Unique navigation", + description: "The newcult.co nav bar animation.", + icon: "layers", + }, + ], + type: "template", + stack: ["nextjs", "tailwind"], + images: [runeHero], + gif: "https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExeGV2MWMzY2I4eW45NThuMWJ0enpsY2tyenZkNTJtNjk4am5hb2FmMSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/lmXonZXi4HBJldN0rt/giphy-downsized-large.gif", + }, + + { + name: "Cult SEO", + slug: "https://www.newcult.co/templates/cult-seo", + new: false, + downloadUrl: "cult-seo.zip", + liveUrl: "https://cleanmyseo.com", + meta: "fullstack", + description: + "Crawl websites, SEO grading algorithm, test site performance, check OG images, and get AI improvements.", + features: [ + { + name: "RSC Web Scraping", + description: "Lightning fast web scraping via rsc.", + icon: "chat", + }, + { + name: "Web Vitals", + description: "Google CRUX API adapters for web vitals.", + icon: "barChart", + }, + { + name: "Structured AI output", + description: "Vercel ai sdk with claude, zod, ai object.", + icon: "ai", + }, + ], + type: "template", + stack: ["nextjs", "tailwind", "claudeAI", "web-scrapers"], + + gradient: "bg-gradient-to-b from-white to-[#2770EB] via-[#FF7102]/20", + gif: "https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExMmthd283MHdqYTAzNjFzZXptbGg2MGIzY3RudzBsdDdveGsxdG9haCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/w1LYqDDIpDaLKj6N5t/giphy.gif", + // images: [cultSeoScore, cultSeoVitals, cultSeoOg, cultSeoHome], + images: [cultSeoOg], + }, + + { + name: "Manifest", + slug: "https://www.newcult.co/templates/manifest", + meta: "fullstack", + liveUrl: "https://dub.sh/vector", + downloadUrl: "cult-manifest-v1.2.zip", + // gradient: "bg-gradient-to-b from-green-50 to-green-400 via-black/10", + gradient: "bg-gradient-to-b from-white/10 to-green-400 via-green-50", + new: false, + description: + "Vector Embedding Template - Full stack template for shipping perplexity style AI apps.", + features: [ + { + name: "Vector embeddings", + description: + "Efficient storage and retrieval of vector embeddings using supabase vector", + icon: "ai", + }, + { + name: "RAG retrieval", + description: + "Perplexity style AI RAG retrieval with sources streamed and cited.", + icon: "chat", + }, + { + name: "Supabase", + description: "Robust database management with Supabase.", + icon: "supabase", + }, + ], + type: "template", + stack: ["nextjs", "tailwind", "supabase", "openai"], + // images: [manifestBottomLeft, manifestCenter, manifestRight], + images: [manifestBottomLeft], + gif: "https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExaWVwNXVkdXM3aWM4NXM2a2s2czFhd283NHdrbWFsdm43bGdsMXp4MyZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/SM08k77xWhQtQDDluI/giphy.gif", + }, +] diff --git a/apps/www/config/docs.ts b/apps/www/config/docs.ts index 0c96769..a494f9e 100644 --- a/apps/www/config/docs.ts +++ b/apps/www/config/docs.ts @@ -39,11 +39,17 @@ export const docsConfig: DocsConfig = { { title: "Components", items: [ + { + title: "LightBoard", + href: "/docs/components/lightboard", + items: [], + label: "latest", + }, { title: "MacOS Dock", href: "/docs/components/dock", items: [], - label: "latest", + label: "new", }, { title: "Sortable List", diff --git a/apps/www/content/docs/components/lightboard.mdx b/apps/www/content/docs/components/lightboard.mdx new file mode 100644 index 0000000..7c304f8 --- /dev/null +++ b/apps/www/content/docs/components/lightboard.mdx @@ -0,0 +1,76 @@ +--- +title: LightBoard +description: A fun lightboard component used to display moving text and draw in a visually appealing way. +component: true +links: +--- + + + +## References + + + +

Inspiration

+ + + + + Rauno Nextjs Blog + + + + + +
+ +## Installation + + + + + Manual + + + + + Copy and paste the following code into your project. + + Update the import paths to match your project setup. + + + + + +## Usage + +```tsx +import { LightBoard } from "@/components/lightboard" +``` + +```tsx +// Example usage of the Dock component with animated cards and dividers + +const LightBoardDemo = () => { + return ( + + ) +} + +export default LightBoardDemo +``` diff --git a/apps/www/public/registry/index.json b/apps/www/public/registry/index.json index 65ab069..df74153 100644 --- a/apps/www/public/registry/index.json +++ b/apps/www/public/registry/index.json @@ -179,5 +179,15 @@ "ui/dock.tsx" ], "type": "components:ui" + }, + { + "name": "lightboard", + "dependencies": [ + "" + ], + "files": [ + "ui/lightboard.tsx" + ], + "type": "components:ui" } ] \ No newline at end of file diff --git a/apps/www/public/registry/styles/default/lightboard.json b/apps/www/public/registry/styles/default/lightboard.json new file mode 100644 index 0000000..7a80273 --- /dev/null +++ b/apps/www/public/registry/styles/default/lightboard.json @@ -0,0 +1,13 @@ +{ + "name": "lightboard", + "dependencies": [ + "" + ], + "files": [ + { + "name": "lightboard.tsx", + "content": "\"use client\"\n\nimport React, { useCallback, useEffect, useRef, useState } from \"react\"\n\nexport type PatternCell = \"0\" | \"1\" | \"2\" | \"3\"\ntype Pattern = PatternCell[][]\n\ninterface LightBoardProps {\n gap?: number\n rows?: number\n lightSize?: number\n updateInterval?: number\n text: string\n font?: \"default\" | \"7segment\"\n colors?: Partial\n disableDrawing?: boolean\n controlledDrawState?: PatternCell\n onDrawStateChange?: (newState: PatternCell) => void\n controlledHoverState?: boolean\n onHoverStateChange?: (isHovered: boolean) => void\n}\n\ninterface LightBoardColors {\n drawLine: string // Color for moderately lit text\n background: string // Color for inactive lights\n textDim: string // Color for dimly lit text\n textBright: string // Color for brightly lit text\n}\n\nconst defaultColors: LightBoardColors = {\n drawLine: \"rgba(160, 160, 200, 0.7)\",\n background: \"rgba(30, 30, 40, 0.3)\",\n textDim: \"rgba(100, 100, 140, 0.5)\",\n textBright: \"rgba(220, 220, 255, 0.9)\",\n}\n\n// This function takes some text and makes sure there's enough space between words\nconst normalizeText = (text: string, minSpacing: number = 3): string => {\n const trimmed = text.trim().toUpperCase() // Remove extra spaces and make all letters big\n const spacedText = ` ${trimmed} `.replace(/\\s+/g, \" \".repeat(minSpacing)) // Add spaces between words\n return spacedText\n}\n\n// This function turns text into a pattern of lights\nconst textToPattern = (\n text: string,\n rows: number,\n columns: number,\n font: { [key: string]: Pattern }\n): Pattern => {\n // First, we make the letters bigger if we have more rows\n const letterHeight = font[\"A\"].length\n const scale = Math.max(1, Math.floor(rows / letterHeight))\n\n // We make each letter in the font bigger\n const scaledFont = Object.fromEntries(\n Object.entries(font).map(([char, pattern]) => [\n char,\n pattern\n .flatMap((row) => Array(scale).fill(row))\n .map((row) =>\n row.flatMap((cell: PatternCell) =>\n Array(scale).fill(cell === \"1\" ? \"1\" : \"3\")\n )\n ),\n ])\n )\n // We add spaces to the text\n const normalizedText = normalizeText(text)\n\n // We turn each letter into a pattern of lights\n const letterPatterns = normalizedText\n .split(\"\")\n .map((char) => scaledFont[char] || scaledFont[\" \"])\n\n // We combine all the letter patterns into one big pattern\n let fullPattern: Pattern = Array(scaledFont[\"A\"].length)\n .fill([])\n .map(() => [])\n\n letterPatterns.forEach((letterPattern) => {\n fullPattern = fullPattern.map((row, i) => [...row, ...letterPattern[i]])\n })\n\n // We add empty space above and below the pattern to center it\n const totalRows = rows\n const patternRows = fullPattern.length\n const topPadding = Math.floor((totalRows - patternRows) / 2)\n const bottomPadding = totalRows - patternRows - topPadding\n\n const paddedPattern = [\n ...Array(topPadding).fill(Array(fullPattern[0].length).fill(\"0\")),\n ...fullPattern,\n ...Array(bottomPadding).fill(Array(fullPattern[0].length).fill(\"0\")),\n ]\n\n // We make the pattern wider by repeating it\n const extendedPattern = paddedPattern.map((row) => {\n while (row.length < columns * 2) {\n row = [...row, ...row]\n }\n return row\n })\n\n return extendedPattern\n}\n\n// This function decides what color each light should be\nfunction getLightColor(\n state: PatternCell,\n colors: Partial\n): string {\n const mergedColors = { ...defaultColors, ...colors }\n\n switch (state) {\n case \"1\":\n return mergedColors.textDim\n case \"2\":\n return mergedColors.drawLine\n case \"3\":\n return mergedColors.textBright\n default:\n return mergedColors.background\n }\n}\n\nconst defaultDrawState: PatternCell = \"2\"\n\nfunction LightBoard({\n text,\n gap = 1,\n lightSize = 4,\n rows = 5,\n font = \"default\",\n updateInterval = 10,\n colors = {},\n controlledDrawState,\n disableDrawing = true,\n controlledHoverState,\n onHoverStateChange,\n}: LightBoardProps) {\n // We decide how many rows and columns of lights we need\n const containerRef = useRef(null)\n const [columns, setColumns] = useState(0)\n const mergedColors = { ...defaultColors, ...colors }\n\n // We choose which font to use for our text\n const selectedFont = font === \"default\" ? defaultFont : sevenSegmentFont\n // We keep track of whether the mouse is over our board\n\n // We keep track of whether we're drawing on the board\n const [isDrawing, setIsDrawing] = useState(false)\n // Use controlled state if provided, otherwise use local state\n const [internalHoverState, setInternalHoverState] = useState(false)\n // This is the brightness of the lights we're drawing (0 to 3)\n\n // This is our pattern of lights that make up the text\n const [basePattern, setBasePattern] = useState(() => {\n return textToPattern(normalizeText(text), rows, columns, selectedFont)\n })\n // This helps us move the text across the board\n const [offset, setOffset] = useState(0)\n // This is how we draw on our light board (it's like a special piece of paper)\n const canvasRef = useRef(null)\n // This remembers where we last drew on the board\n const lastDrawnPosition = useRef<{ x: number; y: number } | null>(null)\n // This helps us know when to update our animation\n const [lastUpdateTime, setLastUpdateTime] = useState(0)\n\n const drawState =\n controlledDrawState !== undefined ? controlledDrawState : defaultDrawState\n\n const isHovered =\n controlledHoverState !== undefined\n ? controlledHoverState\n : internalHoverState\n\n // Calculate the number of columns based on container width\n useEffect(() => {\n const calculateColumns = () => {\n if (containerRef.current) {\n const containerWidth = containerRef.current.offsetWidth\n const calculatedColumns = Math.floor(containerWidth / (lightSize + gap))\n setColumns(calculatedColumns)\n }\n }\n\n calculateColumns()\n window.addEventListener(\"resize\", calculateColumns)\n return () => window.removeEventListener(\"resize\", calculateColumns)\n }, [lightSize, gap])\n\n // This function draws all our lights on the board\n const drawToCanvas = useCallback(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return\n\n ctx.clearRect(0, 0, canvas.width, canvas.height)\n\n const patternWidth = basePattern[0].length\n\n basePattern.forEach((row, rowIndex) => {\n for (let colIndex = 0; colIndex < columns; colIndex++) {\n const patternColIndex = (colIndex + offset) % patternWidth\n const state = row[patternColIndex]\n\n ctx.fillStyle = getLightColor(state as PatternCell, mergedColors)\n ctx.beginPath()\n ctx.arc(\n colIndex * (lightSize + gap) + lightSize / 2,\n rowIndex * (lightSize + gap) + lightSize / 2,\n lightSize / 2,\n 0,\n 2 * Math.PI\n )\n ctx.fill()\n }\n })\n }, [basePattern, offset, columns, lightSize, gap, mergedColors])\n\n // This makes our text move across the board\n useEffect(() => {\n let animationFrameId: number\n\n const animate = () => {\n if (!isHovered) {\n // If the mouse isn't over the board, we move the text\n setOffset((prevOffset) => (prevOffset + 1) % basePattern[0].length)\n }\n drawToCanvas()\n animationFrameId = requestAnimationFrame(animate)\n }\n\n animationFrameId = requestAnimationFrame(animate)\n\n // We clean up our animation when we're done\n return () => cancelAnimationFrame(animationFrameId)\n }, [basePattern, isHovered, drawToCanvas])\n\n // This updates our light pattern when the text changes\n useEffect(() => {\n setBasePattern(\n textToPattern(normalizeText(text), rows, columns, selectedFont)\n )\n }, [text, rows, columns, selectedFont])\n\n // This is another way we make our text move\n const animate = useCallback(() => {\n const currentTime = Date.now()\n if (currentTime - lastUpdateTime >= updateInterval && !isHovered) {\n setOffset((prevOffset) => (prevOffset + 1) % basePattern[0].length)\n setLastUpdateTime(currentTime)\n }\n drawToCanvas()\n }, [updateInterval, isHovered, basePattern, drawToCanvas, lastUpdateTime])\n\n // This keeps our animation going\n useEffect(() => {\n let animationFrameId: number\n\n const loop = () => {\n animate()\n animationFrameId = requestAnimationFrame(loop)\n }\n\n animationFrameId = requestAnimationFrame(loop)\n\n // We clean up our animation when we're done\n return () => cancelAnimationFrame(animationFrameId)\n }, [animate])\n\n // This function helps us draw a line on our light board\n const drawLine = useCallback(\n (startX: number, startY: number, endX: number, endY: number) => {\n const canvas = canvasRef.current\n if (!canvas) return\n\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return\n\n // We figure out which direction we're drawing\n const dx = Math.abs(endX - startX)\n const dy = Math.abs(endY - startY)\n const sx = startX < endX ? 1 : -1\n const sy = startY < endY ? 1 : -1\n let err = dx - dy\n\n // We keep drawing until we reach the end of our line\n while (true) {\n // We figure out which light we're on\n const colIndex = Math.floor(startX / (lightSize + gap))\n const rowIndex = Math.floor(startY / (lightSize + gap))\n\n // If we're still on the board...\n if (\n rowIndex >= 0 &&\n rowIndex < rows &&\n colIndex >= 0 &&\n colIndex < columns\n ) {\n // We figure out which light to change in our pattern\n const actualColIndex = (colIndex + offset) % basePattern[0].length\n\n // If this light isn't already the brightness we want...\n if (basePattern[rowIndex][actualColIndex] !== drawState) {\n // We update our pattern of lights\n setBasePattern((prevPattern) => {\n const newPattern = [...prevPattern]\n newPattern[rowIndex] = [...newPattern[rowIndex]]\n newPattern[rowIndex][actualColIndex] = drawState\n return newPattern\n })\n\n // We draw the new light on our board\n ctx.fillStyle = getLightColor(drawState, mergedColors)\n\n ctx.beginPath()\n ctx.arc(\n colIndex * (lightSize + gap) + lightSize / 2,\n rowIndex * (lightSize + gap) + lightSize / 2,\n lightSize / 2,\n 0,\n 2 * Math.PI\n )\n ctx.fill()\n }\n }\n\n // If we've reached the end of our line, we stop\n if (startX === endX && startY === endY) break\n\n // We figure out where to draw next\n const e2 = 2 * err\n if (e2 > -dy) {\n err -= dy\n startX += sx\n }\n if (e2 < dx) {\n err += dx\n startY += sy\n }\n }\n },\n [\n basePattern,\n columns,\n drawState,\n gap,\n lightSize,\n offset,\n rows,\n mergedColors,\n ]\n )\n\n // _________DRAWING HANDLING_________\n\n const handleInteractionStart = useCallback(\n (x: number, y: number) => {\n if (disableDrawing) return\n setIsDrawing(true)\n lastDrawnPosition.current = null\n drawLine(x, y, x, y)\n },\n [disableDrawing, drawLine]\n )\n\n const handleInteractionMove = useCallback(\n (x: number, y: number) => {\n if (!isDrawing || disableDrawing) return\n if (lastDrawnPosition.current) {\n drawLine(lastDrawnPosition.current.x, lastDrawnPosition.current.y, x, y)\n } else {\n drawLine(x, y, x, y)\n }\n lastDrawnPosition.current = { x, y }\n },\n [isDrawing, disableDrawing, drawLine]\n )\n\n const handleInteractionEnd = useCallback(() => {\n setIsDrawing(false)\n lastDrawnPosition.current = null\n }, [])\n\n // This happens when we press the mouse button to start drawing\n const handleMouseDown = useCallback(\n (event: React.MouseEvent) => {\n const canvas = event.currentTarget\n const rect = canvas.getBoundingClientRect()\n const x = event.clientX - rect.left\n const y = event.clientY - rect.top\n handleInteractionStart(x, y)\n },\n [handleInteractionStart]\n )\n\n const handleMouseMove = useCallback(\n (event: React.MouseEvent) => {\n const canvas = event.currentTarget\n const rect = canvas.getBoundingClientRect()\n const x = event.clientX - rect.left\n const y = event.clientY - rect.top\n handleInteractionMove(x, y)\n },\n [handleInteractionMove]\n )\n\n const handleMouseUp = handleInteractionEnd\n\n const handleTouchStart = useCallback(\n (event: React.TouchEvent) => {\n event.preventDefault()\n const touch = event.touches[0]\n const canvas = event.currentTarget\n const rect = canvas.getBoundingClientRect()\n const x = touch.clientX - rect.left\n const y = touch.clientY - rect.top\n handleInteractionStart(x, y)\n },\n [handleInteractionStart]\n )\n\n const handleTouchMove = useCallback(\n (event: React.TouchEvent) => {\n event.preventDefault()\n const touch = event.touches[0]\n const canvas = event.currentTarget\n const rect = canvas.getBoundingClientRect()\n const x = touch.clientX - rect.left\n const y = touch.clientY - rect.top\n handleInteractionMove(x, y)\n },\n [handleInteractionMove]\n )\n\n const handleTouchEnd = handleInteractionEnd\n\n // Update hover state\n const updateHoverState = useCallback(\n (newState: boolean) => {\n if (controlledHoverState === undefined) {\n setInternalHoverState(newState)\n }\n onHoverStateChange?.(newState)\n },\n [controlledHoverState, onHoverStateChange]\n )\n\n return (\n
\n {columns > 0 && (\n \n controlledHoverState === undefined && updateHoverState(true)\n }\n onMouseLeave={() => {\n controlledHoverState === undefined && updateHoverState(false)\n handleInteractionEnd()\n }}\n onTouchStart={!disableDrawing ? handleTouchStart : undefined}\n onTouchEnd={handleTouchEnd}\n onTouchMove={handleTouchMove}\n style={{\n cursor: disableDrawing ? \"default\" : \"pointer\",\n touchAction: \"none\",\n userSelect: \"none\",\n }}\n />\n )}\n
\n )\n}\n\nexport { LightBoard }\n\nconst sevenSegmentFont: { [key: string]: Pattern } = {\n \"0\": [\n [\"1\", \"1\", \"1\"],\n [\"1\", \"0\", \"1\"],\n [\"1\", \"0\", \"1\"],\n [\"1\", \"0\", \"1\"],\n [\"1\", \"1\", \"1\"],\n ],\n \"1\": [\n [\"0\", \"0\", \"1\"],\n [\"0\", \"0\", \"1\"],\n [\"0\", \"0\", \"1\"],\n [\"0\", \"0\", \"1\"],\n [\"0\", \"0\", \"1\"],\n ],\n // Add more digits as needed\n}\n\nconst defaultFont: { [key: string]: Pattern } = {\n \" \": [\n [\"0\", \"0\", \"0\", \"0\"],\n [\"0\", \"0\", \"0\", \"0\"],\n [\"0\", \"0\", \"0\", \"0\"],\n [\"0\", \"0\", \"0\", \"0\"],\n [\"0\", \"0\", \"0\", \"0\"],\n ],\n A: [\n [\"0\", \"1\", \"1\", \"0\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"1\", \"1\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n ],\n B: [\n [\"1\", \"1\", \"1\", \"0\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"1\", \"1\", \"0\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"1\", \"1\", \"0\"],\n ],\n C: [\n [\"0\", \"1\", \"1\", \"1\"],\n [\"1\", \"0\", \"0\", \"0\"],\n [\"1\", \"0\", \"0\", \"0\"],\n [\"1\", \"0\", \"0\", \"0\"],\n [\"0\", \"1\", \"1\", \"1\"],\n ],\n D: [\n [\"1\", \"1\", \"1\", \"0\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"1\", \"1\", \"0\"],\n ],\n E: [\n [\"1\", \"1\", \"1\", \"1\"],\n [\"1\", \"0\", \"0\", \"0\"],\n [\"1\", \"1\", \"1\", \"0\"],\n [\"1\", \"0\", \"0\", \"0\"],\n [\"1\", \"1\", \"1\", \"1\"],\n ],\n F: [\n [\"1\", \"1\", \"1\", \"1\"],\n [\"1\", \"0\", \"0\", \"0\"],\n [\"1\", \"1\", \"1\", \"0\"],\n [\"1\", \"0\", \"0\", \"0\"],\n [\"1\", \"0\", \"0\", \"0\"],\n ],\n G: [\n [\"0\", \"1\", \"1\", \"1\"],\n [\"1\", \"0\", \"0\", \"0\"],\n [\"1\", \"0\", \"1\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"0\", \"1\", \"1\", \"1\"],\n ],\n H: [\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"1\", \"1\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n ],\n I: [\n [\"1\", \"1\", \"1\"],\n [\"0\", \"1\", \"0\"],\n [\"0\", \"1\", \"0\"],\n [\"0\", \"1\", \"0\"],\n [\"1\", \"1\", \"1\"],\n ],\n J: [\n [\"0\", \"0\", \"1\", \"1\"],\n [\"0\", \"0\", \"0\", \"1\"],\n [\"0\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"0\", \"1\", \"1\", \"0\"],\n ],\n K: [\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"1\", \"0\"],\n [\"1\", \"1\", \"0\", \"0\"],\n [\"1\", \"0\", \"1\", \"0\"],\n [\"1\", \"0\", \"0\", \"1\"],\n ],\n L: [\n [\"1\", \"0\", \"0\", \"0\"],\n [\"1\", \"0\", \"0\", \"0\"],\n [\"1\", \"0\", \"0\", \"0\"],\n [\"1\", \"0\", \"0\", \"0\"],\n [\"1\", \"1\", \"1\", \"1\"],\n ],\n M: [\n [\"1\", \"0\", \"0\", \"0\", \"1\"],\n [\"1\", \"1\", \"0\", \"1\", \"1\"],\n [\"1\", \"0\", \"1\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"0\", \"1\"],\n ],\n N: [\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"1\", \"0\", \"1\"],\n [\"1\", \"0\", \"1\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n ],\n O: [\n [\"0\", \"1\", \"1\", \"0\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"0\", \"1\", \"1\", \"0\"],\n ],\n P: [\n [\"1\", \"1\", \"1\", \"0\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"1\", \"1\", \"0\"],\n [\"1\", \"0\", \"0\", \"0\"],\n [\"1\", \"0\", \"0\", \"0\"],\n ],\n Q: [\n [\"0\", \"1\", \"1\", \"0\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"1\", \"0\"],\n [\"0\", \"1\", \"0\", \"1\"],\n ],\n R: [\n [\"1\", \"1\", \"1\", \"0\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"1\", \"1\", \"0\"],\n [\"1\", \"0\", \"1\", \"0\"],\n [\"1\", \"0\", \"0\", \"1\"],\n ],\n S: [\n [\"0\", \"1\", \"1\", \"1\"],\n [\"1\", \"0\", \"0\", \"0\"],\n [\"0\", \"1\", \"1\", \"0\"],\n [\"0\", \"0\", \"0\", \"1\"],\n [\"1\", \"1\", \"1\", \"0\"],\n ],\n T: [\n [\"1\", \"1\", \"1\", \"1\", \"1\"],\n [\"0\", \"0\", \"1\", \"0\", \"0\"],\n [\"0\", \"0\", \"1\", \"0\", \"0\"],\n [\"0\", \"0\", \"1\", \"0\", \"0\"],\n [\"0\", \"0\", \"1\", \"0\", \"0\"],\n ],\n U: [\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"1\"],\n [\"0\", \"1\", \"1\", \"0\"],\n ],\n V: [\n [\"1\", \"0\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"0\", \"1\"],\n [\"0\", \"1\", \"0\", \"1\", \"0\"],\n [\"0\", \"1\", \"0\", \"1\", \"0\"],\n [\"0\", \"0\", \"1\", \"0\", \"0\"],\n ],\n W: [\n [\"1\", \"0\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"0\", \"0\", \"1\"],\n [\"1\", \"0\", \"1\", \"0\", \"1\"],\n [\"1\", \"1\", \"0\", \"1\", \"1\"],\n [\"1\", \"0\", \"0\", \"0\", \"1\"],\n ],\n X: [\n [\"1\", \"0\", \"0\", \"1\"],\n [\"0\", \"1\", \"1\", \"0\"],\n [\"0\", \"0\", \"0\", \"0\"],\n [\"0\", \"1\", \"1\", \"0\"],\n [\"1\", \"0\", \"0\", \"1\"],\n ],\n Y: [\n [\"1\", \"0\", \"0\", \"0\", \"1\"],\n [\"0\", \"1\", \"0\", \"1\", \"0\"],\n [\"0\", \"0\", \"1\", \"0\", \"0\"],\n [\"0\", \"0\", \"1\", \"0\", \"0\"],\n [\"0\", \"0\", \"1\", \"0\", \"0\"],\n ],\n Z: [\n [\"1\", \"1\", \"1\", \"1\"],\n [\"0\", \"0\", \"0\", \"1\"],\n [\"0\", \"0\", \"1\", \"0\"],\n [\"0\", \"1\", \"0\", \"0\"],\n [\"1\", \"1\", \"1\", \"1\"],\n ],\n}\n" + } + ], + "type": "components:ui" +} \ No newline at end of file diff --git a/apps/www/registry/default/example/lightboard-demo.tsx b/apps/www/registry/default/example/lightboard-demo.tsx new file mode 100644 index 0000000..0ab613b --- /dev/null +++ b/apps/www/registry/default/example/lightboard-demo.tsx @@ -0,0 +1,197 @@ +"use client" + +import { useState } from "react" + +import { LightBoard, PatternCell } from "../ui/lightboard" + +export default function LightBoardDemo() { + const [controlledDrawState, setControlledDrawState] = + useState("2") + const [controlledHoverState, setControlledHoverState] = useState(false) + + const cycleDrawState = () => { + setControlledDrawState((prev) => { + switch (prev) { + case "0": + return "1" + case "1": + return "2" + case "2": + return "3" + case "3": + return "0" + default: + return "0" + } + }) + } + + return ( +
+

LightBoard Demo

+ + {/* Controlled Interactive Board */} +
+

+ Controlled LightBoard with draw support +

+

+ Try drawing on this board by clicking and dragging. +

+ +
+ + +
+ +
+ +
+
+ +

Drawing disabled

+ + {/* Basic example */} +
+ +
+ + {/* Red Alert */} +
+ +
+ + {/* Rainbow Scroll */} +
+ +
+ + {/* Matrix Style */} +
+ +
+ + {/* Interactive Neon Board */} +
+ +
+ +

sketchpad

+

+ Try drawing on this board by clicking and dragging. +

+ +
+ +
+
+ ) +} diff --git a/apps/www/registry/default/ui/lightboard.tsx b/apps/www/registry/default/ui/lightboard.tsx new file mode 100644 index 0000000..d931576 --- /dev/null +++ b/apps/www/registry/default/ui/lightboard.tsx @@ -0,0 +1,694 @@ +"use client" + +import React, { useCallback, useEffect, useRef, useState } from "react" + +export type PatternCell = "0" | "1" | "2" | "3" +type Pattern = PatternCell[][] + +interface LightBoardProps { + gap?: number + rows?: number + lightSize?: number + updateInterval?: number + text: string + font?: "default" | "7segment" + colors?: Partial + disableDrawing?: boolean + controlledDrawState?: PatternCell + onDrawStateChange?: (newState: PatternCell) => void + controlledHoverState?: boolean + onHoverStateChange?: (isHovered: boolean) => void +} + +interface LightBoardColors { + drawLine: string // Color for moderately lit text + background: string // Color for inactive lights + textDim: string // Color for dimly lit text + textBright: string // Color for brightly lit text +} + +const defaultColors: LightBoardColors = { + drawLine: "rgba(160, 160, 200, 0.7)", + background: "rgba(30, 30, 40, 0.3)", + textDim: "rgba(100, 100, 140, 0.5)", + textBright: "rgba(220, 220, 255, 0.9)", +} + +// This function takes some text and makes sure there's enough space between words +const normalizeText = (text: string, minSpacing: number = 3): string => { + const trimmed = text.trim().toUpperCase() // Remove extra spaces and make all letters big + const spacedText = ` ${trimmed} `.replace(/\s+/g, " ".repeat(minSpacing)) // Add spaces between words + return spacedText +} + +// This function turns text into a pattern of lights +const textToPattern = ( + text: string, + rows: number, + columns: number, + font: { [key: string]: Pattern } +): Pattern => { + // First, we make the letters bigger if we have more rows + const letterHeight = font["A"].length + const scale = Math.max(1, Math.floor(rows / letterHeight)) + + // We make each letter in the font bigger + const scaledFont = Object.fromEntries( + Object.entries(font).map(([char, pattern]) => [ + char, + pattern + .flatMap((row) => Array(scale).fill(row)) + .map((row) => + row.flatMap((cell: PatternCell) => + Array(scale).fill(cell === "1" ? "1" : "3") + ) + ), + ]) + ) + // We add spaces to the text + const normalizedText = normalizeText(text) + + // We turn each letter into a pattern of lights + const letterPatterns = normalizedText + .split("") + .map((char) => scaledFont[char] || scaledFont[" "]) + + // We combine all the letter patterns into one big pattern + let fullPattern: Pattern = Array(scaledFont["A"].length) + .fill([]) + .map(() => []) + + letterPatterns.forEach((letterPattern) => { + fullPattern = fullPattern.map((row, i) => [...row, ...letterPattern[i]]) + }) + + // We add empty space above and below the pattern to center it + const totalRows = rows + const patternRows = fullPattern.length + const topPadding = Math.floor((totalRows - patternRows) / 2) + const bottomPadding = totalRows - patternRows - topPadding + + const paddedPattern = [ + ...Array(topPadding).fill(Array(fullPattern[0].length).fill("0")), + ...fullPattern, + ...Array(bottomPadding).fill(Array(fullPattern[0].length).fill("0")), + ] + + // We make the pattern wider by repeating it + const extendedPattern = paddedPattern.map((row) => { + while (row.length < columns * 2) { + row = [...row, ...row] + } + return row + }) + + return extendedPattern +} + +// This function decides what color each light should be +function getLightColor( + state: PatternCell, + colors: Partial +): string { + const mergedColors = { ...defaultColors, ...colors } + + switch (state) { + case "1": + return mergedColors.textDim + case "2": + return mergedColors.drawLine + case "3": + return mergedColors.textBright + default: + return mergedColors.background + } +} + +const defaultDrawState: PatternCell = "2" + +function LightBoard({ + text, + gap = 1, + lightSize = 4, + rows = 5, + font = "default", + updateInterval = 10, + colors = {}, + controlledDrawState, + disableDrawing = true, + controlledHoverState, + onHoverStateChange, +}: LightBoardProps) { + // We decide how many rows and columns of lights we need + const containerRef = useRef(null) + const [columns, setColumns] = useState(0) + const mergedColors = { ...defaultColors, ...colors } + + // We choose which font to use for our text + const selectedFont = font === "default" ? defaultFont : sevenSegmentFont + // We keep track of whether the mouse is over our board + + // We keep track of whether we're drawing on the board + const [isDrawing, setIsDrawing] = useState(false) + // Use controlled state if provided, otherwise use local state + const [internalHoverState, setInternalHoverState] = useState(false) + // This is the brightness of the lights we're drawing (0 to 3) + + // This is our pattern of lights that make up the text + const [basePattern, setBasePattern] = useState(() => { + return textToPattern(normalizeText(text), rows, columns, selectedFont) + }) + // This helps us move the text across the board + const [offset, setOffset] = useState(0) + // This is how we draw on our light board (it's like a special piece of paper) + const canvasRef = useRef(null) + // This remembers where we last drew on the board + const lastDrawnPosition = useRef<{ x: number; y: number } | null>(null) + // This helps us know when to update our animation + const [lastUpdateTime, setLastUpdateTime] = useState(0) + + const drawState = + controlledDrawState !== undefined ? controlledDrawState : defaultDrawState + + const isHovered = + controlledHoverState !== undefined + ? controlledHoverState + : internalHoverState + + // Calculate the number of columns based on container width + useEffect(() => { + const calculateColumns = () => { + if (containerRef.current) { + const containerWidth = containerRef.current.offsetWidth + const calculatedColumns = Math.floor(containerWidth / (lightSize + gap)) + setColumns(calculatedColumns) + } + } + + calculateColumns() + window.addEventListener("resize", calculateColumns) + return () => window.removeEventListener("resize", calculateColumns) + }, [lightSize, gap]) + + // This function draws all our lights on the board + const drawToCanvas = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + + const ctx = canvas.getContext("2d") + if (!ctx) return + + ctx.clearRect(0, 0, canvas.width, canvas.height) + + const patternWidth = basePattern[0].length + + basePattern.forEach((row, rowIndex) => { + for (let colIndex = 0; colIndex < columns; colIndex++) { + const patternColIndex = (colIndex + offset) % patternWidth + const state = row[patternColIndex] + + ctx.fillStyle = getLightColor(state as PatternCell, mergedColors) + ctx.beginPath() + ctx.arc( + colIndex * (lightSize + gap) + lightSize / 2, + rowIndex * (lightSize + gap) + lightSize / 2, + lightSize / 2, + 0, + 2 * Math.PI + ) + ctx.fill() + } + }) + }, [basePattern, offset, columns, lightSize, gap, mergedColors]) + + // This makes our text move across the board + useEffect(() => { + let animationFrameId: number + + const animate = () => { + if (!isHovered) { + // If the mouse isn't over the board, we move the text + setOffset((prevOffset) => (prevOffset + 1) % basePattern[0].length) + } + drawToCanvas() + animationFrameId = requestAnimationFrame(animate) + } + + animationFrameId = requestAnimationFrame(animate) + + // We clean up our animation when we're done + return () => cancelAnimationFrame(animationFrameId) + }, [basePattern, isHovered, drawToCanvas]) + + // This updates our light pattern when the text changes + useEffect(() => { + setBasePattern( + textToPattern(normalizeText(text), rows, columns, selectedFont) + ) + }, [text, rows, columns, selectedFont]) + + // This is another way we make our text move + const animate = useCallback(() => { + const currentTime = Date.now() + if (currentTime - lastUpdateTime >= updateInterval && !isHovered) { + setOffset((prevOffset) => (prevOffset + 1) % basePattern[0].length) + setLastUpdateTime(currentTime) + } + drawToCanvas() + }, [updateInterval, isHovered, basePattern, drawToCanvas, lastUpdateTime]) + + // This keeps our animation going + useEffect(() => { + let animationFrameId: number + + const loop = () => { + animate() + animationFrameId = requestAnimationFrame(loop) + } + + animationFrameId = requestAnimationFrame(loop) + + // We clean up our animation when we're done + return () => cancelAnimationFrame(animationFrameId) + }, [animate]) + + // This function helps us draw a line on our light board + const drawLine = useCallback( + (startX: number, startY: number, endX: number, endY: number) => { + const canvas = canvasRef.current + if (!canvas) return + + const ctx = canvas.getContext("2d") + if (!ctx) return + + // We figure out which direction we're drawing + const dx = Math.abs(endX - startX) + const dy = Math.abs(endY - startY) + const sx = startX < endX ? 1 : -1 + const sy = startY < endY ? 1 : -1 + let err = dx - dy + + // We keep drawing until we reach the end of our line + while (true) { + // We figure out which light we're on + const colIndex = Math.floor(startX / (lightSize + gap)) + const rowIndex = Math.floor(startY / (lightSize + gap)) + + // If we're still on the board... + if ( + rowIndex >= 0 && + rowIndex < rows && + colIndex >= 0 && + colIndex < columns + ) { + // We figure out which light to change in our pattern + const actualColIndex = (colIndex + offset) % basePattern[0].length + + // If this light isn't already the brightness we want... + if (basePattern[rowIndex][actualColIndex] !== drawState) { + // We update our pattern of lights + setBasePattern((prevPattern) => { + const newPattern = [...prevPattern] + newPattern[rowIndex] = [...newPattern[rowIndex]] + newPattern[rowIndex][actualColIndex] = drawState + return newPattern + }) + + // We draw the new light on our board + ctx.fillStyle = getLightColor(drawState, mergedColors) + + ctx.beginPath() + ctx.arc( + colIndex * (lightSize + gap) + lightSize / 2, + rowIndex * (lightSize + gap) + lightSize / 2, + lightSize / 2, + 0, + 2 * Math.PI + ) + ctx.fill() + } + } + + // If we've reached the end of our line, we stop + if (startX === endX && startY === endY) break + + // We figure out where to draw next + const e2 = 2 * err + if (e2 > -dy) { + err -= dy + startX += sx + } + if (e2 < dx) { + err += dx + startY += sy + } + } + }, + [ + basePattern, + columns, + drawState, + gap, + lightSize, + offset, + rows, + mergedColors, + ] + ) + + // _________DRAWING HANDLING_________ + + const handleInteractionStart = useCallback( + (x: number, y: number) => { + if (disableDrawing) return + setIsDrawing(true) + lastDrawnPosition.current = null + drawLine(x, y, x, y) + }, + [disableDrawing, drawLine] + ) + + const handleInteractionMove = useCallback( + (x: number, y: number) => { + if (!isDrawing || disableDrawing) return + if (lastDrawnPosition.current) { + drawLine(lastDrawnPosition.current.x, lastDrawnPosition.current.y, x, y) + } else { + drawLine(x, y, x, y) + } + lastDrawnPosition.current = { x, y } + }, + [isDrawing, disableDrawing, drawLine] + ) + + const handleInteractionEnd = useCallback(() => { + setIsDrawing(false) + lastDrawnPosition.current = null + }, []) + + // This happens when we press the mouse button to start drawing + const handleMouseDown = useCallback( + (event: React.MouseEvent) => { + const canvas = event.currentTarget + const rect = canvas.getBoundingClientRect() + const x = event.clientX - rect.left + const y = event.clientY - rect.top + handleInteractionStart(x, y) + }, + [handleInteractionStart] + ) + + const handleMouseMove = useCallback( + (event: React.MouseEvent) => { + const canvas = event.currentTarget + const rect = canvas.getBoundingClientRect() + const x = event.clientX - rect.left + const y = event.clientY - rect.top + handleInteractionMove(x, y) + }, + [handleInteractionMove] + ) + + const handleMouseUp = handleInteractionEnd + + const handleTouchStart = useCallback( + (event: React.TouchEvent) => { + event.preventDefault() + const touch = event.touches[0] + const canvas = event.currentTarget + const rect = canvas.getBoundingClientRect() + const x = touch.clientX - rect.left + const y = touch.clientY - rect.top + handleInteractionStart(x, y) + }, + [handleInteractionStart] + ) + + const handleTouchMove = useCallback( + (event: React.TouchEvent) => { + event.preventDefault() + const touch = event.touches[0] + const canvas = event.currentTarget + const rect = canvas.getBoundingClientRect() + const x = touch.clientX - rect.left + const y = touch.clientY - rect.top + handleInteractionMove(x, y) + }, + [handleInteractionMove] + ) + + const handleTouchEnd = handleInteractionEnd + + // Update hover state + const updateHoverState = useCallback( + (newState: boolean) => { + if (controlledHoverState === undefined) { + setInternalHoverState(newState) + } + onHoverStateChange?.(newState) + }, + [controlledHoverState, onHoverStateChange] + ) + + return ( +
+ {columns > 0 && ( + + controlledHoverState === undefined && updateHoverState(true) + } + onMouseLeave={() => { + controlledHoverState === undefined && updateHoverState(false) + handleInteractionEnd() + }} + onTouchStart={!disableDrawing ? handleTouchStart : undefined} + onTouchEnd={handleTouchEnd} + onTouchMove={handleTouchMove} + style={{ + cursor: disableDrawing ? "default" : "pointer", + touchAction: "none", + userSelect: "none", + }} + /> + )} +
+ ) +} + +export { LightBoard } + +const sevenSegmentFont: { [key: string]: Pattern } = { + "0": [ + ["1", "1", "1"], + ["1", "0", "1"], + ["1", "0", "1"], + ["1", "0", "1"], + ["1", "1", "1"], + ], + "1": [ + ["0", "0", "1"], + ["0", "0", "1"], + ["0", "0", "1"], + ["0", "0", "1"], + ["0", "0", "1"], + ], + // Add more digits as needed +} + +const defaultFont: { [key: string]: Pattern } = { + " ": [ + ["0", "0", "0", "0"], + ["0", "0", "0", "0"], + ["0", "0", "0", "0"], + ["0", "0", "0", "0"], + ["0", "0", "0", "0"], + ], + A: [ + ["0", "1", "1", "0"], + ["1", "0", "0", "1"], + ["1", "1", "1", "1"], + ["1", "0", "0", "1"], + ["1", "0", "0", "1"], + ], + B: [ + ["1", "1", "1", "0"], + ["1", "0", "0", "1"], + ["1", "1", "1", "0"], + ["1", "0", "0", "1"], + ["1", "1", "1", "0"], + ], + C: [ + ["0", "1", "1", "1"], + ["1", "0", "0", "0"], + ["1", "0", "0", "0"], + ["1", "0", "0", "0"], + ["0", "1", "1", "1"], + ], + D: [ + ["1", "1", "1", "0"], + ["1", "0", "0", "1"], + ["1", "0", "0", "1"], + ["1", "0", "0", "1"], + ["1", "1", "1", "0"], + ], + E: [ + ["1", "1", "1", "1"], + ["1", "0", "0", "0"], + ["1", "1", "1", "0"], + ["1", "0", "0", "0"], + ["1", "1", "1", "1"], + ], + F: [ + ["1", "1", "1", "1"], + ["1", "0", "0", "0"], + ["1", "1", "1", "0"], + ["1", "0", "0", "0"], + ["1", "0", "0", "0"], + ], + G: [ + ["0", "1", "1", "1"], + ["1", "0", "0", "0"], + ["1", "0", "1", "1"], + ["1", "0", "0", "1"], + ["0", "1", "1", "1"], + ], + H: [ + ["1", "0", "0", "1"], + ["1", "0", "0", "1"], + ["1", "1", "1", "1"], + ["1", "0", "0", "1"], + ["1", "0", "0", "1"], + ], + I: [ + ["1", "1", "1"], + ["0", "1", "0"], + ["0", "1", "0"], + ["0", "1", "0"], + ["1", "1", "1"], + ], + J: [ + ["0", "0", "1", "1"], + ["0", "0", "0", "1"], + ["0", "0", "0", "1"], + ["1", "0", "0", "1"], + ["0", "1", "1", "0"], + ], + K: [ + ["1", "0", "0", "1"], + ["1", "0", "1", "0"], + ["1", "1", "0", "0"], + ["1", "0", "1", "0"], + ["1", "0", "0", "1"], + ], + L: [ + ["1", "0", "0", "0"], + ["1", "0", "0", "0"], + ["1", "0", "0", "0"], + ["1", "0", "0", "0"], + ["1", "1", "1", "1"], + ], + M: [ + ["1", "0", "0", "0", "1"], + ["1", "1", "0", "1", "1"], + ["1", "0", "1", "0", "1"], + ["1", "0", "0", "0", "1"], + ["1", "0", "0", "0", "1"], + ], + N: [ + ["1", "0", "0", "1"], + ["1", "1", "0", "1"], + ["1", "0", "1", "1"], + ["1", "0", "0", "1"], + ["1", "0", "0", "1"], + ], + O: [ + ["0", "1", "1", "0"], + ["1", "0", "0", "1"], + ["1", "0", "0", "1"], + ["1", "0", "0", "1"], + ["0", "1", "1", "0"], + ], + P: [ + ["1", "1", "1", "0"], + ["1", "0", "0", "1"], + ["1", "1", "1", "0"], + ["1", "0", "0", "0"], + ["1", "0", "0", "0"], + ], + Q: [ + ["0", "1", "1", "0"], + ["1", "0", "0", "1"], + ["1", "0", "0", "1"], + ["1", "0", "1", "0"], + ["0", "1", "0", "1"], + ], + R: [ + ["1", "1", "1", "0"], + ["1", "0", "0", "1"], + ["1", "1", "1", "0"], + ["1", "0", "1", "0"], + ["1", "0", "0", "1"], + ], + S: [ + ["0", "1", "1", "1"], + ["1", "0", "0", "0"], + ["0", "1", "1", "0"], + ["0", "0", "0", "1"], + ["1", "1", "1", "0"], + ], + T: [ + ["1", "1", "1", "1", "1"], + ["0", "0", "1", "0", "0"], + ["0", "0", "1", "0", "0"], + ["0", "0", "1", "0", "0"], + ["0", "0", "1", "0", "0"], + ], + U: [ + ["1", "0", "0", "1"], + ["1", "0", "0", "1"], + ["1", "0", "0", "1"], + ["1", "0", "0", "1"], + ["0", "1", "1", "0"], + ], + V: [ + ["1", "0", "0", "0", "1"], + ["1", "0", "0", "0", "1"], + ["0", "1", "0", "1", "0"], + ["0", "1", "0", "1", "0"], + ["0", "0", "1", "0", "0"], + ], + W: [ + ["1", "0", "0", "0", "1"], + ["1", "0", "0", "0", "1"], + ["1", "0", "1", "0", "1"], + ["1", "1", "0", "1", "1"], + ["1", "0", "0", "0", "1"], + ], + X: [ + ["1", "0", "0", "1"], + ["0", "1", "1", "0"], + ["0", "0", "0", "0"], + ["0", "1", "1", "0"], + ["1", "0", "0", "1"], + ], + Y: [ + ["1", "0", "0", "0", "1"], + ["0", "1", "0", "1", "0"], + ["0", "0", "1", "0", "0"], + ["0", "0", "1", "0", "0"], + ["0", "0", "1", "0", "0"], + ], + Z: [ + ["1", "1", "1", "1"], + ["0", "0", "0", "1"], + ["0", "0", "1", "0"], + ["0", "1", "0", "0"], + ["1", "1", "1", "1"], + ], +} diff --git a/apps/www/registry/examples.ts b/apps/www/registry/examples.ts index 56ce240..3930c75 100644 --- a/apps/www/registry/examples.ts +++ b/apps/www/registry/examples.ts @@ -109,4 +109,10 @@ export const examples: Registry = [ registryDependencies: ["dock"], files: ["example/dock-demo.tsx"], }, + { + name: "lightboard-demo", + type: "components:example", + registryDependencies: ["lightboard"], + files: ["example/lightboard-demo.tsx"], + }, ] diff --git a/apps/www/registry/ui.ts b/apps/www/registry/ui.ts index 5e007e9..e6bdc7b 100644 --- a/apps/www/registry/ui.ts +++ b/apps/www/registry/ui.ts @@ -109,4 +109,10 @@ export const ui: Registry = [ dependencies: ["framer-motion"], files: ["ui/dock.tsx"], }, + { + name: "lightboard", + type: "components:ui", + dependencies: [""], + files: ["ui/lightboard.tsx"], + }, ]