feat: forum rich-text editor, emoji picker, mentions, discover nav, feed, uploads, profile
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
This commit is contained in:
116
resources/js/components/forum/ThreadRow.jsx
Normal file
116
resources/js/components/forum/ThreadRow.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
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 '-'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user