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:
81
resources/js/Pages/Forum/ForumCategory.jsx
Normal file
81
resources/js/Pages/Forum/ForumCategory.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React from 'react'
|
||||
import Breadcrumbs from '../../components/forum/Breadcrumbs'
|
||||
import ThreadRow from '../../components/forum/ThreadRow'
|
||||
import Pagination from '../../components/forum/Pagination'
|
||||
import Button from '../../components/ui/Button'
|
||||
|
||||
export default function ForumCategory({ category, threads = [], pagination = {}, isAuthenticated = false }) {
|
||||
const name = category?.name ?? 'Category'
|
||||
const slug = category?.slug
|
||||
|
||||
const breadcrumbs = [
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Forum', href: '/forum' },
|
||||
{ label: name },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-5xl mx-auto">
|
||||
{/* Breadcrumbs */}
|
||||
<Breadcrumbs items={breadcrumbs} />
|
||||
|
||||
{/* Header */}
|
||||
<div className="mt-5 mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Forum</p>
|
||||
<h1 className="text-3xl font-bold text-white leading-tight">{name}</h1>
|
||||
</div>
|
||||
{isAuthenticated && slug && (
|
||||
<a href={`/forum/${slug}/new`}>
|
||||
<Button variant="primary" size="sm"
|
||||
leftIcon={
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
New thread
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thread list */}
|
||||
<section className="overflow-hidden rounded-2xl border border-white/[0.06] bg-nova-800/50 backdrop-blur">
|
||||
{/* Column header */}
|
||||
<div className="flex items-center gap-4 border-b border-white/[0.06] px-5 py-3">
|
||||
<span className="flex-1 text-xs font-semibold uppercase tracking-widest text-white/30">Threads</span>
|
||||
<span className="w-12 text-center text-xs font-semibold uppercase tracking-widest text-white/30">Posts</span>
|
||||
</div>
|
||||
|
||||
{threads.length === 0 ? (
|
||||
<div className="px-5 py-12 text-center">
|
||||
<svg className="mx-auto mb-4 text-zinc-600" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
||||
</svg>
|
||||
<p className="text-sm text-zinc-500">No threads in this section yet.</p>
|
||||
{isAuthenticated && slug && (
|
||||
<a href={`/forum/${slug}/new`} className="mt-3 inline-block text-sm text-sky-300 hover:text-sky-200">
|
||||
Be the first to start a discussion →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{threads.map((thread, i) => (
|
||||
<ThreadRow key={thread.topic_id ?? thread.id ?? i} thread={thread} isFirst={i === 0} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination?.last_page > 1 && (
|
||||
<div className="mt-6">
|
||||
<Pagination meta={pagination} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
74
resources/js/Pages/Forum/ForumEditPost.jsx
Normal file
74
resources/js/Pages/Forum/ForumEditPost.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import Breadcrumbs from '../../components/forum/Breadcrumbs'
|
||||
import Button from '../../components/ui/Button'
|
||||
import RichTextEditor from '../../components/forum/RichTextEditor'
|
||||
|
||||
export default function ForumEditPost({ post, thread, csrfToken, errors = {} }) {
|
||||
const [content, setContent] = useState(post?.content ?? '')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const breadcrumbs = [
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Forum', href: '/forum' },
|
||||
{ label: thread?.title ?? 'Thread', href: thread?.id ? `/forum/thread/${thread.id}-${thread.slug ?? ''}` : '/forum' },
|
||||
{ label: 'Edit post' },
|
||||
]
|
||||
|
||||
const handleSubmit = useCallback((e) => {
|
||||
if (submitting) return
|
||||
setSubmitting(true)
|
||||
// Let the form submit normally for PRG
|
||||
}, [submitting])
|
||||
|
||||
return (
|
||||
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-3xl mx-auto">
|
||||
<Breadcrumbs items={breadcrumbs} />
|
||||
|
||||
{/* Header */}
|
||||
<div className="mt-5 mb-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Edit</p>
|
||||
<h1 className="text-2xl font-bold text-white leading-tight">Edit post</h1>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
method="POST"
|
||||
action={`/forum/post/${post?.id}`}
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-5 rounded-2xl border border-white/[0.06] bg-nova-800/50 p-6 backdrop-blur"
|
||||
>
|
||||
<input type="hidden" name="_token" value={csrfToken} />
|
||||
<input type="hidden" name="_method" value="PUT" />
|
||||
|
||||
{/* Rich text editor */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-white/85">
|
||||
Content
|
||||
</label>
|
||||
<RichTextEditor
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
placeholder="Edit your post…"
|
||||
error={errors.content}
|
||||
minHeight={14}
|
||||
autofocus={false}
|
||||
/>
|
||||
<input type="hidden" name="content" value={content} />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<a
|
||||
href={thread?.id ? `/forum/thread/${thread.id}-${thread.slug ?? ''}` : '/forum'}
|
||||
className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
← Cancel
|
||||
</a>
|
||||
<Button type="submit" variant="primary" size="md" loading={submitting}>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
resources/js/Pages/Forum/ForumIndex.jsx
Normal file
31
resources/js/Pages/Forum/ForumIndex.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react'
|
||||
import CategoryCard from '../../components/forum/CategoryCard'
|
||||
|
||||
export default function ForumIndex({ categories = [] }) {
|
||||
return (
|
||||
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Community</p>
|
||||
<h1 className="text-3xl font-bold text-white leading-tight">Forum</h1>
|
||||
<p className="mt-1.5 text-sm text-white/50">Browse forum sections and join the conversation.</p>
|
||||
</div>
|
||||
|
||||
{/* Category grid */}
|
||||
{categories.length === 0 ? (
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-nova-800/50 p-12 text-center">
|
||||
<svg className="mx-auto mb-4 text-zinc-600" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
||||
</svg>
|
||||
<p className="text-sm text-zinc-500">No forum categories available yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
{categories.map((cat) => (
|
||||
<CategoryCard key={cat.id ?? cat.slug} category={cat} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
91
resources/js/Pages/Forum/ForumNewThread.jsx
Normal file
91
resources/js/Pages/Forum/ForumNewThread.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import Breadcrumbs from '../../components/forum/Breadcrumbs'
|
||||
import Button from '../../components/ui/Button'
|
||||
import TextInput from '../../components/ui/TextInput'
|
||||
import RichTextEditor from '../../components/forum/RichTextEditor'
|
||||
|
||||
export default function ForumNewThread({ category, csrfToken, errors = {}, oldValues = {} }) {
|
||||
const [title, setTitle] = useState(oldValues.title ?? '')
|
||||
const [content, setContent] = useState(oldValues.content ?? '')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const slug = category?.slug
|
||||
const categoryName = category?.name ?? 'Category'
|
||||
|
||||
const breadcrumbs = [
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Forum', href: '/forum' },
|
||||
{ label: categoryName, href: slug ? `/forum/${slug}` : '/forum' },
|
||||
{ label: 'New thread' },
|
||||
]
|
||||
|
||||
const handleSubmit = useCallback(async (e) => {
|
||||
e.preventDefault()
|
||||
if (submitting) return
|
||||
setSubmitting(true)
|
||||
|
||||
// Standard form submission to keep server-side validation + redirect
|
||||
e.target.submit()
|
||||
}, [submitting])
|
||||
|
||||
return (
|
||||
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-3xl mx-auto">
|
||||
<Breadcrumbs items={breadcrumbs} />
|
||||
|
||||
{/* Header */}
|
||||
<div className="mt-5 mb-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">New thread</p>
|
||||
<h1 className="text-2xl font-bold text-white leading-tight">
|
||||
Create thread in {categoryName}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
method="POST"
|
||||
action={`/forum/${slug}/new`}
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-5 rounded-2xl border border-white/[0.06] bg-nova-800/50 p-6 backdrop-blur"
|
||||
>
|
||||
<input type="hidden" name="_token" value={csrfToken} />
|
||||
|
||||
<TextInput
|
||||
label="Title"
|
||||
name="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
maxLength={255}
|
||||
placeholder="Thread title…"
|
||||
error={errors.title}
|
||||
/>
|
||||
|
||||
{/* Rich text editor */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-white/85">
|
||||
Content <span className="text-red-400 ml-1">*</span>
|
||||
</label>
|
||||
<RichTextEditor
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
placeholder="Write your post…"
|
||||
error={errors.content}
|
||||
minHeight={14}
|
||||
autofocus={false}
|
||||
/>
|
||||
<input type="hidden" name="content" value={content} />
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<a href={`/forum/${slug}`} className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors">
|
||||
← Cancel
|
||||
</a>
|
||||
<Button type="submit" variant="primary" size="md" loading={submitting}>
|
||||
Publish thread
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
200
resources/js/Pages/Forum/ForumThread.jsx
Normal file
200
resources/js/Pages/Forum/ForumThread.jsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import Breadcrumbs from '../../components/forum/Breadcrumbs'
|
||||
import PostCard from '../../components/forum/PostCard'
|
||||
import ReplyForm from '../../components/forum/ReplyForm'
|
||||
import Pagination from '../../components/forum/Pagination'
|
||||
|
||||
export default function ForumThread({
|
||||
thread,
|
||||
category,
|
||||
author,
|
||||
opPost,
|
||||
posts = [],
|
||||
pagination = {},
|
||||
replyCount = 0,
|
||||
sort = 'asc',
|
||||
quotedPost = null,
|
||||
replyPrefill = '',
|
||||
isAuthenticated = false,
|
||||
canModerate = false,
|
||||
csrfToken = '',
|
||||
status = null,
|
||||
}) {
|
||||
const [currentSort, setCurrentSort] = useState(sort)
|
||||
|
||||
const breadcrumbs = [
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Forum', href: '/forum' },
|
||||
{ label: category?.name ?? 'Category', href: category?.slug ? `/forum/${category.slug}` : '/forum' },
|
||||
{ label: thread?.title ?? 'Thread' },
|
||||
]
|
||||
|
||||
const handleSortToggle = useCallback(() => {
|
||||
const newSort = currentSort === 'asc' ? 'desc' : 'asc'
|
||||
setCurrentSort(newSort)
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('sort', newSort)
|
||||
window.location.href = url.toString()
|
||||
}, [currentSort])
|
||||
|
||||
return (
|
||||
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-5xl mx-auto space-y-5">
|
||||
<Breadcrumbs items={breadcrumbs} />
|
||||
|
||||
{/* Status flash */}
|
||||
{status && (
|
||||
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-300">
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thread header card */}
|
||||
<section className="rounded-2xl border border-white/[0.06] bg-nova-800/50 p-5 backdrop-blur">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold text-white leading-snug">{thread?.title}</h1>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-zinc-500">
|
||||
<span>By {author?.name ?? 'Unknown'}</span>
|
||||
<span className="text-zinc-700">•</span>
|
||||
{thread?.created_at && (
|
||||
<time dateTime={thread.created_at}>{formatDate(thread.created_at)}</time>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="rounded-full bg-sky-500/15 px-2.5 py-1 text-sky-300">
|
||||
{number(thread?.views ?? 0)} views
|
||||
</span>
|
||||
<span className="rounded-full bg-cyan-500/15 px-2.5 py-1 text-cyan-300">
|
||||
{number(replyCount)} replies
|
||||
</span>
|
||||
{thread?.is_pinned && (
|
||||
<span className="rounded-full bg-amber-500/15 px-2.5 py-1 text-amber-300">Pinned</span>
|
||||
)}
|
||||
{thread?.is_locked && (
|
||||
<span className="rounded-full bg-red-500/15 px-2.5 py-1 text-red-300">Locked</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Moderation tools */}
|
||||
{canModerate && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-white/[0.06] pt-3">
|
||||
{thread?.is_locked ? (
|
||||
<ModForm action={`/forum/thread/${thread.id}/unlock`} csrf={csrfToken} label="Unlock" variant="danger" />
|
||||
) : (
|
||||
<ModForm action={`/forum/thread/${thread.id}/lock`} csrf={csrfToken} label="Lock" variant="danger" />
|
||||
)}
|
||||
{thread?.is_pinned ? (
|
||||
<ModForm action={`/forum/thread/${thread.id}/unpin`} csrf={csrfToken} label="Unpin" variant="warning" />
|
||||
) : (
|
||||
<ModForm action={`/forum/thread/${thread.id}/pin`} csrf={csrfToken} label="Pin" variant="warning" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Sort toggle + reply count */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-zinc-500">{number(replyCount)} {replyCount === 1 ? 'reply' : 'replies'}</p>
|
||||
<button
|
||||
onClick={handleSortToggle}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-1.5 text-xs text-zinc-400 transition-colors hover:border-white/20 hover:text-zinc-200"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points={currentSort === 'asc' ? '18 15 12 21 6 15' : '18 9 12 3 6 9'} />
|
||||
<line x1="12" y1="3" x2="12" y2="21" />
|
||||
</svg>
|
||||
{currentSort === 'asc' ? 'Oldest first' : 'Newest first'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* OP Post */}
|
||||
{opPost && (
|
||||
<PostCard
|
||||
post={opPost}
|
||||
thread={thread}
|
||||
isOp
|
||||
isAuthenticated={isAuthenticated}
|
||||
canModerate={canModerate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Reply list */}
|
||||
<section className="space-y-4" aria-label="Replies">
|
||||
{posts.length === 0 ? (
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-nova-800/40 px-5 py-8 text-center text-zinc-500 text-sm">
|
||||
No replies yet. Be the first to respond!
|
||||
</div>
|
||||
) : (
|
||||
posts.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
thread={thread}
|
||||
isAuthenticated={isAuthenticated}
|
||||
canModerate={canModerate}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination?.last_page > 1 && (
|
||||
<div className="sticky bottom-3 z-10 rounded-xl border border-white/[0.06] bg-nova-800/80 p-2 backdrop-blur">
|
||||
<Pagination meta={pagination} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reply form or locked / auth prompt */}
|
||||
{isAuthenticated ? (
|
||||
thread?.is_locked ? (
|
||||
<div className="rounded-2xl border border-red-500/20 bg-red-500/5 px-5 py-4 text-sm text-red-300">
|
||||
This thread is locked. Replies are disabled.
|
||||
</div>
|
||||
) : (
|
||||
<ReplyForm
|
||||
threadId={thread?.id}
|
||||
prefill={replyPrefill}
|
||||
quotedAuthor={quotedPost?.user?.name}
|
||||
csrfToken={csrfToken}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-nova-800/40 px-5 py-5 text-sm text-zinc-400">
|
||||
<a href="/login" className="text-sky-300 hover:text-sky-200 font-medium">Sign in</a> to post a reply.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModForm({ action, csrf, label, variant }) {
|
||||
const colors = variant === 'danger'
|
||||
? 'bg-red-500/15 text-red-300 hover:bg-red-500/25 border-red-500/20'
|
||||
: 'bg-amber-500/15 text-amber-300 hover:bg-amber-500/25 border-amber-500/20'
|
||||
|
||||
return (
|
||||
<form method="POST" action={action}>
|
||||
<input type="hidden" name="_token" value={csrf} />
|
||||
<button type="submit" className={`rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors ${colors}`}>
|
||||
{label}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function number(n) {
|
||||
return (n ?? 0).toLocaleString()
|
||||
}
|
||||
|
||||
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