124 lines
3.5 KiB
TypeScript
124 lines
3.5 KiB
TypeScript
import { Copy, ExternalLink, Globe } from 'lucide-react';
|
|
import { domainFromUrl, type LinkMetadata } from '@/lib/link-metadata';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Button } from '@/components/ui/button';
|
|
import { platform } from '@/lib/platform';
|
|
|
|
interface LinkPreviewCardProps {
|
|
metadata: LinkMetadata;
|
|
compact?: boolean;
|
|
}
|
|
|
|
export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
|
|
const handleOpen = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
platform.link.openExternal(metadata.url);
|
|
};
|
|
|
|
const handleCopy = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
navigator.clipboard.writeText(metadata.url);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className="bg-card text-card-foreground border-border max-w-sm overflow-hidden rounded-2xl border"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleOpen(e);
|
|
}}
|
|
>
|
|
{metadata.image && (
|
|
<img
|
|
src={metadata.image}
|
|
alt=""
|
|
className="h-32 w-full object-cover"
|
|
onError={(e) => {
|
|
(e.target as HTMLImageElement).style.display = 'none';
|
|
}}
|
|
/>
|
|
)}
|
|
<div className="flex flex-col gap-1 p-3">
|
|
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
|
{metadata.favicon ? (
|
|
<img
|
|
src={metadata.favicon}
|
|
alt=""
|
|
className="size-4 rounded-sm"
|
|
onError={(e) => {
|
|
(e.target as HTMLImageElement).replaceWith(
|
|
document.createElement('span'),
|
|
);
|
|
}}
|
|
/>
|
|
) : (
|
|
<Globe className="size-4" />
|
|
)}
|
|
<span className="truncate">{metadata.domain}</span>
|
|
</div>
|
|
{metadata.title && (
|
|
<p className="truncate text-sm font-semibold leading-snug">
|
|
{metadata.title}
|
|
</p>
|
|
)}
|
|
{metadata.description && (
|
|
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
|
{metadata.description}
|
|
</p>
|
|
)}
|
|
{!compact && (
|
|
<div className="mt-1.5 flex gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="xs"
|
|
className="text-muted-foreground"
|
|
onClick={handleOpen}
|
|
>
|
|
<ExternalLink data-icon="inline-start" />
|
|
Open
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="xs"
|
|
className="text-muted-foreground"
|
|
onClick={handleCopy}
|
|
>
|
|
<Copy data-icon="inline-start" />
|
|
Copy
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Shown when metadata couldn't be fetched — the link itself still works. */
|
|
export function LinkPreviewCardFallback({ url }: { url: string }) {
|
|
return (
|
|
<LinkPreviewCard
|
|
metadata={{
|
|
url,
|
|
domain: domainFromUrl(url),
|
|
title: url,
|
|
description: null,
|
|
image: null,
|
|
favicon: null,
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function LinkPreviewCardSkeleton() {
|
|
return (
|
|
<div className="bg-card border-border max-w-sm overflow-hidden rounded-2xl border">
|
|
<Skeleton className="h-32 w-full rounded-none" />
|
|
<div className="flex flex-col gap-2 p-3">
|
|
<Skeleton className="h-3 w-24" />
|
|
<Skeleton className="h-4 w-48" />
|
|
<Skeleton className="h-3 w-full" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|