923 lines
41 KiB
JavaScript
923 lines
41 KiB
JavaScript
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useAuth } from "../../auth/AuthContext";
|
|
import { registerUser, updateProfileUsername, fetchFriendRequests, toSmallImageUrl } from "../../api/client";
|
|
import AuthToast from "../Auth/AuthToast";
|
|
import { promptInstall, isRunningAsPWA, canInstall } from '../../pwaInstall';
|
|
import { trackEvent } from "../../utils/analytics";
|
|
import FriendsList from "../Friends/FriendsList";
|
|
|
|
const SUPPORTED_LANGS = ['en', 'fr', 'es'];
|
|
const THEMES = ['dark', 'blue', 'light'];
|
|
const LAST_SIGNUP_KEY = "sociowire:lastSignup";
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Utilities
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
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 };
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Animation Variants
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
const dropdownVariants = {
|
|
hidden: { opacity: 0, y: -8, scale: 0.95 },
|
|
visible: { opacity: 1, y: 0, scale: 1, transition: { duration: 0.15, ease: "easeOut" } },
|
|
exit: { opacity: 0, y: -8, scale: 0.95, transition: { duration: 0.1 } },
|
|
};
|
|
|
|
const buttonVariants = {
|
|
hover: { scale: 1.05, y: -2 },
|
|
tap: { scale: 0.95 },
|
|
};
|
|
|
|
const badgeVariants = {
|
|
initial: { scale: 0 },
|
|
animate: { scale: 1, transition: { type: "spring", stiffness: 500, damping: 25 } },
|
|
};
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Sub-components
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
function ActionButton({ icon, label, active, color = "default", badge, onClick, title, disabled }) {
|
|
const colorClasses = {
|
|
default: "from-surface-800/80 to-surface-700/80 border-surface-600/30 text-surface-100 hover:border-accent/50 hover:shadow-glow-sm",
|
|
green: "from-emerald-600 to-emerald-700 border-emerald-500/50 text-white hover:from-emerald-500 hover:to-emerald-600",
|
|
purple: "from-violet-600 to-purple-700 border-violet-500/50 text-white hover:from-violet-500 hover:to-purple-600",
|
|
amber: "from-amber-500 to-orange-600 border-amber-400/50 text-white hover:from-amber-400 hover:to-orange-500",
|
|
cyan: "from-cyan-500/20 to-cyan-600/20 border-cyan-400/40 text-cyan-100 hover:border-cyan-300/60",
|
|
};
|
|
|
|
return (
|
|
<motion.button
|
|
type="button"
|
|
className={`
|
|
relative h-7 w-7 sm:h-8 sm:w-auto sm:px-3 rounded-full
|
|
bg-gradient-to-br ${colorClasses[color]}
|
|
border backdrop-blur-sm
|
|
font-semibold text-sm
|
|
inline-flex items-center justify-center gap-1.5
|
|
transition-colors duration-200
|
|
disabled:opacity-50 disabled:cursor-not-allowed
|
|
flex-shrink-0
|
|
${active ? "ring-2 ring-accent/50 shadow-glow-sm" : ""}
|
|
`}
|
|
onClick={onClick}
|
|
title={title}
|
|
disabled={disabled}
|
|
variants={buttonVariants}
|
|
whileHover={!disabled ? "hover" : undefined}
|
|
whileTap={!disabled ? "tap" : undefined}
|
|
>
|
|
<i className={`${icon} text-xs sm:text-sm`} />
|
|
{label && <span className="hidden sm:inline text-xs">{label}</span>}
|
|
|
|
{badge > 0 && (
|
|
<motion.span
|
|
className="absolute -top-1 -right-1 min-w-[16px] h-[16px] px-0.5
|
|
rounded-full bg-red-500 text-white text-[9px] font-bold
|
|
flex items-center justify-center
|
|
border border-surface-900 shadow-lg"
|
|
variants={badgeVariants}
|
|
initial="initial"
|
|
animate="animate"
|
|
>
|
|
{badge}
|
|
</motion.span>
|
|
)}
|
|
</motion.button>
|
|
);
|
|
}
|
|
|
|
function DropdownMenu({ show, position, children, onClose }) {
|
|
const menuRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
if (!show) return;
|
|
const handleClick = (e) => {
|
|
if (menuRef.current && !menuRef.current.contains(e.target)) {
|
|
onClose?.();
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", handleClick);
|
|
return () => document.removeEventListener("mousedown", handleClick);
|
|
}, [show, onClose]);
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{show && (
|
|
<motion.div
|
|
ref={menuRef}
|
|
className="fixed z-[1001] min-w-[140px]
|
|
bg-surface-900/98 backdrop-blur-xl
|
|
border border-surface-600/50 rounded-2xl
|
|
shadow-2xl overflow-hidden"
|
|
style={{ top: position.top, right: position.right }}
|
|
variants={dropdownVariants}
|
|
initial="hidden"
|
|
animate="visible"
|
|
exit="exit"
|
|
>
|
|
{children}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
}
|
|
|
|
function DropdownOption({ active, onClick, children }) {
|
|
return (
|
|
<motion.button
|
|
type="button"
|
|
className={`
|
|
w-full px-4 py-3 text-left text-sm font-medium
|
|
transition-colors duration-150
|
|
${active
|
|
? "bg-accent/20 text-accent"
|
|
: "text-surface-200 hover:bg-surface-700/50"
|
|
}
|
|
`}
|
|
onClick={onClick}
|
|
whileHover={{ x: 4 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
>
|
|
{children}
|
|
</motion.button>
|
|
);
|
|
}
|
|
|
|
function UserChip({ authenticated, loading, username, avatarUrl, onClick }) {
|
|
const { t } = useTranslation();
|
|
|
|
const displayUsername = useMemo(() => {
|
|
const raw = (username || "user").toString();
|
|
if (raw.includes("@")) return "user";
|
|
if (raw.length <= 6) return raw;
|
|
return raw.slice(0, 6) + "..";
|
|
}, [username]);
|
|
|
|
return (
|
|
<motion.button
|
|
type="button"
|
|
className="flex items-center gap-1 sm:gap-2 px-1.5 sm:px-2 py-1 sm:py-1.5 rounded-full
|
|
bg-surface-900/40 backdrop-blur-sm flex-shrink-0
|
|
border border-surface-600/30
|
|
hover:border-accent/50 hover:shadow-glow-sm
|
|
transition-all duration-200"
|
|
onClick={onClick}
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
>
|
|
{/* Avatar */}
|
|
<div className="w-7 h-7 rounded-full overflow-hidden
|
|
bg-gradient-to-br from-blue-500 via-cyan-500 to-blue-400 p-0.5">
|
|
{avatarUrl ? (
|
|
<img src={toSmallImageUrl(avatarUrl)} alt={username} className="w-full h-full rounded-full object-cover bg-surface-900" />
|
|
) : (
|
|
<div className="w-full h-full rounded-full bg-surface-800 flex items-center justify-center text-white font-bold text-xs">
|
|
{authenticated ? initialLetter(username) : <i className="fa-solid fa-user text-[10px]" />}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Text - hidden on mobile */}
|
|
<div className="hidden sm:flex flex-col leading-none">
|
|
<span className="text-[10px] font-bold text-surface-100">
|
|
{loading ? t('common.loading') : authenticated ? t('common.online') : t('common.guest')}
|
|
</span>
|
|
<span className="text-[9px] font-semibold text-surface-400">
|
|
@{authenticated ? displayUsername : t('common.anon')}
|
|
</span>
|
|
</div>
|
|
</motion.button>
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Main Component
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClick, onUserIsland }) {
|
|
const { t, i18n } = useTranslation();
|
|
const {
|
|
loading, authenticated, username, login, logout, restore, loginWithProvider, ssoEnabled,
|
|
avatarUrl, profileLoaded, needsUsername, needsEmailVerification, lastError, lastInfo, clearError, clearInfo,
|
|
} = useAuth();
|
|
|
|
// Dropdown states
|
|
const [showLangMenu, setShowLangMenu] = useState(false);
|
|
const [langMenuPos, setLangMenuPos] = useState({ top: 0, right: 0 });
|
|
const langBtnRef = useRef(null);
|
|
|
|
const [showThemeMenu, setShowThemeMenu] = useState(false);
|
|
const [themeMenuPos, setThemeMenuPos] = useState({ top: 0, right: 0 });
|
|
const themeBtnRef = useRef(null);
|
|
|
|
const [showAuth, setShowAuth] = useState(false);
|
|
const [mode, setMode] = useState("login");
|
|
const [showUsernameModal, setShowUsernameModal] = useState(false);
|
|
const [showFriends, setShowFriends] = useState(false);
|
|
const [pendingRequestsCount, setPendingRequestsCount] = useState(0);
|
|
const [showInstallBtn, setShowInstallBtn] = useState(false);
|
|
|
|
// Form states
|
|
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 [authNotice, setAuthNotice] = useState("");
|
|
const [desiredUsername, setDesiredUsername] = useState("");
|
|
const [usernameError, setUsernameError] = useState("");
|
|
const [usernameInfo, setUsernameInfo] = useState("");
|
|
const [usernameSaving, setUsernameSaving] = useState(false);
|
|
|
|
const usernamePromptTimer = useRef(null);
|
|
|
|
// Toggle functions
|
|
const toggleLangMenu = () => {
|
|
if (!showLangMenu && langBtnRef.current) {
|
|
const rect = langBtnRef.current.getBoundingClientRect();
|
|
setLangMenuPos({ top: rect.bottom + 8, right: window.innerWidth - rect.right });
|
|
}
|
|
setShowLangMenu(!showLangMenu);
|
|
setShowThemeMenu(false);
|
|
};
|
|
|
|
const toggleThemeMenu = () => {
|
|
if (!showThemeMenu && themeBtnRef.current) {
|
|
const rect = themeBtnRef.current.getBoundingClientRect();
|
|
setThemeMenuPos({ top: rect.bottom + 8, right: window.innerWidth - rect.right });
|
|
}
|
|
setShowThemeMenu(!showThemeMenu);
|
|
setShowLangMenu(false);
|
|
};
|
|
|
|
const openModal = (nextMode) => {
|
|
setMode(nextMode);
|
|
setShowAuth(true);
|
|
setSignupError("");
|
|
trackEvent("auth_modal_open", { mode: nextMode || "login" });
|
|
};
|
|
|
|
const closeModal = () => {
|
|
setShowAuth(false);
|
|
setLoginPass("");
|
|
setSignupError("");
|
|
setAuthNotice("");
|
|
};
|
|
|
|
// Reset password URL
|
|
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;
|
|
return `${base}/realms/${realm}/login-actions/reset-credentials?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}`;
|
|
}, []);
|
|
|
|
// Effects
|
|
useEffect(() => {
|
|
if (authenticated) setShowAuth(false);
|
|
}, [authenticated]);
|
|
|
|
useEffect(() => {
|
|
if (!authenticated) { setPendingRequestsCount(0); return; }
|
|
const loadRequests = async () => {
|
|
try {
|
|
const res = await fetchFriendRequests();
|
|
const received = (res?.requests || []).filter(r => r?.direction === 'received');
|
|
setPendingRequestsCount(received.length);
|
|
} catch { setPendingRequestsCount(0); }
|
|
};
|
|
loadRequests();
|
|
const interval = setInterval(loadRequests, 30000);
|
|
return () => clearInterval(interval);
|
|
}, [authenticated]);
|
|
|
|
useEffect(() => {
|
|
const checkInstall = () => {
|
|
if (isRunningAsPWA()) { setShowInstallBtn(false); return; }
|
|
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(() => {
|
|
const handler = (e) => {
|
|
const detail = e?.detail || {};
|
|
const msg = (detail.message || "").toString().trim() || t('auth.pleaseSignIn');
|
|
setAuthNotice(msg);
|
|
setMode(detail.mode === "signup" ? "signup" : "login");
|
|
setShowAuth(true);
|
|
};
|
|
window.addEventListener("sociowire:auth-required", handler);
|
|
return () => window.removeEventListener("sociowire:auth-required", handler);
|
|
}, [t]);
|
|
|
|
// Username prompt
|
|
useEffect(() => {
|
|
const requiresUsername = (name) => {
|
|
if (!name) return false;
|
|
if (name.includes("@")) return true;
|
|
return !/^[A-Za-z0-9_.]{3,24}$/.test(name);
|
|
};
|
|
if (usernamePromptTimer.current) clearTimeout(usernamePromptTimer.current);
|
|
if (loading) return;
|
|
if (!authenticated) { setShowUsernameModal(false); return; }
|
|
const shouldPrompt = !!needsUsername || requiresUsername(username);
|
|
if (shouldPrompt && !showUsernameModal) {
|
|
const base = (username || "").split("@")[0] || "";
|
|
const cleaned = base.replace(/[^A-Za-z0-9_.]/g, "").slice(0, 24);
|
|
setDesiredUsername(cleaned);
|
|
usernamePromptTimer.current = setTimeout(() => setShowUsernameModal(true), 600);
|
|
} else if (!shouldPrompt && showUsernameModal) {
|
|
setShowUsernameModal(false);
|
|
}
|
|
}, [authenticated, username, showUsernameModal, loading, needsUsername]);
|
|
|
|
// Handlers
|
|
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(t('auth.fieldsRequired'));
|
|
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("✅ " + t('auth.accountCreatedMsg') + " (" + em + ")");
|
|
trackEvent("signup_success");
|
|
setMode("login");
|
|
setLoginUser(u || em);
|
|
setLoginPass("");
|
|
} catch (err) {
|
|
setSignupError(err.message || t('auth.registrationError'));
|
|
} finally {
|
|
setSignupLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleUsernameSave = async (e) => {
|
|
e.preventDefault();
|
|
const next = (desiredUsername || "").trim();
|
|
if (!/^[A-Za-z0-9_.]{3,24}$/.test(next) || next.includes("@")) {
|
|
setUsernameError(t('auth.usernameHint'));
|
|
return;
|
|
}
|
|
setUsernameSaving(true);
|
|
setUsernameError("");
|
|
const res = await updateProfileUsername(next);
|
|
setUsernameSaving(false);
|
|
if (!res.ok) {
|
|
setUsernameError(res.status === 409 ? t('auth.usernameTaken') : (res.text || t('auth.usernameUpdateError')));
|
|
return;
|
|
}
|
|
setUsernameInfo(t('auth.usernameSaved'));
|
|
setShowUsernameModal(false);
|
|
await restore();
|
|
};
|
|
|
|
// Toast
|
|
const toast = useMemo(() => {
|
|
if (lastError) {
|
|
const toastType = needsEmailVerification ? "warn" : "error";
|
|
const title = needsEmailVerification ? t('auth.emailVerificationRequired') : t('auth.loginFailed');
|
|
return { type: toastType, title, message: lastError };
|
|
}
|
|
if (lastInfo) return { type: "success", title: "", message: lastInfo };
|
|
return null;
|
|
}, [lastError, lastInfo, needsEmailVerification, t]);
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Render
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
return (
|
|
<>
|
|
{/* Toast */}
|
|
{toast?.message && (
|
|
<div className="fixed top-16 left-1/2 -translate-x-1/2 z-[1002]">
|
|
<AuthToast
|
|
type={toast.type}
|
|
title={toast.title}
|
|
message={toast.message}
|
|
onClose={() => {
|
|
if (lastError) clearError?.();
|
|
if (lastInfo) clearInfo?.();
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Header - Hot Social Network Style */}
|
|
<motion.header
|
|
className="sticky top-0 z-[1000] w-full"
|
|
initial={{ y: -60, opacity: 0 }}
|
|
animate={{ y: 0, opacity: 1 }}
|
|
transition={{ duration: 0.4, ease: "easeOut" }}
|
|
>
|
|
{/* Glassmorphism background with gradient */}
|
|
<div className="absolute inset-0 bg-gradient-to-r from-surface-950/98 via-surface-900/95 to-surface-950/98 backdrop-blur-2xl" />
|
|
{/* Animated accent line */}
|
|
<motion.div
|
|
className="absolute bottom-0 left-0 right-0 h-[2px]"
|
|
style={{ background: "linear-gradient(90deg, #3b82f6, #06b6d4, #10b981, #06b6d4, #3b82f6)", backgroundSize: "200% 100%" }}
|
|
animate={{ backgroundPosition: ["0% 0%", "200% 0%"] }}
|
|
transition={{ duration: 3, repeat: Infinity, ease: "linear" }}
|
|
/>
|
|
|
|
<div className="relative flex items-center justify-between gap-3 px-3 py-2 sm:px-4 sm:py-2.5">
|
|
{/* Left: Logo with pulse effect */}
|
|
<motion.div
|
|
className="flex items-center gap-3 cursor-pointer"
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
>
|
|
<div className="relative">
|
|
<motion.div
|
|
className="absolute -inset-1 rounded-2xl bg-gradient-to-r from-blue-500 to-cyan-500 opacity-50 blur-lg"
|
|
animate={{ opacity: [0.3, 0.6, 0.3] }}
|
|
transition={{ duration: 2, repeat: Infinity }}
|
|
/>
|
|
<div className="relative w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 via-cyan-500 to-emerald-500 p-[2px] shadow-lg shadow-cyan-500/25">
|
|
<div className="w-full h-full rounded-[10px] bg-surface-900 flex items-center justify-center">
|
|
<img src="/logo.png" alt="S" className="w-6 h-6 object-contain" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="hidden sm:flex flex-col">
|
|
<span className="text-lg font-black text-transparent bg-clip-text bg-gradient-to-r from-white via-blue-100 to-cyan-200">
|
|
SOCIOWIRE
|
|
</span>
|
|
<span className="flex items-center gap-1.5 text-[10px] font-medium text-surface-400">
|
|
<motion.span
|
|
className="w-2 h-2 rounded-full bg-emerald-500"
|
|
animate={{ scale: [1, 1.3, 1], opacity: [1, 0.7, 1] }}
|
|
transition={{ duration: 1.5, repeat: Infinity }}
|
|
/>
|
|
{t('topbar.wiredToLife')}
|
|
</span>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Center: Theme pills (desktop) */}
|
|
<div className="hidden md:flex items-center gap-1 px-1.5 py-1 rounded-full bg-surface-800/40 border border-white/5">
|
|
{THEMES.map((themeKey) => (
|
|
<motion.button
|
|
key={themeKey}
|
|
className={`px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-wider transition-all
|
|
${theme === themeKey
|
|
? "bg-gradient-to-r from-blue-500 to-cyan-500 text-white shadow-lg shadow-cyan-500/25"
|
|
: "text-surface-400 hover:text-white hover:bg-white/5"}`}
|
|
onClick={() => onChangeTheme?.(themeKey)}
|
|
whileHover={{ scale: 1.05 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
>
|
|
{t(`theme.${themeKey}`)}
|
|
</motion.button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Right: Action buttons */}
|
|
<div className="flex items-center gap-1.5 sm:gap-2">
|
|
{/* Install PWA */}
|
|
{showInstallBtn && (
|
|
<motion.button
|
|
className="flex items-center gap-2 px-3 py-2 rounded-xl
|
|
bg-gradient-to-r from-emerald-500/20 to-cyan-500/20
|
|
border border-emerald-500/30 text-emerald-400
|
|
text-xs font-bold hover:from-emerald-500/30 hover:to-cyan-500/30 transition-all"
|
|
onClick={() => promptInstall()}
|
|
whileHover={{ scale: 1.05 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
>
|
|
<i className="fa-solid fa-download" />
|
|
<span className="hidden sm:inline">{t('topbar.install')}</span>
|
|
</motion.button>
|
|
)}
|
|
|
|
{/* Theme (mobile) */}
|
|
<div className="md:hidden" ref={themeBtnRef}>
|
|
<motion.button
|
|
className={`w-9 h-9 rounded-xl flex items-center justify-center transition-all
|
|
${showThemeMenu ? "bg-cyan-500/20 text-cyan-400 border-cyan-500/40" : "bg-surface-800/50 text-surface-400 border-white/5"} border`}
|
|
onClick={toggleThemeMenu}
|
|
whileHover={{ scale: 1.1 }}
|
|
whileTap={{ scale: 0.9 }}
|
|
>
|
|
<i className="fa-solid fa-palette" />
|
|
</motion.button>
|
|
</div>
|
|
|
|
{/* Language */}
|
|
<div ref={langBtnRef}>
|
|
<motion.button
|
|
className={`w-9 h-9 rounded-xl flex items-center justify-center transition-all
|
|
${showLangMenu ? "bg-blue-500/20 text-blue-400 border-blue-500/40" : "bg-surface-800/50 text-surface-400 border-white/5"} border`}
|
|
onClick={toggleLangMenu}
|
|
whileHover={{ scale: 1.1 }}
|
|
whileTap={{ scale: 0.9 }}
|
|
>
|
|
<i className="fa-solid fa-globe" />
|
|
</motion.button>
|
|
</div>
|
|
|
|
{/* Friends */}
|
|
{authenticated && (
|
|
<motion.button
|
|
className={`relative w-9 h-9 rounded-xl flex items-center justify-center transition-all
|
|
${showFriends ? "bg-amber-500/20 text-amber-400 border-amber-500/40" : "bg-surface-800/50 text-surface-400 border-white/5"} border`}
|
|
onClick={() => setShowFriends(!showFriends)}
|
|
whileHover={{ scale: 1.1 }}
|
|
whileTap={{ scale: 0.9 }}
|
|
>
|
|
<i className="fa-solid fa-user-group" />
|
|
{pendingRequestsCount > 0 && (
|
|
<motion.span
|
|
className="absolute -top-1.5 -right-1.5 min-w-[18px] h-[18px] px-1
|
|
rounded-full bg-gradient-to-r from-red-500 to-pink-500 text-white text-[10px] font-bold
|
|
flex items-center justify-center shadow-lg shadow-red-500/50"
|
|
initial={{ scale: 0 }}
|
|
animate={{ scale: 1 }}
|
|
>
|
|
{pendingRequestsCount}
|
|
</motion.span>
|
|
)}
|
|
</motion.button>
|
|
)}
|
|
|
|
{/* Separator */}
|
|
<div className="hidden sm:block w-px h-7 bg-gradient-to-b from-transparent via-white/20 to-transparent mx-1" />
|
|
|
|
{/* User/Auth */}
|
|
{authenticated ? (
|
|
<div className="flex items-center gap-2">
|
|
<UserChip
|
|
authenticated={authenticated}
|
|
loading={loading}
|
|
username={username}
|
|
avatarUrl={avatarUrl}
|
|
onClick={() => onUserIsland?.(username)}
|
|
/>
|
|
<motion.button
|
|
type="button"
|
|
className="w-9 h-9 rounded-xl bg-surface-800/50 border border-white/5
|
|
text-surface-400 hover:text-red-400 hover:border-red-500/30 hover:bg-red-500/10
|
|
flex items-center justify-center transition-all"
|
|
onClick={() => { trackEvent("logout_click"); logout(); }}
|
|
whileHover={{ scale: 1.05 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
title={t('topbar.logout')}
|
|
>
|
|
<i className="fa-solid fa-arrow-right-from-bracket text-sm" />
|
|
</motion.button>
|
|
</div>
|
|
) : (
|
|
<motion.button
|
|
type="button"
|
|
className="relative group flex items-center gap-2 px-4 py-2.5 rounded-xl overflow-hidden
|
|
text-white font-bold text-sm"
|
|
onClick={() => openModal("login")}
|
|
disabled={loading}
|
|
whileHover={{ scale: 1.03 }}
|
|
whileTap={{ scale: 0.97 }}
|
|
>
|
|
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500 to-teal-500" />
|
|
<motion.div
|
|
className="absolute inset-0 bg-gradient-to-r from-emerald-400 to-teal-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
/>
|
|
<i className="fa-solid fa-user-plus relative" />
|
|
<span className="relative hidden sm:inline">{t('topbar.joinNow')}</span>
|
|
</motion.button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</motion.header>
|
|
|
|
{/* Dropdowns */}
|
|
<DropdownMenu show={showLangMenu} position={langMenuPos} onClose={() => setShowLangMenu(false)}>
|
|
{SUPPORTED_LANGS.map((lang) => (
|
|
<DropdownOption
|
|
key={lang}
|
|
active={i18n.language?.startsWith(lang)}
|
|
onClick={() => { i18n.changeLanguage(lang); setShowLangMenu(false); }}
|
|
>
|
|
{t(`language.${lang}`)}
|
|
</DropdownOption>
|
|
))}
|
|
</DropdownMenu>
|
|
|
|
<DropdownMenu show={showThemeMenu} position={themeMenuPos} onClose={() => setShowThemeMenu(false)}>
|
|
{THEMES.map((themeKey) => (
|
|
<DropdownOption
|
|
key={themeKey}
|
|
active={theme === themeKey}
|
|
onClick={() => { onChangeTheme?.(themeKey); setShowThemeMenu(false); }}
|
|
>
|
|
{t(`theme.${themeKey}`)}
|
|
</DropdownOption>
|
|
))}
|
|
</DropdownMenu>
|
|
|
|
{/* Friends Panel */}
|
|
<AnimatePresence>
|
|
{showFriends && (
|
|
<motion.div
|
|
className="fixed top-16 right-4 z-[1000]"
|
|
initial={{ opacity: 0, y: -10, scale: 0.95 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
exit={{ opacity: 0, y: -10, scale: 0.95 }}
|
|
>
|
|
<FriendsList
|
|
onSelectUser={(uname) => { setShowFriends(false); onUserIsland?.(uname); }}
|
|
onClose={() => setShowFriends(false)}
|
|
/>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Auth Modal */}
|
|
<AnimatePresence>
|
|
{showAuth && (
|
|
<motion.div
|
|
className="fixed inset-0 z-[10000] flex items-center justify-center p-4
|
|
bg-black/60 backdrop-blur-sm"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
onClick={closeModal}
|
|
>
|
|
<motion.div
|
|
className="w-full max-w-md bg-surface-900/98 backdrop-blur-xl
|
|
rounded-3xl border border-surface-600/50 shadow-2xl overflow-hidden"
|
|
initial={{ scale: 0.9, y: 20 }}
|
|
animate={{ scale: 1, y: 0 }}
|
|
exit={{ scale: 0.9, y: 20 }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header tabs */}
|
|
<div className="flex border-b border-surface-700/50">
|
|
<button
|
|
type="button"
|
|
className={`flex-1 py-4 text-sm font-bold transition-colors
|
|
${mode === "login" ? "text-accent border-b-2 border-accent" : "text-surface-400 hover:text-surface-200"}`}
|
|
onClick={() => setMode("login")}
|
|
>
|
|
{t('auth.login')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`flex-1 py-4 text-sm font-bold transition-colors
|
|
${mode === "signup" ? "text-accent border-b-2 border-accent" : "text-surface-400 hover:text-surface-200"}`}
|
|
onClick={() => setMode("signup")}
|
|
>
|
|
{t('auth.signup')}
|
|
</button>
|
|
<button type="button" className="px-4 text-surface-400 hover:text-surface-100" onClick={closeModal}>
|
|
<i className="fa-solid fa-xmark" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Notices */}
|
|
{signupInfo && <div className="mx-4 mt-4 p-3 rounded-xl bg-emerald-500/20 border border-emerald-500/40 text-emerald-200 text-sm">{signupInfo}</div>}
|
|
{authNotice && <div className="mx-4 mt-4 p-3 rounded-xl bg-amber-500/20 border border-amber-500/40 text-amber-200 text-sm">{authNotice}</div>}
|
|
{mode === "login" && lastError && (
|
|
<div className={`mx-4 mt-4 p-3 rounded-xl text-sm ${needsEmailVerification ? "bg-amber-500/20 border border-amber-500/40 text-amber-200" : "bg-red-500/20 border border-red-500/40 text-red-200"}`}>
|
|
{lastError}
|
|
</div>
|
|
)}
|
|
|
|
{/* Forms */}
|
|
<div className="p-6">
|
|
{mode === "login" ? (
|
|
<form className="space-y-4" onSubmit={handleLoginSubmit}>
|
|
<label className="block">
|
|
<span className="text-sm text-surface-300 mb-1 block">{t('auth.usernameOrEmail')}</span>
|
|
<input
|
|
type="text"
|
|
value={loginUser}
|
|
onChange={(e) => setLoginUser(e.target.value)}
|
|
className="w-full px-4 py-3 rounded-xl bg-surface-800/80 border border-surface-600/50
|
|
text-surface-100 placeholder-surface-500 focus:border-accent/50 focus:outline-none"
|
|
autoComplete="username"
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
<span className="text-sm text-surface-300 mb-1 block">{t('auth.password')}</span>
|
|
<input
|
|
type="password"
|
|
value={loginPass}
|
|
onChange={(e) => setLoginPass(e.target.value)}
|
|
className="w-full px-4 py-3 rounded-xl bg-surface-800/80 border border-surface-600/50
|
|
text-surface-100 placeholder-surface-500 focus:border-accent/50 focus:outline-none"
|
|
autoComplete="current-password"
|
|
/>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
className="text-sm text-accent hover:underline"
|
|
onClick={() => window.open(resetPasswordUrl, "_blank", "noopener,noreferrer")}
|
|
>
|
|
{t('auth.forgotPassword')}
|
|
</button>
|
|
<motion.button
|
|
type="submit"
|
|
className="w-full py-3 rounded-xl bg-gradient-to-r from-emerald-500 to-emerald-600
|
|
text-white font-bold shadow-lg disabled:opacity-50"
|
|
disabled={loading}
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
>
|
|
{loading ? "..." : t('auth.signIn')}
|
|
</motion.button>
|
|
|
|
{ssoEnabled && (
|
|
<>
|
|
<div className="flex items-center gap-4 my-4">
|
|
<div className="flex-1 h-px bg-surface-700" />
|
|
<span className="text-xs text-surface-500">{t('auth.orContinueWith')}</span>
|
|
<div className="flex-1 h-px bg-surface-700" />
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<motion.button
|
|
type="button"
|
|
className="flex-1 py-3 rounded-xl bg-white text-gray-800 font-semibold
|
|
flex items-center justify-center gap-2"
|
|
onClick={() => loginWithProvider?.("google")}
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
>
|
|
<i className="fa-brands fa-google" />
|
|
Google
|
|
</motion.button>
|
|
<motion.button
|
|
type="button"
|
|
className="flex-1 py-3 rounded-xl bg-blue-600 text-white font-semibold
|
|
flex items-center justify-center gap-2"
|
|
onClick={() => loginWithProvider?.("facebook")}
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
>
|
|
<i className="fa-brands fa-facebook-f" />
|
|
Facebook
|
|
</motion.button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</form>
|
|
) : (
|
|
<form className="space-y-4" onSubmit={handleSignupSubmit}>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<label className="block">
|
|
<span className="text-sm text-surface-300 mb-1 block">{t('auth.firstName')}</span>
|
|
<input type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)}
|
|
className="w-full px-4 py-3 rounded-xl bg-surface-800/80 border border-surface-600/50 text-surface-100 focus:border-accent/50 focus:outline-none" />
|
|
</label>
|
|
<label className="block">
|
|
<span className="text-sm text-surface-300 mb-1 block">{t('auth.lastName')}</span>
|
|
<input type="text" value={lastName} onChange={(e) => setLastName(e.target.value)}
|
|
className="w-full px-4 py-3 rounded-xl bg-surface-800/80 border border-surface-600/50 text-surface-100 focus:border-accent/50 focus:outline-none" />
|
|
</label>
|
|
</div>
|
|
<label className="block">
|
|
<span className="text-sm text-surface-300 mb-1 block">{t('auth.username')}</span>
|
|
<input type="text" value={regUser} onChange={(e) => setRegUser(e.target.value)}
|
|
className="w-full px-4 py-3 rounded-xl bg-surface-800/80 border border-surface-600/50 text-surface-100 focus:border-accent/50 focus:outline-none" />
|
|
</label>
|
|
<label className="block">
|
|
<span className="text-sm text-surface-300 mb-1 block">{t('auth.email')}</span>
|
|
<input type="email" value={regEmail} onChange={(e) => setRegEmail(e.target.value)}
|
|
className="w-full px-4 py-3 rounded-xl bg-surface-800/80 border border-surface-600/50 text-surface-100 focus:border-accent/50 focus:outline-none" />
|
|
</label>
|
|
<label className="block">
|
|
<span className="text-sm text-surface-300 mb-1 block">{t('auth.password')}</span>
|
|
<input type="password" value={regPass} onChange={(e) => setRegPass(e.target.value)}
|
|
className="w-full px-4 py-3 rounded-xl bg-surface-800/80 border border-surface-600/50 text-surface-100 focus:border-accent/50 focus:outline-none" />
|
|
</label>
|
|
{signupError && <div className="p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-200 text-sm">{signupError}</div>}
|
|
<motion.button
|
|
type="submit"
|
|
className="w-full py-3 rounded-xl bg-gradient-to-r from-emerald-500 to-emerald-600 text-white font-bold shadow-lg disabled:opacity-50"
|
|
disabled={signupLoading}
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
>
|
|
{signupLoading ? "..." : t('auth.createAccount')}
|
|
</motion.button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Username Modal */}
|
|
<AnimatePresence>
|
|
{showUsernameModal && (
|
|
<motion.div
|
|
className="fixed inset-0 z-[10000] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
>
|
|
<motion.div
|
|
className="w-full max-w-md bg-surface-900/98 backdrop-blur-xl rounded-3xl border border-surface-600/50 shadow-2xl p-6"
|
|
initial={{ scale: 0.9, y: 20 }}
|
|
animate={{ scale: 1, y: 0 }}
|
|
exit={{ scale: 0.9, y: 20 }}
|
|
>
|
|
<h3 className="text-lg font-bold text-surface-100 mb-2">{t('auth.chooseUsername')}</h3>
|
|
<p className="text-sm text-surface-400 mb-4">{t('auth.pickUsername')}</p>
|
|
{usernameError && <div className="mb-4 p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-200 text-sm">{usernameError}</div>}
|
|
{usernameInfo && <div className="mb-4 p-3 rounded-xl bg-emerald-500/20 border border-emerald-500/40 text-emerald-200 text-sm">{usernameInfo}</div>}
|
|
<form onSubmit={handleUsernameSave} className="space-y-4">
|
|
<input
|
|
type="text"
|
|
value={desiredUsername}
|
|
onChange={(e) => setDesiredUsername(e.target.value)}
|
|
className="w-full px-4 py-3 rounded-xl bg-surface-800/80 border border-surface-600/50 text-surface-100 focus:border-accent/50 focus:outline-none"
|
|
placeholder={t('auth.username')}
|
|
/>
|
|
<motion.button
|
|
type="submit"
|
|
className="w-full py-3 rounded-xl bg-gradient-to-r from-blue-500 to-cyan-500 text-white font-bold shadow-lg disabled:opacity-50"
|
|
disabled={usernameSaving}
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
>
|
|
{usernameSaving ? t('auth.savingUsername') : t('auth.saveUsername')}
|
|
</motion.button>
|
|
</form>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</>
|
|
);
|
|
}
|