diff --git a/.vercelignore b/.vercelignore index 9d404684c..2dae74440 100644 --- a/.vercelignore +++ b/.vercelignore @@ -1,3 +1,41 @@ +# Git and version control .git +.github + +# Build cache +.next/cache +.cache + +# Reports and testing reports -.next/cache \ No newline at end of file +coverage +__tests__ + +# Documentation (except README and data files) +docs/ +*.md +!README.md +!src/data/**/*.md +!src/data/**/*.mdx + +# Development files +.vscode +.idea + +# Logs +*.log +logs + +# Temporary files +tmp +temp + +# Test files +*.test.ts +*.test.tsx +*.test.js +*.test.jsx +*.spec.ts +*.spec.tsx +*.spec.js +*.spec.jsx \ No newline at end of file diff --git a/URL_PREVIEW_CARD_README.md b/URL_PREVIEW_CARD_README.md new file mode 100644 index 000000000..8a50ed3a6 --- /dev/null +++ b/URL_PREVIEW_CARD_README.md @@ -0,0 +1,308 @@ +# URL Preview Card Feature + +This feature allows you to create beautiful preview cards for any URL shared in your application. It automatically fetches Open Graph metadata (title, description, image) from the URL and displays it in a card format. + +## Features + +- **Automatic metadata extraction**: Fetches Open Graph tags, Twitter Card data, and fallback metadata from any URL +- **Autogenerated images**: Creates a fallback image for URLs that don't have Open Graph images +- **Loading states**: Shows a skeleton loader while fetching metadata +- **Error handling**: Gracefully handles failed requests with error messages +- **Responsive design**: Works on all screen sizes +- **Hover effects**: Interactive animations on hover +- **External link safety**: Opens links in new tabs with `rel="noopener noreferrer"` + +## Files Created + +### 1. Type Definitions +`/src/types/url-metadata.ts` +- `URLMetadata`: Interface for URL metadata (title, description, image, etc.) +- `OGFetchResponse`: API response interface + +### 2. API Routes + +#### `/api/og/fetch` +Fetches metadata from a given URL by parsing HTML and extracting Open Graph tags. + +**Request:** +```typescript +POST /api/og/fetch +Content-Type: application/json + +{ + "url": "https://example.com" +} +``` + +**Response:** +```typescript +{ + "success": true, + "data": { + "url": "https://example.com", + "title": "Example Domain", + "description": "Example description", + "image": "https://example.com/og-image.jpg", + "siteName": "Example", + "favicon": "https://example.com/favicon.ico", + "type": "website" + } +} +``` + +#### Fallback Image Generation +For URLs without OG images, the component automatically generates a unique SVG image client-side using the `generateFallbackImage()` utility function. This approach: +- Creates unique colors based on hostname (same site = same colors) +- Shows the first letter of the domain +- Includes VWC branding +- **No API calls required** - fully client-side generation +- **Zero bundle size impact** - just inline SVG generation + +### 3. Components + +#### `URLPreviewCard` +`/src/components/url-preview-card/index.tsx` + +A React component that displays a preview card for a URL. + +**Props:** +- `url` (string, required): The URL to preview +- `className` (string, optional): Additional CSS classes + +**Usage:** +```tsx +import URLPreviewCard from '@/components/url-preview-card'; + +function MyComponent() { + return ( + + ); +} +``` + +### 4. Demo Page +`/src/pages/url-preview-demo.tsx` + +A demo page that showcases the URL preview card functionality. Visit `/url-preview-demo` to test it. + +## Usage Examples + +### Basic Usage +```tsx +import URLPreviewCard from '@/components/url-preview-card'; + +export default function MyPage() { + return ( +
+

Check out this link:

