Files
SkinbaseNova/resources/js/components/Feed/PostCard.jsx
Gregor Klevze dc51d65440 feat: forum rich-text editor, emoji picker, mentions, discover nav, feed, uploads, profile
Forum:
- TipTap WYSIWYG editor with full toolbar
- @emoji-mart/react emoji picker (consistent with tweets)
- @mention autocomplete with user search API
- Fix PHP 8.4 parse errors in Blade templates
- Fix thread data display (paginator items)
- Align forum page widths to max-w-5xl

Discover:
- Extract shared _nav.blade.php partial
- Add missing nav links to for-you page
- Add Following link for authenticated users

Feed/Posts:
- Post model, controllers, policies, migrations
- Feed page components (PostComposer, FeedCard, etc)
- Post reactions, comments, saves, reports, sharing
- Scheduled publishing support
- Link preview controller

Profile:
- Profile page components (ProfileHero, ProfileTabs)
- Profile API controller

Uploads:
- Upload wizard enhancements
- Scheduled publish picker
- Studio status bar and readiness checklist
2026-03-03 09:48:31 +01:00

405 lines
17 KiB
JavaScript

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>
)
}