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
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> |
|
) |
|
}
|
|
|