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:
404
resources/js/components/Feed/PostCard.jsx
Normal file
404
resources/js/components/Feed/PostCard.jsx
Normal file
@@ -0,0 +1,404 @@
|
||||
import React, { useState } from 'react'
|
||||
import PostActions from './PostActions'
|
||||
import PostComments from './PostComments'
|
||||
import EmbeddedArtworkCard from './EmbeddedArtworkCard'
|
||||
import VisibilityPill from './VisibilityPill'
|
||||
import LinkPreviewCard from './LinkPreviewCard'
|
||||
|
||||
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`
|
||||
}
|
||||
|
||||
function formatScheduledDate(isoString) {
|
||||
const d = new Date(isoString)
|
||||
return d.toLocaleString(undefined, {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
/** Render plain text body with #hashtag links */
|
||||
function BodyWithHashtags({ html }) {
|
||||
// The body may already be sanitised HTML from the server. We replace
|
||||
// #tag patterns in text nodes (not inside existing anchor elements) with
|
||||
// anchor links pointing to /tags/{tag}.
|
||||
const processed = html.replace(
|
||||
/(?<!["\w])#([A-Za-z][A-Za-z0-9_]{1,63})/g,
|
||||
(_, tag) => `<a href="/tags/${tag.toLowerCase()}" class="text-sky-400 hover:underline">#${tag}</a>`,
|
||||
)
|
||||
return (
|
||||
<div
|
||||
className="text-sm text-slate-300 leading-relaxed [&_a]:text-sky-400 [&_a]:hover:underline [&_strong]:text-white/90 [&_em]:text-slate-200"
|
||||
dangerouslySetInnerHTML={{ __html: processed }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* PostCard
|
||||
* Renders a single post in the feed. Supports text + artwork_share types.
|
||||
*
|
||||
* Props:
|
||||
* post object (formatted by PostFeedService::formatPost)
|
||||
* isLoggedIn boolean
|
||||
* viewerUsername string|null
|
||||
* onDelete function(postId)
|
||||
* onUnsaved function(postId) — called when viewer unsaves this post
|
||||
*/
|
||||
export default function PostCard({ post, isLoggedIn = false, viewerUsername = null, onDelete, onUnsaved }) {
|
||||
const [showComments, setShowComments] = useState(false)
|
||||
const [postData, setPostData] = useState(post)
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [editBody, setEditBody] = useState(post.body ?? '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [saveLoading, setSaveLoading] = useState(false)
|
||||
const [analyticsOpen, setAnalyticsOpen] = useState(false)
|
||||
const [analytics, setAnalytics] = useState(null)
|
||||
|
||||
const isOwn = viewerUsername && post.author.username === viewerUsername
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const { default: axios } = await import('axios')
|
||||
const { data } = await axios.patch(`/api/posts/${post.id}`, { body: editBody })
|
||||
setPostData(data.post)
|
||||
setEditMode(false)
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm('Delete this post?')) return
|
||||
try {
|
||||
const { default: axios } = await import('axios')
|
||||
await axios.delete(`/api/posts/${post.id}`)
|
||||
onDelete?.(post.id)
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
const handlePin = async () => {
|
||||
const { default: axios } = await import('axios')
|
||||
try {
|
||||
if (postData.is_pinned) {
|
||||
await axios.delete(`/api/posts/${post.id}/pin`)
|
||||
setPostData((p) => ({ ...p, is_pinned: false, pinned_order: null }))
|
||||
} else {
|
||||
const { data } = await axios.post(`/api/posts/${post.id}/pin`)
|
||||
setPostData((p) => ({ ...p, is_pinned: true, pinned_order: data.pinned_order ?? 1 }))
|
||||
}
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
setMenuOpen(false)
|
||||
}
|
||||
|
||||
const handleSaveToggle = async () => {
|
||||
if (!isLoggedIn || saveLoading) return
|
||||
setSaveLoading(true)
|
||||
const { default: axios } = await import('axios')
|
||||
try {
|
||||
if (postData.viewer_saved) {
|
||||
await axios.delete(`/api/posts/${post.id}/save`)
|
||||
setPostData((p) => ({ ...p, viewer_saved: false, saves_count: Math.max(0, (p.saves_count ?? 1) - 1) }))
|
||||
onUnsaved?.(post.id)
|
||||
} else {
|
||||
await axios.post(`/api/posts/${post.id}/save`)
|
||||
setPostData((p) => ({ ...p, viewer_saved: true, saves_count: (p.saves_count ?? 0) + 1 }))
|
||||
}
|
||||
} catch {
|
||||
//
|
||||
} finally {
|
||||
setSaveLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenAnalytics = async () => {
|
||||
if (!isOwn) return
|
||||
setAnalyticsOpen(true)
|
||||
if (!analytics) {
|
||||
const { default: axios } = await import('axios')
|
||||
try {
|
||||
const { data } = await axios.get(`/api/posts/${post.id}/analytics`)
|
||||
setAnalytics(data)
|
||||
} catch {
|
||||
setAnalytics(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="rounded-2xl border border-white/[0.06] bg-white/[0.025] hover:border-white/10 transition-colors">
|
||||
{/* ── Pinned banner ──────────────────────────────────────────────── */}
|
||||
{postData.is_pinned && (
|
||||
<div className="flex items-center gap-1.5 px-5 pt-3 pb-0 text-[11px] text-slate-500">
|
||||
<i className="fa-solid fa-thumbtack fa-fw text-sky-500/60" />
|
||||
<span>Pinned post</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Scheduled banner (owner only) ─────────────────────────────── */}
|
||||
{isOwn && postData.status === 'scheduled' && postData.publish_at && (
|
||||
<div className="flex items-center gap-1.5 px-5 pt-3 pb-0 text-[11px] text-amber-500/80">
|
||||
<i className="fa-regular fa-clock fa-fw" />
|
||||
<span>Scheduled for {formatScheduledDate(postData.publish_at)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Achievement badge ──────────────────────────────────────────── */}
|
||||
{postData.type === 'achievement' && (
|
||||
<div className="flex items-center gap-1.5 px-5 pt-3 pb-0 text-[11px] text-amber-400/90">
|
||||
<i className="fa-solid fa-trophy fa-fw text-amber-400" />
|
||||
<span className="font-medium tracking-wide uppercase">Achievement unlocked</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Header ─────────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-3 px-5 pt-4 pb-3">
|
||||
<a href={`/@${post.author.username}`} className="shrink-0">
|
||||
<img
|
||||
src={post.author.avatar ?? '/images/avatar_default.webp'}
|
||||
alt={post.author.name}
|
||||
className="w-9 h-9 rounded-full object-cover ring-1 ring-white/10 hover:ring-sky-500/40 transition-all"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<a
|
||||
href={`/@${post.author.username}`}
|
||||
className="text-sm font-semibold text-white/90 hover:text-sky-400 transition-colors"
|
||||
>
|
||||
{post.author.name || `@${post.author.username}`}
|
||||
</a>
|
||||
<span className="text-slate-600 text-xs">@{post.author.username}</span>
|
||||
{post.meta?.tagged_users?.length > 0 && (
|
||||
<span className="text-slate-600 text-xs flex items-center gap-1 flex-wrap">
|
||||
<span className="text-slate-700">with</span>
|
||||
{post.meta.tagged_users.map((u, i) => (
|
||||
<React.Fragment key={u.id}>
|
||||
{i > 0 && <span className="text-slate-700">,</span>}
|
||||
<a href={`/@${u.username}`} className="text-sky-500/80 hover:text-sky-400 transition-colors">
|
||||
@{u.username}
|
||||
</a>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-slate-500 mt-0.5">
|
||||
<span>{formatRelative(post.created_at)}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<VisibilityPill visibility={post.visibility} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right-side actions: save + owner menu */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Save / bookmark button */}
|
||||
{isLoggedIn && !isOwn && (
|
||||
<button
|
||||
onClick={handleSaveToggle}
|
||||
disabled={saveLoading}
|
||||
title={postData.viewer_saved ? 'Remove bookmark' : 'Save post'}
|
||||
className={`flex items-center justify-center w-8 h-8 rounded-lg transition-colors ${
|
||||
postData.viewer_saved
|
||||
? 'text-amber-400 hover:bg-amber-500/10'
|
||||
: 'text-slate-600 hover:text-slate-300 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<i className={`${postData.viewer_saved ? 'fa-solid' : 'fa-regular'} fa-bookmark fa-fw text-sm`} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Analytics for owner */}
|
||||
{isOwn && (
|
||||
<button
|
||||
onClick={handleOpenAnalytics}
|
||||
title="Post analytics"
|
||||
className="flex items-center justify-center w-8 h-8 rounded-lg text-slate-600 hover:text-sky-400 hover:bg-sky-500/10 transition-colors"
|
||||
>
|
||||
<i className="fa-solid fa-chart-simple fa-fw text-sm" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Owner menu */}
|
||||
{isOwn && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
className="text-slate-500 hover:text-slate-300 px-2 py-1 rounded-lg hover:bg-white/5 transition-colors"
|
||||
aria-label="Post options"
|
||||
>
|
||||
<i className="fa-solid fa-ellipsis-v fa-fw text-xs" />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="absolute right-0 top-full mt-1 w-40 rounded-xl border border-white/10 bg-[#10192e] shadow-2xl z-50 overflow-hidden">
|
||||
<button
|
||||
onClick={() => { setEditMode(true); setMenuOpen(false) }}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 flex items-center gap-2"
|
||||
>
|
||||
<i className="fa-solid fa-pen fa-fw opacity-60" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePin}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-slate-300 hover:bg-white/5 flex items-center gap-2"
|
||||
>
|
||||
<i className={`fa-solid fa-thumbtack fa-fw opacity-60 ${postData.is_pinned ? 'text-sky-400' : ''}`} />
|
||||
{postData.is_pinned ? 'Unpin post' : 'Pin post'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { handleDelete(); setMenuOpen(false) }}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-rose-400 hover:bg-white/5 flex items-center gap-2"
|
||||
>
|
||||
<i className="fa-solid fa-trash-can fa-fw opacity-60" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Body ─────────────────────────────────────────────────────────── */}
|
||||
<div className="px-5 pb-3 space-y-3">
|
||||
{editMode ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={editBody}
|
||||
onChange={(e) => setEditBody(e.target.value)}
|
||||
maxLength={2000}
|
||||
rows={4}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-sm text-white resize-none focus:outline-none focus:border-sky-500/50 transition-colors"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSaveEdit}
|
||||
disabled={saving}
|
||||
className="px-4 py-1.5 rounded-lg bg-sky-600 hover:bg-sky-500 text-white text-xs transition-colors disabled:opacity-40"
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditMode(false)}
|
||||
className="px-4 py-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 text-xs transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
postData.body && <BodyWithHashtags html={postData.body} />
|
||||
)}
|
||||
|
||||
{/* Hashtag pills */}
|
||||
{!editMode && postData.hashtags && postData.hashtags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-0.5">
|
||||
{postData.hashtags.map((tag) => (
|
||||
<a
|
||||
key={tag}
|
||||
href={`/tags/${tag}`}
|
||||
className="text-[11px] text-sky-500/80 hover:text-sky-400 hover:bg-sky-500/10 px-2 py-0.5 rounded-full border border-sky-500/20 hover:border-sky-500/40 transition-all"
|
||||
>
|
||||
#{tag}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link preview (stored OG data) */}
|
||||
{!editMode && postData.meta?.link_preview?.url && (
|
||||
<LinkPreviewCard preview={postData.meta.link_preview} />
|
||||
)}
|
||||
|
||||
{/* Artwork share embed */}
|
||||
{postData.type === 'artwork_share' && postData.artwork && (
|
||||
<EmbeddedArtworkCard artwork={postData.artwork} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Actions ─────────────────────────────────────────────────────── */}
|
||||
<div className="border-t border-white/[0.04] px-5 py-2">
|
||||
<PostActions
|
||||
post={postData}
|
||||
isLoggedIn={isLoggedIn}
|
||||
onCommentToggle={() => setShowComments((v) => !v)}
|
||||
onReactionChange={({ liked, count }) =>
|
||||
setPostData((p) => ({ ...p, viewer_liked: liked, reactions_count: count }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Comments ────────────────────────────────────────────────────── */}
|
||||
{showComments && (
|
||||
<div className="border-t border-white/[0.04] px-5 py-4">
|
||||
<PostComments
|
||||
postId={post.id}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isOwn={isOwn}
|
||||
initialCount={postData.comments_count}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Analytics modal ─────────────────────────────────────────────── */}
|
||||
{analyticsOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setAnalyticsOpen(false) }}
|
||||
>
|
||||
<div className="w-full max-w-sm rounded-2xl border border-white/10 bg-[#0d1628] p-6 shadow-2xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-white">Post Analytics</h3>
|
||||
<button
|
||||
onClick={() => setAnalyticsOpen(false)}
|
||||
className="text-slate-500 hover:text-white w-7 h-7 flex items-center justify-center rounded-lg hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<i className="fa-solid fa-xmark" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!analytics ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<i className="fa-solid fa-spinner fa-spin text-slate-500 text-xl" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ label: 'Impressions', value: analytics.impressions?.toLocaleString() ?? '—', icon: 'fa-eye', color: 'text-sky-400' },
|
||||
{ label: 'Saves', value: analytics.saves?.toLocaleString() ?? '—', icon: 'fa-bookmark', color: 'text-amber-400' },
|
||||
{ label: 'Reactions', value: analytics.reactions?.toLocaleString() ?? '—', icon: 'fa-heart', color: 'text-rose-400' },
|
||||
{ label: 'Engagement', value: analytics.engagement_rate ? `${analytics.engagement_rate}%` : '—', icon: 'fa-chart-simple', color: 'text-emerald-400' },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="rounded-xl bg-white/[0.04] border border-white/[0.06] px-4 py-3">
|
||||
<div className={`${item.color} text-sm mb-1`}>
|
||||
<i className={`fa-solid ${item.icon} fa-fw`} />
|
||||
</div>
|
||||
<p className="text-xl font-bold text-white/90 tabular-nums leading-tight">{item.value}</p>
|
||||
<p className="text-[11px] text-slate-500 mt-0.5">{item.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user