diff --git a/.cta.json b/.cta.json index 2684dfa..ec4b2a2 100644 --- a/.cta.json +++ b/.cta.json @@ -1,5 +1,5 @@ { - "projectName": "latest-ui", + "projectName": "doras-ui", "mode": "file-router", "typescript": true, "tailwind": true, diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..23a78fb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +- If you're creating a Tanstack Start/Router route, you must create the file first with a touch command, as auto generated content is populated and the file gets currupted. So touch the file, then read it and update it. \ No newline at end of file diff --git a/bun.lock b/bun.lock index 907fa14..b86cb5b 100644 --- a/bun.lock +++ b/bun.lock @@ -9,8 +9,10 @@ "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", @@ -355,6 +357,8 @@ "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + "@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg=="], + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], "@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="], @@ -371,6 +375,8 @@ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.7", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg=="], + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="], diff --git a/package.json b/package.json index b4c2a17..5e82c9b 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "doras-ui", "private": true, "type": "module", + "repository": "https://github.com/dorasto/ui", "scripts": { "dev": "vite dev --port 3000", "build": "vite build", @@ -21,8 +22,10 @@ "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", diff --git a/public/components/preview.png b/public/components/preview.png new file mode 100644 index 0000000..d852a63 Binary files /dev/null and b/public/components/preview.png differ diff --git a/public/r/clipboard.json b/public/r/clipboard.json index 6fadfb7..4b706f9 100644 --- a/public/r/clipboard.json +++ b/public/r/clipboard.json @@ -3,6 +3,7 @@ "name": "clipboard", "type": "registry:block", "title": "Clipboard", + "author": "Tommy Lundy ", "description": "A simple method to copy items to the clipboard", "registryDependencies": [ "button", @@ -12,13 +13,11 @@ { "path": "registry/clipboard/clipboard.tsx", "content": "\"use client\";\nimport type React from \"react\";\nimport { useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/ui/button\";\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from \"@/components/ui/tooltip\";\nimport { Check, Copy } from \"lucide-react\";\n\nexport interface ClipboardProps {\n\ttextToCopy: string;\n\tchildren?: React.ReactNode;\n\tclassName?: string;\n\ticonSize?: number;\n\tcopiedDuration?: number;\n\ttooltipText?: string;\n\ttooltipCopiedText?: string;\n\tshowTooltip?: boolean;\n\tcopyIcon?: React.ReactNode;\n\tcheckIcon?: React.ReactNode;\n\tcheckIconClassName?: string;\n\tcopyIconClassName?: string;\n\tonCopy?: () => void;\n\tonError?: (error: Error) => void;\n\ttooltipDelayDuration?: number;\n\tdisabled?: boolean;\n}\n\nexport default function Clipboard({\n\ttextToCopy,\n\tchildren,\n\tclassName,\n\ticonSize = 16,\n\tcopiedDuration = 1500,\n\ttooltipText = \"Click to copy\",\n\ttooltipCopiedText = \"Copied!\",\n\tshowTooltip = true,\n\tcopyIcon,\n\tcheckIcon,\n\tcheckIconClassName,\n\tcopyIconClassName,\n\tonCopy,\n\tonError,\n\ttooltipDelayDuration = 0,\n\tdisabled = false,\n}: ClipboardProps) {\n\tconst [copied, setCopied] = useState(false);\n\n\tconst handleCopy = async () => {\n\t\ttry {\n\t\t\tawait navigator.clipboard.writeText(textToCopy);\n\t\t\tsetCopied(true);\n\t\t\tonCopy?.();\n\t\t\tsetTimeout(() => setCopied(false), copiedDuration);\n\t\t} catch (err) {\n\t\t\tconst error = err instanceof Error ? err : new Error(\"Failed to copy text\");\n\t\t\tconsole.error(\"Failed to copy text: \", error);\n\t\t\tonError?.(error);\n\t\t}\n\t};\n\n\tconst defaultCopyIcon = copyIcon || ;\n\tconst defaultCheckIcon = checkIcon || (\n\t\t\n\t);\n\n\tconst triggerElement = children ? (\n\t\t\n\t\t\t{children}\n\t\t\n\t) : (\n\t\t\n\t\t\t
\n\t\t\t\t{defaultCheckIcon}\n\t\t\t
\n\t\t\t\n\t\t\t\t{defaultCopyIcon}\n\t\t\t\n\t\t\n\t);\n\n\tif (!showTooltip) {\n\t\treturn triggerElement;\n\t}\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t{triggerElement}\n\t\t\t\t{copied ? tooltipCopiedText : tooltipText}\n\t\t\t\n\t\t\n\t);\n}", - "type": "registry:component" + "type": "registry:component", + "target": "components/doras-ui/clipboard.tsx" } ], - "meta": { - "author": "tommerty" - }, "categories": [ - "dashboard" + "general" ] } \ No newline at end of file diff --git a/public/r/preview.json b/public/r/preview.json new file mode 100644 index 0000000..4650bb8 --- /dev/null +++ b/public/r/preview.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "preview", + "type": "registry:block", + "title": "URL Preview", + "author": "Tommy Lundy ", + "description": "Show website metadata and OpenGraph images when hovering over URLs", + "registryDependencies": [ + "hover-card" + ], + "files": [ + { + "path": "registry/preview/preview.tsx", + "content": "\"use client\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useEffect, useState } from \"react\"\nimport {\n\tHoverCard,\n\tHoverCardContent,\n\tHoverCardTrigger,\n} from \"@/components/ui/hover-card\"\nimport { IconExternalLink } from \"@tabler/icons-react\"\n\ntype PreviewMetadata = {\n\ttitle: string | null\n\tdescription: string | null\n\timage: string | null\n\turl: string | null\n}\n\nexport interface PreviewProps {\n\turl: string\n\tchildren?: React.ReactNode\n\tshowImage?: boolean\n\tshowTitle?: boolean\n\tshowDescription?: boolean\n\tclassName?: string\n\tcontentClassName?: string\n\tonError?: (error: Error) => void\n}\n\nconst fetchMetadata = async (url: string): Promise => {\n\ttry {\n\t\t// Use a CORS proxy for client-side requests\n\t\tconst proxyUrl = `/api/preview?url=${encodeURIComponent(url)}`\n\t\tconst response = await fetch(proxyUrl)\n\t\t\n\t\tif (!response.ok) {\n\t\t\tthrow new Error(`Failed to fetch metadata: ${response.statusText}`)\n\t\t}\n\t\t\n\t\treturn await response.json()\n\t} catch (error) {\n\t\t// Fallback to basic metadata from URL\n\t\tconst domain = new URL(url).hostname\n\t\treturn {\n\t\t\ttitle: domain,\n\t\t\tdescription: `Visit ${domain}`,\n\t\t\timage: null,\n\t\t\turl,\n\t\t}\n\t}\n}\n\nexport function Preview({\n\turl,\n\tchildren,\n\tshowImage = true,\n\tshowTitle = true,\n\tshowDescription = true,\n\tclassName,\n\tcontentClassName,\n\tonError,\n}: PreviewProps) {\n\tconst [metadata, setMetadata] = useState(null)\n\tconst [loading, setLoading] = useState(false)\n\n\tuseEffect(() => {\n\t\tasync function fetchData() {\n\t\t\tsetLoading(true)\n\t\t\ttry {\n\t\t\t\tconst data = await fetchMetadata(url)\n\t\t\t\tsetMetadata(data)\n\t\t\t} catch (error) {\n\t\t\t\tconst err = error instanceof Error ? error : new Error('Unknown error')\n\t\t\t\tonError?.(err)\n\t\t\t\t\n\t\t\t\t// Fallback metadata\n\t\t\t\tconst domain = new URL(url).hostname\n\t\t\t\tsetMetadata({\n\t\t\t\t\ttitle: domain,\n\t\t\t\t\tdescription: `Visit ${domain}`,\n\t\t\t\t\timage: null,\n\t\t\t\t\turl,\n\t\t\t\t})\n\t\t\t} finally {\n\t\t\t\tsetLoading(false)\n\t\t\t}\n\t\t}\n\n\t\tfetchData()\n\t}, [url, onError])\n\n\tconst defaultTrigger = (\n\t\t\n\t\t\t{new URL(url).hostname}\n\t\t\t\n\t\t\n\t)\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t{children || defaultTrigger}\n\t\t\t\n\t\t\t\n \n\t\t\t\t{loading ? (\n\t\t\t\t\t
\n\t\t\t\t\t\t
Loading...
\n\t\t\t\t\t
\n\t\t\t\t) : (\n\t\t\t\t\t
\n\t\t\t\t\t\t{showImage && metadata?.image && (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t{showTitle && metadata?.title && (\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t{metadata.title}\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t{showDescription && metadata?.description && (\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t\t{metadata.description}\n\t\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t)}
\n\t\t\t
\n\t\t
\n\t)\n}\n", + "type": "registry:component", + "target": "components/doras-ui/preview.tsx" + } + ], + "categories": [ + "general" + ] +} \ No newline at end of file diff --git a/registry.json b/registry.json index 9372ff1..8cea298 100644 --- a/registry.json +++ b/registry.json @@ -1,17 +1,16 @@ { "$schema": "https://ui.shadcn.com/schema/registry.json", - "name": "acme", + "name": "doras-ui", "homepage": "https://ui.doras.to", "items": [ { "name": "clipboard", "type": "registry:block", "title": "Clipboard", + "author": "Tommy Lundy ", "description": "A simple method to copy items to the clipboard", - "categories": ["dashboard"], - "meta": { - "author": "tommerty" - }, + "categories": ["general"], + "registryDependencies": [ "button", "tooltip" @@ -19,7 +18,26 @@ "files": [ { "path": "registry/clipboard/clipboard.tsx", - "type": "registry:component" + "type": "registry:component", + "target": "components/doras-ui/clipboard.tsx" + } + ] + }, + { + "name": "preview", + "type": "registry:block", + "title": "URL Preview", + "author": "Tommy Lundy ", + "description": "Show website metadata and OpenGraph images when hovering over URLs", + "categories": ["general"], + "registryDependencies": [ + "hover-card" + ], + "files": [ + { + "path": "registry/preview/preview.tsx", + "type": "registry:component", + "target": "components/doras-ui/preview.tsx" } ] } diff --git a/registry/preview/README.md b/registry/preview/README.md new file mode 100644 index 0000000..3c8b0c0 --- /dev/null +++ b/registry/preview/README.md @@ -0,0 +1,61 @@ +# Preview Component + +A hover card component that displays rich URL previews with OpenGraph metadata. + +## Files + +- `preview.tsx` - Main client component with composable parts +- `preview-server.ts` - Server utility for fetching metadata + +## Usage in your project + +This component follows the block structure defined in `/src/content/README.md`. + +### Examples Location +All examples are in `/src/content/preview/examples.tsx` as named exports: +- `Example01` - Basic usage +- `Example02` - Server-side metadata fetching +- `Example03` - Custom styling variations + +### Metadata Location +Block metadata is in `/src/content/blocks-metadata.ts` + +### Documentation +Full documentation is in `/src/content/preview/docs.mdx` + +## Quick Start + +```tsx +import { + Preview, + PreviewContent, + PreviewDescription, + PreviewImage, + PreviewLink, + PreviewTitle, + PreviewTrigger, +} from "@@/registry/preview/preview" + + + + GitHub + + + +
+ Title + Description + github.com +
+
+
+``` + +## Server-Side Fetching + +```tsx +import { fetchPreview } from "@@/registry/preview/preview-server" + +const metadata = await fetchPreview("https://github.com") +// Returns: { title, description, image, url } +``` diff --git a/registry/preview/preview-server.ts b/registry/preview/preview-server.ts new file mode 100644 index 0000000..33d3e19 --- /dev/null +++ b/registry/preview/preview-server.ts @@ -0,0 +1,54 @@ +const TITLE_REGEX = /]*>([^<]+)<\/title>/ +const OG_TITLE_REGEX = + /]*property="og:title"[^>]*content="([^"]+)"/ +const DESCRIPTION_REGEX = + /]*name="description"[^>]*content="([^"]+)"/ +const OG_DESCRIPTION_REGEX = + /]*property="og:description"[^>]*content="([^"]+)"/ +const OG_IMAGE_REGEX = + /]*property="og:image"[^>]*content="([^"]+)"/ +const OG_URL_REGEX = /]*property="og:url"[^>]*content="([^"]+)"/ + +export type PreviewMetadata = { + title: string | null + description: string | null + image: string | null + url: string | null +} + +export const fetchPreview = async (url: string): Promise => { + try { + const response = await fetch(url, { + headers: { + "User-Agent": + "Mozilla/5.0 (compatible; PreviewBot/1.0; +https://example.com/bot)", + }, + }) + + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.statusText}`) + } + + const data = await response.text() + const titleMatch = data.match(OG_TITLE_REGEX) || data.match(TITLE_REGEX) + const descriptionMatch = + data.match(OG_DESCRIPTION_REGEX) || data.match(DESCRIPTION_REGEX) + const imageMatch = data.match(OG_IMAGE_REGEX) + const urlMatch = data.match(OG_URL_REGEX) + + return { + title: titleMatch?.at(1) ?? null, + description: descriptionMatch?.at(1) ?? null, + image: imageMatch?.at(1) ?? null, + url: urlMatch?.at(1) ?? url, + } + } catch (error) { + console.error("Error fetching preview:", error) + return { + title: null, + description: null, + image: null, + url, + } + } +} diff --git a/registry/preview/preview.tsx b/registry/preview/preview.tsx new file mode 100644 index 0000000..f014de9 --- /dev/null +++ b/registry/preview/preview.tsx @@ -0,0 +1,140 @@ +"use client" + +import { cn } from "@/lib/utils" +import { useEffect, useState } from "react" +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card" +import { IconExternalLink } from "@tabler/icons-react" + +type PreviewMetadata = { + title: string | null + description: string | null + image: string | null + url: string | null +} + +export interface PreviewProps { + url: string + children?: React.ReactNode + showImage?: boolean + showTitle?: boolean + showDescription?: boolean + className?: string + contentClassName?: string + onError?: (error: Error) => void +} + +const fetchMetadata = async (url: string): Promise => { + try { + // Use a CORS proxy for client-side requests + const proxyUrl = `/api/preview?url=${encodeURIComponent(url)}` + const response = await fetch(proxyUrl) + + if (!response.ok) { + throw new Error(`Failed to fetch metadata: ${response.statusText}`) + } + + return await response.json() + } catch (error) { + // Fallback to basic metadata from URL + const domain = new URL(url).hostname + return { + title: domain, + description: `Visit ${domain}`, + image: null, + url, + } + } +} + +export function Preview({ + url, + children, + showImage = true, + showTitle = true, + showDescription = true, + className, + contentClassName, + onError, +}: PreviewProps) { + const [metadata, setMetadata] = useState(null) + const [loading, setLoading] = useState(false) + + useEffect(() => { + async function fetchData() { + setLoading(true) + try { + const data = await fetchMetadata(url) + setMetadata(data) + } catch (error) { + const err = error instanceof Error ? error : new Error('Unknown error') + onError?.(err) + + // Fallback metadata + const domain = new URL(url).hostname + setMetadata({ + title: domain, + description: `Visit ${domain}`, + image: null, + url, + }) + } finally { + setLoading(false) + } + } + + fetchData() + }, [url, onError]) + + const defaultTrigger = ( + + {new URL(url).hostname} + + + ) + + return ( + + + {children || defaultTrigger} + + + + {loading ? ( +
+
Loading...
+
+ ) : ( +
+ {showImage && metadata?.image && ( + {metadata.title + )} +
+ {showTitle && metadata?.title && ( +

+ {metadata.title} +

+ )} + {showDescription && metadata?.description && ( +

+ {metadata.description} +

+ )} + +
+
+ )}
+
+
+ ) +} diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index dc7f23f..889c9c0 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -122,47 +122,49 @@ export function MainSidebar() { - {blocksMetadata.map((block) => { - const isActive = pathname === `/blocks/${block.id}`; + {blocksMetadata + .filter((block) => !block.preview) + .map((block) => { + const isActive = pathname === `/blocks/${block.id}`; - return ( - - - - ) : null - } - tooltip={block.name} + - {block.name} - - - - ); - })} + + ) : null + } + tooltip={block.name} + > + {block.name} + + + + ); + })} - + } tooltip="GitHub"> GitHub - + ) { + return +} + +function HoverCardTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function HoverCardContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { HoverCard, HoverCardTrigger, HoverCardContent } diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx new file mode 100644 index 0000000..10af7e6 --- /dev/null +++ b/src/components/ui/progress.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as ProgressPrimitive from "@radix-ui/react-progress" + +import { cn } from "@/lib/utils" + +function Progress({ + className, + value, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { Progress } diff --git a/src/components/ui/spinner.tsx b/src/components/ui/spinner.tsx new file mode 100644 index 0000000..a70e713 --- /dev/null +++ b/src/components/ui/spinner.tsx @@ -0,0 +1,16 @@ +import { Loader2Icon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Spinner({ className, ...props }: React.ComponentProps<"svg">) { + return ( + + ) +} + +export { Spinner } diff --git a/src/content/QUICKSTART.md b/src/content/QUICKSTART.md new file mode 100644 index 0000000..6298848 --- /dev/null +++ b/src/content/QUICKSTART.md @@ -0,0 +1,66 @@ +# Quick Start: Adding a New Block + +This is the **TL;DR** version. See [README.md](./README.md) for full documentation. + +## 3-Step Process + +### 1. Add to `blocks-metadata.ts` + +```typescript +{ + id: "my-component", + category: categoryIds.MyCategory, + name: "My Component", + description: "What it does", + icon: IconComponent, + hasDocs: true, + examples: [ + { id: "my-component-01", name: "Basic", description: "Basic usage" }, + { id: "my-component-02", name: "Advanced", description: "Advanced usage" }, + ], + props: [ + { name: "prop1", type: "string", description: "Does X", required: true }, + ], +} +``` + +### 2. Create `my-component/examples.tsx` + +```tsx +"use client"; + +export function Example01() { + return
Basic example
; +} + +export function Example02() { + return
Advanced example
; +} +``` + +### 3. (Optional) Create `my-component/docs.mdx` + +```mdx +## Features +- Feature 1 +- Feature 2 +``` + +## That's It! + +The system **automatically**: +- Maps example IDs to components +- Loads and displays examples +- Shows documentation +- Generates code snippets + +## File Structure + +``` +src/content/ +├── blocks-metadata.ts ← Edit this +├── blocks-components.tsx ← Auto-generated (don't edit) +└── my-component/ + ├── examples.tsx ← Create this + └── docs.mdx ← Optional +``` diff --git a/src/content/README.md b/src/content/README.md new file mode 100644 index 0000000..f7b04f2 --- /dev/null +++ b/src/content/README.md @@ -0,0 +1,157 @@ +# Content Structure + +This directory contains all the blocks/components showcased in the UI library. + +## Structure + +``` +src/content/ +├── blocks-metadata.ts # Central metadata for ALL blocks (single source of truth) +├── blocks-components.tsx # Auto-generates component mappings from metadata +├── clipboard/ # Example: Clipboard block +│ ├── examples.tsx # All examples in ONE file (Example01, Example02, etc.) +│ └── docs.mdx # Documentation for the clipboard block +└── [block-name]/ # Future blocks follow the same pattern + ├── examples.tsx # All examples as named exports + └── docs.mdx # Optional documentation +``` + +## Key Features + +- ✅ **Single source of truth**: All metadata in `blocks-metadata.ts` +- ✅ **Auto-discovery**: No manual component registration needed +- ✅ **Consolidated examples**: All examples in one file per block +- ✅ **Convention-based**: Follows predictable naming patterns + +## Adding a New Block + +To add a new block (e.g., "sidebar"), you only need to edit **2-3 files**: + +### 1. Add metadata to blocks-metadata.ts + +Add your block to the `blocksMetadata` array: + +```typescript +// First, add category if needed +export const categoryIds = { + Clipboard: "clipboard", + Sidebar: "sidebar", // Add this +} as const; + +// Then add block metadata +{ + id: "sidebar", + category: categoryIds.Sidebar, + name: "Sidebar", + description: "A collapsible sidebar component", + icon: IconSidebar, + hasDocs: true, + examples: [ + { + id: "sidebar-01", + name: "Basic", + description: "A basic sidebar example", + }, + { + id: "sidebar-02", + name: "With navigation", + description: "Sidebar with navigation items", + }, + ], + props: [ + { + name: "open", + type: "boolean", + defaultValue: "true", + description: "Whether the sidebar is open", + }, + // Add more props... + ], +} +``` + +### 2. Create examples file + +Create `src/content/sidebar/examples.tsx` with all examples as named exports: + +```tsx +"use client"; + +// Example 01: Basic sidebar +export function Example01() { + return ( +
+ {/* Your first example */} +
+ ); +} + +// Example 02: Sidebar with navigation +export function Example02() { + return ( +
+ {/* Your second example */} +
+ ); +} +``` + +### 3. Add documentation (optional) + +Create `src/content/sidebar/docs.mdx`: + +```mdx +import { CodeBlock } from "/src/components/ui/code-block.tsx" +import { Label } from "/src/components/ui/label.tsx" + +
+ +
+ +- ✅ Feature 1 +- ✅ Feature 2 + +
+ +\`} /> +
+``` + +**That's it!** The system automatically: +- Discovers your examples from `{blockId}/examples.tsx` +- Maps example IDs to exported functions (Example01, Example02, etc.) +- Loads documentation from `{blockId}/docs.mdx` + +## Naming Conventions + +- **Block ID**: Use kebab-case (e.g., `clipboard`, `sidebar`) +- **Example ID**: Format as `{blockId}-{number}` (e.g., `clipboard-01`, `sidebar-02`) +- **Example exports**: Use `Example{number}` (e.g., `Example01`, `Example02`) +- **File names**: + - Examples: `examples.tsx` + - Documentation: `docs.mdx` + +## How It Works + +### Auto-Discovery System + +`blocks-components.tsx` uses Vite's `import.meta.glob` to: +1. Load all `*/examples.tsx` files +2. Read `blocks-metadata.ts` for example definitions +3. Automatically map example IDs to exported components + +Example: For `clipboard-01`, it loads `clipboard/examples.tsx` and finds `Example01`. + +### Code Loading + +`code-loader.ts` handles: +- **Examples**: Loads from `/src/content/{blockId}/examples.tsx` and extracts specific example functions +- **Docs**: Loads from `/src/content/{blockId}/docs.mdx` + +## Benefits of This Structure + +1. **Minimal files**: Only 2-3 files per block +2. **No manual registration**: Auto-discovery handles component mapping +3. **Easy to maintain**: All examples for a block in one place +4. **Single source of truth**: Metadata drives everything +5. **Scalable**: Add new blocks without touching multiple files diff --git a/src/content/blocks-components.tsx b/src/content/blocks-components.tsx index 26c2d63..9c73e58 100644 --- a/src/content/blocks-components.tsx +++ b/src/content/blocks-components.tsx @@ -1,7 +1,55 @@ -import * as components from "./components"; +/** + * Auto-discovery system for block examples + * Dynamically loads examples from {block}/examples.tsx based on blocks-metadata + */ +import { blocksMetadata } from "./blocks-metadata"; -export const blocksComponents: Record> = { - "clipboard-01": components.Clipboard01, - "clipboard-02": components.Clipboard02, - "clipboard-03": components.Clipboard03, -}; +// Dynamically import all examples.tsx files from block folders +const examplesModules = import.meta.glob>( + "./*/examples.tsx", + { eager: true }, +); + +/** + * Automatically generate the component mapping based on blocks metadata + * Convention: {blockId}/examples.tsx exports Example01, Example02, etc. + */ +function generateBlocksComponents(): Record { + const components: Record = {}; + + for (const block of blocksMetadata) { + const modulePath = `./${block.id}/examples.tsx`; + const module = examplesModules[modulePath]; + + if (!module) { + console.warn(`No examples module found for block: ${block.id}`); + continue; + } + + // Map each example from metadata to its corresponding exported component + for (const example of block.examples) { + // Extract example number from ID (e.g., "clipboard-01" -> "01") + const match = example.id.match(/-(\d+)$/); + if (!match) { + console.warn(`Invalid example ID format: ${example.id}`); + continue; + } + + const exampleNum = match[1]; + const exportName = `Example${exampleNum}`; + const component = module[exportName]; + + if (component) { + components[example.id] = component; + } else { + console.warn( + `Component ${exportName} not found in ${modulePath} for example ${example.id}`, + ); + } + } + } + + return components; +} + +export const blocksComponents = generateBlocksComponents(); diff --git a/src/content/blocks-metadata.ts b/src/content/blocks-metadata.ts index 1f82591..f21e6da 100644 --- a/src/content/blocks-metadata.ts +++ b/src/content/blocks-metadata.ts @@ -1,12 +1,7 @@ -import { - IconClipboard, - IconCopy, - IconMail, - IconTerminal, -} from "@tabler/icons-react"; +import { IconCopy, IconExternalLink } from "@tabler/icons-react"; export const categoryIds = { - Clipboard: "clipboard", + General: "general", // Add more categories as you expand } as const; @@ -34,16 +29,17 @@ export interface BlockMetadata { image?: string; icon?: React.ComponentType<{ size?: number | string; className?: string }>; examples: BlockExample[]; - /** Optional: If true, will attempt to load /src/content/docs/{id}.mdx for detailed documentation */ + /** Optional: If true, will attempt to load /src/content/{id}/docs.mdx for detailed documentation */ hasDocs?: boolean; /** Optional: API props documentation */ props?: PropDefinition[]; + preview?: boolean; } export const blocksMetadata: BlockMetadata[] = [ { id: "clipboard", - category: categoryIds.Clipboard, + category: categoryIds.General, name: "Clipboard", description: @@ -164,4 +160,83 @@ export const blocksMetadata: BlockMetadata[] = [ }, ], }, + { + id: "preview", + category: categoryIds.General, + name: "URL Preview", + description: + "Show website metadata and OpenGraph images when hovering over URLs.", + image: "/components/preview.png", + icon: IconExternalLink, + hasDocs: true, + examples: [ + { + id: "preview-01", + name: "Basic", + description: + "Simple URL preview with hover card showing website metadata.", + iframeHeight: "600px", + }, + { + id: "preview-02", + name: "Hide the image", + description: + "Preview without displaying the OpenGraph image in the hover card.", + }, + { + id: "preview-03", + name: "Custom trigger", + description: + "Using a custom button as the trigger element for the URL preview hover card.", + iframeHeight: "600px", + }, + ], + props: [ + { + name: "url", + type: "string", + description: "The URL to preview and fetch metadata for.", + required: true, + }, + { + name: "children", + type: "React.ReactNode", + description: + "Custom trigger element. If not provided, uses default link with external icon.", + }, + { + name: "showImage", + type: "boolean", + defaultValue: "true", + description: "Whether to display the OpenGraph image.", + }, + { + name: "showTitle", + type: "boolean", + defaultValue: "true", + description: "Whether to display the page title.", + }, + { + name: "showDescription", + type: "boolean", + defaultValue: "true", + description: "Whether to display the page description.", + }, + { + name: "className", + type: "string", + description: "Additional CSS classes for the trigger element.", + }, + { + name: "contentClassName", + type: "string", + description: "Additional CSS classes for the hover card content.", + }, + { + name: "onError", + type: "(error: Error) => void", + description: "Callback function called when metadata fetching fails.", + }, + ], + }, ]; diff --git a/src/content/docs/clipboard.mdx b/src/content/clipboard/docs.mdx similarity index 95% rename from src/content/docs/clipboard.mdx rename to src/content/clipboard/docs.mdx index 05dd097..69162e3 100644 --- a/src/content/docs/clipboard.mdx +++ b/src/content/clipboard/docs.mdx @@ -11,4 +11,4 @@ import { Label } from "/src/components/ui/label.tsx" - ✅ Error handling callbacks
-`} />
\ No newline at end of file +`} /> diff --git a/src/content/components/clipboard-01.tsx b/src/content/clipboard/examples.tsx similarity index 69% rename from src/content/components/clipboard-01.tsx rename to src/content/clipboard/examples.tsx index 6c077b1..b4ccbb5 100644 --- a/src/content/components/clipboard-01.tsx +++ b/src/content/clipboard/examples.tsx @@ -1,10 +1,16 @@ "use client"; -import SimpleClipboard from "@@/registry/clipboard/clipboard"; -import { IconBrandNpm, IconBrandPnpm } from "@tabler/icons-react"; +import Clipboard from "@@/registry/clipboard/clipboard"; +import { + IconBrandNpm, + IconBrandPnpm, + IconCheck, + IconCopy, +} from "@tabler/icons-react"; import { ChevronDownIcon } from "lucide-react"; import { useState } from "react"; import BunIcon from "@/components/icons/bun"; +import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, @@ -14,7 +20,8 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useIsMobile } from "@/hooks/use-mobile"; -export default function ClipboardDemo01() { +// Example 01: Tabs with code copy +export function Example01() { const installSnippet = { npm: `npx shadcn@latest add`, pnpm: `pnpm dlx shadcn@latest add`, @@ -77,7 +84,7 @@ export default function ClipboardDemo01() { )} - ); } + +// Example 02: Custom button with clipboard +export function Example02() { + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + return ( +
+
+
+

Email

+

+ me@example.com +

+
+ + + +
+
+ ); +} + +// Example 03: Simple clipboard +export function Example03() { + return ( +
+ +
+ ); +} diff --git a/src/content/components/clipboard-02.tsx b/src/content/components/clipboard-02.tsx deleted file mode 100644 index 868d769..0000000 --- a/src/content/components/clipboard-02.tsx +++ /dev/null @@ -1,33 +0,0 @@ -"use client"; - -import Clipboard from "@@/registry/clipboard/clipboard"; -import { IconCheck, IconCopy } from "@tabler/icons-react"; -import { useState } from "react"; -import { Button } from "@/components/ui/button"; - -export default function ClipboardDemo02() { - const [copied, setCopied] = useState(false); - - const handleCopy = () => { - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }; - - return ( -
-
-
-

Email

-

- me@example.com -

-
- - - -
-
- ); -} diff --git a/src/content/components/clipboard-03.tsx b/src/content/components/clipboard-03.tsx deleted file mode 100644 index f39a0e9..0000000 --- a/src/content/components/clipboard-03.tsx +++ /dev/null @@ -1,10 +0,0 @@ -"use client"; - -import Clipboard from "@@/registry/clipboard/clipboard"; -export default function ClipboardDemo03() { - return ( -
- -
- ); -} diff --git a/src/content/components/index.ts b/src/content/components/index.ts deleted file mode 100644 index a6cfdb3..0000000 --- a/src/content/components/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default as Clipboard01 } from "./clipboard-01"; -export { default as Clipboard02 } from "./clipboard-02"; -export { default as Clipboard03 } from "./clipboard-03"; diff --git a/src/content/components/sidebar-example.tsx b/src/content/components/sidebar-example.tsx deleted file mode 100644 index 4533e33..0000000 --- a/src/content/components/sidebar-example.tsx +++ /dev/null @@ -1,261 +0,0 @@ -import { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarGroup, - SidebarGroupLabel, - SidebarHeader, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - SidebarSubmenu, - SidebarSubmenuItem, - SidebarTrigger, -} from "@@/registry/sidebar/sidebar"; -import { - BarChartIcon, - BellIcon, - DatabaseIcon, - FileIcon, - FolderIcon, - HomeIcon, - SettingsIcon, - UserIcon, - UsersIcon, -} from "lucide-react"; -import * as React from "react"; - -export function SidebarExample() { - const [activePage, setActivePage] = React.useState("home"); - - return ( -
- {/* Main Sidebar */} - - -
-

My App

- -
-
- - - {/* Navigation Group */} - - Navigation - - - } - isActive={activePage === "home"} - tooltip="Home" - onClick={() => setActivePage("home")} - > - Home - - - - - } - isActive={activePage === "notifications"} - tooltip="Notifications" - onClick={() => setActivePage("notifications")} - > - Notifications - - - - - - {/* Projects Group with Submenu */} - - Projects - - - } - defaultOpen - > - setActivePage("project-1")} - > - Website Redesign - - setActivePage("project-2")} - > - Mobile App - - setActivePage("project-3")} - > - API Development - - - - - - } - > - setActivePage("doc-1")} - > - Getting Started - - setActivePage("doc-2")} - > - API Reference - - - {/* Nested Submenu Example */} -
-
- Guides -
- setActivePage("guide-1")} - > - Installation - - setActivePage("guide-2")} - > - Configuration - -
-
-
-
-
- - {/* Data Group */} - - Data - - - } - isActive={activePage === "database"} - tooltip="Database" - onClick={() => setActivePage("database")} - > - Database - - - - - } - isActive={activePage === "analytics"} - tooltip="Analytics" - onClick={() => setActivePage("analytics")} - > - Analytics - - - - - - {/* Settings Group */} - - Settings - - - } - isActive={activePage === "team"} - tooltip="Team" - onClick={() => setActivePage("team")} - > - Team - - - - - } - isActive={activePage === "settings"} - tooltip="Settings" - onClick={() => setActivePage("settings")} - > - Settings - - - - -
- - -
-
- -
-
-

