Forum: - TipTap WYSIWYG editor with full toolbar - @emoji-mart/react emoji picker (consistent with tweets) - @mention autocomplete with user search API - Fix PHP 8.4 parse errors in Blade templates - Fix thread data display (paginator items) - Align forum page widths to max-w-5xl Discover: - Extract shared _nav.blade.php partial - Add missing nav links to for-you page - Add Following link for authenticated users Feed/Posts: - Post model, controllers, policies, migrations - Feed page components (PostComposer, FeedCard, etc) - Post reactions, comments, saves, reports, sharing - Scheduled publishing support - Link preview controller Profile: - Profile page components (ProfileHero, ProfileTabs) - Profile API controller Uploads: - Upload wizard enhancements - Scheduled publish picker - Studio status bar and readiness checklist
117 lines
4.3 KiB
JavaScript
117 lines
4.3 KiB
JavaScript
import React from 'react'
|
|
|
|
export default function ThreadRow({ thread, isFirst = false }) {
|
|
const id = thread?.topic_id ?? thread?.id ?? 0
|
|
const title = thread?.topic ?? thread?.title ?? 'Untitled'
|
|
const slug = thread?.slug ?? slugify(title)
|
|
const excerpt = thread?.discuss ?? ''
|
|
const posts = thread?.num_posts ?? 0
|
|
const author = thread?.uname ?? 'Unknown'
|
|
const lastUpdate = thread?.last_update ?? thread?.post_date
|
|
const isPinned = thread?.is_pinned ?? false
|
|
|
|
const href = `/forum/thread/${id}-${slug}`
|
|
|
|
return (
|
|
<a
|
|
href={href}
|
|
className={[
|
|
'group flex items-start gap-4 px-5 py-4 transition-colors hover:bg-white/[0.03]',
|
|
!isFirst && 'border-t border-white/[0.06]',
|
|
].filter(Boolean).join(' ')}
|
|
>
|
|
{/* Icon */}
|
|
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-sky-500/10 text-sky-400 group-hover:bg-sky-500/15 transition-colors">
|
|
{isPinned ? (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2z" />
|
|
</svg>
|
|
) : (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
|
</svg>
|
|
)}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="truncate text-sm font-semibold text-white group-hover:text-sky-300 transition-colors">
|
|
{title}
|
|
</h3>
|
|
{isPinned && (
|
|
<span className="shrink-0 rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-300">
|
|
Pinned
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{excerpt && (
|
|
<p className="mt-0.5 truncate text-xs text-white/40">
|
|
{stripHtml(excerpt).slice(0, 180)}
|
|
</p>
|
|
)}
|
|
|
|
<div className="mt-1.5 flex flex-wrap items-center gap-3 text-xs text-white/35">
|
|
<span className="flex items-center gap-1">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
|
|
<circle cx="12" cy="7" r="4" />
|
|
</svg>
|
|
{author}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
|
</svg>
|
|
{posts} {posts === 1 ? 'reply' : 'replies'}
|
|
</span>
|
|
{lastUpdate && (
|
|
<span className="flex items-center gap-1">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="12" cy="12" r="10" />
|
|
<polyline points="12 6 12 12 16 14" />
|
|
</svg>
|
|
{formatDate(lastUpdate)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Reply count badge */}
|
|
<div className="mt-1 shrink-0">
|
|
<span className="inline-flex min-w-[2rem] items-center justify-center rounded-full bg-white/[0.06] px-2.5 py-1 text-xs font-medium text-white/60">
|
|
{posts}
|
|
</span>
|
|
</div>
|
|
</a>
|
|
)
|
|
}
|
|
|
|
function slugify(text) {
|
|
return (text ?? '')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/(^-|-$)/g, '')
|
|
.slice(0, 80)
|
|
}
|
|
|
|
function stripHtml(html) {
|
|
if (typeof document !== 'undefined') {
|
|
const div = document.createElement('div')
|
|
div.innerHTML = html
|
|
return div.textContent || div.innerText || ''
|
|
}
|
|
return html.replace(/<[^>]*>/g, '')
|
|
}
|
|
|
|
function formatDate(dateStr) {
|
|
try {
|
|
const d = new Date(dateStr)
|
|
return d.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
|
+ ' ' + d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
|
} catch {
|
|
return '-'
|
|
}
|
|
}
|