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

1881 lines
63 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 {
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);
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]
);
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 } =
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(false);
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) 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;
const map = mapRef.current;
const overlay = map?.__swCenteredOverlay;
if (overlay && overlay.postId === wantId) {
pendingOpenPostIdRef.current = null;
pendingOpenFullRef.current = false;
return;
}
const match = visiblePosts.find((p) => (p?.id || p?.ID) === wantId);
if (!match) return;
pendingOpenPostIdRef.current = null;
const wantFull = pendingOpenFullRef.current;
onSelectPost?.(match);
openOverlayWhenReady(match, { fullScreen: wantFull });
pendingOpenFullRef.current = false;
}, [visiblePosts, onSelectPost, openOverlayWhenReady]);
useEffect(() => {
return () => {
if (overlayTimerRef.current) clearInterval(overlayTimerRef.current);
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);
}
} catch {}
};
return () => ws && ws.close();
}, [handleIncomingPost, handlePostUpdate]);
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);
};
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.asset_id) return `asset://${v.asset_id}`;
return v.url || "";
};
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) {
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),
};
if (draftUrl.trim()) {
payload.url = draftUrl.trim();
}
if (draftSource.trim()) {
payload.source = draftSource.trim();
}
if (draftPublishedAt.trim()) {
payload.published_at = draftPublishedAt.trim();
}
const mediaCandidates = draftMediaUrls.filter(Boolean);
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()) {
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;
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="map-overlay mode-selector-widget">
<div className="mode-selector-bg" />
<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="map-overlay create-panel-compact">
<div className="create-panel-header">
<span>{`Share as ${currentContentMode.label}`}</span>
<button className="create-close" onClick={closeCreateModal}>
</button>
</div>
<div className="create-panel-body">
<div className="create-step-label">Step {createStep + 1} / 3</div>
<p className="create-panel-hero">{currentFlow.hero}</p>
<div className="create-panel-step">
{createStep === 0 && (
<p className="create-panel-note">{currentFlow.hint}</p>
)}
{createStep === 1 && (
<>
<div className="create-panel-field">
<label>Category</label>
<div className="create-panel-selects">
<select
value={draftCategory}
onChange={(e) => {
const cat = e.target.value;
setDraftCategory(cat);
setDraftSubCategory(
(CREATE_CATEGORY_MAP[cat] || CREATE_CATEGORY_MAP.News)[0]
);
}}
>
{['News', 'Friends', 'Events', 'Market'].map((cat) => (
<option key={cat}>{cat}</option>
))}
</select>
<select
value={draftSubCategory}
onChange={(e) => setDraftSubCategory(e.target.value)}
>
{(CREATE_CATEGORY_MAP[draftCategory] ||
CREATE_CATEGORY_MAP.News
).map((sub) => (
<option key={sub}>{sub}</option>
))}
</select>
</div>
</div>
<div className="create-panel-field">
<label>Title or link</label>
<input
className="create-input create-input-narrow"
placeholder={currentFlow.placeholder}
value={draftTitle}
onChange={(e) => {
const next = e.target.value;
if (isLikelyUrl(next)) {
setDraftTitle(next);
setTitleTouched(false);
setDraftUrl(next.trim());
setUrlPreviewError("");
setDraftSource("");
setDraftPublishedAt("");
handleUrlPreview(next);
return;
}
setDraftTitle(next);
setTitleTouched(true);
}}
/>
<div className="create-hint create-hint-tight">
{currentFlow.hint}
</div>
{draftUrl && !draftSource && !draftPublishedAt && (
<div className="create-link-meta">
<span>Link detected · fetching preview...</span>
</div>
)}
{(draftSource || draftPublishedAt) && (
<div className="create-link-meta">
{draftSource ? <span>{draftSource}</span> : null}
{draftPublishedAt ? (
<span>{formatPreviewDate(draftPublishedAt)}</span>
) : null}
</div>
)}
{urlPreviewError && (
<div className="create-link-error">{urlPreviewError}</div>
)}
</div>
</>
)}
{createStep === 2 && (
<>
{galleryPreviewImage && (
<div className="create-link-preview create-link-preview-tight">
<img src={galleryPreviewImage} alt="Link preview" />
<div className="create-link-preview-meta">
{draftSource ? <span>{draftSource}</span> : null}
{draftPublishedAt ? (
<span>{formatPreviewDate(draftPublishedAt)}</span>
) : null}
</div>
{draftMediaUrls.length > 1 && (
<div className="create-gallery-items">
{draftMediaUrls.slice(1).map((url, idx) => (
<figure key={`${url}-${idx}`} className="create-gallery-item">
<img src={url} alt={`Selected image ${idx + 2}`} />
<button
type="button"
className="create-gallery-remove"
onClick={() => removeMediaUrlAtIndex(idx + 1)}
aria-label="Remove image"
>
×
</button>
</figure>
))}
</div>
)}
</div>
)}
<textarea
className="create-textarea create-textarea-tight"
rows={3}
placeholder={currentFlow.bodyPlaceholder}
value={draftBody}
onChange={(e) => {
setDraftBody(e.target.value);
setBodyTouched(true);
}}
/>
<div className="create-image-choice">
<button
type="button"
className={
"create-image-choice-btn" + (!useCustomImage ? " is-active" : "")
}
onClick={() => {
setUseCustomImage(false);
}}
>
Use category icon
</button>
<button
type="button"
className={
"create-image-choice-btn" + (useCustomImage ? " is-active" : "")
}
onClick={() => {
setUseCustomImage(true);
}}
>
Add an image
</button>
</div>
<div className="create-muted">
Well use the category icon if you skip images.
</div>
{useCustomImage && (
<div className="create-image-panel">
<div className="create-image-row">
<label className="create-file-btn">
Choose file
<input
className="create-file-input"
type="file"
accept="image/*"
multiple
onChange={handleImageFileChange}
disabled={uploadingImage}
/>
</label>
<label className="create-file-btn create-file-btn--camera">
Take photo
<input
className="create-file-input"
type="file"
accept="image/*"
capture="environment"
multiple
onChange={handleImageFileChange}
disabled={uploadingImage}
/>
</label>
{uploadingImage && (
<span className="create-uploading">Uploading...</span>
)}
</div>
{draftImagePreview && (
<div className="create-image-preview-row">
<img src={draftImagePreview} alt="Preview" />
<button
type="button"
onClick={() => {
setDraftImageFile(null);
setDraftImagePreview("");
setDraftImage("");
}}
>
Remove
</button>
</div>
)}
<div className="create-muted">Or paste an image URL:</div>
<div className="create-image-url-row">
<input
className="create-input"
placeholder="https://example.com/image.jpg"
value={draftImage}
onChange={(e) => {
setDraftImage(e.target.value);
setDraftImageFile(null);
setDraftImagePreview("");
setImageTouched(true);
}}
/>
<button
type="button"
className="create-gallery-add"
onClick={handleAddCustomImageUrl}
>
Add
</button>
</div>
</div>
)}
</>
)}
</div>
<div className="create-panel-actions">
<button
type="button"
className="chip-pill create-panel-btn"
onClick={handlePrevStep}
disabled={createStep === 0}
>
Back
</button>
<button
type="button"
className="chip-pill create-panel-btn primary"
onClick={() => {
if (createStep < 2) {
setCreateStep((prev) => prev + 1);
} else {
handleSubmitPost();
}
}}
disabled={isSaving}
>
{createStep === 2 ? (isSaving ? "Posting..." : "Post") : "Next"}
</button>
</div>
</div>
</div>
)}
</div>
{/* User Profile Modal */}
{selectedProfile && (
<UserProfile
username={selectedProfile}
onClose={() => setSelectedProfile(null)}
onVisitIsland={handleProfileVisitIsland}
/>
)}
</div>
);
}