John Doe

-

- john@example.com -

-
-
-
-
- - {/* Main Content */} -
-
-
- -

- {activePage - .split("-") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" ")} -

-
-
-
-
-

- Active Page: {activePage} -

-

- This is the main content area. The sidebar remains sticky and - doesn't scroll with the page content. -

-
- {Array.from({ length: 20 }).map((_, i) => ( -

- Scroll content line {i + 1}. The sidebar stays in place as you - scroll this content. -

- ))} -
-
-
-
-
- ); -} diff --git a/src/content/components/sidebar-multiple.tsx b/src/content/components/sidebar-multiple.tsx deleted file mode 100644 index 1818ecd..0000000 --- a/src/content/components/sidebar-multiple.tsx +++ /dev/null @@ -1,260 +0,0 @@ -/** biome-ignore-all lint/correctness/useUniqueElementIds: */ -import { - Sidebar, - SidebarContent, - SidebarGroup, - SidebarGroupLabel, - SidebarHeader, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - SidebarTrigger, -} from "@@/registry/sidebar/sidebar"; -import { SidebarKeyboardHandler } from "@@/registry/sidebar/sidebar-script"; -import { - FileIcon, - ImageIcon, - LayoutIcon, - MusicIcon, - PaletteIcon, - TypeIcon, - VideoIcon, -} from "lucide-react"; -import * as React from "react"; - -/** - * Multiple Sidebars Example - * - * Demonstrates using multiple independent sidebars in a single layout. - * Each sidebar has its own ID and can be controlled independently. - * This example also shows keyboard shortcuts for toggling sidebars. - */ -export function MultipleSidebarsExample() { - const [selectedFile, setSelectedFile] = React.useState(null); - const [selectedTool, setSelectedTool] = React.useState(null); - - return ( - <> - {/* Add keyboard shortcut handler */} - - -
- {/* Left Sidebar - File Browser */} - - -
-

Files

- -
-

- Press{" "} - - B - {" "} - to toggle -

-
- - - - Media - - - } - isActive={selectedFile === "images"} - tooltip="Images" - onClick={() => setSelectedFile("images")} - > - Images - - - - } - isActive={selectedFile === "videos"} - tooltip="Videos" - onClick={() => setSelectedFile("videos")} - > - Videos - - - - } - isActive={selectedFile === "audio"} - tooltip="Audio" - onClick={() => setSelectedFile("audio")} - > - Audio - - - - - - - Documents - - - } - isActive={selectedFile === "documents"} - tooltip="All Documents" - onClick={() => setSelectedFile("documents")} - > - All Documents - - - - - -
- - {/* Main Content */} -
-
-
- -

