update frontend

This commit is contained in:
Your Name 2025-12-09 20:17:31 -05:00
parent 2a66d974a2
commit 721e17d3a6
4 changed files with 433 additions and 367 deletions

View File

@ -1,59 +1,59 @@
import React, { useEffect, useRef, useState } from "react";
import maplibregl from "maplibre-gl";
import React, { useEffect, useState } from "react";
import "maplibre-gl/dist/maplibre-gl.css";
import "../../styles/mapMarkers.css";
import "../../styles/mapLayout.css";
import { fetchPosts, fetchIpLocation, createPost } from "../../api/client";
import { createPost } from "../../api/client";
import {
LAST_POS_KEY,
LAST_VIEW_KEY,
FILTER_MAIN_CATEGORIES,
FILTER_CATEGORY_MAP,
CREATE_CATEGORY_MAP,
} from "./mapConfig";
import { getViewFromMap, haversineKm } from "./mapGeo";
import {
categoryCode,
matchesSubFilter,
matchesTimeFilter,
} from "./mapFilter";
import { createMarkerForPost, clearAllMarkers } from "./markerManager";
import { useMapCore } from "./useMapCore";
import { useUserPosition } from "./useUserPosition";
import { usePostsEngine } from "./usePostsEngine";
import PostList from "../Posts/PostList";
export default function MapView() {
const containerRef = useRef(null);
const mapRef = useRef(null);
const markersRef = useRef([]); // [{ id, marker, el, ... }]
const expandedElRef = useRef(null);
// MAP + refs
const {
containerRef,
mapRef,
markersRef,
expandedElRef,
viewParams,
hasLastView,
} = useMapCore();
// posts bruts accumulés (tous ceux qu'on a déjà chargés)
const allPostsRef = useRef([]);
// dernière zone fetchée
const lastFetchRef = useRef({
center: null, // [lng, lat]
radiusKm: null,
filterKey: "",
});
const [status, setStatus] = useState("Loading posts...");
const [mainFilter, setMainFilter] = useState("All"); // All/News/Friends/Events/Market
const [timeFilter, setTimeFilter] = useState("RECENT"); // NOW/TODAY/RECENT/PAST
// FILTRES
const [mainFilter, setMainFilter] = useState("All");
const [timeFilter, setTimeFilter] = useState("RECENT");
const [subFilter, setSubFilter] = useState("All");
const [userPosition, setUserPosition] = useState(null); // [lng, lat]
// USER POSITION
const userPosition = useUserPosition(mapRef, hasLastView);
const [viewParams, setViewParams] = useState({
center: null,
radiusKm: 250,
// POSTS ENGINE (markers + Sociowall)
const {
status,
visiblePosts,
loadingPosts,
loadError,
handleIncomingPost,
} = usePostsEngine({
mapRef,
viewParams,
mainFilter,
subFilter,
timeFilter,
markersRef,
expandedElRef,
});
const [hasLastView, setHasLastView] = useState(false);
// SOCIOWALL selection
const [selectedPost, setSelectedPost] = useState(null);
// NEW POST
// NEW POST (overlay)
const [isCreating, setIsCreating] = useState(false);
const [draftTitle, setDraftTitle] = useState("");
const [draftBody, setDraftBody] = useState("");
@ -65,177 +65,6 @@ export default function MapView() {
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState("");
// SOCIOWALL
const [visiblePosts, setVisiblePosts] = useState([]);
const [selectedPost, setSelectedPost] = useState(null);
const [loadingPosts, setLoadingPosts] = useState(false);
const [loadError, setLoadError] = useState("");
/* ================= INIT MAP ================= */
useEffect(() => {
if (!containerRef.current) return;
const map = new maplibregl.Map({
container: containerRef.current,
style: {
version: 8,
sources: {
"carto-dark": {
type: "raster",
tiles: [
"https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png",
],
tileSize: 256,
attribution: "© OpenStreetMap contributors © CARTO",
},
},
layers: [
{
id: "carto-dark-layer",
type: "raster",
source: "carto-dark",
},
],
},
center: [-95, 40],
zoom: 3.5,
pitch: 0,
bearing: 0,
antialias: true,
});
// Reprise dernière vue
let hadLastView = false;
try {
const raw = localStorage.getItem(LAST_VIEW_KEY);
if (raw) {
const v = JSON.parse(raw);
if (
typeof v.lat === "number" &&
typeof v.lon === "number" &&
typeof v.zoom === "number"
) {
map.setCenter([v.lon, v.lat]);
map.setZoom(v.zoom);
hadLastView = true;
}
}
} catch {
// ignore
}
setHasLastView(hadLastView);
map.addControl(new maplibregl.NavigationControl(), "top-right");
mapRef.current = map;
map.on("load", () => {
const vp = getViewFromMap(map);
setViewParams(vp);
});
map.on("moveend", () => {
const vp = getViewFromMap(map);
setViewParams(vp);
try {
const center = vp.center;
localStorage.setItem(
LAST_VIEW_KEY,
JSON.stringify({
lat: center[1],
lon: center[0],
zoom: map.getZoom(),
})
);
} catch {
// ignore
}
});
return () => {
clearAllMarkers(markersRef, expandedElRef);
map.remove();
mapRef.current = null;
};
}, []);
/* ================= POSITION UTILISATEUR (GPS / IP) ================= */
useEffect(() => {
const map = mapRef.current;
if (!map) return;
let cancelled = false;
function saveUserPos(lat, lon) {
if (cancelled) return;
const coords = [lon, lat];
setUserPosition(coords);
try {
localStorage.setItem(LAST_POS_KEY, JSON.stringify({ lat, lon }));
} catch {
// ignore
}
if (!hasLastView) {
map.flyTo({
center: coords,
zoom: 9,
speed: 1.2,
curve: 1.5,
essential: true,
});
}
}
// 1) dernière position user
try {
const raw = localStorage.getItem(LAST_POS_KEY);
if (raw) {
const p = JSON.parse(raw);
if (typeof p.lat === "number" && typeof p.lon === "number") {
setUserPosition([p.lon, p.lat]);
}
}
} catch {
// ignore
}
// 2) GPS 3) IP backend
function viaIpFallback() {
(async () => {
const ipCoords = await fetchIpLocation();
if (!ipCoords || cancelled) return;
const [lon, lat] = ipCoords;
saveUserPos(lat, lon);
})();
}
if ("geolocation" in navigator) {
navigator.geolocation.getCurrentPosition(
(pos) => {
if (cancelled) return;
saveUserPos(pos.coords.latitude, pos.coords.longitude);
},
() => {
if (cancelled) return;
viaIpFallback();
},
{
enableHighAccuracy: true,
timeout: 8000,
maximumAge: 30000,
}
);
} else {
viaIpFallback();
}
return () => {
cancelled = true;
};
}, [hasLastView]);
/* ================= FILTRES (bas) ================= */
const bottomCategories = FILTER_CATEGORY_MAP[mainFilter] || ["All"];
@ -247,146 +76,6 @@ export default function MapView() {
}
}, [mainFilter, bottomCategories, subFilter]);
/* ================= MARKERS & FILTERS ================= */
// Applique TOUS les filtres (catégorie + sous-cat + temps) sur ce qu'on a déjà en mémoire.
const rebuildMarkersForFilters = (tf) => {
const map = mapRef.current;
if (!map) return;
clearAllMarkers(markersRef, expandedElRef);
const posts = allPostsRef.current || [];
const catCode = categoryCode(mainFilter);
const visible = posts.filter((p) => {
// catégorie
if (catCode) {
const pc = (p.category || p.Category || "").toString().toUpperCase();
if (pc !== catCode) return false;
}
// sous-catégorie
if (!matchesSubFilter(p, subFilter)) return false;
// temps
if (!matchesTimeFilter(p.created_at || p.CreatedAt, tf)) return false;
return true;
});
visible.forEach((post) => {
createMarkerForPost(post, mapRef, markersRef, expandedElRef);
});
setVisiblePosts(visible);
setStatus(visible.length ? "" : "No posts found.");
};
// Dès qu'on change de catégorie ou sous-catégorie,
// on filtre immédiatement ce qui est déjà chargé
useEffect(() => {
rebuildMarkersForFilters(timeFilter);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mainFilter, subFilter]);
/* ================= FETCH PAR ZONE (ACCUMULATIF) ================= */
useEffect(() => {
const map = mapRef.current;
if (!map) return;
if (!viewParams.center) return;
let cancelled = false;
async function load() {
const [lng, lat] = viewParams.center;
const radiusKm = viewParams.radiusKm || 250;
const filterKey = `${mainFilter}|${subFilter}`;
const last = lastFetchRef.current;
if (last.center && last.filterKey === filterKey) {
const [lastLng, lastLat] = last.center;
const distKm = haversineKm(lastLat, lastLng, viewParams.center[1], lng);
const minMoveKm = Math.max(100, radiusKm * 0.4);
if (distKm < minMoveKm) {
return;
}
}
try {
setStatus("Loading posts...");
setLoadingPosts(true);
setLoadError("");
const catCode = categoryCode(mainFilter);
const subCatParam =
subFilter && subFilter.toUpperCase() !== "ALL" && subFilter !== "All"
? subFilter
: "";
const newPosts = await fetchPosts({
category: catCode,
subCategory: subCatParam,
time: "",
lat,
lon: lng,
radiusKm,
});
if (cancelled) return;
const existing = allPostsRef.current || [];
const byId = new Map();
for (const p of existing) {
if (p && typeof p.id !== "undefined") {
byId.set(p.id, p);
}
}
if (Array.isArray(newPosts)) {
for (const p of newPosts) {
if (p && typeof p.id !== "undefined" && !byId.has(p.id)) {
byId.set(p.id, p);
}
}
}
allPostsRef.current = Array.from(byId.values());
lastFetchRef.current = {
center: [...viewParams.center],
radiusKm,
filterKey,
};
// Une fois le backend répondu, on met à jour les markers + Sociowall
rebuildMarkersForFilters(timeFilter);
setLoadingPosts(false);
} catch (err) {
console.error("Erreur chargement posts:", err);
if (!cancelled) {
setStatus("Error loading posts.");
setLoadError("Error loading posts.");
setLoadingPosts(false);
}
}
}
load();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewParams, mainFilter, subFilter]);
// Quand on change le filtre temps, on réapplique directement
useEffect(() => {
rebuildMarkersForFilters(timeFilter);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeFilter]);
/* ================= WEBSOCKET ================= */
useEffect(() => {
@ -410,24 +99,7 @@ export default function MapView() {
try {
const msg = JSON.parse(ev.data);
if (msg.type === "new_post" && msg.post) {
const p = msg.post;
// On ajoute à la liste globale
allPostsRef.current = [...allPostsRef.current, p];
// Et si le nouveau post matche les filtres courants, on l'affiche
const catCode = categoryCode(mainFilter);
if (catCode) {
const pc = (p.category || p.Category || "").toString().toUpperCase();
if (pc !== catCode) return;
}
if (!matchesSubFilter(p, subFilter)) return;
if (!matchesTimeFilter(p.created_at || p.CreatedAt, timeFilter)) {
return;
}
createMarkerForPost(p, mapRef, markersRef, expandedElRef);
setVisiblePosts((current) => [...current, p]);
handleIncomingPost(msg.post);
}
} catch {
// ignore
@ -443,7 +115,7 @@ export default function MapView() {
ws.close();
}
};
}, [mainFilter, subFilter, timeFilter]);
}, [handleIncomingPost]);
/* ================= ACTIONS UI ================= */

View File

@ -0,0 +1,115 @@
import { useEffect, useRef, useState } from "react";
import maplibregl from "maplibre-gl";
import { LAST_VIEW_KEY } from "./mapConfig";
import { getViewFromMap } from "./mapGeo";
import { clearAllMarkers } from "./markerManager";
export function useMapCore() {
const containerRef = useRef(null);
const mapRef = useRef(null);
// pour les markers (utilisé aussi par usePostsEngine + MapView)
const markersRef = useRef([]);
const expandedElRef = useRef(null);
const [viewParams, setViewParams] = useState({
center: null,
radiusKm: 250,
});
const [hasLastView, setHasLastView] = useState(false);
useEffect(() => {
if (!containerRef.current) return;
const map = new maplibregl.Map({
container: containerRef.current,
style: {
version: 8,
sources: {
"carto-dark": {
type: "raster",
tiles: [
"https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png",
],
tileSize: 256,
attribution: "© OpenStreetMap contributors © CARTO",
},
},
layers: [
{
id: "carto-dark-layer",
type: "raster",
source: "carto-dark",
},
],
},
center: [-95, 40],
zoom: 3.5,
pitch: 0,
bearing: 0,
antialias: true,
});
// Reprise dernière vue
let hadLastView = false;
try {
const raw = localStorage.getItem(LAST_VIEW_KEY);
if (raw) {
const v = JSON.parse(raw);
if (
typeof v.lat === "number" &&
typeof v.lon === "number" &&
typeof v.zoom === "number"
) {
map.setCenter([v.lon, v.lat]);
map.setZoom(v.zoom);
hadLastView = true;
}
}
} catch {
// ignore
}
setHasLastView(hadLastView);
map.addControl(new maplibregl.NavigationControl(), "top-right");
mapRef.current = map;
map.on("load", () => {
const vp = getViewFromMap(map);
setViewParams(vp);
});
map.on("moveend", () => {
const vp = getViewFromMap(map);
setViewParams(vp);
try {
const center = vp.center;
localStorage.setItem(
LAST_VIEW_KEY,
JSON.stringify({
lat: center[1],
lon: center[0],
zoom: map.getZoom(),
})
);
} catch {
// ignore
}
});
return () => {
clearAllMarkers(markersRef, expandedElRef);
map.remove();
mapRef.current = null;
};
}, []);
return {
containerRef,
mapRef,
markersRef,
expandedElRef,
viewParams,
hasLastView,
};
}

View File

@ -0,0 +1,195 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchPosts } from "../../api/client";
import { categoryCode, matchesSubFilter, matchesTimeFilter } from "./mapFilter";
import { haversineKm } from "./mapGeo";
import { createMarkerForPost, clearAllMarkers } from "./markerManager";
/**
* Gère :
* - posts en mémoire (allPostsRef)
* - filtres (cat / sous-cat / temps) appliqués sur la map + Sociowall
* - fetch par zone (backend)
*/
export function usePostsEngine({
mapRef,
viewParams,
mainFilter,
subFilter,
timeFilter,
markersRef,
expandedElRef,
}) {
const allPostsRef = useRef([]);
const lastFetchRef = useRef({
center: null,
radiusKm: null,
filterKey: "",
});
const [status, setStatus] = useState("Loading posts...");
const [visiblePosts, setVisiblePosts] = useState([]);
const [loadingPosts, setLoadingPosts] = useState(false);
const [loadError, setLoadError] = useState("");
// Applique tous les filtres sur ce qu'on a déjà
const rebuildMarkersForFilters = useCallback(
(tf) => {
const map = mapRef.current;
if (!map) return;
clearAllMarkers(markersRef, expandedElRef);
const posts = allPostsRef.current || [];
const catCode = categoryCode(mainFilter);
const visible = posts.filter((p) => {
// catégorie
if (catCode) {
const pc = (p.category || p.Category || "").toString().toUpperCase();
if (pc !== catCode) return false;
}
// sous-catégorie
if (!matchesSubFilter(p, subFilter)) return false;
// temps
if (!matchesTimeFilter(p.created_at || p.CreatedAt, tf)) return false;
return true;
});
visible.forEach((post) => {
createMarkerForPost(post, mapRef, markersRef, expandedElRef);
});
setVisiblePosts(visible);
setStatus(visible.length ? "" : "No posts found.");
},
[mapRef, markersRef, expandedElRef, mainFilter, subFilter]
);
// Changement de cat / sous-cat / temps → filtre instantané
useEffect(() => {
rebuildMarkersForFilters(timeFilter);
}, [timeFilter, mainFilter, subFilter, rebuildMarkersForFilters]);
// Fetch backend quand la vue bouge assez
useEffect(() => {
const map = mapRef.current;
if (!map) return;
if (!viewParams.center) return;
let cancelled = false;
async function load() {
const [lng, lat] = viewParams.center;
const radiusKm = viewParams.radiusKm || 250;
const filterKey = `${mainFilter}|${subFilter}`;
const last = lastFetchRef.current;
if (last.center && last.filterKey === filterKey) {
const [lastLng, lastLat] = last.center;
const distKm = haversineKm(lastLat, lastLng, viewParams.center[1], lng);
const minMoveKm = Math.max(100, radiusKm * 0.4);
if (distKm < minMoveKm) {
return;
}
}
try {
setStatus("Loading posts...");
setLoadingPosts(true);
setLoadError("");
const catCode = categoryCode(mainFilter);
const subCatParam =
subFilter && subFilter.toUpperCase() !== "ALL" && subFilter !== "All"
? subFilter
: "";
const newPosts = await fetchPosts({
category: catCode,
subCategory: subCatParam,
time: "",
lat,
lon: lng,
radiusKm,
});
if (cancelled) return;
const existing = allPostsRef.current || [];
const byId = new Map();
for (const p of existing) {
if (p && typeof p.id !== "undefined") {
byId.set(p.id, p);
}
}
if (Array.isArray(newPosts)) {
for (const p of newPosts) {
if (p && typeof p.id !== "undefined" && !byId.has(p.id)) {
byId.set(p.id, p);
}
}
}
allPostsRef.current = Array.from(byId.values());
lastFetchRef.current = {
center: [...viewParams.center],
radiusKm,
filterKey,
};
// Après réponse du backend → on ré-applique les filtres
rebuildMarkersForFilters(timeFilter);
setLoadingPosts(false);
} catch (err) {
console.error("Erreur chargement posts:", err);
if (!cancelled) {
setStatus("Error loading posts.");
setLoadError("Error loading posts.");
setLoadingPosts(false);
}
}
}
load();
return () => {
cancelled = true;
};
}, [viewParams, mainFilter, subFilter, mapRef, rebuildMarkersForFilters, timeFilter]);
// Nouveau post reçu via WebSocket
const handleIncomingPost = useCallback(
(p) => {
// ajoute au cache global
allPostsRef.current = [...allPostsRef.current, p];
// si ça matche les filtres courants → on l'affiche
const catCode = categoryCode(mainFilter);
if (catCode) {
const pc = (p.category || p.Category || "").toString().toUpperCase();
if (pc !== catCode) return;
}
if (!matchesSubFilter(p, subFilter)) return;
if (!matchesTimeFilter(p.created_at || p.CreatedAt, timeFilter)) {
return;
}
createMarkerForPost(p, mapRef, markersRef, expandedElRef);
setVisiblePosts((current) => [...current, p]);
},
[mainFilter, subFilter, timeFilter, mapRef, markersRef, expandedElRef]
);
return {
status,
visiblePosts,
loadingPosts,
loadError,
handleIncomingPost,
};
}

