import React, { useCallback, useEffect, useRef, useState } from "react"; import "maplibre-gl/dist/maplibre-gl.css"; import "../../styles/map.css"; import "../../styles/mapMarkers.css"; import "../../styles/posts.css"; import "../../styles/filters.css"; import { createPost } from "../../api/client"; import { FILTER_MAIN_CATEGORIES, FILTER_CATEGORY_MAP, CREATE_CATEGORY_MAP, } from "./mapConfig"; import { useMapCore } from "./useMapCore"; import { useUserPosition } from "./useUserPosition"; import { usePostsEngine } from "./usePostsEngine"; import { openCenteredOverlay } from "./markerManager"; import { useAuth } from "../../auth/AuthContext"; import FilterButtons from "../Filters/FilterButtons"; import TimeFilterButtons from "../Filters/TimeFilterButtons"; import SmartSearchBar from "../Search/SmartSearchBar"; import UserProfile from "../Profile/UserProfile"; import { fetchPostById } from "../../api/client"; export default function MapView({ theme = "dark", onPostsState, selectedPost, onSelectPost, selectedIsland, onSelectIsland, }) { const { authenticated, username, token, needsEmailVerification } = useAuth(); const { containerRef, mapRef, markersRef, expandedElRef, viewParams, hasLastView } = useMapCore(theme); const [mainFilter, setMainFilter] = useState("All"); const TIME_FILTER_KEY = "sociowire:timeFilter"; const [timeFilter, setTimeFilter] = useState(() => { try { const v = localStorage.getItem(TIME_FILTER_KEY); const ok = ["NOW", "TODAY", "RECENT", "PAST"].includes(v); return ok ? v : "PAST"; } catch { return "PAST"; } }); const [subFilter, setSubFilter] = useState("All"); useEffect(() => { try { localStorage.setItem(TIME_FILTER_KEY, timeFilter); } catch {} }, [timeFilter]); const stageRef = useRef(null); const [showSubcats, setShowSubcats] = useState(true); const userPosition = useUserPosition(mapRef, hasLastView); const [searchQuery, setSearchQuery] = useState(""); const skipAutoZoomRef = useRef(false); // Profile state (selectedIsland is now passed from App.jsx) const [selectedProfile, setSelectedProfile] = useState(null); const openedPostRef = useRef(null); const queryPostRef = useRef(false); const overlayTimerRef = useRef(null); const pendingOpenPostIdRef = useRef(null); const pendingOpenFullRef = useRef(false); const openOverlayWhenReady = useCallback( (post, opts = {}) => { if (!post?.id) return; const { fullScreen = false } = opts; const tryOpen = () => { const map = mapRef.current; if (!map) return false; openCenteredOverlay({ map, post, theme, fullScreen }); return true; }; if (tryOpen()) return; if (overlayTimerRef.current) clearInterval(overlayTimerRef.current); overlayTimerRef.current = setInterval(() => { if (tryOpen()) { clearInterval(overlayTimerRef.current); overlayTimerRef.current = null; } }, 220); }, [mapRef, theme] ); const { status, visiblePosts, loadingPosts, loadError, handleIncomingPost } = usePostsEngine({ theme, mapRef, viewParams, mainFilter, subFilter, timeFilter, searchQuery, markersRef, expandedElRef, onAutoWidenTimeFilter: (next) => setTimeFilter(next), onSelectPost, username, }); const [isCreating, setIsCreating] = useState(false); const [draftTitle, setDraftTitle] = useState(""); const [draftBody, setDraftBody] = useState(""); const [draftCategory, setDraftCategory] = useState("News"); const [draftSubCategory, setDraftSubCategory] = useState( CREATE_CATEGORY_MAP.News[0] ); const [draftCoords, setDraftCoords] = useState(null); const [draftImage, setDraftImage] = useState(""); const [draftImageFile, setDraftImageFile] = useState(null); const [draftImagePreview, setDraftImagePreview] = useState(""); const [useCustomImage, setUseCustomImage] = useState(false); const [imageVisibility, setImageVisibility] = useState("public"); const [imageGroupId, setImageGroupId] = useState(""); const [imageUsers, setImageUsers] = useState(""); const [uploadingImage, setUploadingImage] = useState(false); const [isSaving, setIsSaving] = useState(false); const [saveError, setSaveError] = useState(""); const bottomCategories = FILTER_CATEGORY_MAP[mainFilter] || ["All"]; const getSubcatIcon = (main, sub) => { const M = { All: { All: "fa-solid fa-layer-group", }, News: { All: "fa-solid fa-layer-group", World: "fa-solid fa-globe", Politics: "fa-solid fa-landmark-flag", Tech: "fa-solid fa-microchip", Finance: "fa-solid fa-chart-line", Local: "fa-solid fa-location-dot", }, Friends: { All: "fa-solid fa-layer-group", Nearby: "fa-solid fa-location-crosshairs", Chats: "fa-solid fa-comments", Groups: "fa-solid fa-user-group", Stories: "fa-solid fa-book-open", Favs: "fa-solid fa-heart", }, Events: { All: "fa-solid fa-layer-group", Today: "fa-solid fa-calendar-day", Weekend: "fa-solid fa-calendar-week", Music: "fa-solid fa-music", Sports: "fa-solid fa-football", Local: "fa-solid fa-location-dot", }, Market: { All: "fa-solid fa-layer-group", Actu: "fa-solid fa-newspaper", Tech: "fa-solid fa-microchip", Finan: "fa-solid fa-money-bill-trend-up", Sport: "fa-solid fa-basketball", Deals: "fa-solid fa-tags", }, }; return (M[main] && M[main][sub]) || ""; }; useEffect(() => { if (!bottomCategories.length) return; if (!subFilter || !bottomCategories.includes(subFilter)) setSubFilter("All"); }, [mainFilter, bottomCategories, subFilter]); useEffect(() => { if (!onPostsState) return; onPostsState({ status, posts: visiblePosts, loading: loadingPosts, error: loadError, }); }, [status, visiblePosts, loadingPosts, loadError, onPostsState]); useEffect(() => { if (!selectedPost) return; const map = mapRef.current; if (!map) return; const lng = selectedPost.lon ?? selectedPost.lng; const lat = selectedPost.lat ?? selectedPost.latitude; if (typeof lng === "number" && typeof lat === "number") { map.flyTo({ center: [lng, lat], zoom: 8, speed: 1.4 }); } if (selectedPost?.id && openedPostRef.current !== selectedPost.id) { openedPostRef.current = selectedPost.id; const wantFull = pendingOpenFullRef.current && pendingOpenPostIdRef.current === selectedPost.id; openOverlayWhenReady(selectedPost, { fullScreen: wantFull }); if (wantFull) { pendingOpenFullRef.current = false; pendingOpenPostIdRef.current = null; } } }, [selectedPost, mapRef, theme]); useEffect(() => { if (queryPostRef.current) return; queryPostRef.current = true; const qs = new URLSearchParams(window.location.search || ""); let idRaw = qs.get("post_id") || qs.get("id") || qs.get("p"); if (!idRaw) { const path = (window.location.pathname || "").trim(); const m = path.match(/^\/p\/(\d+)/); if (m && m[1]) { idRaw = m[1]; try { window.history.replaceState(null, "", `/?post_id=${m[1]}`); } catch {} } } if (!idRaw) return; const id = Number(idRaw); if (!Number.isFinite(id) || id <= 0) return; pendingOpenPostIdRef.current = id; pendingOpenFullRef.current = true; fetchPostById(id).then((post) => { if (!post) return; onSelectPost?.(post); openOverlayWhenReady(post, { fullScreen: true }); pendingOpenPostIdRef.current = null; pendingOpenFullRef.current = false; }); }, [onSelectPost, openOverlayWhenReady]); useEffect(() => { const wantId = pendingOpenPostIdRef.current; if (!wantId || !Array.isArray(visiblePosts) || !visiblePosts.length) return; const match = visiblePosts.find((p) => (p?.id || p?.ID) === wantId); if (!match) return; pendingOpenPostIdRef.current = null; const wantFull = pendingOpenFullRef.current; onSelectPost?.(match); openOverlayWhenReady(match, { fullScreen: wantFull }); pendingOpenFullRef.current = false; }, [visiblePosts, onSelectPost, openOverlayWhenReady]); useEffect(() => { return () => { if (overlayTimerRef.current) clearInterval(overlayTimerRef.current); }; }, []); useEffect(() => { const el = stageRef.current; if (!el || typeof IntersectionObserver === "undefined") return; const obs = new IntersectionObserver( (entries) => { const e = entries && entries[0]; const ok = !!(e && e.isIntersecting && e.intersectionRatio > 0.15); setShowSubcats(ok); }, { threshold: [0, 0.15, 0.3, 0.6] } ); obs.observe(el); return () => obs.disconnect(); }, []); useEffect(() => { const proto = window.location.protocol === "https:" ? "wss" : "ws"; const host = window.location.port === "5173" ? `${window.location.hostname}:8081` : window.location.host; const wsUrl = `${proto}://${host}/ws`; let ws; try { ws = new WebSocket(wsUrl); } catch { return; } ws.onmessage = (ev) => { try { const msg = JSON.parse(ev.data); if (msg.type === "new_post" && msg.post) handleIncomingPost(msg.post); } catch {} }; return () => ws && ws.close(); }, [handleIncomingPost]); const handleFlyToMe = () => { const map = mapRef.current; if (!map || !userPosition) return; map.flyTo({ center: userPosition, zoom: 9, speed: 1.2 }); }; const handlePickSuggestion = (item, { zoom }) => { const map = mapRef.current; if (!map) return; // Extract coords from item let coords = null; if (Array.isArray(item.coords) && item.coords.length === 2) { coords = item.coords; // [lon, lat] } else if (typeof item.lon === 'number' && typeof item.lat === 'number') { coords = [item.lon, item.lat]; } if (!coords) { console.warn('[MapView] No coords for suggestion:', item); return; } console.log('[MapView] Flying to:', item.title, coords, 'zoom:', zoom); map.flyTo({ center: coords, zoom: zoom || 12, speed: 1.4, essential: true }); map.__swForceFetchAt = Date.now(); // If picking a post, clear filters and avoid query-locking if (item.type === 'post') { skipAutoZoomRef.current = true; setSearchQuery(""); setMainFilter("All"); setSubFilter("All"); setTimeFilter("PAST"); // Show all time periods const raw = item.raw || item; if (raw && typeof raw === "object") { const lat = typeof raw.lat === "number" ? raw.lat : coords[1]; const lon = typeof raw.lon === "number" ? raw.lon : coords[0]; handleIncomingPost({ id: raw.post_id ?? raw.id ?? 0, title: raw.title || item.title || "Post", snippet: raw.snippet || raw.summary || "", category: raw.category || "NEWS", sub_category: raw.sub_category || raw.subCategory || "ALL", lat, lon, url: raw.url || "", source: raw.source || "", published_at: raw.published_at || "", created_at: raw.published_at || "", url_hash: raw.url_hash || "", }); } } else { skipAutoZoomRef.current = false; setSearchQuery(""); } }; const handleSearch = (query) => { console.log('[MapView] Search/filter by:', query); skipAutoZoomRef.current = false; setSearchQuery(query); // Clear ALL filters to show all search results if (query.trim()) { setMainFilter("All"); setSubFilter("All"); setTimeFilter("PAST"); // Show all time periods } }; // Zoom intelligent: quand on cherche, zoom pour voir tous les résultats useEffect(() => { if (!searchQuery || !visiblePosts.length) return; if (skipAutoZoomRef.current) { skipAutoZoomRef.current = false; return; } const map = mapRef.current; if (!map) return; // Attendre un peu que les markers soient créés const timer = setTimeout(() => { const postsWithCoords = visiblePosts.filter(p => { const lat = typeof p.lat === 'number' ? p.lat : p.latitude; const lon = typeof p.lon === 'number' ? p.lon : p.lng; return lat != null && lon != null; }); if (postsWithCoords.length === 0) return; if (postsWithCoords.length === 1) { // Un seul résultat: zoom dessus const p = postsWithCoords[0]; const lat = typeof p.lat === 'number' ? p.lat : p.latitude; const lon = typeof p.lon === 'number' ? p.lon : p.lng; map.flyTo({ center: [lon, lat], zoom: 14, speed: 1.2, essential: true }); } else { // Plusieurs résultats: calculer bounds et fitter const lats = postsWithCoords.map(p => typeof p.lat === 'number' ? p.lat : p.latitude); const lons = postsWithCoords.map(p => typeof p.lon === 'number' ? p.lon : p.lng); const minLat = Math.min(...lats); const maxLat = Math.max(...lats); const minLon = Math.min(...lons); const maxLon = Math.max(...lons); map.fitBounds( [[minLon, minLat], [maxLon, maxLat]], { padding: { top: 100, bottom: 100, left: 100, right: 100 }, maxZoom: 15, duration: 1000 } ); } }, 300); // Délai pour laisser les markers se créer return () => clearTimeout(timer); }, [searchQuery, visiblePosts, mapRef]); const handleProfileVisitIsland = (island) => { console.log('[MapView] Visiting island from profile:', island.username); setSelectedProfile(null); // Close profile modal onSelectIsland && onSelectIsland(island); // Open island viewer via App.jsx }; // Global click handler for author links useEffect(() => { const handleAuthorClick = (e) => { const authorLink = e.target.closest('.sw-author-link'); if (authorLink) { const author = authorLink.getAttribute('data-author'); if (author) { console.log('[MapView] Author clicked:', author); setSelectedProfile(author); } } }; document.addEventListener('click', handleAuthorClick); return () => document.removeEventListener('click', handleAuthorClick); }, []); const handleOpenCreate = () => { if (!authenticated) { try { window.dispatchEvent( new CustomEvent("sociowire:auth-required", { detail: { message: "Please sign in or create a free account to create a post.", mode: "login", }, }) ); } catch {} return; } if (needsEmailVerification) { try { window.dispatchEvent( new CustomEvent("sociowire:auth-required", { detail: { message: "Please verify your email before creating a post. Check your inbox or resend verification in the top bar.", mode: "login", }, }) ); } catch {} return; } setIsCreating(true); setDraftTitle(""); setDraftBody(""); setDraftImage(""); setDraftImageFile(null); setDraftImagePreview(""); setUseCustomImage(false); setImageVisibility("public"); setImageGroupId(""); setImageUsers(""); const main = mainFilter === "All" ? "News" : mainFilter; setDraftCategory(main); const list = CREATE_CATEGORY_MAP[main] || CREATE_CATEGORY_MAP.News; setDraftSubCategory(list[0]); setDraftCoords(null); }; const handleSearchSubmit = (e) => { e.preventDefault(); }; const handleClearSearch = () => { setSearchQuery(""); }; const handleImageFileChange = async (e) => { const file = e.target.files?.[0]; if (!file) return; // Vérifier le type de fichier if (!file.type.startsWith('image/')) { setSaveError("Please select an image file"); return; } // Vérifier la taille (max 5MB) if (file.size > 5 * 1024 * 1024) { setSaveError("Image must be less than 5MB"); return; } setDraftImageFile(file); // Créer preview const reader = new FileReader(); reader.onloadend = () => { setDraftImagePreview(reader.result); }; reader.readAsDataURL(file); // Upload immédiatement setUploadingImage(true); setSaveError(""); try { const formData = new FormData(); formData.append('file', file); formData.append('visibility', imageVisibility); if (imageGroupId.trim()) formData.append('group_id', imageGroupId.trim()); if (imageUsers.trim()) formData.append('users', imageUsers.trim()); const res = await fetch('/api/assets/upload', { method: 'POST', body: formData, credentials: 'include', headers: token ? { 'Authorization': `Bearer ${token}` } : {}, }); if (!res.ok) { throw new Error('Upload failed'); } const data = await res.json(); if (data && data.url) { setDraftImage(data.url); } else if (data && data.asset_id) { setDraftImage(`asset://${data.asset_id}`); } setUploadingImage(false); } catch (err) { console.error('Image upload error:', err); setSaveError('Failed to upload image'); setUploadingImage(false); setDraftImageFile(null); setDraftImagePreview(""); } }; const handleSubmitPost = async () => { if (isSaving) return; setSaveError(""); const map = mapRef.current; if (!map) return; if (!authenticated) { setSaveError("You must be logged in to post."); return; } if (needsEmailVerification) { setSaveError( "Verify your email to post. Use 'Resend email' in the top bar, then login again." ); return; } const authorName = (username || "").trim() || "anon"; let lng; let lat; if (draftCoords && draftCoords.length === 2) { lng = draftCoords[0]; lat = draftCoords[1]; } else { const stage = stageRef.current; const crosshairEl = stage ? stage.querySelector(".crosshair-aim") : null; const containerEl = map.getContainer(); if (crosshairEl && containerEl) { const crossRect = crosshairEl.getBoundingClientRect(); const containerRect = containerEl.getBoundingClientRect(); const px = crossRect.left + crossRect.width / 2 - containerRect.left; const py = crossRect.top + crossRect.height / 2 - containerRect.top; const correctedLngLat = map.unproject([px, py]); lng = correctedLngLat.lng; lat = correctedLngLat.lat; } else { const center = map.getCenter(); lng = center.lng; lat = center.lat; } } if (!draftTitle.trim()) { setSaveError("Title required."); return; } try { setIsSaving(true); const payload = { title: draftTitle.trim(), snippet: draftBody.trim() || draftTitle.trim(), category: draftCategory === "Events" ? "EVENT" : draftCategory.toUpperCase(), sub_category: draftSubCategory || "ALL", lat, lon: lng, author: authorName, }; if (useCustomImage && draftImage.trim()) { payload.image_small = draftImage.trim(); payload.image_large = draftImage.trim(); } const newPost = await createPost(payload, token); if (newPost && newPost.id) handleIncomingPost(newPost); setIsSaving(false); setIsCreating(false); } catch (e) { console.error(e); setIsSaving(false); const code = e && (e.code || ""); const msg = String((e && e.message) || "").toLowerCase(); const isNotVerified = code === "email_not_verified" || (e && e.status === 403 && (msg.includes("not verified") || msg.includes("verify"))); if (isNotVerified) { setSaveError( "Verify your email to post. Use 'Resend email' in the top bar, then login again." ); } else { setSaveError((e && e.message) || "Error creating post."); } } }; const handleMainFilterSelect = (code) => { if (!FILTER_MAIN_CATEGORIES.includes(code)) return; setMainFilter(code); }; const handleSelectPost = (post) => { if (onSelectPost) onSelectPost(post); const map = mapRef.current; if (!map) return; const lng = post.lon ?? post.lng; const lat = post.lat ?? post.latitude; if (typeof lng === "number" && typeof lat === "number") { map.flyTo({ center: [lng, lat], zoom: 8, speed: 1.4 }); } }; return (