react-link-preview-card
Version:
A React component for generating beautiful link preview cards with metadata
277 lines (270 loc) • 9.26 kB
JavaScript
// src/lib/components/LinkPreview/index.tsx
import { useState, useEffect } from "react";
// src/lib/components/LinkPreview/Preview.tsx
import { ExternalLink, Play, FileText, Globe } from "lucide-react";
import { jsx, jsxs } from "react/jsx-runtime";
var Preview = ({ data }) => {
const TypeIcon = () => {
switch (data.type) {
case "video":
return /* @__PURE__ */ jsx(Play, { className: "w-5 h-5 text-gray-600" });
case "article":
return /* @__PURE__ */ jsx(FileText, { className: "w-5 h-5 text-gray-600" });
default:
return /* @__PURE__ */ jsx(Globe, { className: "w-5 h-5 text-gray-600" });
}
};
return /* @__PURE__ */ jsxs(
"a",
{
href: data.url,
target: "_blank",
rel: "noopener noreferrer",
className: "block w-full max-w-md bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow duration-300",
children: [
/* @__PURE__ */ jsxs("div", { className: "relative", children: [
/* @__PURE__ */ jsx(
"img",
{
src: data.image,
alt: data.title,
className: "w-full h-48 object-cover",
onError: (e) => {
e.currentTarget.src = "https://images.unsplash.com/photo-1481627834876-b7833e8f5570?w=800&q=80";
}
}
),
/* @__PURE__ */ jsx("div", { className: "absolute top-2 right-2 bg-white/90 p-1.5 rounded-full", children: /* @__PURE__ */ jsx(ExternalLink, { className: "w-4 h-4 text-gray-600" }) }),
/* @__PURE__ */ jsx("div", { className: "absolute top-2 left-2 bg-white/90 p-1.5 rounded-full", children: /* @__PURE__ */ jsx(TypeIcon, {}) })
] }),
/* @__PURE__ */ jsxs("div", { className: "p-4", children: [
/* @__PURE__ */ jsx("h3", { className: "font-semibold text-lg text-gray-800 mb-2 line-clamp-2", children: data.title }),
/* @__PURE__ */ jsx("p", { className: "text-gray-600 text-sm line-clamp-2", children: data.description })
] })
]
}
);
};
// src/lib/components/LinkPreview/Skeleton.tsx
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
var LinkPreviewSkeleton = () => {
return /* @__PURE__ */ jsx2("div", { className: "w-full max-w-md bg-white rounded-lg shadow-md overflow-hidden", children: /* @__PURE__ */ jsxs2("div", { className: "animate-pulse", children: [
/* @__PURE__ */ jsx2("div", { className: "h-48 bg-gray-200" }),
/* @__PURE__ */ jsxs2("div", { className: "p-4 space-y-4", children: [
/* @__PURE__ */ jsxs2("div", { className: "space-y-2", children: [
/* @__PURE__ */ jsx2("div", { className: "h-5 bg-gray-200 rounded w-3/4" }),
/* @__PURE__ */ jsx2("div", { className: "h-5 bg-gray-200 rounded w-1/2" })
] }),
/* @__PURE__ */ jsxs2("div", { className: "space-y-2", children: [
/* @__PURE__ */ jsx2("div", { className: "h-3 bg-gray-200 rounded w-full" }),
/* @__PURE__ */ jsx2("div", { className: "h-3 bg-gray-200 rounded w-5/6" }),
/* @__PURE__ */ jsx2("div", { className: "h-3 bg-gray-200 rounded w-4/6" })
] })
] })
] }) });
};
// src/lib/utils/youtube.ts
var getYouTubeVideoId = (url) => {
const regExp = /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/;
const match = url.match(regExp);
return match && match[7].length === 11 ? match[7] : null;
};
var getYouTubeMetadata = async (url) => {
const videoId = getYouTubeVideoId(url);
if (!videoId) throw new Error("Invalid YouTube URL");
const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`;
const response = await fetch(oembedUrl);
const data = await response.json();
return {
title: data.title || "YouTube Video",
description: data.author_name ? `By ${data.author_name}` : "",
image: `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`,
url,
type: "video"
};
};
// src/lib/utils/cache.ts
var CACHE_PREFIX = "link-preview-";
var CACHE_DURATION = 24 * 60 * 60 * 1e3;
var cacheGet = (key) => {
try {
const item = localStorage.getItem(`${CACHE_PREFIX}${key}`);
if (!item) return null;
const { data, timestamp } = JSON.parse(item);
if (Date.now() - timestamp > CACHE_DURATION) {
localStorage.removeItem(`${CACHE_PREFIX}${key}`);
return null;
}
return data;
} catch {
return null;
}
};
var cacheSet = (key, data) => {
try {
const item = {
data,
timestamp: Date.now()
};
localStorage.setItem(`${CACHE_PREFIX}${key}`, JSON.stringify(item));
} catch {
}
};
// src/lib/utils/fallback.ts
var FALLBACK_IMAGES = {
default: "https://images.unsplash.com/photo-1481627834876-b7833e8f5570?w=800&q=80",
article: "https://images.unsplash.com/photo-1532012197267-da84d127e765?w=800&q=80",
video: "https://images.unsplash.com/photo-1576097449798-7c7f90e1248a?w=800&q=80"
};
var getRandomFallbackImage = (type) => {
if (type === "video") return FALLBACK_IMAGES.video;
if (type === "article") return FALLBACK_IMAGES.article;
return FALLBACK_IMAGES.default;
};
// src/lib/utils/metadata.ts
import axios from "axios";
var CORS_PROXY = "https://api.allorigins.win/get?url=";
var extractMetadata = (html, url) => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const getMetaContent = (selectors) => {
for (const selector of selectors) {
const element = doc.querySelector(selector);
const content = element?.getAttribute("content") || element?.getAttribute("value");
if (content) return content;
}
return null;
};
const title = getMetaContent(['meta[property="og:title"]', 'meta[name="twitter:title"]']) || doc.querySelector("title")?.textContent || new URL(url).hostname;
const description = getMetaContent([
'meta[property="og:description"]',
'meta[name="twitter:description"]',
'meta[name="description"]'
]) || "";
const image = getMetaContent([
'meta[property="og:image"]',
'meta[property="og:image:url"]',
'meta[name="twitter:image"]'
]) || getRandomFallbackImage("article");
const type = getMetaContent(['meta[property="og:type"]']) || "article";
return {
title,
description,
image,
url,
type
};
};
var fetchWithProxy = async (url) => {
try {
const response = await axios.get(`${CORS_PROXY}${encodeURIComponent(url)}`);
const contents = response.data.contents;
return extractMetadata(contents, url);
} catch (error) {
throw new Error("Failed to fetch with proxy");
}
};
var isValidUrl = (url) => {
try {
new URL(url);
return true;
} catch {
return false;
}
};
var fetchMetadata = async (url) => {
if (!isValidUrl(url)) {
return {
title: "Invalid URL",
description: "The provided URL is not valid",
image: getRandomFallbackImage(),
url,
type: "website"
};
}
const cachedData = cacheGet(url);
if (cachedData) return cachedData;
try {
let metadata;
if (url.includes("youtube.com") || url.includes("youtu.be")) {
metadata = await getYouTubeMetadata(url);
} else {
try {
metadata = await fetchWithProxy(url);
} catch (error) {
console.error("Proxy fetch failed:", error);
metadata = {
title: new URL(url).hostname,
description: "No description available",
image: getRandomFallbackImage("article"),
url,
type: "article"
};
}
}
if (metadata.title !== new URL(url).hostname) {
cacheSet(url, metadata);
}
return metadata;
} catch (error) {
console.error("Error fetching metadata:", error);
return {
title: new URL(url).hostname,
description: "No description available",
image: getRandomFallbackImage("article"),
url,
type: "article"
};
}
};
// src/lib/components/LinkPreview/index.tsx
import { jsx as jsx3 } from "react/jsx-runtime";
var LinkPreview = ({ url }) => {
const [loading, setLoading] = useState(true);
const [data, setData] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
let mounted = true;
const controller = new AbortController();
const getPreviewData = async () => {
try {
setLoading(true);
setError(null);
const metadata = await fetchMetadata(url);
if (mounted) {
setData(metadata);
}
} catch (err) {
if (mounted) {
setError("Failed to load preview");
console.error("Error fetching preview:", err);
}
} finally {
if (mounted) {
setLoading(false);
}
}
};
if (url) {
getPreviewData();
}
return () => {
mounted = false;
controller.abort();
};
}, [url]);
if (loading) {
return /* @__PURE__ */ jsx3(LinkPreviewSkeleton, {});
}
if (error) {
return /* @__PURE__ */ jsx3("div", { className: "w-full max-w-md p-4 bg-red-50 rounded-lg text-red-600", children: error });
}
if (!data) {
return null;
}
return /* @__PURE__ */ jsx3(Preview, { data });
};
export {
LinkPreview
};
//# sourceMappingURL=index.mjs.map