work with kc

This commit is contained in:
Your Name 2025-12-11 00:58:23 +00:00
parent 5e3c2df7f4
commit af03d992cb
8 changed files with 431 additions and 290 deletions

10
package-lock.json generated
View File

@ -58,6 +58,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@ -1074,6 +1075,7 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@ -1124,6 +1126,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -1246,6 +1249,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -1547,6 +1551,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -2767,6 +2772,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -2852,6 +2858,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -2861,6 +2868,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@ -3163,6 +3171,7 @@
"integrity": "sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/runtime": "0.97.0",
"fdir": "^6.5.0",
@ -3285,6 +3294,7 @@
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@ -4,6 +4,9 @@
const API_BASE = "/api";
/**
* Récupère la liste des posts, avec filtres optionnels.
*/
export async function fetchPosts(filters = {}) {
const params = new URLSearchParams();
@ -81,7 +84,7 @@ export async function fetchIpLocation() {
/**
* Crée un nouveau post
* token: access token Keycloak (string)
* token: access token Keycloak (string) ou null
*/
export async function createPost(payload, token) {
const headers = {
@ -108,11 +111,12 @@ export async function createPost(payload, token) {
}
/**
* Crée un nouvel utilisateur (backend doit exposer POST /api/register)
* Crée un nouvel utilisateur via le backend
* (backend: POST /api/signup)
* payload: { username, email, password }
*/
export async function registerUser(payload) {
const res = await fetch(`${API_BASE}/register`, {
const res = await fetch(`${API_BASE}/signup`, {
method: "POST",
headers: {
"Content-Type": "application/json",

View File

@ -1,73 +1,62 @@
import React, { createContext, useContext, useEffect, useState } from "react";
import React, {
createContext,
useContext,
useEffect,
useState,
} from "react";
// CONFIG KEYCLOAK
const AuthContext = createContext(null);
// Config Keycloak pour le login inline
const KC_BASE = "https://web.sociowire.com:8443";
const KC_REALM = "sociowire";
const KC_CLIENT_ID = "sociowire-frontend";
const TOKEN_KEY = "sociowire:token";
const AuthContext = createContext(null);
function parseJwt(token) {
try {
const [, payload] = token.split(".");
const json = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));
return JSON.parse(json);
} catch {
return {};
}
}
const TOKEN_KEY = "sociowire:access_token";
const USERNAME_KEY = "sociowire:username";
export function AuthProvider({ children }) {
const [token, setToken] = useState(null);
const [loading, setLoading] = useState(true);
const [authenticated, setAuthenticated] = useState(false);
const [username, setUsername] = useState("");
const [loading, setLoading] = useState(true);
const [token, setToken] = useState(null);
const [lastError, setLastError] = useState("");
// charger token depuis localStorage au démarrage
// Charge token/username depuis localStorage au démarrage
useEffect(() => {
try {
const saved = localStorage.getItem(TOKEN_KEY);
if (saved) {
const data = JSON.parse(saved);
if (data && data.access_token) {
const now = Date.now() / 1000;
const payload = parseJwt(data.access_token);
if (!payload.exp || payload.exp > now) {
setToken(data.access_token);
setAuthenticated(true);
const name =
payload.preferred_username ||
payload.email ||
payload.name ||
(payload.sub ? String(payload.sub) : "");
setUsername(name || "");
}
}
const storedToken = localStorage.getItem(TOKEN_KEY);
const storedUser = localStorage.getItem(USERNAME_KEY) || "";
if (storedToken) {
setToken(storedToken);
setUsername(storedUser);
setAuthenticated(true);
} else {
setAuthenticated(false);
}
} catch (e) {
console.warn("[AUTH] load token error", e);
console.warn("[AUTH] localStorage error", e);
} finally {
setLoading(false);
}
}, []);
// Login via password grant sur Keycloak
async function login(user, pass) {
setLastError("");
if (!user || !pass) {
setLastError("Username & password required.");
const u = (user || "").trim();
if (!u || !pass) {
setLastError("Username and password required.");
return;
}
setLoading(true);
try {
const params = new URLSearchParams();
params.set("grant_type", "password");
params.set("client_id", KC_CLIENT_ID);
params.set("username", user);
params.set("password", pass);
const params = new URLSearchParams();
params.set("grant_type", "password");
params.set("client_id", KC_CLIENT_ID);
params.set("username", u);
params.set("password", pass);
try {
const res = await fetch(
`${KC_BASE}/realms/${KC_REALM}/protocol/openid-connect/token`,
{
@ -82,72 +71,96 @@ export function AuthProvider({ children }) {
if (!res.ok) {
const text = await res.text();
console.error("[AUTH] login failed", res.status, text);
setLastError("Invalid credentials or auth error.");
let msg = "Invalid credentials or auth error.";
try {
const j = JSON.parse(text);
if (j.error_description) {
msg = j.error_description;
} else if (j.error) {
msg = j.error;
}
} catch (e) {
// ignore parse error
}
setLastError(`${res.status} ${msg}`);
setAuthenticated(false);
setToken(null);
setUsername("");
try {
localStorage.removeItem(TOKEN_KEY);
} catch {}
localStorage.removeItem(USERNAME_KEY);
} catch (e) {
// ignore
}
return;
}
const data = await res.json();
const accessToken = data.access_token;
if (!accessToken) {
setLastError("No access token returned.");
setLastError("No access token in response.");
setAuthenticated(false);
setToken(null);
setUsername("");
return;
}
const payload = parseJwt(accessToken);
const name =
payload.preferred_username ||
payload.email ||
payload.name ||
(payload.sub ? String(payload.sub) : "");
setToken(accessToken);
setAuthenticated(true);
setUsername(name || "");
setUsername(u);
setLastError("");
try {
localStorage.setItem(TOKEN_KEY, JSON.stringify(data));
localStorage.setItem(TOKEN_KEY, accessToken);
localStorage.setItem(USERNAME_KEY, u);
} catch (e) {
console.warn("[AUTH] save token error", e);
console.warn("[AUTH] failed to persist token", e);
}
} catch (err) {
console.error("[AUTH] login error", err);
setLastError("Network/auth error.");
} catch (e) {
console.error("[AUTH] network error", e);
setLastError("Network error during login.");
setAuthenticated(false);
setToken(null);
setUsername("");
} finally {
setLoading(false);
try {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USERNAME_KEY);
} catch (e2) {
// ignore
}
}
}
function logout() {
setToken(null);
setAuthenticated(false);
setToken(null);
setUsername("");
setLastError("");
try {
localStorage.removeItem(TOKEN_KEY);
} catch {}
localStorage.removeItem(USERNAME_KEY);
} catch (e) {
// ignore
}
}
const value = {
loading,
authenticated,
username,
token,
lastError,
login,
logout,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
return (
<AuthContext.Provider
value={{
loading,
authenticated,
username,
token,
login,
logout,
lastError,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {

View File

@ -18,13 +18,16 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
lastError,
} = useAuth();
const [mode, setMode] = useState("login"); // "login" | "register"
const [showAuth, setShowAuth] = useState(false);
const [mode, setMode] = useState("login"); // "login" | "signup"
// login form
const [user, setUser] = useState("");
const [pass, setPass] = useState("");
// login
const [loginUser, setLoginUser] = useState("");
const [loginPass, setLoginPass] = useState("");
// signup form
// signup
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [regUser, setRegUser] = useState("");
const [regEmail, setRegEmail] = useState("");
const [regPass, setRegPass] = useState("");
@ -35,16 +38,28 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
if (loading) authLabel = "AUTH: loading…";
else if (authenticated) authLabel = `AUTH: user=${username || "?"}`;
const handleLoginSubmit = (e) => {
e.preventDefault();
login(user.trim(), pass);
const openModal = (nextMode) => {
setMode(nextMode);
setShowAuth(true);
setSignupError("");
};
const handleRegisterSubmit = async (e) => {
const closeModal = () => {
setShowAuth(false);
};
const handleLoginSubmit = async (e) => {
e.preventDefault();
await login(loginUser, loginPass);
// on laisse l'utilisateur fermer manuellement,
// il voit en haut si ça a marché ou pas.
};
const handleSignupSubmit = async (e) => {
e.preventDefault();
setSignupError("");
if (!regUser.trim() || !regEmail.trim() || !regPass) {
setSignupError("All fields required.");
if (!firstName.trim() || !lastName.trim() || !regUser.trim() || !regEmail.trim() || !regPass) {
setSignupError("Nom, prénom, user, email et mot de passe sont requis.");
return;
}
try {
@ -53,9 +68,13 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
username: regUser.trim(),
email: regEmail.trim(),
password: regPass,
firstName: firstName.trim(),
lastName: lastName.trim(),
});
// auto login après création
login(regUser.trim(), regPass);
// après création, on tente le login direct
await login(regUser.trim(), regPass);
// on ferme le popup, l'état auth s'affiche dans la topbar
setShowAuth(false);
} catch (err) {
console.error(err);
setSignupError(err.message || "Register error.");
@ -65,215 +84,192 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
};
return (
<header className="topbar">
<div className="logo-circle">SW</div>
<div className="title-block">
<div className="main-title">SOCIOWIRE.com</div>
<div className="sub-title">Wired to life</div>
<div style={{ fontSize: "0.65rem", opacity: 0.7 }}>
{authLabel}
{lastError && (
<span style={{ color: "#f97373", marginLeft: "0.3rem" }}>
({lastError})
</span>
)}
{signupError && (
<span style={{ color: "#f97373", marginLeft: "0.3rem" }}>
({signupError})
</span>
)}
</div>
</div>
<div className="topbar-right">
<div className="theme-switch">
{["dark", "blue", "light"].map((t) => (
<button
key={t}
type="button"
className={
"theme-dot" + (theme === t ? " theme-dot-active" : "")
}
title={THEME_LABELS[t]}
onClick={() => onChangeTheme && onChangeTheme(t)}
>
{THEME_LABELS[t][0]}
</button>
))}
</div>
{authenticated ? (
<div style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
<span style={{ fontSize: "0.8rem" }}>{username}</span>
<button className="btn-primary" onClick={logout}>
Logout
</button>
<>
<header className="topbar">
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<div className="logo-circle">SW</div>
<div className="title-block">
<div className="main-title">SOCIOWIRE.com</div>
<div className="sub-title">Wired to life</div>
<div style={{ fontSize: "0.65rem", opacity: 0.7 }}>
{authLabel}
{lastError && (
<span style={{ color: "#f97373", marginLeft: "0.3rem" }}>
({lastError})
</span>
)}
</div>
</div>
) : (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
gap: "0.25rem",
minWidth: "230px",
}}
>
<div
style={{
display: "flex",
gap: "0.25rem",
fontSize: "0.65rem",
}}
>
</div>
<div className="topbar-right">
<div className="theme-switch">
{["dark", "blue", "light"].map((t) => (
<button
key={t}
type="button"
className={
"theme-dot" + (theme === t ? " theme-dot-active" : "")
}
title={THEME_LABELS[t]}
onClick={() => onChangeTheme && onChangeTheme(t)}
>
{THEME_LABELS[t][0]}
</button>
))}
</div>
{authenticated ? (
<div style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
<span style={{ fontSize: "0.8rem" }}>{username}</span>
<button className="btn-primary" onClick={logout}>
Logout
</button>
</div>
) : (
<div style={{ display: "flex", gap: "0.3rem" }}>
<button
type="button"
onClick={() => setMode("login")}
style={{
padding: "0.15rem 0.45rem",
borderRadius: 999,
border: "1px solid #4b5563",
background:
mode === "login" ? "#1e293b" : "transparent",
color: "#e5e7eb",
cursor: "pointer",
fontSize: "0.65rem",
}}
className="btn-primary"
onClick={() => openModal("login")}
>
Login
</button>
<button
type="button"
onClick={() => setMode("register")}
style={{
padding: "0.15rem 0.45rem",
borderRadius: 999,
border: "1px solid #4b5563",
background:
mode === "register" ? "#1e293b" : "transparent",
color: "#e5e7eb",
cursor: "pointer",
fontSize: "0.65rem",
}}
className="btn-secondary"
onClick={() => openModal("signup")}
>
Sign up
</button>
</div>
)}
</div>
</header>
{showAuth && (
<div className="auth-modal-backdrop" onClick={closeModal}>
<div
className="auth-modal"
onClick={(e) => e.stopPropagation()}
>
<div className="auth-modal-header">
<button
type="button"
className={
"auth-tab" + (mode === "login" ? " auth-tab-active" : "")
}
onClick={() => setMode("login")}
>
Login
</button>
<button
type="button"
className={
"auth-tab" + (mode === "signup" ? " auth-tab-active" : "")
}
onClick={() => setMode("signup")}
>
Sign up
</button>
<button
type="button"
className="auth-close"
onClick={closeModal}
>
</button>
</div>
{mode === "login" ? (
<form
onSubmit={handleLoginSubmit}
style={{
display: "flex",
alignItems: "center",
gap: "0.25rem",
}}
>
<input
type="text"
placeholder="user"
value={user}
onChange={(e) => setUser(e.target.value)}
style={{
fontSize: "0.7rem",
padding: "0.15rem 0.4rem",
borderRadius: 999,
border: "1px solid #4b5563",
background: "#020617",
color: "#e5e7eb",
}}
/>
<input
type="password"
placeholder="pass"
value={pass}
onChange={(e) => setPass(e.target.value)}
style={{
fontSize: "0.7rem",
padding: "0.15rem 0.4rem",
borderRadius: 999,
border: "1px solid #4b5563",
background: "#020617",
color: "#e5e7eb",
}}
/>
<form className="auth-form" onSubmit={handleLoginSubmit}>
<label>
Nom d&apos;utilisateur
<input
type="text"
value={loginUser}
onChange={(e) => setLoginUser(e.target.value)}
autoComplete="username"
/>
</label>
<label>
Mot de passe
<input
type="password"
value={loginPass}
onChange={(e) => setLoginPass(e.target.value)}
autoComplete="current-password"
/>
</label>
<button
className="btn-primary"
type="submit"
className="btn-primary btn-full"
disabled={loading}
style={{ fontSize: "0.7rem" }}
>
{loading ? "..." : "Login"}
{loading ? "..." : "Se connecter"}
</button>
</form>
) : (
<form
onSubmit={handleRegisterSubmit}
style={{
display: "flex",
alignItems: "center",
gap: "0.25rem",
}}
>
<input
type="text"
placeholder="user"
value={regUser}
onChange={(e) => setRegUser(e.target.value)}
style={{
fontSize: "0.7rem",
padding: "0.15rem 0.4rem",
borderRadius: 999,
border: "1px solid #4b5563",
background: "#020617",
color: "#e5e7eb",
minWidth: "70px",
}}
/>
<input
type="email"
placeholder="email"
value={regEmail}
onChange={(e) => setRegEmail(e.target.value)}
style={{
fontSize: "0.7rem",
padding: "0.15rem 0.4rem",
borderRadius: 999,
border: "1px solid #4b5563",
background: "#020617",
color: "#e5e7eb",
minWidth: "90px",
}}
/>
<input
type="password"
placeholder="pass"
value={regPass}
onChange={(e) => setRegPass(e.target.value)}
style={{
fontSize: "0.7rem",
padding: "0.15rem 0.4rem",
borderRadius: 999,
border: "1px solid #4b5563",
background: "#020617",
color: "#e5e7eb",
minWidth: "70px",
}}
/>
<form className="auth-form" onSubmit={handleSignupSubmit}>
<div className="auth-row">
<label>
Prénom
<input
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
/>
</label>
<label>
Nom
<input
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
/>
</label>
</div>
<label>
Nom d&apos;utilisateur
<input
type="text"
value={regUser}
onChange={(e) => setRegUser(e.target.value)}
/>
</label>
<label>
Courriel
<input
type="email"
value={regEmail}
onChange={(e) => setRegEmail(e.target.value)}
/>
</label>
<label>
Mot de passe
<input
type="password"
value={regPass}
onChange={(e) => setRegPass(e.target.value)}
/>
</label>
{signupError && (
<div className="auth-error">{signupError}</div>
)}
<button
className="btn-primary"
type="submit"
className="btn-primary btn-full"
disabled={signupLoading}
style={{ fontSize: "0.7rem" }}
>
{signupLoading ? "..." : "Create"}
{signupLoading ? "..." : "Créer le compte"}
</button>
</form>
)}
</div>
)}
</div>
</header>
</div>
)}
</>
);
}

View File

@ -10,14 +10,16 @@ export function useUserPosition(mapRef, hasLastView) {
if (!map) return;
let cancelled = false;
let hasPos = false;
function saveUserPos(lat, lon) {
if (cancelled) return;
hasPos = true;
const coords = [lon, lat];
setUserPosition(coords);
try {
localStorage.setItem(LAST_POS_KEY, JSON.stringify({ lat, lon }));
} catch {
} catch (e) {
// ignore
}
@ -32,27 +34,27 @@ export function useUserPosition(mapRef, hasLastView) {
}
}
// 1) dernière position user
// 1) dernière position user dans localStorage
try {
const raw = localStorage.getItem(LAST_POS_KEY);
if (raw) {
const p = JSON.parse(raw);
if (typeof p.lat === "number" && typeof p.lon === "number") {
hasPos = true;
setUserPosition([p.lon, p.lat]);
}
}
} catch {
} catch (e) {
// ignore
}
// 2) GPS → 3) IP backend
function viaIpFallback() {
(async () => {
const ipCoords = await fetchIpLocation();
if (!ipCoords || cancelled) return;
const [lon, lat] = ipCoords;
saveUserPos(lat, lon);
})();
// 2) GPS → 3) IP backend (uniquement si on n'a VRAIMENT rien)
async function viaIpFallback() {
if (cancelled || hasPos) return;
const ipCoords = await fetchIpLocation();
if (!ipCoords || cancelled) return;
const [lon, lat] = ipCoords;
saveUserPos(lat, lon);
}
if ("geolocation" in navigator) {
@ -62,7 +64,7 @@ export function useUserPosition(mapRef, hasLastView) {
saveUserPos(pos.coords.latitude, pos.coords.longitude);
},
() => {
if (cancelled) return;
if (cancelled || hasPos) return;
viaIpFallback();
},
{
@ -71,7 +73,8 @@ export function useUserPosition(mapRef, hasLastView) {
maximumAge: 30000,
}
);
} else {
} else if (!hasPos) {
// pas de geolocation et aucune position connue → IP fallback
viaIpFallback();
}

View File

@ -11,7 +11,8 @@ import "./styles/popup.css";
import "./styles/overlays.css";
import "./styles/posts.css";
import "./styles/theme.css";
// dans main.jsx, avec les autres styles
import "./styles/auth-modal.css";
import { AuthProvider } from "./auth/AuthContext.jsx";
ReactDOM.createRoot(document.getElementById("root")).render(

89
src/styles/auth-modal.css Normal file
View File

@ -0,0 +1,89 @@
.auth-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.auth-modal {
width: 100%;
max-width: 360px;
background: #020617;
border-radius: 16px;
border: 1px solid rgba(148, 163, 184, 0.9);
box-shadow:
0 18px 45px rgba(0, 0, 0, 0.9),
0 0 24px rgba(56, 189, 248, 0.5);
padding: 0.9rem 1rem 1rem 1rem;
color: #e5e7eb;
}
.auth-modal-header {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.7rem;
}
.auth-tab {
border-radius: 999px;
border: 1px solid #4b5563;
background: #020617;
color: #e5e7eb;
font-size: 0.75rem;
padding: 0.25rem 0.7rem;
cursor: pointer;
}
.auth-tab-active {
background: #1e293b;
border-color: #60a5fa;
}
.auth-close {
margin-left: auto;
border: none;
background: transparent;
color: #9ca3af;
cursor: pointer;
font-size: 0.9rem;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.auth-form label {
display: flex;
flex-direction: column;
font-size: 0.75rem;
gap: 0.15rem;
}
.auth-form input {
font-size: 0.8rem;
padding: 0.25rem 0.55rem;
border-radius: 999px;
border: 1px solid #4b5563;
background: #020617;
color: #e5e7eb;
}
.auth-row {
display: flex;
gap: 0.4rem;
}
.auth-row label {
flex: 1;
}
.auth-error {
font-size: 0.7rem;
color: #fecaca;
}

View File

@ -2,6 +2,7 @@
.topbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
@ -24,6 +25,7 @@
.title-block {
display: flex;
flex-direction: column;
min-width: 0;
}
.main-title {
@ -36,14 +38,16 @@
opacity: 0.7;
}
/* bloc de droite : thèmes + bouton login */
/* bloc de droite : thèmes + boutons */
.topbar-right {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
margin-left: auto;
}
/* petits boutons de thème (D / B / L) */
.theme-switch {
display: flex;
gap: 0.25rem;
@ -69,7 +73,6 @@
box-shadow: 0 0 10px rgba(250, 204, 21, 0.7);
}
/* bouton Login */
.btn-primary {
padding: 0.25rem 0.7rem;
font-size: 0.75rem;
@ -79,3 +82,25 @@
color: #f9fafb;
cursor: pointer;
}
.btn-secondary {
padding: 0.25rem 0.7rem;
font-size: 0.75rem;
border-radius: 999px;
border: 1px solid #60a5fa;
background: #1e293b;
color: #dbeafe;
cursor: pointer;
}
.btn-full {
width: 100%;
}
/* mobile */
@media (max-width: 480px) {
.topbar-right {
width: 100%;
justify-content: flex-start;
}
}