You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

226 lines
7.3 KiB

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 PublicationCoverFallback from './PublicationCoverFallback'
import PublicationCoverImage from './PublicationCoverImage'
import PublicationBooklistButton from './PublicationBooklistButton'
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() ? (
<PublicationCoverImage
imageUrl={metadata.image.trim()}
pubkey={event.pubkey}
autoLoadMedia={autoLoadMedia}
size="default"
layout="stacked"
className="mb-0 w-fit max-w-xl"
/>
) : isFull ? (
<PublicationCoverFallback layout="stacked" size="default" className="w-fit max-w-xl" />
) : 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 || tagsComponent || isFull ? (
<div className="flex min-w-0 flex-col gap-2">
{metadata.source ? (
<a
href={metadata.source}
target="_blank"
rel="noopener noreferrer"
className={cn(
'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 ? <PublicationBooklistButton event={event} className="w-fit self-start" /> : null}
</div>
) : null}
{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>
)
}