Extract shared ExternalContentHeader and show parent preview on comment detail
Refactor the external content header components (URL, Book, Country) and helpers out of ExternalContentPage into a shared module at ExternalContentHeader.tsx. Both the /i/ page and PostDetailPage now import from this shared module. Add a compact ExternalContentPreview component that shows a rich inline preview (thumbnail, title, favicon) of the external content being commented on. This is displayed above the focused comment on the nevent detail page for kind 1111 comments with I tags, similar to how AncestorThread shows the parent for kind 1 replies.
This commit is contained in:
@@ -0,0 +1,583 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { BookOpen, ExternalLink, Globe, MapPin } from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ExternalFavicon } from '@/components/ExternalFavicon';
|
||||
import { YouTubeEmbed } from '@/components/YouTubeEmbed';
|
||||
import { useLinkPreview } from '@/hooks/useLinkPreview';
|
||||
import { useBookInfo } from '@/hooks/useBookInfo';
|
||||
import { getCountryInfo } from '@/lib/countries';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Parsed external content identifier with its type. */
|
||||
export type ExternalContent =
|
||||
| { type: 'url'; value: string }
|
||||
| { type: 'isbn'; value: string }
|
||||
| { type: 'iso3166'; value: string; code: string }
|
||||
| { type: 'unknown'; value: string };
|
||||
|
||||
/** Parse a URI string into a typed external content object. */
|
||||
export function parseExternalUri(uri: string): ExternalContent {
|
||||
if (uri.startsWith('isbn:')) {
|
||||
return { type: 'isbn', value: uri };
|
||||
}
|
||||
if (uri.startsWith('iso3166:')) {
|
||||
const code = uri.slice('iso3166:'.length);
|
||||
return { type: 'iso3166', value: uri, code };
|
||||
}
|
||||
if (uri.startsWith('http://') || uri.startsWith('https://')) {
|
||||
return { type: 'url', value: uri };
|
||||
}
|
||||
return { type: 'unknown', value: uri };
|
||||
}
|
||||
|
||||
/** Extract a YouTube video ID from a URL, or null if not a YouTube link. */
|
||||
export function extractYouTubeId(url: string): string | null {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if ((u.hostname === 'www.youtube.com' || u.hostname === 'youtube.com' || u.hostname === 'm.youtube.com') && u.pathname === '/watch') {
|
||||
return u.searchParams.get('v');
|
||||
}
|
||||
if ((u.hostname === 'www.youtube.com' || u.hostname === 'youtube.com') && u.pathname.startsWith('/embed/')) {
|
||||
return u.pathname.split('/')[2] || null;
|
||||
}
|
||||
if ((u.hostname === 'www.youtube.com' || u.hostname === 'youtube.com') && u.pathname.startsWith('/shorts/')) {
|
||||
return u.pathname.split('/')[2] || null;
|
||||
}
|
||||
if (u.hostname === 'youtu.be') {
|
||||
return u.pathname.slice(1) || null;
|
||||
}
|
||||
} catch {
|
||||
// not a valid URL
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Format an ISBN with hyphens for display (simplified). */
|
||||
export function formatIsbn(isbn: string): string {
|
||||
const digits = isbn.replace(/\D/g, '');
|
||||
if (digits.length === 13) {
|
||||
return `${digits.slice(0, 3)}-${digits.slice(3, 4)}-${digits.slice(4, 9)}-${digits.slice(9, 12)}-${digits.slice(12)}`;
|
||||
}
|
||||
if (digits.length === 10) {
|
||||
return `${digits.slice(0, 1)}-${digits.slice(1, 5)}-${digits.slice(5, 9)}-${digits.slice(9)}`;
|
||||
}
|
||||
return isbn;
|
||||
}
|
||||
|
||||
/** Get a short label for the content type. */
|
||||
export function headerLabel(content: ExternalContent): string {
|
||||
switch (content.type) {
|
||||
case 'url':
|
||||
if (extractYouTubeId(content.value)) return 'YouTube';
|
||||
try {
|
||||
return new URL(content.value).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return 'Web Page';
|
||||
}
|
||||
case 'isbn':
|
||||
return 'Book';
|
||||
case 'iso3166':
|
||||
return getCountryInfo(content.code)?.name ?? 'Country';
|
||||
default:
|
||||
return 'External Content';
|
||||
}
|
||||
}
|
||||
|
||||
/** Get a page title for SEO. */
|
||||
export function seoTitle(content: ExternalContent): string {
|
||||
switch (content.type) {
|
||||
case 'url':
|
||||
try {
|
||||
return `${new URL(content.value).hostname.replace(/^www\./, '')} | Ditto`;
|
||||
} catch {
|
||||
return 'Web Page | Ditto';
|
||||
}
|
||||
case 'isbn': {
|
||||
const isbn = content.value.replace('isbn:', '');
|
||||
return `Book (ISBN ${isbn}) | Ditto`;
|
||||
}
|
||||
case 'iso3166': {
|
||||
const info = getCountryInfo(content.code);
|
||||
return info ? `${info.name} | Ditto` : 'Country | Ditto';
|
||||
}
|
||||
default:
|
||||
return 'External Content | Ditto';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full-size content headers (used on /i/ page)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function UrlContentHeader({ url }: { url: string }) {
|
||||
const youtubeId = useMemo(() => extractYouTubeId(url), [url]);
|
||||
const { data, isLoading } = useLinkPreview(url);
|
||||
|
||||
const domain = useMemo(() => {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}, [url]);
|
||||
|
||||
if (isLoading && !youtubeId) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border overflow-hidden">
|
||||
<Skeleton className="w-full h-[220px] rounded-none" />
|
||||
<div className="p-5 space-y-3">
|
||||
<Skeleton className="h-3 w-32" />
|
||||
<Skeleton className="h-6 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const title = data?.title;
|
||||
const author = data?.author_name;
|
||||
const providerName = data?.provider_name || domain;
|
||||
|
||||
if (youtubeId) {
|
||||
return (
|
||||
<div className="space-y-0 rounded-2xl border border-border overflow-hidden">
|
||||
<YouTubeEmbed videoId={youtubeId} className="border-0 rounded-none" />
|
||||
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group block p-5 space-y-2 hover:bg-secondary/40 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<ExternalFavicon url={url} size={14} className="shrink-0" />
|
||||
<span className="truncate">{providerName}</span>
|
||||
<ExternalLink className="size-3 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
|
||||
{title && (
|
||||
<h2 className="text-xl font-bold leading-snug line-clamp-3">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{author && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
by {author}
|
||||
</p>
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const image = data?.thumbnail_url;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
'group block rounded-2xl border border-border overflow-hidden',
|
||||
'hover:border-primary/30 hover:shadow-lg hover:shadow-primary/5 transition-all duration-300',
|
||||
)}
|
||||
>
|
||||
{image && (
|
||||
<div className="w-full overflow-hidden">
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
className="w-full h-[220px] object-cover group-hover:scale-[1.02] transition-transform duration-500"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.currentTarget.parentElement as HTMLElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-5 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<ExternalFavicon url={url} size={14} className="shrink-0" />
|
||||
<span className="truncate">{providerName}</span>
|
||||
<ExternalLink className="size-3 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
|
||||
{title && (
|
||||
<h2 className="text-xl font-bold leading-snug line-clamp-3">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{!title && (
|
||||
<h2 className="text-xl font-bold leading-snug break-all line-clamp-2 text-muted-foreground">
|
||||
{url}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{author && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
by {author}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function BookContentHeader({ isbn }: { isbn: string }) {
|
||||
const rawIsbn = isbn.replace('isbn:', '');
|
||||
const { data: book, isLoading } = useBookInfo(rawIsbn);
|
||||
const displayIsbn = formatIsbn(rawIsbn);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border overflow-hidden p-5">
|
||||
<div className="flex gap-5">
|
||||
<Skeleton className="w-[120px] h-[180px] rounded-lg shrink-0" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-6 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Skeleton className="h-6 w-16 rounded-full" />
|
||||
<Skeleton className="h-6 w-20 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const coverUrl = book?.cover?.large || book?.cover?.medium;
|
||||
const authors = book?.authors?.map((a) => a.name).join(', ');
|
||||
const publishers = book?.publishers?.map((p) => p.name).join(', ');
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border overflow-hidden">
|
||||
<div className="p-5">
|
||||
<div className="flex gap-5">
|
||||
{coverUrl ? (
|
||||
<div className="shrink-0">
|
||||
<img
|
||||
src={coverUrl}
|
||||
alt={book?.title || 'Book cover'}
|
||||
className="w-[120px] sm:w-[140px] rounded-lg shadow-md object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="shrink-0 w-[120px] sm:w-[140px] h-[180px] sm:h-[210px] rounded-lg bg-secondary flex items-center justify-center">
|
||||
<BookOpen className="size-10 text-muted-foreground/40" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<BookOpen className="size-3.5 shrink-0" />
|
||||
<span>ISBN {displayIsbn}</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-bold leading-snug line-clamp-3">
|
||||
{book?.title || 'Unknown Book'}
|
||||
</h2>
|
||||
|
||||
{authors && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
by {authors}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground pt-1">
|
||||
{book?.publish_date && (
|
||||
<span>{book.publish_date}</span>
|
||||
)}
|
||||
{publishers && (
|
||||
<span>{publishers}</span>
|
||||
)}
|
||||
{book?.number_of_pages && (
|
||||
<span>{book.number_of_pages} pages</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{book?.subjects && book.subjects.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-2">
|
||||
{book.subjects.map((s) => (
|
||||
<span
|
||||
key={s.name}
|
||||
className="text-xs px-2.5 py-0.5 rounded-full bg-secondary text-muted-foreground"
|
||||
>
|
||||
{s.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border px-5 py-2.5">
|
||||
<a
|
||||
href={`https://openlibrary.org/isbn/${rawIsbn}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Globe className="size-3.5" />
|
||||
<span>View on OpenLibrary</span>
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CountryContentHeader({ code }: { code: string }) {
|
||||
const info = getCountryInfo(code);
|
||||
|
||||
if (!info) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border p-5 text-center">
|
||||
<MapPin className="size-8 mx-auto mb-2 text-muted-foreground/40" />
|
||||
<p className="text-muted-foreground">Unknown country code: {code}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border overflow-hidden">
|
||||
<div className="p-6 sm:p-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-6xl sm:text-7xl leading-none" role="img" aria-label={`Flag of ${info.name}`}>
|
||||
{info.flag}
|
||||
</span>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<MapPin className="size-3.5 shrink-0" />
|
||||
<span>ISO 3166 {info.subdivision ? `(${info.subdivision})` : `(${code.toUpperCase()})`}</span>
|
||||
</div>
|
||||
<h2 className="text-2xl sm:text-3xl font-bold leading-snug">
|
||||
{info.name}
|
||||
</h2>
|
||||
{info.subdivision && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Subdivision: {info.subdivision}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border px-5 py-2.5">
|
||||
<a
|
||||
href={`https://en.wikipedia.org/wiki/ISO_3166-2:${code.toUpperCase()}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Globe className="size-3.5" />
|
||||
<span>View on Wikipedia</span>
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compact preview (used on nevent detail pages for kind 1111 comments)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compact preview of external content, shown above a kind 1111 comment
|
||||
* on its detail page. Links to the full /i/ page.
|
||||
*/
|
||||
export function ExternalContentPreview({ identifier }: { identifier: string }) {
|
||||
const content = useMemo(() => parseExternalUri(identifier), [identifier]);
|
||||
const link = `/i/${encodeURIComponent(identifier)}`;
|
||||
|
||||
switch (content.type) {
|
||||
case 'url':
|
||||
return <UrlPreview url={content.value} link={link} />;
|
||||
case 'isbn':
|
||||
return <BookPreview isbn={content.value} link={link} />;
|
||||
case 'iso3166':
|
||||
return <CountryPreview code={content.code} link={link} />;
|
||||
default:
|
||||
return (
|
||||
<Link to={link} className="block px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe className="size-5 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm text-muted-foreground truncate">{identifier}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function UrlPreview({ url, link }: { url: string; link: string }) {
|
||||
const youtubeId = useMemo(() => extractYouTubeId(url), [url]);
|
||||
const { data, isLoading } = useLinkPreview(url);
|
||||
|
||||
const domain = useMemo(() => {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}, [url]);
|
||||
|
||||
const title = data?.title;
|
||||
const image = data?.thumbnail_url;
|
||||
const providerName = data?.provider_name || domain;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-12 rounded-lg shrink-0" />
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// YouTube gets a thumbnail from the video ID
|
||||
const thumbnail = youtubeId
|
||||
? `https://img.youtube.com/vi/${youtubeId}/mqdefault.jpg`
|
||||
: image;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={link}
|
||||
className="flex items-center gap-3 px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors"
|
||||
>
|
||||
{thumbnail ? (
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt=""
|
||||
className="size-12 rounded-lg object-cover shrink-0"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="size-12 rounded-lg bg-secondary flex items-center justify-center shrink-0">
|
||||
<ExternalFavicon url={url} size={20} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<ExternalFavicon url={url} size={12} className="shrink-0" />
|
||||
<span className="truncate">{providerName}</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium truncate mt-0.5">
|
||||
{title || url}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function BookPreview({ isbn, link }: { isbn: string; link: string }) {
|
||||
const rawIsbn = isbn.replace('isbn:', '');
|
||||
const { data: book, isLoading } = useBookInfo(rawIsbn);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="w-9 h-12 rounded shrink-0" />
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const coverUrl = book?.cover?.medium || book?.cover?.large;
|
||||
const authors = book?.authors?.map((a) => a.name).join(', ');
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={link}
|
||||
className="flex items-center gap-3 px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors"
|
||||
>
|
||||
{coverUrl ? (
|
||||
<img
|
||||
src={coverUrl}
|
||||
alt={book?.title || 'Book cover'}
|
||||
className="w-9 h-12 rounded object-cover shrink-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-9 h-12 rounded bg-secondary flex items-center justify-center shrink-0">
|
||||
<BookOpen className="size-4 text-muted-foreground/40" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<BookOpen className="size-3 shrink-0" />
|
||||
<span>Book</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium truncate mt-0.5">
|
||||
{book?.title || `ISBN ${rawIsbn}`}
|
||||
</p>
|
||||
{authors && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
by {authors}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function CountryPreview({ code, link }: { code: string; link: string }) {
|
||||
const info = getCountryInfo(code);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={link}
|
||||
className="flex items-center gap-3 px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors"
|
||||
>
|
||||
<span className="text-2xl leading-none shrink-0" role="img" aria-label={info ? `Flag of ${info.name}` : code}>
|
||||
{info?.flag ?? '🌍'}
|
||||
</span>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<MapPin className="size-3 shrink-0" />
|
||||
<span>Country</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium truncate mt-0.5">
|
||||
{info?.name ?? code}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,423 +1,24 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { ArrowLeft, BookOpen, ExternalLink, Globe, MapPin, MessageSquare } from 'lucide-react';
|
||||
import { ArrowLeft, Globe, MessageSquare } from 'lucide-react';
|
||||
import { Link, useLocation, useParams } from 'react-router-dom';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { CommentForm } from '@/components/comments/CommentForm';
|
||||
import { ExternalFavicon } from '@/components/ExternalFavicon';
|
||||
import { YouTubeEmbed } from '@/components/YouTubeEmbed';
|
||||
import {
|
||||
parseExternalUri,
|
||||
headerLabel,
|
||||
seoTitle,
|
||||
UrlContentHeader,
|
||||
BookContentHeader,
|
||||
CountryContentHeader,
|
||||
} from '@/components/ExternalContentHeader';
|
||||
import { useComments } from '@/hooks/useComments';
|
||||
import { useLinkPreview } from '@/hooks/useLinkPreview';
|
||||
import { useBookInfo } from '@/hooks/useBookInfo';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { getCountryInfo } from '@/lib/countries';
|
||||
import { cn, STICKY_HEADER_CLASS } from '@/lib/utils';
|
||||
import NotFound from './NotFound';
|
||||
|
||||
/** Parsed external content identifier with its type. */
|
||||
type ExternalContent =
|
||||
| { type: 'url'; value: string }
|
||||
| { type: 'isbn'; value: string }
|
||||
| { type: 'iso3166'; value: string; code: string }
|
||||
| { type: 'unknown'; value: string };
|
||||
|
||||
/** Parse a URI string into a typed external content object. */
|
||||
function parseExternalUri(uri: string): ExternalContent {
|
||||
// ISBN - "isbn:9780765382030"
|
||||
if (uri.startsWith('isbn:')) {
|
||||
return { type: 'isbn', value: uri, };
|
||||
}
|
||||
|
||||
// ISO 3166 country/subdivision - "iso3166:US" or "iso3166:US-CA"
|
||||
if (uri.startsWith('iso3166:')) {
|
||||
const code = uri.slice('iso3166:'.length);
|
||||
return { type: 'iso3166', value: uri, code };
|
||||
}
|
||||
|
||||
// URL - starts with http:// or https://
|
||||
if (uri.startsWith('http://') || uri.startsWith('https://')) {
|
||||
return { type: 'url', value: uri };
|
||||
}
|
||||
|
||||
return { type: 'unknown', value: uri };
|
||||
}
|
||||
|
||||
/** Extract a YouTube video ID from a URL, or null if not a YouTube link. */
|
||||
function extractYouTubeId(url: string): string | null {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if ((u.hostname === 'www.youtube.com' || u.hostname === 'youtube.com' || u.hostname === 'm.youtube.com') && u.pathname === '/watch') {
|
||||
return u.searchParams.get('v');
|
||||
}
|
||||
if ((u.hostname === 'www.youtube.com' || u.hostname === 'youtube.com') && u.pathname.startsWith('/embed/')) {
|
||||
return u.pathname.split('/')[2] || null;
|
||||
}
|
||||
if ((u.hostname === 'www.youtube.com' || u.hostname === 'youtube.com') && u.pathname.startsWith('/shorts/')) {
|
||||
return u.pathname.split('/')[2] || null;
|
||||
}
|
||||
if (u.hostname === 'youtu.be') {
|
||||
return u.pathname.slice(1) || null;
|
||||
}
|
||||
} catch {
|
||||
// not a valid URL
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Format an ISBN with hyphens for display (simplified). */
|
||||
function formatIsbn(isbn: string): string {
|
||||
const digits = isbn.replace(/\D/g, '');
|
||||
if (digits.length === 13) {
|
||||
return `${digits.slice(0, 3)}-${digits.slice(3, 4)}-${digits.slice(4, 9)}-${digits.slice(9, 12)}-${digits.slice(12)}`;
|
||||
}
|
||||
if (digits.length === 10) {
|
||||
return `${digits.slice(0, 1)}-${digits.slice(1, 5)}-${digits.slice(5, 9)}-${digits.slice(9)}`;
|
||||
}
|
||||
return isbn;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// URL content header
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function UrlContentHeader({ url }: { url: string }) {
|
||||
const youtubeId = useMemo(() => extractYouTubeId(url), [url]);
|
||||
const { data, isLoading } = useLinkPreview(url);
|
||||
|
||||
const domain = useMemo(() => {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}, [url]);
|
||||
|
||||
if (isLoading && !youtubeId) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border overflow-hidden">
|
||||
<Skeleton className="w-full h-[220px] rounded-none" />
|
||||
<div className="p-5 space-y-3">
|
||||
<Skeleton className="h-3 w-32" />
|
||||
<Skeleton className="h-6 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const title = data?.title;
|
||||
const author = data?.author_name;
|
||||
const providerName = data?.provider_name || domain;
|
||||
|
||||
// YouTube URLs get the interactive embed player
|
||||
if (youtubeId) {
|
||||
return (
|
||||
<div className="space-y-0 rounded-2xl border border-border overflow-hidden">
|
||||
<YouTubeEmbed videoId={youtubeId} className="border-0 rounded-none" />
|
||||
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group block p-5 space-y-2 hover:bg-secondary/40 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<ExternalFavicon url={url} size={14} className="shrink-0" />
|
||||
<span className="truncate">{providerName}</span>
|
||||
<ExternalLink className="size-3 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
|
||||
{title && (
|
||||
<h2 className="text-xl font-bold leading-snug line-clamp-3">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{author && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
by {author}
|
||||
</p>
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Generic URL link preview
|
||||
const image = data?.thumbnail_url;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
'group block rounded-2xl border border-border overflow-hidden',
|
||||
'hover:border-primary/30 hover:shadow-lg hover:shadow-primary/5 transition-all duration-300',
|
||||
)}
|
||||
>
|
||||
{image && (
|
||||
<div className="w-full overflow-hidden">
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
className="w-full h-[220px] object-cover group-hover:scale-[1.02] transition-transform duration-500"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.currentTarget.parentElement as HTMLElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-5 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<ExternalFavicon url={url} size={14} className="shrink-0" />
|
||||
<span className="truncate">{providerName}</span>
|
||||
<ExternalLink className="size-3 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
|
||||
{title && (
|
||||
<h2 className="text-xl font-bold leading-snug line-clamp-3">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{!title && (
|
||||
<h2 className="text-xl font-bold leading-snug break-all line-clamp-2 text-muted-foreground">
|
||||
{url}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{author && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
by {author}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Book content header
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function BookContentHeader({ isbn }: { isbn: string }) {
|
||||
const rawIsbn = isbn.replace('isbn:', '');
|
||||
const { data: book, isLoading } = useBookInfo(rawIsbn);
|
||||
const displayIsbn = formatIsbn(rawIsbn);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border overflow-hidden p-5">
|
||||
<div className="flex gap-5">
|
||||
<Skeleton className="w-[120px] h-[180px] rounded-lg shrink-0" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-6 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Skeleton className="h-6 w-16 rounded-full" />
|
||||
<Skeleton className="h-6 w-20 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const coverUrl = book?.cover?.large || book?.cover?.medium;
|
||||
const authors = book?.authors?.map((a) => a.name).join(', ');
|
||||
const publishers = book?.publishers?.map((p) => p.name).join(', ');
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border overflow-hidden">
|
||||
<div className="p-5">
|
||||
<div className="flex gap-5">
|
||||
{/* Book cover */}
|
||||
{coverUrl ? (
|
||||
<div className="shrink-0">
|
||||
<img
|
||||
src={coverUrl}
|
||||
alt={book?.title || 'Book cover'}
|
||||
className="w-[120px] sm:w-[140px] rounded-lg shadow-md object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="shrink-0 w-[120px] sm:w-[140px] h-[180px] sm:h-[210px] rounded-lg bg-secondary flex items-center justify-center">
|
||||
<BookOpen className="size-10 text-muted-foreground/40" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Book details */}
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<BookOpen className="size-3.5 shrink-0" />
|
||||
<span>ISBN {displayIsbn}</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-bold leading-snug line-clamp-3">
|
||||
{book?.title || 'Unknown Book'}
|
||||
</h2>
|
||||
|
||||
{authors && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
by {authors}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground pt-1">
|
||||
{book?.publish_date && (
|
||||
<span>{book.publish_date}</span>
|
||||
)}
|
||||
{publishers && (
|
||||
<span>{publishers}</span>
|
||||
)}
|
||||
{book?.number_of_pages && (
|
||||
<span>{book.number_of_pages} pages</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{book?.subjects && book.subjects.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-2">
|
||||
{book.subjects.map((s) => (
|
||||
<span
|
||||
key={s.name}
|
||||
className="text-xs px-2.5 py-0.5 rounded-full bg-secondary text-muted-foreground"
|
||||
>
|
||||
{s.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OpenLibrary link */}
|
||||
<div className="border-t border-border px-5 py-2.5">
|
||||
<a
|
||||
href={`https://openlibrary.org/isbn/${rawIsbn}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Globe className="size-3.5" />
|
||||
<span>View on OpenLibrary</span>
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Country content header
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CountryContentHeader({ code }: { code: string }) {
|
||||
const info = getCountryInfo(code);
|
||||
|
||||
if (!info) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border p-5 text-center">
|
||||
<MapPin className="size-8 mx-auto mb-2 text-muted-foreground/40" />
|
||||
<p className="text-muted-foreground">Unknown country code: {code}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border overflow-hidden">
|
||||
<div className="p-6 sm:p-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-6xl sm:text-7xl leading-none" role="img" aria-label={`Flag of ${info.name}`}>
|
||||
{info.flag}
|
||||
</span>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<MapPin className="size-3.5 shrink-0" />
|
||||
<span>ISO 3166 {info.subdivision ? `(${info.subdivision})` : `(${code.toUpperCase()})`}</span>
|
||||
</div>
|
||||
<h2 className="text-2xl sm:text-3xl font-bold leading-snug">
|
||||
{info.name}
|
||||
</h2>
|
||||
{info.subdivision && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Subdivision: {info.subdivision}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border px-5 py-2.5">
|
||||
<a
|
||||
href={`https://en.wikipedia.org/wiki/ISO_3166-2:${code.toUpperCase()}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Globe className="size-3.5" />
|
||||
<span>View on Wikipedia</span>
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page header label
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function headerLabel(content: ExternalContent): string {
|
||||
switch (content.type) {
|
||||
case 'url':
|
||||
if (extractYouTubeId(content.value)) return 'YouTube';
|
||||
try {
|
||||
return new URL(content.value).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return 'Web Page';
|
||||
}
|
||||
case 'isbn':
|
||||
return 'Book';
|
||||
case 'iso3166':
|
||||
return getCountryInfo(content.code)?.name ?? 'Country';
|
||||
default:
|
||||
return 'External Content';
|
||||
}
|
||||
}
|
||||
|
||||
function seoTitle(content: ExternalContent): string {
|
||||
switch (content.type) {
|
||||
case 'url':
|
||||
try {
|
||||
return `${new URL(content.value).hostname.replace(/^www\./, '')} | Ditto`;
|
||||
} catch {
|
||||
return 'Web Page | Ditto';
|
||||
}
|
||||
case 'isbn': {
|
||||
const isbn = content.value.replace('isbn:', '');
|
||||
return `Book (ISBN ${isbn}) | Ditto`;
|
||||
}
|
||||
case 'iso3166': {
|
||||
const info = getCountryInfo(content.code);
|
||||
return info ? `${info.name} | Ditto` : 'Country | Ditto';
|
||||
}
|
||||
default:
|
||||
return 'External Content | Ditto';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -59,6 +59,7 @@ import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { ContentWarningGuard } from '@/components/ContentWarningGuard';
|
||||
import { MutedContentGuard } from '@/components/MutedContentGuard';
|
||||
import { ExternalContentPreview } from '@/components/ExternalContentHeader';
|
||||
import { getParentEventId } from '@/lib/nostrEvents';
|
||||
|
||||
|
||||
@@ -748,6 +749,12 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
|
||||
|
||||
const parentEventId = useMemo(() => isTextNote ? getParentEventId(event) : undefined, [event, isTextNote]);
|
||||
|
||||
// For kind 1111 comments on external content, extract the I tag for the parent preview
|
||||
const externalIdentifier = useMemo(() => {
|
||||
if (!isComment) return undefined;
|
||||
return event.tags.find(([n]) => n === 'I')?.[1];
|
||||
}, [event, isComment]);
|
||||
|
||||
// Keep the focused post pinned to top while ancestor content loads above it.
|
||||
// A ResizeObserver on the ancestor container re-scrolls on every layout shift
|
||||
// (image loads, skeleton→content swaps) for the first few seconds.
|
||||
@@ -804,6 +811,11 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* External content preview for kind 1111 comments on URLs/books/etc. */}
|
||||
{externalIdentifier && (
|
||||
<ExternalContentPreview identifier={externalIdentifier} />
|
||||
)}
|
||||
|
||||
{/* Main post — expanded Ditto-style view */}
|
||||
<article ref={focusedPostRef} className="px-4 pt-3 pb-0">
|
||||
{/* Author row */}
|
||||
|
||||
Reference in New Issue
Block a user