diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index aa66b59f..382e6a31 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -28,6 +28,7 @@ import { WebxdcFeedPage } from "./pages/WebxdcFeedPage"; import { TreasuresPage } from "./pages/TreasuresPage"; import { ThemesPage } from "./pages/ThemesPage"; import { ThemeBuilderPage } from "./pages/ThemeBuilderPage"; +import { ExternalContentPage } from "./pages/ExternalContentPage"; /** Redirects /profile to the user's canonical profile URL (nip05 or npub). */ function ProfileRedirect() { @@ -71,6 +72,7 @@ export function AppRouter() { } />} /> } /> } /> + } /> {/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */} } /> diff --git a/src/hooks/useBookInfo.ts b/src/hooks/useBookInfo.ts new file mode 100644 index 00000000..44453a2b --- /dev/null +++ b/src/hooks/useBookInfo.ts @@ -0,0 +1,101 @@ +import { useQuery } from '@tanstack/react-query'; +import { z } from 'zod'; + +const OpenLibraryBookSchema = z.object({ + title: z.string(), + authors: z.array(z.object({ + name: z.string(), + })).optional(), + number_of_pages: z.number().optional(), + publish_date: z.string().optional(), + publishers: z.array(z.object({ + name: z.string(), + })).optional(), + subjects: z.array(z.object({ + name: z.string(), + })).optional(), + cover: z.object({ + small: z.string().optional(), + medium: z.string().optional(), + large: z.string().optional(), + }).optional(), + excerpts: z.array(z.object({ + text: z.string(), + })).optional(), +}); + +export type BookInfo = z.infer; + +async function fetchBookInfo(isbn: string, signal?: AbortSignal): Promise { + try { + const response = await fetch( + `https://openlibrary.org/isbn/${isbn}.json`, + { signal, headers: { Accept: 'application/json' } }, + ); + if (!response.ok) return null; + + const raw = await response.json(); + + // OpenLibrary sometimes returns author keys instead of embedded objects. + // Fetch author names if needed. + let authors: { name: string }[] | undefined; + if (raw.authors && Array.isArray(raw.authors)) { + const authorEntries = raw.authors as Array<{ key?: string; name?: string }>; + const hasKeys = authorEntries.some((a) => a.key && !a.name); + if (hasKeys) { + const authorNames = await Promise.all( + authorEntries.map(async (a) => { + if (a.name) return { name: a.name }; + if (a.key) { + try { + const res = await fetch(`https://openlibrary.org${a.key}.json`, { signal }); + if (res.ok) { + const data = await res.json(); + return { name: data.name ?? 'Unknown Author' }; + } + } catch { /* ignore */ } + } + return { name: 'Unknown Author' }; + }), + ); + authors = authorNames; + } + } + + // Build cover URLs from cover ID if present + let cover: BookInfo['cover']; + if (raw.covers && Array.isArray(raw.covers) && raw.covers.length > 0) { + const coverId = raw.covers[0]; + cover = { + small: `https://covers.openlibrary.org/b/id/${coverId}-S.jpg`, + medium: `https://covers.openlibrary.org/b/id/${coverId}-M.jpg`, + large: `https://covers.openlibrary.org/b/id/${coverId}-L.jpg`, + }; + } + + return { + title: raw.title ?? 'Unknown Title', + authors: authors ?? raw.authors, + number_of_pages: raw.number_of_pages, + publish_date: raw.publish_date, + publishers: raw.publishers, + subjects: raw.subjects?.slice(0, 5), + cover: cover ?? raw.cover, + excerpts: raw.excerpts, + }; + } catch { + return null; + } +} + +/** Hook to fetch book information from OpenLibrary by ISBN. */ +export function useBookInfo(isbn: string | null) { + return useQuery({ + queryKey: ['book-info', isbn], + queryFn: ({ signal }) => fetchBookInfo(isbn!, signal), + enabled: !!isbn, + staleTime: 1000 * 60 * 60 * 24, // 24 hours + gcTime: 1000 * 60 * 60 * 24 * 7, // 7 days + retry: 1, + }); +} diff --git a/src/lib/countries.ts b/src/lib/countries.ts new file mode 100644 index 00000000..e1dcd47c --- /dev/null +++ b/src/lib/countries.ts @@ -0,0 +1,219 @@ +/** ISO 3166-1 alpha-2 country code to country name and flag emoji mapping. */ +export const COUNTRIES: Record = { + AF: { name: 'Afghanistan', flag: '🇦🇫' }, + AL: { name: 'Albania', flag: '🇦🇱' }, + DZ: { name: 'Algeria', flag: '🇩🇿' }, + AD: { name: 'Andorra', flag: '🇦🇩' }, + AO: { name: 'Angola', flag: '🇦🇴' }, + AG: { name: 'Antigua and Barbuda', flag: '🇦🇬' }, + AR: { name: 'Argentina', flag: '🇦🇷' }, + AM: { name: 'Armenia', flag: '🇦🇲' }, + AU: { name: 'Australia', flag: '🇦🇺' }, + AT: { name: 'Austria', flag: '🇦🇹' }, + AZ: { name: 'Azerbaijan', flag: '🇦🇿' }, + BS: { name: 'Bahamas', flag: '🇧🇸' }, + BH: { name: 'Bahrain', flag: '🇧🇭' }, + BD: { name: 'Bangladesh', flag: '🇧🇩' }, + BB: { name: 'Barbados', flag: '🇧🇧' }, + BY: { name: 'Belarus', flag: '🇧🇾' }, + BE: { name: 'Belgium', flag: '🇧🇪' }, + BZ: { name: 'Belize', flag: '🇧🇿' }, + BJ: { name: 'Benin', flag: '🇧🇯' }, + BT: { name: 'Bhutan', flag: '🇧🇹' }, + BO: { name: 'Bolivia', flag: '🇧🇴' }, + BA: { name: 'Bosnia and Herzegovina', flag: '🇧🇦' }, + BW: { name: 'Botswana', flag: '🇧🇼' }, + BR: { name: 'Brazil', flag: '🇧🇷' }, + BN: { name: 'Brunei', flag: '🇧🇳' }, + BG: { name: 'Bulgaria', flag: '🇧🇬' }, + BF: { name: 'Burkina Faso', flag: '🇧🇫' }, + BI: { name: 'Burundi', flag: '🇧🇮' }, + CV: { name: 'Cabo Verde', flag: '🇨🇻' }, + KH: { name: 'Cambodia', flag: '🇰🇭' }, + CM: { name: 'Cameroon', flag: '🇨🇲' }, + CA: { name: 'Canada', flag: '🇨🇦' }, + CF: { name: 'Central African Republic', flag: '🇨🇫' }, + TD: { name: 'Chad', flag: '🇹🇩' }, + CL: { name: 'Chile', flag: '🇨🇱' }, + CN: { name: 'China', flag: '🇨🇳' }, + CO: { name: 'Colombia', flag: '🇨🇴' }, + KM: { name: 'Comoros', flag: '🇰🇲' }, + CG: { name: 'Congo', flag: '🇨🇬' }, + CD: { name: 'Congo (DRC)', flag: '🇨🇩' }, + CR: { name: 'Costa Rica', flag: '🇨🇷' }, + CI: { name: "Cote d'Ivoire", flag: '🇨🇮' }, + HR: { name: 'Croatia', flag: '🇭🇷' }, + CU: { name: 'Cuba', flag: '🇨🇺' }, + CY: { name: 'Cyprus', flag: '🇨🇾' }, + CZ: { name: 'Czechia', flag: '🇨🇿' }, + DK: { name: 'Denmark', flag: '🇩🇰' }, + DJ: { name: 'Djibouti', flag: '🇩🇯' }, + DM: { name: 'Dominica', flag: '🇩🇲' }, + DO: { name: 'Dominican Republic', flag: '🇩🇴' }, + EC: { name: 'Ecuador', flag: '🇪🇨' }, + EG: { name: 'Egypt', flag: '🇪🇬' }, + SV: { name: 'El Salvador', flag: '🇸🇻' }, + GQ: { name: 'Equatorial Guinea', flag: '🇬🇶' }, + ER: { name: 'Eritrea', flag: '🇪🇷' }, + EE: { name: 'Estonia', flag: '🇪🇪' }, + SZ: { name: 'Eswatini', flag: '🇸🇿' }, + ET: { name: 'Ethiopia', flag: '🇪🇹' }, + FJ: { name: 'Fiji', flag: '🇫🇯' }, + FI: { name: 'Finland', flag: '🇫🇮' }, + FR: { name: 'France', flag: '🇫🇷' }, + GA: { name: 'Gabon', flag: '🇬🇦' }, + GM: { name: 'Gambia', flag: '🇬🇲' }, + GE: { name: 'Georgia', flag: '🇬🇪' }, + DE: { name: 'Germany', flag: '🇩🇪' }, + GH: { name: 'Ghana', flag: '🇬🇭' }, + GR: { name: 'Greece', flag: '🇬🇷' }, + GD: { name: 'Grenada', flag: '🇬🇩' }, + GT: { name: 'Guatemala', flag: '🇬🇹' }, + GN: { name: 'Guinea', flag: '🇬🇳' }, + GW: { name: 'Guinea-Bissau', flag: '🇬🇼' }, + GY: { name: 'Guyana', flag: '🇬🇾' }, + HT: { name: 'Haiti', flag: '🇭🇹' }, + HN: { name: 'Honduras', flag: '🇭🇳' }, + HU: { name: 'Hungary', flag: '🇭🇺' }, + IS: { name: 'Iceland', flag: '🇮🇸' }, + IN: { name: 'India', flag: '🇮🇳' }, + ID: { name: 'Indonesia', flag: '🇮🇩' }, + IR: { name: 'Iran', flag: '🇮🇷' }, + IQ: { name: 'Iraq', flag: '🇮🇶' }, + IE: { name: 'Ireland', flag: '🇮🇪' }, + IL: { name: 'Israel', flag: '🇮🇱' }, + IT: { name: 'Italy', flag: '🇮🇹' }, + JM: { name: 'Jamaica', flag: '🇯🇲' }, + JP: { name: 'Japan', flag: '🇯🇵' }, + JO: { name: 'Jordan', flag: '🇯🇴' }, + KZ: { name: 'Kazakhstan', flag: '🇰🇿' }, + KE: { name: 'Kenya', flag: '🇰🇪' }, + KI: { name: 'Kiribati', flag: '🇰🇮' }, + KP: { name: 'North Korea', flag: '🇰🇵' }, + KR: { name: 'South Korea', flag: '🇰🇷' }, + KW: { name: 'Kuwait', flag: '🇰🇼' }, + KG: { name: 'Kyrgyzstan', flag: '🇰🇬' }, + LA: { name: 'Laos', flag: '🇱🇦' }, + LV: { name: 'Latvia', flag: '🇱🇻' }, + LB: { name: 'Lebanon', flag: '🇱🇧' }, + LS: { name: 'Lesotho', flag: '🇱🇸' }, + LR: { name: 'Liberia', flag: '🇱🇷' }, + LY: { name: 'Libya', flag: '🇱🇾' }, + LI: { name: 'Liechtenstein', flag: '🇱🇮' }, + LT: { name: 'Lithuania', flag: '🇱🇹' }, + LU: { name: 'Luxembourg', flag: '🇱🇺' }, + MG: { name: 'Madagascar', flag: '🇲🇬' }, + MW: { name: 'Malawi', flag: '🇲🇼' }, + MY: { name: 'Malaysia', flag: '🇲🇾' }, + MV: { name: 'Maldives', flag: '🇲🇻' }, + ML: { name: 'Mali', flag: '🇲🇱' }, + MT: { name: 'Malta', flag: '🇲🇹' }, + MH: { name: 'Marshall Islands', flag: '🇲🇭' }, + MR: { name: 'Mauritania', flag: '🇲🇷' }, + MU: { name: 'Mauritius', flag: '🇲🇺' }, + MX: { name: 'Mexico', flag: '🇲🇽' }, + FM: { name: 'Micronesia', flag: '🇫🇲' }, + MD: { name: 'Moldova', flag: '🇲🇩' }, + MC: { name: 'Monaco', flag: '🇲🇨' }, + MN: { name: 'Mongolia', flag: '🇲🇳' }, + ME: { name: 'Montenegro', flag: '🇲🇪' }, + MA: { name: 'Morocco', flag: '🇲🇦' }, + MZ: { name: 'Mozambique', flag: '🇲🇿' }, + MM: { name: 'Myanmar', flag: '🇲🇲' }, + NA: { name: 'Namibia', flag: '🇳🇦' }, + NR: { name: 'Nauru', flag: '🇳🇷' }, + NP: { name: 'Nepal', flag: '🇳🇵' }, + NL: { name: 'Netherlands', flag: '🇳🇱' }, + NZ: { name: 'New Zealand', flag: '🇳🇿' }, + NI: { name: 'Nicaragua', flag: '🇳🇮' }, + NE: { name: 'Niger', flag: '🇳🇪' }, + NG: { name: 'Nigeria', flag: '🇳🇬' }, + MK: { name: 'North Macedonia', flag: '🇲🇰' }, + NO: { name: 'Norway', flag: '🇳🇴' }, + OM: { name: 'Oman', flag: '🇴🇲' }, + PK: { name: 'Pakistan', flag: '🇵🇰' }, + PW: { name: 'Palau', flag: '🇵🇼' }, + PA: { name: 'Panama', flag: '🇵🇦' }, + PG: { name: 'Papua New Guinea', flag: '🇵🇬' }, + PY: { name: 'Paraguay', flag: '🇵🇾' }, + PE: { name: 'Peru', flag: '🇵🇪' }, + PH: { name: 'Philippines', flag: '🇵🇭' }, + PL: { name: 'Poland', flag: '🇵🇱' }, + PT: { name: 'Portugal', flag: '🇵🇹' }, + QA: { name: 'Qatar', flag: '🇶🇦' }, + RO: { name: 'Romania', flag: '🇷🇴' }, + RU: { name: 'Russia', flag: '🇷🇺' }, + RW: { name: 'Rwanda', flag: '🇷🇼' }, + KN: { name: 'Saint Kitts and Nevis', flag: '🇰🇳' }, + LC: { name: 'Saint Lucia', flag: '🇱🇨' }, + VC: { name: 'Saint Vincent and the Grenadines', flag: '🇻🇨' }, + WS: { name: 'Samoa', flag: '🇼🇸' }, + SM: { name: 'San Marino', flag: '🇸🇲' }, + ST: { name: 'Sao Tome and Principe', flag: '🇸🇹' }, + SA: { name: 'Saudi Arabia', flag: '🇸🇦' }, + SN: { name: 'Senegal', flag: '🇸🇳' }, + RS: { name: 'Serbia', flag: '🇷🇸' }, + SC: { name: 'Seychelles', flag: '🇸🇨' }, + SL: { name: 'Sierra Leone', flag: '🇸🇱' }, + SG: { name: 'Singapore', flag: '🇸🇬' }, + SK: { name: 'Slovakia', flag: '🇸🇰' }, + SI: { name: 'Slovenia', flag: '🇸🇮' }, + SB: { name: 'Solomon Islands', flag: '🇸🇧' }, + SO: { name: 'Somalia', flag: '🇸🇴' }, + ZA: { name: 'South Africa', flag: '🇿🇦' }, + SS: { name: 'South Sudan', flag: '🇸🇸' }, + ES: { name: 'Spain', flag: '🇪🇸' }, + LK: { name: 'Sri Lanka', flag: '🇱🇰' }, + SD: { name: 'Sudan', flag: '🇸🇩' }, + SR: { name: 'Suriname', flag: '🇸🇷' }, + SE: { name: 'Sweden', flag: '🇸🇪' }, + CH: { name: 'Switzerland', flag: '🇨🇭' }, + SY: { name: 'Syria', flag: '🇸🇾' }, + TW: { name: 'Taiwan', flag: '🇹🇼' }, + TJ: { name: 'Tajikistan', flag: '🇹🇯' }, + TZ: { name: 'Tanzania', flag: '🇹🇿' }, + TH: { name: 'Thailand', flag: '🇹🇭' }, + TL: { name: 'Timor-Leste', flag: '🇹🇱' }, + TG: { name: 'Togo', flag: '🇹🇬' }, + TO: { name: 'Tonga', flag: '🇹🇴' }, + TT: { name: 'Trinidad and Tobago', flag: '🇹🇹' }, + TN: { name: 'Tunisia', flag: '🇹🇳' }, + TR: { name: 'Turkey', flag: '🇹🇷' }, + TM: { name: 'Turkmenistan', flag: '🇹🇲' }, + TV: { name: 'Tuvalu', flag: '🇹🇻' }, + UG: { name: 'Uganda', flag: '🇺🇬' }, + UA: { name: 'Ukraine', flag: '🇺🇦' }, + AE: { name: 'United Arab Emirates', flag: '🇦🇪' }, + GB: { name: 'United Kingdom', flag: '🇬🇧' }, + US: { name: 'United States', flag: '🇺🇸' }, + UY: { name: 'Uruguay', flag: '🇺🇾' }, + UZ: { name: 'Uzbekistan', flag: '🇺🇿' }, + VU: { name: 'Vanuatu', flag: '🇻🇺' }, + VA: { name: 'Vatican City', flag: '🇻🇦' }, + VE: { name: 'Venezuela', flag: '🇻🇪' }, + VN: { name: 'Vietnam', flag: '🇻🇳' }, + YE: { name: 'Yemen', flag: '🇾🇪' }, + ZM: { name: 'Zambia', flag: '🇿🇲' }, + ZW: { name: 'Zimbabwe', flag: '🇿🇼' }, +}; + +/** Get country info from an ISO 3166 code (country or subdivision). */ +export function getCountryInfo(code: string): { name: string; flag: string; subdivision?: string } | null { + const upper = code.toUpperCase(); + + // Handle subdivision codes like "US-CA" + if (upper.includes('-')) { + const [countryCode] = upper.split('-'); + const country = COUNTRIES[countryCode]; + if (!country) return null; + return { + name: country.name, + flag: country.flag, + subdivision: upper, + }; + } + + const country = COUNTRIES[upper]; + if (!country) return null; + return { name: country.name, flag: country.flag }; +} diff --git a/src/pages/ExternalContentPage.tsx b/src/pages/ExternalContentPage.tsx new file mode 100644 index 00000000..ecda495e --- /dev/null +++ b/src/pages/ExternalContentPage.tsx @@ -0,0 +1,413 @@ +import { useMemo } from 'react'; +import { useSeoMeta } from '@unhead/react'; +import { ArrowLeft, BookOpen, ExternalLink, Globe, MapPin } from 'lucide-react'; +import { Link, useParams } from 'react-router-dom'; +import { Skeleton } from '@/components/ui/skeleton'; +import { CommentsSection } from '@/components/comments/CommentsSection'; +import { ExternalFavicon } from '@/components/ExternalFavicon'; +import { useLinkPreview } from '@/hooks/useLinkPreview'; +import { useBookInfo } from '@/hooks/useBookInfo'; +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 }; +} + +/** 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 { data, isLoading } = useLinkPreview(url); + + const domain = useMemo(() => { + try { + return new URL(url).hostname.replace(/^www\./, ''); + } catch { + return url; + } + }, [url]); + + if (isLoading) { + return ( +
+ +
+ + + +
+
+ ); + } + + const image = data?.thumbnail_url; + const title = data?.title; + const author = data?.author_name; + const providerName = data?.provider_name || domain; + + return ( + + {image && ( +
+ { + (e.currentTarget.parentElement as HTMLElement).style.display = 'none'; + }} + /> +
+ )} + +
+
+ + {providerName} + +
+ + {title && ( +

+ {title} +

+ )} + + {!title && ( +

+ {url} +

+ )} + + {author && ( +

+ by {author} +

+ )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// 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 ( +
+
+ +
+ + + + +
+ + +
+
+
+
+ ); + } + + 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 ( +
+
+
+ {/* Book cover */} + {coverUrl ? ( +
+ {book?.title { + (e.currentTarget as HTMLElement).style.display = 'none'; + }} + /> +
+ ) : ( +
+ +
+ )} + + {/* Book details */} +
+
+ + ISBN {displayIsbn} +
+ +

+ {book?.title || 'Unknown Book'} +

+ + {authors && ( +

+ by {authors} +

+ )} + +
+ {book?.publish_date && ( + {book.publish_date} + )} + {publishers && ( + {publishers} + )} + {book?.number_of_pages && ( + {book.number_of_pages} pages + )} +
+ + {book?.subjects && book.subjects.length > 0 && ( +
+ {book.subjects.map((s) => ( + + {s.name} + + ))} +
+ )} +
+
+
+ + {/* OpenLibrary link */} + +
+ ); +} + +// --------------------------------------------------------------------------- +// Country content header +// --------------------------------------------------------------------------- + +function CountryContentHeader({ code }: { code: string }) { + const info = getCountryInfo(code); + + if (!info) { + return ( +
+ +

