Auto deploy: 2025-12-09 03:18:01
This commit is contained in:
parent
1acb77f7d3
commit
174f3784e3
|
|
@ -0,0 +1,19 @@
|
|||
#!/bin/bash
|
||||
|
||||
echo "Checking for maplibre-gl.css..."
|
||||
|
||||
if [ -f node_modules/maplibre-gl/dist/maplibre-gl.css ]; then
|
||||
CSS="maplibre-gl/dist/maplibre-gl.css"
|
||||
elif [ -f node_modules/maplibre-gl/maplibre-gl.css ]; then
|
||||
CSS="maplibre-gl/maplibre-gl.css"
|
||||
else
|
||||
echo "❌ Aucun fichier CSS trouvé dans maplibre-gl"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✔ CSS found at: $CSS"
|
||||
echo "✔ Updating MapView.jsx..."
|
||||
|
||||
sed -i "s|import .*maplibre.*css.*|import \"$CSS\";|" src/components/Map/MapView.jsx
|
||||
|
||||
echo "✔ Done!"
|
||||
|
|
@ -1,13 +1,36 @@
|
|||
/**
|
||||
* SocioWire API client
|
||||
* Frontend -> Backend (port 8081 via proxy /api)
|
||||
* SocioWire frontend API client
|
||||
*/
|
||||
|
||||
const API_BASE = "/api";
|
||||
|
||||
/** Récupère la liste des posts */
|
||||
export async function fetchPosts() {
|
||||
const res = await fetch(`${API_BASE}/posts`, {
|
||||
/**
|
||||
* Récupère la liste des posts avec filtres.
|
||||
* filters: {
|
||||
* category?: "NEWS" | "FRIENDS" | "EVENTS" | "MARKET"
|
||||
* subCategory?: string
|
||||
* time?: "NOW" | "TODAY" | "RECENT" | "PAST"
|
||||
* lat?: number
|
||||
* lon?: number
|
||||
* radiusKm?: number
|
||||
* }
|
||||
*/
|
||||
export async function fetchPosts(filters = {}) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters.category) params.set("category", filters.category);
|
||||
if (filters.subCategory) params.set("sub_category", filters.subCategory);
|
||||
if (filters.time) params.set("time", filters.time);
|
||||
if (typeof filters.lat === "number") params.set("lat", String(filters.lat));
|
||||
if (typeof filters.lon === "number") params.set("lon", String(filters.lon));
|
||||
if (typeof filters.radiusKm === "number") {
|
||||
params.set("radius_km", String(filters.radiusKm));
|
||||
}
|
||||
|
||||
const qs = params.toString();
|
||||
const url = qs ? `${API_BASE}/posts?${qs}` : `${API_BASE}/posts`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
|
|
@ -21,39 +44,72 @@ export async function fetchPosts() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Récupère la position approx de l'utilisateur.
|
||||
* Le backend doit renvoyer { lat, lon } ou { latitude, longitude } etc.
|
||||
* Récupère la position approx de l'utilisateur depuis le backend.
|
||||
* Backend: GET /api/ip-location → { lat: number, lon: number } ou "null"
|
||||
*/
|
||||
export async function fetchUserLocation() {
|
||||
const res = await fetch(`${API_BASE}/location`, {
|
||||
export async function fetchIpLocation() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/ip-location`, {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn("fetchUserLocation failed", res.status);
|
||||
console.warn("fetchIpLocation: HTTP", res.status);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
const lat =
|
||||
data.lat ??
|
||||
data.latitude ??
|
||||
data.location?.lat ??
|
||||
data.location?.latitude;
|
||||
|
||||
const lon =
|
||||
data.lon ??
|
||||
data.lng ??
|
||||
data.longitude ??
|
||||
data.location?.lon ??
|
||||
data.location?.lng ??
|
||||
data.location?.longitude;
|
||||
|
||||
if (typeof lat === "number" && typeof lon === "number") {
|
||||
return [lon, lat]; // [lng, lat] pour MapLibre
|
||||
const text = await res.text();
|
||||
if (!text || text === "null") {
|
||||
return null;
|
||||
}
|
||||
|
||||
console.warn("fetchUserLocation: invalid payload", data);
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch (e) {
|
||||
console.warn("fetchIpLocation: JSON parse error", e, text);
|
||||
return null;
|
||||
}
|
||||
|
||||
const lat = data.lat;
|
||||
const lon = data.lon;
|
||||
|
||||
if (
|
||||
typeof lat === "number" &&
|
||||
typeof lon === "number" &&
|
||||
!Number.isNaN(lat) &&
|
||||
!Number.isNaN(lon)
|
||||
) {
|
||||
return [lon, lat]; // [lng, lat]
|
||||
}
|
||||
|
||||
console.warn("fetchIpLocation: invalid payload", data);
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.warn("fetchIpLocation: network error", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un nouveau post
|
||||
* Backend: POST /api/post (postCreateHandler)
|
||||
*/
|
||||
export async function createPost(payload) {
|
||||
const res = await fetch(`${API_BASE}/post`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
console.error("createPost failed", res.status, text);
|
||||
throw new Error("Failed to create post");
|
||||
}
|
||||
|
||||
return res.json().catch(() => ({}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,44 @@
|
|||
import React, { useEffect, useRef, useState } from "react";
|
||||
import maplibregl from "maplibre-gl";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
import { fetchPosts } from "../../api/client";
|
||||
import { fetchPosts, fetchIpLocation, createPost } from "../../api/client";
|
||||
|
||||
/* ------------ UTILS -------------- */
|
||||
const LAST_POS_KEY = "sociowire:lastPosition"; // position utilisateur (GPS/IP)
|
||||
const LAST_VIEW_KEY = "sociowire:lastView"; // dernière vue carte (centre+zoom)
|
||||
|
||||
// petite haversine pour estimer le rayon en km en fonction du zoom
|
||||
function haversineKm(lat1, lon1, lat2, lon2) {
|
||||
const R = 6371;
|
||||
const toRad = (deg) => (deg * Math.PI) / 180;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(toRad(lat1)) *
|
||||
Math.cos(toRad(lat2)) *
|
||||
Math.sin(dLon / 2) *
|
||||
Math.sin(dLon / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
// centre + rayon (km) de la vue actuelle
|
||||
function getViewFromMap(map) {
|
||||
const bounds = map.getBounds();
|
||||
const center = bounds.getCenter();
|
||||
const ne = bounds.getNorthEast();
|
||||
let radiusKm = haversineKm(center.lat, center.lng, ne.lat, ne.lng);
|
||||
if (!Number.isFinite(radiusKm) || radiusKm <= 0) {
|
||||
radiusKm = 250;
|
||||
}
|
||||
radiusKm = Math.min(Math.max(radiusKm, 10), 1000); // clamp 10–1000 km
|
||||
return {
|
||||
center: [center.lng, center.lat],
|
||||
radiusKm,
|
||||
};
|
||||
}
|
||||
|
||||
function getCoords(post) {
|
||||
const lat =
|
||||
post.lat ??
|
||||
|
|
@ -24,12 +59,32 @@ function getCoords(post) {
|
|||
function extract(post) {
|
||||
const title = post.title || post.text || post.body || "Post";
|
||||
const body =
|
||||
post.body || post.content || post.text || "No additional details.";
|
||||
post.snippet ||
|
||||
post.body ||
|
||||
post.content ||
|
||||
post.text ||
|
||||
"No additional details.";
|
||||
const short = title.length > 28 ? title.slice(0, 25) + "..." : title;
|
||||
return { title, body, short };
|
||||
}
|
||||
|
||||
/* ------------ BOTTOM CATEGORIES -------------- */
|
||||
// map bouton -> code backend
|
||||
function categoryCode(mainFilter) {
|
||||
switch (mainFilter) {
|
||||
case "News":
|
||||
return "NEWS";
|
||||
case "Friends":
|
||||
return "FRIENDS";
|
||||
case "Events":
|
||||
return "EVENT"; // backend: EVENT (singulier)
|
||||
case "Market":
|
||||
return "MARKET";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------ CATEGORIES -------------- */
|
||||
const CATEGORY_MAP = {
|
||||
News: ["World", "Politics", "Tech", "Finance", "Local"],
|
||||
Friends: ["Nearby", "Chats", "Groups", "Stories", "Favs"],
|
||||
|
|
@ -41,12 +96,36 @@ const CATEGORY_MAP = {
|
|||
export default function MapView() {
|
||||
const containerRef = useRef(null);
|
||||
const mapRef = useRef(null);
|
||||
const markersRef = useRef([]); // { marker, el, compactHTML, expandedHTML }
|
||||
const markersRef = useRef([]);
|
||||
const expandedElRef = useRef(null);
|
||||
|
||||
const [status, setStatus] = useState("Loading posts...");
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
const [mainFilter, setMainFilter] = useState("Default");
|
||||
|
||||
const [mainFilter, setMainFilter] = useState("Default"); // News/Friends/Events/Market
|
||||
const [timeFilter, setTimeFilter] = useState("RECENT"); // NOW/TODAY/RECENT/PAST
|
||||
const [subFilter, setSubFilter] = useState(null); // sous-catégorie du bas
|
||||
|
||||
const [userPosition, setUserPosition] = useState(null); // [lng, lat]
|
||||
const [reloadToken, setReloadToken] = useState(0);
|
||||
|
||||
const [viewParams, setViewParams] = useState({
|
||||
center: null,
|
||||
radiusKm: 250,
|
||||
});
|
||||
|
||||
const [hasLastView, setHasLastView] = useState(false);
|
||||
|
||||
// création de post
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [draftTitle, setDraftTitle] = useState("");
|
||||
const [draftBody, setDraftBody] = useState("");
|
||||
const [draftCategory, setDraftCategory] = useState("News");
|
||||
const [draftSubCategory, setDraftSubCategory] = useState(
|
||||
CATEGORY_MAP.News[0]
|
||||
);
|
||||
const [draftCoords, setDraftCoords] = useState(null); // [lng, lat]
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState("");
|
||||
|
||||
/* ------------ INIT MAP -------------- */
|
||||
useEffect(() => {
|
||||
|
|
@ -55,17 +134,63 @@ export default function MapView() {
|
|||
const map = new maplibregl.Map({
|
||||
container: containerRef.current,
|
||||
style: "https://demotiles.maplibre.org/style.json",
|
||||
center: [-70, 47],
|
||||
zoom: 5,
|
||||
center: [-95, 40],
|
||||
zoom: 3.5,
|
||||
pitch: 45,
|
||||
bearing: -20,
|
||||
bearing: -15,
|
||||
antialias: true,
|
||||
});
|
||||
|
||||
map.addControl(new maplibregl.NavigationControl(), "top-right");
|
||||
// si on a une dernière vue en localStorage, on la remet ici
|
||||
let hadLastView = false;
|
||||
try {
|
||||
const raw = localStorage.getItem(LAST_VIEW_KEY);
|
||||
if (raw) {
|
||||
const v = JSON.parse(raw);
|
||||
if (
|
||||
typeof v.lat === "number" &&
|
||||
typeof v.lon === "number" &&
|
||||
typeof v.zoom === "number"
|
||||
) {
|
||||
map.setCenter([v.lon, v.lat]);
|
||||
map.setZoom(v.zoom);
|
||||
hadLastView = true;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
setHasLastView(hadLastView);
|
||||
|
||||
map.addControl(new maplibregl.NavigationControl(), "top-right");
|
||||
mapRef.current = map;
|
||||
setMapReady(true);
|
||||
|
||||
// première vue → viewParams
|
||||
map.on("load", () => {
|
||||
const vp = getViewFromMap(map);
|
||||
setViewParams(vp);
|
||||
});
|
||||
|
||||
// à chaque fin de move/zoom :
|
||||
// - on maj viewParams (pour fetch)
|
||||
// - on sauvegarde la vue dans localStorage
|
||||
map.on("moveend", () => {
|
||||
const vp = getViewFromMap(map);
|
||||
setViewParams(vp);
|
||||
try {
|
||||
const center = vp.center;
|
||||
localStorage.setItem(
|
||||
LAST_VIEW_KEY,
|
||||
JSON.stringify({
|
||||
lat: center[1],
|
||||
lon: center[0],
|
||||
zoom: map.getZoom(),
|
||||
})
|
||||
);
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
markersRef.current.forEach((m) => m.marker.remove());
|
||||
|
|
@ -75,75 +200,117 @@ export default function MapView() {
|
|||
};
|
||||
}, []);
|
||||
|
||||
/* ------------ CENTRER SUR POSITION USER -------------- */
|
||||
useEffect(() => {
|
||||
if (!mapReady || !mapRef.current) return;
|
||||
|
||||
const map = mapRef.current;
|
||||
let cancelled = false;
|
||||
|
||||
function flyTo(lat, lon) {
|
||||
if (cancelled) return;
|
||||
map.flyTo({
|
||||
center: [lon, lat],
|
||||
zoom: 10,
|
||||
speed: 1.4,
|
||||
curve: 1.6,
|
||||
});
|
||||
}
|
||||
|
||||
async function fallbackIp() {
|
||||
try {
|
||||
const res = await fetch("/api/location"); // <-- adapte si ton endpoint est différent
|
||||
if (!res.ok) return;
|
||||
const data = await res.json(); // attendu: { lat: number, lon: number }
|
||||
if (
|
||||
typeof data.lat === "number" &&
|
||||
typeof data.lon === "number" &&
|
||||
!Number.isNaN(data.lat) &&
|
||||
!Number.isNaN(data.lon)
|
||||
) {
|
||||
flyTo(data.lat, data.lon);
|
||||
}
|
||||
} catch (e) {
|
||||
// pas grave, on garde le centre par défaut
|
||||
console.warn("IP geo fallback failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
if ("geolocation" in navigator) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
if (cancelled) return;
|
||||
flyTo(pos.coords.latitude, pos.coords.longitude);
|
||||
},
|
||||
() => {
|
||||
if (cancelled) return;
|
||||
fallbackIp();
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 8000 }
|
||||
);
|
||||
} else {
|
||||
fallbackIp();
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [mapReady]);
|
||||
|
||||
/* ------------ LOAD POSTS -------------- */
|
||||
/* ------------ POSITION UTILISATEUR (GPS/IP) -------------- */
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
function saveUserPos(lat, lon) {
|
||||
if (cancelled) return;
|
||||
const coords = [lon, lat];
|
||||
setUserPosition(coords);
|
||||
try {
|
||||
localStorage.setItem(LAST_POS_KEY, JSON.stringify({ lat, lon }));
|
||||
} catch (_) {}
|
||||
|
||||
// si on N'A PAS de lastView, on utilise le geo pour centrer
|
||||
if (!hasLastView) {
|
||||
map.flyTo({
|
||||
center: coords,
|
||||
zoom: 9,
|
||||
speed: 1.2,
|
||||
curve: 1.5,
|
||||
essential: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 1) dernière position utilisateur (pas la vue)
|
||||
try {
|
||||
const raw = localStorage.getItem(LAST_POS_KEY);
|
||||
if (raw) {
|
||||
const p = JSON.parse(raw);
|
||||
if (typeof p.lat === "number" && typeof p.lon === "number") {
|
||||
setUserPosition([p.lon, p.lat]);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// 2) GPS → 3) IP backend
|
||||
function viaIpFallback() {
|
||||
(async () => {
|
||||
const ipCoords = await fetchIpLocation();
|
||||
if (!ipCoords || cancelled) return;
|
||||
const [lon, lat] = ipCoords;
|
||||
saveUserPos(lat, lon);
|
||||
})();
|
||||
}
|
||||
|
||||
if ("geolocation" in navigator) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
if (cancelled) return;
|
||||
saveUserPos(pos.coords.latitude, pos.coords.longitude);
|
||||
},
|
||||
() => {
|
||||
if (cancelled) return;
|
||||
viaIpFallback();
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: 8000,
|
||||
maximumAge: 30000,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
viaIpFallback();
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [hasLastView]);
|
||||
|
||||
/* ------------ SOUS-CATEGORIES (bas) -------------- */
|
||||
const bottomCategories =
|
||||
CATEGORY_MAP[mainFilter] || CATEGORY_MAP.Default;
|
||||
|
||||
useEffect(() => {
|
||||
if (!bottomCategories.length) return;
|
||||
if (!subFilter || !bottomCategories.includes(subFilter)) {
|
||||
setSubFilter(bottomCategories[0]);
|
||||
}
|
||||
}, [mainFilter, bottomCategories, subFilter]);
|
||||
|
||||
/* ------------ LOAD POSTS POUR LA ZONE COURANTE + FILTRES -------------- */
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
if (!viewParams.center) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setStatus("Loading posts...");
|
||||
const raw = await fetchPosts();
|
||||
const posts = Array.isArray(raw) ? raw : raw?.items || [];
|
||||
|
||||
const [lng, lat] = viewParams.center;
|
||||
const radiusKm = viewParams.radiusKm || 250;
|
||||
|
||||
const catCode = categoryCode(mainFilter);
|
||||
const subCatParam =
|
||||
subFilter && subFilter.toUpperCase() !== "ALL" ? subFilter : "";
|
||||
|
||||
const posts = await fetchPosts({
|
||||
category: catCode,
|
||||
subCategory: subCatParam,
|
||||
time: timeFilter,
|
||||
lat,
|
||||
lon: lng,
|
||||
radiusKm,
|
||||
});
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
|
|
@ -151,13 +318,20 @@ export default function MapView() {
|
|||
markersRef.current = [];
|
||||
expandedElRef.current = null;
|
||||
|
||||
const bounds = new maplibregl.LngLatBounds();
|
||||
let hasCoords = false;
|
||||
|
||||
posts.forEach((post) => {
|
||||
const coords = getCoords(post);
|
||||
if (!coords) return;
|
||||
|
||||
const [lngP, latP] = coords;
|
||||
if (
|
||||
typeof lngP !== "number" ||
|
||||
typeof latP !== "number" ||
|
||||
Number.isNaN(lngP) ||
|
||||
Number.isNaN(latP)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { title, body, short } = extract(post);
|
||||
|
||||
const compactHTML = `
|
||||
|
|
@ -173,7 +347,9 @@ export default function MapView() {
|
|||
<div class="marker-dot"></div>
|
||||
<span class="marker-expanded-title">${title}</span>
|
||||
</div>
|
||||
<div class="marker-expanded-body">${body}</div>
|
||||
<div class="marker-expanded-body">
|
||||
${body}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
|
@ -181,7 +357,10 @@ export default function MapView() {
|
|||
el.className = "post-marker post-marker-compact";
|
||||
el.innerHTML = compactHTML;
|
||||
|
||||
const marker = new maplibregl.Marker({ element: el, anchor: "bottom" })
|
||||
const marker = new maplibregl.Marker({
|
||||
element: el,
|
||||
anchor: "bottom",
|
||||
})
|
||||
.setLngLat(coords)
|
||||
.addTo(map);
|
||||
|
||||
|
|
@ -215,15 +394,8 @@ export default function MapView() {
|
|||
e.preventDefault();
|
||||
toggle();
|
||||
});
|
||||
|
||||
bounds.extend(coords);
|
||||
hasCoords = true;
|
||||
});
|
||||
|
||||
if (hasCoords) {
|
||||
map.fitBounds(bounds, { padding: 40, maxZoom: 13 });
|
||||
}
|
||||
|
||||
setStatus(posts.length ? "" : "No posts found.");
|
||||
} catch (err) {
|
||||
console.error("Erreur chargement posts:", err);
|
||||
|
|
@ -236,9 +408,134 @@ export default function MapView() {
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [viewParams, mainFilter, timeFilter, subFilter, reloadToken]);
|
||||
|
||||
/* ------------ WEBSOCKET : RELOAD SUR new_post -------------- */
|
||||
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 (e) {
|
||||
console.warn("WebSocket init error:", e);
|
||||
return;
|
||||
}
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === "new_post") {
|
||||
setReloadToken((n) => n + 1);
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
ws.onerror = (e) => {
|
||||
console.warn("WebSocket error:", e);
|
||||
};
|
||||
|
||||
return () => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const bottomCategories = CATEGORY_MAP[mainFilter] || CATEGORY_MAP.Default;
|
||||
/* ------------ ACTIONS UI -------------- */
|
||||
|
||||
const handleFlyToMe = () => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !userPosition) return;
|
||||
map.flyTo({
|
||||
center: userPosition,
|
||||
zoom: 9,
|
||||
speed: 1.1,
|
||||
curve: 1.5,
|
||||
essential: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePickCenter = () => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
const center = map.getCenter();
|
||||
setDraftCoords([center.lng, center.lat]);
|
||||
};
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setIsCreating(true);
|
||||
setSaveError("");
|
||||
setDraftTitle("");
|
||||
setDraftBody("");
|
||||
|
||||
const main = mainFilter === "Default" ? "News" : mainFilter;
|
||||
setDraftCategory(main);
|
||||
const list = CATEGORY_MAP[main] || CATEGORY_MAP.Default;
|
||||
setDraftSubCategory(list[0]);
|
||||
setDraftCoords(null);
|
||||
};
|
||||
|
||||
const handleSubmitPost = async () => {
|
||||
if (isSaving) return;
|
||||
setSaveError("");
|
||||
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
let coords = draftCoords;
|
||||
if (!coords) {
|
||||
const center = map.getCenter();
|
||||
coords = [center.lng, center.lat];
|
||||
}
|
||||
|
||||
if (!coords) {
|
||||
setSaveError("Position inconnue. Déplace la carte et tap sur la cible.");
|
||||
return;
|
||||
}
|
||||
|
||||
const [lng, lat] = coords;
|
||||
|
||||
if (!draftTitle.trim()) {
|
||||
setSaveError("Le titre est requis.");
|
||||
return;
|
||||
}
|
||||
|
||||
const categoryUpper =
|
||||
draftCategory && draftCategory.toUpperCase() !== "DEFAULT"
|
||||
? draftCategory.toUpperCase()
|
||||
: "NEWS";
|
||||
|
||||
const payload = {
|
||||
title: draftTitle.trim(),
|
||||
snippet: draftBody.trim() || draftTitle.trim(),
|
||||
category: categoryUpper,
|
||||
sub_category: draftSubCategory || "ALL",
|
||||
lat,
|
||||
lon: lng,
|
||||
author: "tommy",
|
||||
};
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await createPost(payload);
|
||||
setIsSaving(false);
|
||||
setIsCreating(false);
|
||||
setReloadToken((n) => n + 1);
|
||||
} catch (e) {
|
||||
console.error("createPost error:", e);
|
||||
setIsSaving(false);
|
||||
setSaveError("Erreur lors de la création du post.");
|
||||
}
|
||||
};
|
||||
|
||||
/* ------------ RENDER -------------- */
|
||||
|
||||
return (
|
||||
<div className="map-view">
|
||||
|
|
@ -254,14 +551,29 @@ export default function MapView() {
|
|||
<button className="chip-hex">Zoom Out</button>
|
||||
<button className="chip-hex">Swall</button>
|
||||
</div>
|
||||
<button className="chip-pill top-newpost" onClick={handleOpenCreate}>
|
||||
New post
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* LEFT */}
|
||||
{/* LEFT — temps */}
|
||||
<div className="map-overlay map-overlay-left">
|
||||
<button className="chip-round">Now</button>
|
||||
<button className="chip-round">Today</button>
|
||||
<button className="chip-round">Recent</button>
|
||||
<button className="chip-round">Past</button>
|
||||
{[
|
||||
["NOW", "Now"],
|
||||
["TODAY", "Today"],
|
||||
["RECENT", "Recent"],
|
||||
["PAST", "Past"],
|
||||
].map(([code, label]) => (
|
||||
<button
|
||||
key={code}
|
||||
className={
|
||||
timeFilter === code ? "chip-round chip-selected" : "chip-round"
|
||||
}
|
||||
onClick={() => setTimeFilter(code)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* RIGHT — filtres principaux */}
|
||||
|
|
@ -279,19 +591,119 @@ export default function MapView() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* BOTTOM — arc/orbite sous le globe */}
|
||||
{/* BOTTOM — arc courbé inversé autour du globe */}
|
||||
<div className="map-overlay map-overlay-bottom">
|
||||
{bottomCategories.map((c, idx) => (
|
||||
{bottomCategories.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
className={
|
||||
idx === 0 ? "chip-egg chip-egg-active" : "chip-egg"
|
||||
subFilter === c ? "chip-egg chip-egg-active" : "chip-egg"
|
||||
}
|
||||
onClick={() => setSubFilter(c)}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* BOUTON "MY SPOT" */}
|
||||
<div className="map-overlay map-overlay-myloc">
|
||||
<button
|
||||
className="chip-pill"
|
||||
disabled={!userPosition}
|
||||
onClick={handleFlyToMe}
|
||||
>
|
||||
My spot
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* CROSSHAIR SUR LA MAP (au-dessus du popup) */}
|
||||
{isCreating && (
|
||||
<div className="map-overlay map-crosshair" onClick={handlePickCenter}>
|
||||
<div className="crosshair-aim" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PANEL CREATION DE POST (1/5 du bas) */}
|
||||
{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>
|
||||
|
||||
<div className="create-row">
|
||||
<span className="crosshair-label">
|
||||
Déplace la carte sous la cible, puis tap sur la cible pour fixer
|
||||
la position.
|
||||
{draftCoords ? " ✓ Position choisie" : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="create-row">
|
||||
<select
|
||||
value={draftCategory}
|
||||
onChange={(e) => {
|
||||
const cat = e.target.value;
|
||||
setDraftCategory(cat);
|
||||
const list = CATEGORY_MAP[cat] || CATEGORY_MAP.Default;
|
||||
setDraftSubCategory(list[0]);
|
||||
}}
|
||||
>
|
||||
{["News", "Friends", "Events", "Market"].map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{cat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={draftSubCategory}
|
||||
onChange={(e) => setDraftSubCategory(e.target.value)}
|
||||
>
|
||||
{(CATEGORY_MAP[draftCategory] || CATEGORY_MAP.Default).map(
|
||||
(sub) => (
|
||||
<option key={sub} value={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="Add a bit more 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 as tommy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/* ==========================================
|
||||
SOCIOWIRE — OVERLAY + ARC AUTOUR DU GLOBE
|
||||
SOCIOWIRE — OVERLAY + ARC + CREATE PANEL
|
||||
========================================== */
|
||||
|
||||
.map-overlay {
|
||||
|
|
@ -43,6 +43,11 @@
|
|||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
/* bouton New post dans la top bar */
|
||||
.top-newpost {
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
|
||||
/* --------- LEFT --------- */
|
||||
|
||||
.map-overlay-left {
|
||||
|
|
@ -65,41 +70,178 @@
|
|||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Bouton principal sélectionné (News/Friends/Events/Market) */
|
||||
/* Bouton principal sélectionné */
|
||||
.chip-selected {
|
||||
background: linear-gradient(135deg, #2563eb, #0ea5e9);
|
||||
border-color: #7dd3fc;
|
||||
box-shadow: 0 0 20px rgba(56, 189, 248, 0.55);
|
||||
}
|
||||
|
||||
/* --------- BOTTOM — ARC (type orbite) --------- */
|
||||
/* --------- BOTTOM — ARC (INVERSE AUTOUR DU GLOBE) --------- */
|
||||
|
||||
.map-overlay-bottom {
|
||||
bottom: 3.2rem; /* au-dessus de la barre Android */
|
||||
bottom: 3.2rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* On crée un arc en jouant avec translateY + rotate */
|
||||
/* courbure inversée : centre plus bas, côtés plus hauts */
|
||||
.map-overlay-bottom .chip-egg:nth-child(1) {
|
||||
transform: translateY(8px) rotate(-12deg);
|
||||
transform: translateY(-8px) rotate(-12deg);
|
||||
}
|
||||
.map-overlay-bottom .chip-egg:nth-child(2) {
|
||||
transform: translateY(4px) rotate(-6deg);
|
||||
transform: translateY(-4px) rotate(-6deg);
|
||||
}
|
||||
.map-overlay-bottom .chip-egg:nth-child(3) {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
}
|
||||
.map-overlay-bottom .chip-egg:nth-child(4) {
|
||||
transform: translateY(4px) rotate(6deg);
|
||||
transform: translateY(-4px) rotate(6deg);
|
||||
}
|
||||
.map-overlay-bottom .chip-egg:nth-child(5) {
|
||||
transform: translateY(8px) rotate(12deg);
|
||||
transform: translateY(-8px) rotate(12deg);
|
||||
}
|
||||
|
||||
/* --------- CHIPS (boutons) --------- */
|
||||
/* --------- BOUTON "MY SPOT" --------- */
|
||||
|
||||
.map-overlay-myloc {
|
||||
right: 0.7rem;
|
||||
bottom: 6rem;
|
||||
}
|
||||
|
||||
/* --------- CROSSHAIR SUR LA MAP --------- */
|
||||
|
||||
.map-crosshair {
|
||||
left: 50%;
|
||||
top: 40%; /* au-dessus du popup */
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.crosshair-aim {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #38bdf8;
|
||||
position: relative;
|
||||
box-shadow: 0 0 14px rgba(56, 189, 248, 0.9);
|
||||
}
|
||||
|
||||
.crosshair-aim::before,
|
||||
.crosshair-aim::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
background: #38bdf8;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.crosshair-aim::before {
|
||||
width: 70%;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.crosshair-aim::after {
|
||||
width: 1px;
|
||||
height: 70%;
|
||||
}
|
||||
|
||||
/* --------- CREATE POST PANEL (1/5 du bas) --------- */
|
||||
|
||||
.create-post-panel {
|
||||
left: 50%;
|
||||
bottom: 20%;
|
||||
transform: translateX(-50%);
|
||||
width: 92%;
|
||||
max-width: 520px;
|
||||
|
||||
background: rgba(15, 23, 42, 0.96);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.9);
|
||||
box-shadow:
|
||||
0 16px 40px rgba(0, 0, 0, 0.9),
|
||||
0 0 24px rgba(56, 189, 248, 0.6);
|
||||
|
||||
padding: 0.6rem 0.7rem 0.7rem 0.7rem;
|
||||
}
|
||||
|
||||
.create-post-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.4rem;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.create-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #9ca3af;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.create-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.crosshair-label {
|
||||
font-size: 0.75rem;
|
||||
color: #cbd5f5;
|
||||
}
|
||||
|
||||
.create-row select {
|
||||
flex: 1;
|
||||
background: #020617;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #4b5563;
|
||||
padding: 0.35rem 0.7rem;
|
||||
font-size: 0.75rem;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.create-input {
|
||||
width: 100%;
|
||||
margin-bottom: 0.3rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #4b5563;
|
||||
padding: 0.35rem 0.7rem;
|
||||
background: #020617;
|
||||
color: #e5e7eb;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.create-textarea {
|
||||
width: 100%;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid #4b5563;
|
||||
padding: 0.35rem 0.7rem;
|
||||
background: #020617;
|
||||
color: #e5e7eb;
|
||||
resize: none;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.create-error {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.7rem;
|
||||
color: #f97373;
|
||||
}
|
||||
|
||||
.create-actions {
|
||||
margin-top: 0.4rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* --------- STYLES DES CHIPS --------- */
|
||||
|
||||
.chip-round,
|
||||
.chip-pill,
|
||||
|
|
@ -117,19 +259,16 @@
|
|||
0 0 12px rgba(56, 189, 248, 0.35);
|
||||
}
|
||||
|
||||
/* boutons d’orbite (catégories dynamiques) */
|
||||
.chip-egg {
|
||||
background: rgba(0, 10, 30, 0.9);
|
||||
border: 1px solid rgba(56, 189, 248, 0.55);
|
||||
}
|
||||
|
||||
/* catégorie sélectionnée (première) */
|
||||
.chip-egg-active {
|
||||
background: linear-gradient(135deg, #1d4ed8, #0ea5e9);
|
||||
border-color: #7dd3fc;
|
||||
}
|
||||
|
||||
/* boutons hex (Zoom, Swall) */
|
||||
.chip-hex {
|
||||
border-radius: 0.65rem;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue