update
This commit is contained in:
33
resources/js/components/social/BookmarkButton.jsx
Normal file
33
resources/js/components/social/BookmarkButton.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
export default function BookmarkButton({ active = false, count = 0, onToggle, label = 'Save', activeLabel = 'Saved' }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const handleClick = async () => {
|
||||
if (!onToggle || busy) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await onToggle()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={busy}
|
||||
className={[
|
||||
'inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-medium transition-all disabled:cursor-not-allowed disabled:opacity-60',
|
||||
active
|
||||
? 'border-amber-400/30 bg-amber-400/12 text-amber-200'
|
||||
: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white',
|
||||
].join(' ')}
|
||||
>
|
||||
<i className={`fa-solid fa-fw ${busy ? 'fa-circle-notch fa-spin' : 'fa-bookmark'}`} />
|
||||
<span>{active ? activeLabel : label}</span>
|
||||
<span className="text-xs opacity-80">{Number(count || 0).toLocaleString()}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
61
resources/js/components/social/CommentForm.jsx
Normal file
61
resources/js/components/social/CommentForm.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
export default function CommentForm({ placeholder = 'Write a comment…', submitLabel = 'Post', onSubmit, onCancel, compact = false }) {
|
||||
const [content, setContent] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
const trimmed = content.trim()
|
||||
if (!trimmed || busy) return
|
||||
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await onSubmit?.(trimmed)
|
||||
setContent('')
|
||||
} catch (submitError) {
|
||||
setError(submitError?.message || 'Unable to post comment.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="space-y-3" onSubmit={handleSubmit}>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
rows={compact ? 3 : 4}
|
||||
maxLength={10000}
|
||||
placeholder={placeholder}
|
||||
className="w-full rounded-2xl border border-white/[0.08] bg-white/[0.04] px-4 py-3 text-sm text-white placeholder-white/35 outline-none transition focus:border-sky-400/40 focus:bg-white/[0.06]"
|
||||
/>
|
||||
|
||||
{error ? <p className="text-sm text-rose-300">{error}</p> : null}
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-white/35">{content.trim().length}/10000</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{onCancel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || content.trim().length === 0}
|
||||
className="rounded-full bg-sky-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy ? 'Posting…' : submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
87
resources/js/components/social/CommentList.jsx
Normal file
87
resources/js/components/social/CommentList.jsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React, { useState } from 'react'
|
||||
import LevelBadge from '../xp/LevelBadge'
|
||||
import CommentForm from './CommentForm'
|
||||
|
||||
function CommentItem({ comment, canReply, onReply, onDelete }) {
|
||||
const [replying, setReplying] = useState(false)
|
||||
|
||||
return (
|
||||
<article id={`story-comment-${comment.id}`} className="rounded-2xl border border-white/[0.08] bg-white/[0.04] p-4">
|
||||
<div className="flex gap-3">
|
||||
<img
|
||||
src={comment.user?.avatar_url || 'https://files.skinbase.org/default/avatar_default.webp'}
|
||||
alt={comment.user?.display || 'User'}
|
||||
className="h-10 w-10 rounded-full object-cover ring-1 ring-white/10"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{comment.user?.profile_url ? (
|
||||
<a href={comment.user.profile_url} className="text-sm font-semibold text-white hover:text-sky-300">
|
||||
{comment.user.display}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm font-semibold text-white">{comment.user?.display || 'User'}</span>
|
||||
)}
|
||||
<LevelBadge level={comment.user?.level} rank={comment.user?.rank} compact />
|
||||
<span className="text-xs uppercase tracking-[0.16em] text-white/30">{comment.time_ago}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="prose prose-invert prose-sm mt-2 max-w-none text-white/80 prose-p:my-1.5 prose-a:text-sky-300"
|
||||
dangerouslySetInnerHTML={{ __html: comment.rendered_content || '' }}
|
||||
/>
|
||||
|
||||
<div className="mt-3 flex items-center gap-3 text-xs text-white/45">
|
||||
{canReply ? (
|
||||
<button type="button" onClick={() => setReplying((value) => !value)} className="transition hover:text-white">
|
||||
Reply
|
||||
</button>
|
||||
) : null}
|
||||
{comment.can_delete ? (
|
||||
<button type="button" onClick={() => onDelete?.(comment.id)} className="transition hover:text-rose-300">
|
||||
Delete
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{replying ? (
|
||||
<div className="mt-3">
|
||||
<CommentForm
|
||||
compact
|
||||
placeholder={`Reply to ${comment.user?.display || 'user'}…`}
|
||||
submitLabel="Reply"
|
||||
onCancel={() => setReplying(false)}
|
||||
onSubmit={async (content) => {
|
||||
await onReply?.(comment.id, content)
|
||||
setReplying(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{Array.isArray(comment.replies) && comment.replies.length > 0 ? (
|
||||
<div className="mt-4 space-y-3 border-l border-white/[0.08] pl-4">
|
||||
{comment.replies.map((reply) => (
|
||||
<CommentItem key={reply.id} comment={reply} canReply={canReply} onReply={onReply} onDelete={onDelete} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CommentList({ comments = [], canReply = false, onReply, onDelete, emptyMessage = 'No comments yet.' }) {
|
||||
if (!comments.length) {
|
||||
return <p className="text-sm text-white/45">{emptyMessage}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{comments.map((comment) => (
|
||||
<CommentItem key={comment.id} comment={comment} canReply={canReply} onReply={onReply} onDelete={onDelete} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
97
resources/js/components/social/FollowButton.jsx
Normal file
97
resources/js/components/social/FollowButton.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { useState } from 'react'
|
||||
import NovaConfirmDialog from '../ui/NovaConfirmDialog'
|
||||
|
||||
export default function FollowButton({
|
||||
username,
|
||||
initialFollowing = false,
|
||||
initialCount = 0,
|
||||
showCount = true,
|
||||
className = '',
|
||||
followingClassName = 'bg-white/[0.04] border border-white/[0.08] text-white/75 hover:bg-white/[0.08]',
|
||||
idleClassName = 'bg-accent text-deep hover:brightness-110',
|
||||
sizeClassName = 'px-4 py-2.5 text-sm',
|
||||
confirmMessage,
|
||||
onChange,
|
||||
}) {
|
||||
const [following, setFollowing] = useState(Boolean(initialFollowing))
|
||||
const [count, setCount] = useState(Number(initialCount || 0))
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
|
||||
const csrfToken = typeof document !== 'undefined'
|
||||
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
: null
|
||||
|
||||
const persist = async (nextState) => {
|
||||
if (!username || loading) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch(`/api/user/${encodeURIComponent(username)}/follow`, {
|
||||
method: nextState ? 'POST' : 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken || '',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Follow request failed')
|
||||
const payload = await response.json()
|
||||
const nextFollowing = Boolean(payload?.following)
|
||||
const nextCount = Number(payload?.followers_count ?? count)
|
||||
setFollowing(nextFollowing)
|
||||
setCount(nextCount)
|
||||
onChange?.({ following: nextFollowing, followersCount: nextCount })
|
||||
} catch {
|
||||
// Keep previous state on failure.
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onToggle = async () => {
|
||||
if (!following) {
|
||||
await persist(true)
|
||||
return
|
||||
}
|
||||
|
||||
setConfirmOpen(true)
|
||||
}
|
||||
|
||||
const toneClassName = following ? followingClassName : idleClassName
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
disabled={loading || !username}
|
||||
aria-label={following ? 'Unfollow creator' : 'Follow creator'}
|
||||
className={[
|
||||
'inline-flex items-center justify-center gap-2 rounded-xl font-semibold transition-all disabled:cursor-not-allowed disabled:opacity-60',
|
||||
sizeClassName,
|
||||
toneClassName,
|
||||
className,
|
||||
].join(' ')}
|
||||
>
|
||||
<i className={`fa-solid fa-fw ${loading ? 'fa-circle-notch fa-spin' : following ? 'fa-user-check' : 'fa-user-plus'}`} />
|
||||
<span>{following ? 'Following' : 'Follow'}</span>
|
||||
{showCount ? <span className="text-xs opacity-70">{count.toLocaleString()}</span> : null}
|
||||
</button>
|
||||
|
||||
<NovaConfirmDialog
|
||||
open={confirmOpen}
|
||||
title="Unfollow creator?"
|
||||
message={confirmMessage || `You will stop seeing updates from @${username} in your following feed.`}
|
||||
confirmLabel="Unfollow"
|
||||
cancelLabel="Keep following"
|
||||
confirmTone="danger"
|
||||
onConfirm={async () => {
|
||||
setConfirmOpen(false)
|
||||
await persist(false)
|
||||
}}
|
||||
onClose={() => setConfirmOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
33
resources/js/components/social/LikeButton.jsx
Normal file
33
resources/js/components/social/LikeButton.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
export default function LikeButton({ active = false, count = 0, onToggle, label = 'Like', activeLabel = 'Liked' }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const handleClick = async () => {
|
||||
if (!onToggle || busy) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await onToggle()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={busy}
|
||||
className={[
|
||||
'inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-medium transition-all disabled:cursor-not-allowed disabled:opacity-60',
|
||||
active
|
||||
? 'border-rose-500/30 bg-rose-500/12 text-rose-300'
|
||||
: 'border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white',
|
||||
].join(' ')}
|
||||
>
|
||||
<i className={`fa-solid fa-fw ${busy ? 'fa-circle-notch fa-spin' : active ? 'fa-heart' : 'fa-heart'}`} />
|
||||
<span>{active ? activeLabel : label}</span>
|
||||
<span className="text-xs opacity-80">{Number(count || 0).toLocaleString()}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
163
resources/js/components/social/NotificationDropdown.jsx
Normal file
163
resources/js/components/social/NotificationDropdown.jsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
|
||||
export default function NotificationDropdown({ initialUnreadCount = 0, notificationsUrl = '/api/notifications' }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [items, setItems] = useState([])
|
||||
const [unreadCount, setUnreadCount] = useState(Number(initialUnreadCount || 0))
|
||||
const rootRef = useRef(null)
|
||||
const csrfToken = typeof document !== 'undefined'
|
||||
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined
|
||||
|
||||
const onDocumentClick = (event) => {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', onDocumentClick)
|
||||
return () => document.removeEventListener('mousedown', onDocumentClick)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || items.length > 0) return
|
||||
|
||||
setLoading(true)
|
||||
fetch(notificationsUrl, {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error('Failed to load notifications')
|
||||
return response.json()
|
||||
})
|
||||
.then((payload) => {
|
||||
setItems(Array.isArray(payload?.data) ? payload.data : [])
|
||||
setUnreadCount(Number(payload?.unread_count || 0))
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [items.length, notificationsUrl, open])
|
||||
|
||||
const markAllRead = async () => {
|
||||
try {
|
||||
await fetch('/api/notifications/read-all', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken || '',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
setItems((current) => current.map((item) => ({ ...item, read: true })))
|
||||
setUnreadCount(0)
|
||||
} catch {
|
||||
// Keep current state on failure.
|
||||
}
|
||||
}
|
||||
|
||||
const markSingleRead = async (id) => {
|
||||
await fetch(`/api/notifications/${id}/read`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken || '',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
|
||||
setItems((current) => current.map((item) => item.id === id ? { ...item, read: true } : item))
|
||||
setUnreadCount((current) => Math.max(0, current - 1))
|
||||
}
|
||||
|
||||
const handleNotificationClick = async (event, item) => {
|
||||
if (!item?.id || item.read) return
|
||||
|
||||
const href = event.currentTarget.getAttribute('href')
|
||||
if (!href) return
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
try {
|
||||
await markSingleRead(item.id)
|
||||
} catch {
|
||||
// Continue to the destination even if marking as read fails.
|
||||
}
|
||||
|
||||
window.location.assign(href)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="relative inline-flex h-10 w-10 items-center justify-center rounded-lg text-white/75 transition hover:bg-white/5 hover:text-white"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 8a6 6 0 10-12 0c0 7-3 7-3 7h18s-3 0-3-7" />
|
||||
<path d="M13.7 21a2 2 0 01-3.4 0" />
|
||||
</svg>
|
||||
{unreadCount > 0 ? (
|
||||
<span className="absolute -bottom-1 right-0 rounded bg-red-700/80 px-1.5 py-0.5 text-[11px] font-semibold text-white border border-sb-line">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div className="absolute right-0 mt-2 w-[22rem] overflow-hidden rounded-2xl border border-white/[0.08] bg-panel shadow-2xl shadow-black/35">
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] px-4 py-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">Notifications</h3>
|
||||
<p className="text-xs text-white/35">Recent follows, likes, comments, and unlocks.</p>
|
||||
</div>
|
||||
{unreadCount > 0 ? (
|
||||
<button type="button" onClick={markAllRead} className="text-xs font-medium text-sky-300 transition hover:text-sky-200">
|
||||
Mark all read
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[28rem] overflow-y-auto">
|
||||
{loading ? <div className="px-4 py-6 text-sm text-white/45">Loading notifications…</div> : null}
|
||||
{!loading && items.length === 0 ? <div className="px-4 py-6 text-sm text-white/45">No notifications yet.</div> : null}
|
||||
{!loading && items.map((item) => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={item.url || '/dashboard/comments/received'}
|
||||
onClick={(event) => handleNotificationClick(event, item)}
|
||||
className={[
|
||||
'flex gap-3 border-b border-white/[0.05] px-4 py-3 transition hover:bg-white/[0.04]',
|
||||
item.read ? 'bg-transparent' : 'bg-sky-500/[0.06]',
|
||||
].join(' ')}
|
||||
>
|
||||
<img
|
||||
src={item.actor?.avatar_url || 'https://files.skinbase.org/default/avatar_default.webp'}
|
||||
alt={item.actor?.name || 'Notification'}
|
||||
className="h-10 w-10 rounded-full object-cover ring-1 ring-white/10"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-white/85">{item.message}</p>
|
||||
<p className="mt-1 text-xs uppercase tracking-[0.16em] text-white/30">{item.time_ago || ''}</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/[0.06] bg-white/[0.02] px-4 py-3">
|
||||
<a href="/dashboard/notifications" className="inline-flex items-center gap-2 text-sm font-medium text-sky-300 transition hover:text-sky-200">
|
||||
<i className="fa-solid fa-arrow-right" aria-hidden="true" />
|
||||
View all notifications
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
161
resources/js/components/social/StorySocialPanel.jsx
Normal file
161
resources/js/components/social/StorySocialPanel.jsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React, { useState } from 'react'
|
||||
import FollowButton from './FollowButton'
|
||||
import LikeButton from './LikeButton'
|
||||
import BookmarkButton from './BookmarkButton'
|
||||
import CommentForm from './CommentForm'
|
||||
import CommentList from './CommentList'
|
||||
|
||||
export default function StorySocialPanel({ story, creator, initialState, initialComments, isAuthenticated = false }) {
|
||||
const [state, setState] = useState({
|
||||
liked: Boolean(initialState?.liked),
|
||||
bookmarked: Boolean(initialState?.bookmarked),
|
||||
likesCount: Number(initialState?.likes_count || 0),
|
||||
commentsCount: Number(initialState?.comments_count || 0),
|
||||
bookmarksCount: Number(initialState?.bookmarks_count || 0),
|
||||
})
|
||||
const [comments, setComments] = useState(Array.isArray(initialComments) ? initialComments : [])
|
||||
const csrfToken = typeof document !== 'undefined'
|
||||
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
: null
|
||||
|
||||
const postJson = async (url, method = 'POST', body = null) => {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken || '',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: body ? JSON.stringify(body) : null,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
throw new Error(payload?.message || 'Request failed.')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
const refreshCounts = (nextComments) => {
|
||||
const countReplies = (items) => items.reduce((sum, item) => sum + 1 + countReplies(item.replies || []), 0)
|
||||
return countReplies(nextComments)
|
||||
}
|
||||
|
||||
const insertReply = (items, parentId, newComment) => items.map((item) => {
|
||||
if (item.id === parentId) {
|
||||
return { ...item, replies: [...(item.replies || []), newComment] }
|
||||
}
|
||||
|
||||
if (Array.isArray(item.replies) && item.replies.length > 0) {
|
||||
return { ...item, replies: insertReply(item.replies, parentId, newComment) }
|
||||
}
|
||||
|
||||
return item
|
||||
})
|
||||
|
||||
const deleteCommentRecursive = (items, commentId) => items
|
||||
.filter((item) => item.id !== commentId)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
replies: Array.isArray(item.replies) ? deleteCommentRecursive(item.replies, commentId) : [],
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<LikeButton
|
||||
active={state.liked}
|
||||
count={state.likesCount}
|
||||
onToggle={async () => {
|
||||
if (!isAuthenticated) {
|
||||
window.location.href = '/login'
|
||||
return
|
||||
}
|
||||
|
||||
const payload = await postJson(`/api/stories/${story.id}/like`, 'POST', { state: !state.liked })
|
||||
setState((current) => ({
|
||||
...current,
|
||||
liked: Boolean(payload?.liked),
|
||||
likesCount: Number(payload?.likes_count || 0),
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
|
||||
<BookmarkButton
|
||||
active={state.bookmarked}
|
||||
count={state.bookmarksCount}
|
||||
onToggle={async () => {
|
||||
if (!isAuthenticated) {
|
||||
window.location.href = '/login'
|
||||
return
|
||||
}
|
||||
|
||||
const payload = await postJson(`/api/stories/${story.id}/bookmark`, 'POST', { state: !state.bookmarked })
|
||||
setState((current) => ({
|
||||
...current,
|
||||
bookmarked: Boolean(payload?.bookmarked),
|
||||
bookmarksCount: Number(payload?.bookmarks_count || 0),
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
|
||||
{creator?.username ? (
|
||||
<FollowButton
|
||||
username={creator.username}
|
||||
initialFollowing={Boolean(initialState?.is_following_creator)}
|
||||
initialCount={Number(creator.followers_count || 0)}
|
||||
className="min-w-[11rem]"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<section className="rounded-2xl border border-white/[0.08] bg-white/[0.04] p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Discussion</h2>
|
||||
<p className="text-sm text-white/40">{state.commentsCount.toLocaleString()} comments on this story</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAuthenticated ? (
|
||||
<div className="mb-5">
|
||||
<CommentForm
|
||||
placeholder="Add to the story discussion…"
|
||||
submitLabel="Post Comment"
|
||||
onSubmit={async (content) => {
|
||||
const payload = await postJson(`/api/stories/${story.id}/comments`, 'POST', { content })
|
||||
const nextComments = [payload.data, ...comments]
|
||||
setComments(nextComments)
|
||||
setState((current) => ({ ...current, commentsCount: refreshCounts(nextComments) }))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mb-5 text-sm text-white/45">
|
||||
<a href="/login" className="text-sky-300 hover:text-sky-200">Sign in</a> to join the discussion.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<CommentList
|
||||
comments={comments}
|
||||
canReply={isAuthenticated}
|
||||
emptyMessage="No comments yet. Start the discussion."
|
||||
onReply={async (parentId, content) => {
|
||||
const payload = await postJson(`/api/stories/${story.id}/comments`, 'POST', { content, parent_id: parentId })
|
||||
const nextComments = insertReply(comments, parentId, payload.data)
|
||||
setComments(nextComments)
|
||||
setState((current) => ({ ...current, commentsCount: refreshCounts(nextComments) }))
|
||||
}}
|
||||
onDelete={async (commentId) => {
|
||||
await postJson(`/api/stories/${story.id}/comments/${commentId}`, 'DELETE')
|
||||
const nextComments = deleteCommentRecursive(comments, commentId)
|
||||
setComments(nextComments)
|
||||
setState((current) => ({ ...current, commentsCount: refreshCounts(nextComments) }))
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user