View File

@ -0,0 +1,84 @@
import { useEffect, useState } from "react";
import { LAST_POS_KEY } from "./mapConfig";
import { fetchIpLocation } from "../../api/client";
export function useUserPosition(mapRef, hasLastView) {
const [userPosition, setUserPosition] = useState(null); // [lng, lat]
useEffect(() => {
const map = mapRef.current;
if (!map) return;
let cancelled = false;
function saveUserPos(lat, lon) {
if (cancelled) return;
const coords = [lon, lat];
setUserPosition(coords);
try {
localStorage.setItem(LAST_POS_KEY, JSON.stringify({ lat, lon }));
} catch {
// ignore
}
if (!hasLastView) {
map.flyTo({
center: coords,
zoom: 9,
speed: 1.2,
curve: 1.5,
essential: true,
});
}
}
// 1) dernière position user
try {
const raw = localStorage.getItem(LAST_POS_KEY);
if (raw) {
const p = JSON.parse(raw);
if (typeof p.lat === "number" && typeof p.lon === "number") {
setUserPosition([p.lon, p.lat]);
}
}
} catch {
// ignore
}
// 2) GPS → 3) IP backend
function viaIpFallback() {
(async () => {
const ipCoords = await fetchIpLocation();
if (!ipCoords || cancelled) return;
const [lon, lat] = ipCoords;
saveUserPos(lat, lon);
})();
}
if ("geolocation" in navigator) {
navigator.geolocation.getCurrentPosition(
(pos) => {
if (cancelled) return;
saveUserPos(pos.coords.latitude, pos.coords.longitude);
},
() => {
if (cancelled) return;
viaIpFallback();
},
{
enableHighAccuracy: true,
timeout: 8000,
maximumAge: 30000,
}
);
} else {
viaIpFallback();
}
return () => {
cancelled = true;
};
}, [mapRef, hasLastView]);
return userPosition;
}