From eb417eafcf6a7620423c193feb9220d0b6ed89c6 Mon Sep 17 00:00:00 2001 From: Tommy Lundy Date: Tue, 28 Oct 2025 14:32:51 +0000 Subject: [PATCH 1/4] feat: Organize clipboard examples and update component imports in blocks --- src/content/README.md | 121 ++++++++++++++++++ src/content/blocks-components.tsx | 13 +- src/content/blocks-metadata.ts | 2 +- src/content/clipboard/docs.mdx | 14 ++ src/content/clipboard/examples/example-01.tsx | 110 ++++++++++++++++ src/content/clipboard/examples/example-02.tsx | 33 +++++ src/content/clipboard/examples/example-03.tsx | 11 ++ src/content/components/clipboard-01.tsx | 4 +- src/lib/code-loader.ts | 22 +++- 9 files changed, 315 insertions(+), 15 deletions(-) create mode 100644 src/content/README.md create mode 100644 src/content/clipboard/docs.mdx create mode 100644 src/content/clipboard/examples/example-01.tsx create mode 100644 src/content/clipboard/examples/example-02.tsx create mode 100644 src/content/clipboard/examples/example-03.tsx diff --git a/src/content/README.md b/src/content/README.md new file mode 100644 index 0000000..5ad3267 --- /dev/null +++ b/src/content/README.md @@ -0,0 +1,121 @@ +# 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 +├── blocks-components.tsx # Central component registry mapping IDs to components +├── clipboard/ # Example: Clipboard block +│ ├── examples/ +│ │ ├── example-01.tsx # First example variant +│ │ ├── example-02.tsx # Second example variant +│ │ └── example-03.tsx # Third example variant +│ └── docs.mdx # Documentation for the clipboard block +└── [block-name]/ # Future blocks follow the same pattern + ├── examples/ + │ └── example-01.tsx + └── docs.mdx +``` + +## Adding a New Block + +To add a new block (e.g., "sidebar"): + +### 1. Create the block folder structure + +```bash +mkdir -p src/content/sidebar/examples +``` + +### 2. Add example components + +Create `src/content/sidebar/examples/example-01.tsx`: + +```tsx +"use client"; + +export default function SidebarExample01() { + return ( +
+ {/* Your example component */} +
+ ); +} +``` + +### 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 + +
+ +\`} /> +
+``` + +### 4. Register in blocks-metadata.ts + +Add your block metadata to the `blocksMetadata` array: + +```typescript +{ + id: "sidebar", + category: categoryIds.Sidebar, // Add to categoryIds first + name: "Sidebar", + description: "A collapsible sidebar component", + icon: IconSidebar, + hasDocs: true, + examples: [ + { + id: "sidebar-01", + name: "Basic", + description: "A basic sidebar example", + }, + ], + props: [ + // Add prop definitions here + ], +} +``` + +### 5. Register in blocks-components.tsx + +Import and register your examples: + +```typescript +import SidebarExample01 from "./sidebar/examples/example-01"; + +export const blocksComponents: Record = { + "clipboard-01": ClipboardExample01, + "sidebar-01": SidebarExample01, // Add this +}; +``` + +## Naming Conventions + +- **Block ID**: Use kebab-case (e.g., `clipboard`, `sidebar`) +- **Example ID**: Use `{blockId}-{number}` (e.g., `clipboard-01`, `sidebar-02`) +- **Example files**: Use `example-{number}.tsx` (e.g., `example-01.tsx`) +- **Component names**: Use PascalCase with block name (e.g., `ClipboardExample01`) + +## File Loading + +The system automatically loads: +- **Examples**: From `/src/content/{blockId}/examples/example-{number}.tsx` +- **Docs**: From `/src/content/{blockId}/docs.mdx` + +This is handled by `/src/lib/code-loader.ts`. diff --git a/src/content/blocks-components.tsx b/src/content/blocks-components.tsx index 26c2d63..377987f 100644 --- a/src/content/blocks-components.tsx +++ b/src/content/blocks-components.tsx @@ -1,7 +1,10 @@ -import * as components from "./components"; +// Import examples from organized block folders +import ClipboardExample01 from "./clipboard/examples/example-01"; +import ClipboardExample02 from "./clipboard/examples/example-02"; +import ClipboardExample03 from "./clipboard/examples/example-03"; -export const blocksComponents: Record> = { - "clipboard-01": components.Clipboard01, - "clipboard-02": components.Clipboard02, - "clipboard-03": components.Clipboard03, +export const blocksComponents: Record = { + "clipboard-01": ClipboardExample01, + "clipboard-02": ClipboardExample02, + "clipboard-03": ClipboardExample03, }; diff --git a/src/content/blocks-metadata.ts b/src/content/blocks-metadata.ts index 1f82591..7d678a2 100644 --- a/src/content/blocks-metadata.ts +++ b/src/content/blocks-metadata.ts @@ -34,7 +34,7 @@ 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[]; diff --git a/src/content/clipboard/docs.mdx b/src/content/clipboard/docs.mdx new file mode 100644 index 0000000..69162e3 --- /dev/null +++ b/src/content/clipboard/docs.mdx @@ -0,0 +1,14 @@ +import { CodeBlock } from "/src/components/ui/code-block.tsx" +import { Label } from "/src/components/ui/label.tsx" + +
+ +
+ +- ✅ Visual feedback with icon transitions +- ✅ Customizable appearance and behavior +- ✅ Tooltip support +- ✅ Error handling callbacks +
+ +`} />
diff --git a/src/content/clipboard/examples/example-01.tsx b/src/content/clipboard/examples/example-01.tsx new file mode 100644 index 0000000..5187105 --- /dev/null +++ b/src/content/clipboard/examples/example-01.tsx @@ -0,0 +1,110 @@ +"use client"; + +import Clipboard from "@@/registry/clipboard/clipboard"; +import { IconBrandNpm, IconBrandPnpm } from "@tabler/icons-react"; +import { ChevronDownIcon } from "lucide-react"; +import { useState } from "react"; +import BunIcon from "@/components/icons/bun"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useIsMobile } from "@/hooks/use-mobile"; + +export default function ClipboardDemo01() { + const installSnippet = { + npm: `npx shadcn@latest add`, + pnpm: `pnpm dlx shadcn@latest add`, + bun: `bunx --bun shadcn@latest add`, + }; + const [activeInstallTab, setActiveInstallTab] = useState("npm"); + const isMobile = useIsMobile(); + + const packageManagers = [ + { value: "npm", label: "npm", icon: }, + { value: "pnpm", label: "pnpm", icon: }, + { value: "bun", label: "bun", icon: }, + ]; + + const activePackageManager = packageManagers.find( + (pm) => pm.value === activeInstallTab, + ); + + return ( +
+ setActiveInstallTab(value)} + > +
+ {isMobile ? ( + + + {activePackageManager?.icon} + {activePackageManager?.label} + + + + {packageManagers.map((pm) => ( + setActiveInstallTab(pm.value)} + className="gap-2" + > + {pm.icon} + {pm.label} + + ))} + + + ) : ( + + + + npm + + + + pnpm + + + + bun + + + )} + +
+
+ + {installSnippet.npm} + + + {installSnippet.pnpm} + + + {installSnippet.bun} + +
+
+
+ ); +} diff --git a/src/content/clipboard/examples/example-02.tsx b/src/content/clipboard/examples/example-02.tsx new file mode 100644 index 0000000..868d769 --- /dev/null +++ b/src/content/clipboard/examples/example-02.tsx @@ -0,0 +1,33 @@ +"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/clipboard/examples/example-03.tsx b/src/content/clipboard/examples/example-03.tsx new file mode 100644 index 0000000..2a1d575 --- /dev/null +++ b/src/content/clipboard/examples/example-03.tsx @@ -0,0 +1,11 @@ +"use client"; + +import Clipboard from "@@/registry/clipboard/clipboard"; + +export default function ClipboardDemo03() { + return ( +
+ +
+ ); +} diff --git a/src/content/components/clipboard-01.tsx b/src/content/components/clipboard-01.tsx index 6c077b1..5187105 100644 --- a/src/content/components/clipboard-01.tsx +++ b/src/content/components/clipboard-01.tsx @@ -1,6 +1,6 @@ "use client"; -import SimpleClipboard from "@@/registry/clipboard/clipboard"; +import Clipboard from "@@/registry/clipboard/clipboard"; import { IconBrandNpm, IconBrandPnpm } from "@tabler/icons-react"; import { ChevronDownIcon } from "lucide-react"; import { useState } from "react"; @@ -77,7 +77,7 @@ export default function ClipboardDemo01() { )} - { - 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/example-${exampleNum}.tsx`; const loader = modules[path]; if (!loader) { - console.warn(`No code found for example: ${exampleId}`); + console.warn(`No code found for example: ${exampleId} at path: ${path}`); return null; } @@ -47,7 +55,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) { From 0c95ce5cd15a7460c075171970e0d9b2aeafd784 Mon Sep 17 00:00:00 2001 From: Tommy Lundy Date: Tue, 28 Oct 2025 15:00:58 +0000 Subject: [PATCH 2/4] Refactor clipboard examples and components - Consolidated clipboard examples into a single examples.tsx file. - Removed individual example files (example-01.tsx, example-02.tsx, example-03.tsx). - Updated code loader to handle new examples structure. - Removed unused clipboard components and sidebar examples. - Added quick start guide for adding new blocks. - Updated documentation for clipboard features and usage. --- src/content/QUICKSTART.md | 66 +++++ src/content/README.md | 154 +++++++---- src/content/blocks-components.tsx | 65 ++++- .../{examples/example-01.tsx => examples.tsx} | 48 +++- src/content/clipboard/examples/example-02.tsx | 33 --- src/content/clipboard/examples/example-03.tsx | 11 - src/content/components/clipboard-01.tsx | 110 -------- src/content/components/clipboard-02.tsx | 33 --- src/content/components/clipboard-03.tsx | 10 - src/content/components/index.ts | 3 - src/content/components/sidebar-example.tsx | 261 ------------------ src/content/components/sidebar-multiple.tsx | 260 ----------------- src/content/components/sidebar-simple.tsx | 204 -------------- src/content/docs/clipboard.mdx | 14 - src/lib/code-loader.ts | 35 ++- 15 files changed, 289 insertions(+), 1018 deletions(-) create mode 100644 src/content/QUICKSTART.md rename src/content/clipboard/{examples/example-01.tsx => examples.tsx} (71%) delete mode 100644 src/content/clipboard/examples/example-02.tsx delete mode 100644 src/content/clipboard/examples/example-03.tsx delete mode 100644 src/content/components/clipboard-01.tsx delete mode 100644 src/content/components/clipboard-02.tsx delete mode 100644 src/content/components/clipboard-03.tsx delete mode 100644 src/content/components/index.ts delete mode 100644 src/content/components/sidebar-example.tsx delete mode 100644 src/content/components/sidebar-multiple.tsx delete mode 100644 src/content/components/sidebar-simple.tsx delete mode 100644 src/content/docs/clipboard.mdx 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 index 5ad3267..f7b04f2 100644 --- a/src/content/README.md +++ b/src/content/README.md @@ -6,41 +6,91 @@ This directory contains all the blocks/components showcased in the UI library. ``` src/content/ -├── blocks-metadata.ts # Central metadata for ALL blocks -├── blocks-components.tsx # Central component registry mapping IDs to components +├── 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/ -│ │ ├── example-01.tsx # First example variant -│ │ ├── example-02.tsx # Second example variant -│ │ └── example-03.tsx # Third example variant +│ ├── 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/ - │ └── example-01.tsx - └── docs.mdx + ├── 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"): +To add a new block (e.g., "sidebar"), you only need to edit **2-3 files**: -### 1. Create the block folder structure +### 1. Add metadata to blocks-metadata.ts -```bash -mkdir -p src/content/sidebar/examples +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. Add example components +### 2. Create examples file -Create `src/content/sidebar/examples/example-01.tsx`: +Create `src/content/sidebar/examples.tsx` with all examples as named exports: ```tsx "use client"; -export default function SidebarExample01() { +// Example 01: Basic sidebar +export function Example01() { + return ( +
+ {/* Your first example */} +
+ ); +} + +// Example 02: Sidebar with navigation +export function Example02() { return (
- {/* Your example component */} + {/* Your second example */}
); } @@ -67,55 +117,41 @@ import { Label } from "/src/components/ui/label.tsx" ``` -### 4. Register in blocks-metadata.ts - -Add your block metadata to the `blocksMetadata` array: +**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` -```typescript -{ - id: "sidebar", - category: categoryIds.Sidebar, // Add to categoryIds first - name: "Sidebar", - description: "A collapsible sidebar component", - icon: IconSidebar, - hasDocs: true, - examples: [ - { - id: "sidebar-01", - name: "Basic", - description: "A basic sidebar example", - }, - ], - props: [ - // Add prop definitions here - ], -} -``` +## Naming Conventions -### 5. Register in blocks-components.tsx +- **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` -Import and register your examples: +## How It Works -```typescript -import SidebarExample01 from "./sidebar/examples/example-01"; +### Auto-Discovery System -export const blocksComponents: Record = { - "clipboard-01": ClipboardExample01, - "sidebar-01": SidebarExample01, // Add this -}; -``` +`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 -## Naming Conventions +Example: For `clipboard-01`, it loads `clipboard/examples.tsx` and finds `Example01`. -- **Block ID**: Use kebab-case (e.g., `clipboard`, `sidebar`) -- **Example ID**: Use `{blockId}-{number}` (e.g., `clipboard-01`, `sidebar-02`) -- **Example files**: Use `example-{number}.tsx` (e.g., `example-01.tsx`) -- **Component names**: Use PascalCase with block name (e.g., `ClipboardExample01`) +### Code Loading -## File 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` -The system automatically loads: -- **Examples**: From `/src/content/{blockId}/examples/example-{number}.tsx` -- **Docs**: From `/src/content/{blockId}/docs.mdx` +## Benefits of This Structure -This is handled by `/src/lib/code-loader.ts`. +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 377987f..9c73e58 100644 --- a/src/content/blocks-components.tsx +++ b/src/content/blocks-components.tsx @@ -1,10 +1,55 @@ -// Import examples from organized block folders -import ClipboardExample01 from "./clipboard/examples/example-01"; -import ClipboardExample02 from "./clipboard/examples/example-02"; -import ClipboardExample03 from "./clipboard/examples/example-03"; - -export const blocksComponents: Record = { - "clipboard-01": ClipboardExample01, - "clipboard-02": ClipboardExample02, - "clipboard-03": ClipboardExample03, -}; +/** + * Auto-discovery system for block examples + * Dynamically loads examples from {block}/examples.tsx based on blocks-metadata + */ +import { blocksMetadata } from "./blocks-metadata"; + +// 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/clipboard/examples/example-01.tsx b/src/content/clipboard/examples.tsx similarity index 71% rename from src/content/clipboard/examples/example-01.tsx rename to src/content/clipboard/examples.tsx index 5187105..b4ccbb5 100644 --- a/src/content/clipboard/examples/example-01.tsx +++ b/src/content/clipboard/examples.tsx @@ -1,10 +1,16 @@ "use client"; import Clipboard from "@@/registry/clipboard/clipboard"; -import { IconBrandNpm, IconBrandPnpm } from "@tabler/icons-react"; +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`, @@ -108,3 +115,40 @@ 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/clipboard/examples/example-02.tsx b/src/content/clipboard/examples/example-02.tsx deleted file mode 100644 index 868d769..0000000 --- a/src/content/clipboard/examples/example-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/clipboard/examples/example-03.tsx b/src/content/clipboard/examples/example-03.tsx deleted file mode 100644 index 2a1d575..0000000 --- a/src/content/clipboard/examples/example-03.tsx +++ /dev/null @@ -1,11 +0,0 @@ -"use client"; - -import Clipboard from "@@/registry/clipboard/clipboard"; - -export default function ClipboardDemo03() { - return ( -
- -
- ); -} diff --git a/src/content/components/clipboard-01.tsx b/src/content/components/clipboard-01.tsx deleted file mode 100644 index 5187105..0000000 --- a/src/content/components/clipboard-01.tsx +++ /dev/null @@ -1,110 +0,0 @@ -"use client"; - -import Clipboard from "@@/registry/clipboard/clipboard"; -import { IconBrandNpm, IconBrandPnpm } from "@tabler/icons-react"; -import { ChevronDownIcon } from "lucide-react"; -import { useState } from "react"; -import BunIcon from "@/components/icons/bun"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { useIsMobile } from "@/hooks/use-mobile"; - -export default function ClipboardDemo01() { - const installSnippet = { - npm: `npx shadcn@latest add`, - pnpm: `pnpm dlx shadcn@latest add`, - bun: `bunx --bun shadcn@latest add`, - }; - const [activeInstallTab, setActiveInstallTab] = useState("npm"); - const isMobile = useIsMobile(); - - const packageManagers = [ - { value: "npm", label: "npm", icon: }, - { value: "pnpm", label: "pnpm", icon: }, - { value: "bun", label: "bun", icon: }, - ]; - - const activePackageManager = packageManagers.find( - (pm) => pm.value === activeInstallTab, - ); - - return ( -
- setActiveInstallTab(value)} - > -
- {isMobile ? ( - - - {activePackageManager?.icon} - {activePackageManager?.label} - - - - {packageManagers.map((pm) => ( - setActiveInstallTab(pm.value)} - className="gap-2" - > - {pm.icon} - {pm.label} - - ))} - - - ) : ( - - - - npm - - - - pnpm - - - - bun - - - )} - -
-
- - {installSnippet.npm} - - - {installSnippet.pnpm} - - - {installSnippet.bun} - -
-
-
- ); -} 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/docs/clipboard.mdx b/src/content/docs/clipboard.mdx deleted file mode 100644 index 05dd097..0000000 --- a/src/content/docs/clipboard.mdx +++ /dev/null @@ -1,14 +0,0 @@ -import { CodeBlock } from "/src/components/ui/code-block.tsx" -import { Label } from "/src/components/ui/label.tsx" - -
- -
- -- ✅ Visual feedback with icon transitions -- ✅ Customizable appearance and behavior -- ✅ Tooltip support -- ✅ Error handling callbacks -
- -`} />
\ No newline at end of file diff --git a/src/lib/code-loader.ts b/src/lib/code-loader.ts index 7e548ec..2163d26 100644 --- a/src/lib/code-loader.ts +++ b/src/lib/code-loader.ts @@ -3,8 +3,8 @@ * Uses Vite's import.meta.glob to load raw source files */ -// Load all example component files as raw strings from the new organized structure -const modules = import.meta.glob("/src/content/*/examples/*.tsx", { +// Load consolidated examples.tsx files as raw strings +const modules = import.meta.glob("/src/content/*/examples.tsx", { query: "?raw", import: "default", }); @@ -15,9 +15,9 @@ export const mdxModules = import.meta.glob("/src/content/*/docs.mdx", { }); /** - * 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, @@ -30,17 +30,36 @@ export async function getExampleCode( } const [, blockName, exampleNum] = match; - const path = `/src/content/${blockName}/examples/example-${exampleNum}.tsx`; + const path = `/src/content/${blockName}/examples.tsx`; const loader = modules[path]; if (!loader) { - console.warn(`No code found for example: ${exampleId} at path: ${path}`); + 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; From f6e12af25050853ba39575b1a2d82cef483f2a95 Mon Sep 17 00:00:00 2001 From: Tommy Lundy Date: Tue, 28 Oct 2025 16:51:58 +0000 Subject: [PATCH 3/4] feat: add URL Preview component with OpenGraph metadata support - Implemented the Preview component to display rich URL previews using OpenGraph metadata. - Added server-side fetching utility to retrieve metadata from URLs. - Created API route for fetching metadata with error handling. - Developed hover card UI components for displaying previews on hover. - Included comprehensive documentation and usage examples for the Preview component. --- bun.lock | 3 + package.json | 1 + public/components/preview.png | Bin 0 -> 60945 bytes public/r/clipboard.json | 9 +- public/r/preview.json | 22 +++++ registry.json | 30 +++++-- registry/preview/README.md | 61 +++++++++++++ registry/preview/preview-server.ts | 54 +++++++++++ registry/preview/preview.tsx | 140 +++++++++++++++++++++++++++++ src/components/layout/sidebar.tsx | 56 ++++++------ src/components/ui/hover-card.tsx | 42 +++++++++ src/content/blocks-metadata.ts | 91 +++++++++++++++++-- src/content/preview/docs.mdx | 50 +++++++++++ src/content/preview/examples.tsx | 33 +++++++ src/lib/seo-image-generator.tsx | 22 ++++- src/routeTree.gen.ts | 21 +++++ src/routes/api/preview.ts | 72 +++++++++++++++ src/routes/blocks/$blockId.tsx | 2 +- src/routes/blocks/index.tsx | 66 +++++++------- 19 files changed, 693 insertions(+), 82 deletions(-) create mode 100644 public/components/preview.png create mode 100644 public/r/preview.json create mode 100644 registry/preview/README.md create mode 100644 registry/preview/preview-server.ts create mode 100644 registry/preview/preview.tsx create mode 100644 src/components/ui/hover-card.tsx create mode 100644 src/content/preview/docs.mdx create mode 100644 src/content/preview/examples.tsx create mode 100644 src/routes/api/preview.ts diff --git a/bun.lock b/bun.lock index 907fa14..966c219 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@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-separator": "^1.1.7", @@ -355,6 +356,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=="], diff --git a/package.json b/package.json index b4c2a17..d46b3d4 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@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-separator": "^1.1.7", diff --git a/public/components/preview.png b/public/components/preview.png new file mode 100644 index 0000000000000000000000000000000000000000..d852a639ad2dcf0b8a1d422bbad38edc235da8a9 GIT binary patch literal 60945 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

<2X71Q`A--|Xd-81n!GNR0 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 ZZvHY>^9P#ixiIiY?Uv@v((4vs{{zR1f2aTe literal 0 HcmV?d00001 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/content/blocks-metadata.ts b/src/content/blocks-metadata.ts index 7d678a2..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; @@ -38,12 +33,13 @@ export interface BlockMetadata { 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/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/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 OgBlocksBlockIdDotpngRoute = OgBlocksBlockIdDotpngRouteImport.update({ id: '/og/blocks/$blockId.png', path: '/og/blocks/$blockId.png', @@ -140,6 +146,7 @@ const DemoStartSsrDataOnlyRoute = DemoStartSsrDataOnlyRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/blocks': typeof BlocksRouteRouteWithChildren + '/api/preview': typeof ApiPreviewRoute '/blocks/$blockId': typeof BlocksBlockIdRoute '/demo/table': typeof DemoTableRoute '/demo/tanstack-query': typeof DemoTanstackQueryRoute @@ -162,6 +169,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/api/preview': typeof ApiPreviewRoute '/blocks/$blockId': typeof BlocksBlockIdRoute '/demo/table': typeof DemoTableRoute '/demo/tanstack-query': typeof DemoTanstackQueryRoute @@ -186,6 +194,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/blocks': typeof BlocksRouteRouteWithChildren + '/api/preview': typeof ApiPreviewRoute '/blocks/$blockId': typeof BlocksBlockIdRoute '/demo/table': typeof DemoTableRoute '/demo/tanstack-query': typeof DemoTanstackQueryRoute @@ -211,6 +220,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/blocks' + | '/api/preview' | '/blocks/$blockId' | '/demo/table' | '/demo/tanstack-query' @@ -233,6 +243,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/api/preview' | '/blocks/$blockId' | '/demo/table' | '/demo/tanstack-query' @@ -256,6 +267,7 @@ export interface FileRouteTypes { | '__root__' | '/' | '/blocks' + | '/api/preview' | '/blocks/$blockId' | '/demo/table' | '/demo/tanstack-query' @@ -280,6 +292,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute BlocksRouteRoute: typeof BlocksRouteRouteWithChildren + ApiPreviewRoute: typeof ApiPreviewRoute DemoTableRoute: typeof DemoTableRoute DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute OgBlocksDotpngRoute: typeof OgBlocksDotpngRoute @@ -363,6 +376,13 @@ 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 + } '/og/blocks/$blockId.png': { id: '/og/blocks/$blockId.png' path: '/og/blocks/$blockId.png' @@ -469,6 +489,7 @@ const BlocksRouteRouteWithChildren = BlocksRouteRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, BlocksRouteRoute: BlocksRouteRouteWithChildren, + ApiPreviewRoute: ApiPreviewRoute, DemoTableRoute: DemoTableRoute, DemoTanstackQueryRoute: DemoTanstackQueryRoute, OgBlocksDotpngRoute: OgBlocksDotpngRoute, 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} -

