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, fetchPostPreview, resolveAssetRef } from "../../api/client"; import { LiveKitBroadcast, LiveKitViewerModal } from "../Live"; import { WeatherModal } from "../Weather"; 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, fetchPostByUrl } from "../../api/client"; import { trackEvent } from "../../utils/analytics"; import DayNightTerminator from "./DayNightTerminator"; const CONTENT_CREATOR_MODES = [ { id: "live", label: "Live video", icon: "fa-solid fa-broadcast-tower", hint: "Broadcast a live scene with a headline or link.", detail: [ "Drop the stream URL or quick headline to pin the feed instantly.", "We’ll open the location where you place the crosshair so viewers know where to tune.", ], }, { id: "link", label: "Link story", icon: "fa-solid fa-link", hint: "Share a URL and we’ll prefill the preview.", detail: [ "Paste a news link, playlist, or update so we can fetch an image and snippet.", "Choose the most relevant pin so the story belongs to the right neighborhood.", ], }, { id: "photo", label: "Photo story", icon: "fa-solid fa-camera-retro", hint: "Upload or link an image with context.", detail: [ "Upload a photo set or paste an image URL from your gallery.", "Caption quickly so the community knows what they’re looking at.", ], }, { id: "text", label: "Written update", icon: "fa-solid fa-pen-to-square", hint: "Type a short report or microblog.", detail: [ "Write the key message in the title field (or add more context in the body).", "Use subcategory filters to mark the tone: News, Event, Market, etc.", ], }, { id: "other", label: "Other", icon: "fa-solid fa-ellipsis", hint: "Custom content that spans media types.", detail: [ "Mix media, public drafts, or reference notes in one place.", "Tag a region or Island to give the update a home.", ], }, ]; const MODE_FLOW_CONFIG = { live: { hero: "Pin where the live scene is happening and paste your stream link or headline.", placeholder: "Streaming link or headline", hint: "We’ll treat this as a live broadcast and show it as soon as the pin lands.", bodyPlaceholder: "Context for viewers, optional.", }, link: { hero: "Drop the link, describe what it is, and pin the relevant spot.", placeholder: "Article or playlist URL", hint: "We’ll auto-fetch a preview once the link is detected.", bodyPlaceholder: "What matters about this link?", }, photo: { hero: "Snap or upload a photo and pin the place you captured it.", placeholder: "Photo caption or image URL", hint: "Add a quick caption so the community knows what they’re seeing.", bodyPlaceholder: "Describe the scene in a few words.", }, text: { hero: "Type a short update and pin the area you’re reporting from.", placeholder: "Headline or short note", hint: "Readers should understand the gist before opening the full card.", bodyPlaceholder: "Provide more context, opinions, or call to actions.", }, other: { hero: "Combine media, notes, or references and pin wherever it matters.", placeholder: "Describe your update", hint: "This mode is flexible—add whatever helps the reader.", bodyPlaceholder: "Add links, attachments, or extra context if needed.", }, }; const LINK_PREVIEW_PLACEHOLDER = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNDAiIGhlaWdodD0iMTQwIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiByeD0iMTIiIHJ5PSIxMiIgZmlsbD0iIzBiMTIyMCIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjOTRhM2I4IiBmb250LWZhbWlseT0iSW50ZXIsIEFyaWFsIiBmb250LXNpemU9IjI0Ij5MaW5rPC90ZXh0Pjwvc3ZnPg=="; export default function MapView({ theme = "dark", onPostsState, selectedPost, onSelectPost, selectedIsland, onSelectIsland, onOpenIslands, }) { const { authenticated, username, token, needsEmailVerification } = useAuth(); const CLUSTERS_ENABLED = false; const HEATMAP_ENABLED = false; const { containerRef, mapRef, markersRef, expandedElRef, viewParams, hasLastView } = useMapCore(theme); const [mainFilter, setMainFilter] = useState("All"); const TIME_FILTER_KEY = "sociowire:timeHours"; const [timeHours, setTimeHours] = useState(() => { try { const v = localStorage.getItem(TIME_FILTER_KEY); const n = Number(v); return Number.isFinite(n) && n > 0 ? n : 336; } catch { return 336; } }); const [subFilter, setSubFilter] = useState("All"); const [mainNewsOnly, setMainNewsOnly] = useState(false); const DAY_NIGHT_KEY = "sociowire:dayNight"; const [dayNightEnabled, setDayNightEnabled] = useState(() => { try { return localStorage.getItem(DAY_NIGHT_KEY) === "true"; } catch { return false; } }); const debugEnabled = (() => { try { return typeof window !== "undefined" && new URLSearchParams(window.location.search || "").has("debug"); } catch { return false; } })(); useEffect(() => { try { localStorage.setItem(TIME_FILTER_KEY, String(timeHours)); } catch {} }, [timeHours]); useEffect(() => { try { localStorage.setItem(DAY_NIGHT_KEY, dayNightEnabled ? "true" : "false"); } catch {} }, [dayNightEnabled]); useEffect(() => { if (clusterFocus) setClusterFocus(null); if (mainNewsOnly) setMainNewsOnly(false); }, [timeHours]); const stageRef = useRef(null); const [showSubcats, setShowSubcats] = useState(true); const userPosition = useUserPosition(mapRef, hasLastView); const [searchQuery, setSearchQuery] = useState(""); const skipAutoZoomRef = useRef(false); const [clusterFocus, setClusterFocus] = useState(null); const [forceFullPostId, setForceFullPostId] = useState(null); // Profile state (selectedIsland is now passed from App.jsx) const [selectedProfile, setSelectedProfile] = useState(null); // Live broadcast state const [showLiveBroadcast, setShowLiveBroadcast] = useState(false); const [activeLivePost, setActiveLivePost] = useState(null); const [liveCoords, setLiveCoords] = useState(null); // Weather modal state const [activeWeatherPost, setActiveWeatherPost] = useState(null); const openedPostRef = useRef(null); const queryPostRef = useRef(false); const overlayTimerRef = useRef(null); const panTimerRef = useRef(null); const pendingOpenPostIdRef = useRef(null); const pendingOpenFullRef = useRef(false); const searchFitQueryRef = useRef(""); const searchUserMovedRef = useRef(false); const openOverlayWhenReady = useCallback( (post, opts = {}) => { if (!post) 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] ); // Long-press detection for creating posts (2.5 seconds) // Cancels on: zoom, pan, multi-touch, finger movement > 10px const longPressTimerRef = useRef(null); const longPressStartPos = useRef(null); const longPressCallbackRef = useRef(null); const LONG_PRESS_DURATION = 1000; const LONG_PRESS_MOVE_THRESHOLD = 10; // pixels const cancelLongPress = useCallback(() => { if (longPressTimerRef.current) { clearTimeout(longPressTimerRef.current); longPressTimerRef.current = null; } longPressStartPos.current = null; }, []); // Cancel long press on ANY map interaction useEffect(() => { const map = mapRef.current; if (!map) return; const cancel = () => cancelLongPress(); map.on('zoomstart', cancel); map.on('movestart', cancel); map.on('pitchstart', cancel); map.on('rotatestart', cancel); map.on('zoom', cancel); map.on('move', cancel); map.on('pitch', cancel); map.on('rotate', cancel); map.on('dragstart', cancel); map.on('drag', cancel); return () => { map.off('zoomstart', cancel); map.off('movestart', cancel); map.off('pitchstart', cancel); map.off('rotatestart', cancel); map.off('zoom', cancel); map.off('move', cancel); map.off('pitch', cancel); map.off('rotate', cancel); map.off('dragstart', cancel); map.off('drag', cancel); }; }, [mapRef.current, cancelLongPress]); // Touch/mouse event handlers for long-press // Re-run when viewParams changes (indicates map is ready) useEffect(() => { const container = containerRef.current; if (!container) return; const handleTouchStart = (e) => { // Multi-touch = cancel immediately if (e.touches.length !== 1) { cancelLongPress(); return; } // Ignore UI elements if (e.target.closest('.map-overlay') || e.target.closest('.sw-marker') || e.target.closest('button')) { return; } const touch = e.touches[0]; longPressStartPos.current = { x: touch.clientX, y: touch.clientY }; longPressTimerRef.current = setTimeout(() => { if (longPressStartPos.current && longPressCallbackRef.current) { longPressCallbackRef.current(); } cancelLongPress(); }, LONG_PRESS_DURATION); }; const handleTouchMove = (e) => { // Multi-touch = cancel if (e.touches.length !== 1) { cancelLongPress(); return; } // Check if finger moved too far from start position if (longPressStartPos.current && e.touches[0]) { const dx = e.touches[0].clientX - longPressStartPos.current.x; const dy = e.touches[0].clientY - longPressStartPos.current.y; const distance = Math.sqrt(dx * dx + dy * dy); if (distance > LONG_PRESS_MOVE_THRESHOLD) { cancelLongPress(); } } }; const handleTouchEnd = () => cancelLongPress(); const handleMouseDown = (e) => { if (e.target.closest('.map-overlay') || e.target.closest('.sw-marker') || e.target.closest('button')) { return; } longPressStartPos.current = { x: e.clientX, y: e.clientY }; longPressTimerRef.current = setTimeout(() => { if (longPressStartPos.current && longPressCallbackRef.current) { longPressCallbackRef.current(); } cancelLongPress(); }, LONG_PRESS_DURATION); }; const handleMouseMove = (e) => { if (longPressStartPos.current) { const dx = e.clientX - longPressStartPos.current.x; const dy = e.clientY - longPressStartPos.current.y; const distance = Math.sqrt(dx * dx + dy * dy); if (distance > LONG_PRESS_MOVE_THRESHOLD) { cancelLongPress(); } } }; const handleMouseUp = () => cancelLongPress(); const handleWheel = () => cancelLongPress(); container.addEventListener('touchstart', handleTouchStart, { passive: true }); container.addEventListener('touchmove', handleTouchMove, { passive: true }); container.addEventListener('touchend', handleTouchEnd); container.addEventListener('touchcancel', handleTouchEnd); container.addEventListener('mousedown', handleMouseDown); container.addEventListener('mousemove', handleMouseMove); container.addEventListener('mouseup', handleMouseUp); container.addEventListener('mouseleave', handleMouseUp); container.addEventListener('wheel', handleWheel, { passive: true }); return () => { container.removeEventListener('touchstart', handleTouchStart); container.removeEventListener('touchmove', handleTouchMove); container.removeEventListener('touchend', handleTouchEnd); container.removeEventListener('touchcancel', handleTouchEnd); container.removeEventListener('mousedown', handleMouseDown); container.removeEventListener('mousemove', handleMouseMove); container.removeEventListener('mouseup', handleMouseUp); container.removeEventListener('mouseleave', handleMouseUp); container.removeEventListener('wheel', handleWheel); cancelLongPress(); }; }, [viewParams, cancelLongPress]); const fitBoundsWhenReady = useCallback( (bounds, opts = {}) => { const tryFit = () => { const map = mapRef.current; if (!map) return false; try { map.fitBounds(bounds, opts); } catch {} return true; }; if (tryFit()) return; if (panTimerRef.current) clearInterval(panTimerRef.current); panTimerRef.current = setInterval(() => { if (tryFit()) { clearInterval(panTimerRef.current); panTimerRef.current = null; } }, 220); }, [mapRef] ); const panToPostWhenReady = useCallback( (post, zoom = 12) => { const lat = Number(post?.lat); const lon = Number(post?.lon); if (!Number.isFinite(lat) || !Number.isFinite(lon)) return; const tryPan = () => { const map = mapRef.current; if (!map) return false; try { map.easeTo({ center: [lon, lat], zoom, duration: 700 }); } catch {} return true; }; if (tryPan()) return; if (panTimerRef.current) clearInterval(panTimerRef.current); panTimerRef.current = setInterval(() => { if (tryPan()) { clearInterval(panTimerRef.current); panTimerRef.current = null; } }, 220); }, [mapRef] ); const { status, debugStatus, visiblePosts, loadingPosts, loadError, handleIncomingPost, handlePostUpdate, handlePostDelete } = usePostsEngine({ theme, mapRef, viewParams, mainFilter, subFilter, timeHours, searchQuery, clusterFocus, mainNewsOnly, clustersEnabled: CLUSTERS_ENABLED, heatmapEnabled: HEATMAP_ENABLED, forceFullPostId, markersRef, expandedElRef, onAutoWidenTimeFilter: () => setTimeHours(336), onSelectPost, username, debugEnabled, }); const [isCreating, setIsCreating] = useState(false); const [draftTitle, setDraftTitle] = useState(""); const [draftBody, setDraftBody] = useState(""); const [draftUrl, setDraftUrl] = useState(""); const [draftSource, setDraftSource] = useState(""); const [draftPublishedAt, setDraftPublishedAt] = useState(""); const [draftCategory, setDraftCategory] = useState("News"); const [draftSubCategory, setDraftSubCategory] = useState( CREATE_CATEGORY_MAP.News[0] ); const [contentMode, setContentMode] = useState(CONTENT_CREATOR_MODES[0].id); const currentContentMode = CONTENT_CREATOR_MODES.find((mode) => mode.id === contentMode) || CONTENT_CREATOR_MODES[0]; const currentFlow = MODE_FLOW_CONFIG[contentMode] || MODE_FLOW_CONFIG.text; const [draftCoords, setDraftCoords] = useState(null); const [modeSelectorVisible, setModeSelectorVisible] = useState(false); const [modeChosen, setModeChosen] = useState(false); const [draftImage, setDraftImage] = useState(""); const [draftImageSmall, setDraftImageSmall] = useState(""); const [draftImageMedium, setDraftImageMedium] = useState(""); const [draftImageLarge, setDraftImageLarge] = useState(""); const [draftImageFile, setDraftImageFile] = useState(null); const [draftImagePreview, setDraftImagePreview] = useState( LINK_PREVIEW_PLACEHOLDER ); const [draftMediaUrls, setDraftMediaUrls] = useState([]); const [useCustomImage, setUseCustomImage] = useState(contentMode !== "link"); const [postVisibility, setPostVisibility] = useState("public"); const [postGroupId, setPostGroupId] = useState(""); const [postUsers, setPostUsers] = useState(""); const [imageVisibility, setImageVisibility] = useState("public"); const [imageGroupId, setImageGroupId] = useState(""); const [imageUsers, setImageUsers] = useState(""); const [uploadingImage, setUploadingImage] = useState(false); const [urlPreviewLoading, setUrlPreviewLoading] = useState(false); const [urlPreviewError, setUrlPreviewError] = useState(""); const [titleTouched, setTitleTouched] = useState(false); const [bodyTouched, setBodyTouched] = useState(false); const [imageTouched, setImageTouched] = useState(false); const [isSaving, setIsSaving] = useState(false); const [saveError, setSaveError] = useState(""); const [createStep, setCreateStep] = useState(0); const previewTimerRef = useRef(null); const lastPreviewUrlRef = useRef(""); const bottomCategories = FILTER_CATEGORY_MAP[mainFilter] || ["All"]; const formatPreviewDate = (value) => { if (!value) return ""; const d = new Date(value); if (Number.isNaN(d.getTime())) return String(value); return d.toLocaleString(); }; const isLikelyUrl = (value) => /^https?:\/\//i.test(String(value || "").trim()); 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(() => { trackEvent("filter_main_change", { filter: mainFilter }); }, [mainFilter]); useEffect(() => { trackEvent("filter_sub_change", { filter: subFilter, parent: mainFilter }); }, [subFilter, mainFilter]); useEffect(() => { trackEvent("filter_time_change", { hours: timeHours }); }, [timeHours]); useEffect(() => { if (queryPostRef.current) return; const qs = new URLSearchParams(window.location.search || ""); let idRaw = qs.get("post_id") || qs.get("id") || qs.get("p"); let wasSharedLink = false; if (!idRaw) { const path = (window.location.pathname || "").trim(); const m = path.match(/^\/p\/(\d+)/); if (m && m[1]) { idRaw = m[1]; wasSharedLink = true; // Clean URL immediately - no post_id in URL, just stay on root try { window.history.replaceState(null, "", "/"); } catch {} } } else { // If arrived with ?post_id=123, also clean it wasSharedLink = true; try { window.history.replaceState(null, "", "/"); } catch {} } if (!idRaw) return; queryPostRef.current = true; const id = Number(idRaw); if (!Number.isFinite(id) || id <= 0) return; pendingOpenPostIdRef.current = id; pendingOpenFullRef.current = true; fetchPostById(id).then((post) => { if (!post) { // Post doesn't exist - show message and redirect alert("This post no longer exists or was deleted."); try { window.history.replaceState(null, "", "/"); } catch {} pendingOpenPostIdRef.current = null; pendingOpenFullRef.current = false; return; } onSelectPost?.(post); openOverlayWhenReady(post, { fullScreen: true }); pendingOpenPostIdRef.current = null; pendingOpenFullRef.current = false; }); }, [onSelectPost, openOverlayWhenReady]); useEffect(() => { if (queryPostRef.current) return; if (!CLUSTERS_ENABLED) return; const path = (window.location.pathname || "").trim(); const clusterMatch = path.match(/^\/cluster\/[^/]+\/\d{8}/); if (!clusterMatch) return; queryPostRef.current = true; const fullUrl = `${window.location.origin}${clusterMatch[0]}`; fetchPostByUrl(fullUrl).then((post) => { if (!post) return; const items = Array.isArray(post.cluster_items) ? post.cluster_items : []; const ids = Array.isArray(post.cluster_members) ? post.cluster_members : []; const points = items .map((it) => ({ lat: Number(it?.lat), lon: Number(it?.lon) })) .filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lon)); const mainPost = { ...post }; if (!Array.isArray(mainPost.cluster_items) || mainPost.cluster_items.length === 0) { mainPost.cluster_items = items; } setClusterFocus({ key: post.cluster_key || "", ids, posts: items.map((it) => ({ id: Number(it.id || it.ID) || 0, title: it.title || "Untitled", snippet: it.snippet || "", category: "NEWS", sub_category: "World", lat: Number(it.lat), lon: Number(it.lon), url: it.url || "", source: it.source || "", image_small: it.image_small || "", image_medium: it.image_medium || "", image_large: it.image_large || "", created_at: it.published_at || "", published_at: it.published_at || "", })).concat([mainPost]), points, center: { lat: Number(post.lat), lon: Number(post.lon) }, }); onSelectPost?.(mainPost); panToPostWhenReady(mainPost, 12); openOverlayWhenReady(mainPost, { fullScreen: true }); }); }, [mapRef, onSelectPost, openOverlayWhenReady, panToPostWhenReady]); useEffect(() => { if (!CLUSTERS_ENABLED || !HEATMAP_ENABLED) return; const handler = (ev) => { const detail = ev?.detail || {}; const mode = detail.mode || "click"; const skipPan = !!detail.skipPan; const key = (detail.clusterKey || "").toString(); const members = Array.isArray(detail.members) ? detail.members : []; const postId = Number(detail.postId || 0); const ids = members.length ? Array.from(new Set([...members, postId].filter(Boolean))) : (postId ? [postId] : []); const items = Array.isArray(detail.items) ? detail.items : []; const mainPost = detail.post && typeof detail.post === "object" ? detail.post : null; const toExtraPosts = (list, basePost) => { const out = []; if (basePost) { const withItems = { ...basePost }; if (!Array.isArray(withItems.cluster_items) || withItems.cluster_items.length === 0) { withItems.cluster_items = list; } out.push(withItems); } for (const it of list) { if (!it) continue; const id = Number(it.id || it.ID); out.push({ id: Number.isFinite(id) ? id : 0, title: it.title || it.Title || "Untitled", snippet: it.snippet || "", category: "NEWS", sub_category: "World", lat: Number(it.lat), lon: Number(it.lon), url: it.url || "", source: it.source || "", image_small: it.image_small || "", image_medium: it.image_medium || "", image_large: it.image_large || "", created_at: it.published_at || "", published_at: it.published_at || "", }); } return out; }; const applyClusterFocus = (list, basePost, points) => { setClusterFocus((prev) => { if (prev && prev.key && key && prev.key === key) { return mode === "hover" ? prev : null; } return { key, ids, posts: toExtraPosts(list, basePost), points, center: detail.center || null, mode, }; }); }; const map = mapRef.current; if (!map) return; try { map.__swForceFetchAt = Date.now(); } catch {} const points = Array.isArray(detail.points) ? detail.points : []; const center = detail.center || {}; const ensurePoints = (list) => { const pts = list .map((it) => ({ lat: Number(it?.lat), lon: Number(it?.lon) })) .filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lon)); if (Number.isFinite(center.lat) && Number.isFinite(center.lon)) { pts.push({ lat: center.lat, lon: center.lon }); } return pts; }; const usePoints = (pts) => { if (skipPan) return; const valid = pts.filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lon)); if (valid.length >= 2) { let minLat = 90, maxLat = -90, minLon = 180, maxLon = -180; for (const p of valid) { minLat = Math.min(minLat, p.lat); maxLat = Math.max(maxLat, p.lat); minLon = Math.min(minLon, p.lon); maxLon = Math.max(maxLon, p.lon); } fitBoundsWhenReady([[minLon, minLat], [maxLon, maxLat]], { padding: 80, duration: 700 }); return; } const lat = Number(center.lat); const lon = Number(center.lon); if (Number.isFinite(lat) && Number.isFinite(lon)) { panToPostWhenReady({ lat, lon }, 12); } }; if (!items.length && mainPost?.url) { fetchPostByUrl(mainPost.url).then((full) => { if (full && Array.isArray(full.cluster_items) && full.cluster_items.length > 0) { const fullItems = full.cluster_items; const pts = ensurePoints(fullItems); applyClusterFocus(fullItems, { ...mainPost, ...full, cluster_items: fullItems }, pts); usePoints(pts); } else { applyClusterFocus(items, mainPost, points); usePoints(points); } }); return; } const pts = ensurePoints(items); applyClusterFocus(items, mainPost, pts); usePoints(pts); }; window.addEventListener("sociowire:cluster-heatmap", handler); return () => window.removeEventListener("sociowire:cluster-heatmap", handler); }, [mapRef, fitBoundsWhenReady, panToPostWhenReady, CLUSTERS_ENABLED, HEATMAP_ENABLED]); useEffect(() => { if (!CLUSTERS_ENABLED || !HEATMAP_ENABLED) return; const handler = () => { setClusterFocus((prev) => (prev?.mode === "hover" ? null : prev)); }; window.addEventListener("sociowire:cluster-heatmap-clear", handler); return () => window.removeEventListener("sociowire:cluster-heatmap-clear", handler); }, [CLUSTERS_ENABLED, HEATMAP_ENABLED]); useEffect(() => { const wantId = pendingOpenPostIdRef.current; if (!wantId || !Array.isArray(visiblePosts) || !visiblePosts.length) return; // If pendingOpenFullRef is true, we're waiting for fetchPostById to validate the post exists // Don't intercept with cached data - let the fetch complete first if (pendingOpenFullRef.current) return; const map = mapRef.current; const overlay = map?.__swCenteredOverlay; if (overlay && overlay.postId === wantId) { pendingOpenPostIdRef.current = null; return; } const match = visiblePosts.find((p) => (p?.id || p?.ID) === wantId); if (!match) return; pendingOpenPostIdRef.current = null; onSelectPost?.(match); openOverlayWhenReady(match, { fullScreen: false }); }, [visiblePosts, onSelectPost, openOverlayWhenReady]); useEffect(() => { return () => { if (overlayTimerRef.current) clearInterval(overlayTimerRef.current); if (panTimerRef.current) clearInterval(panTimerRef.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); return; } if (msg.type === "post_update") { handlePostUpdate(msg); return; } if (msg.type === "post_delete") { handlePostDelete(msg); return; } } catch {} }; return () => ws && ws.close(); }, [handleIncomingPost, handlePostUpdate, handlePostDelete]); // Listen for live post created event (from LiveBroadcastModal) useEffect(() => { const onLivePostCreated = (e) => { if (e.detail && e.detail.id) { handleIncomingPost(e.detail); } }; window.addEventListener('live-post-created', onLivePostCreated); return () => window.removeEventListener('live-post-created', onLivePostCreated); }, [handleIncomingPost]); useEffect(() => { const onLivePostUpdated = (e) => { if (e.detail && e.detail.id) { handlePostUpdate({ post_id: e.detail.id, post: e.detail }); } }; const onLivePostDeleted = (e) => { const postId = e.detail?.post_id || e.detail?.id; if (postId) { handlePostDelete({ post_id: postId }); // Clear selected post and openedPostRef if it was deleted const selId = selectedPost?.id || selectedPost?.ID; if (selId && selId === postId) { openedPostRef.current = null; onSelectPost?.(null); } } }; window.addEventListener('live-post-updated', onLivePostUpdated); window.addEventListener('live-post-deleted', onLivePostDeleted); return () => { window.removeEventListener('live-post-updated', onLivePostUpdated); window.removeEventListener('live-post-deleted', onLivePostDeleted); }; }, [handlePostUpdate, handlePostDelete, selectedPost, onSelectPost]); useEffect(() => { const onLiveOpen = (e) => { if (e.detail) { setActiveLivePost(e.detail); } }; window.addEventListener('livekit-open', onLiveOpen); return () => window.removeEventListener('livekit-open', onLiveOpen); }, []); // Weather modal event listener useEffect(() => { const onWeatherOpen = (e) => { if (e.detail) { setActiveWeatherPost(e.detail); } }; window.addEventListener('weather-open', onWeatherOpen); return () => window.removeEventListener('weather-open', onWeatherOpen); }, []); 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 picking a post, open the real post (with likes/comments) after fetch. const kind = (item?.kind || item?.type || "").toString().toLowerCase(); const raw = item?.raw || item; const looksLikePost = kind === "post" || kind === "posts" || Number(raw?.post_id || 0) > 0 || !!raw?.url_hash || (!!raw?.url && !kind.includes("profile") && !kind.includes("place")); if (looksLikePost) { skipAutoZoomRef.current = true; setSearchQuery(""); setMainFilter("All"); setSubFilter("All"); setTimeHours(336); // 2 weeks const postId = Number(raw?.post_id ?? raw?.id ?? item?.id ?? 0); const rawUrl = raw?.url || item?.url || ""; if (Number.isFinite(postId) && postId > 0) { pendingOpenPostIdRef.current = postId; pendingOpenFullRef.current = false; } const focusPost = (post) => { if (!post) return; const lat = Number(post?.lat); const lon = Number(post?.lon); if (Number.isFinite(lat) && Number.isFinite(lon)) { const center = map.getCenter?.(); const dz = (zoom || 12); const distKm = center ? Math.sqrt(Math.pow(center.lat - lat, 2) + Math.pow(center.lng - lon, 2)) * 111 : 999; const curZoom = map.getZoom?.() || dz; const targetZoom = distKm > 8 ? dz : Math.max(curZoom, Math.min(dz, curZoom + 0.8)); map.easeTo({ center: [lon, lat], zoom: targetZoom, duration: 650, essential: true }); map.__swForceFetchAt = Date.now(); } openedPostRef.current = post?.id || null; onSelectPost?.(post); openOverlayWhenReady(post, { fullScreen: false }); }; const stubPost = (() => { const lat = Number(raw?.lat ?? item?.lat); const lon = Number(raw?.lon ?? item?.lon); const id = Number(postId || 0); if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; return { id: Number.isFinite(id) ? id : 0, title: raw?.title || item?.title || "Post", snippet: raw?.snippet || item?.snippet || "", category: raw?.category || item?.category || "NEWS", sub_category: raw?.sub_category || item?.sub_category || "", lat, lon, url: raw?.url || item?.url || "", image_small: raw?.image_small || item?.image_small || "", image_medium: raw?.image_medium || item?.image_medium || "", image_large: raw?.image_large || item?.image_large || "", created_at: raw?.created_at || item?.created_at || "", published_at: raw?.published_at || item?.published_at || "", }; })(); const tryOpen = async (attempt = 0) => { let post = null; if (Number.isFinite(postId) && postId > 0) { post = await fetchPostById(postId); } if (!post && rawUrl) { post = await fetchPostByUrl(rawUrl); } if (post) { pendingOpenPostIdRef.current = null; pendingOpenFullRef.current = false; focusPost(post); return; } if (attempt < 2) { setTimeout(() => tryOpen(attempt + 1), 650 + attempt * 400); return; } if (stubPost) { pendingOpenPostIdRef.current = null; pendingOpenFullRef.current = false; focusPost(stubPost); } if (coords) { pendingOpenPostIdRef.current = null; pendingOpenFullRef.current = false; map.easeTo({ center: coords, zoom: zoom || 12, duration: 650, essential: true }); map.__swForceFetchAt = Date.now(); } }; tryOpen(); return; } 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 (kind === 'city' || kind === 'region' || kind === 'place' || kind === 'poi') { // Place picks should move the map without forcing a text filter. skipAutoZoomRef.current = false; setSearchQuery(""); setForceFullPostId(null); return; } const q = (item.title || item.name || "").toString().trim(); skipAutoZoomRef.current = false; setSearchQuery(q); if (q) { setMainFilter("All"); setSubFilter("All"); setTimeHours(336); } }; const handleSearch = (query) => { console.log('[MapView] Search/filter by:', query); skipAutoZoomRef.current = false; setSearchQuery(query); if (!query.trim()) setForceFullPostId(null); // Clear ALL filters to show all search results if (query.trim()) { setMainFilter("All"); setSubFilter("All"); setTimeHours(336); // 2 weeks } }; // Zoom intelligent: quand on cherche, zoom pour voir tous les résultats (une seule fois) useEffect(() => { if (!searchQuery || !visiblePosts.length) return; if (skipAutoZoomRef.current) { skipAutoZoomRef.current = false; return; } if (searchUserMovedRef.current) return; if (searchFitQueryRef.current === searchQuery) 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 } ); searchFitQueryRef.current = searchQuery; } }, 300); // Délai pour laisser les markers se créer return () => clearTimeout(timer); }, [searchQuery, visiblePosts, mapRef]); useEffect(() => { searchFitQueryRef.current = ""; searchUserMovedRef.current = false; }, [searchQuery]); useEffect(() => { const map = mapRef.current; if (!map) return; if (!searchQuery) return; const handleMove = () => { searchUserMovedRef.current = true; }; map.on("movestart", handleMove); return () => map.off("movestart", handleMove); }, [mapRef, searchQuery]); 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 closeCreateModal = () => { setIsCreating(false); setModeSelectorVisible(false); setModeChosen(false); }; const handleModeSelect = (modeId) => { setContentMode(modeId); setModeSelectorVisible(false); setModeChosen(true); }; const handlePrevStep = () => { setSaveError(""); setCreateStep((prev) => Math.max(0, prev - 1)); }; 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); setCreateStep(0); setDraftTitle(""); setDraftBody(""); setDraftUrl(""); setDraftSource(""); setDraftPublishedAt(""); setDraftImage(""); setDraftImageSmall(""); setDraftImageMedium(""); setDraftImageLarge(""); setDraftImageFile(null); setDraftImagePreview(LINK_PREVIEW_PLACEHOLDER); setDraftMediaUrls([]); setUseCustomImage(false); setPostVisibility("public"); setPostGroupId(""); setPostUsers(""); setImageVisibility("public"); setImageGroupId(""); setImageUsers(""); setUrlPreviewLoading(false); setUrlPreviewError(""); setSaveError(""); setIsSaving(false); setTitleTouched(false); setBodyTouched(false); setImageTouched(false); lastPreviewUrlRef.current = ""; const main = mainFilter === "All" ? "News" : mainFilter; setDraftCategory(main); const list = CREATE_CATEGORY_MAP[main] || CREATE_CATEGORY_MAP.News; setDraftSubCategory(list[0]); setDraftCoords(null); setModeSelectorVisible(true); setModeChosen(false); }; // Update ref for long-press callback useEffect(() => { longPressCallbackRef.current = handleOpenCreate; }, [authenticated, needsEmailVerification, mainFilter]); useEffect(() => { setUseCustomImage(contentMode !== "link"); }, [contentMode]); const handleSearchSubmit = (e) => { e.preventDefault(); }; const handleClearSearch = () => { setSearchQuery(""); }; const updateImageVariantsFromGallery = (urls) => { const cleaned = (urls || []).filter(Boolean); const small = cleaned[0] || ""; const medium = cleaned[1] || small; const large = cleaned[2] || medium || small; setDraftImageSmall(small); setDraftImageMedium(medium); setDraftImageLarge(large); setDraftImage(small); setDraftImagePreview(small); }; const addMediaUrlToGallery = (rawUrl) => { const trimmed = (rawUrl || "").trim(); if (!trimmed) return false; setDraftMediaUrls((prev) => { if (prev.includes(trimmed)) { updateImageVariantsFromGallery(prev); return prev; } const next = [...prev, trimmed]; updateImageVariantsFromGallery(next); return next; }); setUseCustomImage(true); return true; }; const removeMediaUrlAtIndex = (index) => { setDraftMediaUrls((prev) => { if (index < 0 || index >= prev.length) return prev; const next = prev.filter((_, idx) => idx !== index); updateImageVariantsFromGallery(next); if (next.length === 0) { setDraftImage(""); setDraftImagePreview(""); } else { setDraftImage(next[0]); setDraftImagePreview(next[0]); } return next; }); }; const replaceMediaUrl = (from, to) => { if (!from || !to || from === to) return; setDraftMediaUrls((prev) => { let replaced = false; const next = prev.map((value) => { if (value === from) { replaced = true; return to; } return value; }); if (!replaced) next.push(to); updateImageVariantsFromGallery(next); return next; }); }; const readFileAsDataURL = (file) => { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => { resolve(reader.result || ""); }; reader.onerror = reject; reader.readAsDataURL(file); }); }; const handleAddCustomImageUrl = () => { const raw = (draftImage || "").trim(); if (!raw) { setSaveError("Paste an image URL first."); return; } if (!/^https?:\/\//i.test(raw)) { setSaveError("Image URL must start with http:// or https://"); return; } const added = addMediaUrlToGallery(raw); if (added) { setDraftImage(""); setSaveError(""); } }; const handleImageFileChange = async (e) => { const files = Array.from(e.target.files || []); if (!files.length) return; setImageTouched(true); setSaveError(""); setUploadingImage(true); for (const file of files) { if (!file.type.startsWith("image/")) { setSaveError("Please select an image file"); continue; } if (file.size > 5 * 1024 * 1024) { setSaveError("Image must be less than 5MB"); continue; } setDraftImageFile(file); let placeholder = ""; try { const dataUrl = await readFileAsDataURL(file); if (typeof dataUrl === "string" && dataUrl) { placeholder = dataUrl; setDraftImagePreview(dataUrl); addMediaUrlToGallery(dataUrl); } } catch (err) { console.error("File read error:", err); setSaveError("Failed to read image"); } 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(); const variants = data && data.variants ? data.variants : null; const pickRef = (v) => { if (!v) return ""; if (v.url) return v.url; if (v.asset_id) return `asset://${v.asset_id}`; return ""; }; const smallRef = variants ? pickRef(variants.small) : ""; const mediumRef = variants ? pickRef(variants.medium) : ""; let largeRef = variants ? pickRef(variants.large) : ""; if (!largeRef) { if (data && data.url) largeRef = data.url; else if (data && data.asset_id) largeRef = `asset://${data.asset_id}`; } const fallbackRef = largeRef || mediumRef || smallRef; const selected = fallbackRef || mediumRef || smallRef; if (selected) { const resolved = await resolveAssetRef(selected); const finalUrl = resolved || selected; replaceMediaUrl(placeholder || selected, finalUrl); setDraftImagePreview(finalUrl); } } catch (err) { console.error("Image upload error:", err); setSaveError("Failed to upload image"); } } setUploadingImage(false); e.target.value = ""; }; const handleUrlPreview = async (rawOverride) => { const raw = (rawOverride || draftUrl).trim(); if (!raw || urlPreviewLoading) return; if (!/^https?:\/\//i.test(raw)) return; if (raw === lastPreviewUrlRef.current) return; setUrlPreviewLoading(true); setUrlPreviewError(""); lastPreviewUrlRef.current = raw; try { const data = await fetchPostPreview(raw, token); if (!data) throw new Error("Preview unavailable"); const title = (data.title || "").trim(); const desc = (data.description || "").trim(); const image = (data.image || "").trim(); const isPlaceholder = data.image_placeholder || (image && image === LINK_PREVIEW_PLACEHOLDER); if (!titleTouched && title) setDraftTitle(title); if (!bodyTouched && desc) setDraftBody(desc); if (data.source) setDraftSource(String(data.source)); if (data.published_at) setDraftPublishedAt(String(data.published_at)); if (!imageTouched) { if (image && !isPlaceholder) { setUseCustomImage(true); if (!draftImageFile && (!draftImagePreview || draftImagePreview === LINK_PREVIEW_PLACEHOLDER)) { addMediaUrlToGallery(image); } } else { setUseCustomImage(false); setDraftImagePreview(LINK_PREVIEW_PLACEHOLDER); } } } catch (err) { setUrlPreviewError(err?.message || "Failed to fetch link preview"); } finally { setUrlPreviewLoading(false); } }; useEffect(() => { const raw = draftUrl.trim(); if (!raw || !/^https?:\/\//i.test(raw)) return; if (raw === lastPreviewUrlRef.current) return; if (previewTimerRef.current) clearTimeout(previewTimerRef.current); previewTimerRef.current = setTimeout(() => { handleUrlPreview(raw); }, 650); return () => { if (previewTimerRef.current) clearTimeout(previewTimerRef.current); }; }, [draftUrl]); 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, visibility: postVisibility, group_id: postGroupId.trim(), users: postUsers .split(",") .map((u) => u.trim()) .filter(Boolean), // Mark as user-created post so enrichment preserves the position source: "sociowire", }; if (draftUrl.trim()) { payload.url = draftUrl.trim(); } if (draftSource.trim()) { payload.source = draftSource.trim(); } if (draftPublishedAt.trim()) { payload.published_at = draftPublishedAt.trim(); } // Filter out data: URLs - they crash browsers and bloat the DB const mediaCandidates = draftMediaUrls.filter( (url) => url && !url.startsWith("data:") ); if (mediaCandidates.length > 0) { payload.image_small = mediaCandidates[0]; payload.image_medium = mediaCandidates[1] || mediaCandidates[0]; payload.image_large = mediaCandidates[2] || mediaCandidates[1] || mediaCandidates[0]; payload.media_urls = mediaCandidates; } else if (useCustomImage && draftImage.trim() && !draftImage.trim().startsWith("data:")) { const fallback = draftImage.trim(); payload.image_small = fallback; payload.image_medium = fallback; payload.image_large = fallback; } 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 }); } }; const galleryPreviewImage = draftMediaUrls[0] || draftImagePreview || draftImageLarge || draftImageMedium || draftImageSmall || draftImage; const galleryItems = draftMediaUrls.filter( (url) => url && url !== LINK_PREVIEW_PLACEHOLDER ); const hasCustomImages = galleryItems.length > 0; const isLinkMode = contentMode === "link"; const shouldShowPreviewCard = isLinkMode; const galleryThumbnails = shouldShowPreviewCard ? galleryItems.slice(1) : galleryItems; return (
{currentFlow.hint}