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

391 lines
11 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, { useEffect, useState } from "react";
import "maplibre-gl/dist/maplibre-gl.css";
import "../../styles/mapMarkers.css";
import "../../styles/overlays.css";
import "../../styles/posts.css";
import { createPost } from "../../api/client";
import {
FILTER_MAIN_CATEGORIES,
FILTER_CATEGORY_MAP,
CREATE_CATEGORY_MAP,
} from "./mapConfig";
import { useMapCore } from "./useMapCore";
import { useUserPosition } from "./useUserPosition";
import { usePostsEngine } from "./usePostsEngine";
import PostList from "../Posts/PostList";
import { useAuth } from "../../auth/AuthContext";
export default function MapView({ theme = "dark" }) {
const { authenticated, username, token } = useAuth();
const {
containerRef,
mapRef,
markersRef,
expandedElRef,
viewParams,
hasLastView,
} = useMapCore(theme);
const [mainFilter, setMainFilter] = useState("All");
const [timeFilter, setTimeFilter] = useState("RECENT");
const [subFilter, setSubFilter] = useState("All");
const userPosition = useUserPosition(mapRef, hasLastView);
const {
status,
visiblePosts,
loadingPosts,
loadError,
handleIncomingPost,
} = usePostsEngine({
mapRef,
viewParams,
mainFilter,
subFilter,
timeFilter,
markersRef,
expandedElRef,
});
const [selectedPost, setSelectedPost] = useState(null);
const [isCreating, setIsCreating] = useState(false);
const [draftTitle, setDraftTitle] = useState("");
const [draftBody, setDraftBody] = useState("");
const [draftCategory, setDraftCategory] = useState("News");
const [draftSubCategory, setDraftSubCategory] = useState(
CREATE_CATEGORY_MAP.News[0]
);
const [draftCoords, setDraftCoords] = useState(null);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState("");
const bottomCategories = FILTER_CATEGORY_MAP[mainFilter] || ["All"];
useEffect(() => {
if (!bottomCategories.length) return;
if (!subFilter || !bottomCategories.includes(subFilter)) {
setSubFilter("All");
}
}, [mainFilter, bottomCategories, subFilter]);
// WebSocket live
useEffect(() => {
const proto = window.location.protocol === "https:" ? "wss" : "ws";
const host =
window.location.port === "5173"
? `${window.location.hostname}:8081`
: window.location.host;
const wsUrl = `${proto}://${host}/ws`;
let ws;
try {
ws = new WebSocket(wsUrl);
} catch {
return;
}
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === "new_post" && msg.post) {
handleIncomingPost(msg.post);
}
} catch {
// ignore
}
};
return () => ws && ws.close();
}, [handleIncomingPost]);
const handleFlyToMe = () => {
const map = mapRef.current;
if (!map || !userPosition) return;
map.flyTo({ center: userPosition, zoom: 9, speed: 1.2 });
};
const handleOpenCreate = () => {
if (!authenticated) {
alert("You must be logged in to place a wire.");
return;
}
setIsCreating(true);
setDraftTitle("");
setDraftBody("");
const main = mainFilter === "All" ? "News" : mainFilter;
setDraftCategory(main);
const list = CREATE_CATEGORY_MAP[main] || CREATE_CATEGORY_MAP.News;
setDraftSubCategory(list[0]);
setDraftCoords(null);
};
const handleSearchClick = () => {
alert("🔍 Search feature coming soon!");
};
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;
}
const authorName = (username || "").trim() || "anon";
// Crosshair offset fix
let baseLng;
let baseLat;
if (draftCoords && draftCoords.length === 2) {
baseLng = draftCoords[0];
baseLat = draftCoords[1];
} else {
const center = map.getCenter();
baseLng = center.lng;
baseLat = center.lat;
}
const pixelOffsetY = -20;
const screenPoint = map.project([baseLng, baseLat]);
const correctedPoint = {
x: screenPoint.x,
y: screenPoint.y + pixelOffsetY,
};
const correctedLngLat = map.unproject([correctedPoint.x, correctedPoint.y]);
const lng = correctedLngLat.lng;
const lat = correctedLngLat.lat;
if (!draftTitle.trim()) {
setSaveError("Title required.");
return;
}
try {
setIsSaving(true);
const newPost = await createPost(
{
title: draftTitle.trim(),
snippet: draftBody.trim() || draftTitle.trim(),
category: draftCategory.toUpperCase(),
sub_category: draftSubCategory || "ALL",
lat,
lon: lng,
author: authorName,
},
token
);
if (newPost && newPost.id) {
handleIncomingPost(newPost);
}
setIsSaving(false);
setIsCreating(false);
} catch (e) {
console.error(e);
setIsSaving(false);
setSaveError("Error creating post.");
}
};
const handleSelectPost = (post) => {
setSelectedPost(post);
const map = mapRef.current;
if (!map) return;
const lng = post.lon ?? post.lng;
const lat = post.lat ?? post.latitude;
if (typeof lng === "number" && typeof lat === "number") {
map.flyTo({ center: [lng, lat], zoom: 8, speed: 1.4 });
}
};
return (
<div className="map-view">
<div ref={containerRef} className="map-container" />
{status && <div className="map-status">{status}</div>}
{/* TOP: Look / Place */}
<div className="map-overlay map-overlay-top">
<button className="chip-pill" onClick={handleSearchClick}>
Look at
</button>
<button className="chip-pill" onClick={handleOpenCreate}>
Place your wire
</button>
</div>
{/* LEFT: time filters NOW / TODAY / RECENT / PAST */}
<div className="map-overlay game-filter-column game-filter-column-left">
{[
["NOW", "Now"],
["TODAY", "Today"],
["RECENT", "Recent"],
["PAST", "Past"],
].map(([code, label]) => (
<div className="game-filter-item" key={code}>
<button
className={
"game-filter-circle" +
(timeFilter === code ? " game-filter-circle-active" : "")
}
onClick={() => setTimeFilter(code)}
>
<span>{label[0]}</span>
</button>
<span className="game-filter-label">{label}</span>
</div>
))}
</div>
{/* RIGHT: main categories ALL / NEWS / FRIENDS / EVENTS / MARKET */}
<div className="map-overlay game-filter-column game-filter-column-right">
{FILTER_MAIN_CATEGORIES.map((cat) => {
const label = cat;
return (
<div className="game-filter-item" key={cat}>
<button
className={
"game-filter-circle" +
(mainFilter === cat ? " game-filter-circle-active" : "")
}
onClick={() => setMainFilter(cat)}
>
<span>{label[0]}</span>
</button>
<span className="game-filter-label">{label}</span>
</div>
);
})}
</div>
{/* BOTTOM: subcategories */}
<div className="map-overlay map-overlay-bottom">
{bottomCategories.map((c) => (
<button
key={c}
className={
subFilter === c ? "chip-egg chip-egg-active" : "chip-egg"
}
onClick={() => setSubFilter(c)}
>
{c}
</button>
))}
</div>
{/* MY SPOT */}
<div className="map-overlay map-overlay-myloc">
<button
className="chip-pill"
disabled={!userPosition}
onClick={handleFlyToMe}
>
My spot
</button>
</div>
{/* CROSSHAIR */}
{isCreating && (
<div className="map-overlay map-crosshair">
<div className="crosshair-aim" />
</div>
)}
{/* CREATE POST PANEL */}
{isCreating && (
<div className="map-overlay create-post-panel">
<div className="create-post-header">
<span>Create wire</span>
<button
className="create-close"
onClick={() => setIsCreating(false)}
>
</button>
</div>
<span className="crosshair-label">
Move the map, aim with the crosshair your wire will be pinned
there.
</span>
<div className="create-row">
<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>
<input
className="create-input"
placeholder="Short title..."
value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)}
/>
<textarea
className="create-textarea"
rows={2}
placeholder="Context (optional)..."
value={draftBody}
onChange={(e) => setDraftBody(e.target.value)}
/>
{saveError && <div className="create-error">{saveError}</div>}
<div className="create-actions">
<button
className="chip-pill"
onClick={handleSubmitPost}
disabled={isSaving}
>
{isSaving ? "Posting..." : "Post"}
</button>
</div>
</div>
)}
{/* SOCIOWALL + CHAT */}
<div className="map-overlay map-overlay-wall">
<div className="sociowall-panel">
<div className="sociowall-header">Sociowall</div>
<PostList
posts={visiblePosts}
loading={loadingPosts}
error={loadError}
selectedPost={selectedPost}
onSelectPost={handleSelectPost}
/>
</div>
<div className="chat-panel">
<div className="chat-header">Chat</div>
<ul className="chat-users">
<li>Yan</li>
<li>Marc</li>
<li>Fiso</li>
</ul>
</div>
</div>
</div>
);
}