nleh
This commit is contained in:
parent
2157222e17
commit
3e34dbde20
|
|
@ -181,7 +181,7 @@ export function AuthProvider({ children }) {
|
|||
|
||||
async function refreshProfile(accessTokenOverride) {
|
||||
const at = accessTokenOverride || tokens?.access_token;
|
||||
if (!at) return { ok: False };
|
||||
if (!at) return { ok: false };
|
||||
|
||||
// /api/me is the truth; token claim is fallback only
|
||||
const mr = await me(at);
|
||||
|
|
@ -414,6 +414,8 @@ export function AuthProvider({ children }) {
|
|||
setError,
|
||||
setLastInfo,
|
||||
setNeedsEmailVerification,
|
||||
clearError: () => setError(""),
|
||||
clearInfo: () => setLastInfo(""),
|
||||
};
|
||||
}, [status, user, tokens, error, lastInfo, needsEmailVerification]);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,425 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -4,12 +4,66 @@ import { registerUser } from "../../api/client";
|
|||
import AuthToast from "../Auth/AuthToast";
|
||||
|
||||
const THEME_LABELS = { dark: "Dark", blue: "Blue", light: "Light" };
|
||||
const LAST_SIGNUP_KEY = "sociowire:lastSignup";
|
||||
|
||||
function initialLetter(name = "") {
|
||||
const s = (name || "").trim();
|
||||
return s ? s[0].toUpperCase() : "G";
|
||||
}
|
||||
|
||||
function truthyParam(v) {
|
||||
if (!v) return false;
|
||||
const x = String(v).toLowerCase().trim();
|
||||
return x === "1" || x === "true" || x === "yes" || x === "ok";
|
||||
}
|
||||
|
||||
function readLastSignup() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LAST_SIGNUP_KEY);
|
||||
if (!raw) return null;
|
||||
const j = JSON.parse(raw);
|
||||
if (!j || typeof j !== "object") return null;
|
||||
return {
|
||||
username: (j.username || "").toString().trim(),
|
||||
email: (j.email || "").toString().trim(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseMergedParams() {
|
||||
// Support both:
|
||||
// - /?verified=1&email=...
|
||||
// - /#/something?verified=1&email=...
|
||||
const search = window.location.search || "";
|
||||
const hash = window.location.hash || "";
|
||||
|
||||
const qs = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
|
||||
|
||||
let hashQs = "";
|
||||
if (hash.includes("?")) hashQs = hash.slice(hash.indexOf("?") + 1);
|
||||
const hs = new URLSearchParams(hashQs);
|
||||
|
||||
const get = (k) => {
|
||||
const a = qs.get(k);
|
||||
if (a != null && a !== "") return a;
|
||||
const b = hs.get(k);
|
||||
return b != null ? b : null;
|
||||
};
|
||||
|
||||
const hasAny = (...keys) => any(keys, qs, hs)
|
||||
|
||||
return { qs, hs, get, hash };
|
||||
}
|
||||
|
||||
function any(keys, qs, hs){
|
||||
for (const k of keys){
|
||||
if (qs.has(k) || hs.has(k)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export default function TopBar({ theme = "dark", onChangeTheme }) {
|
||||
const {
|
||||
loading,
|
||||
|
|
@ -18,9 +72,7 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
|
|||
login,
|
||||
logout,
|
||||
needsEmailVerification,
|
||||
openKeycloak,
|
||||
lastError,
|
||||
lastErrorCode,
|
||||
lastInfo,
|
||||
clearError,
|
||||
clearInfo,
|
||||
|
|
@ -45,16 +97,17 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
|
|||
setMode(nextMode);
|
||||
setShowAuth(true);
|
||||
setSignupError("");
|
||||
setSignupInfo("");
|
||||
// keep signupInfo (so verify/signup notice stays visible)
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setShowAuth(false);
|
||||
setLoginPass("");
|
||||
setSignupError("");
|
||||
setSignupInfo("");
|
||||
// keep signupInfo
|
||||
};
|
||||
|
||||
// Close auth modal automatically when logged in
|
||||
useEffect(() => {
|
||||
if (authenticated) setShowAuth(false);
|
||||
}, [authenticated]);
|
||||
|
|
@ -62,21 +115,47 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
|
|||
/* 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 { get, qs, hs, hash } = parseMergedParams();
|
||||
|
||||
if (verified === "1" || verified === "true") {
|
||||
setSignupInfo("✅ Email confirmé. Tu peux te connecter.");
|
||||
const verified =
|
||||
get("verified") ||
|
||||
get("email_verified") ||
|
||||
get("emailVerified") ||
|
||||
get("kc_verified");
|
||||
|
||||
const signup = get("signup");
|
||||
const notice = get("notice");
|
||||
|
||||
const email =
|
||||
get("email") ||
|
||||
get("user_email") ||
|
||||
get("mail");
|
||||
|
||||
const uname =
|
||||
get("username") ||
|
||||
get("user") ||
|
||||
get("preferred_username") ||
|
||||
get("login");
|
||||
|
||||
const last = readLastSignup();
|
||||
|
||||
if (truthyParam(verified)) {
|
||||
const who = (email || uname || last?.email || last?.username || "").trim();
|
||||
setSignupInfo(`✅ Email confirmé${who ? ` (${who})` : ""}. 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.");
|
||||
|
||||
const prefill = (uname || email || last?.username || last?.email || "").trim();
|
||||
if (prefill) setLoginUser(prefill);
|
||||
} else if (truthyParam(signup)) {
|
||||
const who = (email || uname || last?.email || last?.username || "").trim();
|
||||
setSignupInfo(`✅ Compte créé${who ? ` (${who})` : ""}. Vérifie tes emails (et le spam), puis connecte-toi.`);
|
||||
setMode("login");
|
||||
setShowAuth(true);
|
||||
|
||||
const prefill = (uname || email || last?.username || last?.email || "").trim();
|
||||
if (prefill) setLoginUser(prefill);
|
||||
} else if (notice) {
|
||||
// allow custom notice (URL-encoded)
|
||||
const msg = decodeURIComponent(notice);
|
||||
if (msg && msg.trim()) {
|
||||
setSignupInfo(msg.trim());
|
||||
|
|
@ -85,9 +164,14 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
|
|||
}
|
||||
}
|
||||
|
||||
// clean URL (remove params without reload)
|
||||
if (verified || signup || notice) {
|
||||
const clean = window.location.pathname + (window.location.hash || "");
|
||||
// clean URL (remove params without reload) — both search and hash query
|
||||
const touched =
|
||||
qs.has("verified") || qs.has("email_verified") || qs.has("emailVerified") || qs.has("signup") || qs.has("notice") ||
|
||||
hs.has("verified") || hs.has("email_verified") || hs.has("emailVerified") || hs.has("signup") || hs.has("notice");
|
||||
|
||||
if (touched) {
|
||||
const cleanHash = hash.includes("?") ? hash.split("?")[0] : hash;
|
||||
const clean = window.location.pathname + cleanHash;
|
||||
window.history.replaceState({}, "", clean);
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -96,7 +180,6 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
|
|||
}, []);
|
||||
/* SW_URL_NOTICE:END */
|
||||
|
||||
|
||||
const handleLoginSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
await login(loginUser, loginPass);
|
||||
|
|
@ -115,17 +198,25 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
|
|||
try {
|
||||
setSignupLoading(true);
|
||||
|
||||
const u = regUser.trim();
|
||||
const em = regEmail.trim();
|
||||
|
||||
await registerUser({
|
||||
username: regUser.trim(),
|
||||
email: regEmail.trim(),
|
||||
username: u,
|
||||
email: em,
|
||||
password: regPass,
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
});
|
||||
|
||||
setSignupInfo("Account created. Check your inbox (spam) to verify your email, then login.");
|
||||
// Remember for the "verified" return
|
||||
try {
|
||||
localStorage.setItem(LAST_SIGNUP_KEY, JSON.stringify({ username: u, email: em }));
|
||||
} catch {}
|
||||
|
||||
setSignupInfo(`✅ Compte créé (${em}). Vérifie tes emails (spam aussi), puis connecte-toi.`);
|
||||
setMode("login");
|
||||
setLoginUser(regUser.trim());
|
||||
setLoginUser(u || em);
|
||||
setLoginPass("");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
|
@ -150,29 +241,23 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
|
|||
return (
|
||||
<>
|
||||
{/* Toast area (under topbar) */}
|
||||
{(toast?.message) && (
|
||||
{toast?.message ? (
|
||||
<div className="sw-toast-wrap">
|
||||
<AuthToast
|
||||
type={toast.type}
|
||||
title={toast.title}
|
||||
message={toast.message}
|
||||
onClose={() => {
|
||||
if (lastError) clearError();
|
||||
if (lastInfo) clearInfo();
|
||||
if (lastError && typeof clearError === "function") clearError();
|
||||
if (lastInfo && typeof clearInfo === "function") 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>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<header className="topbar">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.65rem" }}>
|
||||
<div className="brand-stack">
|
||||
<div className="brand-toprow">
|
||||
<div className="logo-circle">SW</div>
|
||||
<div className="title-block">
|
||||
<div className="main-title">SOCIOWIRE.com</div>
|
||||
|
|
@ -180,7 +265,8 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="topbar-right">
|
||||
{/* Theme dots UNDER the brand (prevents topbar wrapping / overflow) */}
|
||||
<div className="brand-under">
|
||||
<div className="theme-switch">
|
||||
{["dark", "blue", "light"].map((t) => (
|
||||
<button
|
||||
|
|
@ -194,24 +280,21 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
|
|||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="topbar-right">
|
||||
{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-userline">Online</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" />
|
||||
|
|
@ -221,19 +304,22 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
|
|||
<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>
|
||||
)}
|
||||
|
||||
{/* Single button: Logon / Logoff (same spot, same style) */}
|
||||
<button
|
||||
className="btn-primary"
|
||||
type="button"
|
||||
onClick={() => (authenticated ? logout() : openModal("login"))}
|
||||
disabled={loading}
|
||||
title={authenticated ? "Logoff" : "Logon"}
|
||||
>
|
||||
{authenticated ? "Logoff" : "Logon"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{showAuth && (
|
||||
{showAuth ? (
|
||||
<div className="auth-modal-backdrop" onClick={closeModal}>
|
||||
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="auth-modal-header">
|
||||
|
|
@ -256,18 +342,18 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* SW_MODAL_NOTICE:BEGIN */}
|
||||
{signupInfo ? (
|
||||
<div className="auth-notice auth-notice-success">
|
||||
{signupInfo}
|
||||
{/* Pretty notices inside modal */}
|
||||
{signupInfo ? <div className="auth-notice auth-notice-success">{signupInfo}</div> : null}
|
||||
{mode === "login" && lastError ? (
|
||||
<div className={"auth-notice " + (needsEmailVerification ? "auth-notice-warn" : "auth-notice-error")}>
|
||||
{lastError}
|
||||
</div>
|
||||
) : null}
|
||||
{/* SW_MODAL_NOTICE:END */}
|
||||
|
||||
{mode === "login" ? (
|
||||
<form className="auth-form" onSubmit={handleLoginSubmit}>
|
||||
<label>
|
||||
Username
|
||||
Username or email
|
||||
<input
|
||||
type="text"
|
||||
value={loginUser}
|
||||
|
|
@ -287,6 +373,12 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
|
|||
<button type="submit" className="btn-primary btn-full" disabled={loading}>
|
||||
{loading ? "..." : "Sign in"}
|
||||
</button>
|
||||
|
||||
{needsEmailVerification ? (
|
||||
<div className="auth-notice auth-notice-warn" style={{ marginTop: 10 }}>
|
||||
⚠️ Ton compte demande une vérification email. Confirme ton email, puis réessaie de te connecter.
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
) : (
|
||||
<form className="auth-form" onSubmit={handleSignupSubmit}>
|
||||
|
|
@ -313,12 +405,7 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
|
|||
<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>
|
||||
)}
|
||||
{signupError ? <div className="auth-notice auth-notice-error">{signupError}</div> : null}
|
||||
|
||||
<button type="submit" className="btn-primary btn-full" disabled={signupLoading}>
|
||||
{signupLoading ? "..." : "Create account"}
|
||||
|
|
@ -327,7 +414,7 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,392 @@
|
|||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -96,3 +96,80 @@ body[data-theme="light"] .auth-form input {
|
|||
.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 */
|
||||
|
||||
|
||||
/* SW_AUTH_MODAL_NOTICE_POLISH:BEGIN */
|
||||
.auth-notice{
|
||||
margin: 0 0 .65rem 0;
|
||||
padding: .65rem .75rem;
|
||||
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;
|
||||
}
|
||||
.auth-notice-info{
|
||||
background: rgba(56,189,248,0.14);
|
||||
border-color: rgba(56,189,248,0.55);
|
||||
color: #e0f2fe;
|
||||
}
|
||||
/* SW_AUTH_MODAL_NOTICE_POLISH:END */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
.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 */
|
||||
|
|
@ -91,3 +91,14 @@ body[data-theme="light"] .sw-toast{
|
|||
}
|
||||
body[data-theme="light"] .sw-toast-icon{ background: rgba(255,255,255,.95); }
|
||||
body[data-theme="light"] .sw-toast-x{ background: rgba(255,255,255,.95); }
|
||||
|
||||
/* SW_TOAST_SUBACTIONS:BEGIN */
|
||||
.sw-toast-subactions{
|
||||
max-width: 980px;
|
||||
margin: 10px auto 0;
|
||||
display:flex;
|
||||
gap:10px;
|
||||
justify-content:center;
|
||||
pointer-events: auto;
|
||||
}
|
||||
/* SW_TOAST_SUBACTIONS:END */
|
||||
|
|
|
|||
|
|
@ -340,3 +340,151 @@ body[data-theme="light"] .sw-avatar{
|
|||
}
|
||||
|
||||
/* 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 */
|
||||
|
||||
|
||||
/* SW_TOPBAR_ONE_LINE_FIX:BEGIN */
|
||||
/* Keep the header on ONE ROW (no wrapping to a second line) */
|
||||
.topbar{
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Left brand stack (logo + title, with theme dots underneath) */
|
||||
.brand-stack{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand-toprow{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .65rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand-under{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
padding-left: 46px; /* aligns under title block visually */
|
||||
}
|
||||
|
||||
/* Prevent title from pushing the right side off-screen */
|
||||
.title-block{ min-width: 0; }
|
||||
.main-title{
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: min(360px, 48vw);
|
||||
}
|
||||
.sub-title{
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: min(360px, 48vw);
|
||||
}
|
||||
|
||||
/* Right side never wraps */
|
||||
.topbar-right{
|
||||
flex-wrap: nowrap !important;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
max-width: 52vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Make user handle truncate instead of forcing width */
|
||||
.sw-userhandle{ max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sw-userchip{ min-width: 0; }
|
||||
|
||||
@media (max-width: 640px){
|
||||
.main-title, .sub-title{ max-width: 62vw; }
|
||||
.topbar-right{ max-width: 45vw; }
|
||||
.brand-under{ padding-left: 0; }
|
||||
}
|
||||
/* SW_TOPBAR_ONE_LINE_FIX:END */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,425 @@
|
|||
.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 */
|
||||
Loading…
Reference in New Issue