sw-fe/src/components/Posts/PostCard.jsx

699 lines
23 KiB
JavaScript

import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { loadAuth } from "../../auth/authStorage";
import {
fetchPostLikeState,
recordPostShare,
togglePostLike,
updatePostVisibility,
fetchProfile,
editPost,
deletePost,
} from "../../api/client";
import { useAuth } from "../../auth/AuthContext";
import { trackEvent } from "../../utils/analytics";
function isAuthed() {
try {
const auth = loadAuth();
return !!(auth && auth.access_token);
} catch {
return false;
}
}
function requestAuth(message) {
try {
window.dispatchEvent(
new CustomEvent("sociowire:auth-required", {
detail: { message, mode: "login" },
})
);
} catch {}
trackEvent("auth_required", { context: "wall", mode: "login" });
}
// Time formatting is now handled in the component with translations
function formatRelativeTime(createdAt) {
try {
if (!createdAt) return "now";
const d = createdAt instanceof Date ? createdAt : new Date(String(createdAt));
if (Number.isNaN(d.getTime())) return "now";
const s = Math.floor((Date.now() - d.getTime()) / 1000);
if (s < 60) return "now";
const m = Math.floor(s / 60);
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 48) return `${h}h`;
const days = Math.floor(h / 24);
return `${days}d`;
} catch {
return "now";
}
}
function formatCount(value) {
const n = Number(value);
if (!Number.isFinite(n)) return "0";
if (n < 1000) return String(Math.max(0, Math.floor(n)));
if (n < 1000000) return `${(n / 1000).toFixed(1)}k`;
return `${(n / 1000000).toFixed(1)}m`;
}
function categoryLabel(post) {
const c = (post?.category || "NEWS").toString().toUpperCase();
if (c === "EVENT") return "EVENTS";
return c;
}
function categoryIconClass(post) {
const c = (post?.category || "NEWS").toString().toUpperCase();
switch (c) {
case "EVENT":
case "EVENTS":
return "fa-regular fa-calendar";
case "FRIENDS":
return "fa-solid fa-user-group";
case "MARKET":
return "fa-solid fa-store";
default:
return "fa-regular fa-newspaper";
}
}
function normalizeImageUrl(raw) {
const v = (raw || "").toString().trim();
if (!v) return "";
const lower = v.toLowerCase();
if (
lower === "null" ||
lower === "undefined" ||
lower === "none" ||
lower === "false" ||
lower === "0" ||
lower === "self" ||
lower === "default"
) {
return "";
}
if (lower.startsWith("data:")) return "";
if (
lower.startsWith("http://") ||
lower.startsWith("https://") ||
lower.startsWith("//") ||
lower.startsWith("/") ||
lower.startsWith("./")
) {
return v;
}
return "";
}
const wallAvatarCache = new Map();
const wallAvatarInflight = new Map();
async function getAvatarForUser(name) {
const key = (name || "").toLowerCase().trim();
if (!key) return "";
if (wallAvatarCache.has(key)) return wallAvatarCache.get(key);
if (wallAvatarInflight.has(key)) return wallAvatarInflight.get(key);
const p = (async () => {
const profile = await fetchProfile(name);
const url = (profile && profile.avatar_url) ? String(profile.avatar_url) : "";
wallAvatarCache.set(key, url);
wallAvatarInflight.delete(key);
return url;
})().catch(() => {
wallAvatarCache.set(key, "");
wallAvatarInflight.delete(key);
return "";
});
wallAvatarInflight.set(key, p);
return p;
}
export default function PostCard({ post, selected, onSelect, onPostDeleted, onPostUpdated }) {
const { t } = useTranslation();
const [liked, setLiked] = useState(false);
const [authed, setAuthed] = useState(isAuthed());
const [activeImageIndex, setActiveImageIndex] = useState(0);
const [failedImageIndices, setFailedImageIndices] = useState(() => new Set());
const [imgFailed, setImgFailed] = useState(false);
const [showPrivacy, setShowPrivacy] = useState(false);
const [privacySaving, setPrivacySaving] = useState(false);
const [privacyError, setPrivacyError] = useState("");
const [postVisibility, setPostVisibility] = useState(
(post?.visibility || "public").toString()
);
const [postGroupId, setPostGroupId] = useState(
(post?.group_id || "").toString()
);
const [postUsers, setPostUsers] = useState("");
const [applyToImages, setApplyToImages] = useState(true);
const [counts, setCounts] = useState(() => ({
like: post?.like_count ?? 0,
comment: post?.comment_count ?? 0,
share: post?.share_count ?? 0,
}));
const { username, token } = useAuth();
// Edit/Delete state
const [showEditDelete, setShowEditDelete] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [editTitle, setEditTitle] = useState("");
const [editSnippet, setEditSnippet] = useState("");
const [editSaving, setEditSaving] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
const postId = post?.id ?? post?._id ?? 0;
useEffect(() => {
let active = true;
if (!authed || !postId) return () => {};
fetchPostLikeState(postId).then((res) => {
if (!active) return;
if (res?.ok && res.liked) setLiked(true);
});
return () => {
active = false;
};
}, [authed, postId]);
useEffect(() => {
const handler = () => {
const next = isAuthed();
setAuthed(next);
if (!next) setLiked(false);
};
window.addEventListener("sociowire:auth-changed", handler);
return () => window.removeEventListener("sociowire:auth-changed", handler);
}, []);
useEffect(() => {
setCounts({
like: post?.like_count ?? 0,
comment: post?.comment_count ?? 0,
share: post?.share_count ?? 0,
});
}, [post?.like_count, post?.comment_count, post?.share_count]);
useEffect(() => {
setPostVisibility((post?.visibility || "public").toString());
setPostGroupId((post?.group_id || "").toString());
}, [post?.visibility, post?.group_id]);
const mediaUrls = useMemo(() => {
const list = [];
const seen = new Set();
const addUrl = (value) => {
const normalized = normalizeImageUrl(value);
if (!normalized || seen.has(normalized)) {
return;
}
seen.add(normalized);
list.push(normalized);
};
if (Array.isArray(post?.media_urls)) {
post.media_urls.forEach(addUrl);
}
[post?.image_large, post?.image_medium, post?.image_small, post?.image, post?.media_url].forEach(addUrl);
return list;
}, [post?.media_urls, post?.image_large, post?.image_medium, post?.image_small, post?.image, post?.media_url]);
const mediaKey = mediaUrls.join("|");
useEffect(() => {
setActiveImageIndex(0);
setFailedImageIndices(new Set());
setImgFailed(false);
}, [mediaKey]);
const title = post?.title || "Untitled";
const snippet = post?.snippet || "";
const author = (post?.author || post?.Author || "anon").toString();
const isAuthor =
username && author && author.toLowerCase() === username.toLowerCase();
const [authorAvatar, setAuthorAvatar] = useState("");
const sub = (post?.sub_category || post?.subCategory || "").toString();
const timeAgo = formatRelativeTime(post?.created_at || post?.CreatedAt || post?.createdAt);
const cat = categoryLabel(post);
const metaLine = useMemo(() => {
const parts = [];
if (sub) parts.push(sub);
if (post?.region) parts.push(post.region);
return parts.join(" • ");
}, [sub, post?.region]);
const safeImageIndex = mediaUrls.length
? Math.min(Math.max(activeImageIndex, 0), mediaUrls.length - 1)
: 0;
const activeImage = mediaUrls[safeImageIndex] || "";
const hasGalleryImage = Boolean(activeImage && !imgFailed);
const findNextAvailableIndex = (current, failed) => {
if (mediaUrls.length === 0) return -1;
for (let offset = 1; offset <= mediaUrls.length; offset += 1) {
const idx = (current + offset) % mediaUrls.length;
if (!failed.has(idx)) {
return idx;
}
}
return -1;
};
const findPrevAvailableIndex = (current, failed) => {
if (mediaUrls.length === 0) return -1;
for (let offset = 1; offset <= mediaUrls.length; offset += 1) {
const idx = (current - offset + mediaUrls.length) % mediaUrls.length;
if (!failed.has(idx)) {
return idx;
}
}
return -1;
};
const handleImageError = () => {
if (!activeImage) {
setImgFailed(true);
return;
}
const nextFailed = new Set(failedImageIndices);
nextFailed.add(safeImageIndex);
setFailedImageIndices(nextFailed);
if (nextFailed.size >= mediaUrls.length) {
setImgFailed(true);
return;
}
const nextIndex = findNextAvailableIndex(safeImageIndex, nextFailed);
if (nextIndex >= 0) {
setActiveImageIndex(nextIndex);
}
};
useEffect(() => {
let cancelled = false;
const who = (author || "").trim();
if (!who) {
setAuthorAvatar("");
return;
}
getAvatarForUser(who).then((url) => {
if (cancelled) return;
setAuthorAvatar(url || "");
});
return () => {
cancelled = true;
};
}, [author]);
return (
<article
className={"post-card sw-wall-card" + (selected ? " selected" : "")}
onClick={() => {
trackEvent("post_view", {
post_id: postId,
post_title: title?.slice(0, 100),
post_category: cat,
post_author: author,
content_type: "post",
});
onSelect && onSelect(post);
}}
>
<div className="sw-wall-head">
<div className="sw-wall-avatar">
{authorAvatar ? <img src={authorAvatar} alt={author} /> : (author ? author[0]?.toUpperCase() : "U")}
</div>
<div className="sw-wall-meta">
<div className="sw-wall-author">{author}</div>
<div className="sw-wall-time">{timeAgo}</div>
</div>
<div className="sw-wall-badge">{cat}</div>
</div>
{hasGalleryImage ? (
<div className="sw-wall-gallery">
<div className="sw-wall-image">
<img
src={activeImage}
alt=""
loading="lazy"
onError={handleImageError}
/>
{mediaUrls.length > 1 && (
<div className="sw-wall-gallery-count">
{safeImageIndex + 1}/{mediaUrls.length}
</div>
)}
{mediaUrls.length > 1 && (
<>
<button
type="button"
className="sw-wall-gallery-arrow left"
onClick={(e) => {
e.stopPropagation();
const prev = findPrevAvailableIndex(safeImageIndex, failedImageIndices);
if (prev >= 0) setActiveImageIndex(prev);
}}
aria-label="Previous image"
>
<i className="fa-solid fa-chevron-left" />
</button>
<button
type="button"
className="sw-wall-gallery-arrow right"
onClick={(e) => {
e.stopPropagation();
const next = findNextAvailableIndex(safeImageIndex, failedImageIndices);
if (next >= 0) setActiveImageIndex(next);
}}
aria-label="Next image"
>
<i className="fa-solid fa-chevron-right" />
</button>
</>
)}
</div>
{mediaUrls.length > 1 && (
<div className="sw-wall-gallery-thumbs">
{mediaUrls.map((value, idx) => (
<button
key={`thumb-${value}-${idx}`}
type="button"
className={`sw-wall-gallery-thumb${idx === safeImageIndex ? " active" : ""}`}
onClick={() => setActiveImageIndex(idx)}
>
<img src={value} alt="" loading="lazy" />
</button>
))}
</div>
)}
</div>
) : (
<div className="sw-wall-image sw-wall-image-fallback" aria-hidden="true">
<div className="sw-wall-image-fallback-inner">
<i className={categoryIconClass(post)} />
<span>{categoryLabel(post)}</span>
</div>
</div>
)}
<div className="sw-wall-body">
<div className="sw-wall-title">{title}</div>
{snippet ? <div className="sw-wall-snippet">{snippet}</div> : null}
{metaLine ? <div className="sw-wall-meta-line">{metaLine}</div> : null}
</div>
<div className="sw-wall-footer">
<div className="sw-wall-stats">
<span><i className="fa-regular fa-heart" /> {formatCount(counts.like)}</span>
<span><i className="fa-regular fa-comment" /> {formatCount(counts.comment)}</span>
<span><i className="fa-solid fa-share-nodes" /> {formatCount(counts.share)}</span>
</div>
<div className="sw-wall-actions">
<button
type="button"
className={"sw-wall-btn" + (liked ? " is-active" : "")}
onClick={async (e) => {
e.stopPropagation();
if (!authed) {
requestAuth(t('post.signInToLike'));
return;
}
trackEvent("post_like", { post_id: postId, post_title: title?.slice(0, 100), post_category: cat });
const res = await togglePostLike(postId, !liked);
if (res?.ok) {
setLiked(!!res.liked);
if (res?.counts) {
setCounts({
like: res.counts.like_count,
comment: res.counts.comment_count,
share: res.counts.share_count,
});
}
}
}}
>
<i className="fa-regular fa-heart" /> {t('post.like')}
</button>
<button
type="button"
className="sw-wall-btn"
onClick={(e) => {
e.stopPropagation();
if (!authed) {
requestAuth(t('post.signInToComment'));
return;
}
trackEvent("post_comment", { post_id: postId, post_title: title?.slice(0, 100), post_category: cat });
if (onSelect) onSelect(post);
}}
>
<i className="fa-regular fa-comment" /> {t('post.comment')}
</button>
<button
type="button"
className="sw-wall-btn"
onClick={async (e) => {
e.stopPropagation();
const shareUrl = `${window.location.origin}/p/${postId}`;
trackEvent("post_share", { post_id: postId, post_title: title?.slice(0, 100), post_category: cat });
try {
if (navigator.share) {
await navigator.share({ title: title || "SocioWire", url: shareUrl });
} else if (navigator.clipboard) {
await navigator.clipboard.writeText(shareUrl);
}
} catch {}
const res = await recordPostShare(postId);
if (res?.counts) {
setCounts({
like: res.counts.like_count,
comment: res.counts.comment_count,
share: res.counts.share_count,
});
}
}}
>
<i className="fa-solid fa-share-nodes" /> {t('post.share')}
</button>
{isAuthor ? (
<>
<button
type="button"
className={"sw-wall-btn" + (showPrivacy ? " is-active" : "")}
onClick={(e) => {
e.stopPropagation();
setShowPrivacy((prev) => !prev);
setShowEditDelete(false);
}}
>
<i className="fa-solid fa-lock" /> {t('post.visibility')}
</button>
<button
type="button"
className={"sw-wall-btn" + (showEditDelete ? " is-active" : "")}
onClick={(e) => {
e.stopPropagation();
setShowEditDelete((prev) => !prev);
setShowPrivacy(false);
if (!showEditDelete) {
setEditTitle(post?.title || "");
setEditSnippet(post?.snippet || "");
}
}}
>
<i className="fa-solid fa-ellipsis" />
</button>
</>
) : null}
</div>
</div>
{isAuthor && showPrivacy ? (
<div className="sw-wall-privacy" onClick={(e) => e.stopPropagation()}>
<div className="sw-wall-privacy-row">
<label>{t('common.post')}</label>
<select
value={postVisibility}
onChange={(e) => {
const next = e.target.value;
setPostVisibility(next);
if (next !== "group") setPostGroupId("");
if (next !== "custom") setPostUsers("");
}}
>
<option value="public">{t('post.public')}</option>
<option value="friends">{t('post.friendsOnly')}</option>
<option value="private">{t('post.private')}</option>
<option value="group">{t('post.group')}</option>
<option value="custom">{t('post.custom')}</option>
</select>
</div>
{postVisibility === "group" ? (
<input
className="sw-wall-privacy-input"
placeholder={t('post.groupId')}
value={postGroupId}
onChange={(e) => setPostGroupId(e.target.value)}
/>
) : null}
{postVisibility === "custom" ? (
<input
className="sw-wall-privacy-input"
placeholder={t('post.usernamesComma')}
value={postUsers}
onChange={(e) => setPostUsers(e.target.value)}
/>
) : null}
<label className="sw-wall-privacy-check">
<input
type="checkbox"
checked={applyToImages}
onChange={(e) => setApplyToImages(e.target.checked)}
/>
{t('post.applyToImages')}
</label>
{privacyError ? <div className="sw-wall-privacy-error">{privacyError}</div> : null}
<button
type="button"
className="sw-wall-btn"
disabled={privacySaving}
onClick={async () => {
setPrivacyError("");
setPrivacySaving(true);
try {
const payload = {
visibility: postVisibility,
group_id: postGroupId.trim(),
users: postUsers
.split(",")
.map((u) => u.trim())
.filter(Boolean),
};
if (applyToImages) {
payload.image_visibility = postVisibility;
payload.image_group_id = postGroupId.trim();
payload.image_users = payload.users;
}
await updatePostVisibility(postId, payload, token);
setPrivacySaving(false);
} catch (err) {
setPrivacyError(t('post.visibilityError'));
setPrivacySaving(false);
}
}}
>
{privacySaving ? t('post.saving') : t('common.save')}
</button>
</div>
) : null}
{isAuthor && showEditDelete ? (
<div className="sw-wall-edit-menu" onClick={(e) => e.stopPropagation()}>
{!isEditing && !showDeleteConfirm ? (
<>
<button
type="button"
className="sw-wall-btn"
onClick={() => setIsEditing(true)}
>
<i className="fa-solid fa-pen" /> {t('common.edit')}
</button>
<button
type="button"
className="sw-wall-btn sw-wall-btn-danger"
onClick={() => setShowDeleteConfirm(true)}
>
<i className="fa-solid fa-trash" /> {t('common.delete')}
</button>
</>
) : null}
{isEditing ? (
<div className="sw-wall-edit-form">
<input
type="text"
className="sw-wall-edit-input"
placeholder={t('post.title')}
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
/>
<textarea
className="sw-wall-edit-textarea"
placeholder={t('post.description')}
value={editSnippet}
onChange={(e) => setEditSnippet(e.target.value)}
rows={3}
/>
<div className="sw-wall-edit-actions">
<button
type="button"
className="sw-wall-btn"
disabled={editSaving}
onClick={async () => {
setEditSaving(true);
const res = await editPost(postId, {
title: editTitle.trim(),
snippet: editSnippet.trim(),
}, token);
setEditSaving(false);
if (res?.ok) {
setIsEditing(false);
setShowEditDelete(false);
if (onPostUpdated) onPostUpdated(res);
}
}}
>
{editSaving ? t('post.saving') : t('common.save')}
</button>
<button
type="button"
className="sw-wall-btn"
onClick={() => setIsEditing(false)}
>
{t('common.cancel')}
</button>
</div>
</div>
) : null}
{showDeleteConfirm ? (
<div className="sw-wall-delete-confirm">
<p>{t('post.deletePost')}</p>
<div className="sw-wall-edit-actions">
<button
type="button"
className="sw-wall-btn sw-wall-btn-danger"
disabled={deleting}
onClick={async () => {
setDeleting(true);
const res = await deletePost(postId, token);
setDeleting(false);
if (res?.ok) {
setShowDeleteConfirm(false);
setShowEditDelete(false);
if (onPostDeleted) onPostDeleted(postId);
}
}}
>
{deleting ? t('post.deleting') : t('common.delete')}
</button>
<button
type="button"
className="sw-wall-btn"
onClick={() => setShowDeleteConfirm(false)}
>
{t('common.cancel')}
</button>
</div>
</div>
) : null}
</div>
) : null}
</article>
);
}