Unknown country code: {code}

+
+ ); + } + + return ( +
+
+
+ + {info.flag} + +
+
+ + ISO 3166 {info.subdivision ? `(${info.subdivision})` : `(${code.toUpperCase()})`} +
+

+ {info.name} +

+ {info.subdivision && ( +

+ Subdivision: {info.subdivision} +

+ )} +
+
+
+ + +
+ ); +} + +// --------------------------------------------------------------------------- +// Page header label +// --------------------------------------------------------------------------- + +function headerLabel(content: ExternalContent): string { + switch (content.type) { + case 'url': + 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 +// --------------------------------------------------------------------------- + +export function ExternalContentPage() { + const { '*': rawUri } = useParams(); + const uri = rawUri ? decodeURIComponent(rawUri) : ''; + + const content = useMemo(() => { + if (!uri) return null; + return parseExternalUri(uri); + }, [uri]); + + useSeoMeta({ title: content ? seoTitle(content) : 'External Content | Ditto' }); + + if (!content || !uri) { + return ; + } + + // Build the NIP-73 identifier for comments. + // For URLs, the raw URL is used. For others, the full prefixed identifier. + const commentRoot = useMemo(() => { + return new URL(content.value); + }, [content.value]); + + return ( +
+ {/* Sticky header */} +
+ + + +

{headerLabel(content)}

+
+ +
+ {/* Content-specific header */} + {content.type === 'url' && } + {content.type === 'isbn' && } + {content.type === 'iso3166' && } + {content.type === 'unknown' && ( +
+ +

{content.value}

+
+ )} + + {/* Comments section */} + +
+
+ ); +}