sw-fe/src/components/Map/MapView.jsx

2076 lines
70 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 {
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";
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.",
"Well 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 well 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 theyre 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: "Well 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: "Well 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 theyre seeing.",
bodyPlaceholder: "Describe the scene in a few words.",
},
text: {
hero: "Type a short update and pin the area youre 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 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(() => {
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);
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);
}, []);
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 = () => {
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("");
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 (
<div className="map-page">
<div className="map-stage" ref={stageRef}>
<div ref={containerRef} className="map-container" />
{status && <div className="map-status">{status}</div>}
{debugEnabled && (
<>
<div
className="sw-debug-banner"
style={{
position: "fixed",
top: 62,
left: 8,
zIndex: 99999,
padding: "6px 8px",
background: "rgba(0,0,0,0.8)",
color: "#fff",
fontSize: 12,
borderRadius: 6,
maxWidth: "92vw",
pointerEvents: "none",
}}
>
{debugStatus || "debug active"}
</div>
<div
style={{
position: "fixed",
bottom: 0,
left: 0,
right: 0,
zIndex: 99999,
background: "#ffea00",
color: "#111",
fontWeight: 700,
fontSize: 14,
textAlign: "center",
padding: "4px 0",
letterSpacing: "0.08em",
pointerEvents: "none",
}}
>
DEV DEBUG
</div>
</>
)}
{/* TOP: Search avec suggestions intelligentes */}
<div className="map-overlay map-overlay-top">
<div className="sw-top-row">
<div className="sw-search-row">
<SmartSearchBar
placeholder="Search cities, places, posts..."
onPick={handlePickSuggestion}
onSearch={handleSearch}
onMySpot={handleFlyToMe}
onPlaceTourWire={handleOpenCreate}
/>
<div className="sw-search__side" aria-label="Search actions">
{CLUSTERS_ENABLED && (
<button
type="button"
className={
"sw-search__iconBtn" + (mainNewsOnly ? " is-active" : "")
}
title="Main news"
onClick={() => setMainNewsOnly((prev) => !prev)}
>
<i className="fa-solid fa-bolt"></i>
</button>
)}
<button
type="button"
className="sw-search__iconBtn"
title="Place / Tour / Wire"
onClick={() => handleOpenCreate && handleOpenCreate()}
>
<i className="fa-solid fa-plus"></i>
</button>
<button
type="button"
className="sw-search__iconBtn"
title="Profile Island"
onClick={() => onOpenIslands && onOpenIslands()}
>
<i className="fa-solid fa-umbrella-beach"></i>
</button>
</div>
</div>
</div>
</div>
<div className="map-overlay map-overlay-left">
<TimeFilterButtons
activeHours={timeHours}
onSelect={(hours) => setTimeHours(hours)}
/>
</div>
<div className="map-overlay map-overlay-right">
<FilterButtons active={mainFilter} onSelect={handleMainFilterSelect} />
</div>
<div className="map-overlay map-overlay-bottom">
<div className="sw-bottom-row">
{bottomCategories.map((c) => (
<button
key={c}
type="button"
className={
"sw-bottom-item" + (subFilter === c ? " sw-bottom-active" : "")
}
onClick={() => setSubFilter(c)}
>
<div className="sw-bottom-circle">
{(() => {
const ic = getSubcatIcon(mainFilter, c);
return ic ? (
<i className={ic} />
) : c === "All" ? (
"All"
) : (
c.slice(0, 2)
);
})()}
</div>
<div className="sw-bottom-label">{c}</div>
</button>
))}
</div>
</div>
{isCreating && modeChosen && (
<div className="map-overlay map-crosshair">
<div className="crosshair-aim" />
</div>
)}
{modeSelectorVisible && (
<>
<div className="mode-selector-backdrop" onClick={closeCreateModal} />
<div className="map-overlay mode-selector-widget mode-selector-v2">
<button className="mode-selector-close" onClick={closeCreateModal} title="Close">
<i className="fa-solid fa-xmark" />
</button>
<div className="mode-selector-title">Create a post</div>
<div className="mode-selector-chips">
{CONTENT_CREATOR_MODES.map((mode) => (
<button
key={mode.id}
className={
"mode-selector-chip" +
(mode.id === contentMode ? " is-active" : "")
}
onClick={() => handleModeSelect(mode.id)}
>
<i className={mode.icon} />
<span>{mode.label}</span>
</button>
))}
</div>
</div>
</>
)}
{isCreating && modeChosen && (
<div className="create-floating-ui">
{/* Close button */}
<button className="cfloat-close" onClick={closeCreateModal}>
<i className="fa-solid fa-xmark" />
</button>
{/* Step indicator */}
<div className="cfloat-box cfloat-step">
<i className={currentContentMode.icon} />
<span>Step {createStep + 1}/3</span>
</div>
{/* STEP 0 - Position hint */}
{createStep === 0 && (
<div className="cfloat-box cfloat-hint">
<p>{currentFlow.hint}</p>
</div>
)}
{/* STEP 1 - Category */}
{createStep === 1 && (
<div className="cfloat-box cfloat-category">
<label>Category</label>
<div className="cfloat-selects">
<select value={draftCategory} onChange={(e) => {
setDraftCategory(e.target.value);
setDraftSubCategory((CREATE_CATEGORY_MAP[e.target.value] || CREATE_CATEGORY_MAP.News)[0]);
}}>
{['News', 'Friends', 'Events', 'Market'].map((c) => <option key={c}>{c}</option>)}
</select>
<select value={draftSubCategory} onChange={(e) => setDraftSubCategory(e.target.value)}>
{(CREATE_CATEGORY_MAP[draftCategory] || CREATE_CATEGORY_MAP.News).map((s) => <option key={s}>{s}</option>)}
</select>
</div>
</div>
)}
{/* STEP 1 - Title */}
{createStep === 1 && (
<div className="cfloat-box cfloat-title">
<label>Title or Link</label>
<input
placeholder={currentFlow.placeholder}
value={draftTitle}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={(e) => {
const v = e.target.value;
if (isLikelyUrl(v)) {
setDraftTitle(v); setTitleTouched(false); setDraftUrl(v.trim());
setUrlPreviewError(""); setDraftSource(""); setDraftPublishedAt("");
handleUrlPreview(v);
} else {
setDraftTitle(v); setTitleTouched(true);
}
}}
onPaste={(e) => {
const pasted = e.clipboardData?.getData('text') || '';
if (pasted && isLikelyUrl(pasted.trim())) {
e.preventDefault();
const v = pasted.trim();
setDraftTitle(v); setTitleTouched(false); setDraftUrl(v);
setUrlPreviewError(""); setDraftSource(""); setDraftPublishedAt("");
handleUrlPreview(v);
}
}}
/>
{draftUrl && !draftSource && <span className="cfloat-meta">Fetching preview...</span>}
{draftSource && <span className="cfloat-meta">{draftSource}</span>}
{urlPreviewError && <span className="cfloat-error">{urlPreviewError}</span>}
</div>
)}
{/* STEP 2 - Preview */}
{createStep === 2 && shouldShowPreviewCard && (
<div className="cfloat-box cfloat-preview">
<img src={galleryPreviewImage || LINK_PREVIEW_PLACEHOLDER} alt="Preview" />
{draftSource && <span>{draftSource}</span>}
</div>
)}
{/* STEP 2 - Description */}
{createStep === 2 && (
<div className="cfloat-box cfloat-desc">
<label>Description</label>
<textarea
rows={3}
placeholder={currentFlow.bodyPlaceholder}
value={draftBody}
onChange={(e) => { setDraftBody(e.target.value); setBodyTouched(true); }}
/>
</div>
)}
{/* STEP 2 - Image */}
{createStep === 2 && (
<div className="cfloat-box cfloat-image">
<div className="cfloat-img-toggle">
<button className={!useCustomImage ? "active" : ""} onClick={() => setUseCustomImage(false)}>
<i className="fa-solid fa-icons" /> Icon
</button>
<button className={useCustomImage ? "active" : ""} onClick={() => setUseCustomImage(true)}>
<i className="fa-solid fa-image" /> Image
</button>
</div>
{useCustomImage && (
<>
<div className="cfloat-img-upload">
<label>
<i className="fa-solid fa-upload" /> Upload
<input type="file" accept="image/*" multiple onChange={handleImageFileChange} disabled={uploadingImage} hidden />
</label>
<label>
<i className="fa-solid fa-camera" /> Camera
<input type="file" accept="image/*" capture="environment" onChange={handleImageFileChange} disabled={uploadingImage} hidden />
</label>
{uploadingImage && <span>Uploading...</span>}
</div>
{galleryItems.length > 0 && (
<div className="cfloat-gallery">
{galleryItems.map((url, idx) => (
<div key={idx} className="cfloat-gallery-item">
<img src={url} alt={`Upload ${idx + 1}`} />
<button onClick={() => removeMediaUrlAtIndex(idx)} title="Remove">
<i className="fa-solid fa-xmark" />
</button>
</div>
))}
</div>
)}
</>
)}
</div>
)}
{/* Error display */}
{saveError && (
<div className="cfloat-box cfloat-error-box">
<i className="fa-solid fa-exclamation-triangle" />
<span>{saveError}</span>
</div>
)}
{/* Navigation buttons */}
<div className="cfloat-box cfloat-nav">
<button className="cfloat-btn" onClick={handlePrevStep} disabled={createStep === 0}>
<i className="fa-solid fa-arrow-left" /> Back
</button>
<button
className="cfloat-btn cfloat-btn-primary"
disabled={isSaving}
onClick={() => {
if (contentMode === "live" && createStep === 0) {
const map = mapRef.current;
if (map) {
const stage = stageRef.current;
const crosshairEl = stage?.querySelector(".crosshair-aim");
const containerEl = map.getContainer();
let coords;
if (crosshairEl && containerEl) {
const cr = crosshairEl.getBoundingClientRect();
const co = containerEl.getBoundingClientRect();
const ll = map.unproject([cr.left + cr.width/2 - co.left, cr.top + cr.height/2 - co.top]);
coords = [ll.lng, ll.lat];
} else {
const c = map.getCenter();
coords = [c.lng, c.lat];
}
setLiveCoords(coords);
setShowLiveBroadcast(true);
setIsCreating(false);
setModeChosen(false);
}
return;
}
if (createStep < 2) setCreateStep((p) => p + 1);
else handleSubmitPost();
}}
>
{createStep === 2 ? (isSaving ? "Posting..." : "Post") : (contentMode === "live" && createStep === 0 ? "Go Live" : "Next")}
<i className="fa-solid fa-arrow-right" />
</button>
</div>
</div>
)}
</div>
{/* User Profile Modal */}
{selectedProfile && (
<UserProfile
username={selectedProfile}
onClose={() => setSelectedProfile(null)}
onVisitIsland={handleProfileVisitIsland}
/>
)}
{/* Live Broadcast Modal - Using LiveKit */}
{showLiveBroadcast && (
<LiveKitBroadcast
coords={liveCoords}
onClose={() => {
setShowLiveBroadcast(false);
setLiveCoords(null);
}}
onStreamCreated={({ roomName, title, coords }) => {
console.log("[MapView] LiveKit stream created:", roomName, title, coords);
}}
/>
)}
{activeLivePost && (
<LiveKitViewerModal
post={activeLivePost}
onClose={() => setActiveLivePost(null)}
/>
)}
</div>
);
}