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
223 lines
7.8 KiB
JavaScript
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>
|
|
)
|
|
}
|