update frontend
This commit is contained in:
parent
40d55b478d
commit
0849f38629
|
|
@ -1,244 +0,0 @@
|
||||||
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { clearAuth, guessUsernameFromToken, jwtExpSeconds, loadAuth, saveAuth } from "./authStorage";
|
|
||||||
|
|
||||||
const AuthCtx = createContext(null);
|
|
||||||
|
|
||||||
function apiBase() {
|
|
||||||
// même domaine par défaut
|
|
||||||
return (import.meta?.env?.VITE_API_BASE_URL || "").replace(/\/+$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function apiFetch(path, opts = {}) {
|
|
||||||
const url = apiBase() + path;
|
|
||||||
const res = await fetch(url, {
|
|
||||||
...opts,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(opts.headers || {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function whoami(accessToken) {
|
|
||||||
const res = await apiFetch("/api/whoami", {
|
|
||||||
method: "GET",
|
|
||||||
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {},
|
|
||||||
});
|
|
||||||
const j = await res.json().catch(() => null);
|
|
||||||
return { ok: res.ok, status: res.status, json: j };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshTokens(refreshToken) {
|
|
||||||
const res = await apiFetch("/api/refresh", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
||||||
});
|
|
||||||
const j = await res.json().catch(() => null);
|
|
||||||
return { ok: res.ok, status: res.status, json: j };
|
|
||||||
}
|
|
||||||
|
|
||||||
function nowSec() {
|
|
||||||
return Math.floor(Date.now() / 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AuthProvider({ children }) {
|
|
||||||
const [status, setStatus] = useState("loading"); // loading | anon | auth
|
|
||||||
const [user, setUser] = useState(null); // { username }
|
|
||||||
const [tokens, setTokens] = useState(null); // { access_token, refresh_token, ... }
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const refreshTimer = useRef(null);
|
|
||||||
|
|
||||||
function setAnon(msg = "") {
|
|
||||||
setStatus("anon");
|
|
||||||
setUser(null);
|
|
||||||
setTokens(null);
|
|
||||||
setError(msg || "");
|
|
||||||
clearAuth();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setAuthFromTokens(t, usernameGuess = "") {
|
|
||||||
setTokens(t);
|
|
||||||
saveAuth({ ...t, obtained_at: Date.now() });
|
|
||||||
const u = usernameGuess || guessUsernameFromToken(t?.access_token) || "";
|
|
||||||
setUser(u ? { username: u } : null);
|
|
||||||
setStatus("auth");
|
|
||||||
setError("");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureFreshSession(existing) {
|
|
||||||
if (!existing?.access_token) return { ok: false, reason: "no_access_token" };
|
|
||||||
|
|
||||||
const exp = jwtExpSeconds(existing.access_token);
|
|
||||||
const skew = 60; // seconds
|
|
||||||
const isExpiredOrSoon = !exp || exp <= (nowSec() + skew);
|
|
||||||
|
|
||||||
// 1) refresh si nécessaire
|
|
||||||
if (isExpiredOrSoon) {
|
|
||||||
if (!existing.refresh_token) return { ok: false, reason: "expired_no_refresh" };
|
|
||||||
|
|
||||||
const rr = await refreshTokens(existing.refresh_token);
|
|
||||||
if (!rr.ok || !rr.json?.access_token) {
|
|
||||||
return { ok: false, reason: `refresh_failed_${rr.status}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = {
|
|
||||||
...existing,
|
|
||||||
...rr.json,
|
|
||||||
};
|
|
||||||
|
|
||||||
// certains serveurs renvoient pas refresh_token à chaque fois
|
|
||||||
if (!merged.refresh_token) merged.refresh_token = existing.refresh_token;
|
|
||||||
|
|
||||||
setAuthFromTokens(merged);
|
|
||||||
existing = merged;
|
|
||||||
} else {
|
|
||||||
// access token encore bon
|
|
||||||
setAuthFromTokens(existing);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) confirmer avec backend (whoami)
|
|
||||||
const w = await whoami(existing.access_token);
|
|
||||||
if (w.ok && w.json?.authenticated) {
|
|
||||||
const uname = w.json.username || guessUsernameFromToken(existing.access_token) || "";
|
|
||||||
setUser(uname ? { username: uname } : null);
|
|
||||||
setStatus("auth");
|
|
||||||
setError("");
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Si whoami fail, on considère désync (token invalide pour backend)
|
|
||||||
return { ok: false, reason: `whoami_${w.status}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleAutoRefresh(t) {
|
|
||||||
if (refreshTimer.current) {
|
|
||||||
clearInterval(refreshTimer.current);
|
|
||||||
refreshTimer.current = null;
|
|
||||||
}
|
|
||||||
// check aux 30s, refresh quand exp < 90s
|
|
||||||
refreshTimer.current = setInterval(async () => {
|
|
||||||
const cur = loadAuth();
|
|
||||||
if (!cur?.access_token) return;
|
|
||||||
|
|
||||||
const exp = jwtExpSeconds(cur.access_token);
|
|
||||||
if (!exp) return;
|
|
||||||
|
|
||||||
if (exp <= nowSec() + 90) {
|
|
||||||
if (!cur.refresh_token) {
|
|
||||||
setAnon("Session expirée (pas de refresh_token).");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const rr = await refreshTokens(cur.refresh_token);
|
|
||||||
if (!rr.ok || !rr.json?.access_token) {
|
|
||||||
setAnon("Session expirée. Please login again.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const merged = { ...cur, ...rr.json };
|
|
||||||
if (!merged.refresh_token) merged.refresh_token = cur.refresh_token;
|
|
||||||
setAuthFromTokens(merged);
|
|
||||||
}
|
|
||||||
}, 30000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function restore() {
|
|
||||||
setStatus("loading");
|
|
||||||
setError("");
|
|
||||||
const saved = loadAuth();
|
|
||||||
if (!saved?.access_token) {
|
|
||||||
setAnon("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const r = await ensureFreshSession(saved);
|
|
||||||
if (!r.ok) {
|
|
||||||
setAnon("Login desynced. Please login again.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleAutoRefresh(saved);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function login(username, password) {
|
|
||||||
setError("");
|
|
||||||
setStatus("loading");
|
|
||||||
|
|
||||||
const res = await apiFetch("/api/login", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ username, password }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const j = await res.json().catch(() => null);
|
|
||||||
if (!res.ok || !j?.access_token) {
|
|
||||||
setAnon(j?.error_description || j?.error || "Login failed");
|
|
||||||
return { ok: false, status: res.status, json: j };
|
|
||||||
}
|
|
||||||
|
|
||||||
const t = {
|
|
||||||
...j,
|
|
||||||
obtained_at: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
setAuthFromTokens(t);
|
|
||||||
scheduleAutoRefresh(t);
|
|
||||||
|
|
||||||
// confirm whoami (si ça fail -> desync)
|
|
||||||
const w = await whoami(t.access_token);
|
|
||||||
if (!w.ok || !w.json?.authenticated) {
|
|
||||||
setAnon("Login desynced. Please login again.");
|
|
||||||
return { ok: false, status: w.status, json: w.json };
|
|
||||||
}
|
|
||||||
|
|
||||||
const uname = w.json.username || guessUsernameFromToken(t.access_token) || "";
|
|
||||||
setUser(uname ? { username: uname } : null);
|
|
||||||
setStatus("auth");
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
function logout() {
|
|
||||||
if (refreshTimer.current) clearInterval(refreshTimer.current);
|
|
||||||
refreshTimer.current = null;
|
|
||||||
setAnon("");
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
restore();
|
|
||||||
|
|
||||||
const onStorage = (e) => {
|
|
||||||
if (e.key === "sociowire:auth") restore();
|
|
||||||
};
|
|
||||||
window.addEventListener("storage", onStorage);
|
|
||||||
return () => window.removeEventListener("storage", onStorage);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (status === "auth" && tokens?.access_token) {
|
|
||||||
scheduleAutoRefresh(tokens);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [status]);
|
|
||||||
|
|
||||||
const value = useMemo(() => {
|
|
||||||
return {
|
|
||||||
status,
|
|
||||||
user,
|
|
||||||
tokens,
|
|
||||||
error,
|
|
||||||
|
|
||||||
isAuthed: status === "auth",
|
|
||||||
accessToken: tokens?.access_token || "",
|
|
||||||
|
|
||||||
login,
|
|
||||||
logout,
|
|
||||||
restore,
|
|
||||||
setError,
|
|
||||||
};
|
|
||||||
}, [status, user, tokens, error]);
|
|
||||||
|
|
||||||
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuth() {
|
|
||||||
return useContext(AuthCtx);
|
|
||||||
}
|
|
||||||
|
|
@ -1,425 +0,0 @@
|
||||||
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
clearAuth,
|
|
||||||
guessUsernameFromToken,
|
|
||||||
jwtExpSeconds,
|
|
||||||
loadAuth,
|
|
||||||
saveAuth,
|
|
||||||
decodeJwtPayload,
|
|
||||||
} from "./authStorage";
|
|
||||||
import { parseAuthError } from "./authErrors";
|
|
||||||
|
|
||||||
const AuthCtx = createContext(null);
|
|
||||||
|
|
||||||
function apiBase() {
|
|
||||||
return (import.meta?.env?.VITE_API_BASE_URL || "").replace(/\/+$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function apiFetch(path, opts = {}) {
|
|
||||||
const url = apiBase() + path;
|
|
||||||
const res = await fetch(url, {
|
|
||||||
credentials: "include",
|
|
||||||
...opts,
|
|
||||||
headers: {
|
|
||||||
...(opts.headers || {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
function tokenEmailVerified(accessToken) {
|
|
||||||
try {
|
|
||||||
const p = decodeJwtPayload(accessToken);
|
|
||||||
if (!p) return null;
|
|
||||||
if (typeof p.email_verified === "boolean") return p.email_verified;
|
|
||||||
if (typeof p.emailVerified === "boolean") return p.emailVerified;
|
|
||||||
return null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// tolerant whoami: accepts {authenticated:true} OR {username:""} OR {user:""} OR just ok+token
|
|
||||||
async function whoami(accessToken) {
|
|
||||||
const res = await apiFetch("/api/whoami", {
|
|
||||||
method: "GET",
|
|
||||||
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
let json = null;
|
|
||||||
try {
|
|
||||||
json = text ? JSON.parse(text) : null;
|
|
||||||
} catch {
|
|
||||||
json = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uname =
|
|
||||||
(json && (json.username || json.user || json.preferred_username)) ||
|
|
||||||
guessUsernameFromToken(accessToken) ||
|
|
||||||
"";
|
|
||||||
|
|
||||||
const authed =
|
|
||||||
!!res.ok &&
|
|
||||||
(json?.authenticated === true ||
|
|
||||||
json?.ok === true ||
|
|
||||||
!!json?.username ||
|
|
||||||
!!json?.user ||
|
|
||||||
!!uname);
|
|
||||||
|
|
||||||
return { ok: !!res.ok, status: res.status, json, text, authenticated: authed, username: uname };
|
|
||||||
}
|
|
||||||
|
|
||||||
// NEW: /api/me is the source of truth for email_verified
|
|
||||||
async function me(accessToken) {
|
|
||||||
const res = await apiFetch("/api/me", {
|
|
||||||
method: "GET",
|
|
||||||
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
let json = null;
|
|
||||||
try {
|
|
||||||
json = text ? JSON.parse(text) : null;
|
|
||||||
} catch {
|
|
||||||
json = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try multiple shapes
|
|
||||||
const u = (json && (json.user || json.profile || json.me)) || json || null;
|
|
||||||
|
|
||||||
const emailVerified =
|
|
||||||
u && typeof u.email_verified === "boolean"
|
|
||||||
? u.email_verified
|
|
||||||
: u && typeof u.emailVerified === "boolean"
|
|
||||||
? u.emailVerified
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: !!res.ok,
|
|
||||||
status: res.status,
|
|
||||||
json,
|
|
||||||
text,
|
|
||||||
emailVerified,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshTokens(refreshToken) {
|
|
||||||
const res = await apiFetch("/api/refresh", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
let json = null;
|
|
||||||
try {
|
|
||||||
json = text ? JSON.parse(text) : null;
|
|
||||||
} catch {
|
|
||||||
json = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: res.ok, status: res.status, json, text };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resendVerification(accessToken) {
|
|
||||||
const res = await apiFetch("/api/resend-verification", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
let json = null;
|
|
||||||
try {
|
|
||||||
json = text ? JSON.parse(text) : null;
|
|
||||||
} catch {
|
|
||||||
json = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: res.ok, status: res.status, json, text };
|
|
||||||
}
|
|
||||||
|
|
||||||
function nowSec() {
|
|
||||||
return Math.floor(Date.now() / 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AuthProvider({ children }) {
|
|
||||||
const [status, setStatus] = useState("loading"); // loading | anon | auth
|
|
||||||
const [user, setUser] = useState(null); // { username }
|
|
||||||
const [tokens, setTokens] = useState(null); // { access_token, refresh_token, ... }
|
|
||||||
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [lastInfo, setLastInfo] = useState("");
|
|
||||||
const [needsEmailVerification, setNeedsEmailVerification] = useState(false);
|
|
||||||
|
|
||||||
const refreshTimer = useRef(null);
|
|
||||||
|
|
||||||
function setAnon(msg = "") {
|
|
||||||
setStatus("anon");
|
|
||||||
setUser(null);
|
|
||||||
setTokens(null);
|
|
||||||
setError(msg || "");
|
|
||||||
setLastInfo("");
|
|
||||||
setNeedsEmailVerification(false);
|
|
||||||
clearAuth();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setAuthFromTokens(t, usernameGuess = "") {
|
|
||||||
setTokens(t);
|
|
||||||
saveAuth({ ...t, obtained_at: Date.now() });
|
|
||||||
|
|
||||||
const u = usernameGuess || guessUsernameFromToken(t?.access_token) || "";
|
|
||||||
setUser(u ? { username: u } : null);
|
|
||||||
|
|
||||||
setStatus("auth");
|
|
||||||
setError("");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshProfile(accessTokenOverride) {
|
|
||||||
const at = accessTokenOverride || tokens?.access_token;
|
|
||||||
if (!at) return { ok: False };
|
|
||||||
|
|
||||||
// /api/me is the truth; token claim is fallback only
|
|
||||||
const mr = await me(at);
|
|
||||||
let verified = mr.emailVerified
|
|
||||||
|
|
||||||
if (typeof verified !== "boolean") {
|
|
||||||
const tv = tokenEmailVerified(at);
|
|
||||||
if (typeof tv === "boolean") verified = tv;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof verified === "boolean") {
|
|
||||||
setNeedsEmailVerification(!verified);
|
|
||||||
return { ok: true, verified };
|
|
||||||
}
|
|
||||||
|
|
||||||
// If cannot determine, do not block UI aggressively
|
|
||||||
setNeedsEmailVerification(false);
|
|
||||||
return { ok: mr.ok, verified: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureFreshSession(existing) {
|
|
||||||
if (!existing?.access_token) return { ok: false, reason: "no_access_token" };
|
|
||||||
|
|
||||||
const exp = jwtExpSeconds(existing.access_token);
|
|
||||||
const skew = 60;
|
|
||||||
const isExpiredOrSoon = !exp || exp <= nowSec() + skew;
|
|
||||||
|
|
||||||
if (isExpiredOrSoon) {
|
|
||||||
if (!existing.refresh_token) return { ok: false, reason: "expired_no_refresh" };
|
|
||||||
|
|
||||||
const rr = await refreshTokens(existing.refresh_token);
|
|
||||||
if (!rr.ok || !rr.json?.access_token) {
|
|
||||||
return { ok: false, reason: `refresh_failed_${rr.status}`, detail: rr.text };
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = { ...existing, ...rr.json };
|
|
||||||
if (!merged.refresh_token) merged.refresh_token = existing.refresh_token;
|
|
||||||
|
|
||||||
setAuthFromTokens(merged);
|
|
||||||
existing = merged;
|
|
||||||
} else {
|
|
||||||
setAuthFromTokens(existing);
|
|
||||||
}
|
|
||||||
|
|
||||||
const w = await whoami(existing.access_token);
|
|
||||||
if (w.ok && w.authenticated) {
|
|
||||||
setUser(w.username ? { username: w.username } : null);
|
|
||||||
setStatus("auth");
|
|
||||||
setError("");
|
|
||||||
|
|
||||||
// ✅ Update email verification state from backend
|
|
||||||
await refreshProfile(existing.access_token);
|
|
||||||
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: false, reason: `whoami_${w.status}`, detail: w.text || "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleAutoRefresh() {
|
|
||||||
if (refreshTimer.current) {
|
|
||||||
clearInterval(refreshTimer.current);
|
|
||||||
refreshTimer.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshTimer.current = setInterval(async () => {
|
|
||||||
const cur = loadAuth();
|
|
||||||
if (!cur?.access_token) return;
|
|
||||||
|
|
||||||
const exp = jwtExpSeconds(cur.access_token);
|
|
||||||
if (!exp) return;
|
|
||||||
|
|
||||||
if (exp <= nowSec() + 90) {
|
|
||||||
if (!cur.refresh_token) {
|
|
||||||
setAnon("Session expirée (pas de refresh_token).");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rr = await refreshTokens(cur.refresh_token);
|
|
||||||
if (!rr.ok || !rr.json?.access_token) {
|
|
||||||
setAnon("Session expirée. Please login again.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = { ...cur, ...rr.json };
|
|
||||||
if (!merged.refresh_token) merged.refresh_token = cur.refresh_token;
|
|
||||||
|
|
||||||
setAuthFromTokens(merged);
|
|
||||||
|
|
||||||
// keep verification fresh too
|
|
||||||
await refreshProfile(merged.access_token);
|
|
||||||
} else {
|
|
||||||
// lightweight: refresh verification occasionally without spamming
|
|
||||||
// (every 30s interval already runs; but this call is only parsing token unless needed)
|
|
||||||
const tv = tokenEmailVerified(cur.access_token);
|
|
||||||
if (typeof tv === "boolean") setNeedsEmailVerification(!tv);
|
|
||||||
}
|
|
||||||
}, 30000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function restore() {
|
|
||||||
setStatus("loading");
|
|
||||||
setError("");
|
|
||||||
setLastInfo("");
|
|
||||||
const saved = loadAuth();
|
|
||||||
if (!saved?.access_token) {
|
|
||||||
setAnon("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const r = await ensureFreshSession(saved);
|
|
||||||
if (!r.ok) {
|
|
||||||
setAnon("Login desynced. Please login again.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleAutoRefresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function login(username, password) {
|
|
||||||
setError("");
|
|
||||||
setLastInfo("");
|
|
||||||
setStatus("loading");
|
|
||||||
|
|
||||||
const res = await apiFetch("/api/login", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ username, password }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
let j = null;
|
|
||||||
try {
|
|
||||||
j = text ? JSON.parse(text) : null;
|
|
||||||
} catch {
|
|
||||||
j = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res.ok || !j?.access_token) {
|
|
||||||
const parsed = parseAuthError(text || "", res.status);
|
|
||||||
setNeedsEmailVerification(parsed.code === "email_verification_required");
|
|
||||||
setAnon(parsed.message || j?.error_description || j?.error || text || "Login failed");
|
|
||||||
return { ok: false, status: res.status, json: j, text };
|
|
||||||
}
|
|
||||||
|
|
||||||
const t = { ...j, obtained_at: Date.now() };
|
|
||||||
setAuthFromTokens(t);
|
|
||||||
scheduleAutoRefresh();
|
|
||||||
|
|
||||||
const w = await whoami(t.access_token);
|
|
||||||
if (!w.ok || !w.authenticated) {
|
|
||||||
setAnon("Login desynced. Please login again.");
|
|
||||||
return { ok: false, status: w.status, json: w.json, text: w.text };
|
|
||||||
}
|
|
||||||
|
|
||||||
setUser(w.username ? { username: w.username } : null);
|
|
||||||
setStatus("auth");
|
|
||||||
|
|
||||||
// ✅ update verified state from backend
|
|
||||||
await refreshProfile(t.access_token);
|
|
||||||
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
function logout() {
|
|
||||||
if (refreshTimer.current) clearInterval(refreshTimer.current);
|
|
||||||
refreshTimer.current = null;
|
|
||||||
setAnon("");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resendVerificationEmail() {
|
|
||||||
const at = tokens?.access_token || "";
|
|
||||||
if (!at) {
|
|
||||||
setError("Not logged in.");
|
|
||||||
return { ok: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
setError("");
|
|
||||||
setLastInfo("");
|
|
||||||
|
|
||||||
const r = await resendVerification(at);
|
|
||||||
if (!r.ok) {
|
|
||||||
setError(r.json?.error || r.json?.message || r.text || `Resend failed (${r.status})`);
|
|
||||||
return { ok: false, status: r.status };
|
|
||||||
}
|
|
||||||
|
|
||||||
setLastInfo("Verification email sent. Check inbox/spam, then refresh session (logout/login).");
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
restore();
|
|
||||||
const onStorage = (e) => {
|
|
||||||
if (e.key === "sociowire:auth") restore();
|
|
||||||
};
|
|
||||||
window.addEventListener("storage", onStorage);
|
|
||||||
return () => window.removeEventListener("storage", onStorage);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Backward-compatible fields expected by TopBar/MapView
|
|
||||||
const value = useMemo(() => {
|
|
||||||
const loading = status === "loading";
|
|
||||||
const authenticated = status === "auth";
|
|
||||||
const username = user?.username || "";
|
|
||||||
const token = tokens?.access_token || "";
|
|
||||||
|
|
||||||
return {
|
|
||||||
// internal
|
|
||||||
status,
|
|
||||||
user,
|
|
||||||
tokens,
|
|
||||||
error,
|
|
||||||
|
|
||||||
// expected by UI
|
|
||||||
loading,
|
|
||||||
authenticated,
|
|
||||||
username,
|
|
||||||
token,
|
|
||||||
|
|
||||||
lastError: error || "",
|
|
||||||
lastInfo: lastInfo || "",
|
|
||||||
needsEmailVerification: !!needsEmailVerification,
|
|
||||||
resendVerificationEmail,
|
|
||||||
|
|
||||||
login,
|
|
||||||
logout,
|
|
||||||
restore,
|
|
||||||
refreshProfile,
|
|
||||||
setError,
|
|
||||||
setLastInfo,
|
|
||||||
setNeedsEmailVerification,
|
|
||||||
};
|
|
||||||
}, [status, user, tokens, error, lastInfo, needsEmailVerification]);
|
|
||||||
|
|
||||||
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuth() {
|
|
||||||
return useContext(AuthCtx);
|
|
||||||
}
|
|
||||||
|
|
@ -1,287 +0,0 @@
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useAuth } from "../../auth/AuthContext";
|
|
||||||
import { registerUser } from "../../api/client";
|
|
||||||
import AuthToast from "../Auth/AuthToast";
|
|
||||||
|
|
||||||
const THEME_LABELS = { dark: "Dark", blue: "Blue", light: "Light" };
|
|
||||||
|
|
||||||
function initialLetter(name = "") {
|
|
||||||
const s = (name || "").trim();
|
|
||||||
return s ? s[0].toUpperCase() : "G";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TopBar({ theme = "dark", onChangeTheme }) {
|
|
||||||
const {
|
|
||||||
loading,
|
|
||||||
authenticated,
|
|
||||||
username,
|
|
||||||
login,
|
|
||||||
logout,
|
|
||||||
needsEmailVerification,
|
|
||||||
openKeycloak,
|
|
||||||
lastError,
|
|
||||||
lastErrorCode,
|
|
||||||
lastInfo,
|
|
||||||
clearError,
|
|
||||||
clearInfo,
|
|
||||||
} = useAuth();
|
|
||||||
|
|
||||||
const [showAuth, setShowAuth] = useState(false);
|
|
||||||
const [mode, setMode] = useState("login");
|
|
||||||
|
|
||||||
const [loginUser, setLoginUser] = useState("");
|
|
||||||
const [loginPass, setLoginPass] = useState("");
|
|
||||||
|
|
||||||
const [firstName, setFirstName] = useState("");
|
|
||||||
const [lastName, setLastName] = useState("");
|
|
||||||
const [regUser, setRegUser] = useState("");
|
|
||||||
const [regEmail, setRegEmail] = useState("");
|
|
||||||
const [regPass, setRegPass] = useState("");
|
|
||||||
const [signupError, setSignupError] = useState("");
|
|
||||||
const [signupInfo, setSignupInfo] = useState("");
|
|
||||||
const [signupLoading, setSignupLoading] = useState(false);
|
|
||||||
|
|
||||||
const openModal = (nextMode) => {
|
|
||||||
setMode(nextMode);
|
|
||||||
setShowAuth(true);
|
|
||||||
setSignupError("");
|
|
||||||
setSignupInfo("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
setShowAuth(false);
|
|
||||||
setLoginPass("");
|
|
||||||
setSignupError("");
|
|
||||||
setSignupInfo("");
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (authenticated) setShowAuth(false);
|
|
||||||
}, [authenticated]);
|
|
||||||
|
|
||||||
const handleLoginSubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
await login(loginUser, loginPass);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSignupSubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setSignupError("");
|
|
||||||
setSignupInfo("");
|
|
||||||
|
|
||||||
if (!firstName.trim() || !lastName.trim() || !regUser.trim() || !regEmail.trim() || !regPass) {
|
|
||||||
setSignupError("First name, last name, username, email and password are required.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setSignupLoading(true);
|
|
||||||
|
|
||||||
await registerUser({
|
|
||||||
username: regUser.trim(),
|
|
||||||
email: regEmail.trim(),
|
|
||||||
password: regPass,
|
|
||||||
firstName: firstName.trim(),
|
|
||||||
lastName: lastName.trim(),
|
|
||||||
});
|
|
||||||
|
|
||||||
setSignupInfo("Account created. Check your inbox (spam) to verify your email, then login.");
|
|
||||||
setMode("login");
|
|
||||||
setLoginUser(regUser.trim());
|
|
||||||
setLoginPass("");
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
setSignupError(err.message || "Registration error.");
|
|
||||||
} finally {
|
|
||||||
setSignupLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toast = useMemo(() => {
|
|
||||||
if (lastError) {
|
|
||||||
const t = needsEmailVerification ? "warn" : "error";
|
|
||||||
const title = needsEmailVerification ? "Vérification email requise" : "Connexion impossible";
|
|
||||||
return { type: t, title, message: lastError };
|
|
||||||
}
|
|
||||||
if (lastInfo) {
|
|
||||||
return { type: "success", title: "", message: lastInfo };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [lastError, lastInfo, needsEmailVerification]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Toast area (under topbar) */}
|
|
||||||
{(toast?.message) && (
|
|
||||||
<div className="sw-toast-wrap">
|
|
||||||
<AuthToast
|
|
||||||
type={toast.type}
|
|
||||||
title={toast.title}
|
|
||||||
message={toast.message}
|
|
||||||
onClose={() => {
|
|
||||||
if (lastError) clearError();
|
|
||||||
if (lastInfo) clearInfo();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{needsEmailVerification && (
|
|
||||||
<div style={{ maxWidth: 980, margin: "10px auto 0", display: "flex", gap: 10, justifyContent: "center" }}>
|
|
||||||
<button className="btn-secondary" type="button" onClick={openKeycloak}>
|
|
||||||
Ouvrir Keycloak (Proceed)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<header className="topbar">
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "0.65rem" }}>
|
|
||||||
<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>
|
|
||||||
</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.45rem" }}>
|
|
||||||
<div className="sw-userchip" title={username}>
|
|
||||||
<div className="sw-avatar">
|
|
||||||
<span>{initialLetter(username)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="sw-usertext">
|
|
||||||
<div className="sw-userline">Connecté</div>
|
|
||||||
<div className="sw-userhandle">@{username || "user"}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button className="btn-primary" onClick={logout}>
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: "flex", gap: "0.35rem", alignItems: "center" }}>
|
|
||||||
<div className="sw-userchip" title="Guest">
|
|
||||||
<div className="sw-avatar">
|
|
||||||
<i className="fa-solid fa-user" />
|
|
||||||
</div>
|
|
||||||
<div className="sw-usertext">
|
|
||||||
<div className="sw-userline">{loading ? "Loading…" : "Guest"}</div>
|
|
||||||
<div className="sw-userhandle">anon</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="button" className="btn-primary" onClick={() => openModal("login")}>
|
|
||||||
Login
|
|
||||||
</button>
|
|
||||||
<button type="button" 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 className="auth-form" onSubmit={handleLoginSubmit}>
|
|
||||||
<label>
|
|
||||||
Username
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={loginUser}
|
|
||||||
onChange={(e) => setLoginUser(e.target.value)}
|
|
||||||
autoComplete="username"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Password
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={loginPass}
|
|
||||||
onChange={(e) => setLoginPass(e.target.value)}
|
|
||||||
autoComplete="current-password"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button type="submit" className="btn-primary btn-full" disabled={loading}>
|
|
||||||
{loading ? "..." : "Sign in"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<form className="auth-form" onSubmit={handleSignupSubmit}>
|
|
||||||
<div className="auth-row">
|
|
||||||
<label>
|
|
||||||
First name
|
|
||||||
<input type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Last name
|
|
||||||
<input type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label>
|
|
||||||
Username
|
|
||||||
<input type="text" value={regUser} onChange={(e) => setRegUser(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Email
|
|
||||||
<input type="email" value={regEmail} onChange={(e) => setRegEmail(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Password
|
|
||||||
<input type="password" value={regPass} onChange={(e) => setRegPass(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{signupError && <div className="auth-error">{signupError}</div>}
|
|
||||||
{signupInfo && (
|
|
||||||
<div className="auth-error" style={{ background: "rgba(34,197,94,0.14)", borderColor: "rgba(34,197,94,0.55)", color: "#d1fae5" }}>
|
|
||||||
{signupInfo}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button type="submit" className="btn-primary btn-full" disabled={signupLoading}>
|
|
||||||
{signupLoading ? "..." : "Create account"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,392 +0,0 @@
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useAuth } from "../../auth/AuthContext";
|
|
||||||
import { registerUser } from "../../api/client";
|
|
||||||
import AuthToast from "../Auth/AuthToast";
|
|
||||||
|
|
||||||
const THEME_LABELS = { dark: "Dark", blue: "Blue", light: "Light" };
|
|
||||||
const LAST_EMAIL_KEY = "sociowire:lastEmail";
|
|
||||||
|
|
||||||
function initialLetter(name = "") {
|
|
||||||
const s = (name || "").trim();
|
|
||||||
return s ? s[0].toUpperCase() : "G";
|
|
||||||
}
|
|
||||||
|
|
||||||
function looksLikeEmail(s = "") {
|
|
||||||
return typeof s === "string" && /.+@.+\..+/.test(s.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TopBar({ theme = "dark", onChangeTheme }) {
|
|
||||||
const {
|
|
||||||
loading,
|
|
||||||
authenticated,
|
|
||||||
username,
|
|
||||||
login,
|
|
||||||
logout,
|
|
||||||
needsEmailVerification,
|
|
||||||
resendVerificationEmail,
|
|
||||||
lastError,
|
|
||||||
lastInfo,
|
|
||||||
setError,
|
|
||||||
setLastInfo,
|
|
||||||
} = useAuth();
|
|
||||||
|
|
||||||
const clearError = () => setError && setError("");
|
|
||||||
const clearInfo = () => setLastInfo && setLastInfo("");
|
|
||||||
|
|
||||||
const [showAuth, setShowAuth] = useState(false);
|
|
||||||
const [mode, setMode] = useState("login");
|
|
||||||
|
|
||||||
const [loginUser, setLoginUser] = useState("");
|
|
||||||
const [loginPass, setLoginPass] = useState("");
|
|
||||||
|
|
||||||
const [firstName, setFirstName] = useState("");
|
|
||||||
const [lastName, setLastName] = useState("");
|
|
||||||
const [regUser, setRegUser] = useState("");
|
|
||||||
const [regEmail, setRegEmail] = useState("");
|
|
||||||
const [regPass, setRegPass] = useState("");
|
|
||||||
const [signupError, setSignupError] = useState("");
|
|
||||||
const [signupInfo, setSignupInfo] = useState("");
|
|
||||||
const [signupLoading, setSignupLoading] = useState(false);
|
|
||||||
|
|
||||||
const openModal = (nextMode) => {
|
|
||||||
setMode(nextMode);
|
|
||||||
setShowAuth(true);
|
|
||||||
setSignupError("");
|
|
||||||
setSignupInfo("");
|
|
||||||
|
|
||||||
// Prefill login with last known email (nice for email-verified return)
|
|
||||||
if (nextMode === "login") {
|
|
||||||
try {
|
|
||||||
const last = localStorage.getItem(LAST_EMAIL_KEY) || "";
|
|
||||||
if (!loginUser.trim() && last.trim()) setLoginUser(last.trim());
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
setShowAuth(false);
|
|
||||||
setLoginPass("");
|
|
||||||
setSignupError("");
|
|
||||||
setSignupInfo("");
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (authenticated) setShowAuth(false);
|
|
||||||
}, [authenticated]);
|
|
||||||
|
|
||||||
/* SW_URL_NOTICE:BEGIN */
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
const qs = new URLSearchParams(window.location.search || "");
|
|
||||||
const verified = qs.get("verified") || qs.get("email_verified");
|
|
||||||
const signup = qs.get("signup");
|
|
||||||
const notice = qs.get("notice");
|
|
||||||
const email = (qs.get("email") || qs.get("user") || qs.get("username") || "").trim();
|
|
||||||
|
|
||||||
if (email) {
|
|
||||||
try { localStorage.setItem(LAST_EMAIL_KEY, email); } catch {}
|
|
||||||
// If user uses email to login, prefill it
|
|
||||||
if (!loginUser.trim()) setLoginUser(email);
|
|
||||||
if (!regEmail.trim()) setRegEmail(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (verified === "1" || verified === "true") {
|
|
||||||
setSignupInfo(`✅ Email confirmé${email ? ` (${email})` : ""}. Tu peux te connecter.`);
|
|
||||||
setMode("login");
|
|
||||||
setShowAuth(true);
|
|
||||||
} else if (signup === "1" || signup === "true") {
|
|
||||||
setSignupInfo("✅ Compte créé. Vérifie tes emails (et le spam), puis connecte-toi.");
|
|
||||||
setMode("login");
|
|
||||||
setShowAuth(true);
|
|
||||||
} else if (notice) {
|
|
||||||
const msg = decodeURIComponent(notice);
|
|
||||||
if (msg && msg.trim()) {
|
|
||||||
setSignupInfo(msg.trim());
|
|
||||||
setMode("login");
|
|
||||||
setShowAuth(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// clean URL (remove params without reload)
|
|
||||||
if (verified || signup || notice || email) {
|
|
||||||
const clean = window.location.pathname + (window.location.hash || "");
|
|
||||||
window.history.replaceState({}, "", clean);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
/* SW_URL_NOTICE:END */
|
|
||||||
|
|
||||||
const handleLoginSubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
// store email-like login for next time
|
|
||||||
try {
|
|
||||||
if (looksLikeEmail(loginUser)) localStorage.setItem(LAST_EMAIL_KEY, loginUser.trim());
|
|
||||||
} catch {}
|
|
||||||
await login(loginUser, loginPass);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSignupSubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setSignupError("");
|
|
||||||
setSignupInfo("");
|
|
||||||
|
|
||||||
if (!firstName.trim() || !lastName.trim() || !regUser.trim() || !regEmail.trim() || !regPass) {
|
|
||||||
setSignupError("First name, last name, username, email and password are required.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setSignupLoading(true);
|
|
||||||
|
|
||||||
await registerUser({
|
|
||||||
username: regUser.trim(),
|
|
||||||
email: regEmail.trim(),
|
|
||||||
password: regPass,
|
|
||||||
firstName: firstName.trim(),
|
|
||||||
lastName: lastName.trim(),
|
|
||||||
});
|
|
||||||
|
|
||||||
try { localStorage.setItem(LAST_EMAIL_KEY, regEmail.trim()); } catch {}
|
|
||||||
|
|
||||||
setSignupInfo("✅ Account created. Check your inbox (spam) to verify your email, then login.");
|
|
||||||
setMode("login");
|
|
||||||
|
|
||||||
// Some people login with email, some with username => prefer email if present
|
|
||||||
setLoginUser((regEmail.trim() || regUser.trim()));
|
|
||||||
setLoginPass("");
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
setSignupError(err.message || "Registration error.");
|
|
||||||
} finally {
|
|
||||||
setSignupLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toast = useMemo(() => {
|
|
||||||
if (lastError) {
|
|
||||||
const t = needsEmailVerification ? "warn" : "error";
|
|
||||||
const title = needsEmailVerification ? "Vérification email requise" : "Connexion impossible";
|
|
||||||
return { type: t, title, message: lastError };
|
|
||||||
}
|
|
||||||
if (lastInfo) return { type: "success", title: "", message: lastInfo };
|
|
||||||
return null;
|
|
||||||
}, [lastError, lastInfo, needsEmailVerification]);
|
|
||||||
|
|
||||||
const authComboLabel = authenticated ? "Log off" : (loading ? "..." : "Log on");
|
|
||||||
const authLine = authenticated ? "Online" : "Guest";
|
|
||||||
const authHandle = authenticated ? `@${username || "user"}` : "anon";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Toast area (under topbar) */}
|
|
||||||
{(toast?.message) && (
|
|
||||||
<div className="sw-toast-wrap">
|
|
||||||
<AuthToast
|
|
||||||
type={toast.type}
|
|
||||||
title={toast.title}
|
|
||||||
message={toast.message}
|
|
||||||
onClose={() => {
|
|
||||||
if (lastError) clearError();
|
|
||||||
if (lastInfo) clearInfo();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{needsEmailVerification && (
|
|
||||||
<div className="sw-toast-subactions">
|
|
||||||
<button
|
|
||||||
className="btn-secondary"
|
|
||||||
type="button"
|
|
||||||
onClick={() => openModal("login")}
|
|
||||||
>
|
|
||||||
Ouvrir Login
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="btn-secondary"
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
// Works only if already authenticated (token present); otherwise just tells user what to do.
|
|
||||||
if (authenticated) resendVerificationEmail && resendVerificationEmail();
|
|
||||||
else setLastInfo && setLastInfo("Va voir ta boîte email (et le spam) et clique le lien de confirmation, puis reviens ici.");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Resend email
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<header className="topbar">
|
|
||||||
<div className="topbar-left">
|
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* ONE unified auth button (guest online / log on / log off) */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={"sw-auth-combo" + (authenticated ? " sw-auth-combo--on" : " sw-auth-combo--off")}
|
|
||||||
onClick={() => {
|
|
||||||
if (authenticated) logout();
|
|
||||||
else openModal("login");
|
|
||||||
}}
|
|
||||||
title={authenticated ? "Click to log off" : "Click to log on"}
|
|
||||||
>
|
|
||||||
<div className="sw-avatar">
|
|
||||||
{authenticated ? <span>{initialLetter(username)}</span> : <i className="fa-solid fa-user" />}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="sw-usertext">
|
|
||||||
<div className="sw-userline">{authLine}</div>
|
|
||||||
<div className="sw-userhandle">{authHandle}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="sw-auth-action">{authComboLabel}</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{!authenticated && (
|
|
||||||
<button type="button" className="btn-secondary" onClick={() => openModal("signup")}>
|
|
||||||
Sign up
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* Nice notices (modal) */}
|
|
||||||
{signupInfo ? <div className="auth-notice auth-notice-success">{signupInfo}</div> : null}
|
|
||||||
|
|
||||||
{mode === "login" && needsEmailVerification ? (
|
|
||||||
<div className="auth-notice auth-notice-warn">
|
|
||||||
⚠️ Ton compte doit confirmer l’email avant de se connecter. Vérifie ta boîte mail (et le spam),
|
|
||||||
clique le lien, puis reviens ici.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{mode === "login" && lastError ? (
|
|
||||||
<div className="auth-notice auth-notice-error">{lastError}</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{mode === "login" && lastInfo ? (
|
|
||||||
<div className="auth-notice auth-notice-success">{lastInfo}</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{mode === "login" ? (
|
|
||||||
<form className="auth-form" onSubmit={handleLoginSubmit}>
|
|
||||||
<label>
|
|
||||||
Username / Email
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={loginUser}
|
|
||||||
onChange={(e) => setLoginUser(e.target.value)}
|
|
||||||
autoComplete="username"
|
|
||||||
inputMode={looksLikeEmail(loginUser) ? "email" : "text"}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
Password
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={loginPass}
|
|
||||||
onChange={(e) => setLoginPass(e.target.value)}
|
|
||||||
autoComplete="current-password"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<button type="submit" className="btn-primary btn-full" disabled={loading}>
|
|
||||||
{loading ? "..." : "Sign in"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<form className="auth-form" onSubmit={handleSignupSubmit}>
|
|
||||||
<div className="auth-row">
|
|
||||||
<label>
|
|
||||||
First name
|
|
||||||
<input type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Last name
|
|
||||||
<input type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
Username
|
|
||||||
<input type="text" value={regUser} onChange={(e) => setRegUser(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
Email
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={regEmail}
|
|
||||||
onChange={(e) => setRegEmail(e.target.value)}
|
|
||||||
autoComplete="email"
|
|
||||||
inputMode="email"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
Password
|
|
||||||
<input type="password" value={regPass} onChange={(e) => setRegPass(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{signupError ? <div className="auth-notice auth-notice-error">{signupError}</div> : null}
|
|
||||||
{signupInfo ? <div className="auth-notice auth-notice-success">{signupInfo}</div> : null}
|
|
||||||
|
|
||||||
<button type="submit" className="btn-primary btn-full" disabled={signupLoading}>
|
|
||||||
{signupLoading ? "..." : "Create account"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,275 +0,0 @@
|
||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { useAuth } from "../../auth/AuthContext";
|
|
||||||
import { registerUser } from "../../api/client";
|
|
||||||
|
|
||||||
const THEME_LABELS = { dark: "Dark", blue: "Blue", light: "Light" };
|
|
||||||
|
|
||||||
export default function TopBar({ theme = "dark", onChangeTheme }) {
|
|
||||||
const {
|
|
||||||
loading,
|
|
||||||
authenticated,
|
|
||||||
username,
|
|
||||||
login,
|
|
||||||
logout,
|
|
||||||
needsEmailVerification,
|
|
||||||
resendVerificationEmail,
|
|
||||||
lastError,
|
|
||||||
lastInfo,
|
|
||||||
} = useAuth();
|
|
||||||
|
|
||||||
const [showAuth, setShowAuth] = useState(false);
|
|
||||||
const [mode, setMode] = useState("login");
|
|
||||||
|
|
||||||
const [loginUser, setLoginUser] = useState("");
|
|
||||||
const [loginPass, setLoginPass] = useState("");
|
|
||||||
|
|
||||||
const [firstName, setFirstName] = useState("");
|
|
||||||
const [lastName, setLastName] = useState("");
|
|
||||||
const [regUser, setRegUser] = useState("");
|
|
||||||
const [regEmail, setRegEmail] = useState("");
|
|
||||||
const [regPass, setRegPass] = useState("");
|
|
||||||
const [signupError, setSignupError] = useState("");
|
|
||||||
const [signupInfo, setSignupInfo] = useState("");
|
|
||||||
const [signupLoading, setSignupLoading] = useState(false);
|
|
||||||
|
|
||||||
let authLabel = "AUTH: anon";
|
|
||||||
if (loading) authLabel = "AUTH: loading…";
|
|
||||||
else if (authenticated) authLabel = `AUTH: user=${username || "?"}`;
|
|
||||||
|
|
||||||
const openModal = (nextMode) => {
|
|
||||||
setMode(nextMode);
|
|
||||||
setShowAuth(true);
|
|
||||||
setSignupError("");
|
|
||||||
setSignupInfo("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
setShowAuth(false);
|
|
||||||
setLoginPass("");
|
|
||||||
setSignupError("");
|
|
||||||
setSignupInfo("");
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (authenticated) setShowAuth(false);
|
|
||||||
}, [authenticated]);
|
|
||||||
|
|
||||||
const handleLoginSubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
await login(loginUser, loginPass);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSignupSubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setSignupError("");
|
|
||||||
setSignupInfo("");
|
|
||||||
|
|
||||||
if (!firstName.trim() || !lastName.trim() || !regUser.trim() || !regEmail.trim() || !regPass) {
|
|
||||||
setSignupError("First name, last name, username, email and password are required.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setSignupLoading(true);
|
|
||||||
|
|
||||||
await registerUser({
|
|
||||||
username: regUser.trim(),
|
|
||||||
email: regEmail.trim(),
|
|
||||||
password: regPass,
|
|
||||||
firstName: firstName.trim(),
|
|
||||||
lastName: lastName.trim(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// IMPORTANT: si Verify Email est activé côté Keycloak, on ne tente PAS de login auto.
|
|
||||||
setSignupInfo("Account created. Check your inbox (spam) to verify your email, then login.");
|
|
||||||
setMode("login");
|
|
||||||
setLoginUser(regUser.trim());
|
|
||||||
setLoginPass("");
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
setSignupError(err.message || "Registration error.");
|
|
||||||
} finally {
|
|
||||||
setSignupLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<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.8 }}>
|
|
||||||
{authLabel}
|
|
||||||
{lastError && (
|
|
||||||
<span style={{ color: "#f97373", marginLeft: "0.35rem" }}>
|
|
||||||
({lastError})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{authenticated && needsEmailVerification && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 6,
|
|
||||||
fontSize: "0.70rem",
|
|
||||||
background: "rgba(250,204,21,0.14)",
|
|
||||||
border: "1px solid rgba(250,204,21,0.55)",
|
|
||||||
padding: "6px 8px",
|
|
||||||
borderRadius: 12,
|
|
||||||
display: "flex",
|
|
||||||
gap: 8,
|
|
||||||
alignItems: "center",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontWeight: 800 }}>Verify email required.</span>
|
|
||||||
<span style={{ opacity: 0.9 }}>Posting is locked until verification.</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-secondary"
|
|
||||||
onClick={resendVerificationEmail}
|
|
||||||
style={{ padding: "0.30rem 0.70rem" }}
|
|
||||||
>
|
|
||||||
Resend email
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!needsEmailVerification && lastInfo && (
|
|
||||||
<div style={{ marginTop: 6, fontSize: "0.70rem", opacity: 0.85 }}>
|
|
||||||
{lastInfo}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: "flex", gap: "0.3rem" }}>
|
|
||||||
<button type="button" className="btn-primary" onClick={() => openModal("login")}>
|
|
||||||
Login
|
|
||||||
</button>
|
|
||||||
<button type="button" 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 className="auth-form" onSubmit={handleLoginSubmit}>
|
|
||||||
<label>
|
|
||||||
Username
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={loginUser}
|
|
||||||
onChange={(e) => setLoginUser(e.target.value)}
|
|
||||||
autoComplete="username"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Password
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={loginPass}
|
|
||||||
onChange={(e) => setLoginPass(e.target.value)}
|
|
||||||
autoComplete="current-password"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button type="submit" className="btn-primary btn-full" disabled={loading}>
|
|
||||||
{loading ? "..." : "Sign in"}
|
|
||||||
</button>
|
|
||||||
{lastError && <div className="auth-error" style={{ marginTop: "0.6rem" }}>{lastError}</div>}
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<form className="auth-form" onSubmit={handleSignupSubmit}>
|
|
||||||
<div className="auth-row">
|
|
||||||
<label>
|
|
||||||
First name
|
|
||||||
<input type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Last name
|
|
||||||
<input type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label>
|
|
||||||
Username
|
|
||||||
<input type="text" value={regUser} onChange={(e) => setRegUser(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Email
|
|
||||||
<input type="email" value={regEmail} onChange={(e) => setRegEmail(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Password
|
|
||||||
<input type="password" value={regPass} onChange={(e) => setRegPass(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{signupError && <div className="auth-error">{signupError}</div>}
|
|
||||||
{signupInfo && (
|
|
||||||
<div className="auth-error" style={{ background: "rgba(34,197,94,0.14)", borderColor: "rgba(34,197,94,0.55)", color: "#d1fae5" }}>
|
|
||||||
{signupInfo}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button type="submit" className="btn-primary btn-full" disabled={signupLoading}>
|
|
||||||
{signupLoading ? "..." : "Create account"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,390 +0,0 @@
|
||||||
import React, { useState, useEffect, useMemo } from "react";
|
|
||||||
import { useAuth } from "../../auth/AuthContext";
|
|
||||||
import { registerUser } from "../../api/client";
|
|
||||||
|
|
||||||
const THEME_LABELS = { dark: "Dark", blue: "Blue", light: "Light" };
|
|
||||||
const LAST_SIGNUP_USER_KEY = "sociowire:lastSignupUser";
|
|
||||||
|
|
||||||
function normalizeAuthMessage(raw) {
|
|
||||||
const msg = String(raw || "").trim();
|
|
||||||
if (!msg) return "";
|
|
||||||
|
|
||||||
const lower = msg.toLowerCase();
|
|
||||||
|
|
||||||
// Keycloak classic when email verification required
|
|
||||||
if (
|
|
||||||
lower.includes("not fully set up") ||
|
|
||||||
lower.includes("verify") ||
|
|
||||||
lower.includes("verification") ||
|
|
||||||
lower.includes("required action") ||
|
|
||||||
(lower.includes("email") && lower.includes("verified"))
|
|
||||||
) {
|
|
||||||
return "Tu dois confirmer ton email avant de te connecter. Vérifie ta boîte mail (et le spam), puis réessaie.";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bad credentials
|
|
||||||
if (
|
|
||||||
lower.includes("invalid user credentials") ||
|
|
||||||
lower.includes("bad credentials") ||
|
|
||||||
(lower.includes("invalid_grant") && !lower.includes("not fully set up"))
|
|
||||||
) {
|
|
||||||
return "Mauvais username ou mot de passe.";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback (shorten very long raw blobs)
|
|
||||||
if (msg.length > 240) return msg.slice(0, 240) + "…";
|
|
||||||
return msg;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TopBar({ theme = "dark", onChangeTheme }) {
|
|
||||||
const {
|
|
||||||
loading,
|
|
||||||
authenticated,
|
|
||||||
username,
|
|
||||||
login,
|
|
||||||
logout,
|
|
||||||
needsEmailVerification,
|
|
||||||
resendVerificationEmail,
|
|
||||||
lastError,
|
|
||||||
lastInfo,
|
|
||||||
} = useAuth();
|
|
||||||
|
|
||||||
const [showAuth, setShowAuth] = useState(false);
|
|
||||||
const [mode, setMode] = useState("login");
|
|
||||||
|
|
||||||
const [loginUser, setLoginUser] = useState("");
|
|
||||||
const [loginPass, setLoginPass] = useState("");
|
|
||||||
|
|
||||||
const [firstName, setFirstName] = useState("");
|
|
||||||
const [lastName, setLastName] = useState("");
|
|
||||||
const [regUser, setRegUser] = useState("");
|
|
||||||
const [regEmail, setRegEmail] = useState("");
|
|
||||||
const [regPass, setRegPass] = useState("");
|
|
||||||
const [signupError, setSignupError] = useState("");
|
|
||||||
const [signupInfo, setSignupInfo] = useState("");
|
|
||||||
const [signupLoading, setSignupLoading] = useState(false);
|
|
||||||
|
|
||||||
const isVerifiedPage =
|
|
||||||
typeof window !== "undefined" && window.location?.pathname === "/auth/verified";
|
|
||||||
|
|
||||||
const prettyError = useMemo(() => normalizeAuthMessage(lastError), [lastError]);
|
|
||||||
|
|
||||||
const openModal = (nextMode) => {
|
|
||||||
setMode(nextMode);
|
|
||||||
setShowAuth(true);
|
|
||||||
setSignupError("");
|
|
||||||
setSignupInfo("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
setShowAuth(false);
|
|
||||||
setLoginPass("");
|
|
||||||
setSignupError("");
|
|
||||||
setSignupInfo("");
|
|
||||||
};
|
|
||||||
|
|
||||||
// close modal automatically on auth
|
|
||||||
useEffect(() => {
|
|
||||||
if (authenticated) setShowAuth(false);
|
|
||||||
}, [authenticated]);
|
|
||||||
|
|
||||||
// ✅ If user lands on /auth/verified (after clicking email link),
|
|
||||||
// open login modal + prefill username from last signup.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isVerifiedPage) return;
|
|
||||||
|
|
||||||
setMode("login");
|
|
||||||
setShowAuth(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const lastU = localStorage.getItem(LAST_SIGNUP_USER_KEY) || "";
|
|
||||||
if (lastU && !loginUser) setLoginUser(lastU);
|
|
||||||
} catch {}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [isVerifiedPage]);
|
|
||||||
|
|
||||||
// ✅ After successful login, if still on /auth/verified, cleanly go back home
|
|
||||||
useEffect(() => {
|
|
||||||
if (!authenticated) return;
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
const path = window.location?.pathname || "";
|
|
||||||
if (path === "/auth/verified") {
|
|
||||||
try {
|
|
||||||
window.history.replaceState({}, "", "/");
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}, [authenticated]);
|
|
||||||
|
|
||||||
const handleLoginSubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
await login(loginUser, loginPass);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSignupSubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setSignupError("");
|
|
||||||
setSignupInfo("");
|
|
||||||
|
|
||||||
if (!firstName.trim() || !lastName.trim() || !regUser.trim() || !regEmail.trim() || !regPass) {
|
|
||||||
setSignupError("First name, last name, username, email and password are required.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setSignupLoading(true);
|
|
||||||
|
|
||||||
await registerUser({
|
|
||||||
username: regUser.trim(),
|
|
||||||
email: regEmail.trim(),
|
|
||||||
password: regPass,
|
|
||||||
firstName: firstName.trim(),
|
|
||||||
lastName: lastName.trim(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Store last signup username so /auth/verified can prefill login.
|
|
||||||
try {
|
|
||||||
localStorage.setItem(LAST_SIGNUP_USER_KEY, regUser.trim());
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
// IMPORTANT: si Verify Email est activé côté Keycloak, on ne tente PAS de login auto.
|
|
||||||
setSignupInfo("Compte créé ✅ Vérifie ton email (spam inclus), puis reviens te connecter.");
|
|
||||||
setMode("login");
|
|
||||||
setLoginUser(regUser.trim());
|
|
||||||
setLoginPass("");
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
setSignupError(err.message || "Registration error.");
|
|
||||||
} finally {
|
|
||||||
setSignupLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const AuthPill = () => {
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<span className="auth-pill auth-pill--loading">
|
|
||||||
<i className="fa-solid fa-spinner fa-spin" />
|
|
||||||
Loading…
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (authenticated) {
|
|
||||||
return (
|
|
||||||
<span className="auth-pill auth-pill--ok" title="Connecté">
|
|
||||||
<i className="fa-solid fa-circle-check" />
|
|
||||||
Connecté
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className="auth-pill auth-pill--anon" title="Invité">
|
|
||||||
<i className="fa-regular fa-user" />
|
|
||||||
Guest
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* ✅ Clean auth row */}
|
|
||||||
<div className="auth-strip">
|
|
||||||
<AuthPill />
|
|
||||||
{authenticated && username && (
|
|
||||||
<span className="auth-pill" title={username}>
|
|
||||||
<i className="fa-solid fa-at" />
|
|
||||||
{username}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ✅ Verified page hint */}
|
|
||||||
{isVerifiedPage && !authenticated && (
|
|
||||||
<div className="auth-banner auth-banner--success">
|
|
||||||
<span style={{ fontWeight: 900 }}>✅ Email confirmé.</span>
|
|
||||||
<span style={{ opacity: 0.92 }}>Connecte-toi pour continuer.</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Email verification banner (if you later wire it from backend) */}
|
|
||||||
{authenticated && needsEmailVerification && (
|
|
||||||
<div className="auth-banner auth-banner--info">
|
|
||||||
<span style={{ fontWeight: 900 }}>
|
|
||||||
<i className="fa-solid fa-triangle-exclamation" style={{ marginRight: 6 }} />
|
|
||||||
Verify email required.
|
|
||||||
</span>
|
|
||||||
<span style={{ opacity: 0.9 }}>Posting is locked until verification.</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-secondary"
|
|
||||||
onClick={resendVerificationEmail}
|
|
||||||
style={{ padding: "0.30rem 0.70rem" }}
|
|
||||||
>
|
|
||||||
Resend email
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ✅ Nice error banner */}
|
|
||||||
{prettyError && (
|
|
||||||
<div className="auth-banner auth-banner--error">
|
|
||||||
<i className="fa-solid fa-circle-xmark" />
|
|
||||||
<span>{prettyError}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Optional lastInfo from AuthContext */}
|
|
||||||
{!needsEmailVerification && lastInfo && (
|
|
||||||
<div className="auth-banner auth-banner--info">
|
|
||||||
<i className="fa-solid fa-circle-info" />
|
|
||||||
<span>{lastInfo}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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.45rem" }}>
|
|
||||||
<div className="user-chip" title={username || ""}>
|
|
||||||
<span className="user-avatar">
|
|
||||||
<i className="fa-solid fa-user-astronaut" />
|
|
||||||
</span>
|
|
||||||
<span className="user-name">{username || "user"}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button className="btn-primary" onClick={logout}>
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: "flex", gap: "0.3rem" }}>
|
|
||||||
<button type="button" className="btn-primary" onClick={() => openModal("login")}>
|
|
||||||
Login
|
|
||||||
</button>
|
|
||||||
<button type="button" 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 className="auth-form" onSubmit={handleLoginSubmit}>
|
|
||||||
<label>
|
|
||||||
Username
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={loginUser}
|
|
||||||
onChange={(e) => setLoginUser(e.target.value)}
|
|
||||||
autoComplete="username"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Password
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={loginPass}
|
|
||||||
onChange={(e) => setLoginPass(e.target.value)}
|
|
||||||
autoComplete="current-password"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button type="submit" className="btn-primary btn-full" disabled={loading}>
|
|
||||||
{loading ? "..." : "Sign in"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<form className="auth-form" onSubmit={handleSignupSubmit}>
|
|
||||||
<div className="auth-row">
|
|
||||||
<label>
|
|
||||||
First name
|
|
||||||
<input type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Last name
|
|
||||||
<input type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label>
|
|
||||||
Username
|
|
||||||
<input type="text" value={regUser} onChange={(e) => setRegUser(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Email
|
|
||||||
<input type="email" value={regEmail} onChange={(e) => setRegEmail(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Password
|
|
||||||
<input type="password" value={regPass} onChange={(e) => setRegPass(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{signupError && <div className="auth-error">{signupError}</div>}
|
|
||||||
{signupInfo && (
|
|
||||||
<div
|
|
||||||
className="auth-error"
|
|
||||||
style={{
|
|
||||||
background: "rgba(34,197,94,0.14)",
|
|
||||||
borderColor: "rgba(34,197,94,0.55)",
|
|
||||||
color: "#d1fae5",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{signupInfo}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button type="submit" className="btn-primary btn-full" disabled={signupLoading}>
|
|
||||||
{signupLoading ? "..." : "Create account"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,332 +0,0 @@
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useAuth } from "../../auth/AuthContext";
|
|
||||||
import { registerUser } from "../../api/client";
|
|
||||||
import AuthToast from "../Auth/AuthToast";
|
|
||||||
|
|
||||||
const THEME_LABELS = { dark: "Dark", blue: "Blue", light: "Light" };
|
|
||||||
|
|
||||||
function initialLetter(name = "") {
|
|
||||||
const s = (name || "").trim();
|
|
||||||
return s ? s[0].toUpperCase() : "G";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TopBar({ theme = "dark", onChangeTheme }) {
|
|
||||||
const {
|
|
||||||
loading,
|
|
||||||
authenticated,
|
|
||||||
username,
|
|
||||||
login,
|
|
||||||
logout,
|
|
||||||
needsEmailVerification,
|
|
||||||
openKeycloak,
|
|
||||||
lastError,
|
|
||||||
lastErrorCode,
|
|
||||||
lastInfo,
|
|
||||||
clearError,
|
|
||||||
clearInfo,
|
|
||||||
} = useAuth();
|
|
||||||
|
|
||||||
const [showAuth, setShowAuth] = useState(false);
|
|
||||||
const [mode, setMode] = useState("login");
|
|
||||||
|
|
||||||
const [loginUser, setLoginUser] = useState("");
|
|
||||||
const [loginPass, setLoginPass] = useState("");
|
|
||||||
|
|
||||||
const [firstName, setFirstName] = useState("");
|
|
||||||
const [lastName, setLastName] = useState("");
|
|
||||||
const [regUser, setRegUser] = useState("");
|
|
||||||
const [regEmail, setRegEmail] = useState("");
|
|
||||||
const [regPass, setRegPass] = useState("");
|
|
||||||
const [signupError, setSignupError] = useState("");
|
|
||||||
const [signupInfo, setSignupInfo] = useState("");
|
|
||||||
const [signupLoading, setSignupLoading] = useState(false);
|
|
||||||
|
|
||||||
const openModal = (nextMode) => {
|
|
||||||
setMode(nextMode);
|
|
||||||
setShowAuth(true);
|
|
||||||
setSignupError("");
|
|
||||||
setSignupInfo("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
setShowAuth(false);
|
|
||||||
setLoginPass("");
|
|
||||||
setSignupError("");
|
|
||||||
setSignupInfo("");
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (authenticated) setShowAuth(false);
|
|
||||||
}, [authenticated]);
|
|
||||||
|
|
||||||
/* SW_URL_NOTICE:BEGIN */
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
const qs = new URLSearchParams(window.location.search || "");
|
|
||||||
const verified = qs.get("verified") || qs.get("email_verified");
|
|
||||||
const signup = qs.get("signup");
|
|
||||||
const notice = qs.get("notice");
|
|
||||||
|
|
||||||
if (verified === "1" || verified === "true") {
|
|
||||||
setSignupInfo("✅ Email confirmé. Tu peux te connecter.");
|
|
||||||
setMode("login");
|
|
||||||
setShowAuth(true);
|
|
||||||
} else if (signup === "1" || signup === "true") {
|
|
||||||
setSignupInfo("✅ Compte créé. Vérifie tes emails (et le spam), puis connecte-toi.");
|
|
||||||
setMode("login");
|
|
||||||
setShowAuth(true);
|
|
||||||
} else if (notice) {
|
|
||||||
// allow custom notice (URL-encoded)
|
|
||||||
const msg = decodeURIComponent(notice);
|
|
||||||
if (msg && msg.trim()) {
|
|
||||||
setSignupInfo(msg.trim());
|
|
||||||
setMode("login");
|
|
||||||
setShowAuth(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// clean URL (remove params without reload)
|
|
||||||
if (verified or signup or notice):
|
|
||||||
const clean = window.location.pathname + (window.location.hash || "");
|
|
||||||
window.history.replaceState({}, "", clean);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
/* SW_URL_NOTICE:END */
|
|
||||||
|
|
||||||
|
|
||||||
const handleLoginSubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
await login(loginUser, loginPass);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSignupSubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setSignupError("");
|
|
||||||
setSignupInfo("");
|
|
||||||
|
|
||||||
if (!firstName.trim() || !lastName.trim() || !regUser.trim() || !regEmail.trim() || !regPass) {
|
|
||||||
setSignupError("First name, last name, username, email and password are required.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setSignupLoading(true);
|
|
||||||
|
|
||||||
await registerUser({
|
|
||||||
username: regUser.trim(),
|
|
||||||
email: regEmail.trim(),
|
|
||||||
password: regPass,
|
|
||||||
firstName: firstName.trim(),
|
|
||||||
lastName: lastName.trim(),
|
|
||||||
});
|
|
||||||
|
|
||||||
setSignupInfo("Account created. Check your inbox (spam) to verify your email, then login.");
|
|
||||||
setMode("login");
|
|
||||||
setLoginUser(regUser.trim());
|
|
||||||
setLoginPass("");
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
setSignupError(err.message || "Registration error.");
|
|
||||||
} finally {
|
|
||||||
setSignupLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toast = useMemo(() => {
|
|
||||||
if (lastError) {
|
|
||||||
const t = needsEmailVerification ? "warn" : "error";
|
|
||||||
const title = needsEmailVerification ? "Vérification email requise" : "Connexion impossible";
|
|
||||||
return { type: t, title, message: lastError };
|
|
||||||
}
|
|
||||||
if (lastInfo) {
|
|
||||||
return { type: "success", title: "", message: lastInfo };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [lastError, lastInfo, needsEmailVerification]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Toast area (under topbar) */}
|
|
||||||
{(toast?.message) && (
|
|
||||||
<div className="sw-toast-wrap">
|
|
||||||
<AuthToast
|
|
||||||
type={toast.type}
|
|
||||||
title={toast.title}
|
|
||||||
message={toast.message}
|
|
||||||
onClose={() => {
|
|
||||||
if (lastError) clearError();
|
|
||||||
if (lastInfo) clearInfo();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{needsEmailVerification && (
|
|
||||||
<div style={{ maxWidth: 980, margin: "10px auto 0", display: "flex", gap: 10, justifyContent: "center" }}>
|
|
||||||
<button className="btn-secondary" type="button" onClick={openKeycloak}>
|
|
||||||
Ouvrir Keycloak (Proceed)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<header className="topbar">
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "0.65rem" }}>
|
|
||||||
<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>
|
|
||||||
</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.45rem" }}>
|
|
||||||
<div className="sw-userchip" title={username}>
|
|
||||||
<div className="sw-avatar">
|
|
||||||
<span>{initialLetter(username)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="sw-usertext">
|
|
||||||
<div className="sw-userline">Connecté</div>
|
|
||||||
<div className="sw-userhandle">@{username || "user"}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button className="btn-primary" onClick={logout}>
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: "flex", gap: "0.35rem", alignItems: "center" }}>
|
|
||||||
<div className="sw-userchip" title="Guest">
|
|
||||||
<div className="sw-avatar">
|
|
||||||
<i className="fa-solid fa-user" />
|
|
||||||
</div>
|
|
||||||
<div className="sw-usertext">
|
|
||||||
<div className="sw-userline">{loading ? "Loading…" : "Guest"}</div>
|
|
||||||
<div className="sw-userhandle">anon</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="button" className="btn-primary" onClick={() => openModal("login")}>
|
|
||||||
Login
|
|
||||||
</button>
|
|
||||||
<button type="button" 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>
|
|
||||||
|
|
||||||
{/* SW_MODAL_NOTICE:BEGIN */}
|
|
||||||
{signupInfo ? (
|
|
||||||
<div className="auth-notice auth-notice-success">
|
|
||||||
{signupInfo}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{/* SW_MODAL_NOTICE:END */}
|
|
||||||
|
|
||||||
{mode === "login" ? (
|
|
||||||
<form className="auth-form" onSubmit={handleLoginSubmit}>
|
|
||||||
<label>
|
|
||||||
Username
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={loginUser}
|
|
||||||
onChange={(e) => setLoginUser(e.target.value)}
|
|
||||||
autoComplete="username"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Password
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={loginPass}
|
|
||||||
onChange={(e) => setLoginPass(e.target.value)}
|
|
||||||
autoComplete="current-password"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button type="submit" className="btn-primary btn-full" disabled={loading}>
|
|
||||||
{loading ? "..." : "Sign in"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<form className="auth-form" onSubmit={handleSignupSubmit}>
|
|
||||||
<div className="auth-row">
|
|
||||||
<label>
|
|
||||||
First name
|
|
||||||
<input type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Last name
|
|
||||||
<input type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label>
|
|
||||||
Username
|
|
||||||
<input type="text" value={regUser} onChange={(e) => setRegUser(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Email
|
|
||||||
<input type="email" value={regEmail} onChange={(e) => setRegEmail(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Password
|
|
||||||
<input type="password" value={regPass} onChange={(e) => setRegPass(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{signupError && <div className="auth-error">{signupError}</div>}
|
|
||||||
{signupInfo && (
|
|
||||||
<div className="auth-error" style={{ background: "rgba(34,197,94,0.14)", borderColor: "rgba(34,197,94,0.55)", color: "#d1fae5" }}>
|
|
||||||
{signupInfo}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button type="submit" className="btn-primary btn-full" disabled={signupLoading}>
|
|
||||||
{signupLoading ? "..." : "Create account"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import "@fortawesome/fontawesome-free/css/all.min.css"; // local via npm (no CDN)
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import ReactDOM from "react-dom/client";
|
|
||||||
import App from "./App.jsx";
|
|
||||||
|
|
||||||
import "./styles/base.css";
|
|
||||||
import "./styles/layout.css";
|
|
||||||
import "./styles/topbar.css";
|
|
||||||
import "./styles/map.css";
|
|
||||||
import "./styles/overlays.css";
|
|
||||||
import "./styles/posts.css";
|
|
||||||
import "./styles/theme.css";
|
|
||||||
import "./styles/auth-modal.css";
|
|
||||||
import "./styles/auth-toast.css";
|
|
||||||
import "./styles/filters.css";
|
|
||||||
import "./styles/mapMarkers.css";
|
|
||||||
|
|
||||||
import { AuthProvider } from "./auth/AuthContext.jsx";
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<AuthProvider>
|
|
||||||
<App />
|
|
||||||
</AuthProvider>
|
|
||||||
</React.StrictMode>
|
|
||||||
);
|
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
.auth-modal-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 60;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: rgba(2, 6, 23, 0.65);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-modal {
|
|
||||||
width: min(640px, 95vw);
|
|
||||||
border-radius: 22px;
|
|
||||||
padding: 1.1rem 1.25rem 1.35rem;
|
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.85);
|
|
||||||
border: 1px solid rgba(148, 163, 184, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-theme="dark"] .auth-modal {
|
|
||||||
background: radial-gradient(circle at top left, #0f172a, #020617 70%);
|
|
||||||
}
|
|
||||||
body[data-theme="blue"] .auth-modal {
|
|
||||||
background: radial-gradient(circle at top left, #0ea5e9, #020617 65%);
|
|
||||||
border-color: rgba(96, 165, 250, 0.9);
|
|
||||||
}
|
|
||||||
body[data-theme="light"] .auth-modal {
|
|
||||||
background: radial-gradient(circle at top left, #ffffff, #e5e7eb 70%);
|
|
||||||
color: #020617;
|
|
||||||
border-color: rgba(148, 163, 184, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-modal-header { display:flex; align-items:center; gap:.5rem; margin-bottom:.9rem; }
|
|
||||||
|
|
||||||
.auth-tab {
|
|
||||||
padding: .45rem .95rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
background: transparent;
|
|
||||||
font-size: .85rem;
|
|
||||||
color: #e5e7eb;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.auth-tab-active {
|
|
||||||
background: linear-gradient(135deg, #1d4ed8, #38bdf8);
|
|
||||||
border-color: rgba(191, 219, 254, 0.9);
|
|
||||||
color: #f9fafb;
|
|
||||||
}
|
|
||||||
body[data-theme="light"] .auth-tab { color:#0f172a; }
|
|
||||||
body[data-theme="light"] .auth-tab-active { color:#f9fafb; }
|
|
||||||
|
|
||||||
.auth-close {
|
|
||||||
margin-left:auto;
|
|
||||||
width:32px; height:32px;
|
|
||||||
border-radius:999px;
|
|
||||||
border:1px solid rgba(148, 163, 184, 0.6);
|
|
||||||
background: rgba(15, 23, 42, 0.85);
|
|
||||||
color:#e5e7eb;
|
|
||||||
font-size:.8rem;
|
|
||||||
cursor:pointer;
|
|
||||||
}
|
|
||||||
body[data-theme="light"] .auth-close { background:#e5e7eb; color:#020617; }
|
|
||||||
|
|
||||||
.auth-form { display:flex; flex-direction:column; gap:.7rem; }
|
|
||||||
.auth-row { display:flex; gap:.6rem; }
|
|
||||||
.auth-row label { flex:1; }
|
|
||||||
.auth-form label { display:flex; flex-direction:column; font-size:.8rem; gap:.25rem; }
|
|
||||||
|
|
||||||
.auth-form input {
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid rgba(51, 65, 85, 0.9);
|
|
||||||
background: rgba(15, 23, 42, 0.96);
|
|
||||||
color: #e5e7eb;
|
|
||||||
padding: .55rem .85rem;
|
|
||||||
font-size: .85rem;
|
|
||||||
}
|
|
||||||
body[data-theme="light"] .auth-form input {
|
|
||||||
background:#f9fafb;
|
|
||||||
color:#020617;
|
|
||||||
border-color: rgba(148, 163, 184, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-full { width:100%; margin-top:.4rem; }
|
|
||||||
|
|
||||||
.auth-error {
|
|
||||||
margin-top:.25rem;
|
|
||||||
font-size:.8rem;
|
|
||||||
color:#fecaca;
|
|
||||||
background: rgba(127, 29, 29, 0.55);
|
|
||||||
border-radius:.5rem;
|
|
||||||
padding:.4rem .6rem;
|
|
||||||
border: 1px solid rgba(248, 113, 113, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width:480px){
|
|
||||||
.auth-modal { padding:.95rem .9rem 1.1rem; }
|
|
||||||
.auth-row { flex-direction:column; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* SW_AUTH_NOTICE:BEGIN */
|
|
||||||
.auth-notice{
|
|
||||||
margin: .2rem 0 .65rem;
|
|
||||||
padding: .55rem .7rem;
|
|
||||||
border-radius: 14px;
|
|
||||||
font-size: .82rem;
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1.2;
|
|
||||||
border: 1px solid rgba(148,163,184,.35);
|
|
||||||
background: rgba(2,6,23,.22);
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-notice-success{
|
|
||||||
background: rgba(34,197,94,0.14);
|
|
||||||
border-color: rgba(34,197,94,0.55);
|
|
||||||
color: #d1fae5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-notice-error{
|
|
||||||
background: rgba(248,113,113,0.14);
|
|
||||||
border-color: rgba(248,113,113,0.55);
|
|
||||||
color: #ffe4e6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-notice-warn{
|
|
||||||
background: rgba(251,191,36,0.12);
|
|
||||||
border-color: rgba(251,191,36,0.40);
|
|
||||||
color: #fffbeb;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-theme="light"] .auth-notice{
|
|
||||||
background: rgba(255,255,255,0.92);
|
|
||||||
border-color: rgba(148,163,184,0.75);
|
|
||||||
color: #0b1220;
|
|
||||||
}
|
|
||||||
body[data-theme="light"] .auth-notice-success{ color:#0b1220; }
|
|
||||||
body[data-theme="light"] .auth-notice-error{ color:#0b1220; }
|
|
||||||
body[data-theme="light"] .auth-notice-warn{ color:#0b1220; }
|
|
||||||
/* SW_AUTH_NOTICE:END */
|
|
||||||
|
|
@ -1,251 +0,0 @@
|
||||||
.topbar{
|
|
||||||
position: sticky; top:0; z-index:100;
|
|
||||||
width:100%;
|
|
||||||
padding: .55rem .9rem;
|
|
||||||
display:flex; align-items:center; justify-content:space-between; gap:.6rem;
|
|
||||||
background: linear-gradient(135deg,#071225 0%,#0b2b5f 38%,#0e65c0 72%,#27a8ff 100%);
|
|
||||||
border-bottom-left-radius:18px; border-bottom-right-radius:18px;
|
|
||||||
box-shadow: 0 10px 24px rgba(0,0,0,.55), 0 0 18px rgba(56,189,248,.45);
|
|
||||||
overflow:hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-circle{
|
|
||||||
width:40px; height:40px; border-radius:50%; flex-shrink:0;
|
|
||||||
background-image:url("/icons/logo-master.png");
|
|
||||||
background-size:cover; background-position:center; background-repeat:no-repeat;
|
|
||||||
box-shadow: 0 0 10px rgba(37,99,235,.8), 0 0 18px rgba(56,189,248,.6);
|
|
||||||
color:transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-block{ display:flex; flex-direction:column; line-height:1.05; min-width:0; }
|
|
||||||
.main-title{ font-size:1.02rem; font-weight:800; letter-spacing:.04em; color:#f9fafb; }
|
|
||||||
.sub-title{ font-size:.74rem; color:#dbeafe; opacity:.95; }
|
|
||||||
|
|
||||||
.topbar-right{ display:flex; align-items:center; gap:.5rem; margin-left:auto; flex-wrap:wrap; }
|
|
||||||
|
|
||||||
.theme-switch{ display:flex; gap:.28rem; }
|
|
||||||
.theme-dot{
|
|
||||||
width:22px; height:22px; border-radius:999px;
|
|
||||||
border:1px solid rgba(148,163,184,.85);
|
|
||||||
background:rgba(15,23,42,.9); color:#e5e7eb; font-size:.65rem;
|
|
||||||
display:flex; align-items:center; justify-content:center; padding:0; cursor:pointer;
|
|
||||||
box-shadow:0 0 6px rgba(15,23,42,.9); transition:transform 0.36s ease;
|
|
||||||
}
|
|
||||||
.theme-dot:hover{ transform:scale(1.06); }
|
|
||||||
.theme-dot-active{ border-color:#facc15; box-shadow:0 0 10px rgba(250,204,21,.9),0 0 14px rgba(56,189,248,.55); }
|
|
||||||
|
|
||||||
.btn-primary, .btn-secondary{
|
|
||||||
padding:.32rem .85rem; font-size:.78rem; border-radius:999px; font-weight:700; cursor:pointer;
|
|
||||||
white-space:nowrap; line-height:1;
|
|
||||||
}
|
|
||||||
.btn-primary{
|
|
||||||
border:1px solid #22c55e;
|
|
||||||
background:radial-gradient(circle at 30% 30%,#22c55e,#16a34a);
|
|
||||||
color:#f9fafb;
|
|
||||||
box-shadow:0 4px 10px rgba(22,163,74,.5),0 0 8px rgba(22,163,74,.6);
|
|
||||||
}
|
|
||||||
.btn-primary:disabled{ opacity:.6; cursor:default; }
|
|
||||||
.btn-secondary{
|
|
||||||
border:1px solid #bfdbfe;
|
|
||||||
background:rgba(15,23,42,.25);
|
|
||||||
color:#e0f2fe;
|
|
||||||
box-shadow:0 3px 10px rgba(15,23,42,.6);
|
|
||||||
}
|
|
||||||
.btn-secondary:hover{ background:rgba(15,23,42,.45); }
|
|
||||||
|
|
||||||
@media (max-width:640px){
|
|
||||||
.topbar{ padding:.5rem .7rem; border-bottom-left-radius:16px; border-bottom-right-radius:16px; }
|
|
||||||
.main-title{ font-size:1rem; }
|
|
||||||
.sub-title{ font-size:.7rem; }
|
|
||||||
.topbar-right{ width:100%; justify-content:flex-start; gap:.45rem; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* SW_AUTH_UI:BEGIN */
|
|
||||||
.auth-strip{
|
|
||||||
margin-top: 6px;
|
|
||||||
display:flex;
|
|
||||||
align-items:center;
|
|
||||||
gap: .45rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-pill{
|
|
||||||
display:inline-flex;
|
|
||||||
align-items:center;
|
|
||||||
gap: .35rem;
|
|
||||||
padding: .18rem .55rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: .70rem;
|
|
||||||
font-weight: 900;
|
|
||||||
letter-spacing: .02em;
|
|
||||||
border: 1px solid rgba(148,163,184,.55);
|
|
||||||
background: rgba(2,6,23,.25);
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
.auth-pill i{ font-size: .85rem; opacity:.95; }
|
|
||||||
|
|
||||||
.auth-pill--ok{
|
|
||||||
border-color: rgba(34,197,94,.65);
|
|
||||||
background: rgba(34,197,94,.16);
|
|
||||||
}
|
|
||||||
.auth-pill--loading{
|
|
||||||
border-color: rgba(56,189,248,.65);
|
|
||||||
background: rgba(56,189,248,.14);
|
|
||||||
}
|
|
||||||
.auth-pill--anon{
|
|
||||||
border-color: rgba(148,163,184,.55);
|
|
||||||
background: rgba(2,6,23,.20);
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-banner{
|
|
||||||
margin-top: 6px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: 14px;
|
|
||||||
font-size: .72rem;
|
|
||||||
font-weight: 800;
|
|
||||||
display:flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items:center;
|
|
||||||
flex-wrap:wrap;
|
|
||||||
}
|
|
||||||
.auth-banner--error{
|
|
||||||
background: rgba(248,113,113,0.14);
|
|
||||||
border: 1px solid rgba(248,113,113,0.55);
|
|
||||||
color: #ffe4e6;
|
|
||||||
}
|
|
||||||
.auth-banner--info{
|
|
||||||
background: rgba(56,189,248,0.14);
|
|
||||||
border: 1px solid rgba(56,189,248,0.55);
|
|
||||||
color: #e0f2fe;
|
|
||||||
}
|
|
||||||
.auth-banner--success{
|
|
||||||
background: rgba(34,197,94,0.14);
|
|
||||||
border: 1px solid rgba(34,197,94,0.55);
|
|
||||||
color: #d1fae5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-chip{
|
|
||||||
display:inline-flex;
|
|
||||||
align-items:center;
|
|
||||||
gap: .45rem;
|
|
||||||
padding: .22rem .55rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(2,6,23,.25);
|
|
||||||
border: 1px solid rgba(148,163,184,.55);
|
|
||||||
}
|
|
||||||
.user-avatar{
|
|
||||||
width: 26px;
|
|
||||||
height: 26px;
|
|
||||||
border-radius: 999px;
|
|
||||||
display:flex;
|
|
||||||
align-items:center;
|
|
||||||
justify-content:center;
|
|
||||||
background: rgba(56,189,248,0.18);
|
|
||||||
border: 1px solid rgba(56,189,248,0.35);
|
|
||||||
}
|
|
||||||
.user-avatar i{ font-size: 14px; }
|
|
||||||
|
|
||||||
.user-name{
|
|
||||||
font-size: .80rem;
|
|
||||||
font-weight: 950;
|
|
||||||
color: #f9fafb;
|
|
||||||
max-width: 220px;
|
|
||||||
overflow:hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Light theme readability */
|
|
||||||
body[data-theme="light"] .auth-pill{
|
|
||||||
background: rgba(255,255,255,0.82);
|
|
||||||
border-color: rgba(148,163,184,0.85);
|
|
||||||
color: #0b1220;
|
|
||||||
}
|
|
||||||
body[data-theme="light"] .user-chip{
|
|
||||||
background: rgba(255,255,255,0.82);
|
|
||||||
border-color: rgba(148,163,184,0.85);
|
|
||||||
}
|
|
||||||
body[data-theme="light"] .user-name{ color:#0b1220; }
|
|
||||||
body[data-theme="light"] .user-avatar{
|
|
||||||
background: rgba(30,107,255,0.12);
|
|
||||||
border-color: rgba(30,107,255,0.35);
|
|
||||||
color:#0b1220;
|
|
||||||
}
|
|
||||||
/* SW_AUTH_UI:END */
|
|
||||||
|
|
||||||
/* SW_AUTH_UI2:BEGIN */
|
|
||||||
.sw-notice{
|
|
||||||
margin: 10px 14px 0 14px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 16px;
|
|
||||||
font-size: .78rem;
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1.2;
|
|
||||||
border: 1px solid rgba(148,163,184,.35);
|
|
||||||
background: rgba(2,6,23,.22);
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
.sw-notice--error{
|
|
||||||
background: rgba(248,113,113,0.14);
|
|
||||||
border-color: rgba(248,113,113,0.55);
|
|
||||||
color: #ffe4e6;
|
|
||||||
}
|
|
||||||
.sw-notice--info{
|
|
||||||
background: rgba(56,189,248,0.14);
|
|
||||||
border-color: rgba(56,189,248,0.55);
|
|
||||||
color: #e0f2fe;
|
|
||||||
}
|
|
||||||
.sw-notice--success{
|
|
||||||
background: rgba(34,197,94,0.14);
|
|
||||||
border-color: rgba(34,197,94,0.55);
|
|
||||||
color: #d1fae5;
|
|
||||||
}
|
|
||||||
.sw-notice--warn{
|
|
||||||
background: rgba(251,191,36,0.12);
|
|
||||||
border-color: rgba(251,191,36,0.40);
|
|
||||||
color: #fffbeb;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* single user chip (no duplicates) */
|
|
||||||
.user-chip{
|
|
||||||
display:inline-flex;
|
|
||||||
align-items:center;
|
|
||||||
gap: .45rem;
|
|
||||||
padding: .22rem .55rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(2,6,23,.25);
|
|
||||||
border: 1px solid rgba(148,163,184,.55);
|
|
||||||
}
|
|
||||||
.user-avatar{
|
|
||||||
width: 26px;
|
|
||||||
height: 26px;
|
|
||||||
border-radius: 999px;
|
|
||||||
display:flex;
|
|
||||||
align-items:center;
|
|
||||||
justify-content:center;
|
|
||||||
background: rgba(56,189,248,0.18);
|
|
||||||
border: 1px solid rgba(56,189,248,0.35);
|
|
||||||
}
|
|
||||||
.user-avatar i{ font-size: 14px; }
|
|
||||||
.user-name{
|
|
||||||
font-size: .82rem;
|
|
||||||
font-weight: 950;
|
|
||||||
color: #f9fafb;
|
|
||||||
max-width: 180px;
|
|
||||||
overflow:hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.user-badge{
|
|
||||||
display:inline-flex;
|
|
||||||
align-items:center;
|
|
||||||
gap: .35rem;
|
|
||||||
padding: .10rem .45rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: .70rem;
|
|
||||||
font-weight: 950;
|
|
||||||
border: 1px solid rgba(34,197,94,.55);
|
|
||||||
background: rgba(34,197,94,.14);
|
|
||||||
color: #d1fae5;
|
|
||||||
}
|
|
||||||
/* SW_AUTH_UI2:END */
|
|
||||||
|
|
@ -1,425 +0,0 @@
|
||||||
.topbar{
|
|
||||||
position: sticky; top:0; z-index:100;
|
|
||||||
width:100%;
|
|
||||||
padding: .55rem .9rem;
|
|
||||||
display:flex; align-items:center; justify-content:space-between; gap:.6rem;
|
|
||||||
background: linear-gradient(135deg,#071225 0%,#0b2b5f 38%,#0e65c0 72%,#27a8ff 100%);
|
|
||||||
border-bottom-left-radius:18px; border-bottom-right-radius:18px;
|
|
||||||
box-shadow: 0 10px 24px rgba(0,0,0,.55), 0 0 18px rgba(56,189,248,.45);
|
|
||||||
overflow:hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-circle{
|
|
||||||
width:40px; height:40px; border-radius:50%; flex-shrink:0;
|
|
||||||
background-image:url("/icons/logo-master.png");
|
|
||||||
background-size:cover; background-position:center; background-repeat:no-repeat;
|
|
||||||
box-shadow: 0 0 10px rgba(37,99,235,.8), 0 0 18px rgba(56,189,248,.6);
|
|
||||||
color:transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-block{ display:flex; flex-direction:column; line-height:1.05; min-width:0; }
|
|
||||||
.main-title{ font-size:1.02rem; font-weight:800; letter-spacing:.04em; color:#f9fafb; }
|
|
||||||
.sub-title{ font-size:.74rem; color:#dbeafe; opacity:.95; }
|
|
||||||
|
|
||||||
.topbar-right{ display:flex; align-items:center; gap:.5rem; margin-left:auto; flex-wrap:wrap; }
|
|
||||||
|
|
||||||
.theme-switch{ display:flex; gap:.28rem; }
|
|
||||||
.theme-dot{
|
|
||||||
width:22px; height:22px; border-radius:999px;
|
|
||||||
border:1px solid rgba(148,163,184,.85);
|
|
||||||
background:rgba(15,23,42,.9); color:#e5e7eb; font-size:.65rem;
|
|
||||||
display:flex; align-items:center; justify-content:center; padding:0; cursor:pointer;
|
|
||||||
box-shadow:0 0 6px rgba(15,23,42,.9); transition:transform 0.36s ease;
|
|
||||||
}
|
|
||||||
.theme-dot:hover{ transform:scale(1.06); }
|
|
||||||
.theme-dot-active{ border-color:#facc15; box-shadow:0 0 10px rgba(250,204,21,.9),0 0 14px rgba(56,189,248,.55); }
|
|
||||||
|
|
||||||
.btn-primary, .btn-secondary{
|
|
||||||
padding:.32rem .85rem; font-size:.78rem; border-radius:999px; font-weight:700; cursor:pointer;
|
|
||||||
white-space:nowrap; line-height:1;
|
|
||||||
}
|
|
||||||
.btn-primary{
|
|
||||||
border:1px solid #22c55e;
|
|
||||||
background:radial-gradient(circle at 30% 30%,#22c55e,#16a34a);
|
|
||||||
color:#f9fafb;
|
|
||||||
box-shadow:0 4px 10px rgba(22,163,74,.5),0 0 8px rgba(22,163,74,.6);
|
|
||||||
}
|
|
||||||
.btn-primary:disabled{ opacity:.6; cursor:default; }
|
|
||||||
.btn-secondary{
|
|
||||||
border:1px solid #bfdbfe;
|
|
||||||
background:rgba(15,23,42,.25);
|
|
||||||
color:#e0f2fe;
|
|
||||||
box-shadow:0 3px 10px rgba(15,23,42,.6);
|
|
||||||
}
|
|
||||||
.btn-secondary:hover{ background:rgba(15,23,42,.45); }
|
|
||||||
|
|
||||||
@media (max-width:640px){
|
|
||||||
.topbar{ padding:.5rem .7rem; border-bottom-left-radius:16px; border-bottom-right-radius:16px; }
|
|
||||||
.main-title{ font-size:1rem; }
|
|
||||||
.sub-title{ font-size:.7rem; }
|
|
||||||
.topbar-right{ width:100%; justify-content:flex-start; gap:.45rem; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* SW_AUTH_UI:BEGIN */
|
|
||||||
.auth-strip{
|
|
||||||
margin-top: 6px;
|
|
||||||
display:flex;
|
|
||||||
align-items:center;
|
|
||||||
gap: .45rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-pill{
|
|
||||||
display:inline-flex;
|
|
||||||
align-items:center;
|
|
||||||
gap: .35rem;
|
|
||||||
padding: .18rem .55rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: .70rem;
|
|
||||||
font-weight: 900;
|
|
||||||
letter-spacing: .02em;
|
|
||||||
border: 1px solid rgba(148,163,184,.55);
|
|
||||||
background: rgba(2,6,23,.25);
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
.auth-pill i{ font-size: .85rem; opacity:.95; }
|
|
||||||
|
|
||||||
.auth-pill--ok{
|
|
||||||
border-color: rgba(34,197,94,.65);
|
|
||||||
background: rgba(34,197,94,.16);
|
|
||||||
}
|
|
||||||
.auth-pill--loading{
|
|
||||||
border-color: rgba(56,189,248,.65);
|
|
||||||
background: rgba(56,189,248,.14);
|
|
||||||
}
|
|
||||||
.auth-pill--anon{
|
|
||||||
border-color: rgba(148,163,184,.55);
|
|
||||||
background: rgba(2,6,23,.20);
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-banner{
|
|
||||||
margin-top: 6px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: 14px;
|
|
||||||
font-size: .72rem;
|
|
||||||
font-weight: 800;
|
|
||||||
display:flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items:center;
|
|
||||||
flex-wrap:wrap;
|
|
||||||
}
|
|
||||||
.auth-banner--error{
|
|
||||||
background: rgba(248,113,113,0.14);
|
|
||||||
border: 1px solid rgba(248,113,113,0.55);
|
|
||||||
color: #ffe4e6;
|
|
||||||
}
|
|
||||||
.auth-banner--info{
|
|
||||||
background: rgba(56,189,248,0.14);
|
|
||||||
border: 1px solid rgba(56,189,248,0.55);
|
|
||||||
color: #e0f2fe;
|
|
||||||
}
|
|
||||||
.auth-banner--success{
|
|
||||||
background: rgba(34,197,94,0.14);
|
|
||||||
border: 1px solid rgba(34,197,94,0.55);
|
|
||||||
color: #d1fae5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-chip{
|
|
||||||
display:inline-flex;
|
|
||||||
align-items:center;
|
|
||||||
gap: .45rem;
|
|
||||||
padding: .22rem .55rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(2,6,23,.25);
|
|
||||||
border: 1px solid rgba(148,163,184,.55);
|
|
||||||
}
|
|
||||||
.user-avatar{
|
|
||||||
width: 26px;
|
|
||||||
height: 26px;
|
|
||||||
border-radius: 999px;
|
|
||||||
display:flex;
|
|
||||||
align-items:center;
|
|
||||||
justify-content:center;
|
|
||||||
background: rgba(56,189,248,0.18);
|
|
||||||
border: 1px solid rgba(56,189,248,0.35);
|
|
||||||
}
|
|
||||||
.user-avatar i{ font-size: 14px; }
|
|
||||||
|
|
||||||
.user-name{
|
|
||||||
font-size: .80rem;
|
|
||||||
font-weight: 950;
|
|
||||||
color: #f9fafb;
|
|
||||||
max-width: 220px;
|
|
||||||
overflow:hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Light theme readability */
|
|
||||||
body[data-theme="light"] .auth-pill{
|
|
||||||
background: rgba(255,255,255,0.82);
|
|
||||||
border-color: rgba(148,163,184,0.85);
|
|
||||||
color: #0b1220;
|
|
||||||
}
|
|
||||||
body[data-theme="light"] .user-chip{
|
|
||||||
background: rgba(255,255,255,0.82);
|
|
||||||
border-color: rgba(148,163,184,0.85);
|
|
||||||
}
|
|
||||||
body[data-theme="light"] .user-name{ color:#0b1220; }
|
|
||||||
body[data-theme="light"] .user-avatar{
|
|
||||||
background: rgba(30,107,255,0.12);
|
|
||||||
border-color: rgba(30,107,255,0.35);
|
|
||||||
color:#0b1220;
|
|
||||||
}
|
|
||||||
/* SW_AUTH_UI:END */
|
|
||||||
|
|
||||||
/* SW_AUTH_UI2:BEGIN */
|
|
||||||
.sw-notice{
|
|
||||||
margin: 10px 14px 0 14px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 16px;
|
|
||||||
font-size: .78rem;
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1.2;
|
|
||||||
border: 1px solid rgba(148,163,184,.35);
|
|
||||||
background: rgba(2,6,23,.22);
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
.sw-notice--error{
|
|
||||||
background: rgba(248,113,113,0.14);
|
|
||||||
border-color: rgba(248,113,113,0.55);
|
|
||||||
color: #ffe4e6;
|
|
||||||
}
|
|
||||||
.sw-notice--info{
|
|
||||||
background: rgba(56,189,248,0.14);
|
|
||||||
border-color: rgba(56,189,248,0.55);
|
|
||||||
color: #e0f2fe;
|
|
||||||
}
|
|
||||||
.sw-notice--success{
|
|
||||||
background: rgba(34,197,94,0.14);
|
|
||||||
border-color: rgba(34,197,94,0.55);
|
|
||||||
color: #d1fae5;
|
|
||||||
}
|
|
||||||
.sw-notice--warn{
|
|
||||||
background: rgba(251,191,36,0.12);
|
|
||||||
border-color: rgba(251,191,36,0.40);
|
|
||||||
color: #fffbeb;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* single user chip (no duplicates) */
|
|
||||||
.user-chip{
|
|
||||||
display:inline-flex;
|
|
||||||
align-items:center;
|
|
||||||
gap: .45rem;
|
|
||||||
padding: .22rem .55rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(2,6,23,.25);
|
|
||||||
border: 1px solid rgba(148,163,184,.55);
|
|
||||||
}
|
|
||||||
.user-avatar{
|
|
||||||
width: 26px;
|
|
||||||
height: 26px;
|
|
||||||
border-radius: 999px;
|
|
||||||
display:flex;
|
|
||||||
align-items:center;
|
|
||||||
justify-content:center;
|
|
||||||
background: rgba(56,189,248,0.18);
|
|
||||||
border: 1px solid rgba(56,189,248,0.35);
|
|
||||||
}
|
|
||||||
.user-avatar i{ font-size: 14px; }
|
|
||||||
.user-name{
|
|
||||||
font-size: .82rem;
|
|
||||||
font-weight: 950;
|
|
||||||
color: #f9fafb;
|
|
||||||
max-width: 180px;
|
|
||||||
overflow:hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.user-badge{
|
|
||||||
display:inline-flex;
|
|
||||||
align-items:center;
|
|
||||||
gap: .35rem;
|
|
||||||
padding: .10rem .45rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: .70rem;
|
|
||||||
font-weight: 950;
|
|
||||||
border: 1px solid rgba(34,197,94,.55);
|
|
||||||
background: rgba(34,197,94,.14);
|
|
||||||
color: #d1fae5;
|
|
||||||
}
|
|
||||||
/* SW_AUTH_UI2:END */
|
|
||||||
|
|
||||||
|
|
||||||
/* SW_TOPBAR_POLISH:BEGIN */
|
|
||||||
|
|
||||||
/* Make right side look like a clean “glass dock” */
|
|
||||||
.topbar-right{
|
|
||||||
padding: .28rem .35rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(2,6,23,.18);
|
|
||||||
border: 1px solid rgba(255,255,255,.14);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* User chip used by TopBar.jsx (missing styles before) */
|
|
||||||
.sw-userchip{
|
|
||||||
display:flex;
|
|
||||||
align-items:center;
|
|
||||||
gap:.5rem;
|
|
||||||
padding:.28rem .55rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(2,6,23,.22);
|
|
||||||
border: 1px solid rgba(148,163,184,.45);
|
|
||||||
box-shadow: 0 4px 14px rgba(0,0,0,.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sw-avatar{
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
border-radius: 999px;
|
|
||||||
display:grid;
|
|
||||||
place-items:center;
|
|
||||||
background: rgba(56,189,248,0.18);
|
|
||||||
border: 1px solid rgba(56,189,248,0.35);
|
|
||||||
color: #e5e7eb;
|
|
||||||
font-weight: 950;
|
|
||||||
overflow:hidden;
|
|
||||||
}
|
|
||||||
.sw-avatar i{ font-size: 14px; }
|
|
||||||
|
|
||||||
.sw-usertext{
|
|
||||||
display:flex;
|
|
||||||
flex-direction:column;
|
|
||||||
line-height: 1.05;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.sw-userline{
|
|
||||||
font-size: .72rem;
|
|
||||||
font-weight: 950;
|
|
||||||
color: #f9fafb;
|
|
||||||
}
|
|
||||||
.sw-userhandle{
|
|
||||||
font-size: .68rem;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #dbeafe;
|
|
||||||
opacity: .92;
|
|
||||||
max-width: 160px;
|
|
||||||
overflow:hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile: dock becomes full width and nice */
|
|
||||||
@media (max-width:640px){
|
|
||||||
.topbar-right{
|
|
||||||
width:100%;
|
|
||||||
border-radius: 16px;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Light theme readability */
|
|
||||||
body[data-theme="light"] .topbar-right{
|
|
||||||
background: rgba(255,255,255,.55);
|
|
||||||
border-color: rgba(148,163,184,.55);
|
|
||||||
}
|
|
||||||
body[data-theme="light"] .sw-userchip{
|
|
||||||
background: rgba(255,255,255,.80);
|
|
||||||
border-color: rgba(148,163,184,.70);
|
|
||||||
color: #0b1220;
|
|
||||||
}
|
|
||||||
body[data-theme="light"] .sw-userline,
|
|
||||||
body[data-theme="light"] .sw-userhandle{
|
|
||||||
color:#0b1220;
|
|
||||||
}
|
|
||||||
body[data-theme="light"] .sw-avatar{
|
|
||||||
background: rgba(30,107,255,0.10);
|
|
||||||
border-color: rgba(30,107,255,0.35);
|
|
||||||
color:#0b1220;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* SW_TOPBAR_POLISH:END */
|
|
||||||
|
|
||||||
/* SW_TOPBAR_OVERFLOW_FIX:BEGIN */
|
|
||||||
.topbar{
|
|
||||||
flex-wrap: wrap; /* ✅ allow wrap instead of overflowing right */
|
|
||||||
row-gap: .45rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar-left{
|
|
||||||
display:flex;
|
|
||||||
align-items:center;
|
|
||||||
gap: .65rem;
|
|
||||||
min-width: 0; /* ✅ allow shrinking */
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-block{ min-width:0; }
|
|
||||||
.main-title, .sub-title{
|
|
||||||
max-width: 58vw;
|
|
||||||
overflow:hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar-right{
|
|
||||||
min-width: 0;
|
|
||||||
max-width: 100%;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
justify-content: flex-end;
|
|
||||||
flex-wrap: wrap; /* ✅ wrap buttons instead of clipping */
|
|
||||||
gap: .45rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width:640px){
|
|
||||||
.main-title, .sub-title{ max-width: 86vw; }
|
|
||||||
.topbar-right{
|
|
||||||
justify-content: flex-start;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Unified auth button (guest online / log on / log off) */
|
|
||||||
.sw-auth-combo{
|
|
||||||
display:flex;
|
|
||||||
align-items:center;
|
|
||||||
gap:.5rem;
|
|
||||||
padding:.25rem .55rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid rgba(148,163,184,.55);
|
|
||||||
background: rgba(2,6,23,.22);
|
|
||||||
color: #e5e7eb;
|
|
||||||
cursor:pointer;
|
|
||||||
max-width: 100%;
|
|
||||||
box-shadow: 0 4px 14px rgba(0,0,0,.22);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sw-auth-combo .sw-usertext{
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sw-auth-combo .sw-userhandle{
|
|
||||||
max-width: 120px;
|
|
||||||
overflow:hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sw-auth-action{
|
|
||||||
margin-left: .25rem;
|
|
||||||
padding: .22rem .55rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-weight: 950;
|
|
||||||
font-size: .70rem;
|
|
||||||
border: 1px solid rgba(56,189,248,.35);
|
|
||||||
background: rgba(56,189,248,.14);
|
|
||||||
color: #dbeafe;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sw-auth-combo--on .sw-auth-action{
|
|
||||||
border-color: rgba(34,197,94,.45);
|
|
||||||
background: rgba(34,197,94,.16);
|
|
||||||
color: #d1fae5;
|
|
||||||
}
|
|
||||||
/* SW_TOPBAR_OVERFLOW_FIX:END */
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
.topbar{
|
|
||||||
position: sticky; top:0; z-index:100;
|
|
||||||
width:100%;
|
|
||||||
padding: .55rem .9rem;
|
|
||||||
display:flex; align-items:center; justify-content:space-between; gap:.6rem;
|
|
||||||
background: linear-gradient(135deg,#071225 0%,#0b2b5f 38%,#0e65c0 72%,#27a8ff 100%);
|
|
||||||
border-bottom-left-radius:18px; border-bottom-right-radius:18px;
|
|
||||||
box-shadow: 0 10px 24px rgba(0,0,0,.55), 0 0 18px rgba(56,189,248,.45);
|
|
||||||
overflow:hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-circle{
|
|
||||||
width:40px; height:40px; border-radius:50%; flex-shrink:0;
|
|
||||||
background-image:url("/icons/logo-master.png");
|
|
||||||
background-size:cover; background-position:center; background-repeat:no-repeat;
|
|
||||||
box-shadow: 0 0 10px rgba(37,99,235,.8), 0 0 18px rgba(56,189,248,.6);
|
|
||||||
color:transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-block{ display:flex; flex-direction:column; line-height:1.05; min-width:0; }
|
|
||||||
.main-title{ font-size:1.02rem; font-weight:800; letter-spacing:.04em; color:#f9fafb; }
|
|
||||||
.sub-title{ font-size:.74rem; color:#dbeafe; opacity:.95; }
|
|
||||||
|
|
||||||
.topbar-right{ display:flex; align-items:center; gap:.5rem; margin-left:auto; flex-wrap:wrap; }
|
|
||||||
|
|
||||||
.theme-switch{ display:flex; gap:.28rem; }
|
|
||||||
.theme-dot{
|
|
||||||
width:22px; height:22px; border-radius:999px;
|
|
||||||
border:1px solid rgba(148,163,184,.85);
|
|
||||||
background:rgba(15,23,42,.9); color:#e5e7eb; font-size:.65rem;
|
|
||||||
display:flex; align-items:center; justify-content:center; padding:0; cursor:pointer;
|
|
||||||
box-shadow:0 0 6px rgba(15,23,42,.9); transition:transform 0.36s ease;
|
|
||||||
}
|
|
||||||
.theme-dot:hover{ transform:scale(1.06); }
|
|
||||||
.theme-dot-active{ border-color:#facc15; box-shadow:0 0 10px rgba(250,204,21,.9),0 0 14px rgba(56,189,248,.55); }
|
|
||||||
|
|
||||||
.btn-primary, .btn-secondary{
|
|
||||||
padding:.32rem .85rem; font-size:.78rem; border-radius:999px; font-weight:700; cursor:pointer;
|
|
||||||
white-space:nowrap; line-height:1;
|
|
||||||
}
|
|
||||||
.btn-primary{
|
|
||||||
border:1px solid #22c55e;
|
|
||||||
background:radial-gradient(circle at 30% 30%,#22c55e,#16a34a);
|
|
||||||
color:#f9fafb;
|
|
||||||
box-shadow:0 4px 10px rgba(22,163,74,.5),0 0 8px rgba(22,163,74,.6);
|
|
||||||
}
|
|
||||||
.btn-primary:disabled{ opacity:.6; cursor:default; }
|
|
||||||
.btn-secondary{
|
|
||||||
border:1px solid #bfdbfe;
|
|
||||||
background:rgba(15,23,42,.25);
|
|
||||||
color:#e0f2fe;
|
|
||||||
box-shadow:0 3px 10px rgba(15,23,42,.6);
|
|
||||||
}
|
|
||||||
.btn-secondary:hover{ background:rgba(15,23,42,.45); }
|
|
||||||
|
|
||||||
@media (max-width:640px){
|
|
||||||
.topbar{ padding:.5rem .7rem; border-bottom-left-radius:16px; border-bottom-right-radius:16px; }
|
|
||||||
.main-title{ font-size:1rem; }
|
|
||||||
.sub-title{ font-size:.7rem; }
|
|
||||||
.topbar-right{ width:100%; justify-content:flex-start; gap:.45rem; }
|
|
||||||
}
|
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
.topbar{
|
|
||||||
position: sticky; top:0; z-index:100;
|
|
||||||
width:100%;
|
|
||||||
padding: .55rem .9rem;
|
|
||||||
display:flex; align-items:center; justify-content:space-between; gap:.6rem;
|
|
||||||
background: linear-gradient(135deg,#071225 0%,#0b2b5f 38%,#0e65c0 72%,#27a8ff 100%);
|
|
||||||
border-bottom-left-radius:18px; border-bottom-right-radius:18px;
|
|
||||||
box-shadow: 0 10px 24px rgba(0,0,0,.55), 0 0 18px rgba(56,189,248,.45);
|
|
||||||
overflow:hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-circle{
|
|
||||||
width:40px; height:40px; border-radius:50%; flex-shrink:0;
|
|
||||||
background-image:url("/icons/logo-master.png");
|
|
||||||
background-size:cover; background-position:center; background-repeat:no-repeat;
|
|
||||||
box-shadow: 0 0 10px rgba(37,99,235,.8), 0 0 18px rgba(56,189,248,.6);
|
|
||||||
color:transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-block{ display:flex; flex-direction:column; line-height:1.05; min-width:0; }
|
|
||||||
.main-title{ font-size:1.02rem; font-weight:800; letter-spacing:.04em; color:#f9fafb; }
|
|
||||||
.sub-title{ font-size:.74rem; color:#dbeafe; opacity:.95; }
|
|
||||||
|
|
||||||
.topbar-right{ display:flex; align-items:center; gap:.5rem; margin-left:auto; flex-wrap:wrap; }
|
|
||||||
|
|
||||||
.theme-switch{ display:flex; gap:.28rem; }
|
|
||||||
.theme-dot{
|
|
||||||
width:22px; height:22px; border-radius:999px;
|
|
||||||
border:1px solid rgba(148,163,184,.85);
|
|
||||||
background:rgba(15,23,42,.9); color:#e5e7eb; font-size:.65rem;
|
|
||||||
display:flex; align-items:center; justify-content:center; padding:0; cursor:pointer;
|
|
||||||
box-shadow:0 0 6px rgba(15,23,42,.9); transition:transform 0.36s ease;
|
|
||||||
}
|
|
||||||
.theme-dot:hover{ transform:scale(1.06); }
|
|
||||||
.theme-dot-active{ border-color:#facc15; box-shadow:0 0 10px rgba(250,204,21,.9),0 0 14px rgba(56,189,248,.55); }
|
|
||||||
|
|
||||||
.btn-primary, .btn-secondary{
|
|
||||||
padding:.32rem .85rem; font-size:.78rem; border-radius:999px; font-weight:700; cursor:pointer;
|
|
||||||
white-space:nowrap; line-height:1;
|
|
||||||
}
|
|
||||||
.btn-primary{
|
|
||||||
border:1px solid #22c55e;
|
|
||||||
background:radial-gradient(circle at 30% 30%,#22c55e,#16a34a);
|
|
||||||
color:#f9fafb;
|
|
||||||
box-shadow:0 4px 10px rgba(22,163,74,.5),0 0 8px rgba(22,163,74,.6);
|
|
||||||
}
|
|
||||||
.btn-primary:disabled{ opacity:.6; cursor:default; }
|
|
||||||
.btn-secondary{
|
|
||||||
border:1px solid #bfdbfe;
|
|
||||||
background:rgba(15,23,42,.25);
|
|
||||||
color:#e0f2fe;
|
|
||||||
box-shadow:0 3px 10px rgba(15,23,42,.6);
|
|
||||||
}
|
|
||||||
.btn-secondary:hover{ background:rgba(15,23,42,.45); }
|
|
||||||
|
|
||||||
@media (max-width:640px){
|
|
||||||
.topbar{ padding:.5rem .7rem; border-bottom-left-radius:16px; border-bottom-right-radius:16px; }
|
|
||||||
.main-title{ font-size:1rem; }
|
|
||||||
.sub-title{ font-size:.7rem; }
|
|
||||||
.topbar-right{ width:100%; justify-content:flex-start; gap:.45rem; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* SW_AUTH_UI:BEGIN */
|
|
||||||
.auth-strip{
|
|
||||||
margin-top: 6px;
|
|
||||||
display:flex;
|
|
||||||
align-items:center;
|
|
||||||
gap: .45rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-pill{
|
|
||||||
display:inline-flex;
|
|
||||||
align-items:center;
|
|
||||||
gap: .35rem;
|
|
||||||
padding: .18rem .55rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: .70rem;
|
|
||||||
font-weight: 900;
|
|
||||||
letter-spacing: .02em;
|
|
||||||
border: 1px solid rgba(148,163,184,.55);
|
|
||||||
background: rgba(2,6,23,.25);
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
.auth-pill i{ font-size: .85rem; opacity:.95; }
|
|
||||||
|
|
||||||
.auth-pill--ok{
|
|
||||||
border-color: rgba(34,197,94,.65);
|
|
||||||
background: rgba(34,197,94,.16);
|
|
||||||
}
|
|
||||||
.auth-pill--loading{
|
|
||||||
border-color: rgba(56,189,248,.65);
|
|
||||||
background: rgba(56,189,248,.14);
|
|
||||||
}
|
|
||||||
.auth-pill--anon{
|
|
||||||
border-color: rgba(148,163,184,.55);
|
|
||||||
background: rgba(2,6,23,.20);
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-banner{
|
|
||||||
margin-top: 6px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: 14px;
|
|
||||||
font-size: .72rem;
|
|
||||||
font-weight: 800;
|
|
||||||
display:flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items:center;
|
|
||||||
flex-wrap:wrap;
|
|
||||||
}
|
|
||||||
.auth-banner--error{
|
|
||||||
background: rgba(248,113,113,0.14);
|
|
||||||
border: 1px solid rgba(248,113,113,0.55);
|
|
||||||
color: #ffe4e6;
|
|
||||||
}
|
|
||||||
.auth-banner--info{
|
|
||||||
background: rgba(56,189,248,0.14);
|
|
||||||
border: 1px solid rgba(56,189,248,0.55);
|
|
||||||
color: #e0f2fe;
|
|
||||||
}
|
|
||||||
.auth-banner--success{
|
|
||||||
background: rgba(34,197,94,0.14);
|
|
||||||
border: 1px solid rgba(34,197,94,0.55);
|
|
||||||
color: #d1fae5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-chip{
|
|
||||||
display:inline-flex;
|
|
||||||
align-items:center;
|
|
||||||
gap: .45rem;
|
|
||||||
padding: .22rem .55rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(2,6,23,.25);
|
|
||||||
border: 1px solid rgba(148,163,184,.55);
|
|
||||||
}
|
|
||||||
.user-avatar{
|
|
||||||
width: 26px;
|
|
||||||
height: 26px;
|
|
||||||
border-radius: 999px;
|
|
||||||
display:flex;
|
|
||||||
align-items:center;
|
|
||||||
justify-content:center;
|
|
||||||
background: rgba(56,189,248,0.18);
|
|
||||||
border: 1px solid rgba(56,189,248,0.35);
|
|
||||||
}
|
|
||||||
.user-avatar i{ font-size: 14px; }
|
|
||||||
|
|
||||||
.user-name{
|
|
||||||
font-size: .80rem;
|
|
||||||
font-weight: 950;
|
|
||||||
color: #f9fafb;
|
|
||||||
max-width: 220px;
|
|
||||||
overflow:hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Light theme readability */
|
|
||||||
body[data-theme="light"] .auth-pill{
|
|
||||||
background: rgba(255,255,255,0.82);
|
|
||||||
border-color: rgba(148,163,184,0.85);
|
|
||||||
color: #0b1220;
|
|
||||||
}
|
|
||||||
body[data-theme="light"] .user-chip{
|
|
||||||
background: rgba(255,255,255,0.82);
|
|
||||||
border-color: rgba(148,163,184,0.85);
|
|
||||||
}
|
|
||||||
body[data-theme="light"] .user-name{ color:#0b1220; }
|
|
||||||
body[data-theme="light"] .user-avatar{
|
|
||||||
background: rgba(30,107,255,0.12);
|
|
||||||
border-color: rgba(30,107,255,0.35);
|
|
||||||
color:#0b1220;
|
|
||||||
}
|
|
||||||
/* SW_AUTH_UI:END */
|
|
||||||
Loading…
Reference in New Issue