Multi-Sidebar Layout

- -
-
- -
-
-
-

- Multiple Independent Sidebars -

-

- This example shows how to use multiple sidebars in a single - layout. Each sidebar operates independently with its own state - and keyboard shortcut. -

-
- -
-
-

Left Sidebar

-

- File browser navigation -

- {selectedFile && ( -

- Selected:{" "} - {selectedFile} -

- )} -
- -
-

Right Sidebar

-

- Tools and properties panel -

- {selectedTool && ( -

- Active tool:{" "} - {selectedTool} -

- )} -
-
- -
-

Benefits

-
    -
  • • Each sidebar has independent state
  • -
  • • Can be positioned on either side
  • -
  • • Custom widths for each sidebar
  • -
  • • Both collapse independently
  • -
  • • Mobile responsive with sheets
  • -
  • • Keyboard shortcuts for quick toggling (⌘B, ⌘T)
  • -
-
-
-
-
- - {/* Right Sidebar - Tools */} - - -
-

Tools

- -
-

- Press{" "} - - T - {" "} - to toggle -

-
- - - - Design - - - } - isActive={selectedTool === "layout"} - tooltip="Layout" - onClick={() => setSelectedTool("layout")} - > - Layout - - - - } - isActive={selectedTool === "colors"} - tooltip="Colors" - onClick={() => setSelectedTool("colors")} - > - Colors - - - - } - isActive={selectedTool === "typography"} - tooltip="Typography" - onClick={() => setSelectedTool("typography")} - > - Typography - - - - - -
-
- - ); -} diff --git a/src/content/components/sidebar-simple.tsx b/src/content/components/sidebar-simple.tsx deleted file mode 100644 index 35aa1d0..0000000 --- a/src/content/components/sidebar-simple.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarGroup, - SidebarGroupLabel, - SidebarHeader, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - SidebarSubmenu, - SidebarSubmenuItem, - SidebarTrigger, -} from "@@/registry/sidebar/sidebar"; -import { sidebarActions } from "@@/registry/sidebar/sidebar-store"; -import { FileIcon, HomeIcon, SettingsIcon, UserIcon } from "lucide-react"; -import * as React from "react"; - -/** - * Simple Sidebar Example - * - * This example demonstrates the basic usage of the sidebar component: - * - A collapsible sidebar with navigation items - * - Submenus for organizing related items - * - External control via store actions - * - Mobile responsive (automatically uses Sheet on mobile) - */ -export function SimpleSidebarExample() { - const [currentPage, setCurrentPage] = React.useState("home"); - - return ( -
- {/* Sidebar */} - - {/* Header with title and toggle button */} - -
-

My Application

- -
-
- - {/* Main navigation content */} - - {/* Main Navigation */} - - Main - - - } - isActive={currentPage === "home"} - tooltip="Home" - onClick={() => setCurrentPage("home")} - > - Home - - - - - - {/* Documents with Submenu */} - - Content - - - } - defaultOpen - > - setCurrentPage("all-docs")} - > - All Documents - - setCurrentPage("recent")} - > - Recent - - setCurrentPage("favorites")} - > - Favorites - - - - - - - {/* Settings */} - - System - - - } - isActive={currentPage === "settings"} - tooltip="Settings" - onClick={() => setCurrentPage("settings")} - > - Settings - - - - - - - {/* Footer with user info */} - -
-
- -
-
-

User Name

-

- user@email.com -

-
-
-
-
- - {/* Main Content Area */} -
- {/* Header */} -
-
- {/* Mobile trigger - only visible on small screens */} - - -

- {currentPage.replace("-", " ")} -

- - {/* External control example */} - -
-
- - {/* Page Content */} -
-
-
-

- Welcome to {currentPage.replace("-", " ")} -

-

- This is a simple example demonstrating the sidebar component. - Try collapsing the sidebar or resizing your browser to see the - mobile behavior. -

-
- -
-

Key Features

-
    -
  • ✓ Collapsible sidebar with smooth transitions
  • -
  • ✓ Automatic tooltips when collapsed
  • -
  • ✓ Mobile responsive (Sheet on mobile)
  • -
  • ✓ External control via TanStack Store
  • -
  • ✓ Sticky positioning (no absolute positioning)
  • -
  • ✓ Support for nested submenus
  • -
-
- - {/* Scrollable content */} -
-

Scroll Behavior

-

- Notice how the sidebar stays in place while you scroll this - content. The sidebar uses sticky positioning. -

-
- {Array.from({ length: 30 }).map((_, index) => ( -
- Content block {index + 1} -
- ))} -
-
-
-
-
-
- ); -} diff --git a/src/content/preview/docs.mdx b/src/content/preview/docs.mdx new file mode 100644 index 0000000..c057bab --- /dev/null +++ b/src/content/preview/docs.mdx @@ -0,0 +1,50 @@ +import { CodeBlock } from "/src/components/ui/code-block.tsx" +import { Label } from "/src/components/ui/label.tsx" + +# Features + +- ✅ **Hover activation** - Shows preview on hover with smooth animations +- ✅ **OpenGraph support** - Displays OG images, titles, and descriptions +- ✅ **Server-side fetching** - Fetches metadata via API route +- ✅ **Customizable styling** - Control what to show/hide with props +- ✅ **Platform agnostic** - Works with any React framework +- ✅ **Accessible** - Built on Radix UI with proper ARIA attributes + +# Usage +`} /> + + +# API Route Setup +To fetch metadata, you need to create an API route. +### Tanstack Start/Router + { + const url = new URL(request.url).searchParams.get("url") + if (!url) { + return Response.json({ error: "URL required" }, { status: 400 }) + } + const metadata = await fetchPreview(url) + return Response.json(metadata) + }, + }, + }, +})`} /> + + +### Next.js (untested) + diff --git a/src/content/preview/examples.tsx b/src/content/preview/examples.tsx new file mode 100644 index 0000000..14be888 --- /dev/null +++ b/src/content/preview/examples.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { Preview } from "@@/registry/preview/preview"; +import { Button } from "@/components/ui/button"; + +export function Example01() { + return ( +
+