- )} -
- - ))} + + ))}
))} From 1da8a95c1b72ea9f712a589c20162a5ad3640ff3 Mon Sep 17 00:00:00 2001 From: Tommy Lundy Date: Tue, 28 Oct 2025 17:08:33 +0000 Subject: [PATCH 4/4] feat: update project name to doras-ui, add GitHub stars API route, and implement progress and spinner components --- .cta.json | 2 +- AGENTS.md | 1 + bun.lock | 3 ++ package.json | 2 + src/components/ui/progress.tsx | 29 ++++++++++++++ src/components/ui/spinner.tsx | 16 ++++++++ src/hooks/use-github-stars.ts | 32 +++++++++++++++ src/routeTree.gen.ts | 21 ++++++++++ src/routes/api/github-stars.ts | 73 ++++++++++++++++++++++++++++++++++ src/routes/index.tsx | 38 +++++++++++++----- 10 files changed, 207 insertions(+), 10 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/components/ui/progress.tsx create mode 100644 src/components/ui/spinner.tsx create mode 100644 src/hooks/use-github-stars.ts create mode 100644 src/routes/api/github-stars.ts 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 966c219..b86cb5b 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "@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", @@ -374,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 d46b3d4..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", @@ -24,6 +25,7 @@ "@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/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/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/routeTree.gen.ts b/src/routeTree.gen.ts index cacca63..2dd17c4 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -19,6 +19,7 @@ import { Route as DemoTanstackQueryRouteImport } from './routes/demo/tanstack-qu import { Route as DemoTableRouteImport } from './routes/demo/table' import { Route as BlocksBlockIdRouteImport } from './routes/blocks/$blockId' import { Route as ApiPreviewRouteImport } from './routes/api/preview' +import { Route as ApiGithubStarsRouteImport } from './routes/api/github-stars' import { Route as OgBlocksBlockIdDotpngRouteImport } from './routes/og/blocks/$blockId[.]png' import { Route as DemoStartServerFuncsRouteImport } from './routes/demo/start.server-funcs' import { Route as DemoStartApiRequestRouteImport } from './routes/demo/start.api-request' @@ -82,6 +83,11 @@ const ApiPreviewRoute = ApiPreviewRouteImport.update({ 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', @@ -146,6 +152,7 @@ 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 @@ -169,6 +176,7 @@ 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 @@ -194,6 +202,7 @@ 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 @@ -220,6 +229,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/blocks' + | '/api/github-stars' | '/api/preview' | '/blocks/$blockId' | '/demo/table' @@ -243,6 +253,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/api/github-stars' | '/api/preview' | '/blocks/$blockId' | '/demo/table' @@ -267,6 +278,7 @@ export interface FileRouteTypes { | '__root__' | '/' | '/blocks' + | '/api/github-stars' | '/api/preview' | '/blocks/$blockId' | '/demo/table' @@ -292,6 +304,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute BlocksRouteRoute: typeof BlocksRouteRouteWithChildren + ApiGithubStarsRoute: typeof ApiGithubStarsRoute ApiPreviewRoute: typeof ApiPreviewRoute DemoTableRoute: typeof DemoTableRoute DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute @@ -383,6 +396,13 @@ declare module '@tanstack/react-router' { 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' @@ -489,6 +509,7 @@ const BlocksRouteRouteWithChildren = BlocksRouteRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, BlocksRouteRoute: BlocksRouteRouteWithChildren, + ApiGithubStarsRoute: ApiGithubStarsRoute, ApiPreviewRoute: ApiPreviewRoute, DemoTableRoute: DemoTableRoute, DemoTanstackQueryRoute: DemoTanstackQueryRoute, 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/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 + + + + )}