2272 lines
76 KiB
JavaScript
2272 lines
76 KiB
JavaScript
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 RadialMenu from "./RadialMenu";
|
||
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",
|
||
contentFilter = "all",
|
||
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 MAIN_FILTER_KEY = "sociowire:mainFilter";
|
||
const SUB_FILTER_KEY = "sociowire:subFilter";
|
||
const TIME_FILTER_KEY = "sociowire:timeHours";
|
||
|
||
const [mainFilters, setMainFilters] = useState(() => {
|
||
try {
|
||
const saved = localStorage.getItem(MAIN_FILTER_KEY);
|
||
if (saved) {
|
||
const parsed = JSON.parse(saved);
|
||
if (Array.isArray(parsed)) return parsed;
|
||
// Legacy: single string
|
||
if (typeof parsed === "string" && parsed !== "All") return [parsed];
|
||
}
|
||
} catch {}
|
||
return []; // Empty = All
|
||
});
|
||
|
||
// Computed mainFilter for backend compatibility
|
||
// Empty array = "All", single item = that item, multiple = first (or "All")
|
||
const mainFilter = mainFilters.length === 0 ? "All" :
|
||
mainFilters.length === 1 ? mainFilters[0] : "All";
|
||
|
||
// Toggle a category in the multi-select array
|
||
const handleCategoryToggle = (catId) => {
|
||
setMainFilters((prev) => {
|
||
if (prev.includes(catId)) {
|
||
// Remove it
|
||
return prev.filter((c) => c !== catId);
|
||
} else {
|
||
// Add it
|
||
return [...prev, catId];
|
||
}
|
||
});
|
||
};
|
||
|
||
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 [subFilters, setSubFilters] = useState(() => {
|
||
try {
|
||
const saved = localStorage.getItem(SUB_FILTER_KEY);
|
||
if (saved) {
|
||
const parsed = JSON.parse(saved);
|
||
if (Array.isArray(parsed)) return parsed;
|
||
}
|
||
} catch {}
|
||
return []; // Empty = All
|
||
});
|
||
|
||
// Computed subFilter for backend compatibility
|
||
const subFilter = subFilters.length === 0 ? "All" :
|
||
subFilters.length === 1 ? subFilters[0].split(":")[1] : "All";
|
||
|
||
// Toggle a subcategory
|
||
const handleSubcategoryToggle = (catId, subId) => {
|
||
const key = `${catId}:${subId}`;
|
||
setSubFilters((prev) => {
|
||
if (prev.includes(key)) {
|
||
return prev.filter((s) => s !== key);
|
||
} else {
|
||
return [...prev, key];
|
||
}
|
||
});
|
||
};
|
||
const [mainNewsOnly, setMainNewsOnly] = useState(false);
|
||
const WEATHER_VIEW_KEY = "sociowire:weatherView";
|
||
const [weatherViewEnabled, setWeatherViewEnabled] = useState(() => {
|
||
try {
|
||
const saved = localStorage.getItem(WEATHER_VIEW_KEY);
|
||
return saved === null ? true : saved === "true";
|
||
} catch {
|
||
return true;
|
||
}
|
||
});
|
||
|
||
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(MAIN_FILTER_KEY, JSON.stringify(mainFilters));
|
||
} catch {}
|
||
}, [mainFilters]);
|
||
|
||
useEffect(() => {
|
||
try {
|
||
localStorage.setItem(SUB_FILTER_KEY, JSON.stringify(subFilters));
|
||
} catch {}
|
||
}, [subFilters]);
|
||
|
||
useEffect(() => {
|
||
try {
|
||
localStorage.setItem(WEATHER_VIEW_KEY, weatherViewEnabled ? "true" : "false");
|
||
} catch {}
|
||
}, [weatherViewEnabled]);
|
||
|
||
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);
|
||
// Nearest weather city (fetched from /api/weather?lat=X&lon=Y)
|
||
const [nearestWeather, setNearestWeather] = 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,
|
||
mainFilters,
|
||
subFilter,
|
||
subFilters,
|
||
contentFilter,
|
||
timeHours,
|
||
searchQuery,
|
||
clusterFocus,
|
||
mainNewsOnly,
|
||
clustersEnabled: CLUSTERS_ENABLED,
|
||
heatmapEnabled: HEATMAP_ENABLED,
|
||
forceFullPostId,
|
||
markersRef,
|
||
expandedElRef,
|
||
onAutoWidenTimeFilter: () => setTimeHours(336),
|
||
onSelectPost,
|
||
username,
|
||
debugEnabled,
|
||
weatherEnabled: weatherViewEnabled,
|
||
});
|
||
|
||
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]);
|
||
|
||
// Fetch nearest weather from backend based on map center
|
||
const fetchNearestWeatherRef = useRef(null);
|
||
const lastWeatherFetchRef = useRef({ lat: null, lon: null });
|
||
|
||
const fetchNearestWeather = useCallback(async () => {
|
||
const map = mapRef.current;
|
||
if (!map) return;
|
||
|
||
try {
|
||
const center = map.getCenter();
|
||
if (!center) return;
|
||
|
||
// Skip if we already fetched for similar position
|
||
const last = lastWeatherFetchRef.current;
|
||
if (last.lat !== null) {
|
||
const dist = Math.sqrt(
|
||
Math.pow(center.lat - last.lat, 2) + Math.pow(center.lng - last.lon, 2)
|
||
);
|
||
if (dist < 0.5) return; // Skip if moved less than ~50km
|
||
}
|
||
|
||
lastWeatherFetchRef.current = { lat: center.lat, lon: center.lng };
|
||
|
||
// Call /api/weather?lat=X&lon=Y which returns nearest city from weather_data
|
||
const url = `${window.VITE_API_BASE_URL || ''}/api/weather?lat=${center.lat}&lon=${center.lng}`;
|
||
const res = await fetch(url);
|
||
if (!res.ok) return;
|
||
|
||
const data = await res.json();
|
||
if (data && data.city) {
|
||
// Convert backend response to our format
|
||
const iconMap = {
|
||
'sun': '☀️', 'moon': '🌙', 'cloud-sun': '⛅', 'cloud-moon': '⛅',
|
||
'cloud': '☁️', 'smog': '🌫️', 'cloud-rain': '🌧️', 'cloud-showers-heavy': '🌧️',
|
||
'snowflake': '❄️', 'cloud-sun-rain': '🌦️', 'cloud-meatball': '🌨️', 'bolt': '⛈️'
|
||
};
|
||
const icon = iconMap[data.current?.weather_icon] || '🌡️';
|
||
const temp = Math.round(data.current?.temperature ?? 0);
|
||
|
||
setNearestWeather({
|
||
city: data.city,
|
||
temp,
|
||
icon,
|
||
lat: data.lat,
|
||
lon: data.lon,
|
||
post_id: data.post_id
|
||
});
|
||
}
|
||
} catch (err) {
|
||
console.warn('[Weather] Failed to fetch:', err);
|
||
}
|
||
}, []);
|
||
|
||
// Update weather on map move (debounced)
|
||
useEffect(() => {
|
||
const map = mapRef.current;
|
||
if (!map) return;
|
||
|
||
const handleMoveEnd = () => {
|
||
if (fetchNearestWeatherRef.current) clearTimeout(fetchNearestWeatherRef.current);
|
||
fetchNearestWeatherRef.current = setTimeout(fetchNearestWeather, 500);
|
||
};
|
||
|
||
map.on('moveend', handleMoveEnd);
|
||
|
||
// Initial fetch
|
||
fetchNearestWeather();
|
||
|
||
return () => {
|
||
map.off('moveend', handleMoveEnd);
|
||
if (fetchNearestWeatherRef.current) clearTimeout(fetchNearestWeatherRef.current);
|
||
};
|
||
}, [fetchNearestWeather]);
|
||
|
||
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.");
|
||
}
|
||
}
|
||
};
|
||
|
||
// Legacy handler - now using handleCategoryToggle for multi-select
|
||
|
||
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" />
|
||
<DayNightTerminator map={mapRef.current} enabled={weatherViewEnabled} opacity={0.15} />
|
||
{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">
|
||
<button
|
||
type="button"
|
||
className="sw-search__iconBtn"
|
||
title="New Post"
|
||
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>
|
||
{/* Weather widget - single nearest city - BELOW search bar */}
|
||
{nearestWeather && (() => {
|
||
const w = nearestWeather;
|
||
const isSunny = w.icon === '☀️' || w.icon === '⛅';
|
||
const isNight = w.icon === '🌙';
|
||
const isRainy = w.icon === '🌧️' || w.icon === '🌦️' || w.icon === '⛈️';
|
||
const isSnow = w.icon === '❄️' || w.icon === '🌨️';
|
||
const bg = isNight
|
||
? 'linear-gradient(135deg, rgba(30, 41, 59, 0.9), rgba(15, 23, 42, 0.9))'
|
||
: isSunny
|
||
? 'linear-gradient(135deg, rgba(56, 189, 248, 0.85), rgba(14, 165, 233, 0.85))'
|
||
: isRainy
|
||
? 'linear-gradient(135deg, rgba(71, 85, 105, 0.9), rgba(51, 65, 85, 0.9))'
|
||
: isSnow
|
||
? 'linear-gradient(135deg, rgba(148, 163, 184, 0.9), rgba(203, 213, 225, 0.9))'
|
||
: 'linear-gradient(135deg, rgba(100, 116, 139, 0.85), rgba(71, 85, 105, 0.85))';
|
||
|
||
return (
|
||
<div
|
||
onClick={() => setActiveWeatherPost(w)}
|
||
style={{
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: '5px',
|
||
marginTop: '8px',
|
||
marginLeft: '70px', // Offset from left FAB
|
||
padding: '5px 12px',
|
||
borderRadius: '16px',
|
||
background: bg,
|
||
border: '1px solid rgba(255,255,255,0.25)',
|
||
color: isSnow ? '#1e293b' : '#fff',
|
||
fontSize: '13px',
|
||
fontWeight: '600',
|
||
cursor: 'pointer',
|
||
backdropFilter: 'blur(8px)',
|
||
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
|
||
}}
|
||
>
|
||
<span style={{ fontSize: '16px' }}>{w.icon}</span>
|
||
<span>{w.city}:</span>
|
||
<span style={{ fontWeight: '700' }}>{w.temp}°</span>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
|
||
{/* Dual FAB Menu - Left: Categories, Right: Time/Weather */}
|
||
<RadialMenu
|
||
onCategoryToggle={handleCategoryToggle}
|
||
onSubcategoryToggle={handleSubcategoryToggle}
|
||
onTime={(h) => setTimeHours(h)}
|
||
onWeather={() => setWeatherViewEnabled((prev) => !prev)}
|
||
activeCategories={mainFilters}
|
||
activeSubcategories={subFilters}
|
||
activeTime={timeHours}
|
||
weatherEnabled={weatherViewEnabled}
|
||
/>
|
||
|
||
{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) { setSaveError(""); 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)}
|
||
/>
|
||
)}
|
||
|
||
{activeWeatherPost && (
|
||
<WeatherModal
|
||
postId={activeWeatherPost.id || activeWeatherPost.ID}
|
||
city={activeWeatherPost.city}
|
||
lat={activeWeatherPost.lat}
|
||
lon={activeWeatherPost.lon}
|
||
onClose={() => setActiveWeatherPost(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|