Refine sociowall feed and post media fallbacks
This commit is contained in:
parent
ffeaee6e88
commit
8fa842ecb7
13
src/App.jsx
13
src/App.jsx
|
|
@ -62,6 +62,17 @@ export default function App() {
|
||||||
const [activeChats, setActiveChats] = useState([]);
|
const [activeChats, setActiveChats] = useState([]);
|
||||||
const [showContactsList, setShowContactsList] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(ACTIVE_CHATS_KEY);
|
const saved = localStorage.getItem(ACTIVE_CHATS_KEY);
|
||||||
|
|
@ -187,4 +198,4 @@ export default function App() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ function applyPostDetails(modalWrap, post) {
|
||||||
|
|
||||||
const title = (post?.title || post?.Title || "Untitled").toString();
|
const title = (post?.title || post?.Title || "Untitled").toString();
|
||||||
const summary = (post?.snippet || post?.body || "").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 url = (post?.url || "").toString().trim();
|
||||||
|
|
||||||
const titleEl = modalWrap.querySelector(".sw-modal-title");
|
const titleEl = modalWrap.querySelector(".sw-modal-title");
|
||||||
|
|
@ -100,7 +100,7 @@ function applyPostDetails(modalWrap, post) {
|
||||||
if (hero) {
|
if (hero) {
|
||||||
hero.innerHTML = img
|
hero.innerHTML = img
|
||||||
? `<img src="${escapeHtml(img)}" alt="" loading="lazy" />`
|
? `<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");
|
const badge = modalWrap.querySelector(".sw-modal-badge");
|
||||||
|
|
@ -151,7 +151,13 @@ function renderComments(container, items) {
|
||||||
.map((c) => {
|
.map((c) => {
|
||||||
const who = (c?.username || "anon").toString().trim();
|
const who = (c?.username || "anon").toString().trim();
|
||||||
const body = (c?.body || "").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("");
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
@ -196,6 +202,34 @@ function normCatLabel(post) {
|
||||||
return "NEWS";
|
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) {
|
function formatRelativeTime(createdAt) {
|
||||||
try {
|
try {
|
||||||
if (!createdAt) return "now";
|
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 sub = (postData?.sub_category || postData?.subCategory || "").toString().trim();
|
||||||
const metaLeft = author ? `by ${author}` : "";
|
const metaLeft = author ? `by ${author}` : "";
|
||||||
const metaRight = sub ? sub : "";
|
const metaRight = sub ? sub : "";
|
||||||
const img = data?.image || "";
|
const img = heroImageForPost(postData);
|
||||||
const url = data?.url || "";
|
const url = data?.url || "";
|
||||||
const postID = postData?.id || postData?.ID || 0;
|
const postID = postData?.id || postData?.ID || 0;
|
||||||
const viewCount = readCount(postData, ["view_count", "views", "viewCount"]);
|
const viewCount = readCount(postData, ["view_count", "views", "viewCount"]);
|
||||||
|
|
@ -309,7 +343,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
||||||
${
|
${
|
||||||
img
|
img
|
||||||
? `<img src="${escapeHtml(img)}" alt="" loading="lazy" />`
|
? `<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>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -267,7 +267,8 @@ export function adaptPostToTemplateData(post) {
|
||||||
const source = author || sub || "";
|
const source = author || sub || "";
|
||||||
const summary = post?.snippet || post?.body || "";
|
const summary = post?.snippet || post?.body || "";
|
||||||
const url = post?.url || "";
|
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);
|
const categoryIcon = getCategoryIcon(post);
|
||||||
return { headline, source, summary, url, image, categoryIcon };
|
return { headline, source, summary, url, image, categoryIcon };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -720,7 +720,7 @@ export function usePostsEngine({
|
||||||
const exists = (allPostsRef.current || []).some((x) => getPostKey(x) === key);
|
const exists = (allPostsRef.current || []).some((x) => getPostKey(x) === key);
|
||||||
if (exists) return;
|
if (exists) return;
|
||||||
}
|
}
|
||||||
allPostsRef.current = [...allPostsRef.current, p];
|
allPostsRef.current = [p, ...allPostsRef.current];
|
||||||
|
|
||||||
const catCode = categoryCode(mainFilter);
|
const catCode = categoryCode(mainFilter);
|
||||||
if (catCode) {
|
if (catCode) {
|
||||||
|
|
@ -743,7 +743,7 @@ export function usePostsEngine({
|
||||||
createMarkerForPost(p, mapRef, markersRef, expandedElRef, theme);
|
createMarkerForPost(p, mapRef, markersRef, expandedElRef, theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
setVisiblePosts((current) => [...current, p]);
|
setVisiblePosts((current) => [p, ...current]);
|
||||||
},
|
},
|
||||||
[mainFilter, subFilter, timeFilter, searchQuery, mapRef, markersRef, expandedElRef, theme, refreshVisibleAndMarkers]
|
[mainFilter, subFilter, timeFilter, searchQuery, mapRef, markersRef, expandedElRef, theme, refreshVisibleAndMarkers]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,269 @@
|
||||||
import React from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import CardRenderer from "../Cards/CardRenderer";
|
import { loadAuth } from "../../auth/authStorage";
|
||||||
import { cardTokens } from "../../theme/cardTokens";
|
import {
|
||||||
import { getTemplateSpecForPost, adaptPostToTemplateData } from "../Map/templateSpecs";
|
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 }) {
|
export default function PostCard({ post, selected, onSelect }) {
|
||||||
const spec = getTemplateSpecForPost(post, "mini");
|
const [liked, setLiked] = useState(false);
|
||||||
const data = adaptPostToTemplateData(post);
|
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 (
|
return (
|
||||||
<article
|
<article
|
||||||
className={"post-card" + (selected ? " selected" : "")}
|
className={"post-card sw-wall-card" + (selected ? " selected" : "")}
|
||||||
onClick={() => onSelect && onSelect(post)}
|
onClick={() => onSelect && onSelect(post)}
|
||||||
style={{ padding: 6 }}
|
|
||||||
>
|
>
|
||||||
<div className="sw-wall-mini">
|
<div className="sw-wall-head">
|
||||||
<CardRenderer
|
<div className="sw-wall-avatar">{author ? author[0]?.toUpperCase() : "U"}</div>
|
||||||
spec={spec}
|
<div className="sw-wall-meta">
|
||||||
data={data}
|
<div className="sw-wall-author">{author}</div>
|
||||||
themeTokens={cardTokens.blue}
|
<div className="sw-wall-time">{timeAgo}</div>
|
||||||
onAction={(action, value) => {
|
</div>
|
||||||
if (action?.type === "open_url" && value) window.open(value, "_blank");
|
<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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useMemo } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import PostCard from "./PostCard";
|
import PostCard from "./PostCard";
|
||||||
|
|
||||||
function getKmFromPost(post) {
|
function getKmFromPost(post) {
|
||||||
|
|
@ -7,6 +7,9 @@ function getKmFromPost(post) {
|
||||||
|
|
||||||
export default function PostList({ posts, loading, error, selectedPost, onSelectPost }) {
|
export default function PostList({ posts, loading, error, selectedPost, onSelectPost }) {
|
||||||
const [kmFilter, setKmFilter] = useState(1000);
|
const [kmFilter, setKmFilter] = useState(1000);
|
||||||
|
const [visibleCount, setVisibleCount] = useState(6);
|
||||||
|
const listRef = useRef(null);
|
||||||
|
const sentinelRef = useRef(null);
|
||||||
|
|
||||||
const filtered = useMemo(
|
const filtered = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -18,8 +21,28 @@ export default function PostList({ posts, loading, error, selectedPost, onSelect
|
||||||
[posts, kmFilter]
|
[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 (
|
return (
|
||||||
<div className="post-list">
|
<div className="post-list" ref={listRef}>
|
||||||
<div className="post-list-header">
|
<div className="post-list-header">
|
||||||
<div className="km-filter">
|
<div className="km-filter">
|
||||||
<label>
|
<label>
|
||||||
|
|
@ -37,7 +60,7 @@ export default function PostList({ posts, loading, error, selectedPost, onSelect
|
||||||
|
|
||||||
{error && <p className="status-text status-error">{error}</p>}
|
{error && <p className="status-text status-error">{error}</p>}
|
||||||
|
|
||||||
{filtered.map((p) => (
|
{filtered.slice(0, visibleCount).map((p) => (
|
||||||
<PostCard
|
<PostCard
|
||||||
key={p.id ?? p._id}
|
key={p.id ?? p._id}
|
||||||
post={p}
|
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>}
|
{!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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -227,10 +227,72 @@
|
||||||
gap:3px;
|
gap:3px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
opacity:.95;
|
opacity:.95;
|
||||||
|
position: relative;
|
||||||
|
padding-left: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sw-chat-line{ font-size: 10px; color:#c7e3ff; }
|
.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-inputrow{ display:flex; gap:8px; align-items:center; }
|
||||||
|
|
||||||
.sw-livechat-input{
|
.sw-livechat-input{
|
||||||
|
|
@ -480,6 +542,29 @@
|
||||||
width:100%;
|
width:100%;
|
||||||
height:100%;
|
height:100%;
|
||||||
background: radial-gradient(circle at 35% 35%, rgba(56,189,248,0.22), rgba(3,14,32,0.98));
|
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{
|
.sw-modal-title{
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,10 @@
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.9);
|
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);
|
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;
|
display:flex;
|
||||||
flex-direction:column;
|
flex-direction:column;
|
||||||
|
transition: max-height 220ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sociowall-header{
|
.sociowall-header{
|
||||||
|
|
@ -44,26 +45,148 @@
|
||||||
overflow-y:auto;
|
overflow-y:auto;
|
||||||
padding-right:.15rem;
|
padding-right:.15rem;
|
||||||
max-height: 44vh;
|
max-height: 44vh;
|
||||||
|
transition: max-height 220ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* prevent long content from widening layout */
|
/* prevent long content from widening layout */
|
||||||
.post-card{
|
.post-card{
|
||||||
overflow:hidden;
|
overflow:hidden;
|
||||||
min-width:0;
|
min-width:0;
|
||||||
border-radius:10px;
|
border-radius:12px;
|
||||||
border:1px solid rgba(55, 65, 81, 0.9);
|
border:1px solid rgba(51, 65, 85, 0.9);
|
||||||
background: rgba(15, 23, 42, 0.92);
|
background: rgba(15, 23, 42, 0.92);
|
||||||
padding:.3rem .45rem;
|
padding:.6rem .7rem;
|
||||||
margin-bottom:.22rem;
|
margin-bottom:.5rem;
|
||||||
cursor:pointer;
|
cursor:pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* SCALE the fixed template so it never forces width */
|
.sw-wall-card{
|
||||||
.post-card .sw-wall-mini{
|
display:flex;
|
||||||
--sw-wall-scale: 0.86;
|
flex-direction:column;
|
||||||
transform: scale(var(--sw-wall-scale));
|
gap:.4rem;
|
||||||
transform-origin: left top;
|
}
|
||||||
width: calc(100% / var(--sw-wall-scale));
|
|
||||||
|
.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; }
|
.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;
|
min-width: 260px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-list{ max-height: 50vh; }
|
.sociowall-panel{ max-height: 22vh; }
|
||||||
.chat-users{ max-height: 50vh; }
|
.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 */
|
/* SW_DESKTOP_WIDE_LAYOUT:END */
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue