Add NIP-73 external content page at /i/:uri with URL, Book, and Country support
This commit is contained in:
@@ -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() {
|
||||
<Route path="/decks" element={<KindFeedPage kind={37381} title="Magic Decks" icon={<CardsIcon className="size-5" />} />} />
|
||||
<Route path="/themes" element={<ThemesPage />} />
|
||||
<Route path="/bookmarks" element={<BookmarksPage />} />
|
||||
<Route path="/i/*" element={<ExternalContentPage />} />
|
||||
|
||||
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}
|
||||
<Route path="/:nip19" element={<NIP19Page />} />
|
||||
|
||||
@@ -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<typeof OpenLibraryBookSchema>;
|
||||
|
||||
async function fetchBookInfo(isbn: string, signal?: AbortSignal): Promise<BookInfo | null> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/** ISO 3166-1 alpha-2 country code to country name and flag emoji mapping. */
|
||||
export const COUNTRIES: Record<string, { name: string; flag: string }> = {
|
||||
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 };
|
||||
}
|
||||
@@ -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 (
|
||||
<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 image = data?.thumbnail_url;
|
||||
const title = data?.title;
|
||||
const author = data?.author_name;
|
||||
const providerName = data?.provider_name || domain;
|
||||
|
||||
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':
|
||||
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 <NotFound />;
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<main className="min-h-screen">
|
||||
{/* Sticky header */}
|
||||
<div className={cn(STICKY_HEADER_CLASS, 'flex items-center gap-4 px-4 mt-4 mb-5 bg-background/80 backdrop-blur-md z-10')}>
|
||||
<Link to="/" className="p-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold truncate">{headerLabel(content)}</h1>
|
||||
</div>
|
||||
|
||||
<div className="px-4 space-y-6 pb-8">
|
||||
{/* Content-specific header */}
|
||||
{content.type === 'url' && <UrlContentHeader url={content.value} />}
|
||||
{content.type === 'isbn' && <BookContentHeader isbn={content.value} />}
|
||||
{content.type === 'iso3166' && <CountryContentHeader code={content.code} />}
|
||||
{content.type === 'unknown' && (
|
||||
<div className="rounded-2xl border border-border p-5 text-center">
|
||||
<Globe className="size-8 mx-auto mb-2 text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground break-all">{content.value}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comments section */}
|
||||
<CommentsSection
|
||||
root={commentRoot}
|
||||
title="Discussion"
|
||||
emptyStateMessage="No comments yet"
|
||||
emptyStateSubtitle="Be the first to share your thoughts about this!"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user