Refine sociowall feed and post media fallbacks

This commit is contained in:
Your Name 2025-12-25 00:45:15 -05:00
parent ffeaee6e88
commit 8fa842ecb7
8 changed files with 566 additions and 41 deletions

View File

@ -62,6 +62,17 @@ export default function App() {
const [activeChats, setActiveChats] = useState([]);
const [showContactsList, setShowContactsList] = useState(false);
useEffect(() => {
const onScroll = () => {
const threshold = window.innerHeight * 0.55;
const expanded = window.scrollY > threshold;
document.body.classList.toggle("sw-wall-expanded", expanded);
};
window.addEventListener("scroll", onScroll, { passive: true });
onScroll();
return () => window.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
try {
const saved = localStorage.getItem(ACTIVE_CHATS_KEY);

View File

@ -87,7 +87,7 @@ function applyPostDetails(modalWrap, post) {
const title = (post?.title || post?.Title || "Untitled").toString();
const summary = (post?.snippet || post?.body || "").toString();
const img = post?.image_large || post?.image_small || post?.image || post?.media_url || "";
const img = heroImageForPost(post);
const url = (post?.url || "").toString().trim();
const titleEl = modalWrap.querySelector(".sw-modal-title");
@ -100,7 +100,7 @@ function applyPostDetails(modalWrap, post) {
if (hero) {
hero.innerHTML = img
? `<img src="${escapeHtml(img)}" alt="" loading="lazy" />`
: `<div class="sw-modal-hero-fallback"></div>`;
: `<div class="sw-modal-hero-fallback"><div class="sw-modal-hero-fallback-inner"><i class="${categoryIconClass(post)}"></i><span>${escapeHtml(normCatLabel(post))}</span></div></div>`;
}
const badge = modalWrap.querySelector(".sw-modal-badge");
@ -151,7 +151,13 @@ function renderComments(container, items) {
.map((c) => {
const who = (c?.username || "anon").toString().trim();
const body = (c?.body || "").toString().trim();
return `<div class="sw-chat-line">— <strong>${escapeHtml(who)}</strong>: ${escapeHtml(body)}</div>`;
const ts = (c?.created_at || "").toString().trim();
return `
<div class="sw-chat-bubble">
<div class="sw-chat-meta">${escapeHtml(who)}${ts ? ` · ${escapeHtml(ts)}` : ""}</div>
<div class="sw-chat-text">${escapeHtml(body)}</div>
</div>
`;
})
.join("");
}
@ -196,6 +202,34 @@ function normCatLabel(post) {
return "NEWS";
}
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 heroImageForPost(post) {
const raw =
post?.image_large ||
post?.image_small ||
post?.image ||
post?.media_url ||
"";
const v = (raw || "").toString().trim();
if (v) return v;
const cat = normCatLabel(post);
return `/og/category?cat=${encodeURIComponent(cat)}`;
}
function formatRelativeTime(createdAt) {
try {
if (!createdAt) return "now";
@ -257,7 +291,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
const sub = (postData?.sub_category || postData?.subCategory || "").toString().trim();
const metaLeft = author ? `by ${author}` : "";
const metaRight = sub ? sub : "";
const img = data?.image || "";
const img = heroImageForPost(postData);
const url = data?.url || "";
const postID = postData?.id || postData?.ID || 0;
const viewCount = readCount(postData, ["view_count", "views", "viewCount"]);
@ -309,7 +343,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
${
img
? `<img src="${escapeHtml(img)}" alt="" loading="lazy" />`
: `<div class="sw-modal-hero-fallback"></div>`
: `<div class="sw-modal-hero-fallback"><div class="sw-modal-hero-fallback-inner"><i class="${categoryIconClass(postData)}"></i><span>${escapeHtml(normCatLabel(postData))}</span></div></div>`
}
</div>

View File

@ -267,7 +267,8 @@ export function adaptPostToTemplateData(post) {
const source = author || sub || "";
const summary = post?.snippet || post?.body || "";
const url = post?.url || "";
const image = post?.image_large || post?.image_small || post?.image || post?.media_url || "";
const rawImage = post?.image_large || post?.image_small || post?.image || post?.media_url || "";
const image = rawImage || `/og/category?cat=${encodeURIComponent((post?.category || "NEWS").toString().toUpperCase())}&variant=mini`;
const categoryIcon = getCategoryIcon(post);
return { headline, source, summary, url, image, categoryIcon };
}

View File

@ -720,7 +720,7 @@ export function usePostsEngine({
const exists = (allPostsRef.current || []).some((x) => getPostKey(x) === key);
if (exists) return;
}
allPostsRef.current = [...allPostsRef.current, p];
allPostsRef.current = [p, ...allPostsRef.current];
const catCode = categoryCode(mainFilter);
if (catCode) {
@ -743,7 +743,7 @@ export function usePostsEngine({
createMarkerForPost(p, mapRef, markersRef, expandedElRef, theme);
}
setVisiblePosts((current) => [...current, p]);
setVisiblePosts((current) => [p, ...current]);
},
[mainFilter, subFilter, timeFilter, searchQuery, mapRef, markersRef, expandedElRef, theme, refreshVisibleAndMarkers]
);

View File

@ -1,28 +1,270 @@
import React from "react";
import CardRenderer from "../Cards/CardRenderer";
import { cardTokens } from "../../theme/cardTokens";
import { getTemplateSpecForPost, adaptPostToTemplateData } from "../Map/templateSpecs";
import React, { useEffect, useMemo, useState } from "react";
import { loadAuth } from "../../auth/authStorage";
import {
fetchPostLikeState,
recordPostShare,
togglePostLike,
} from "../../api/client";
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 {}
}
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 "";
}
export default function PostCard({ post, selected, onSelect }) {
const spec = getTemplateSpecForPost(post, "mini");
const data = adaptPostToTemplateData(post);
const [liked, setLiked] = useState(false);
const [authed, setAuthed] = useState(isAuthed());
const [imgFailed, setImgFailed] = useState(false);
const [counts, setCounts] = useState(() => ({
like: post?.like_count ?? 0,
comment: post?.comment_count ?? 0,
share: post?.share_count ?? 0,
}));
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]);
const rawImg = post?.image_large || post?.image_small || post?.image || post?.media_url || "";
const img = normalizeImageUrl(rawImg);
const title = post?.title || "Untitled";
const snippet = post?.snippet || "";
const author = (post?.author || post?.Author || "anon").toString();
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]);
return (
<article
className={"post-card" + (selected ? " selected" : "")}
className={"post-card sw-wall-card" + (selected ? " selected" : "")}
onClick={() => onSelect && onSelect(post)}
style={{ padding: 6 }}
>
<div className="sw-wall-mini">
<CardRenderer
spec={spec}
data={data}
themeTokens={cardTokens.blue}
onAction={(action, value) => {
if (action?.type === "open_url" && value) window.open(value, "_blank");
}}
<div className="sw-wall-head">
<div className="sw-wall-avatar">{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>
{img && !imgFailed ? (
<div className="sw-wall-image">
<img
src={img}
alt=""
loading="lazy"
onError={() => setImgFailed(true)}
/>
</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-title">{title}</div>
{snippet ? <div className="sw-wall-snippet">{snippet}</div> : null}
{metaLine ? <div className="sw-wall-meta-line">{metaLine}</div> : null}
<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("Please sign in or create a free account to like this post.");
return;
}
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" /> Like
</button>
<button
type="button"
className="sw-wall-btn"
onClick={(e) => {
e.stopPropagation();
if (!authed) {
requestAuth("Please sign in or create a free account to comment.");
return;
}
if (onSelect) onSelect(post);
}}
>
<i className="fa-regular fa-comment" /> Comment
</button>
<button
type="button"
className="sw-wall-btn"
onClick={async (e) => {
e.stopPropagation();
const shareUrl = `${window.location.origin}/p/${postId}`;
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" /> Share
</button>
</div>
</article>
);
}

View File

@ -1,4 +1,4 @@
import React, { useState, useMemo } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import PostCard from "./PostCard";
function getKmFromPost(post) {
@ -7,6 +7,9 @@ function getKmFromPost(post) {
export default function PostList({ posts, loading, error, selectedPost, onSelectPost }) {
const [kmFilter, setKmFilter] = useState(1000);
const [visibleCount, setVisibleCount] = useState(6);
const listRef = useRef(null);
const sentinelRef = useRef(null);
const filtered = useMemo(
() =>
@ -18,8 +21,28 @@ export default function PostList({ posts, loading, error, selectedPost, onSelect
[posts, kmFilter]
);
useEffect(() => {
setVisibleCount(6);
}, [kmFilter]);
useEffect(() => {
const root = listRef.current || null;
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
const hit = entries && entries[0];
if (!hit || !hit.isIntersecting) return;
setVisibleCount((c) => Math.min(filtered.length, c + 6));
},
{ root, rootMargin: "200px 0px", threshold: 0.01 }
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [filtered.length]);
return (
<div className="post-list">
<div className="post-list" ref={listRef}>
<div className="post-list-header">
<div className="km-filter">
<label>
@ -37,7 +60,7 @@ export default function PostList({ posts, loading, error, selectedPost, onSelect
{error && <p className="status-text status-error">{error}</p>}
{filtered.map((p) => (
{filtered.slice(0, visibleCount).map((p) => (
<PostCard
key={p.id ?? p._id}
post={p}
@ -47,6 +70,7 @@ export default function PostList({ posts, loading, error, selectedPost, onSelect
))}
{!loading && filtered.length === 0 && <p className="status-text">Aucun post dans ce rayon.</p>}
{filtered.length > visibleCount ? <div ref={sentinelRef} className="sw-wall-sentinel" /> : null}
</div>
);
}

View File

@ -227,10 +227,72 @@
gap:3px;
margin-bottom: 8px;
opacity:.95;
position: relative;
padding-left: 12px;
}
.sw-chat-line{ font-size: 10px; color:#c7e3ff; }
.sw-chat-bubble{
position: relative;
display:flex;
flex-direction:column;
gap:2px;
padding:6px 8px;
border-radius:10px;
background: rgba(15,23,42,0.55);
border:1px solid rgba(56,189,248,0.18);
}
.sw-chat-bubble::before{
content:"";
position:absolute;
left:-12px;
top:12px;
width:6px;
height:6px;
border-radius:999px;
background: rgba(56,189,248,0.6);
box-shadow: 0 0 0 2px rgba(15,23,42,0.7);
}
.sw-livechat-body::before{
content:"";
position:absolute;
left:0;
top:4px;
bottom:4px;
width:2px;
background: linear-gradient(180deg, rgba(56,189,248,0.65), rgba(56,189,248,0.08));
border-radius: 999px;
}
body[data-theme="light"] .sw-chat-bubble{
background: rgba(255,255,255,0.8);
border-color: rgba(59,130,246,0.25);
}
body[data-theme="light"] .sw-chat-meta{
color:#1d4ed8;
}
body[data-theme="light"] .sw-chat-text{
color:#0b1220;
}
body[data-theme="light"] .sw-livechat-body::before{
background: linear-gradient(180deg, rgba(59,130,246,0.65), rgba(59,130,246,0.08));
}
body[data-theme="light"] .sw-chat-bubble::before{
background: rgba(59,130,246,0.7);
box-shadow: 0 0 0 2px rgba(255,255,255,0.95);
}
.sw-chat-meta{
font-size: 10px;
font-weight: 700;
color:#bfdbfe;
}
.sw-chat-text{
font-size: 11px;
color:#e2f2ff;
line-height: 1.3;
}
.sw-livechat-inputrow{ display:flex; gap:8px; align-items:center; }
.sw-livechat-input{
@ -480,6 +542,29 @@
width:100%;
height:100%;
background: radial-gradient(circle at 35% 35%, rgba(56,189,248,0.22), rgba(3,14,32,0.98));
display:flex;
align-items:center;
justify-content:center;
color:#93c5fd;
}
.sw-modal-hero-fallback-inner{
display:flex;
flex-direction:column;
align-items:center;
gap:8px;
text-transform: uppercase;
letter-spacing: .08em;
font-weight: 800;
font-size: .75rem;
}
.sw-modal-hero-fallback-inner i{
font-size: 46px;
opacity: 0.95;
}
body[data-theme="light"] .sw-modal-hero-fallback{
background: radial-gradient(circle at 35% 35%, rgba(59,130,246,0.18), rgba(255,255,255,0.95));
color:#1d4ed8;
}
.sw-modal-title{

View File

@ -24,9 +24,10 @@
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.9);
box-shadow: 0 10px 28px rgba(0,0,0,.9), 0 0 18px rgba(56,189,248,.45);
padding: .45rem .55rem;
padding: .35rem .45rem;
display:flex;
flex-direction:column;
transition: max-height 220ms ease;
}
.sociowall-header{
@ -44,26 +45,148 @@
overflow-y:auto;
padding-right:.15rem;
max-height: 44vh;
transition: max-height 220ms ease;
}
/* prevent long content from widening layout */
.post-card{
overflow:hidden;
min-width:0;
border-radius:10px;
border:1px solid rgba(55, 65, 81, 0.9);
border-radius:12px;
border:1px solid rgba(51, 65, 85, 0.9);
background: rgba(15, 23, 42, 0.92);
padding:.3rem .45rem;
margin-bottom:.22rem;
padding:.6rem .7rem;
margin-bottom:.5rem;
cursor:pointer;
}
/* SCALE the fixed template so it never forces width */
.post-card .sw-wall-mini{
--sw-wall-scale: 0.86;
transform: scale(var(--sw-wall-scale));
transform-origin: left top;
width: calc(100% / var(--sw-wall-scale));
.sw-wall-card{
display:flex;
flex-direction:column;
gap:.4rem;
}
.sw-wall-head{
display:flex;
align-items:center;
gap:.5rem;
}
.sw-wall-avatar{
width:34px;
height:34px;
border-radius:999px;
background: rgba(56,189,248,0.22);
border:1px solid rgba(56,189,248,0.55);
color:#e2f2ff;
font-weight:800;
display:flex;
align-items:center;
justify-content:center;
}
.sw-wall-meta{ flex:1; min-width:0; }
.sw-wall-author{
font-size:.85rem;
font-weight:800;
color:#e5e7eb;
}
.sw-wall-time{
font-size:.72rem;
color:#94a3b8;
}
.sw-wall-badge{
font-size:.65rem;
font-weight:800;
padding:.2rem .5rem;
border-radius:999px;
border:1px solid rgba(56,189,248,0.5);
color:#dbeafe;
background: rgba(56,189,248,0.18);
text-transform: uppercase;
letter-spacing:.06em;
}
.sw-wall-image{
width:100%;
height: 180px;
border-radius:12px;
overflow:hidden;
border:1px solid rgba(148,163,184,0.25);
background: rgba(15,23,42,0.85);
}
.sw-wall-image-fallback{
display:flex;
align-items:center;
justify-content:center;
color:#93c5fd;
background: radial-gradient(circle at 30% 30%, rgba(56,189,248,0.18), rgba(15,23,42,0.92));
}
.sw-wall-image-fallback-inner{
display:flex;
flex-direction:column;
align-items:center;
gap:8px;
text-transform: uppercase;
letter-spacing: .08em;
font-weight: 800;
font-size: .72rem;
}
.sw-wall-image-fallback i{
font-size: 46px;
opacity: 0.95;
}
body[data-theme="light"] .sw-wall-image-fallback{
background: radial-gradient(circle at 30% 30%, rgba(59,130,246,0.18), rgba(255,255,255,0.9));
color:#1d4ed8;
}
.sw-wall-image img{
width:100%;
height:100%;
object-fit: cover;
display:block;
}
.sw-wall-title{
font-size: 1rem;
font-weight: 900;
color:#f8fafc;
}
.sw-wall-snippet{
font-size:.86rem;
color:#d7e3ff;
line-height:1.3;
}
.sw-wall-meta-line{
font-size:.74rem;
color:#94a3b8;
}
.sw-wall-stats{
display:flex;
gap:.75rem;
font-size:.75rem;
color:#cbd5f5;
}
.sw-wall-stats i{ margin-right:.25rem; }
.sw-wall-actions{
display:flex;
gap:.4rem;
flex-wrap:wrap;
}
.sw-wall-sentinel{
height: 1px;
}
.sw-wall-btn{
border:1px solid rgba(56,189,248,0.35);
background: rgba(15,23,42,0.6);
color:#e2f2ff;
font-weight:800;
font-size:.75rem;
padding:.35rem .6rem;
border-radius:999px;
cursor:pointer;
}
.sw-wall-btn i{ margin-right:.25rem; }
.sw-wall-btn.is-active{
background: rgba(56,189,248,0.35);
border-color: rgba(56,189,248,0.8);
}
.post-list-header{ display:flex; align-items:center; gap:.5rem; margin-bottom:.25rem; font-size:.7rem; color:#cbd5f5; }
@ -156,7 +279,12 @@
min-width: 260px;
}
.post-list{ max-height: 50vh; }
.chat-users{ max-height: 50vh; }
.sociowall-panel{ max-height: 22vh; }
.post-list{ max-height: 16vh; }
.chat-users{ max-height: 16vh; }
body.sw-wall-expanded .sociowall-panel{ max-height: 70vh; }
body.sw-wall-expanded .post-list{ max-height: 62vh; }
body.sw-wall-expanded .chat-users{ max-height: 62vh; }
}
/* SW_DESKTOP_WIDE_LAYOUT:END */