Content filters + SocioWall header + localStorage persistence

- Add shared ContentFilters component (Links, Video, Photos, Blogs, Live, Events)
- Server-side content_type filtering via reco-service
- SocioWall business-style header inside panel
- Save filter preferences to localStorage
- Theme-aware filter bar (blue/dark/light themes)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
SocioWire 2026-01-11 02:57:42 +00:00
parent 774839e408
commit b49dd448c1
15 changed files with 532 additions and 230 deletions

View File

@ -2,6 +2,7 @@ import React, { Suspense, useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import TopBar from "./components/Layout/TopBar"; import TopBar from "./components/Layout/TopBar";
import PostList from "./components/Posts/PostList"; import PostList from "./components/Posts/PostList";
import ContentFilters from "./components/Filters/ContentFilters";
import IslandUniverse from "./components/Island/IslandUniverse"; import IslandUniverse from "./components/Island/IslandUniverse";
import IslandViewer from "./components/Island/IslandViewer"; import IslandViewer from "./components/Island/IslandViewer";
import ChatContainer from "./components/Chat/ChatContainer"; import ChatContainer from "./components/Chat/ChatContainer";
@ -10,6 +11,7 @@ import { fetchIslandByUsername } from "./api/client";
const MapView = React.lazy(() => import("./components/Map/MapView")); const MapView = React.lazy(() => import("./components/Map/MapView"));
const THEME_KEY = "sociowire:theme"; const THEME_KEY = "sociowire:theme";
const CONTENT_FILTER_KEY = "sociowire:contentFilter";
export default function App() { export default function App() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -21,10 +23,30 @@ export default function App() {
const [wallError, setWallError] = useState(""); const [wallError, setWallError] = useState("");
const [selectedPost, setSelectedPost] = useState(null); const [selectedPost, setSelectedPost] = useState(null);
// Content filter (shared between map and sociowall) - persisted
// "all" | "links" | "video" | "image" | "text" | "live" | "event"
const [contentFilter, setContentFilter] = useState(() => {
try {
const saved = localStorage.getItem(CONTENT_FILTER_KEY);
if (saved && ["all", "links", "video", "image", "text", "live", "event"].includes(saved)) {
return saved;
}
} catch {}
return "all";
});
// Save content filter to localStorage
useEffect(() => {
try {
localStorage.setItem(CONTENT_FILTER_KEY, contentFilter);
} catch {}
}, [contentFilter]);
// Island Universe & Island Viewer state // Island Universe & Island Viewer state
const [showUniverse, setShowUniverse] = useState(false); const [showUniverse, setShowUniverse] = useState(false);
const [selectedIsland, setSelectedIsland] = useState(null); const [selectedIsland, setSelectedIsland] = useState(null);
const handlePostsState = useCallback((next) => { const handlePostsState = useCallback((next) => {
if (!next) return; if (!next) return;
if (Array.isArray(next.posts)) setWallPosts(next.posts); if (Array.isArray(next.posts)) setWallPosts(next.posts);
@ -158,6 +180,7 @@ export default function App() {
> >
<MapView <MapView
theme={theme} theme={theme}
contentFilter={contentFilter}
onPostsState={handlePostsState} onPostsState={handlePostsState}
selectedPost={selectedPost} selectedPost={selectedPost}
onSelectPost={setSelectedPost} onSelectPost={setSelectedPost}
@ -170,9 +193,17 @@ export default function App() {
{/* BELOW THE MAP (page scroll like Facebook) */} {/* BELOW THE MAP (page scroll like Facebook) */}
<div className="below-stage"> <div className="below-stage">
{/* Content filters - shared between map and sociowall */}
<ContentFilters
activeFilter={contentFilter}
onFilterChange={setContentFilter}
/>
<div className="below-row"> <div className="below-row">
<div className="sociowall-panel" id="wall"> <div className="sociowall-panel" id="wall">
<div className="sociowall-header">SOCIOWALL</div> {/* SocioWall header inside panel */}
<div className="sociowall-header">
<h2 className="sociowall-title">SOCIOWALL</h2>
</div>
<PostList <PostList
posts={wallPosts} posts={wallPosts}
loading={wallLoading} loading={wallLoading}
@ -181,9 +212,9 @@ export default function App() {
onSelectPost={setSelectedPost} onSelectPost={setSelectedPost}
onPostDeleted={handlePostDeleted} onPostDeleted={handlePostDeleted}
onPostUpdated={handlePostUpdated} onPostUpdated={handlePostUpdated}
contentFilter={contentFilter}
/> />
</div> </div>
{/* CHAT PANEL SUPPRIMÉ */}
</div> </div>
</div> </div>
@ -209,7 +240,7 @@ export default function App() {
<footer className="sw-footer"> <footer className="sw-footer">
<div className="sw-footer-inner"> <div className="sw-footer-inner">
<div className="sw-footer-brand">SOCIOWIRE</div> <div className="sw-footer-brand">SOCIOWIRE</div>
<div className="sw-footer-brand">© Sociowire v0.0009</div> <div className="sw-footer-brand">© Sociowire v0.0010</div>
<div id="sw-weather-debug" style={{color: '#0ea5e9', fontSize: '10px'}}></div> <div id="sw-weather-debug" style={{color: '#0ea5e9', fontSize: '10px'}}></div>
<nav className="sw-footer-nav" aria-label="Site"> <nav className="sw-footer-nav" aria-label="Site">
<a href="/">{t('nav.home')}</a> <a href="/">{t('nav.home')}</a>

View File

@ -468,6 +468,7 @@ export async function fetchSmartFeed(params = {}) {
if (params.category) qs.set("category", String(params.category)); if (params.category) qs.set("category", String(params.category));
if (params.sub_category) qs.set("sub_category", String(params.sub_category)); if (params.sub_category) qs.set("sub_category", String(params.sub_category));
if (params.time_hours) qs.set("time_hours", String(params.time_hours)); if (params.time_hours) qs.set("time_hours", String(params.time_hours));
if (params.content_type) qs.set("content_type", String(params.content_type));
// NEW: Screen bounds for on-screen prioritization // NEW: Screen bounds for on-screen prioritization
if (params.bounds) { if (params.bounds) {
@ -1053,7 +1054,8 @@ export async function fetchWeatherByPostId(postId) {
} }
export async function fetchWeatherPosts() { export async function fetchWeatherPosts() {
const url = `${apiBase()}/weather-posts`; // Fetch all weather cities globally (lat=0,lon=0 with huge radius)
const url = `${apiBase()}/posts?content_type=weather&lat=0&lon=0&radius_km=50000&limit=100`;
const res = await fetch(url); const res = await fetch(url);
if (!res.ok) return []; if (!res.ok) return [];
return res.json(); return res.json();

View File

@ -402,10 +402,9 @@ body[data-theme="light"] .contacts-empty {
@media (max-width: 640px) { @media (max-width: 640px) {
.chat-bottom-bar { .chat-bottom-bar {
right: 8px; right: 8px;
left: 8px; left: auto;
gap: 6px; gap: 6px;
/* Handle virtual keyboard */ bottom: 12px;
bottom: max(16px, env(safe-area-inset-bottom, 16px));
} }
.chat-tab { .chat-tab {

View File

@ -0,0 +1,169 @@
.content-filters {
display: flex;
justify-content: center;
gap: 8px;
padding: 10px 16px;
/* Match sociowall panel colors */
background: linear-gradient(
180deg,
rgba(22, 26, 38, 0.95) 0%,
rgba(18, 22, 32, 0.96) 100%
);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-radius: 999px;
border: 1px solid rgba(100, 120, 160, 0.2);
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.3);
margin: 0 auto 12px auto;
max-width: 420px;
}
.content-filter-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border: none;
border-radius: 999px;
background: transparent;
color: rgba(148, 163, 184, 0.9);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.content-filter-btn:hover {
background: rgba(59, 130, 246, 0.15);
color: #e2e8f0;
}
.content-filter-btn.active {
background: linear-gradient(135deg, #3b82f6, #2563eb);
color: white;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4);
}
.content-filter-btn i {
font-size: 14px;
}
/* Colored icons */
.content-filter-btn.filter-links i {
color: #3b82f6;
}
.content-filter-btn.filter-video i {
color: #8b5cf6;
}
.content-filter-btn.filter-image i {
color: #10b981;
}
.content-filter-btn.filter-text i {
color: #06b6d4;
}
.content-filter-btn.filter-live i {
color: #ef4444;
}
.content-filter-btn.filter-event i {
color: #f59e0b;
}
/* Keep white icon when active */
.content-filter-btn.active i {
color: white;
}
/* Filter-specific active colors */
.content-filter-btn.filter-live.active {
background: linear-gradient(135deg, #ef4444, #dc2626);
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.4);
}
.content-filter-btn.filter-video.active {
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
box-shadow: 0 2px 8px rgba(139, 92, 246, 0.4);
}
.content-filter-btn.filter-image.active {
background: linear-gradient(135deg, #10b981, #059669);
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.4);
}
.content-filter-btn.filter-text.active {
background: linear-gradient(135deg, #06b6d4, #0891b2);
box-shadow: 0 2px 8px rgba(6, 182, 212, 0.4);
}
.content-filter-btn.filter-event.active {
background: linear-gradient(135deg, #f59e0b, #d97706);
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.4);
}
/* Blue theme (default dark) */
body[data-theme="blue"] .content-filters {
background: linear-gradient(
180deg,
rgba(22, 26, 38, 0.95) 0%,
rgba(18, 22, 32, 0.96) 100%
);
border-color: rgba(100, 120, 160, 0.2);
}
/* Dark theme */
body[data-theme="dark"] .content-filters {
background: linear-gradient(
180deg,
rgba(15, 15, 20, 0.95) 0%,
rgba(10, 10, 15, 0.96) 100%
);
border-color: rgba(60, 60, 80, 0.3);
}
/* Light theme */
body[data-theme="light"] .content-filters {
background: linear-gradient(
180deg,
rgba(248, 250, 252, 0.95) 0%,
rgba(241, 245, 249, 0.96) 100%
);
border-color: rgba(148, 163, 184, 0.3);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
body[data-theme="light"] .content-filter-btn {
color: #475569;
}
body[data-theme="light"] .content-filter-btn:hover {
background: rgba(59, 130, 246, 0.1);
color: #1e293b;
}
/* Mobile */
@media (max-width: 640px) {
.content-filters {
padding: 8px 12px;
gap: 6px;
max-width: calc(100% - 24px);
margin-bottom: 10px;
}
.content-filter-btn {
padding: 6px 10px;
font-size: 12px;
flex-shrink: 0;
}
.filter-label {
display: none;
}
.content-filter-btn i {
font-size: 16px;
}
}

View File

@ -0,0 +1,55 @@
import React from "react";
import "./ContentFilters.css";
const FILTERS = [
{ key: "links", icon: "fa-solid fa-link", label: "Links" },
{ key: "video", icon: "fa-solid fa-circle-play", label: "Video" },
{ key: "image", icon: "fa-solid fa-camera", label: "Photos" },
{ key: "text", icon: "fa-solid fa-pen-to-square", label: "Blogs" },
{ key: "live", icon: "fa-solid fa-tower-broadcast", label: "Live" },
{ key: "event", icon: "fa-regular fa-calendar-days", label: "Events" },
];
export default function ContentFilters({ activeFilter, onFilterChange }) {
return (
<div className="content-filters">
{FILTERS.map((f) => (
<button
key={f.key}
type="button"
className={`content-filter-btn filter-${f.key}${activeFilter === f.key ? " active" : ""}`}
onClick={() => onFilterChange(activeFilter === f.key ? "all" : f.key)}
>
<i className={f.icon} />
<span className="filter-label">{f.label}</span>
</button>
))}
</div>
);
}
// Export filter matching function for reuse
export function matchesContentFilter(post, filter) {
if (!filter || filter === "all") return true;
const ct = (post.content_type || post.contentType || "").toLowerCase();
const cat = (post.category || "").toLowerCase();
const hasVideo = !!(post.video_url || post.videoUrl || post.embed_url || post.embedUrl);
const hasImage = !!(post.image_small || post.image_medium || post.image_large || (post.media_urls && post.media_urls.length > 0));
switch (filter) {
case "links":
return cat === "news" || ct === "news";
case "video":
return hasVideo || ct === "video" || ct === "camera";
case "image":
return hasImage && !hasVideo && ct !== "camera";
case "text":
return ct === "post" || ct === "text" || ct === "blog" || cat === "friends";
case "live":
return ct === "live" || ct === "stream" || ct === "camera" || hasVideo;
case "event":
return cat === "event" || cat === "events" || ct === "event";
default:
return true;
}
}

View File

@ -5,7 +5,7 @@ import "../../styles/mapMarkers.css";
import "../../styles/posts.css"; import "../../styles/posts.css";
import "../../styles/filters.css"; import "../../styles/filters.css";
import { createPost, fetchPostPreview, resolveAssetRef, fetchWeatherPosts } from "../../api/client"; import { createPost, fetchPostPreview, resolveAssetRef } from "../../api/client";
import { LiveKitBroadcast, LiveKitViewerModal } from "../Live"; import { LiveKitBroadcast, LiveKitViewerModal } from "../Live";
import { WeatherModal } from "../Weather"; import { WeatherModal } from "../Weather";
import { import {
@ -117,6 +117,7 @@ const LINK_PREVIEW_PLACEHOLDER =
export default function MapView({ export default function MapView({
theme = "dark", theme = "dark",
contentFilter = "all",
onPostsState, onPostsState,
selectedPost, selectedPost,
onSelectPost, onSelectPost,
@ -131,8 +132,18 @@ export default function MapView({
const { containerRef, mapRef, markersRef, expandedElRef, viewParams, hasLastView } = const { containerRef, mapRef, markersRef, expandedElRef, viewParams, hasLastView } =
useMapCore(theme); useMapCore(theme);
const [mainFilter, setMainFilter] = useState("All"); const MAIN_FILTER_KEY = "sociowire:mainFilter";
const SUB_FILTER_KEY = "sociowire:subFilter";
const TIME_FILTER_KEY = "sociowire:timeHours"; const TIME_FILTER_KEY = "sociowire:timeHours";
const [mainFilter, setMainFilter] = useState(() => {
try {
const saved = localStorage.getItem(MAIN_FILTER_KEY);
if (saved && FILTER_MAIN_CATEGORIES.includes(saved)) return saved;
} catch {}
return "All";
});
const [timeHours, setTimeHours] = useState(() => { const [timeHours, setTimeHours] = useState(() => {
try { try {
const v = localStorage.getItem(TIME_FILTER_KEY); const v = localStorage.getItem(TIME_FILTER_KEY);
@ -142,7 +153,14 @@ export default function MapView({
return 336; return 336;
} }
}); });
const [subFilter, setSubFilter] = useState("All");
const [subFilter, setSubFilter] = useState(() => {
try {
const saved = localStorage.getItem(SUB_FILTER_KEY);
if (saved) return saved;
} catch {}
return "All";
});
const [mainNewsOnly, setMainNewsOnly] = useState(false); const [mainNewsOnly, setMainNewsOnly] = useState(false);
const WEATHER_VIEW_KEY = "sociowire:weatherView"; const WEATHER_VIEW_KEY = "sociowire:weatherView";
const [weatherViewEnabled, setWeatherViewEnabled] = useState(() => { const [weatherViewEnabled, setWeatherViewEnabled] = useState(() => {
@ -169,6 +187,18 @@ export default function MapView({
} catch {} } catch {}
}, [timeHours]); }, [timeHours]);
useEffect(() => {
try {
localStorage.setItem(MAIN_FILTER_KEY, mainFilter);
} catch {}
}, [mainFilter]);
useEffect(() => {
try {
localStorage.setItem(SUB_FILTER_KEY, subFilter);
} catch {}
}, [subFilter]);
useEffect(() => { useEffect(() => {
try { try {
localStorage.setItem(WEATHER_VIEW_KEY, weatherViewEnabled ? "true" : "false"); localStorage.setItem(WEATHER_VIEW_KEY, weatherViewEnabled ? "true" : "false");
@ -199,8 +229,7 @@ export default function MapView({
const [liveCoords, setLiveCoords] = useState(null); const [liveCoords, setLiveCoords] = useState(null);
// Weather modal state // Weather modal state
const [activeWeatherPost, setActiveWeatherPost] = useState(null); const [activeWeatherPost, setActiveWeatherPost] = useState(null);
// Weather cities widget (top left under search) // Nearest weather city (fetched from /api/weather?lat=X&lon=Y)
const [weatherCities, setWeatherCities] = useState([]);
const [nearestWeather, setNearestWeather] = useState(null); const [nearestWeather, setNearestWeather] = useState(null);
const openedPostRef = useRef(null); const openedPostRef = useRef(null);
const queryPostRef = useRef(false); const queryPostRef = useRef(false);
@ -438,6 +467,7 @@ export default function MapView({
viewParams, viewParams,
mainFilter, mainFilter,
subFilter, subFilter,
contentFilter,
timeHours, timeHours,
searchQuery, searchQuery,
clusterFocus, clusterFocus,
@ -1211,81 +1241,79 @@ export default function MapView({
return () => map.off("movestart", handleMove); return () => map.off("movestart", handleMove);
}, [mapRef, searchQuery]); }, [mapRef, searchQuery]);
// Fetch weather cities on mount // Fetch nearest weather from backend based on map center
useEffect(() => { const fetchNearestWeatherRef = useRef(null);
const loadWeatherCities = async () => { const lastWeatherFetchRef = useRef({ lat: null, lon: null });
try {
const posts = await fetchWeatherPosts();
if (Array.isArray(posts) && posts.length > 0) {
// Parse weather data from posts
const cities = posts.map(p => {
const title = p.title || '';
const tempMatch = title.match(/:\s*(-?\d+)°/);
const cityMatch = title.match(/^[^\w]*([A-Za-zÀ-ÿ\s]+):/);
const temp = tempMatch ? parseInt(tempMatch[1], 10) : null;
const city = cityMatch ? cityMatch[1].trim() : '';
// Detect weather icon from emoji in title const fetchNearestWeather = useCallback(async () => {
let icon = '🌡️';
if (title.includes('☀️')) icon = '☀️';
else if (title.includes('🌙')) icon = '🌙';
else if (title.includes('⛅')) icon = '⛅';
else if (title.includes('☁️')) icon = '☁️';
else if (title.includes('🌫️')) icon = '🌫️';
else if (title.includes('🌧️')) icon = '🌧️';
else if (title.includes('❄️')) icon = '❄️';
else if (title.includes('🌦️')) icon = '🌦️';
else if (title.includes('🌨️')) icon = '🌨️';
else if (title.includes('⛈️')) icon = '⛈️';
return { ...p, city, temp, icon };
}).filter(c => c.city && c.temp !== null);
setWeatherCities(cities);
}
} catch (err) {
console.warn('[Weather] Failed to load cities:', err);
}
};
loadWeatherCities();
// Refresh every 10 minutes
const interval = setInterval(loadWeatherCities, 10 * 60 * 1000);
return () => clearInterval(interval);
}, []);
// Find nearest weather city when map moves
useEffect(() => {
const map = mapRef.current; const map = mapRef.current;
if (!map || weatherCities.length === 0) return; if (!map) return;
const updateNearest = () => {
try { try {
const center = map.getCenter(); const center = map.getCenter();
if (!center) return; if (!center) return;
let nearest = null; // Skip if we already fetched for similar position
let minDist = Infinity; const last = lastWeatherFetchRef.current;
if (last.lat !== null) {
for (const w of weatherCities) {
if (w.lat == null || w.lon == null) continue;
const dist = Math.sqrt( const dist = Math.sqrt(
Math.pow(center.lat - w.lat, 2) + Math.pow(center.lng - w.lon, 2) Math.pow(center.lat - last.lat, 2) + Math.pow(center.lng - last.lon, 2)
); );
if (dist < minDist) { if (dist < 0.5) return; // Skip if moved less than ~50km
minDist = dist;
nearest = w;
}
} }
if (nearest) setNearestWeather(nearest); lastWeatherFetchRef.current = { lat: center.lat, lon: center.lng };
} catch {}
// Call /api/weather?lat=X&lon=Y which returns nearest city from weather_data
const url = `${window.VITE_API_BASE_URL || ''}/api/weather?lat=${center.lat}&lon=${center.lng}`;
const res = await fetch(url);
if (!res.ok) return;
const data = await res.json();
if (data && data.city) {
// Convert backend response to our format
const iconMap = {
'sun': '☀️', 'moon': '🌙', 'cloud-sun': '⛅', 'cloud-moon': '⛅',
'cloud': '☁️', 'smog': '🌫️', 'cloud-rain': '🌧️', 'cloud-showers-heavy': '🌧️',
'snowflake': '❄️', 'cloud-sun-rain': '🌦️', 'cloud-meatball': '🌨️', 'bolt': '⛈️'
};
const icon = iconMap[data.current?.weather_icon] || '🌡️';
const temp = Math.round(data.current?.temperature ?? 0);
setNearestWeather({
city: data.city,
temp,
icon,
lat: data.lat,
lon: data.lon,
post_id: data.post_id
});
}
} catch (err) {
console.warn('[Weather] Failed to fetch:', err);
}
}, []);
// Update weather on map move (debounced)
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const handleMoveEnd = () => {
if (fetchNearestWeatherRef.current) clearTimeout(fetchNearestWeatherRef.current);
fetchNearestWeatherRef.current = setTimeout(fetchNearestWeather, 500);
}; };
updateNearest(); map.on('moveend', handleMoveEnd);
map.on("idle", updateNearest);
return () => map.off("idle", updateNearest); // Initial fetch
}, [mapRef, weatherCities]); fetchNearestWeather();
return () => {
map.off('moveend', handleMoveEnd);
if (fetchNearestWeatherRef.current) clearTimeout(fetchNearestWeatherRef.current);
};
}, [fetchNearestWeather]);
const handleProfileVisitIsland = (island) => { const handleProfileVisitIsland = (island) => {
console.log('[MapView] Visiting island from profile:', island.username); console.log('[MapView] Visiting island from profile:', island.username);
@ -1953,6 +1981,7 @@ export default function MapView({
<FilterButtons active={mainFilter} onSelect={handleMainFilterSelect} /> <FilterButtons active={mainFilter} onSelect={handleMainFilterSelect} />
</div> </div>
{mainFilter !== "All" && (
<div className="map-overlay map-overlay-bottom"> <div className="map-overlay map-overlay-bottom">
<div className="sw-bottom-row"> <div className="sw-bottom-row">
{bottomCategories.map((c) => ( {bottomCategories.map((c) => (
@ -1981,6 +2010,7 @@ export default function MapView({
))} ))}
</div> </div>
</div> </div>
)}
{isCreating && modeChosen && ( {isCreating && modeChosen && (
<div className="map-overlay map-crosshair"> <div className="map-overlay map-crosshair">

View File

@ -6,6 +6,7 @@ import { createMarkerForPost, createSimpleMarkerForPost } from "./markerManager"
import { getTemplateKeyForPost } from "./templateSpecs"; import { getTemplateKeyForPost } from "./templateSpecs";
import { prioritizePosts } from "./postPriority"; import { prioritizePosts } from "./postPriority";
import { trackEvent } from "../../utils/analytics"; import { trackEvent } from "../../utils/analytics";
import { matchesContentFilter } from "../Filters/ContentFilters";
function getId(p) { function getId(p) {
return p?.id ?? p?._id ?? null; return p?.id ?? p?._id ?? null;
@ -373,6 +374,7 @@ export function usePostsEngine({
viewParams, viewParams,
mainFilter, mainFilter,
subFilter, subFilter,
contentFilter = "all",
timeHours, timeHours,
searchQuery, searchQuery,
clusterFocus, clusterFocus,
@ -837,7 +839,7 @@ export function usePostsEngine({
useEffect(() => { useEffect(() => {
const map = mapRef.current; const map = mapRef.current;
replacePoolRef.current = true; replacePoolRef.current = true;
pendingFilterRef.current = `${mainFilter}|${subFilter}|${timeHours}`; pendingFilterRef.current = `${mainFilter}|${subFilter}|${contentFilter}|${timeHours}`;
allPostsRef.current = []; allPostsRef.current = [];
autoWidenRef.current = true; // Prevent auto-widen when user manually changes filter (keep their time choice) autoWidenRef.current = true; // Prevent auto-widen when user manually changes filter (keep their time choice)
if (map && clustersEnabled && heatmapEnabled) { if (map && clustersEnabled && heatmapEnabled) {
@ -851,7 +853,7 @@ export function usePostsEngine({
} catch {} } catch {}
}, 60); }, 60);
} }
}, [mainFilter, subFilter, timeHours, mapRef, clustersEnabled, heatmapEnabled, updateClusterAreas]); }, [mainFilter, subFilter, contentFilter, timeHours, mapRef, clustersEnabled, heatmapEnabled, updateClusterAreas]);
const computeVisible = useCallback( const computeVisible = useCallback(
(tf) => { (tf) => {
@ -868,6 +870,9 @@ export function usePostsEngine({
const isClusterFocus = !!(clustersEnabled && clusterIDs && clusterIDs.length > 0); const isClusterFocus = !!(clustersEnabled && clusterIDs && clusterIDs.length > 0);
return posts.filter((p) => { return posts.filter((p) => {
// Apply content filter (links, video, image, live, event)
if (!matchesContentFilter(p, contentFilter)) return false;
if (isClusterFocus) { if (isClusterFocus) {
const id = Number(getId(p)); const id = Number(getId(p));
const clusterPostId = Number(p?.cluster_post_id); const clusterPostId = Number(p?.cluster_post_id);
@ -886,7 +891,7 @@ export function usePostsEngine({
return true; return true;
}); });
}, },
[mainFilter, subFilter, searchQuery, clusterFocus, mainNewsOnly, clustersEnabled] [mainFilter, subFilter, contentFilter, searchQuery, clusterFocus, mainNewsOnly, clustersEnabled]
); );
const refreshVisibleAndMarkers = useCallback( const refreshVisibleAndMarkers = useCallback(
@ -952,7 +957,7 @@ export function usePostsEngine({
useEffect(() => { useEffect(() => {
if (pendingFilterRef.current) return; if (pendingFilterRef.current) return;
refreshVisibleAndMarkers(timeHours); refreshVisibleAndMarkers(timeHours);
}, [timeHours, mainFilter, subFilter, searchQuery, clusterFocus, refreshVisibleAndMarkers]); }, [timeHours, mainFilter, subFilter, contentFilter, searchQuery, clusterFocus, refreshVisibleAndMarkers]);
useEffect(() => { useEffect(() => {
if (!clustersEnabled) return; if (!clustersEnabled) return;
@ -1082,6 +1087,17 @@ export function usePostsEngine({
} }
if (limit > 120) limit = 120; if (limit > 120) limit = 120;
// Map contentFilter to content_type for API
let contentTypeParam = undefined;
if (contentFilter && contentFilter !== "all") {
if (contentFilter === "live") contentTypeParam = "camera";
else if (contentFilter === "video") contentTypeParam = "video,camera";
else if (contentFilter === "image") contentTypeParam = "image";
else if (contentFilter === "text") contentTypeParam = "post";
else if (contentFilter === "links") contentTypeParam = "news";
else if (contentFilter === "event") contentTypeParam = "event";
}
let newPosts = await fetchSmartFeed({ let newPosts = await fetchSmartFeed({
lat, lat,
lon: lng, lon: lng,
@ -1093,6 +1109,7 @@ export function usePostsEngine({
time_hours: timeHours || undefined, time_hours: timeHours || undefined,
category: catCode || undefined, category: catCode || undefined,
sub_category: subCatParam || undefined, sub_category: subCatParam || undefined,
content_type: contentTypeParam || undefined,
}); });
if (cancelled || reqId !== requestSeqRef.current) return; if (cancelled || reqId !== requestSeqRef.current) return;
@ -1201,7 +1218,7 @@ export function usePostsEngine({
delayedFetchRef.current = null; delayedFetchRef.current = null;
} }
}; };
}, [viewParams, mapReady, mainFilter, subFilter, mapRef, timeHours, expandedElRef, refreshVisibleAndMarkers, computeVisible, onAutoWidenTimeFilter, syncMarkers, username]); }, [viewParams, mapReady, mainFilter, subFilter, contentFilter, mapRef, timeHours, expandedElRef, refreshVisibleAndMarkers, computeVisible, onAutoWidenTimeFilter, syncMarkers, username]);
useEffect(() => { useEffect(() => {
const q = (searchQuery || "").trim(); const q = (searchQuery || "").trim();

View File

@ -1,43 +1,31 @@
import React, { useEffect, useMemo, useRef, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import PostCard from "./PostCard"; import PostCard from "./PostCard";
import { matchesContentFilter } from "../Filters/ContentFilters";
function getKmFromPost(post) { export default function PostList({ posts, loading, error, selectedPost, onSelectPost, onPostDeleted, onPostUpdated, contentFilter = "all" }) {
return post.km ?? post.distance_km ?? post.distanceKm ?? post.dist_km ?? null; const [visibleCount, setVisibleCount] = useState(1);
}
export default function PostList({ posts, loading, error, selectedPost, onSelectPost, onPostDeleted, onPostUpdated }) {
const { t } = useTranslation();
const [kmFilter, setKmFilter] = useState(1000);
const [visibleCount, setVisibleCount] = useState(6);
const listRef = useRef(null); const listRef = useRef(null);
const sentinelRef = useRef(null); const sentinelRef = useRef(null);
const filtered = useMemo( const filtered = useMemo(
() => () => posts.filter((p) => matchesContentFilter(p, contentFilter)),
posts.filter((p) => { [posts, contentFilter]
const km = getKmFromPost(p);
if (km == null) return true;
return km <= kmFilter;
}),
[posts, kmFilter]
); );
useEffect(() => { useEffect(() => {
setVisibleCount(6); setVisibleCount(1);
}, [kmFilter]); }, [contentFilter]);
useEffect(() => { useEffect(() => {
const root = listRef.current || null;
const sentinel = sentinelRef.current; const sentinel = sentinelRef.current;
if (!sentinel) return; if (!sentinel) return;
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
const hit = entries && entries[0]; const hit = entries && entries[0];
if (!hit || !hit.isIntersecting) return; if (!hit || !hit.isIntersecting) return;
setVisibleCount((c) => Math.min(filtered.length, c + 6)); setVisibleCount((c) => Math.min(filtered.length, c + 2));
}, },
{ root, rootMargin: "200px 0px", threshold: 0.01 } { root: null, rootMargin: "400px 0px", threshold: 0 }
); );
observer.observe(sentinel); observer.observe(sentinel);
return () => observer.disconnect(); return () => observer.disconnect();
@ -45,21 +33,6 @@ export default function PostList({ posts, loading, error, selectedPost, onSelect
return ( return (
<div className="post-list" ref={listRef}> <div className="post-list" ref={listRef}>
<div className="post-list-header">
<div className="km-filter">
<label>
{t('postlist.radius')} <span>{kmFilter} {t('postlist.km')}</span>
</label>
<input
type="range"
min={0}
max={1000}
value={kmFilter}
onChange={(e) => setKmFilter(Number(e.target.value))}
/>
</div>
</div>
{error && <p className="status-text status-error">{error}</p>} {error && <p className="status-text status-error">{error}</p>}
{filtered.slice(0, visibleCount).map((p) => ( {filtered.slice(0, visibleCount).map((p) => (
@ -73,7 +46,7 @@ export default function PostList({ posts, loading, error, selectedPost, onSelect
/> />
))} ))}
{!loading && filtered.length === 0 && <p className="status-text">{t('postlist.noPostsInRadius')}</p>} {!loading && filtered.length === 0 && <p className="status-text">No posts found</p>}
{filtered.length > visibleCount ? <div ref={sentinelRef} className="sw-wall-sentinel" /> : null} {filtered.length > visibleCount ? <div ref={sentinelRef} className="sw-wall-sentinel" /> : null}
</div> </div>
); );

View File

@ -33,6 +33,9 @@
"contact": "Contact", "contact": "Contact",
"privacy": "Privacy" "privacy": "Privacy"
}, },
"wall": {
"latestPosts": "Latest posts from around the world"
},
"topbar": { "topbar": {
"wiredToLife": "Wired to life", "wiredToLife": "Wired to life",
"installApp": "Install App", "installApp": "Install App",

View File

@ -33,6 +33,9 @@
"contact": "Contacto", "contact": "Contacto",
"privacy": "Privacidad" "privacy": "Privacidad"
}, },
"wall": {
"latestPosts": "Últimas publicaciones de todo el mundo"
},
"topbar": { "topbar": {
"wiredToLife": "Conectado a la vida", "wiredToLife": "Conectado a la vida",
"installApp": "Instalar App", "installApp": "Instalar App",

View File

@ -33,6 +33,9 @@
"contact": "Contact", "contact": "Contact",
"privacy": "Confidentialité" "privacy": "Confidentialité"
}, },
"wall": {
"latestPosts": "Dernières publications du monde entier"
},
"topbar": { "topbar": {
"wiredToLife": "Connecté à la vie", "wiredToLife": "Connecté à la vie",
"installApp": "Installer l'app", "installApp": "Installer l'app",

View File

@ -84,57 +84,7 @@
color: #38bdf8; color: #38bdf8;
} }
/* Bouton Contacts flottant en bas à droite */ /* Chat styles moved to ChatContainer.css */
.contacts-float-btn {
position: fixed;
bottom: 16px;
right: 16px;
padding: 8px 12px;
background: rgba(30, 41, 59, 0.8);
border-radius: 999px;
color: #e5e7eb;
font-size: 13px;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
z-index: 51;
border: none;
cursor: pointer;
backdrop-filter: blur(10px);
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
}
.contact-icon { font-size: 16px; }
/* Liste des contacts (dropdown) */
.contacts-dropdown {
position: fixed;
bottom: 70px;
right: 16px;
background: rgba(30, 41, 59, 0.95);
border-radius: 12px;
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 52;
box-shadow: 0 10px 20px rgba(0,0,0,0.6);
}
.contact-item {
display: flex;
align-items: center;
gap: 8px;
color: #e5e7eb;
cursor: pointer;
padding: 6px 10px;
border-radius: 8px;
}
.contact-item:hover {
background: rgba(56,189,248,0.2);
}
.contact-avatar { .contact-avatar {
width: 24px; width: 24px;

View File

@ -1,6 +1,6 @@
:root{ :root{
--sw-subcats-nudge: -45px; /* subcats vertical nudge */ --sw-subcats-nudge: -45px; /* subcats vertical nudge */
--sw-below-overlap: 80px; /* sociowall overlaps map */ --sw-below-overlap: 120px; /* sociowall overlaps map */
} }
.map-page{ .map-page{
@ -117,7 +117,7 @@
/* Landscape: vh is small, but still keep map tall */ /* Landscape: vh is small, but still keep map tall */
@media (orientation: landscape){ @media (orientation: landscape){
:root{ --sw-below-overlap: 60px; } :root{ --sw-below-overlap: 90px; }
.map-stage{ height: clamp(400px, 80vh, 900px); } .map-stage{ height: clamp(400px, 80vh, 900px); }
} }

View File

@ -2,6 +2,23 @@
SOCIOWALL - Premium Facebook-Style Feed SOCIOWALL - Premium Facebook-Style Feed
*/ */
/* SocioWall Header */
.sociowall-header {
text-align: center;
padding: 6px 0 10px;
border-bottom: 1px solid rgba(100, 120, 160, 0.12);
margin-bottom: 8px;
}
.sociowall-title {
margin: 0;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(148, 163, 184, 0.9);
}
/* Container below map */ /* Container below map */
.below-row { .below-row {
display: flex; display: flex;
@ -17,20 +34,19 @@
.sociowall-panel { .sociowall-panel {
flex: 1 1 100%; flex: 1 1 100%;
width: 100%; width: 100%;
max-width: 680px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
min-height: 200px; min-height: 200px;
padding: 1.5rem 1rem; padding: 1.25rem 1.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.25rem; gap: 0.75rem;
/* Glassmorphism dark gray */ /* Glassmorphism dark gray */
background: linear-gradient( background: linear-gradient(
165deg, 180deg,
rgba(30, 35, 50, 0.95) 0%, rgba(22, 26, 38, 0.98) 0%,
rgba(22, 27, 42, 0.98) 50%, rgba(18, 22, 32, 0.99) 100%
rgba(18, 22, 35, 0.99) 100%
); );
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
@ -38,14 +54,67 @@
/* Rounded corners */ /* Rounded corners */
border-radius: 24px; border-radius: 24px;
/* Subtle border glow */ /* Border */
border: 1px solid rgba(100, 120, 160, 0.15); border: 1px solid rgba(100, 120, 160, 0.2);
box-shadow: box-shadow: 0 4px 32px rgba(0, 0, 0, 0.4);
0 4px 32px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.03) inset,
0 1px 0 rgba(255, 255, 255, 0.05) inset;
} }
/*
FILTER BAR - Content type filters
*/
.sociowall-filters {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.25rem;
padding: 0;
margin: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border-bottom: 1px solid rgba(100, 120, 160, 0.15);
}
.sociowall-filter-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.65rem 1rem;
border-radius: 0;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: #64748b;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s ease;
}
.sociowall-filter-btn:hover {
color: #94a3b8;
background: rgba(255, 255, 255, 0.03);
}
.sociowall-filter-btn.active {
color: #60a5fa;
border-bottom-color: #60a5fa;
}
.sociowall-filter-btn i {
font-size: 0.9rem;
}
.sociowall-filter-btn.filter-all i { color: #a78bfa; }
.sociowall-filter-btn.filter-news i { color: #60a5fa; }
.sociowall-filter-btn.filter-video i { color: #f472b6; }
.sociowall-filter-btn.filter-image i { color: #34d399; }
.sociowall-filter-btn.filter-live i { color: #f87171; }
.sociowall-filter-btn.filter-event i { color: #fbbf24; }
.sociowall-filter-btn.active i { color: inherit; }
/* Header with gradient underline */ /* Header with gradient underline */
.sociowall-header { .sociowall-header {
font-size: 0.85rem; font-size: 0.85rem;
@ -985,6 +1054,6 @@ body[data-theme="light"] .sw-wall-edit-textarea {
} }
.sociowall-panel { .sociowall-panel {
max-width: 680px; max-width: 1200px;
} }
} }

View File

@ -3,15 +3,13 @@ body[data-theme="dark"] { background:#050816; color:#ffffff; }
/* Blue theme - sociowall uses dark gray glassmorphism */ /* Blue theme - sociowall uses dark gray glassmorphism */
body[data-theme="blue"] .sociowall-panel { body[data-theme="blue"] .sociowall-panel {
background: linear-gradient( background: linear-gradient(
165deg, 180deg,
rgba(28, 32, 42, 0.97) 0%, rgba(22, 26, 38, 0.98) 0%,
rgba(22, 26, 36, 0.98) 50%, rgba(18, 22, 32, 0.99) 100%
rgba(18, 21, 30, 0.99) 100%
); );
border-color: rgba(80, 90, 110, 0.25); border-radius: 24px;
box-shadow: border: 1px solid rgba(80, 100, 140, 0.25);
0 4px 32px rgba(0, 0, 0, 0.5), box-shadow: 0 4px 32px rgba(0, 0, 0, 0.4);
0 0 0 1px rgba(255, 255, 255, 0.04) inset;
} }
body[data-theme="blue"] .post-card { body[data-theme="blue"] .post-card {