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

661 lines
20 KiB
JavaScript

import React, { 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 } 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 { useAuth } from "../../auth/AuthContext";
import FilterButtons from "../Filters/FilterButtons";
import TimeFilterButtons from "../Filters/TimeFilterButtons";
export default function MapView({
theme = "dark",
onPostsState,
selectedPost,
onSelectPost,
}) {
const { authenticated, username, token, needsEmailVerification } = useAuth();
const { containerRef, mapRef, markersRef, expandedElRef, viewParams, hasLastView } =
useMapCore(theme);
const [mainFilter, setMainFilter] = useState("All");
const TIME_FILTER_KEY = "sociowire:timeFilter";
const [timeFilter, setTimeFilter] = useState(() => {
try {
const v = localStorage.getItem(TIME_FILTER_KEY);
const ok = ["NOW", "TODAY", "RECENT", "PAST"].includes(v);
return ok ? v : "TODAY";
} catch {
return "TODAY";
}
});
const [subFilter, setSubFilter] = useState("All");
useEffect(() => {
try {
localStorage.setItem(TIME_FILTER_KEY, timeFilter);
} catch {}
}, [timeFilter]);
const stageRef = useRef(null);
const [showSubcats, setShowSubcats] = useState(true);
const userPosition = useUserPosition(mapRef, hasLastView);
const [searchQuery, setSearchQuery] = useState("");
const { status, visiblePosts, loadingPosts, loadError, handleIncomingPost } =
usePostsEngine({
theme,
mapRef,
viewParams,
mainFilter,
subFilter,
timeFilter,
searchQuery,
markersRef,
expandedElRef,
});
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 [draftImage, setDraftImage] = useState("");
const [draftImageFile, setDraftImageFile] = useState(null);
const [draftImagePreview, setDraftImagePreview] = useState("");
const [useCustomImage, setUseCustomImage] = useState(false);
const [uploadingImage, setUploadingImage] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState("");
const bottomCategories = FILTER_CATEGORY_MAP[mainFilter] || ["All"];
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 });
}
}, [selectedPost, mapRef]);
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);
} catch {}
};
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;
}
if (needsEmailVerification) {
alert(
"You must verify your email before posting. Use 'Resend email' in the top bar, then login again."
);
return;
}
setIsCreating(true);
setDraftTitle("");
setDraftBody("");
setDraftImage("");
setDraftImageFile(null);
setDraftImagePreview("");
setUseCustomImage(false);
const main = mainFilter === "All" ? "News" : mainFilter;
setDraftCategory(main);
const list = CREATE_CATEGORY_MAP[main] || CREATE_CATEGORY_MAP.News;
setDraftSubCategory(list[0]);
setDraftCoords(null);
};
const handleSearchSubmit = (e) => {
e.preventDefault();
};
const handleClearSearch = () => {
setSearchQuery("");
};
const handleImageFileChange = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
// Vérifier le type de fichier
if (!file.type.startsWith('image/')) {
setSaveError("Please select an image file");
return;
}
// Vérifier la taille (max 5MB)
if (file.size > 5 * 1024 * 1024) {
setSaveError("Image must be less than 5MB");
return;
}
setDraftImageFile(file);
// Créer preview
const reader = new FileReader();
reader.onloadend = () => {
setDraftImagePreview(reader.result);
};
reader.readAsDataURL(file);
// Upload immédiatement
setUploadingImage(true);
setSaveError("");
try {
const formData = new FormData();
formData.append('image', file);
const res = await fetch('/api/upload-image', {
method: 'POST',
body: formData,
credentials: 'include',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
});
if (!res.ok) {
throw new Error('Upload failed');
}
const data = await res.json();
setDraftImage(data.url || data.image_url);
setUploadingImage(false);
} catch (err) {
console.error('Image upload error:', err);
setSaveError('Failed to upload image');
setUploadingImage(false);
setDraftImageFile(null);
setDraftImagePreview("");
}
};
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 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 payload = {
title: draftTitle.trim(),
snippet: draftBody.trim() || draftTitle.trim(),
category:
draftCategory === "Events" ? "EVENT" : draftCategory.toUpperCase(),
sub_category: draftSubCategory || "ALL",
lat,
lon: lng,
author: authorName,
};
if (useCustomImage && draftImage.trim()) {
payload.image_small = draftImage.trim();
payload.image_large = draftImage.trim();
}
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 });
}
};
return (
<div className="map-page">
<div className="map-stage" ref={stageRef}>
<div ref={containerRef} className="map-container" />
{status && <div className="map-status">{status}</div>}
{/* TOP: Search à gauche, 2 icônes à droite, sur une seule ligne */}
<div className="map-overlay map-overlay-top">
<div className="sw-top-row">
<form
className="sw-searchbar sw-top-search"
onSubmit={handleSearchSubmit}
role="search"
aria-label="Search"
>
<i className="fa-solid fa-magnifying-glass sw-search-icon" />
<input
className="sw-search-input"
type="search"
placeholder="Look at… (city, topic, @user)"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
/>
{searchQuery ? (
<button
type="button"
className="sw-search-clear"
onClick={handleClearSearch}
aria-label="Clear search"
title="Clear"
>
</button>
) : null}
</form>
<div className="map-top-actions">
<button
type="button"
className="map-action-btn"
onClick={handleFlyToMe}
disabled={!userPosition}
>
<div className="map-action-icon">
<i className="fa-solid fa-location-crosshairs" />
</div>
<div className="map-action-label">My spot</div>
</button>
<button
type="button"
className="map-action-btn"
onClick={handleOpenCreate}
>
<div className="map-action-icon">
<i className="fa-solid fa-bolt" />
</div>
<div className="map-action-label">New wire</div>
</button>
</div>
</div>
</div>
<div className="map-overlay map-overlay-left">
<TimeFilterButtons
active={timeFilter}
onSelect={(code) => setTimeFilter(code)}
/>
</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 && (
<div className="map-overlay map-crosshair">
<div className="crosshair-aim" />
</div>
)}
{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)}
/>
<div className="create-image-option">
<label style={{ display: "flex", alignItems: "center", gap: "8px", cursor: "pointer" }}>
<input
type="checkbox"
checked={useCustomImage}
onChange={(e) => setUseCustomImage(e.target.checked)}
/>
<span>Use custom image instead of category icon</span>
</label>
</div>
{useCustomImage && (
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<div style={{ display: "flex", gap: "8px" }}>
<input
type="file"
accept="image/*"
onChange={handleImageFileChange}
disabled={uploadingImage}
style={{ flex: 1 }}
/>
{uploadingImage && <span>Uploading...</span>}
</div>
{draftImagePreview && (
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<img
src={draftImagePreview}
alt="Preview"
style={{ width: "40px", height: "40px", objectFit: "cover", borderRadius: "6px" }}
/>
<button
type="button"
onClick={() => {
setDraftImageFile(null);
setDraftImagePreview("");
setDraftImage("");
}}
style={{ padding: "4px 8px", fontSize: "11px" }}
>
Remove
</button>
</div>
)}
<div style={{ fontSize: "12px", color: "#888" }}>
Or paste an image URL:
</div>
<input
className="create-input"
placeholder="https://example.com/image.jpg"
value={draftImageFile ? "" : draftImage}
onChange={(e) => {
setDraftImage(e.target.value);
setDraftImageFile(null);
setDraftImagePreview("");
}}
disabled={!!draftImageFile}
/>
</div>
)}
{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>
)}
</div>
</div>
);
}