Post Analytics
{item.value}
{item.label}
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( /(? `#${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 ({item.value}
{item.label}