8 changed files with 193 additions and 39 deletions
@ -1,22 +0,0 @@
@@ -1,22 +0,0 @@
|
||||
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' |
||||
import { cn } from '@/lib/utils' |
||||
import { Event } from 'nostr-tools' |
||||
import { useMemo } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
export default function LongFormArticlePreview({ |
||||
event, |
||||
className |
||||
}: { |
||||
event: Event |
||||
className?: string |
||||
}) { |
||||
const { t } = useTranslation() |
||||
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) |
||||
|
||||
return ( |
||||
<div className={cn('pointer-events-none', className)}> |
||||
[{t('Article')}] <span className="italic pr-0.5">{metadata.title}</span> |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,136 @@
@@ -0,0 +1,136 @@
|
||||
import { cardEventBodyBlurb } from '@/lib/card-event-body-blurb' |
||||
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' |
||||
import { toNote, toNoteList } from '@/lib/link' |
||||
import { useSecondaryPageOptional } from '@/PageManager' |
||||
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' |
||||
import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider' |
||||
import { cn } from '@/lib/utils' |
||||
import { Event, kinds } from 'nostr-tools' |
||||
import { useMemo } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import Image from '../Image' |
||||
|
||||
/** |
||||
* Feed / embed / preview surface for NIP-23 long-form (kind 30023): title, summary, image, tags — no “Show more” body. |
||||
* Full article stays on the note page ({@link showFull} on {@link Note}). |
||||
*/ |
||||
export default function LongFormCard({ |
||||
event, |
||||
className, |
||||
/** When false (e.g. parent-reply preview strip), card is non-interactive like the old one-line preview. */ |
||||
interactive = true |
||||
}: { |
||||
event: Event |
||||
className?: string |
||||
interactive?: boolean |
||||
}) { |
||||
const { t } = useTranslation() |
||||
const screenSize = useScreenSizeOptional() |
||||
const isSmallScreen = screenSize?.isSmallScreen ?? false |
||||
const secondaryPage = useSecondaryPageOptional() |
||||
const push = secondaryPage?.push ?? ((url: string) => { |
||||
window.location.href = url |
||||
}) |
||||
const contentPolicy = useContentPolicyOptional() |
||||
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true |
||||
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) |
||||
const bodyBlurb = useMemo(() => cardEventBodyBlurb(event.content), [event.content]) |
||||
const summaryText = (metadata.summary?.trim() || bodyBlurb).trim() |
||||
|
||||
const displayTitle = metadata.title?.trim() || t('Long-form Article') |
||||
|
||||
const handleCardClick = (e: React.MouseEvent) => { |
||||
if (!interactive) return |
||||
e.stopPropagation() |
||||
push(toNote(event.id)) |
||||
} |
||||
|
||||
const titleComponent = ( |
||||
<div className="min-w-0 text-xl font-semibold break-words sm:line-clamp-2">{displayTitle}</div> |
||||
) |
||||
|
||||
const tagsComponent = interactive && metadata.tags.length > 0 && ( |
||||
<div className="flex flex-wrap gap-1"> |
||||
{metadata.tags.map((tag) => ( |
||||
<div |
||||
key={tag} |
||||
className="flex max-w-32 cursor-pointer items-center 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="truncate">{tag}</span> |
||||
</div> |
||||
))} |
||||
</div> |
||||
) |
||||
|
||||
const tagsReadonly = !interactive && metadata.tags.length > 0 && ( |
||||
<div className="flex flex-wrap gap-1"> |
||||
{metadata.tags.map((tag) => ( |
||||
<span |
||||
key={tag} |
||||
className="flex max-w-32 items-center rounded-full bg-muted px-2.5 py-0.5 text-xs text-muted-foreground" |
||||
> |
||||
#<span className="truncate">{tag}</span> |
||||
</span> |
||||
))} |
||||
</div> |
||||
) |
||||
|
||||
const summaryComponent = summaryText ? ( |
||||
<div className="text-base text-muted-foreground line-clamp-4 break-words">{summaryText}</div> |
||||
) : null |
||||
|
||||
const shellClass = cn(className, !interactive && 'pointer-events-none') |
||||
const cardClass = cn( |
||||
'rounded-lg border p-4 transition-colors', |
||||
interactive && 'cursor-pointer hover:bg-muted/50' |
||||
) |
||||
|
||||
if (isSmallScreen) { |
||||
return ( |
||||
<div className={shellClass}> |
||||
<div className={cardClass} onClick={interactive ? handleCardClick : undefined}> |
||||
{metadata.image && autoLoadMedia && ( |
||||
<Image |
||||
image={{ url: metadata.image, pubkey: event.pubkey }} |
||||
className="mb-3 aspect-video w-full max-w-[400px]" |
||||
hideIfError |
||||
/> |
||||
)} |
||||
<div className="space-y-2"> |
||||
{titleComponent} |
||||
{summaryComponent} |
||||
{tagsComponent} |
||||
{tagsReadonly} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<div className={cn('w-full min-w-0', shellClass)}> |
||||
<div className={cn(cardClass, 'min-w-0')} onClick={interactive ? handleCardClick : undefined}> |
||||
<div className="flex min-w-0 gap-4"> |
||||
{metadata.image && autoLoadMedia && ( |
||||
<Image |
||||
image={{ url: metadata.image, pubkey: event.pubkey }} |
||||
classNames={{ wrapper: 'w-auto max-w-[400px] shrink-0' }} |
||||
className="h-44 max-w-[400px] shrink rounded-lg bg-foreground object-cover aspect-[4/3] xl:aspect-video" |
||||
hideIfError |
||||
/> |
||||
)} |
||||
<div className="min-w-0 flex-1 basis-0 space-y-2 overflow-hidden"> |
||||
{titleComponent} |
||||
{summaryComponent} |
||||
{tagsComponent} |
||||
{tagsReadonly} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
|
||||
/** Max length for card blurb when the `summary` tag is absent (article-style events). */ |
||||
export const CARD_EVENT_BODY_BLURB_MAX = 250 |
||||
|
||||
/** |
||||
* First {@link CARD_EVENT_BODY_BLURB_MAX} characters of event `content`, stripped of common |
||||
* markdown / lightweight HTML so feed cards can show a readable teaser without a `summary` tag. |
||||
*/ |
||||
export function cardEventBodyBlurb(raw: string | undefined, max = CARD_EVENT_BODY_BLURB_MAX): string { |
||||
if (raw == null) return '' |
||||
let s = raw.trim() |
||||
if (!s) return '' |
||||
|
||||
s = s.replace(/```[\s\S]*?```/g, ' ') |
||||
s = s.replace(/`[^`]+`/g, ' ') |
||||
s = s.replace(/<[^>]+>/g, ' ') |
||||
s = s.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') |
||||
s = s.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') |
||||
s = s.replace(/^#{1,6}\s+/gm, '') |
||||
s = s.replace(/\*\*|__/g, '') |
||||
s = s.replace(/~~/g, '') |
||||
s = s.replace(/\*|_/g, ' ') |
||||
s = s.replace(/\s+/g, ' ').trim() |
||||
if (!s) return '' |
||||
if (s.length <= max) return s |
||||
return `${s.slice(0, max).trimEnd()}…` |
||||
} |
||||
Loading…
Reference in new issue