14 changed files with 1273 additions and 74 deletions
@ -0,0 +1,218 @@
@@ -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 @@
@@ -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