14 changed files with 1273 additions and 74 deletions
@ -0,0 +1,218 @@ |
|||||||
|
import { ExtendedKind } from '@/constants' |
||||||
|
import { |
||||||
|
getPublicationIndexMetadataFromEvent, |
||||||
|
type PublicationAuthor |
||||||
|
} from '@/lib/event-metadata' |
||||||
|
import { toNoteList } from '@/lib/link' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { useSecondaryPageOptional } from '@/PageManager' |
||||||
|
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia' |
||||||
|
import { BookOpen, ExternalLink } from 'lucide-react' |
||||||
|
import { Event, kinds } from 'nostr-tools' |
||||||
|
import { useMemo } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import Image from '../Image' |
||||||
|
|
||||||
|
function formatAuthorLine(authors: PublicationAuthor[]): string { |
||||||
|
if (authors.length === 0) return '' |
||||||
|
return authors |
||||||
|
.map(({ name, role }) => { |
||||||
|
const normalizedRole = role?.trim().toLowerCase() |
||||||
|
if (!normalizedRole || normalizedRole === 'author') return name |
||||||
|
return `${name} (${role})` |
||||||
|
}) |
||||||
|
.join(' · ') |
||||||
|
} |
||||||
|
|
||||||
|
function formatPublicationType(type: string): string { |
||||||
|
return type |
||||||
|
.split(/[\s_-]+/) |
||||||
|
.filter(Boolean) |
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) |
||||||
|
.join(' ') |
||||||
|
} |
||||||
|
|
||||||
|
function sourceHostname(source: string): string { |
||||||
|
try { |
||||||
|
return new URL(source).hostname.replace(/^www\./, '') |
||||||
|
} catch { |
||||||
|
return source |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function MetaChip({ children, className }: { children: React.ReactNode; className?: string }) { |
||||||
|
return ( |
||||||
|
<span |
||||||
|
className={cn( |
||||||
|
'inline-flex items-center rounded-full bg-muted px-2.5 py-0.5 text-xs text-muted-foreground', |
||||||
|
className |
||||||
|
)} |
||||||
|
> |
||||||
|
{children} |
||||||
|
</span> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export default function PublicationIndexMetadata({ |
||||||
|
event, |
||||||
|
variant = 'compact', |
||||||
|
showTitle = true, |
||||||
|
className |
||||||
|
}: { |
||||||
|
event: Event |
||||||
|
variant?: 'compact' | 'full' |
||||||
|
showTitle?: boolean |
||||||
|
className?: string |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const secondaryPage = useSecondaryPageOptional() |
||||||
|
const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url }) |
||||||
|
const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey, event) |
||||||
|
const metadata = useMemo(() => getPublicationIndexMetadataFromEvent(event), [event]) |
||||||
|
|
||||||
|
if (event.kind !== ExtendedKind.PUBLICATION) return null |
||||||
|
|
||||||
|
const authorLine = formatAuthorLine(metadata.authors) |
||||||
|
const isFull = variant === 'full' |
||||||
|
const title = |
||||||
|
metadata.title?.trim() || |
||||||
|
event.tags.find((tag) => tag[0] === 'd')?.[1]?.replace(/-/g, ' ') || |
||||||
|
t('Publication Note') |
||||||
|
|
||||||
|
const metaChips: React.ReactNode[] = [] |
||||||
|
if (metadata.type) { |
||||||
|
metaChips.push(<MetaChip key="type">{formatPublicationType(metadata.type)}</MetaChip>) |
||||||
|
} |
||||||
|
if (metadata.language) { |
||||||
|
metaChips.push(<MetaChip key="lang">{metadata.language.toUpperCase()}</MetaChip>) |
||||||
|
} |
||||||
|
if (metadata.version) { |
||||||
|
metaChips.push(<MetaChip key="version">{t('Publication version', { version: metadata.version })}</MetaChip>) |
||||||
|
} |
||||||
|
if (metadata.sectionCount > 0) { |
||||||
|
metaChips.push( |
||||||
|
<MetaChip key="sections"> |
||||||
|
{t('Publication sections', { count: metadata.sectionCount })} |
||||||
|
</MetaChip> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
const tagsComponent = |
||||||
|
metadata.tags.length > 0 ? ( |
||||||
|
<div className="flex min-w-0 flex-wrap gap-1"> |
||||||
|
{metadata.tags.map((tag) => ( |
||||||
|
<button |
||||||
|
key={tag} |
||||||
|
type="button" |
||||||
|
className="inline-flex max-w-full min-w-0 items-center gap-0.5 rounded-full bg-muted px-2.5 py-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground" |
||||||
|
onClick={(e) => { |
||||||
|
e.stopPropagation() |
||||||
|
push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] })) |
||||||
|
}} |
||||||
|
> |
||||||
|
<span className="shrink-0">#</span> |
||||||
|
<span className="min-w-0 truncate">{tag}</span> |
||||||
|
</button> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
) : null |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={cn('min-w-0 space-y-2', isFull && 'space-y-4', className)}> |
||||||
|
{isFull && metadata.image?.trim() ? ( |
||||||
|
<Image |
||||||
|
image={{ url: metadata.image.trim(), pubkey: event.pubkey }} |
||||||
|
className="aspect-[16/10] w-full max-w-xl rounded-lg bg-foreground object-cover" |
||||||
|
hideIfError |
||||||
|
holdUntilClick={!autoLoadMedia} |
||||||
|
/> |
||||||
|
) : isFull ? ( |
||||||
|
<div className="flex aspect-[16/10] w-full max-w-xl items-center justify-center rounded-lg bg-muted text-muted-foreground"> |
||||||
|
<BookOpen className="size-14" aria-hidden /> |
||||||
|
</div> |
||||||
|
) : null} |
||||||
|
|
||||||
|
{showTitle ? ( |
||||||
|
<div |
||||||
|
className={cn( |
||||||
|
'min-w-0 font-semibold break-words text-foreground', |
||||||
|
isFull ? 'text-2xl leading-tight sm:text-3xl' : 'text-xl sm:line-clamp-2' |
||||||
|
)} |
||||||
|
> |
||||||
|
{title} |
||||||
|
</div> |
||||||
|
) : null} |
||||||
|
|
||||||
|
{authorLine ? ( |
||||||
|
<div |
||||||
|
className={cn( |
||||||
|
'min-w-0 break-words text-muted-foreground', |
||||||
|
isFull ? 'text-base sm:text-lg' : 'text-sm line-clamp-2' |
||||||
|
)} |
||||||
|
> |
||||||
|
{authorLine} |
||||||
|
</div> |
||||||
|
) : null} |
||||||
|
|
||||||
|
{metaChips.length > 0 ? ( |
||||||
|
<div className="flex min-w-0 flex-wrap gap-1.5">{metaChips}</div> |
||||||
|
) : null} |
||||||
|
|
||||||
|
{metadata.releaseDate ? ( |
||||||
|
<div className={cn('text-muted-foreground', isFull ? 'text-sm' : 'text-xs')}> |
||||||
|
{t('Publication released', { date: metadata.releaseDate })} |
||||||
|
</div> |
||||||
|
) : null} |
||||||
|
|
||||||
|
{metadata.summary ? ( |
||||||
|
<div |
||||||
|
className={cn( |
||||||
|
'min-w-0 break-words text-muted-foreground', |
||||||
|
isFull ? 'text-base leading-relaxed' : 'text-sm line-clamp-3' |
||||||
|
)} |
||||||
|
> |
||||||
|
{metadata.summary} |
||||||
|
</div> |
||||||
|
) : null} |
||||||
|
|
||||||
|
{metadata.source ? ( |
||||||
|
<a |
||||||
|
href={metadata.source} |
||||||
|
target="_blank" |
||||||
|
rel="noopener noreferrer" |
||||||
|
className={cn( |
||||||
|
'inline-flex min-w-0 max-w-full items-center gap-1.5 text-primary hover:underline', |
||||||
|
isFull ? 'text-sm' : 'text-xs' |
||||||
|
)} |
||||||
|
onClick={(e) => e.stopPropagation()} |
||||||
|
> |
||||||
|
<ExternalLink className="size-3.5 shrink-0" aria-hidden /> |
||||||
|
<span className="truncate">{sourceHostname(metadata.source)}</span> |
||||||
|
</a> |
||||||
|
) : null} |
||||||
|
|
||||||
|
{tagsComponent} |
||||||
|
|
||||||
|
{isFull && metadata.sections.length > 0 ? ( |
||||||
|
<div className="rounded-lg border border-border bg-muted/20 p-3"> |
||||||
|
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-foreground"> |
||||||
|
<BookOpen className="size-4 shrink-0 text-muted-foreground" aria-hidden /> |
||||||
|
{t('Publication table of contents')} |
||||||
|
</div> |
||||||
|
<ol className="max-h-64 space-y-1 overflow-y-auto text-sm text-muted-foreground"> |
||||||
|
{metadata.sections.map((section, index) => ( |
||||||
|
<li key={`${section.coordinate}-${index}`} className="flex min-w-0 gap-2"> |
||||||
|
<span className="shrink-0 tabular-nums text-muted-foreground/80">{index + 1}.</span> |
||||||
|
<span className="min-w-0 break-words"> |
||||||
|
{section.label || |
||||||
|
section.coordinate.split(':').pop()?.replace(/-/g, ' ') || |
||||||
|
section.coordinate} |
||||||
|
</span> |
||||||
|
</li> |
||||||
|
))} |
||||||
|
</ol> |
||||||
|
</div> |
||||||
|
) : null} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,61 @@ |
|||||||
|
import { describe, expect, it } from 'vitest' |
||||||
|
import { ExtendedKind } from '@/constants' |
||||||
|
import { getPublicationIndexMetadataFromEvent } from '@/lib/event-metadata' |
||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
|
||||||
|
const PK = 'a'.repeat(64) |
||||||
|
|
||||||
|
function indexEvent(tags: string[][]): Event { |
||||||
|
return { |
||||||
|
id: '1'.repeat(64), |
||||||
|
kind: ExtendedKind.PUBLICATION, |
||||||
|
pubkey: PK, |
||||||
|
created_at: 100, |
||||||
|
content: '', |
||||||
|
tags, |
||||||
|
sig: 'c'.repeat(128) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
describe('getPublicationIndexMetadataFromEvent', () => { |
||||||
|
it('extracts NKBIP-01 index tags', () => { |
||||||
|
const event = indexEvent([ |
||||||
|
['d', 'little-clay-cart'], |
||||||
|
['title', 'The Little Clay Cart'], |
||||||
|
['author', 'Sudraka', 'author'], |
||||||
|
['author', 'Arthur W. Ryder', 'translator'], |
||||||
|
['source', 'https://www.gutenberg.org/ebooks/21020'], |
||||||
|
['l', 'en', 'ISO-639-1'], |
||||||
|
['release_date', 'April 10, 2007'], |
||||||
|
['type', 'book'], |
||||||
|
['version', '1.0'], |
||||||
|
['summary', 'A classic Sanskrit play.'], |
||||||
|
['a', `30041:${PK}:chapter-1`, 'wss://relay.example', 'Chapter One'], |
||||||
|
['a', `30041:${PK}:chapter-2`] |
||||||
|
]) |
||||||
|
|
||||||
|
const meta = getPublicationIndexMetadataFromEvent(event) |
||||||
|
|
||||||
|
expect(meta.title).toBe('The Little Clay Cart') |
||||||
|
expect(meta.authors).toEqual([ |
||||||
|
{ name: 'Sudraka', role: 'author' }, |
||||||
|
{ name: 'Arthur W. Ryder', role: 'translator' } |
||||||
|
]) |
||||||
|
expect(meta.source).toBe('https://www.gutenberg.org/ebooks/21020') |
||||||
|
expect(meta.language).toBe('en') |
||||||
|
expect(meta.releaseDate).toBe('April 10, 2007') |
||||||
|
expect(meta.type).toBe('book') |
||||||
|
expect(meta.version).toBe('1.0') |
||||||
|
expect(meta.summary).toBe('A classic Sanskrit play.') |
||||||
|
expect(meta.sectionCount).toBe(2) |
||||||
|
expect(meta.sections[0].label).toBe('Chapter One') |
||||||
|
expect(meta.sections[1].label).toBeUndefined() |
||||||
|
}) |
||||||
|
|
||||||
|
it('falls back to d-tag title casing', () => { |
||||||
|
const event = indexEvent([['d', 'village-life-in-china'], ['a', `30041:${PK}:intro`]]) |
||||||
|
const meta = getPublicationIndexMetadataFromEvent(event) |
||||||
|
expect(meta.title).toBe('Village Life In China') |
||||||
|
expect(meta.sectionCount).toBe(1) |
||||||
|
}) |
||||||
|
}) |
||||||
Loading…
Reference in new issue