+ Create your link in bio on and + showcase your brand with style. +

+
+ ); +} + +export function Example02() { + return ( +
+ +
+ ); +} + +export function Example03() { + return ( +
+ + + +
+ ); +} diff --git a/src/hooks/use-github-stars.ts b/src/hooks/use-github-stars.ts new file mode 100644 index 0000000..eeaccee --- /dev/null +++ b/src/hooks/use-github-stars.ts @@ -0,0 +1,32 @@ +import { useQuery } from "@tanstack/react-query"; + +interface GitHubStarsData { + stars: number; + name: string; + fullName: string; + description: string; + url: string; +} + +export function useGitHubStars(owner?: string, repo?: string) { + return useQuery({ + queryKey: ["github-stars", owner, repo], + queryFn: async (): Promise => { + const params = new URLSearchParams(); + if (owner) params.set("owner", owner); + if (repo) params.set("repo", repo); + + const url = `/api/github-stars${params.toString() ? `?${params.toString()}` : ""}`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error("Failed to fetch GitHub stars"); + } + + return response.json(); + }, + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 10 * 60 * 1000, // 10 minutes + retry: 3, + }); +} diff --git a/src/lib/code-loader.ts b/src/lib/code-loader.ts index 03facd4..2163d26 100644 --- a/src/lib/code-loader.ts +++ b/src/lib/code-loader.ts @@ -3,36 +3,63 @@ * Uses Vite's import.meta.glob to load raw source files */ -// Load all example component files as raw strings -const modules = import.meta.glob("/src/content/components/*.tsx", { +// Load consolidated examples.tsx files as raw strings +const modules = import.meta.glob("/src/content/*/examples.tsx", { query: "?raw", import: "default", }); -// Load all MDX documentation files -export const mdxModules = import.meta.glob("/src/content/docs/**/*.mdx", { +// Load all MDX documentation files from block folders +export const mdxModules = import.meta.glob("/src/content/*/docs.mdx", { import: "default", }); /** - * Get the raw source code for an example by its ID + * Extract a specific example function from the consolidated examples.tsx file * @param exampleId - The ID of the example (e.g., "clipboard-01") - * @returns Promise that resolves to the raw source code string + * @returns Promise that resolves to the raw source code string of that specific example */ export async function getExampleCode( exampleId: string, ): Promise { - const path = `/src/content/components/${exampleId}.tsx`; + // Extract block name and example number from ID (e.g., "clipboard-01" -> "clipboard", "01") + const match = exampleId.match(/^(.+)-(\d+)$/); + if (!match) { + console.warn(`Invalid example ID format: ${exampleId}`); + return null; + } + + const [, blockName, exampleNum] = match; + const path = `/src/content/${blockName}/examples.tsx`; const loader = modules[path]; if (!loader) { - console.warn(`No code found for example: ${exampleId}`); + console.warn( + `No examples file found for block: ${blockName} at path: ${path}`, + ); return null; } try { - const code = await loader(); - return code as string; + const fullCode = (await loader()) as string; + + // Extract the specific example function from the file + const functionName = `Example${exampleNum}`; + const regex = new RegExp( + `export function ${functionName}\\(\\)[^{]*\\{[\\s\\S]*?^\\}`, + "m", + ); + + const match = fullCode.match(regex); + if (match) { + return match[0]; + } + + // If regex fails, return the whole file (fallback) + console.warn( + `Could not extract ${functionName} from ${path}, returning full file`, + ); + return fullCode; } catch (error) { console.error(`Error loading code for ${exampleId}:`, error); return null; @@ -47,7 +74,7 @@ export async function getExampleCode( export async function getBlockDocs( blockId: string, ): Promise { - const path = `/src/content/docs/${blockId}.mdx`; + const path = `/src/content/${blockId}/docs.mdx`; const loader = mdxModules[path]; if (!loader) { diff --git a/src/lib/seo-image-generator.tsx b/src/lib/seo-image-generator.tsx index 70a4974..e802cfe 100644 --- a/src/lib/seo-image-generator.tsx +++ b/src/lib/seo-image-generator.tsx @@ -40,7 +40,7 @@ export async function generateSeoImage({ description, siteName = "Doras UI", url = "ui.doras.to", - image, + image = "https://ui.doras.to/logo192.png", }: SeoImageGeneratorProps) { // Load fonts const fontWeights = [400, 600, 700] as const; @@ -98,7 +98,6 @@ export async function generateSeoImage({ padding: "80px", height: "100%", position: "relative", - zIndex: 10, }} > {/* Site branding */} @@ -130,7 +129,7 @@ export async function generateSeoImage({ {/* Component Image */} - {image && ( + {image ? (
component +
+ ) : ( +
+ component BlocksRouteRoute, } as any) +const ApiPreviewRoute = ApiPreviewRouteImport.update({ + id: '/api/preview', + path: '/api/preview', + getParentRoute: () => rootRouteImport, +} as any) +const ApiGithubStarsRoute = ApiGithubStarsRouteImport.update({ + id: '/api/github-stars', + path: '/api/github-stars', + getParentRoute: () => rootRouteImport, +} as any) const OgBlocksBlockIdDotpngRoute = OgBlocksBlockIdDotpngRouteImport.update({ id: '/og/blocks/$blockId.png', path: '/og/blocks/$blockId.png', @@ -140,6 +152,8 @@ const DemoStartSsrDataOnlyRoute = DemoStartSsrDataOnlyRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/blocks': typeof BlocksRouteRouteWithChildren + '/api/github-stars': typeof ApiGithubStarsRoute + '/api/preview': typeof ApiPreviewRoute '/blocks/$blockId': typeof BlocksBlockIdRoute '/demo/table': typeof DemoTableRoute '/demo/tanstack-query': typeof DemoTanstackQueryRoute @@ -162,6 +176,8 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/api/github-stars': typeof ApiGithubStarsRoute + '/api/preview': typeof ApiPreviewRoute '/blocks/$blockId': typeof BlocksBlockIdRoute '/demo/table': typeof DemoTableRoute '/demo/tanstack-query': typeof DemoTanstackQueryRoute @@ -186,6 +202,8 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/blocks': typeof BlocksRouteRouteWithChildren + '/api/github-stars': typeof ApiGithubStarsRoute + '/api/preview': typeof ApiPreviewRoute '/blocks/$blockId': typeof BlocksBlockIdRoute '/demo/table': typeof DemoTableRoute '/demo/tanstack-query': typeof DemoTanstackQueryRoute @@ -211,6 +229,8 @@ export interface FileRouteTypes { fullPaths: | '/' | '/blocks' + | '/api/github-stars' + | '/api/preview' | '/blocks/$blockId' | '/demo/table' | '/demo/tanstack-query' @@ -233,6 +253,8 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/api/github-stars' + | '/api/preview' | '/blocks/$blockId' | '/demo/table' | '/demo/tanstack-query' @@ -256,6 +278,8 @@ export interface FileRouteTypes { | '__root__' | '/' | '/blocks' + | '/api/github-stars' + | '/api/preview' | '/blocks/$blockId' | '/demo/table' | '/demo/tanstack-query' @@ -280,6 +304,8 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute BlocksRouteRoute: typeof BlocksRouteRouteWithChildren + ApiGithubStarsRoute: typeof ApiGithubStarsRoute + ApiPreviewRoute: typeof ApiPreviewRoute DemoTableRoute: typeof DemoTableRoute DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute OgBlocksDotpngRoute: typeof OgBlocksDotpngRoute @@ -363,6 +389,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof BlocksBlockIdRouteImport parentRoute: typeof BlocksRouteRoute } + '/api/preview': { + id: '/api/preview' + path: '/api/preview' + fullPath: '/api/preview' + preLoaderRoute: typeof ApiPreviewRouteImport + parentRoute: typeof rootRouteImport + } + '/api/github-stars': { + id: '/api/github-stars' + path: '/api/github-stars' + fullPath: '/api/github-stars' + preLoaderRoute: typeof ApiGithubStarsRouteImport + parentRoute: typeof rootRouteImport + } '/og/blocks/$blockId.png': { id: '/og/blocks/$blockId.png' path: '/og/blocks/$blockId.png' @@ -469,6 +509,8 @@ const BlocksRouteRouteWithChildren = BlocksRouteRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, BlocksRouteRoute: BlocksRouteRouteWithChildren, + ApiGithubStarsRoute: ApiGithubStarsRoute, + ApiPreviewRoute: ApiPreviewRoute, DemoTableRoute: DemoTableRoute, DemoTanstackQueryRoute: DemoTanstackQueryRoute, OgBlocksDotpngRoute: OgBlocksDotpngRoute, diff --git a/src/routes/api/github-stars.ts b/src/routes/api/github-stars.ts new file mode 100644 index 0000000..860d460 --- /dev/null +++ b/src/routes/api/github-stars.ts @@ -0,0 +1,73 @@ +import { createFileRoute } from "@tanstack/react-router"; + +interface GitHubRepo { + stargazers_count: number; + name: string; + full_name: string; + description: string; + html_url: string; +} + +async function fetchGitHubStars(owner: string, repo: string) { + try { + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}`, + { + headers: { + "User-Agent": "Doras-UI/1.0", + Accept: "application/vnd.github.v3+json", + // Add GitHub token if you have one (recommended for higher rate limits) + // Authorization: `token ${process.env.GITHUB_TOKEN}`, + }, + }, + ); + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.statusText}`); + } + + const data: GitHubRepo = await response.json(); + + return { + stars: data.stargazers_count, + name: data.name, + fullName: data.full_name, + description: data.description, + url: data.html_url, + }; + } catch (error) { + console.error("Error fetching GitHub stars:", error); + throw error; + } +} + +export const Route = createFileRoute("/api/github-stars")({ + server: { + handlers: { + GET: async ({ request }) => { + const url = new URL(request.url); + const owner = url.searchParams.get("owner") || "dorasto"; + const repo = url.searchParams.get("repo") || "ui"; + + try { + const repoData = await fetchGitHubStars(owner, repo); + + // Add cache headers to avoid hitting rate limits too often + return Response.json(repoData, { + headers: { + "Cache-Control": "public, max-age=300", // Cache for 5 minutes + }, + }); + } catch (error) { + return Response.json( + { + error: "Failed to fetch GitHub repository data", + message: error instanceof Error ? error.message : "Unknown error", + }, + { status: 500 }, + ); + } + }, + }, + }, +}); diff --git a/src/routes/api/preview.ts b/src/routes/api/preview.ts new file mode 100644 index 0000000..b6018a7 --- /dev/null +++ b/src/routes/api/preview.ts @@ -0,0 +1,72 @@ +import { createFileRoute } from "@tanstack/react-router"; + +const TITLE_REGEX = /]*>([^<]+)<\/title>/; +const OG_TITLE_REGEX = /]*property="og:title"[^>]*content="([^"]+)"/; +const DESCRIPTION_REGEX = /]*name="description"[^>]*content="([^"]+)"/; +const OG_DESCRIPTION_REGEX = + /]*property="og:description"[^>]*content="([^"]+)"/; +const OG_IMAGE_REGEX = /]*property="og:image"[^>]*content="([^"]+)"/; +const OG_URL_REGEX = /]*property="og:url"[^>]*content="([^"]+)"/; + +async function fetchPreview(url: string) { + try { + const response = await fetch(url, { + headers: { + "User-Agent": "Mozilla/5.0 (compatible; PreviewBot/1.0)", + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.statusText}`); + } + + const data = await response.text(); + const titleMatch = data.match(OG_TITLE_REGEX) || data.match(TITLE_REGEX); + const descriptionMatch = + data.match(OG_DESCRIPTION_REGEX) || data.match(DESCRIPTION_REGEX); + const imageMatch = data.match(OG_IMAGE_REGEX); + const urlMatch = data.match(OG_URL_REGEX); + + return { + title: titleMatch?.at(1) ?? null, + description: descriptionMatch?.at(1) ?? null, + image: imageMatch?.at(1) ?? null, + url: urlMatch?.at(1) ?? url, + }; + } catch (error) { + console.error("Error fetching preview:", error); + return { + title: null, + description: null, + image: null, + url, + }; + } +} + +export const Route = createFileRoute("/api/preview")({ + server: { + handlers: { + GET: async ({ request }) => { + const url = new URL(request.url).searchParams.get("url"); + + if (!url) { + return Response.json( + { error: "URL parameter is required" }, + { status: 400 }, + ); + } + + try { + const metadata = await fetchPreview(url); + return Response.json(metadata); + } catch (_error) { + return Response.json( + { error: "Failed to fetch preview" }, + { status: 500 }, + ); + } + }, + }, + }, +}); diff --git a/src/routes/blocks/$blockId.tsx b/src/routes/blocks/$blockId.tsx index b564133..83f5c11 100644 --- a/src/routes/blocks/$blockId.tsx +++ b/src/routes/blocks/$blockId.tsx @@ -122,7 +122,7 @@ function BlockPage() { } } }; - const baseUrl = import.meta.env.VITE_BASE_URL; + const baseUrl = "https://ui.doras.to"; const installSnippet = { npm: `npx shadcn@latest add ${baseUrl}/r/${block.id}.json`, pnpm: `pnpm dlx shadcn@latest add ${baseUrl}/r/${block.id}.json`, diff --git a/src/routes/blocks/index.tsx b/src/routes/blocks/index.tsx index 91b5300..0e24396 100644 --- a/src/routes/blocks/index.tsx +++ b/src/routes/blocks/index.tsx @@ -51,39 +51,41 @@ function BlocksIndex() {
- {blocks.map((block) => ( - -
-
-

- {block.icon && } - {block.name} -

- - {block.examples.length}{" "} - {block.examples.length === 1 ? "example" : "examples"} - + {blocks + .filter((block) => !block.preview) + .map((block) => ( + +
+
+

+ {block.icon && } + {block.name} +

+ + {block.examples.length}{" "} + {block.examples.length === 1 ? "example" : "examples"} + +
+ {block.image && ( + {block.name} + )} + {block.description && ( +

+ {block.description} +

+ )}
- {block.image && ( - {block.name} - )} - {block.description && ( -

- {block.description} -

- )} -
- - ))} + + ))}
))} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index e37714d..44f5eab 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,7 +1,10 @@ import { createFileRoute, Link } from "@tanstack/react-router"; -import { ArrowRight, Package } from "lucide-react"; +import { ArrowRight, Package, Star } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; import { blocksMetadata } from "@/content/blocks-metadata"; +import { useGitHubStars } from "@/hooks/use-github-stars"; import { seo } from "@/utils/seo"; export const Route = createFileRoute("/")({ @@ -21,6 +24,7 @@ export const Route = createFileRoute("/")({ function App() { const totalBlocks = blocksMetadata.length; const categories = [...new Set(blocksMetadata.map((b) => b.category))]; + const { data: githubData, isLoading: isLoadingStars } = useGitHubStars(); return (
@@ -33,18 +37,34 @@ function App() {

-
-
+
+ {totalBlocks} {totalBlocks === 1 ? "Block" : "Blocks"} -
-
-
- {categories.length}{" "} - {categories.length === 1 ? "Category" : "Categories"} -
+ + + + {categories.length}{" "} + {categories.length === 1 ? "Category" : "Categories"} + + + {isLoadingStars ? ( + + ) : ( + + + + + {isLoadingStars ? "" : githubData?.stars || "0"} stars + + + + )}