- Infinite loop carousels for Similar Artworks & Trending rails
- Mouse wheel horizontal scrolling on both carousels
- Author avatar shown on hover in RailCard (similar + trending)
- Removed "View" badge from RailCard hover overlay
- Added `id` to Meilisearch filterable attributes
- Auto-prepend Scout prefix in meilisearch:configure-index command
- Added author name + avatar to Similar Artworks API response
- Added avatar_url to ArtworkListResource author object
- Added direct /art/{id}/{slug} URL to ArtworkListResource
- Fixed race condition: Similar Artworks no longer briefly shows trending items
- Fixed user_profiles eager load (user_id primary key, not id)
- Bumped /api/art/{id}/similar rate limit to 300/min
- Removed decorative heart icons from tag pills
- Moved ReactionBar under artwork description
78 lines
2.1 KiB
JavaScript
78 lines
2.1 KiB
JavaScript
import React, { useMemo, useState } from 'react'
|
|
|
|
const COLLAPSE_AT = 560
|
|
|
|
function renderMarkdownSafe(text) {
|
|
const lines = text.split(/\n{2,}/)
|
|
|
|
return lines.map((line, lineIndex) => {
|
|
const parts = []
|
|
let rest = line
|
|
let key = 0
|
|
const linkPattern = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g
|
|
|
|
let match = linkPattern.exec(rest)
|
|
let lastIndex = 0
|
|
while (match) {
|
|
if (match.index > lastIndex) {
|
|
parts.push(<span key={`txt-${lineIndex}-${key++}`}>{rest.slice(lastIndex, match.index)}</span>)
|
|
}
|
|
|
|
parts.push(
|
|
<a
|
|
key={`lnk-${lineIndex}-${key++}`}
|
|
href={match[2]}
|
|
target="_blank"
|
|
rel="noopener noreferrer nofollow"
|
|
className="text-accent hover:underline"
|
|
>
|
|
{match[1]}
|
|
</a>,
|
|
)
|
|
|
|
lastIndex = match.index + match[0].length
|
|
match = linkPattern.exec(rest)
|
|
}
|
|
|
|
if (lastIndex < rest.length) {
|
|
parts.push(<span key={`txt-${lineIndex}-${key++}`}>{rest.slice(lastIndex)}</span>)
|
|
}
|
|
|
|
return (
|
|
<p key={`p-${lineIndex}`} className="text-sm leading-7 text-white/50">
|
|
{parts}
|
|
</p>
|
|
)
|
|
})
|
|
}
|
|
|
|
export default function ArtworkDescription({ artwork }) {
|
|
const [expanded, setExpanded] = useState(false)
|
|
const content = (artwork?.description || '').trim()
|
|
const collapsed = content.length > COLLAPSE_AT && !expanded
|
|
const visibleText = collapsed ? `${content.slice(0, COLLAPSE_AT)}…` : content
|
|
// useMemo must always be called (Rules of Hooks) — guard inside the callback
|
|
const rendered = useMemo(
|
|
() => (content.length > 0 ? renderMarkdownSafe(visibleText) : null),
|
|
[content, visibleText],
|
|
)
|
|
|
|
if (content.length === 0) return null
|
|
|
|
return (
|
|
<div>
|
|
<div className="max-w-[720px] space-y-3 text-sm leading-7 text-white/50">{rendered}</div>
|
|
|
|
{content.length > COLLAPSE_AT && (
|
|
<button
|
|
type="button"
|
|
className="mt-3 text-sm font-medium text-accent transition-colors hover:text-accent/80"
|
|
onClick={() => setExpanded((value) => !value)}
|
|
>
|
|
{expanded ? 'Show less' : 'Show more'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|