391 lines
11 KiB
JavaScript
391 lines
11 KiB
JavaScript
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>
|
||
);
|
||
}
|