import React, { useState } from 'react' import PostActions from './PostActions' import PostComments from './PostComments' import ArtworkCard from '../artwork/ArtworkCard' 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( /(? `#${tag}`, ) return (
) } /** * 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 (
{/* ── Pinned banner ──────────────────────────────────────────────── */} {postData.is_pinned && (
Pinned post
)} {/* ── Scheduled banner (owner only) ─────────────────────────────── */} {isOwn && postData.status === 'scheduled' && postData.publish_at && (
Scheduled for {formatScheduledDate(postData.publish_at)}
)} {/* ── Achievement badge ──────────────────────────────────────────── */} {postData.type === 'achievement' && (
Achievement unlocked
)} {/* ── Header ─────────────────────────────────────────────────────── */}
{post.author.name}
{post.author.name || `@${post.author.username}`} @{post.author.username} {post.meta?.tagged_users?.length > 0 && ( with {post.meta.tagged_users.map((u, i) => ( {i > 0 && ,} @{u.username} ))} )}
{formatRelative(post.created_at)} ·
{/* Right-side actions: save + owner menu */}
{/* Save / bookmark button */} {isLoggedIn && !isOwn && ( )} {/* Analytics for owner */} {isOwn && ( )} {/* Owner menu */} {isOwn && (
{menuOpen && (
)}
)}
{/* ── Body ─────────────────────────────────────────────────────────── */}
{editMode ? (