Add news article comments and reactions
This commit is contained in:
645
resources/js/Pages/News/NewsComments.jsx
Normal file
645
resources/js/Pages/News/NewsComments.jsx
Normal file
@@ -0,0 +1,645 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import axios from 'axios'
|
||||
import CommentForm from '../../components/comments/CommentForm'
|
||||
import ReactionBar from '../../components/comments/ReactionBar'
|
||||
import LevelBadge from '../../components/xp/LevelBadge'
|
||||
import { isFlood } from '../../utils/emojiFlood'
|
||||
|
||||
const ABSOLUTE_DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
|
||||
const ABSOLUTE_DATE_TIME_FORMATTER = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
|
||||
function formatAbsoluteDate(value) {
|
||||
if (!value) return ''
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
|
||||
return ABSOLUTE_DATE_FORMATTER.format(date)
|
||||
}
|
||||
|
||||
function formatAbsoluteDateTime(value) {
|
||||
if (!value) return ''
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
|
||||
return ABSOLUTE_DATE_TIME_FORMATTER.format(date)
|
||||
}
|
||||
|
||||
function formatCommentTime(primaryLabel, createdAt) {
|
||||
return primaryLabel || formatAbsoluteDate(createdAt)
|
||||
}
|
||||
|
||||
function ReplyIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-3.5 w-3.5">
|
||||
<path fillRule="evenodd" d="M7.793 2.232a.75.75 0 0 1-.025 1.06L3.622 7.25h10.003a5.375 5.375 0 0 1 0 10.75H10.75a.75.75 0 0 1 0-1.5h2.875a3.875 3.875 0 0 0 0-7.75H3.622l4.146 3.957a.75.75 0 0 1-1.036 1.085l-5.5-5.25a.75.75 0 0 1 0-1.085l5.5-5.25a.75.75 0 0 1 1.06.025Z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function TrashIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-3.5 w-3.5">
|
||||
<path fillRule="evenodd" d="M7.5 2.75A1.75 1.75 0 0 0 5.75 4.5v.25H3.5a.75.75 0 0 0 0 1.5h.54l.853 9.1A2.25 2.25 0 0 0 7.133 17.5h5.734a2.25 2.25 0 0 0 2.24-2.15l.853-9.1h.54a.75.75 0 0 0 0-1.5h-2.25V4.5A1.75 1.75 0 0 0 12.5 2.75h-5Zm5.25 2V4.5a.25.25 0 0 0-.25-.25h-5a.25.25 0 0 0-.25.25v.25h5.5Zm-4 3a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0V8.5a.75.75 0 0 1 .75-.75Zm3.25.75a.75.75 0 0 0-1.5 0v4.5a.75.75 0 0 0 1.5 0V8.5Z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatBubbleIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.2} stroke="currentColor" className="h-10 w-10 text-white/15">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ChevronDownIcon({ className }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className={className}>
|
||||
<path fillRule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function Avatar({ user, size = 36 }) {
|
||||
if (user?.avatar_url) {
|
||||
return (
|
||||
<img
|
||||
src={user.avatar_url}
|
||||
alt={user.display || user.username || ''}
|
||||
width={size}
|
||||
height={size}
|
||||
className="rounded-full object-cover shrink-0 ring-1 ring-white/10"
|
||||
style={{ width: size, height: size }}
|
||||
loading="lazy"
|
||||
onError={(event) => {
|
||||
event.currentTarget.onerror = null
|
||||
event.currentTarget.src = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const initials = (user?.display || user?.username || '?').slice(0, 1).toUpperCase()
|
||||
|
||||
return (
|
||||
<span
|
||||
className="flex items-center justify-center rounded-full bg-gradient-to-br from-nova-600 to-nova-800 text-sm font-bold text-white/90 shrink-0 ring-1 ring-white/10"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function DeleteButton({ onDelete, pending = false }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
disabled={pending}
|
||||
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium uppercase tracking-wider text-white/35 transition-all duration-200 hover:bg-rose-500/10 hover:text-rose-100 disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
<TrashIcon />
|
||||
Remove
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ReplyItem({ reply, articleId, isLoggedIn, onReplyPosted, onDelete, deletePending = false, depth = 1 }) {
|
||||
const user = reply.user
|
||||
const html = reply.rendered_content ?? null
|
||||
const plain = reply.raw_content ?? ''
|
||||
const profileLabel = user?.display || user?.username || 'Member'
|
||||
const replies = reply.replies || []
|
||||
const reactionEndpoint = `/api/news/comments/${reply.id}/reactions`
|
||||
|
||||
const [showReplyForm, setShowReplyForm] = useState(false)
|
||||
const [showAllReplies, setShowAllReplies] = useState(false)
|
||||
const [reactionTotals, setReactionTotals] = useState(reply.reactions ?? {})
|
||||
|
||||
useEffect(() => {
|
||||
if (reply.reactions || !reply.id) return
|
||||
|
||||
axios
|
||||
.get(reactionEndpoint)
|
||||
.then(({ data }) => setReactionTotals(data.totals ?? {}))
|
||||
.catch(() => {})
|
||||
}, [reply.id, reply.reactions, reactionEndpoint])
|
||||
|
||||
const handleReplyPosted = useCallback((newReply) => {
|
||||
onReplyPosted?.(reply.id, newReply)
|
||||
setShowReplyForm(false)
|
||||
setShowAllReplies(true)
|
||||
}, [onReplyPosted, reply.id])
|
||||
|
||||
const visibleReplies = showAllReplies ? replies : replies.slice(0, 2)
|
||||
const hiddenReplyCount = replies.length - 2
|
||||
const avatarSize = depth >= 3 ? 22 : 28
|
||||
|
||||
return (
|
||||
<li className="rounded-lg bg-white/[0.02] px-3 py-2.5" id={`comment-${reply.id}`}>
|
||||
<div className="flex gap-2.5">
|
||||
{user?.profile_url ? (
|
||||
<a href={user.profile_url} className="shrink-0 mt-0.5" tabIndex={-1}>
|
||||
<Avatar user={user} size={avatarSize} />
|
||||
</a>
|
||||
) : (
|
||||
<span className="shrink-0 mt-0.5"><Avatar user={user} size={avatarSize} /></span>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{user?.profile_url ? (
|
||||
<a href={user.profile_url} className="text-[12px] font-semibold text-white/90 hover:text-accent transition-colors">
|
||||
{profileLabel}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-[12px] font-semibold text-white/90">{profileLabel}</span>
|
||||
)}
|
||||
<LevelBadge level={user?.level} rank={user?.rank} compact className="shrink-0" />
|
||||
<span className="text-white/15" aria-hidden="true">·</span>
|
||||
<time
|
||||
dateTime={reply.created_at}
|
||||
title={formatAbsoluteDateTime(reply.created_at)}
|
||||
className="text-[10px] font-medium tracking-wide text-white/25 uppercase"
|
||||
>
|
||||
{formatCommentTime(reply.time_ago, reply.created_at)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{html ? (
|
||||
<div
|
||||
className="mt-1 text-[12.5px] leading-[1.65] text-white/70 prose prose-invert prose-sm max-w-none prose-p:my-1 prose-a:text-sky-400 prose-a:no-underline hover:prose-a:underline prose-code:bg-white/[0.07] prose-code:px-1 prose-code:py-0.5 prose-code:rounded-md prose-code:text-xs"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
) : (
|
||||
<p className="mt-1 text-[12.5px] leading-[1.65] text-white/70 whitespace-pre-line break-words">{plain}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||
{isLoggedIn && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowReplyForm((value) => !value)}
|
||||
className={[
|
||||
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[10px] font-medium uppercase tracking-wider transition-all duration-200 focus:outline-none',
|
||||
showReplyForm ? 'bg-accent/10 text-accent' : 'text-white/35 hover:bg-white/[0.06] hover:text-white/65',
|
||||
].join(' ')}
|
||||
>
|
||||
<ReplyIcon />
|
||||
Reply
|
||||
</button>
|
||||
)}
|
||||
|
||||
{reply.can_delete ? <DeleteButton onDelete={() => onDelete(reply.id)} pending={deletePending} /> : null}
|
||||
|
||||
<ReactionBar
|
||||
entityType="comment"
|
||||
entityId={reply.id}
|
||||
initialTotals={reactionTotals}
|
||||
isLoggedIn={isLoggedIn}
|
||||
endpoint={reactionEndpoint}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showReplyForm ? (
|
||||
<div className="mt-2">
|
||||
<CommentForm
|
||||
artworkId={articleId}
|
||||
submitUrl={`/api/news/articles/${articleId}/comments`}
|
||||
contentField="content"
|
||||
maxLength={4000}
|
||||
placeholder="Share your thoughts about this article…"
|
||||
onPosted={handleReplyPosted}
|
||||
parentId={reply.id}
|
||||
replyTo={profileLabel}
|
||||
onCancelReply={() => setShowReplyForm(false)}
|
||||
isLoggedIn={isLoggedIn}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{replies.length > 0 ? (
|
||||
<div className="mt-2">
|
||||
<ul className={`space-y-1 pl-3 border-l-2 ${depth >= 3 ? 'border-white/[0.03]' : 'border-white/[0.05]'}`}>
|
||||
{visibleReplies.map((child) => (
|
||||
<ReplyItem
|
||||
key={child.id}
|
||||
reply={child}
|
||||
articleId={articleId}
|
||||
isLoggedIn={isLoggedIn}
|
||||
onReplyPosted={onReplyPosted}
|
||||
onDelete={onDelete}
|
||||
deletePending={deletePending && child.id === reply.id}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{!showAllReplies && hiddenReplyCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllReplies(true)}
|
||||
className="mt-1.5 ml-3 inline-flex items-center gap-1 text-[10px] font-medium text-accent/70 transition-colors hover:text-accent"
|
||||
>
|
||||
<ChevronDownIcon className="h-3 w-3" />
|
||||
Show {hiddenReplyCount} more {hiddenReplyCount === 1 ? 'reply' : 'replies'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function CommentItem({ comment, isLoggedIn, articleId, onReplyPosted, onDelete, deletePending = false }) {
|
||||
const user = comment.user
|
||||
const html = comment.rendered_content ?? null
|
||||
const plain = comment.raw_content ?? ''
|
||||
const profileLabel = user?.display || user?.username || 'Member'
|
||||
const replies = comment.replies || []
|
||||
const flood = isFlood(plain)
|
||||
const reactionEndpoint = `/api/news/comments/${comment.id}/reactions`
|
||||
|
||||
const [expanded, setExpanded] = useState(!flood)
|
||||
const [showReplyForm, setShowReplyForm] = useState(false)
|
||||
const [showAllReplies, setShowAllReplies] = useState(false)
|
||||
const [reactionTotals, setReactionTotals] = useState(comment.reactions ?? {})
|
||||
|
||||
useEffect(() => {
|
||||
if (comment.reactions || !comment.id) return
|
||||
|
||||
axios
|
||||
.get(reactionEndpoint)
|
||||
.then(({ data }) => setReactionTotals(data.totals ?? {}))
|
||||
.catch(() => {})
|
||||
}, [comment.id, comment.reactions, reactionEndpoint])
|
||||
|
||||
const handleReplyPosted = useCallback((newReply) => {
|
||||
onReplyPosted?.(comment.id, newReply)
|
||||
setShowReplyForm(false)
|
||||
setShowAllReplies(true)
|
||||
}, [comment.id, onReplyPosted])
|
||||
|
||||
const visibleReplies = showAllReplies ? replies : replies.slice(0, 2)
|
||||
const hiddenReplyCount = replies.length - 2
|
||||
|
||||
return (
|
||||
<li
|
||||
id={`comment-${comment.id}`}
|
||||
className="group/comment rounded-2xl border border-white/[0.06] bg-white/[0.03] shadow-[0_1px_3px_rgba(0,0,0,.25)] backdrop-blur-sm transition-all duration-200 hover:border-white/[0.1] hover:bg-white/[0.05]"
|
||||
>
|
||||
<div className="p-4 sm:p-5">
|
||||
<div className="flex gap-3.5">
|
||||
{user?.profile_url ? (
|
||||
<a href={user.profile_url} className="shrink-0 mt-0.5" tabIndex={-1} aria-hidden="true">
|
||||
<Avatar user={user} size={38} />
|
||||
</a>
|
||||
) : (
|
||||
<span className="shrink-0 mt-0.5"><Avatar user={user} size={38} /></span>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{user?.profile_url ? (
|
||||
<a href={user.profile_url} className="text-[13px] font-semibold text-white/95 transition-colors hover:text-accent">
|
||||
{profileLabel}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-[13px] font-semibold text-white/95">{profileLabel}</span>
|
||||
)}
|
||||
<LevelBadge level={user?.level} rank={user?.rank} compact className="shrink-0" />
|
||||
<span className="text-white/15" aria-hidden="true">·</span>
|
||||
<time
|
||||
dateTime={comment.created_at}
|
||||
title={formatAbsoluteDateTime(comment.created_at)}
|
||||
className="text-[11px] font-medium tracking-wide text-white/30 uppercase"
|
||||
>
|
||||
{formatCommentTime(comment.time_ago, comment.created_at)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<div className={!expanded ? 'overflow-hidden relative' : undefined} style={!expanded ? { maxHeight: '5em' } : undefined}>
|
||||
{html ? (
|
||||
<div
|
||||
className="text-[13px] leading-[1.7] text-white/80 prose prose-invert prose-sm max-w-none prose-p:my-1.5 prose-a:text-sky-400 prose-a:no-underline hover:prose-a:underline prose-code:bg-white/[0.07] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-md prose-code:text-xs prose-code:font-normal"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-[13px] leading-[1.7] text-white/80 whitespace-pre-line break-words">{plain}</p>
|
||||
)}
|
||||
|
||||
{flood && !expanded ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-gradient-to-t from-nova-900/95 to-transparent" aria-hidden="true" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{flood ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
className="rounded-md px-2 py-0.5 text-xs font-medium text-sky-400 transition-all hover:bg-sky-500/10 hover:text-sky-300"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? '↑ Collapse' : '↓ Show full comment'}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||
{isLoggedIn ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowReplyForm((value) => !value)}
|
||||
className={[
|
||||
'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-[11px] font-medium uppercase tracking-wider transition-all duration-200 focus:outline-none',
|
||||
showReplyForm ? 'bg-accent/10 text-accent' : 'text-white/40 hover:bg-white/[0.06] hover:text-white/70',
|
||||
].join(' ')}
|
||||
>
|
||||
<ReplyIcon />
|
||||
Reply
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{comment.can_delete ? <DeleteButton onDelete={() => onDelete(comment.id)} pending={deletePending} /> : null}
|
||||
|
||||
<ReactionBar
|
||||
entityType="comment"
|
||||
entityId={comment.id}
|
||||
initialTotals={reactionTotals}
|
||||
isLoggedIn={isLoggedIn}
|
||||
endpoint={reactionEndpoint}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(replies.length > 0 || showReplyForm) ? (
|
||||
<div className="border-t border-white/[0.04] bg-white/[0.01] px-4 pb-4 pt-3 sm:px-5 sm:pb-5">
|
||||
{replies.length > 0 ? (
|
||||
<>
|
||||
<ul className="space-y-1 pl-4 border-l-2 border-white/[0.06]">
|
||||
{visibleReplies.map((reply) => (
|
||||
<ReplyItem
|
||||
key={reply.id}
|
||||
reply={reply}
|
||||
articleId={articleId}
|
||||
isLoggedIn={isLoggedIn}
|
||||
onReplyPosted={onReplyPosted}
|
||||
onDelete={onDelete}
|
||||
deletePending={deletePending && reply.id === comment.id}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{!showAllReplies && hiddenReplyCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllReplies(true)}
|
||||
className="mt-2 ml-4 inline-flex items-center gap-1 text-[11px] font-medium text-accent/70 transition-colors hover:text-accent"
|
||||
>
|
||||
<ChevronDownIcon className="h-3.5 w-3.5" />
|
||||
Show {hiddenReplyCount} more {hiddenReplyCount === 1 ? 'reply' : 'replies'}
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{showReplyForm ? (
|
||||
<div className={replies.length > 0 ? 'mt-3 pl-4 border-l-2 border-accent/20' : ''}>
|
||||
<CommentForm
|
||||
artworkId={articleId}
|
||||
submitUrl={`/api/news/articles/${articleId}/comments`}
|
||||
contentField="content"
|
||||
maxLength={4000}
|
||||
placeholder="Share your thoughts about this article…"
|
||||
onPosted={handleReplyPosted}
|
||||
parentId={comment.id}
|
||||
replyTo={profileLabel}
|
||||
onCancelReply={() => setShowReplyForm(false)}
|
||||
isLoggedIn={isLoggedIn}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function Skeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex gap-3.5 rounded-2xl border border-white/[0.04] bg-white/[0.02] p-5 animate-pulse"
|
||||
style={{ animationDelay: `${index * 120}ms` }}
|
||||
>
|
||||
<div className="w-[38px] h-[38px] rounded-full bg-white/[0.06] shrink-0" />
|
||||
<div className="flex-1 space-y-3 pt-1">
|
||||
<div className="flex gap-2.5">
|
||||
<div className="h-3 bg-white/[0.06] rounded-full w-24" />
|
||||
<div className="h-3 bg-white/[0.04] rounded-full w-14" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 bg-white/[0.05] rounded-full w-full" />
|
||||
<div className="h-3 bg-white/[0.04] rounded-full w-4/5" />
|
||||
<div className="h-3 bg-white/[0.03] rounded-full w-2/5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NewsComments({ articleId, isLoggedIn = false, loginUrl = '/login' }) {
|
||||
const [comments, setComments] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [lastPage, setLastPage] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [deletingId, setDeletingId] = useState(null)
|
||||
const initialized = useRef(false)
|
||||
|
||||
const loadComments = useCallback(async (nextPage = 1) => {
|
||||
if (!articleId) return
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const { data } = await axios.get(`/api/news/articles/${articleId}/comments?page=${nextPage}`)
|
||||
|
||||
if (nextPage === 1) {
|
||||
setComments(data.data ?? [])
|
||||
} else {
|
||||
setComments((current) => [...current, ...(data.data ?? [])])
|
||||
}
|
||||
|
||||
setPage(data.meta?.current_page ?? nextPage)
|
||||
setLastPage(data.meta?.last_page ?? 1)
|
||||
setTotal(data.meta?.total ?? 0)
|
||||
} catch {
|
||||
// Keep the server-rendered fallback in place if the API is unavailable.
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [articleId])
|
||||
|
||||
useEffect(() => {
|
||||
if (initialized.current) return
|
||||
initialized.current = true
|
||||
loadComments(1)
|
||||
}, [loadComments])
|
||||
|
||||
const handlePosted = useCallback((newComment) => {
|
||||
const comment = { ...newComment, replies: newComment.replies || [] }
|
||||
setComments((current) => [comment, ...current])
|
||||
setTotal((current) => current + 1)
|
||||
}, [])
|
||||
|
||||
const handleReplyPosted = useCallback((parentId, newReply) => {
|
||||
const insertReply = (nodes) => nodes.map((comment) => {
|
||||
if (comment.id === parentId) {
|
||||
return { ...comment, replies: [...(comment.replies || []), { ...newReply, replies: [] }] }
|
||||
}
|
||||
|
||||
if (comment.replies?.length) {
|
||||
return { ...comment, replies: insertReply(comment.replies) }
|
||||
}
|
||||
|
||||
return comment
|
||||
})
|
||||
|
||||
setComments((current) => insertReply(current))
|
||||
setTotal((current) => current + 1)
|
||||
}, [])
|
||||
|
||||
const handleDelete = useCallback(async (commentId) => {
|
||||
if (!commentId || deletingId) return
|
||||
|
||||
setDeletingId(commentId)
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/news/articles/${articleId}/comments/${commentId}`)
|
||||
await loadComments(1)
|
||||
} catch {
|
||||
// Preserve the current state if deletion fails.
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}, [articleId, deletingId, loadComments])
|
||||
|
||||
return (
|
||||
<section aria-label="Comments" className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold tracking-tight text-white sm:text-xl">Comments</h2>
|
||||
{total > 0 ? (
|
||||
<span className="inline-flex items-center rounded-full bg-white/[0.06] px-2.5 py-0.5 text-xs font-medium tabular-nums text-white/50">
|
||||
{total}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{loading && comments.length === 0 ? (
|
||||
<Skeleton />
|
||||
) : comments.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-white/[0.08] bg-white/[0.015] px-6 py-10 text-center">
|
||||
<ChatBubbleIcon />
|
||||
<p className="text-sm font-medium text-white/40">No comments yet</p>
|
||||
<p className="text-xs text-white/25">Be the first to share your thoughts.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ul className="space-y-3 sm:space-y-4">
|
||||
{comments.map((comment) => (
|
||||
<CommentItem
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
articleId={articleId}
|
||||
isLoggedIn={isLoggedIn}
|
||||
onReplyPosted={handleReplyPosted}
|
||||
onDelete={handleDelete}
|
||||
deletePending={deletingId === comment.id}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{page < lastPage ? (
|
||||
<div className="flex justify-center pt-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => loadComments(page + 1)}
|
||||
className="group relative rounded-full border border-white/[0.08] bg-white/[0.03] px-6 py-2.5 text-sm font-medium text-white/50 transition-all duration-200 hover:border-white/[0.15] hover:bg-white/[0.06] hover:text-white/80 hover:shadow-lg hover:shadow-black/20 disabled:opacity-40 disabled:pointer-events-none"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Loading…
|
||||
</span>
|
||||
) : 'Load more comments'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{articleId ? (
|
||||
<CommentForm
|
||||
artworkId={articleId}
|
||||
submitUrl={`/api/news/articles/${articleId}/comments`}
|
||||
contentField="content"
|
||||
maxLength={4000}
|
||||
placeholder="Share your thoughts about this article…"
|
||||
onPosted={handlePosted}
|
||||
isLoggedIn={isLoggedIn}
|
||||
loginUrl={loginUrl}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
const mountEl = document.getElementById('news-comments-root')
|
||||
const propsEl = document.getElementById('news-comments-props')
|
||||
|
||||
if (mountEl && propsEl) {
|
||||
let props = {}
|
||||
|
||||
try {
|
||||
props = JSON.parse(propsEl.textContent || '{}')
|
||||
} catch {
|
||||
props = {}
|
||||
}
|
||||
|
||||
createRoot(mountEl).render(<NewsComments {...props} />)
|
||||
}
|
||||
}
|
||||
|
||||
export default NewsComments
|
||||
@@ -3,6 +3,7 @@ import { router, useForm, usePage } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import RichTextEditor from '../../components/forum/RichTextEditor'
|
||||
import WorldMediaUploadField from '../../components/worlds/editor/WorldMediaUploadField'
|
||||
import DateTimePicker from '../../components/ui/DateTimePicker'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
import { Checkbox } from '../../components/ui'
|
||||
|
||||
@@ -312,6 +313,7 @@ function buildSubmitPayload(data) {
|
||||
published_at: data.published_at ? String(data.published_at) : null,
|
||||
is_featured: Boolean(data.is_featured),
|
||||
is_pinned: Boolean(data.is_pinned),
|
||||
comments_enabled: Boolean(data.comments_enabled),
|
||||
tag_ids: Array.isArray(data.tag_ids) ? data.tag_ids.map((id) => Number(id)).filter(Boolean) : [],
|
||||
new_tag_names: Array.isArray(data.new_tag_names) ? data.new_tag_names.map((name) => normalizeNewTagName(name)).filter(Boolean) : [],
|
||||
meta_title: String(data.meta_title || ''),
|
||||
@@ -345,6 +347,7 @@ function buildInitialFormData(article, defaultAuthor, typeOptions) {
|
||||
published_at: article.published_at ? String(article.published_at).slice(0, 16) : '',
|
||||
is_featured: Boolean(article.is_featured),
|
||||
is_pinned: Boolean(article.is_pinned),
|
||||
comments_enabled: Boolean(article.comments_enabled),
|
||||
tag_ids: Array.isArray(article.tag_ids) ? article.tag_ids : [],
|
||||
new_tag_names: [],
|
||||
meta_title: article.meta_title || '',
|
||||
@@ -679,11 +682,11 @@ export default function StudioNewsEditor() {
|
||||
<div className="grid gap-2 text-sm text-slate-300">
|
||||
<NovaSelect label="Workflow status" value={form.data.editorial_status || null} onChange={(nextValue) => form.setData('editorial_status', String(nextValue || ''))} options={statusOptions} searchable={false} className="bg-black/20" error={form.errors.editorial_status} />
|
||||
</div>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<div className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Publish at</span>
|
||||
<input type="datetime-local" value={form.data.published_at || ''} onChange={(event) => form.setData('published_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<DateTimePicker value={form.data.published_at || ''} onChange={(nextValue) => form.setData('published_at', nextValue)} placeholder="Pick a publish slot" clearable className="bg-black/20" />
|
||||
<FieldError message={form.errors.published_at} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
@@ -714,6 +717,11 @@ export default function StudioNewsEditor() {
|
||||
<Checkbox checked={form.data.is_pinned} onChange={(event) => form.setData('is_pinned', event.target.checked)} label="Pin to the top of the newsroom" size={20} variant="accent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
<Checkbox checked={form.data.comments_enabled} onChange={(event) => form.setData('comments_enabled', event.target.checked)} label="Allow comments on the article page" size={20} variant="accent" />
|
||||
<FieldError message={form.errors.comments_enabled} />
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
|
||||
@@ -85,6 +85,12 @@ export default function CommentForm({
|
||||
replyTo = null,
|
||||
onCancelReply = null,
|
||||
compact = false,
|
||||
submitUrl = null,
|
||||
contentField = 'content',
|
||||
maxLength = 10000,
|
||||
placeholder = 'Share your thoughts…',
|
||||
submitLabel = 'Post comment',
|
||||
submittingLabel = 'Posting…',
|
||||
}) {
|
||||
const [content, setContent] = useState('')
|
||||
const [tab, setTab] = useState('write') // 'write' | 'preview'
|
||||
@@ -92,6 +98,8 @@ export default function CommentForm({
|
||||
const [errors, setErrors] = useState([])
|
||||
const textareaRef = useRef(null)
|
||||
const formRef = useRef(null)
|
||||
const resolvedSubmitUrl = submitUrl || (artworkId ? `/api/artworks/${artworkId}/comments` : null)
|
||||
const warningThreshold = Math.max(1, Math.floor(maxLength * 0.9))
|
||||
|
||||
// Auto-focus when entering reply mode
|
||||
useEffect(() => {
|
||||
@@ -237,14 +245,14 @@ export default function CommentForm({
|
||||
}
|
||||
|
||||
const trimmed = content.trim()
|
||||
if (!trimmed) return
|
||||
if (!trimmed || !resolvedSubmitUrl) return
|
||||
|
||||
setSubmitting(true)
|
||||
setErrors([])
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(`/api/artworks/${artworkId}/comments`, {
|
||||
content: trimmed,
|
||||
const { data } = await axios.post(resolvedSubmitUrl, {
|
||||
[contentField]: trimmed,
|
||||
parent_id: parentId || null,
|
||||
})
|
||||
|
||||
@@ -255,7 +263,12 @@ export default function CommentForm({
|
||||
} catch (err) {
|
||||
if (err.response?.status === 422) {
|
||||
const fieldErrors = err.response.data?.errors ?? {}
|
||||
const allErrors = Object.values(fieldErrors).flat()
|
||||
const allErrors = [
|
||||
...(Array.isArray(fieldErrors[contentField]) ? fieldErrors[contentField] : []),
|
||||
...Object.entries(fieldErrors)
|
||||
.filter(([field]) => field !== contentField)
|
||||
.flatMap(([, messages]) => Array.isArray(messages) ? messages : []),
|
||||
]
|
||||
setErrors(allErrors.length ? allErrors : ['Invalid content.'])
|
||||
} else {
|
||||
setErrors(['Something went wrong. Please try again.'])
|
||||
@@ -264,7 +277,7 @@ export default function CommentForm({
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
[artworkId, content, isLoggedIn, loginUrl, onPosted, parentId, onCancelReply],
|
||||
[content, contentField, isLoggedIn, loginUrl, onPosted, parentId, onCancelReply, resolvedSubmitUrl],
|
||||
)
|
||||
|
||||
/* ── Logged-out state ─────────────────────────────────────────────────── */
|
||||
@@ -342,10 +355,10 @@ export default function CommentForm({
|
||||
<span
|
||||
className={[
|
||||
'text-[11px] tabular-nums font-medium transition-colors',
|
||||
content.length > 9000 ? 'text-amber-400/80' : 'text-white/20',
|
||||
content.length > warningThreshold ? 'text-amber-400/80' : 'text-white/20',
|
||||
].join(' ')}
|
||||
>
|
||||
{content.length > 0 && `${content.length.toLocaleString()}/10,000`}
|
||||
{content.length > 0 && `${content.length.toLocaleString()}/${maxLength.toLocaleString()}`}
|
||||
</span>
|
||||
<EmojiPickerButton onEmojiSelect={handleEmojiSelect} disabled={submitting} />
|
||||
</div>
|
||||
@@ -385,9 +398,9 @@ export default function CommentForm({
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={replyTo ? `Reply to ${replyTo}…` : 'Share your thoughts…'}
|
||||
placeholder={replyTo ? `Reply to ${replyTo}…` : placeholder}
|
||||
rows={compact ? 2 : 4}
|
||||
maxLength={10000}
|
||||
maxLength={maxLength}
|
||||
disabled={submitting}
|
||||
aria-label="Comment text"
|
||||
className="w-full resize-none bg-transparent px-4 py-3 text-[13px] leading-relaxed text-white/90 placeholder-white/25 focus:outline-none disabled:opacity-50"
|
||||
@@ -451,10 +464,10 @@ export default function CommentForm({
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Posting…
|
||||
{submittingLabel}
|
||||
</span>
|
||||
) : (
|
||||
'Post comment'
|
||||
submitLabel
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -32,18 +32,24 @@ function HeartOutlineIcon({ className }) {
|
||||
* entityId number
|
||||
* initialTotals Record<slug, { emoji, label, count, mine }>
|
||||
* isLoggedIn boolean
|
||||
* endpoint string | null
|
||||
*/
|
||||
export default function ReactionBar({ entityType, entityId, initialTotals = {}, isLoggedIn = false }) {
|
||||
export default function ReactionBar({ entityType, entityId, initialTotals = {}, isLoggedIn = false, endpoint = null }) {
|
||||
const [totals, setTotals] = useState(initialTotals)
|
||||
const [loading, setLoading] = useState(null)
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const containerRef = useRef(null)
|
||||
const hoverTimeout = useRef(null)
|
||||
|
||||
const endpoint =
|
||||
useEffect(() => {
|
||||
setTotals(initialTotals ?? {})
|
||||
}, [entityId, initialTotals])
|
||||
|
||||
const resolvedEndpoint = endpoint || (
|
||||
entityType === 'artwork'
|
||||
? `/api/artworks/${entityId}/reactions`
|
||||
: `/api/comments/${entityId}/reactions`
|
||||
)
|
||||
|
||||
// Close picker when clicking outside
|
||||
useEffect(() => {
|
||||
@@ -81,7 +87,7 @@ export default function ReactionBar({ entityType, entityId, initialTotals = {},
|
||||
})
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(endpoint, { reaction: slug })
|
||||
const { data } = await axios.post(resolvedEndpoint, { reaction: slug })
|
||||
setTotals(data.totals)
|
||||
} catch {
|
||||
setTotals((prev) => {
|
||||
@@ -99,7 +105,7 @@ export default function ReactionBar({ entityType, entityId, initialTotals = {},
|
||||
setLoading(null)
|
||||
}
|
||||
},
|
||||
[endpoint, isLoggedIn, loading],
|
||||
[resolvedEndpoint, isLoggedIn, loading],
|
||||
)
|
||||
|
||||
// Compute summary data
|
||||
@@ -167,7 +173,7 @@ export default function ReactionBar({ entityType, entityId, initialTotals = {},
|
||||
aria-label={isArtworkVariant
|
||||
? (myReaction
|
||||
? `Open reaction picker. Current reaction: ${myReactionData?.label}.`
|
||||
: 'Open reaction picker for this artwork')
|
||||
: 'React to this artwork')
|
||||
: (myReaction
|
||||
? `You reacted with ${myReactionData?.label}. Click to remove.`
|
||||
: 'React to this comment')}
|
||||
|
||||
120
resources/views/news/_comments.blade.php
Normal file
120
resources/views/news/_comments.blade.php
Normal file
@@ -0,0 +1,120 @@
|
||||
@php
|
||||
$commentsCollection = $comments ?? collect();
|
||||
$commentsCount = $commentsCount ?? $commentsCollection->count();
|
||||
$viewer = auth()->user();
|
||||
@endphp
|
||||
|
||||
@if($isPreview)
|
||||
@if($article->commentsAreEnabled())
|
||||
<section id="comments" class="mt-8 rounded-[32px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.94),rgba(7,11,19,0.92))] p-6 shadow-[0_18px_45px_rgba(0,0,0,0.2)] sm:p-8">
|
||||
<div class="rounded-2xl border border-indigo-300/20 bg-indigo-400/10 px-5 py-4 text-sm text-indigo-100">
|
||||
Comments are enabled for this article, but posting is disabled in preview mode.
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
@elseif($article->commentsAreEnabled())
|
||||
<section id="comments" class="mt-8 rounded-[32px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.94),rgba(7,11,19,0.92))] p-6 shadow-[0_18px_45px_rgba(0,0,0,0.2)] sm:p-8">
|
||||
<div class="flex flex-col gap-3 border-b border-white/[0.06] pb-6 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">Conversation</p>
|
||||
<h2 class="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">{{ number_format((int) $commentsCount) }} {{ (int) $commentsCount === 1 ? 'Comment' : 'Comments' }}</h2>
|
||||
<p class="mt-2 max-w-2xl text-sm leading-6 text-white/50">Keep the discussion focused on the article. Safe markdown formatting is supported for signed-in members.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(session('status'))
|
||||
<div class="mt-6 rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
|
||||
{{ session('status') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($errors->has('body'))
|
||||
<div class="mt-6 rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
|
||||
{{ $errors->first('body') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@auth
|
||||
<form method="POST" action="{{ route('news.comments.store', ['slug' => $article->slug]) }}" class="mt-6 rounded-[28px] border border-white/[0.06] bg-black/20 p-4 sm:p-5">
|
||||
@csrf
|
||||
<label class="block">
|
||||
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/35">Add your comment</span>
|
||||
<textarea name="body" rows="4" maxlength="4000" placeholder="Share your thoughts about this article" class="mt-3 w-full rounded-2xl border border-white/[0.08] bg-black/30 px-4 py-3 text-sm leading-6 text-white placeholder:text-white/25 outline-none transition focus:border-sky-400/40 focus:bg-black/40">{{ old('body') }}</textarea>
|
||||
</label>
|
||||
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="text-xs leading-5 text-white/35">Basic markdown is supported. Replies and reactions use the enhanced editor when JavaScript is available.</p>
|
||||
<button type="submit" class="inline-flex items-center gap-2 rounded-full border border-sky-400/20 bg-sky-500/10 px-4 py-2.5 text-sm font-semibold text-sky-100 transition hover:bg-sky-500/15">
|
||||
<i class="fa-solid fa-paper-plane text-xs"></i>
|
||||
Post comment
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@else
|
||||
<div class="mt-6 rounded-[28px] border border-white/[0.06] bg-black/20 p-5 text-sm text-white/55">
|
||||
<a href="{{ route('login') }}" class="font-semibold text-sky-200 transition hover:text-white">Sign in</a>
|
||||
to join the discussion on this article.
|
||||
</div>
|
||||
@endauth
|
||||
|
||||
@if($commentsCollection->isEmpty())
|
||||
<div class="mt-6 rounded-[28px] border border-dashed border-white/[0.08] bg-white/[0.02] px-6 py-12 text-center">
|
||||
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.03] text-white/25">
|
||||
<i class="fa-regular fa-comments text-xl"></i>
|
||||
</div>
|
||||
<h3 class="mt-4 text-lg font-semibold text-white">No comments yet</h3>
|
||||
<p class="mx-auto mt-2 max-w-xl text-sm leading-6 text-white/40">Start the conversation if you have feedback, context, or a question about this update.</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="mt-6 space-y-4">
|
||||
@foreach($commentsCollection as $comment)
|
||||
@php
|
||||
$commentUser = $comment->user;
|
||||
$displayName = $commentUser?->name ?: $commentUser?->username ?: $comment->author_name ?: 'Former member';
|
||||
$username = $commentUser?->username;
|
||||
$profileUrl = $username ? '/@' . strtolower((string) $username) : null;
|
||||
$canDelete = $viewer && ((int) $viewer->id === (int) $comment->user_id || (int) $viewer->id === (int) $article->author_id || $viewer->isAdmin() || $viewer->isModerator());
|
||||
@endphp
|
||||
<article class="rounded-[28px] border border-white/[0.06] bg-black/20 p-5">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
@if($commentUser)
|
||||
<img src="{{ \App\Support\AvatarUrl::forUser((int) $commentUser->id, $commentUser->profile?->avatar_hash, 64) }}" alt="{{ $displayName }}" class="h-11 w-11 rounded-full border border-white/[0.08] object-cover">
|
||||
@else
|
||||
<div class="flex h-11 w-11 items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.04] text-sm font-semibold text-white/55">
|
||||
{{ \Illuminate\Support\Str::upper(\Illuminate\Support\Str::substr($displayName, 0, 1)) }}
|
||||
</div>
|
||||
@endif
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
@if($profileUrl)
|
||||
<a href="{{ $profileUrl }}" class="truncate text-sm font-semibold text-white transition hover:text-sky-300">{{ $displayName }}</a>
|
||||
<span class="text-xs uppercase tracking-[0.16em] text-white/25">{{ '@' . $username }}</span>
|
||||
@else
|
||||
<span class="truncate text-sm font-semibold text-white">{{ $displayName }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<p class="mt-1 text-xs uppercase tracking-[0.16em] text-white/25">{{ optional($comment->created_at)->diffForHumans() ?? 'Unknown time' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($canDelete)
|
||||
<form method="POST" action="{{ route('news.comments.destroy', ['slug' => $article->slug, 'comment' => $comment->id]) }}">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="inline-flex items-center gap-2 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] text-white/55 transition hover:border-rose-300/20 hover:bg-rose-500/10 hover:text-rose-100">
|
||||
<i class="fa-regular fa-trash-can text-[11px]"></i>
|
||||
Remove
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="story-prose prose prose-invert mt-4 max-w-none text-[0.98rem] leading-7 prose-p:text-white/74">
|
||||
{!! $comment->getDisplayHtml() !!}
|
||||
</div>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
@endif
|
||||
@@ -64,6 +64,7 @@
|
||||
:breadcrumbs="$headerBreadcrumbs"
|
||||
:description="$article->excerpt ? Str::limit(strip_tags((string) $article->excerpt), 180) : 'Latest Skinbase announcement and community update.'"
|
||||
headerClass="pb-6"
|
||||
innerClass="mx-auto max-w-7xl"
|
||||
>
|
||||
<x-slot name="actions">
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm text-white/60">
|
||||
@@ -125,7 +126,7 @@
|
||||
<p class="mt-5 text-lg leading-8 text-white/65">{{ $article->excerpt }}</p>
|
||||
@endif
|
||||
|
||||
<div class="prose prose-invert prose-sky mt-8 max-w-none prose-p:text-white/72 prose-li:text-white/70 prose-strong:text-white prose-a:text-sky-300 hover:prose-a:text-sky-200 prose-headings:text-white">
|
||||
<div class="story-prose prose prose-invert mt-8 max-w-none text-[1.02rem] leading-8 prose-p:text-white/72 prose-strong:text-white prose-headings:text-white [&_img]:rounded-[24px] [&_img]:border [&_img]:border-white/[0.08] [&_img]:shadow-[0_20px_45px_rgba(0,0,0,0.24)]">
|
||||
{!! $article->rendered_content !!}
|
||||
</div>
|
||||
|
||||
@@ -161,6 +162,34 @@
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($article->commentsAreEnabled() && ! $isPreview)
|
||||
<script id="news-comments-props" type="application/json">
|
||||
{!! json_encode([
|
||||
'articleId' => (int) $article->id,
|
||||
'isLoggedIn' => auth()->check(),
|
||||
'loginUrl' => route('login'),
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) !!}
|
||||
</script>
|
||||
|
||||
<div id="news-comments-root">
|
||||
@include('news._comments', [
|
||||
'article' => $article,
|
||||
'comments' => $comments ?? collect(),
|
||||
'commentsCount' => $commentsCount ?? 0,
|
||||
'isPreview' => $isPreview,
|
||||
])
|
||||
</div>
|
||||
|
||||
@vite(['resources/js/Pages/News/NewsComments.jsx'])
|
||||
@else
|
||||
@include('news._comments', [
|
||||
'article' => $article,
|
||||
'comments' => $comments ?? collect(),
|
||||
'commentsCount' => $commentsCount ?? 0,
|
||||
'isPreview' => $isPreview,
|
||||
])
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@include('news._related_entities', ['relatedEntities' => $relatedEntities ?? []])
|
||||
|
||||
Reference in New Issue
Block a user