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:
41
resources/js/components/forum/AuthorBadge.jsx
Normal file
41
resources/js/components/forum/AuthorBadge.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react'
|
||||
|
||||
const ROLE_STYLES = {
|
||||
admin: 'bg-red-500/15 text-red-300',
|
||||
moderator: 'bg-amber-500/15 text-amber-300',
|
||||
member: 'bg-sky-500/15 text-sky-300',
|
||||
}
|
||||
|
||||
const ROLE_LABELS = {
|
||||
admin: 'Admin',
|
||||
moderator: 'Moderator',
|
||||
member: 'Member',
|
||||
}
|
||||
|
||||
export default function AuthorBadge({ user, size = 'md' }) {
|
||||
const name = user?.name ?? 'Anonymous'
|
||||
const avatar = user?.avatar_url ?? '/default/avatar_default.webp'
|
||||
const role = (user?.role ?? 'member').toLowerCase()
|
||||
const cls = ROLE_STYLES[role] ?? ROLE_STYLES.member
|
||||
const label = ROLE_LABELS[role] ?? 'Member'
|
||||
|
||||
const imgSize = size === 'sm' ? 'h-8 w-8' : 'h-10 w-10'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={avatar}
|
||||
alt={`${name} avatar`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className={`${imgSize} rounded-full border border-white/10 object-cover`}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-zinc-100">{name}</div>
|
||||
<span className={`inline-flex rounded-full px-2 py-0.5 text-[11px] font-medium ${cls}`}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
30
resources/js/components/forum/Breadcrumbs.jsx
Normal file
30
resources/js/components/forum/Breadcrumbs.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function Breadcrumbs({ items = [] }) {
|
||||
return (
|
||||
<nav className="text-sm text-zinc-400" aria-label="Breadcrumb">
|
||||
<ol className="flex flex-wrap items-center gap-1.5">
|
||||
{items.map((item, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && (
|
||||
<li aria-hidden="true" className="text-zinc-600">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="inline-block">
|
||||
<path d="M6 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
{item.href ? (
|
||||
<a href={item.href} className="hover:text-zinc-200 transition-colors">
|
||||
{item.label}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-zinc-200">{item.label}</span>
|
||||
)}
|
||||
</li>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
85
resources/js/components/forum/CategoryCard.jsx
Normal file
85
resources/js/components/forum/CategoryCard.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function CategoryCard({ category }) {
|
||||
const name = category?.name ?? 'Untitled'
|
||||
const slug = category?.slug
|
||||
const threads = category?.thread_count ?? 0
|
||||
const posts = category?.post_count ?? 0
|
||||
const lastActivity = category?.last_activity_at
|
||||
const preview = category?.preview_image ?? '/images/forum-default.jpg'
|
||||
const href = slug ? `/forum/${slug}` : '#'
|
||||
|
||||
const timeAgo = lastActivity ? formatTimeAgo(lastActivity) : null
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="group relative block overflow-hidden rounded-2xl border border-white/[0.06] bg-nova-800/50 shadow-xl backdrop-blur transition-all duration-300 hover:border-cyan-400/20 hover:shadow-cyan-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400"
|
||||
>
|
||||
{/* Image */}
|
||||
<div className="relative aspect-[16/9]">
|
||||
<img
|
||||
src={preview}
|
||||
alt={`${name} preview`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-full w-full object-cover object-center transition-transform duration-500 group-hover:scale-[1.03]"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
|
||||
|
||||
{/* Overlay content */}
|
||||
<div className="absolute inset-x-0 bottom-0 p-5">
|
||||
<div className="mb-2 inline-flex h-8 w-8 items-center justify-center rounded-lg bg-cyan-400/15 text-cyan-300">
|
||||
<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>
|
||||
|
||||
<h3 className="text-lg font-bold text-white leading-snug">{name}</h3>
|
||||
|
||||
{timeAgo && (
|
||||
<p className="mt-1 text-xs text-white/50">
|
||||
Last activity: <span className="text-white/70">{timeAgo}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex items-center gap-4 text-sm">
|
||||
<span className="flex items-center gap-1.5 text-cyan-300">
|
||||
<svg width="14" height="14" 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>
|
||||
{number(posts)} posts
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-cyan-300/70">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
{number(threads)} topics
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function number(n) {
|
||||
return (n ?? 0).toLocaleString()
|
||||
}
|
||||
|
||||
function formatTimeAgo(dateStr) {
|
||||
try {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diff = Math.floor((now - date) / 1000)
|
||||
|
||||
if (diff < 60) return 'just now'
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`
|
||||
return date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
111
resources/js/components/forum/EmojiPicker.jsx
Normal file
111
resources/js/components/forum/EmojiPicker.jsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import data from '@emoji-mart/data'
|
||||
import Picker from '@emoji-mart/react'
|
||||
|
||||
/**
|
||||
* Emoji picker button for the forum rich-text editor.
|
||||
* Uses the same @emoji-mart/react picker as profile tweets / comments
|
||||
* so the UI is consistent across the whole site.
|
||||
*
|
||||
* The panel is rendered through a React portal so it escapes any
|
||||
* overflow-hidden containers (like the editor wrapper).
|
||||
*/
|
||||
export default function EmojiPicker({ onSelect, editor }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [panelStyle, setPanelStyle] = useState({})
|
||||
const panelRef = useRef(null)
|
||||
const buttonRef = useRef(null)
|
||||
|
||||
// Position the portal panel relative to the trigger button
|
||||
useEffect(() => {
|
||||
if (!open || !buttonRef.current) return
|
||||
const rect = buttonRef.current.getBoundingClientRect()
|
||||
const panelWidth = 352 // emoji-mart default width
|
||||
const panelHeight = 435 // approximate picker height
|
||||
|
||||
const spaceAbove = rect.top
|
||||
const openAbove = spaceAbove > panelHeight + 8
|
||||
|
||||
setPanelStyle({
|
||||
position: 'fixed',
|
||||
zIndex: 9999,
|
||||
left: Math.max(8, Math.min(rect.right - panelWidth, window.innerWidth - panelWidth - 8)),
|
||||
...(openAbove
|
||||
? { bottom: window.innerHeight - rect.top + 6 }
|
||||
: { top: rect.bottom + 6 }),
|
||||
})
|
||||
}, [open])
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e) => {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target) &&
|
||||
buttonRef.current && !buttonRef.current.contains(e.target)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e) => { if (e.key === 'Escape') setOpen(false) }
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [open])
|
||||
|
||||
const handleSelect = useCallback((emoji) => {
|
||||
const native = emoji.native ?? ''
|
||||
onSelect?.(native)
|
||||
if (editor) {
|
||||
editor.chain().focus().insertContent(native).run()
|
||||
}
|
||||
setOpen(false)
|
||||
}, [onSelect, editor])
|
||||
|
||||
const panel = open ? createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
style={panelStyle}
|
||||
className="rounded-xl shadow-2xl overflow-hidden"
|
||||
>
|
||||
<Picker
|
||||
data={data}
|
||||
onEmojiSelect={handleSelect}
|
||||
theme="dark"
|
||||
previewPosition="none"
|
||||
skinTonePosition="search"
|
||||
maxFrequentRows={2}
|
||||
perLine={9}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
title="Insert emoji"
|
||||
aria-label="Open emoji picker"
|
||||
aria-expanded={open}
|
||||
className={[
|
||||
'inline-flex h-8 w-8 items-center justify-center rounded-lg text-sm transition-colors',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400',
|
||||
open
|
||||
? 'bg-sky-600/25 text-sky-300'
|
||||
: 'text-zinc-400 hover:bg-white/[0.06] hover:text-zinc-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-[15px]">😊</span>
|
||||
</button>
|
||||
{panel}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
76
resources/js/components/forum/MentionList.jsx
Normal file
76
resources/js/components/forum/MentionList.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Dropdown list rendered by TipTap's mention suggestion.
|
||||
* Receives `items` (user objects) and keyboard nav commands via ref.
|
||||
*/
|
||||
const MentionList = forwardRef(function MentionList({ items, command }, ref) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
// Reset selection when items change
|
||||
useEffect(() => setSelectedIndex(0), [items])
|
||||
|
||||
// Expose keyboard handler to TipTap suggestion plugin
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }) => {
|
||||
if (event.key === 'ArrowUp') {
|
||||
setSelectedIndex((i) => (i + items.length - 1) % items.length)
|
||||
return true
|
||||
}
|
||||
if (event.key === 'ArrowDown') {
|
||||
setSelectedIndex((i) => (i + 1) % items.length)
|
||||
return true
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
selectItem(selectedIndex)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
}))
|
||||
|
||||
const selectItem = (index) => {
|
||||
const item = items[index]
|
||||
if (item) {
|
||||
command({ id: item.username, label: item.username })
|
||||
}
|
||||
}
|
||||
|
||||
if (!items.length) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/[0.08] bg-nova-800 p-3 shadow-xl backdrop-blur">
|
||||
<p className="text-xs text-zinc-500">No users found</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-[200px] overflow-hidden rounded-xl border border-white/[0.08] bg-nova-800 shadow-xl backdrop-blur">
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => selectItem(index)}
|
||||
className={[
|
||||
'flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors',
|
||||
index === selectedIndex
|
||||
? 'bg-sky-600/20 text-white'
|
||||
: 'text-zinc-300 hover:bg-white/[0.04]',
|
||||
].join(' ')}
|
||||
>
|
||||
<img
|
||||
src={item.avatar_url}
|
||||
alt=""
|
||||
className="h-6 w-6 rounded-full border border-white/10 object-cover"
|
||||
/>
|
||||
<span className="truncate font-medium">@{item.username}</span>
|
||||
{item.name && item.name !== item.username && (
|
||||
<span className="truncate text-xs text-zinc-500">{item.name}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default MentionList
|
||||
93
resources/js/components/forum/Pagination.jsx
Normal file
93
resources/js/components/forum/Pagination.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* Pagination control that mirrors Laravel's paginator shape.
|
||||
* Expects: { current_page, last_page, per_page, total, path } or links array.
|
||||
*/
|
||||
export default function Pagination({ meta, onPageChange }) {
|
||||
const current = meta?.current_page ?? 1
|
||||
const lastPage = meta?.last_page ?? 1
|
||||
|
||||
if (lastPage <= 1) return null
|
||||
|
||||
const pages = buildPages(current, lastPage)
|
||||
|
||||
const go = (page) => {
|
||||
if (page < 1 || page > lastPage || page === current) return
|
||||
if (onPageChange) {
|
||||
onPageChange(page)
|
||||
} else {
|
||||
// Fallback: navigate via URL
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('page', page)
|
||||
window.location.href = url.toString()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<nav aria-label="Pagination" className="flex items-center justify-center gap-1.5">
|
||||
{/* Prev */}
|
||||
<button
|
||||
onClick={() => go(current - 1)}
|
||||
disabled={current <= 1}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-white/10 text-zinc-400 transition-colors hover:border-white/20 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M10 4L6 8l4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{pages.map((p, i) =>
|
||||
p === '...' ? (
|
||||
<span key={`dots-${i}`} className="px-1 text-xs text-zinc-600">…</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => go(p)}
|
||||
aria-current={p === current ? 'page' : undefined}
|
||||
className={[
|
||||
'inline-flex h-9 min-w-[2.25rem] items-center justify-center rounded-xl text-sm font-medium transition-colors',
|
||||
p === current
|
||||
? 'bg-sky-600/20 text-sky-300 border border-sky-500/30'
|
||||
: 'border border-white/10 text-zinc-400 hover:border-white/20 hover:text-white',
|
||||
].join(' ')}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Next */}
|
||||
<button
|
||||
onClick={() => go(current + 1)}
|
||||
disabled={current >= lastPage}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-white/10 text-zinc-400 transition-colors hover:border-white/20 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M6 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
/** Build a compact page-number array with ellipsis. */
|
||||
function buildPages(current, last) {
|
||||
if (last <= 7) {
|
||||
return Array.from({ length: last }, (_, i) => i + 1)
|
||||
}
|
||||
|
||||
const pages = new Set([1, 2, current - 1, current, current + 1, last - 1, last])
|
||||
const sorted = [...pages].filter(p => p >= 1 && p <= last).sort((a, b) => a - b)
|
||||
|
||||
const result = []
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
if (i > 0 && sorted[i] - sorted[i - 1] > 1) {
|
||||
result.push('...')
|
||||
}
|
||||
result.push(sorted[i])
|
||||
}
|
||||
return result
|
||||
}
|
||||
192
resources/js/components/forum/PostCard.jsx
Normal file
192
resources/js/components/forum/PostCard.jsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import React, { useState } from 'react'
|
||||
import AuthorBadge from './AuthorBadge'
|
||||
|
||||
export default function PostCard({ post, thread, isOp = false, isAuthenticated = false, canModerate = false }) {
|
||||
const [reported, setReported] = useState(false)
|
||||
const [reporting, setReporting] = useState(false)
|
||||
|
||||
const author = post?.user
|
||||
const content = post?.rendered_content ?? post?.content ?? ''
|
||||
const postedAt = post?.created_at
|
||||
const editedAt = post?.edited_at
|
||||
const isEdited = post?.is_edited
|
||||
const postId = post?.id
|
||||
const threadId = thread?.id
|
||||
const threadSlug = thread?.slug
|
||||
|
||||
const handleReport = async () => {
|
||||
if (reporting || reported) return
|
||||
setReporting(true)
|
||||
try {
|
||||
const res = await fetch(`/forum/post/${postId}/report`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrf(),
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
if (res.ok) setReported(true)
|
||||
} catch { /* silent */ }
|
||||
setReporting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
id={`post-${postId}`}
|
||||
className="overflow-hidden rounded-2xl border border-white/[0.06] bg-nova-800/50 backdrop-blur transition-all hover:border-white/10"
|
||||
>
|
||||
{/* Header */}
|
||||
<header className="flex flex-col gap-3 border-b border-white/[0.06] px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<AuthorBadge user={author} />
|
||||
<div className="flex items-center gap-2 text-xs text-zinc-500">
|
||||
{postedAt && (
|
||||
<time dateTime={postedAt}>
|
||||
{formatDate(postedAt)}
|
||||
</time>
|
||||
)}
|
||||
{isOp && (
|
||||
<span className="rounded-full bg-cyan-500/15 px-2.5 py-0.5 text-[11px] font-medium text-cyan-300">
|
||||
OP
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-5">
|
||||
<div
|
||||
className="prose prose-invert max-w-none text-sm leading-relaxed prose-pre:overflow-x-auto prose-a:text-sky-300 prose-a:hover:text-sky-200"
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
|
||||
{isEdited && editedAt && (
|
||||
<p className="mt-3 text-xs text-zinc-600">
|
||||
Edited {formatTimeAgo(editedAt)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Attachments */}
|
||||
{post?.attachments?.length > 0 && (
|
||||
<div className="mt-5 space-y-3 border-t border-white/[0.06] pt-4">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-widest text-white/30">Attachments</h4>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{post.attachments.map((att) => (
|
||||
<AttachmentItem key={att.id} attachment={att} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="flex flex-wrap items-center gap-3 border-t border-white/[0.06] px-5 py-3 text-xs">
|
||||
{/* Quote */}
|
||||
{threadId && (
|
||||
<a
|
||||
href={`/forum/thread/${threadId}-${threadSlug ?? ''}?quote=${postId}#reply-content`}
|
||||
className="rounded-lg border border-white/10 px-2.5 py-1 text-zinc-400 transition-colors hover:border-white/20 hover:text-zinc-200"
|
||||
>
|
||||
Quote
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Report */}
|
||||
{isAuthenticated && (post?.user_id !== post?.current_user_id) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReport}
|
||||
disabled={reported || reporting}
|
||||
className={[
|
||||
'rounded-lg border border-white/10 px-2.5 py-1 transition-colors',
|
||||
reported
|
||||
? 'text-emerald-400 border-emerald-500/20 cursor-default'
|
||||
: 'text-zinc-400 hover:border-white/20 hover:text-zinc-200',
|
||||
].join(' ')}
|
||||
>
|
||||
{reported ? 'Reported ✓' : reporting ? 'Reporting…' : 'Report'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Edit */}
|
||||
{(post?.can_edit) && (
|
||||
<a
|
||||
href={`/forum/post/${postId}/edit`}
|
||||
className="rounded-lg border border-white/10 px-2.5 py-1 text-zinc-400 transition-colors hover:border-white/20 hover:text-zinc-200"
|
||||
>
|
||||
Edit
|
||||
</a>
|
||||
)}
|
||||
|
||||
{canModerate && (
|
||||
<span className="ml-auto text-[11px] text-amber-400/60">Mod</span>
|
||||
)}
|
||||
</footer>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function AttachmentItem({ attachment }) {
|
||||
const mime = attachment?.mime_type ?? ''
|
||||
const isImage = mime.startsWith('image/')
|
||||
const url = attachment?.url ?? '#'
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-white/[0.06] bg-slate-900/60">
|
||||
{isImage ? (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className="block">
|
||||
<img
|
||||
src={url}
|
||||
alt="Attachment"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-40 w-full object-cover transition-transform hover:scale-[1.02]"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-3 text-sm text-sky-300 hover:text-sky-200"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
Download attachment
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getCsrf() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? ''
|
||||
}
|
||||
|
||||
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 ''
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimeAgo(dateStr) {
|
||||
try {
|
||||
const now = new Date()
|
||||
const date = new Date(dateStr)
|
||||
const diff = Math.floor((now - date) / 1000)
|
||||
if (diff < 60) return 'just now'
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`
|
||||
return formatDate(dateStr)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
82
resources/js/components/forum/ReplyForm.jsx
Normal file
82
resources/js/components/forum/ReplyForm.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useState, useRef, useCallback } from 'react'
|
||||
import Button from '../ui/Button'
|
||||
import RichTextEditor from './RichTextEditor'
|
||||
|
||||
export default function ReplyForm({ threadId, prefill = '', quotedAuthor = null, csrfToken }) {
|
||||
const [content, setContent] = useState(prefill)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const formRef = useRef(null)
|
||||
|
||||
const handleSubmit = useCallback(async (e) => {
|
||||
e.preventDefault()
|
||||
if (submitting || content.trim().length < 2) return
|
||||
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/forum/thread/${threadId}/reply`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ content: content.trim() }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
// Reload page to show new reply
|
||||
window.location.reload()
|
||||
} else if (res.status === 422) {
|
||||
const json = await res.json()
|
||||
setError(json.errors?.content?.[0] ?? 'Validation error.')
|
||||
} else {
|
||||
setError('Failed to post reply. Please try again.')
|
||||
}
|
||||
} catch {
|
||||
setError('Network error. Please try again.')
|
||||
}
|
||||
|
||||
setSubmitting(false)
|
||||
}, [content, threadId, csrfToken, submitting])
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-4 rounded-2xl border border-white/[0.06] bg-nova-800/50 p-5 backdrop-blur"
|
||||
>
|
||||
{quotedAuthor && (
|
||||
<p className="text-xs text-cyan-400/70">
|
||||
Replying with quote from <strong className="text-cyan-300">{quotedAuthor}</strong>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Rich text editor */}
|
||||
<RichTextEditor
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
placeholder="Write your reply…"
|
||||
error={error}
|
||||
minHeight={10}
|
||||
/>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="md"
|
||||
loading={submitting}
|
||||
disabled={content.trim().length < 2}
|
||||
>
|
||||
Post reply
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
316
resources/js/components/forum/RichTextEditor.jsx
Normal file
316
resources/js/components/forum/RichTextEditor.jsx
Normal file
@@ -0,0 +1,316 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { useEditor, EditorContent } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import Underline from '@tiptap/extension-underline'
|
||||
import Mention from '@tiptap/extension-mention'
|
||||
import mentionSuggestion from './mentionSuggestion'
|
||||
import EmojiPicker from './EmojiPicker'
|
||||
|
||||
/* ─── Toolbar button ─── */
|
||||
function ToolbarBtn({ onClick, active, disabled, title, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={[
|
||||
'inline-flex h-8 w-8 items-center justify-center rounded-lg text-sm transition-colors',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400',
|
||||
active
|
||||
? 'bg-sky-600/25 text-sky-300'
|
||||
: 'text-zinc-400 hover:bg-white/[0.06] hover:text-zinc-200',
|
||||
disabled && 'opacity-30 pointer-events-none',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <div className="mx-1 h-5 w-px bg-white/10" />
|
||||
}
|
||||
|
||||
/* ─── Toolbar ─── */
|
||||
function Toolbar({ editor }) {
|
||||
if (!editor) return null
|
||||
|
||||
const addLink = useCallback(() => {
|
||||
const prev = editor.getAttributes('link').href
|
||||
const url = window.prompt('URL', prev ?? 'https://')
|
||||
if (url === null) return
|
||||
if (url === '') {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
} else {
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
const addImage = useCallback(() => {
|
||||
const url = window.prompt('Image URL', 'https://')
|
||||
if (url) {
|
||||
editor.chain().focus().setImage({ src: url }).run()
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-0.5 border-b border-white/[0.06] px-2.5 py-2">
|
||||
{/* Text formatting */}
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
active={editor.isActive('bold')}
|
||||
title="Bold (Ctrl+B)"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M6 4h8a4 4 0 014 4 4 4 0 01-4 4H6zm0 8h9a4 4 0 014 4 4 4 0 01-4 4H6z"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
active={editor.isActive('italic')}
|
||||
title="Italic (Ctrl+I)"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
active={editor.isActive('underline')}
|
||||
title="Underline (Ctrl+U)"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 3v7a6 6 0 006 6 6 6 0 006-6V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
active={editor.isActive('strike')}
|
||||
title="Strikethrough"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="4" y1="12" x2="20" y2="12"/><path d="M17.5 7.5c0-2-1.5-3.5-5.5-3.5S6.5 5.5 6.5 7.5c0 4 11 4 11 8 0 2-1.5 3.5-5.5 3.5s-5.5-1.5-5.5-3.5"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Headings */}
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
active={editor.isActive('heading', { level: 2 })}
|
||||
title="Heading 2"
|
||||
>
|
||||
<span className="text-xs font-bold">H2</span>
|
||||
</ToolbarBtn>
|
||||
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
active={editor.isActive('heading', { level: 3 })}
|
||||
title="Heading 3"
|
||||
>
|
||||
<span className="text-xs font-bold">H3</span>
|
||||
</ToolbarBtn>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Lists */}
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
active={editor.isActive('bulletList')}
|
||||
title="Bullet list"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="9" y1="6" x2="20" y2="6"/><line x1="9" y1="12" x2="20" y2="12"/><line x1="9" y1="18" x2="20" y2="18"/><circle cx="4.5" cy="6" r="1" fill="currentColor"/><circle cx="4.5" cy="12" r="1" fill="currentColor"/><circle cx="4.5" cy="18" r="1" fill="currentColor"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
active={editor.isActive('orderedList')}
|
||||
title="Numbered list"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><text x="3" y="8" fontSize="7" fill="currentColor" stroke="none" fontFamily="sans-serif">1</text><text x="3" y="14" fontSize="7" fill="currentColor" stroke="none" fontFamily="sans-serif">2</text><text x="3" y="20" fontSize="7" fill="currentColor" stroke="none" fontFamily="sans-serif">3</text></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Block elements */}
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
active={editor.isActive('blockquote')}
|
||||
title="Quote"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M4.583 17.321C3.553 16.227 3 15 3 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311C9.591 11.68 11 13.24 11 15.14c0 .94-.36 1.84-1.001 2.503A3.34 3.34 0 017.559 18.6a3.77 3.77 0 01-2.976-.879zm10.4 0C13.953 16.227 13.4 15 13.4 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311 1.986.169 3.395 1.729 3.395 3.629 0 .94-.36 1.84-1.001 2.503a3.34 3.34 0 01-2.44.957 3.77 3.77 0 01-2.976-.879z"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
active={editor.isActive('codeBlock')}
|
||||
title="Code block"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||
active={editor.isActive('code')}
|
||||
title="Inline code"
|
||||
>
|
||||
<span className="font-mono text-[11px] font-bold">{'{}'}</span>
|
||||
</ToolbarBtn>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Link & Image */}
|
||||
<ToolbarBtn
|
||||
onClick={addLink}
|
||||
active={editor.isActive('link')}
|
||||
title="Link"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<ToolbarBtn onClick={addImage} title="Insert image">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Horizontal rule */}
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
||||
title="Horizontal rule"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="3" y1="12" x2="21" y2="12"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
{/* Emoji picker */}
|
||||
<EmojiPicker editor={editor} />
|
||||
|
||||
{/* Mention hint */}
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().insertContent('@').run()}
|
||||
title="Mention a user (type @username)"
|
||||
>
|
||||
<span className="text-xs font-bold">@</span>
|
||||
</ToolbarBtn>
|
||||
|
||||
{/* Undo / Redo */}
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
title="Undo (Ctrl+Z)"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
title="Redo (Ctrl+Shift+Z)"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.13-9.36L23 10"/></svg>
|
||||
</ToolbarBtn>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Main editor component ─── */
|
||||
|
||||
/**
|
||||
* Rich text editor for forum posts & replies.
|
||||
*
|
||||
* @prop {string} content – initial HTML content
|
||||
* @prop {function} onChange – called with HTML string on every change
|
||||
* @prop {string} placeholder – placeholder text
|
||||
* @prop {string} error – validation error message
|
||||
* @prop {number} minHeight – min height in rem (default 12)
|
||||
* @prop {boolean} autofocus – focus on mount
|
||||
*/
|
||||
export default function RichTextEditor({
|
||||
content = '',
|
||||
onChange,
|
||||
placeholder = 'Write something…',
|
||||
error,
|
||||
minHeight = 12,
|
||||
autofocus = false,
|
||||
}) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [2, 3] },
|
||||
codeBlock: {
|
||||
HTMLAttributes: { class: 'forum-code-block' },
|
||||
},
|
||||
}),
|
||||
Underline,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
class: 'text-sky-300 underline hover:text-sky-200',
|
||||
rel: 'noopener noreferrer nofollow',
|
||||
},
|
||||
}),
|
||||
Image.configure({
|
||||
HTMLAttributes: { class: 'rounded-lg max-w-full' },
|
||||
}),
|
||||
Placeholder.configure({ placeholder }),
|
||||
Mention.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'mention',
|
||||
},
|
||||
suggestion: mentionSuggestion,
|
||||
}),
|
||||
],
|
||||
content,
|
||||
autofocus,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: [
|
||||
'prose prose-invert prose-sm max-w-none',
|
||||
'focus:outline-none',
|
||||
'px-4 py-3',
|
||||
'prose-headings:text-white prose-headings:font-bold',
|
||||
'prose-p:text-zinc-200 prose-p:leading-relaxed',
|
||||
'prose-a:text-sky-300 prose-a:no-underline hover:prose-a:text-sky-200',
|
||||
'prose-blockquote:border-l-sky-500/50 prose-blockquote:text-zinc-400',
|
||||
'prose-code:text-amber-300 prose-code:bg-white/[0.06] prose-code:rounded prose-code:px-1.5 prose-code:py-0.5 prose-code:text-xs',
|
||||
'prose-pre:bg-white/[0.04] prose-pre:border prose-pre:border-white/[0.06] prose-pre:rounded-xl',
|
||||
'prose-img:rounded-xl',
|
||||
'prose-hr:border-white/10',
|
||||
].join(' '),
|
||||
style: `min-height: ${minHeight}rem`,
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor: e }) => {
|
||||
onChange?.(e.getHTML())
|
||||
},
|
||||
})
|
||||
|
||||
// Sync content from outside (e.g. prefill / quote)
|
||||
useEffect(() => {
|
||||
if (editor && content && !editor.getHTML().includes(content.slice(0, 30))) {
|
||||
editor.commands.setContent(content, false)
|
||||
}
|
||||
}, [content]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div
|
||||
className={[
|
||||
'overflow-hidden rounded-xl border bg-white/[0.04] transition-colors',
|
||||
error
|
||||
? 'border-red-500/60 focus-within:border-red-500/70 focus-within:ring-2 focus-within:ring-red-500/30'
|
||||
: 'border-white/12 hover:border-white/20 focus-within:border-sky-500/50 focus-within:ring-2 focus-within:ring-sky-500/20',
|
||||
].join(' ')}
|
||||
>
|
||||
<Toolbar editor={editor} />
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-red-400">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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 '-'
|
||||
}
|
||||
}
|
||||
74
resources/js/components/forum/mentionSuggestion.js
Normal file
74
resources/js/components/forum/mentionSuggestion.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import { ReactRenderer } from '@tiptap/react'
|
||||
import tippy from 'tippy.js'
|
||||
import MentionList from './MentionList'
|
||||
|
||||
/**
|
||||
* TipTap suggestion configuration for @mentions.
|
||||
* Fetches users from /api/search/users?q=... as the user types.
|
||||
*/
|
||||
export default {
|
||||
items: async ({ query }) => {
|
||||
if (!query || query.length < 2) return []
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/search/users?q=${encodeURIComponent(query)}&per_page=6`)
|
||||
if (!res.ok) return []
|
||||
const json = await res.json()
|
||||
return json.data ?? []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
render: () => {
|
||||
let component
|
||||
let popup
|
||||
|
||||
return {
|
||||
onStart: (props) => {
|
||||
component = new ReactRenderer(MentionList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
})
|
||||
|
||||
if (!props.clientRect) return
|
||||
|
||||
popup = tippy('body', {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'bottom-start',
|
||||
theme: 'mention',
|
||||
arrow: false,
|
||||
offset: [0, 8],
|
||||
})
|
||||
},
|
||||
|
||||
onUpdate: (props) => {
|
||||
component?.updateProps(props)
|
||||
|
||||
if (!props.clientRect) return
|
||||
|
||||
popup?.[0]?.setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
})
|
||||
},
|
||||
|
||||
onKeyDown: (props) => {
|
||||
if (props.event.key === 'Escape') {
|
||||
popup?.[0]?.hide()
|
||||
return true
|
||||
}
|
||||
return component?.ref?.onKeyDown(props) ?? false
|
||||
},
|
||||
|
||||
onExit: () => {
|
||||
popup?.[0]?.destroy()
|
||||
component?.destroy()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user