diff --git a/deploy.sh b/deploy.sh
old mode 100644
new mode 100755
diff --git a/package-lock.json b/package-lock.json
index 34e1992..5c72180 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"axios": "^1.13.2",
+ "keycloak-js": "^26.2.1",
"maplibre-gl": "^5.14.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -2200,6 +2201,15 @@
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
"license": "ISC"
},
+ "node_modules/keycloak-js": {
+ "version": "26.2.1",
+ "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.1.tgz",
+ "integrity": "sha512-bZt6fQj/TLBAmivXSxSlqAJxBx/knNZDQGJIW4ensGYGN4N6tUKV8Zj3Y7/LOV8eIpvWsvqV70fbACihK8Ze0Q==",
+ "license": "Apache-2.0",
+ "workspaces": [
+ "test"
+ ]
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
diff --git a/package.json b/package.json
index e6a6af5..a247088 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
},
"dependencies": {
"axios": "^1.13.2",
+ "keycloak-js": "^26.2.1",
"maplibre-gl": "^5.14.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
diff --git a/src/App.jsx b/src/App.jsx
index dd4df67..752fd29 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,6 +1,8 @@
-import React, { useEffect, useState } from "react";
+import React, { Suspense, useEffect, useState } from "react";
import TopBar from "./components/Layout/TopBar";
-import MapView from "./components/Map/MapView";
+
+// Lazy-load de la carte (gros chunk : maplibre, CSS, etc.)
+const MapView = React.lazy(() => import("./components/Map/MapView"));
const THEME_KEY = "sociowire:theme";
@@ -39,8 +41,9 @@ export default function App() {
- {/* on passe le thème à la carte */}
-
+ Loading map…
}>
+
+
diff --git a/src/api/client.js b/src/api/client.js
index d1f5325..efd5202 100644
--- a/src/api/client.js
+++ b/src/api/client.js
@@ -4,17 +4,6 @@
const API_BASE = "/api";
-/**
- * Récupère la liste des posts avec filtres.
- * filters: {
- * category?: "NEWS" | "FRIENDS" | "EVENT" | "MARKET"
- * subCategory?: string
- * time?: "NOW" | "TODAY" | "RECENT" | "PAST"
- * lat?: number
- * lon?: number
- * radiusKm?: number
- * }
- */
export async function fetchPosts(filters = {}) {
const params = new URLSearchParams();
@@ -45,7 +34,6 @@ export async function fetchPosts(filters = {}) {
/**
* 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 fetchIpLocation() {
try {
@@ -93,15 +81,19 @@ export async function fetchIpLocation() {
/**
* Crée un nouveau post
- * Backend: POST /api/post (postCreateHandler)
- * Attend: { title, snippet, category, sub_category, lat, lon, author }
+ * token: access token Keycloak (string)
*/
-export async function createPost(payload) {
+export async function createPost(payload, token) {
+ const headers = {
+ "Content-Type": "application/json",
+ };
+ if (token) {
+ headers["Authorization"] = `Bearer ${token}`;
+ }
+
const res = await fetch(`${API_BASE}/post`, {
method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
+ headers,
credentials: "include",
body: JSON.stringify(payload),
});
diff --git a/src/auth/AuthContext.jsx b/src/auth/AuthContext.jsx
new file mode 100644
index 0000000..0c2d9d6
--- /dev/null
+++ b/src/auth/AuthContext.jsx
@@ -0,0 +1,111 @@
+import React, { createContext, useContext, useEffect, useState } from "react";
+import keycloak from "./keycloak";
+
+const AuthContext = createContext(null);
+
+export function AuthProvider({ children }) {
+ const [kc, setKc] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [authenticated, setAuthenticated] = useState(false);
+ const [username, setUsername] = useState("");
+ const [lastError, setLastError] = useState("");
+
+ useEffect(() => {
+ let isMounted = true;
+
+ async function init() {
+ try {
+ console.log("[AUTH] init keycloak…");
+ const ok = await keycloak.init({
+ onLoad: "check-sso", // ne force pas le login au chargement
+ checkLoginIframe: false,
+ });
+
+ if (!isMounted) return;
+
+ console.log("[AUTH] init result:", ok);
+ window.kc = keycloak; // debug global
+
+ setKc(keycloak);
+ setAuthenticated(ok);
+
+ if (ok && keycloak.tokenParsed) {
+ const p = keycloak.tokenParsed;
+ const name =
+ p.preferred_username ||
+ p.email ||
+ p.name ||
+ (p.sub ? String(p.sub) : "");
+ setUsername(name || "");
+ } else {
+ setUsername("");
+ }
+ } catch (err) {
+ console.error("[AUTH] keycloak init error", err);
+ if (isMounted) {
+ setLastError(String(err));
+ }
+ } finally {
+ if (isMounted) setLoading(false);
+ }
+ }
+
+ init();
+
+ // refresh token simplifié
+ const interval = setInterval(() => {
+ if (!keycloak || !keycloak.authenticated) return;
+ keycloak
+ .updateToken(60)
+ .then((refreshed) => {
+ if (refreshed) {
+ console.log("[AUTH] token refreshed");
+ }
+ })
+ .catch((err) => {
+ console.warn("[AUTH] token refresh error", err);
+ });
+ }, 30000);
+
+ return () => {
+ isMounted = false;
+ clearInterval(interval);
+ };
+ }, []);
+
+ const login = () => {
+ keycloak.login({
+ redirectUri: window.location.href,
+ });
+ };
+
+ const logout = () => {
+ keycloak.logout({
+ redirectUri: window.location.origin + "/",
+ });
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useAuth() {
+ const ctx = useContext(AuthContext);
+ if (!ctx) {
+ throw new Error("useAuth must be used inside ");
+ }
+ return ctx;
+}
diff --git a/src/auth/authUtils.js b/src/auth/authUtils.js
new file mode 100644
index 0000000..a908834
--- /dev/null
+++ b/src/auth/authUtils.js
@@ -0,0 +1,57 @@
+import keycloak from "./keycloak";
+
+/**
+ * Initialise Keycloak sans forcer le login.
+ * - onLoad: "check-sso" => ne redirige pas si user non loggé
+ */
+export function initAuth() {
+ return keycloak.init({
+ onLoad: "check-sso",
+ checkLoginIframe: false,
+ });
+}
+
+/**
+ * Appelle une action qui nécessite d'être authentifié.
+ * Si non loggé => ouvre la page de login Keycloak, puis revient sur la même page.
+ */
+export function requireAuth(action) {
+ if (keycloak.authenticated) {
+ return action();
+ }
+
+ keycloak.login({
+ redirectUri: window.location.href,
+ });
+}
+
+/**
+ * Retourne le token JWT actuel (ou null)
+ */
+export function getToken() {
+ return keycloak.token || null;
+}
+
+/**
+ * Retourne le username depuis le token (si présent)
+ */
+export function getUsername() {
+ const profile = keycloak.tokenParsed || {};
+ return (
+ profile.preferred_username ||
+ profile.email ||
+ profile.name ||
+ null
+ );
+}
+
+/**
+ * Logout et revient sur la même page.
+ */
+export function logout() {
+ keycloak.logout({
+ redirectUri: window.location.origin,
+ });
+}
+
+export default keycloak;
diff --git a/src/auth/keycloak.js b/src/auth/keycloak.js
new file mode 100644
index 0000000..30afe2f
--- /dev/null
+++ b/src/auth/keycloak.js
@@ -0,0 +1,9 @@
+import Keycloak from "keycloak-js";
+
+const keycloak = new Keycloak({
+ url: "https://web.sociowire.com:8443",
+ realm: "sociowire",
+ clientId: "sociowire-frontend",
+});
+
+export default keycloak;
diff --git a/src/components/Layout/TopBar.jsx b/src/components/Layout/TopBar.jsx
index 56e11a6..f733643 100644
--- a/src/components/Layout/TopBar.jsx
+++ b/src/components/Layout/TopBar.jsx
@@ -1,4 +1,5 @@
import React from "react";
+import { useAuth } from "../../auth/AuthContext";
const THEME_LABELS = {
dark: "Dark",
@@ -7,6 +8,12 @@ const THEME_LABELS = {
};
export default function TopBar({ theme = "dark", onChangeTheme }) {
+ const { loading, authenticated, username, login, logout, lastError } = useAuth();
+
+ let authLabel = "AUTH: anon";
+ if (loading) authLabel = "AUTH: loading…";
+ else if (authenticated) authLabel = `AUTH: user=${username || "?"}`;
+
return (
SW
@@ -14,6 +21,14 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
SOCIOWIRE.com
Wired to life
+
+ {authLabel}
+ {lastError && (
+
+ (auth err)
+
+ )}
+
@@ -32,7 +47,23 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
))}
-
+
+ {loading ? (
+
+ ) : authenticated ? (
+
+ {username}
+
+
+ ) : (
+
+ )}
);
diff --git a/src/components/Map/MapView.jsx b/src/components/Map/MapView.jsx
index 62c3b82..4d98ca4 100644
--- a/src/components/Map/MapView.jsx
+++ b/src/components/Map/MapView.jsx
@@ -14,8 +14,11 @@ 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 } = useAuth();
+
const {
containerRef,
mapRef,
@@ -69,6 +72,7 @@ export default function MapView({ theme = "dark" }) {
}
}, [mainFilter, bottomCategories, subFilter]);
+ // WebSocket live
useEffect(() => {
const proto = window.location.protocol === "https:" ? "wss" : "ws";
const host =
@@ -102,6 +106,10 @@ export default function MapView({ theme = "dark" }) {
};
const handleOpenCreate = () => {
+ if (!authenticated) {
+ alert("You must be logged in to place a wire.");
+ return;
+ }
setIsCreating(true);
setDraftTitle("");
setDraftBody("");
@@ -121,26 +129,76 @@ export default function MapView({ theme = "dark" }) {
setSaveError("");
const map = mapRef.current;
if (!map) return;
- let coords = draftCoords || [map.getCenter().lng, map.getCenter().lat];
- const [lng, lat] = coords;
+
+ if (!authenticated) {
+ setSaveError("You must be logged in to post.");
+ return;
+ }
+
+ const authorName = (username || "").trim() || "anon";
+
+ // ----- CROSSHAIR OFFSET FIX -----
+ // Point de départ : soit coords définies, soit centre de la map
+ 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;
+ }
+
+ // Offset vertical en pixels :
+ // si le post apparaît trop bas, on remonte (pixelOffsetY négatif)
+ const pixelOffsetY = -20; // ajuste à -25/-30 si nécessaire
+
+ // lng/lat -> pixels
+ const screenPoint = map.project([baseLng, baseLat]);
+
+ // appliquer l'offset vertical
+ const correctedPoint = {
+ x: screenPoint.x,
+ y: screenPoint.y + pixelOffsetY,
+ };
+
+ // pixels -> lng/lat corrigé
+ const correctedLngLat = map.unproject([correctedPoint.x, correctedPoint.y]);
+ const lng = correctedLngLat.lng;
+ const lat = correctedLngLat.lat;
+ // ----- END FIX -----
+
if (!draftTitle.trim()) {
setSaveError("Title required.");
return;
}
+
try {
setIsSaving(true);
- await createPost({
+
+ // On récupère le post retourné par le backend
+ const newPost = await createPost({
title: draftTitle.trim(),
snippet: draftBody.trim() || draftTitle.trim(),
category: draftCategory.toUpperCase(),
sub_category: draftSubCategory || "ALL",
lat,
lon: lng,
- author: "tommy",
+ author: authorName,
});
+
+ // On l’injecte direct dans le moteur de posts,
+ // en plus du broadcast WebSocket
+ if (newPost && newPost.id) {
+ handleIncomingPost(newPost);
+ }
+
setIsSaving(false);
setIsCreating(false);
- } catch {
+ } catch (e) {
+ console.error(e);
setIsSaving(false);
setSaveError("Error creating post.");
}
@@ -233,7 +291,14 @@ export default function MapView({ theme = "dark" }) {
- {/* CREATE POST */}
+ {/* CROSSHAIR au centre pendant la création */}
+ {isCreating && (
+
+ )}
+
+ {/* CREATE POST PANEL */}
{isCreating && (
@@ -246,7 +311,8 @@ export default function MapView({ theme = "dark" }) {
- Move the map, then tap to fix the position.
+ Move the map, aim with the crosshair — your wire will be pinned
+ there.
diff --git a/src/main.jsx b/src/main.jsx
index faff4e8..d7caeed 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -12,8 +12,12 @@ import "./styles/overlays.css";
import "./styles/posts.css";
import "./styles/theme.css";
+import { AuthProvider } from "./auth/AuthContext.jsx";
+
ReactDOM.createRoot(document.getElementById("root")).render(
-
+
+
+
);