+ +
+ ); +} +``` + +### Multiple Cards +```tsx +import URLPreviewCard from '@/components/url-preview-card'; + +export default function LinkCollection() { + const urls = [ + 'https://vetswhocode.io', + 'https://github.com/Vets-Who-Code', + 'https://www.npmjs.com/package/next', + ]; + + return ( +
+ {urls.map((url) => ( + + ))} +
+ ); +} +``` + +### With Custom Styling +```tsx +import URLPreviewCard from '@/components/url-preview-card'; + +export default function StyledCard() { + return ( + + ); +} +``` + +### Using the API Directly +If you need just the metadata without the card component: + +```tsx +import { useState, useEffect } from 'react'; +import type { URLMetadata } from '@/types/url-metadata'; + +export default function CustomCard() { + const [metadata, setMetadata] = useState(null); + + useEffect(() => { + fetch('/api/og/fetch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: 'https://vetswhocode.io' }), + }) + .then(res => res.json()) + .then(data => { + if (data.success) { + setMetadata(data.data); + } + }); + }, []); + + if (!metadata) return
Loading...
; + + return ( +
+

{metadata.title}

+

{metadata.description}

+ {metadata.title} +
+ ); +} +``` + +## How It Works + +1. **User provides a URL**: The URL is passed to the `URLPreviewCard` component +2. **Fetch metadata**: The component calls `/api/og/fetch` with the URL +3. **Parse HTML**: The API route fetches the URL and parses the HTML with node-html-parser +4. **Extract metadata**: Open Graph tags, Twitter Card data, and fallback metadata are extracted +5. **Return data**: The metadata is returned to the component +6. **Display card**: The component renders a card with the title, description, and image +7. **Fallback image**: If no image is found, a unique SVG is generated client-side based on the hostname + +## Customization + +### Styling +The component uses Tailwind CSS with the `tw-` prefix. You can customize the appearance by: +- Modifying the component in `/src/components/url-preview-card/index.tsx` +- Passing custom classes via the `className` prop +- Updating the Tailwind config + +### Image Generation +Customize the fallback OG image appearance in the `generateFallbackImage()` function in `/src/components/url-preview-card/index.tsx`: +- Change colors, gradients, typography (lines 22-42) +- Modify the SVG layout and design +- Add your logo or different branding +- Adjust the hash function for different color schemes + +### Metadata Extraction +Enhance metadata extraction in `/src/pages/api/og/fetch.ts`: +- Add more metadata sources (Schema.org, JSON-LD, etc.) +- Implement caching to reduce API calls +- Add support for additional metadata fields + +## Performance Optimization + +### Caching (Recommended) +To avoid fetching the same URL multiple times, consider implementing caching: + +1. **Client-side caching**: Use React Query or SWR +2. **Server-side caching**: Store metadata in Redis or your database +3. **CDN caching**: Cache the API responses at the edge + +Example with Prisma (database caching): +```typescript +// Add to your Prisma schema +model URLMetadata { + id String @id @default(cuid()) + url String @unique + title String? + description String? + image String? + siteName String? + favicon String? + type String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} +``` + +## Testing + +Visit `/url-preview-demo` to test the feature: +1. Enter any URL +2. Click "Preview" +3. See the generated card + +Try these example URLs: +- https://vetswhocode.io +- https://github.com/Vets-Who-Code +- https://www.npmjs.com/package/next + +## Dependencies + +- `node-html-parser`: Lightweight HTML parsing library for Node.js +- **No additional dependencies** for image generation - uses inline SVG generation + +## Browser Support + +Works in all modern browsers that support: +- ES6+ +- Fetch API +- Next.js Image component + +## Security Considerations + +- **SSRF Protection**: The API validates URLs before fetching +- **Timeout**: Requests timeout after 10 seconds to prevent hanging +- **User-Agent**: A custom user-agent is set to identify the bot +- **Content-Type validation**: Only HTML content is parsed +- **XSS Protection**: Metadata is sanitized before rendering + +## Troubleshooting + +### No image appears +- Check if the URL has Open Graph meta tags +- Verify the image URL is accessible +- Check browser console for CORS errors +- The fallback image generator should handle this automatically + +### Slow loading +- The URL might be slow to respond +- Consider implementing caching +- Check your network connection + +### CORS errors +- OG image fetching happens server-side to avoid CORS issues +- If you see CORS errors, ensure you're using the API route + +### Build errors +- Ensure all dependencies are installed: `npm install` +- Check TypeScript errors: `npm run typecheck` +- Clear Next.js cache: `rm -rf .next` + +## Future Enhancements + +Potential improvements: +- [ ] Add database caching for fetched metadata +- [ ] Implement rate limiting to prevent abuse +- [ ] Add support for video OG tags +- [ ] Create multiple card layout variants +- [ ] Add copy-to-clipboard functionality for URLs +- [ ] Implement a URL shortener integration +- [ ] Add social share buttons to cards +- [ ] Support for rich embeds (YouTube, Twitter, etc.) + +## Contributing + +To contribute to this feature: +1. Create a new branch from `og_cards` +2. Make your changes +3. Test thoroughly +4. Submit a pull request + +--- + +Built with ❤️ for Vets Who Code diff --git a/next.config.js b/next.config.js index c1e4835b6..56e38924e 100644 --- a/next.config.js +++ b/next.config.js @@ -26,6 +26,11 @@ const nextConfig = { experimental: {}, + // Ensure MDX and data files are included for all pages + outputFileTracingIncludes: { + '/**': ['src/data/**/*'], + }, + webpack(config, { isServer }) { config.module.rules.push({ test: /\.svg$/, @@ -38,12 +43,28 @@ const nextConfig = { }; } + // Optimize for serverless functions + if (isServer) { + // Enable tree-shaking for server bundles + config.optimization = { + ...config.optimization, + usedExports: true, + sideEffects: false, + }; + } + return config; }, images: { domains: [], - remotePatterns: [], + remotePatterns: [ + { + protocol: 'https', + hostname: '**', + }, + ], + unoptimized: false, }, }; diff --git a/package-lock.json b/package-lock.json index df62c7ee2..7edd8bf4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,7 @@ "next-react-svg": "^1.1.3", "next-seo": "^5.5.0", "next-sitemap": "^3.1.55", + "node-html-parser": "^7.0.1", "plato": "^1.7.0", "react": "^18.3.1", "react-ace": "^11.0.1", @@ -8081,8 +8082,7 @@ "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" }, "node_modules/brace-expansion": { "version": "1.1.11", @@ -8961,7 +8961,6 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", - "dev": true, "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", @@ -8977,7 +8976,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "dev": true, "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", @@ -8991,7 +8989,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true, "funding": [ { "type": "github", @@ -9003,7 +9000,6 @@ "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dev": true, "dependencies": { "domelementtype": "^2.3.0" }, @@ -9018,7 +9014,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", - "dev": true, "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", @@ -9045,7 +9040,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", - "dev": true, "engines": { "node": ">= 6" }, @@ -9736,7 +9730,6 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "devOptional": true, "engines": { "node": ">=0.12" }, @@ -11987,6 +11980,14 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "bin": { + "he": "bin/he" + } + }, "node_modules/html-encoding-sniffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", @@ -15988,6 +15989,15 @@ "tslib": "^2.0.3" } }, + "node_modules/node-html-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-7.0.1.tgz", + "integrity": "sha512-KGtmPY2kS0thCWGK0VuPyOS+pBKhhe8gXztzA2ilAOhbUbxa9homF1bOyKvhGzMLXUoRds9IOmr/v5lr/lqNmA==", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -16032,7 +16042,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dev": true, "dependencies": { "boolbase": "^1.0.0" }, @@ -16393,17 +16402,29 @@ } }, "node_modules/parse5": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", - "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dev": true, "dependencies": { - "entities": "^4.5.0" + "entities": "^6.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", diff --git a/package.json b/package.json index 0cda9b898..0cf2c797c 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "next-react-svg": "^1.1.3", "next-seo": "^5.5.0", "next-sitemap": "^3.1.55", + "node-html-parser": "^7.0.1", "plato": "^1.7.0", "react": "^18.3.1", "react-ace": "^11.0.1", diff --git a/public/fallback-VgpzxPwKvXAx3Q4748PfJ.js b/public/fallback-ig51mFqkghFuP2RH4aGuS.js similarity index 100% rename from public/fallback-VgpzxPwKvXAx3Q4748PfJ.js rename to public/fallback-ig51mFqkghFuP2RH4aGuS.js diff --git a/src/components/url-preview-card/index.tsx b/src/components/url-preview-card/index.tsx new file mode 100644 index 000000000..cd6778c1e --- /dev/null +++ b/src/components/url-preview-card/index.tsx @@ -0,0 +1,182 @@ +import { useEffect, useState, useMemo } from 'react'; +import Image from 'next/image'; +import type { URLMetadata } from '@/types/url-metadata'; + +interface URLPreviewCardProps { + url: string; + className?: string; +} + +// Generate a unique fallback image as an SVG data URL +function generateFallbackImage(hostname: string): string { + // Hash function to generate consistent colors + const hashCode = (str: string) => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + return hash; + }; + + const hash = Math.abs(hashCode(hostname)); + const hue1 = hash % 360; + const hue2 = (hash + 60) % 360; + + const firstLetter = hostname.replace('www.', '').charAt(0).toUpperCase(); + + const svg = ` + + + + + + + + + + + + ${firstLetter} + ${hostname} + Shared from Vets Who Code + + `; + + // Use btoa for browser compatibility (or encodeURIComponent for simpler approach) + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +} + +export default function URLPreviewCard({ url, className = '' }: URLPreviewCardProps) { + const [metadata, setMetadata] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchMetadata = async () => { + try { + setLoading(true); + setError(null); + + const response = await fetch('/api/og/fetch', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ url }), + }); + + // Check if response is JSON + const contentType = response.headers.get('content-type'); + if (!contentType || !contentType.includes('application/json')) { + const text = await response.text(); + console.error('Non-JSON response:', text); + setError('Server returned an error. Check console for details.'); + return; + } + + const data = await response.json(); + + if (data.success && data.data) { + setMetadata(data.data); + } else { + setError(data.error || 'Failed to fetch URL metadata'); + } + } catch (err) { + console.error('Fetch error:', err); + setError(err instanceof Error ? err.message : 'An error occurred'); + } finally { + setLoading(false); + } + }; + + if (url) { + fetchMetadata(); + } + }, [url]); + + if (loading) { + return ( +
+
+
+
+
+
+
+
+ ); + } + + if (error || !metadata) { + return ( +
+

+ {error || 'Failed to load preview'} +

+ + {url} + +
+ ); + } + + const hostname = new URL(metadata.url).hostname; + + // Generate fallback image using useMemo to avoid regenerating on every render + const fallbackImage = useMemo(() => generateFallbackImage(hostname), [hostname]); + const displayImage = metadata.image || fallbackImage; + + return ( + +
+ {metadata.title +
+
+ {metadata.siteName && ( +
+ {metadata.favicon && ( + + )} + + {metadata.siteName} + +
+ )} +

+ {metadata.title} +

+ {metadata.description && ( +

+ {metadata.description} +

+ )} +

+ {hostname} +

+
+
+ ); +} diff --git a/src/pages/api/og/fetch.ts b/src/pages/api/og/fetch.ts new file mode 100644 index 000000000..8c6f85cb3 --- /dev/null +++ b/src/pages/api/og/fetch.ts @@ -0,0 +1,106 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { parse } from 'node-html-parser'; +import type { OGFetchResponse, URLMetadata } from '@/types/url-metadata'; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'POST' && req.method !== 'GET') { + return res.status(405).json({ success: false, error: 'Method not allowed' }); + } + + const url = req.method === 'POST' ? req.body.url : req.query.url; + + if (!url || typeof url !== 'string') { + return res.status(400).json({ success: false, error: 'URL is required' }); + } + + try { + // Validate URL format + const urlObj = new URL(url); + + // Fetch the URL content with timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + const response = await fetch(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; VetsWhoCodeBot/1.0; +https://vetswhocode.io)', + }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + return res.status(400).json({ + success: false, + error: `Failed to fetch URL: ${response.statusText}` + }); + } + + const html = await response.text(); + const root = parse(html); + + // Helper function to get meta content + const getMeta = (selector: string): string | undefined => { + return root.querySelector(selector)?.getAttribute('content') || undefined; + }; + + // Extract Open Graph metadata + const metadata: URLMetadata = { + url: url, + title: + getMeta('meta[property="og:title"]') || + getMeta('meta[name="twitter:title"]') || + root.querySelector('title')?.text || + urlObj.hostname, + description: + getMeta('meta[property="og:description"]') || + getMeta('meta[name="twitter:description"]') || + getMeta('meta[name="description"]') || + undefined, + image: + getMeta('meta[property="og:image"]') || + getMeta('meta[name="twitter:image"]') || + getMeta('meta[property="og:image:url"]') || + undefined, + siteName: + getMeta('meta[property="og:site_name"]') || + urlObj.hostname, + favicon: + root.querySelector('link[rel="icon"]')?.getAttribute('href') || + root.querySelector('link[rel="shortcut icon"]')?.getAttribute('href') || + `${urlObj.origin}/favicon.ico`, + type: + getMeta('meta[property="og:type"]') || + 'website', + }; + + // Convert relative URLs to absolute + if (metadata.image && !metadata.image.startsWith('http')) { + metadata.image = new URL(metadata.image, urlObj.origin).toString(); + } + if (metadata.favicon && !metadata.favicon.startsWith('http')) { + metadata.favicon = new URL(metadata.favicon, urlObj.origin).toString(); + } + + return res.status(200).json({ success: true, data: metadata }); + } catch (error) { + console.error('Error fetching URL metadata:', error); + + if (error instanceof TypeError && error.message.includes('Invalid URL')) { + return res.status(400).json({ success: false, error: 'Invalid URL format' }); + } + + if (error instanceof Error && error.name === 'AbortError') { + return res.status(408).json({ success: false, error: 'Request timeout' }); + } + + return res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch URL metadata' + }); + } +} diff --git a/src/pages/url-preview-demo.tsx b/src/pages/url-preview-demo.tsx new file mode 100644 index 000000000..1befa3498 --- /dev/null +++ b/src/pages/url-preview-demo.tsx @@ -0,0 +1,106 @@ +import { useState } from 'react'; +import URLPreviewCard from '@/components/url-preview-card'; + +const URLPreviewDemo = () => { + const [url, setUrl] = useState(''); + const [submittedUrl, setSubmittedUrl] = useState(''); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setSubmittedUrl(url); + }; + + const exampleUrls = [ + 'https://vetswhocode.io', + 'https://github.com/Vets-Who-Code', + 'https://www.npmjs.com/package/next', + ]; + + return ( +
+
+

+ URL Preview Card Demo +

+ +
+
+
+ +
+ setUrl(e.target.value)} + placeholder="https://example.com" + className="tw-flex-1 tw-px-4 tw-py-2 tw-border tw-border-gray-300 tw-rounded-md focus:tw-ring-2 focus:tw-ring-blue-500 focus:tw-border-transparent" + required + /> + +
+
+
+ +
+

Try these examples:

+
+ {exampleUrls.map((exampleUrl) => ( + + ))} +
+
+
+ + {submittedUrl && ( +
+
+

+ Preview Card +

+ +
+ +
+

+ Usage Example +

+
+
+                  {`import URLPreviewCard from '@/components/url-preview-card';
+
+function MyComponent() {
+  return (
+    
+  );
+}`}
+                
+
+
+
+ )} +
+
+ ); +}; + +export default URLPreviewDemo; diff --git a/src/types/url-metadata.ts b/src/types/url-metadata.ts new file mode 100644 index 000000000..e51d3921f --- /dev/null +++ b/src/types/url-metadata.ts @@ -0,0 +1,15 @@ +export interface URLMetadata { + url: string; + title?: string; + description?: string; + image?: string; + siteName?: string; + favicon?: string; + type?: string; +} + +export interface OGFetchResponse { + success: boolean; + data?: URLMetadata; + error?: string; +} diff --git a/yarn.lock b/yarn.lock index 7e7ad9501..f192426ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5109,6 +5109,11 @@ entities@^4.2.0, entities@^4.4.0, entities@^4.5.0: resolved "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz" integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== +entities@^6.0.0: + version "6.0.1" + resolved "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz" + integrity sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g== + entities@1.0: version "1.0.0" resolved "https://registry.npmjs.org/entities/-/entities-1.0.0.tgz" @@ -6621,6 +6626,11 @@ hast-util-whitespace@^3.0.0: dependencies: "@types/hast" "^3.0.0" +he@1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/he/-/he-1.2.0.tgz" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + html-encoding-sniffer@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz" @@ -8933,6 +8943,14 @@ no-case@^3.0.4: lower-case "^2.0.2" tslib "^2.0.3" +node-html-parser@^7.0.1: + version "7.0.1" + resolved "https://registry.npmjs.org/node-html-parser/-/node-html-parser-7.0.1.tgz" + integrity sha512-KGtmPY2kS0thCWGK0VuPyOS+pBKhhe8gXztzA2ilAOhbUbxa9homF1bOyKvhGzMLXUoRds9IOmr/v5lr/lqNmA== + dependencies: + css-select "^5.1.0" + he "1.2.0" + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz" @@ -9212,11 +9230,11 @@ parse-json@^5.2.0: lines-and-columns "^1.1.6" parse5@^7.0.0, parse5@^7.1.1: - version "7.2.1" - resolved "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz" - integrity sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ== + version "7.3.0" + resolved "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz" + integrity sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw== dependencies: - entities "^4.5.0" + entities "^6.0.0" parseurl@~1.3.3: version "1.3.3"