Files
SkinbaseNova/resources/js/components/Feed/PostComments.jsx
Gregor Klevze dc51d65440 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
2026-03-03 09:48:31 +01:00

223 lines
7.8 KiB
JavaScript

import React, { useState, useEffect, useRef } from 'react'
import axios from 'axios'
function formatRelative(isoString) {
const diff = Date.now() - new Date(isoString).getTime()
const s = Math.floor(diff / 1000)
if (s < 60) return 'just now'
const m = Math.floor(s / 60)
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ago`
const d = Math.floor(h / 24)
return `${d}d ago`
}
export default function PostComments({ postId, isLoggedIn, isOwn = false, initialCount = 0 }) {
const [comments, setComments] = useState([])
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [body, setBody] = useState('')
const [error, setError] = useState(null)
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(false)
const [loaded, setLoaded] = useState(false)
const textareaRef = useRef(null)
const fetchComments = async (p = 1) => {
setLoading(true)
try {
const { data } = await axios.get(`/api/posts/${postId}/comments`, { params: { page: p } })
setComments((prev) => p === 1 ? data.data : [...prev, ...data.data])
setHasMore(data.meta.current_page < data.meta.last_page)
setPage(p)
} catch {
//
} finally {
setLoading(false)
setLoaded(true)
}
}
useEffect(() => {
fetchComments(1)
}, [postId])
const handleSubmit = async (e) => {
e.preventDefault()
if (!body.trim()) return
setSubmitting(true)
setError(null)
try {
const { data } = await axios.post(`/api/posts/${postId}/comments`, { body })
setComments((prev) => [...prev, data.comment])
setBody('')
} catch (err) {
setError(err.response?.data?.message ?? 'Failed to post comment.')
} finally {
setSubmitting(false)
}
}
const handleDelete = async (commentId) => {
if (!window.confirm('Delete this comment?')) return
try {
await axios.delete(`/api/posts/${postId}/comments/${commentId}`)
setComments((prev) => prev.filter((c) => c.id !== commentId))
} catch {
//
}
}
const handleHighlight = async (comment) => {
try {
if (comment.is_highlighted) {
await axios.delete(`/api/posts/${postId}/comments/${comment.id}/highlight`)
setComments((prev) =>
prev.map((c) => c.id === comment.id ? { ...c, is_highlighted: false } : c),
)
} else {
await axios.post(`/api/posts/${postId}/comments/${comment.id}/highlight`)
// Only one can be highlighted — clear others and set this one
setComments((prev) =>
prev.map((c) => ({ ...c, is_highlighted: c.id === comment.id })),
)
}
} catch {
//
}
}
// Highlighted comment always first (server also orders this way, but keep client in sync)
const sorted = [...comments].sort((a, b) =>
(b.is_highlighted ? 1 : 0) - (a.is_highlighted ? 1 : 0),
)
return (
<div className="space-y-3">
{/* Comment list */}
{!loaded && loading && (
<div className="space-y-2">
{[1, 2].map((i) => (
<div key={i} className="flex gap-2 animate-pulse">
<div className="w-7 h-7 rounded-full bg-white/10 shrink-0" />
<div className="flex-1 space-y-1">
<div className="h-2.5 bg-white/10 rounded w-24" />
<div className="h-2 bg-white/6 rounded w-3/4" />
</div>
</div>
))}
</div>
)}
{loaded && sorted.map((c) => (
<div
key={c.id}
className={`flex gap-2 group ${c.is_highlighted ? 'rounded-xl bg-sky-500/5 border border-sky-500/15 px-3 py-2 -mx-3' : ''}`}
>
{/* Avatar */}
<a href={`/@${c.author.username}`} className="shrink-0">
<img
src={c.author.avatar ?? '/images/avatar_default.webp'}
alt={c.author.name}
className="w-7 h-7 rounded-full object-cover ring-1 ring-white/10"
loading="lazy"
/>
</a>
{/* Body */}
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2 flex-wrap">
<a
href={`/@${c.author.username}`}
className="text-xs font-semibold text-white/80 hover:text-sky-400 transition-colors"
>
{c.author.name || `@${c.author.username}`}
</a>
<span className="text-[10px] text-slate-600">{formatRelative(c.created_at)}</span>
{c.is_highlighted && (
<span className="text-[10px] text-sky-400 font-medium flex items-center gap-1">
<i className="fa-solid fa-star fa-xs" />
Highlighted by author
</span>
)}
</div>
<div
className="text-sm text-slate-300 mt-0.5 [&_a]:text-sky-400 [&_a]:hover:underline"
dangerouslySetInnerHTML={{ __html: c.body }}
/>
</div>
{/* Actions: highlight (owner) + delete */}
<div className="flex items-start gap-1 opacity-0 group-hover:opacity-100 transition-all ml-1">
{isOwn && (
<button
onClick={() => handleHighlight(c)}
title={c.is_highlighted ? 'Remove highlight' : 'Highlight comment'}
className={`text-xs transition-colors px-1 py-0.5 rounded ${
c.is_highlighted
? 'text-sky-400 hover:text-slate-400'
: 'text-slate-600 hover:text-sky-400'
}`}
>
<i className={`${c.is_highlighted ? 'fa-solid' : 'fa-regular'} fa-star`} />
</button>
)}
{isLoggedIn && (
<button
onClick={() => handleDelete(c.id)}
className="text-slate-600 hover:text-rose-400 transition-all text-xs"
title="Delete comment"
>
<i className="fa-solid fa-trash-can" />
</button>
)}
</div>
</div>
))}
{loaded && hasMore && (
<button
onClick={() => fetchComments(page + 1)}
disabled={loading}
className="text-xs text-sky-400 hover:text-sky-300 transition-colors"
>
{loading ? 'Loading…' : 'Load more comments'}
</button>
)}
{/* Composer */}
{isLoggedIn ? (
<form onSubmit={handleSubmit} className="flex gap-2 mt-2">
<textarea
ref={textareaRef}
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Write a comment…"
maxLength={1000}
rows={1}
className="flex-1 bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-sm text-white resize-none placeholder-slate-600 focus:outline-none focus:border-sky-500/50 transition-colors"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit(e)
}
}}
/>
<button
type="submit"
disabled={submitting || !body.trim()}
className="px-3 py-2 rounded-xl bg-sky-600 hover:bg-sky-500 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm transition-colors"
>
{submitting ? <i className="fa-solid fa-spinner fa-spin" /> : <i className="fa-solid fa-paper-plane" />}
</button>
</form>
) : (
<p className="text-xs text-slate-500 mt-2">
<a href="/login" className="text-sky-400 hover:underline">Sign in</a> to comment.
</p>
)}
{error && <p className="text-xs text-rose-400">{error}</p>}
</div>
)
}