100 lines
2.9 KiB
TypeScript
100 lines
2.9 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
|
|
interface LinkPreviewProps {
|
|
url: string;
|
|
}
|
|
|
|
interface MicrolinkData {
|
|
publisher?: string;
|
|
title?: string;
|
|
description?: string;
|
|
image?: { url: string };
|
|
logo?: { url: string };
|
|
}
|
|
|
|
export default function LinkPreview({ url }: LinkPreviewProps) {
|
|
const [data, setData] = useState<MicrolinkData | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
let isMounted = true;
|
|
setLoading(true);
|
|
|
|
// Check cache first to avoid rate limiting
|
|
const cacheKey = `link_preview_${url}`;
|
|
const cached = sessionStorage.getItem(cacheKey);
|
|
if (cached) {
|
|
try {
|
|
const parsed = JSON.parse(cached);
|
|
if (isMounted) {
|
|
setData(parsed);
|
|
setLoading(false);
|
|
}
|
|
return;
|
|
} catch (e) {}
|
|
}
|
|
|
|
fetch(`https://api.microlink.io?url=${encodeURIComponent(url)}`)
|
|
.then(res => res.json())
|
|
.then(res => {
|
|
if (isMounted && res.status === 'success' && res.data) {
|
|
setData(res.data);
|
|
sessionStorage.setItem(cacheKey, JSON.stringify(res.data));
|
|
}
|
|
if (isMounted) setLoading(false);
|
|
})
|
|
.catch((err) => {
|
|
console.error('Failed to fetch link preview:', err);
|
|
if (isMounted) setLoading(false);
|
|
});
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
};
|
|
}, [url]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="mt-2 text-xs text-knot-400 opacity-70 italic border-l-[3px] border-knot-500/50 pl-2">
|
|
Загрузка предпросмотра...
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!data || (!data.title && !data.description && !data.image)) {
|
|
return null;
|
|
}
|
|
|
|
// Domain for publisher fallback
|
|
let domain = data.publisher;
|
|
if (!domain) {
|
|
try {
|
|
domain = new URL(url).hostname.replace(/^www\./, '');
|
|
} catch (e) {}
|
|
}
|
|
|
|
return (
|
|
<a
|
|
href={url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="block mt-2 border-l-[3px] border-knot-500 bg-black/20 rounded-r-lg overflow-hidden hover:bg-black/30 transition-colors"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="p-2.5 flex flex-col gap-1.5">
|
|
<div className="flex items-center gap-1.5 text-xs font-semibold text-knot-400">
|
|
{data.logo?.url && <img src={data.logo.url} alt="" className="w-3.5 h-3.5 rounded-sm object-cover" />}
|
|
<span className="truncate">{domain}</span>
|
|
</div>
|
|
{data.title && <div className="text-sm font-bold text-white leading-tight break-words">{data.title}</div>}
|
|
{data.description && <div className="text-[13px] text-zinc-300 line-clamp-3 leading-snug">{data.description}</div>}
|
|
</div>
|
|
{data.image?.url && (
|
|
<div className="w-full relative overflow-hidden bg-black/20" style={{ maxHeight: '300px' }}>
|
|
<img src={data.image.url} alt="" className="w-full h-full object-cover" />
|
|
</div>
|
|
)}
|
|
</a>
|
|
);
|
|
}
|