sw-fe/src/components/Layout/TopBar.jsx

649 lines
22 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "../../auth/AuthContext";
import { registerUser, updateProfileUsername } from "../../api/client";
import AuthToast from "../Auth/AuthToast";
import { promptInstall, isRunningAsPWA, canInstall } from '../../pwaInstall';
import { trackEvent } from "../../utils/analytics";
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() {
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;
};
return { qs, hs, get, hash };
}
export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick, onUserIsland }) {
const {
loading,
authenticated,
username,
login,
logout,
restore,
loginWithProvider,
ssoEnabled,
avatarUrl,
needsEmailVerification,
lastError,
lastInfo,
clearError,
clearInfo,
} = useAuth();
const displayUsername = (() => {
const base = (username || "user").toString();
if (base.length <= 9) return base;
return base.slice(0, 9) + "..";
})();
const [showAuth, setShowAuth] = useState(false);
const [mode, setMode] = useState("login");
const [showUsernameModal, setShowUsernameModal] = useState(false);
const [desiredUsername, setDesiredUsername] = useState("");
const [usernameError, setUsernameError] = useState("");
const [usernameInfo, setUsernameInfo] = useState("");
const [usernameSaving, setUsernameSaving] = useState(false);
const usernamePromptTimer = useRef(null);
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 [showInstallBtn, setShowInstallBtn] = useState(false);
const [authNotice, setAuthNotice] = useState("");
const resetPasswordUrl = useMemo(() => {
const base = (import.meta?.env?.VITE_KC_URL || "https://auth.sociowire.com").replace(/\/+$/, "");
const realm = import.meta?.env?.VITE_KC_REALM || "sociowire";
const clientId = import.meta?.env?.VITE_KC_CLIENT_ID || "sociowire-frontend";
const redirectUri = window.location.origin + window.location.pathname;
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
});
return `${base}/realms/${realm}/login-actions/reset-credentials?${params.toString()}`;
}, []);
const openModal = (nextMode) => {
setMode(nextMode);
setShowAuth(true);
setSignupError("");
trackEvent("auth_modal_open", { mode: nextMode || "login" });
};
const closeModal = () => {
setShowAuth(false);
setLoginPass("");
setSignupError("");
setAuthNotice("");
};
const handleUsernameSave = async (e) => {
e.preventDefault();
const next = (desiredUsername || "").trim();
const re = /^[A-Za-z0-9_.]{3,24}$/;
if (!re.test(next) || next.includes("@")) {
setUsernameError("Use 3-24 letters, numbers, _ or .");
return;
}
setUsernameSaving(true);
setUsernameError("");
setUsernameInfo("");
const res = await updateProfileUsername(next);
setUsernameSaving(false);
if (!res.ok) {
if (res.status === 409) {
setUsernameError("Username already taken.");
} else {
setUsernameError(res.text || "Could not update username.");
}
return;
}
setUsernameInfo("Username saved.");
setShowUsernameModal(false);
await restore();
};
useEffect(() => {
if (authenticated) setShowAuth(false);
}, [authenticated]);
useEffect(() => {
const needsUsername = (name) => {
if (!name) return false;
if (name.includes("@")) return true;
const re = /^[A-Za-z0-9_.]{3,24}$/;
return !re.test(name);
};
if (usernamePromptTimer.current) {
clearTimeout(usernamePromptTimer.current);
usernamePromptTimer.current = null;
}
if (loading) return;
if (!authenticated) {
setShowUsernameModal(false);
return;
}
if (needsUsername(username)) {
if (!showUsernameModal) {
const base = (username || "").split("@")[0] || "";
const cleaned = base.replace(/[^A-Za-z0-9_.]/g, "").slice(0, 24);
setDesiredUsername(cleaned);
setUsernameError("");
setUsernameInfo("");
usernamePromptTimer.current = setTimeout(() => {
setShowUsernameModal(true);
}, 350);
}
} else if (showUsernameModal) {
setShowUsernameModal(false);
}
}, [authenticated, username, showUsernameModal, loading]);
useEffect(() => {
return () => {
if (usernamePromptTimer.current) {
clearTimeout(usernamePromptTimer.current);
usernamePromptTimer.current = null;
}
};
}, []);
useEffect(() => {
const handler = (e) => {
const detail = e?.detail || {};
const msg =
(detail.message || "").toString().trim() ||
"Please sign in or create a free account to continue.";
setAuthNotice(msg);
setMode(detail.mode === "signup" ? "signup" : "login");
setShowAuth(true);
};
window.addEventListener("sociowire:auth-required", handler);
return () => window.removeEventListener("sociowire:auth-required", handler);
}, []);
// Check if PWA install should be shown (simple: hide if already PWA)
useEffect(() => {
const checkInstall = () => {
// Hide if already running as PWA
if (isRunningAsPWA()) {
setShowInstallBtn(false);
return;
}
// Show if can install
setShowInstallBtn(canInstall());
};
checkInstall();
const handlePrompt = () => {
setTimeout(checkInstall, 100);
};
window.addEventListener('beforeinstallprompt', handlePrompt);
window.addEventListener('appinstalled', () => setShowInstallBtn(false));
return () => {
window.removeEventListener('beforeinstallprompt', handlePrompt);
window.removeEventListener('appinstalled', () => setShowInstallBtn(false));
};
}, []);
useEffect(() => {
try {
const { get, qs, hs, hash } = parseMergedParams();
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 confirmed" + (who ? " (" + who + ")" : "") + ". You can log in.");
setMode("login");
setShowAuth(true);
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("✅ Account created" + (who ? " (" + who + ")" : "") + ". Check your emails (spam too), then log in.");
setMode("login");
setShowAuth(true);
const prefill = (uname || email || last?.username || last?.email || "").trim();
if (prefill) setLoginUser(prefill);
} else if (notice) {
const msg = decodeURIComponent(notice);
if (msg && msg.trim()) {
setSignupInfo(msg.trim());
setMode("login");
setShowAuth(true);
}
}
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) {
// ignore
}
}, []);
const handleLoginSubmit = async (e) => {
e.preventDefault();
trackEvent("login_submit");
const res = await login(loginUser, loginPass);
if (res?.ok) trackEvent("login_success");
};
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);
trackEvent("signup_submit");
const u = regUser.trim();
const em = regEmail.trim();
await registerUser({
username: u,
email: em,
password: regPass,
firstName: firstName.trim(),
lastName: lastName.trim(),
});
try {
localStorage.setItem(LAST_SIGNUP_KEY, JSON.stringify({ username: u, email: em }));
} catch {}
setSignupInfo("✅ Account created (" + em + "). Check your emails (spam too), then log in.");
trackEvent("signup_success");
setMode("login");
setLoginUser(u || em);
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 ? "Email verification required" : "Login failed";
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 && typeof clearError === "function") clearError();
if (lastInfo && typeof clearInfo === "function") clearInfo();
}}
/>
</div>
) : null}
<header className="topbar">
<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>
<div className="sub-title">Wired to life</div>
</div>
</div>
{/* Theme dots */}
<div className="brand-under">
<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>
</div>
</div>
<div className="topbar-right">
{/* User info */}
<div className="topbar-user-section">
{authenticated ? (
<button
type="button"
className="sw-userchip sw-userchip-btn"
title={username}
onClick={() => onUserIsland && onUserIsland(username)}
>
<div className="sw-avatar">
{avatarUrl ? <img src={avatarUrl} alt={username} /> : <span>{initialLetter(username)}</span>}
</div>
<div className="sw-usertext">
<div className="sw-userline">Online</div>
<div className="sw-userhandle">@{displayUsername}</div>
</div>
</button>
) : (
<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>
)}
</div>
{/* Action buttons - separate section */}
<div className="topbar-actions">
{/* PWA Install button - no dismiss X */}
{showInstallBtn && (
<button
className="btn-action btn-action-install"
type="button"
onClick={() => promptInstall()}
title="Install App"
>
<i className="fa-solid fa-download"></i>
</button>
)}
{/* Login/Logout button */}
<button
className="btn-action btn-action-auth"
type="button"
onClick={() => {
if (authenticated) {
trackEvent("logout_click");
logout();
} else {
openModal("login");
}
}}
disabled={loading}
title={authenticated ? "Logout" : "Login"}
>
<i className={authenticated ? "fa-solid fa-right-from-bracket" : "fa-solid fa-right-to-bracket"}></i>
<span className="btn-label">{authenticated ? "Logout" : "Login"}</span>
</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>
{signupInfo ? <div className="auth-notice auth-notice-success">{signupInfo}</div> : null}
{authNotice ? <div className="auth-notice auth-notice-warn">{authNotice}</div> : null}
{mode === "login" && lastError ? (
<div className={"auth-notice " + (needsEmailVerification ? "auth-notice-warn" : "auth-notice-error")}>
{lastError}
</div>
) : null}
{mode === "login" ? (
<form className="auth-form" onSubmit={handleLoginSubmit}>
<label>
Username or email
<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="button"
className="auth-link"
onClick={() => window.open(resetPasswordUrl, "_blank", "noopener,noreferrer")}
>
Forgot password?
</button>
<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 }}>
Your account requires email verification. Confirm your email, then try logging in again.
</div>
) : null}
{ssoEnabled ? (
<>
<div className="auth-divider">or continue with</div>
<div className="auth-oauth">
<button
type="button"
className="btn-oauth btn-oauth-google"
disabled={loading}
onClick={() => {
trackEvent("login_provider_click", { provider: "google" });
loginWithProvider && loginWithProvider("google");
}}
>
<i className="fa-brands fa-google"></i>
<span>Continue with Google</span>
</button>
<button
type="button"
className="btn-oauth btn-oauth-facebook"
disabled={loading}
onClick={() => {
trackEvent("login_provider_click", { provider: "facebook" });
loginWithProvider && loginWithProvider("facebook");
}}
>
<i className="fa-brands fa-facebook-f"></i>
<span>Continue with Facebook</span>
</button>
</div>
</>
) : null}
</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-notice auth-notice-error">{signupError}</div> : null}
<button type="submit" className="btn-primary btn-full" disabled={signupLoading}>
{signupLoading ? "..." : "Create account"}
</button>
</form>
)}
</div>
</div>
) : null}
{showUsernameModal ? (
<div className="auth-modal-backdrop" onClick={() => {}}>
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
<div className="auth-modal-header">
<div className="auth-tab auth-tab-active">Choose username</div>
</div>
<div className="auth-notice auth-notice-info">
Pick a public username to complete your profile.
</div>
{usernameError ? <div className="auth-notice auth-notice-error">{usernameError}</div> : null}
{usernameInfo ? <div className="auth-notice auth-notice-success">{usernameInfo}</div> : null}
<form className="auth-form" onSubmit={handleUsernameSave}>
<label>
Username
<input
type="text"
value={desiredUsername}
onChange={(e) => setDesiredUsername(e.target.value)}
autoComplete="username"
/>
</label>
<button type="submit" className="btn-primary btn-full" disabled={usernameSaving}>
{usernameSaving ? "Saving..." : "Save username"}
</button>
</form>
</div>
</div>
) : null}
</>
);
}