feat: TopBar refonte + map markers animations + theme system

- Add TopBarNew with Framer Motion animations and compact design
- Enhance map markers with bounce-in animations and hover effects
- Improve cluster styles with gradient backgrounds
- Add comprehensive theme system (dark/blue/light modes)
- Add theme-aware utilities and improved contrasts
- Better accessibility with focus rings and motion preferences

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-01-19 09:15:35 +00:00
parent 8f2e5b8c18
commit a1ffc300ff
4 changed files with 1469 additions and 1 deletions

View File

@ -1,7 +1,7 @@
import React, { Suspense, useEffect, useState, useCallback } from "react"; import React, { Suspense, useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { version as appVersion } from "../package.json"; import { version as appVersion } from "../package.json";
import TopBar from "./components/Layout/TopBar"; import TopBar from "./components/Layout/TopBarNew";
import PostList from "./components/Posts/PostList"; import PostList from "./components/Posts/PostList";
import ContentFilters from "./components/Filters/ContentFilters"; import ContentFilters from "./components/Filters/ContentFilters";
import IslandUniverse from "./components/Island/IslandUniverse"; import IslandUniverse from "./components/Island/IslandUniverse";

View File

@ -0,0 +1,865 @@
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-8 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-2
transition-colors duration-200
disabled:opacity-50 disabled:cursor-not-allowed
${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-sm`} />
{label && <span className="hidden sm:inline text-xs">{label}</span>}
{badge > 0 && (
<motion.span
className="absolute -top-1.5 -right-1.5 min-w-[18px] h-[18px] px-1
rounded-full bg-red-500 text-white text-[10px] font-bold
flex items-center justify-center
border-2 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 <= 9) return raw;
return raw.slice(0, 9) + "..";
}, [username]);
return (
<motion.button
type="button"
className="flex items-center gap-2 px-2 py-1.5 rounded-full
bg-surface-900/40 backdrop-blur-sm
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-accent-dark via-purple-500 to-pink-500 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 */}
<motion.header
className="sticky top-0 z-[1000] w-full px-3 py-2
flex items-center justify-between gap-3
bg-gradient-to-r from-surface-950/95 via-surface-900/95 to-surface-950/95
backdrop-blur-xl
border-b border-surface-700/50
shadow-lg"
initial={{ y: -60, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.4, ease: "easeOut" }}
>
{/* Left: Logo & Brand */}
<div className="flex items-center gap-3">
<motion.div
className="w-10 h-10 sm:w-11 sm:h-11 flex-shrink-0"
whileHover={{ scale: 1.05, rotate: 5 }}
whileTap={{ scale: 0.95 }}
>
<img src="/logo.png" alt="SocioWire" className="w-full h-full object-contain" />
</motion.div>
<div className="flex flex-col leading-tight">
<span className="text-sm sm:text-base font-black text-surface-50 tracking-wide">
SOCIOWIRE
</span>
<span className="hidden sm:block text-[10px] font-medium text-surface-400">
{t('topbar.wiredToLife')}
</span>
</div>
{/* Theme dots - desktop only */}
<div className="hidden md:flex items-center gap-1.5 ml-2">
{THEMES.map((themeKey) => (
<motion.button
key={themeKey}
type="button"
className={`
w-5 h-5 rounded-full text-[9px] font-bold
border transition-all duration-200
${theme === themeKey
? "border-amber-400 shadow-[0_0_10px_rgba(251,191,36,0.6)] bg-surface-800"
: "border-surface-500 bg-surface-900/80 hover:border-surface-400"
}
`}
title={t(`theme.${themeKey}`)}
onClick={() => onChangeTheme?.(themeKey)}
whileHover={{ scale: 1.15 }}
whileTap={{ scale: 0.9 }}
>
{t(`theme.${themeKey}`)[0]}
</motion.button>
))}
</div>
</div>
{/* Right: Actions */}
<div className="flex items-center gap-2 px-2 py-1.5 rounded-full
bg-surface-900/50 backdrop-blur-sm border border-surface-700/30">
{/* User chip */}
<UserChip
authenticated={authenticated}
loading={loading}
username={username}
avatarUrl={avatarUrl}
onClick={() => authenticated && onUserIsland?.(username)}
/>
{/* PWA Install */}
{showInstallBtn && (
<ActionButton
icon="fa-solid fa-download"
color="purple"
onClick={() => promptInstall()}
title={t('topbar.installApp')}
/>
)}
{/* Theme - mobile */}
<div className="md:hidden" ref={themeBtnRef}>
<ActionButton
icon="fa-solid fa-palette"
color="purple"
active={showThemeMenu}
onClick={toggleThemeMenu}
title={t(`theme.${theme}`)}
/>
</div>
{/* Language */}
<div ref={langBtnRef}>
<ActionButton
icon="fa-solid fa-globe"
label={i18n.language?.toUpperCase().slice(0, 2)}
color="cyan"
active={showLangMenu}
onClick={toggleLangMenu}
title={t('language.selectLanguage')}
/>
</div>
{/* Friends */}
{authenticated && (
<ActionButton
icon="fa-solid fa-user-group"
color="amber"
active={showFriends}
badge={pendingRequestsCount}
onClick={() => setShowFriends(!showFriends)}
title={t('topbar.friends')}
/>
)}
{/* Auth */}
<ActionButton
icon={authenticated ? "fa-solid fa-right-from-bracket" : "fa-solid fa-right-to-bracket"}
label={authenticated ? t('topbar.logout') : t('topbar.login')}
color="green"
onClick={() => {
if (authenticated) { trackEvent("logout_click"); logout(); }
else openModal("login");
}}
disabled={loading}
title={authenticated ? t('topbar.logout') : t('topbar.login')}
/>
</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-accent to-purple-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>
</>
);
}

View File

@ -1460,3 +1460,304 @@ body[data-theme="light"] .sw-modal-x{
border-right: 6px solid transparent; border-right: 6px solid transparent;
border-top: 8px solid #3b82f6; border-top: 8px solid #3b82f6;
} }
/*
ENHANCED MARKER ANIMATIONS (v2 - Framer Motion style)
*/
/* Smooth entry animations */
@keyframes markerBounceIn {
0% {
opacity: 0;
transform: scale(0) translateY(20px);
}
50% {
transform: scale(1.15) translateY(-5px);
}
70% {
transform: scale(0.95) translateY(2px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes markerFadeSlide {
0% {
opacity: 0;
transform: translateY(16px) scale(0.9);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes markerPulseRing {
0% {
transform: scale(0.8);
opacity: 0.8;
}
50% {
transform: scale(1.4);
opacity: 0;
}
100% {
transform: scale(0.8);
opacity: 0;
}
}
@keyframes glowPulse {
0%, 100% {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3), 0 0 0 rgba(96, 165, 250, 0);
}
50% {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4), 0 0 20px rgba(96, 165, 250, 0.4);
}
}
/* Simple marker enhancements */
.sw-simple-marker {
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1),
box-shadow 0.25s ease,
border-color 0.2s ease;
will-change: transform;
}
.post-pin--simple:hover .sw-simple-marker {
transform: scale(1.2) translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4), 0 0 15px rgba(96, 165, 250, 0.35);
border-color: rgba(96, 165, 250, 0.8);
}
/* Live marker pulsing glow */
.sw-live-marker {
animation: glowPulse 2s ease-in-out infinite;
}
.sw-live-marker::before {
content: "";
position: absolute;
inset: -4px;
border-radius: 50%;
background: rgba(239, 68, 68, 0.3);
animation: markerPulseRing 2s ease-out infinite;
pointer-events: none;
}
/* Hover card smooth animation */
.sw-simple-hover-card {
transform: translateX(-50%) translateY(-8px) scale(0.95);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.post-pin--simple:hover .sw-simple-hover-card {
opacity: 1;
transform: translateX(-50%) translateY(-12px) scale(1);
pointer-events: auto;
}
/* Entry animation for markers */
.sw-appear {
opacity: 0;
animation: markerFadeSlide 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.sw-appear-in {
opacity: 1;
}
/* Stagger effect for multiple markers */
.sw-appear:nth-child(2) { animation-delay: 0.05s; }
.sw-appear:nth-child(3) { animation-delay: 0.1s; }
.sw-appear:nth-child(4) { animation-delay: 0.15s; }
.sw-appear:nth-child(5) { animation-delay: 0.2s; }
/* Cluster marker styles */
.sw-cluster-marker {
position: relative;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.sw-cluster-marker:hover {
transform: scale(1.1);
}
.sw-cluster-bubble {
min-width: 44px;
height: 44px;
padding: 0 12px;
border-radius: 22px;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.95), rgba(139, 92, 246, 0.9));
border: 3px solid rgba(255, 255, 255, 0.95);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35), 0 0 20px rgba(96, 165, 250, 0.25);
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: all 0.25s ease;
}
.sw-cluster-marker:hover .sw-cluster-bubble {
background: linear-gradient(135deg, rgba(96, 165, 250, 0.98), rgba(167, 139, 250, 0.95));
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4), 0 0 30px rgba(96, 165, 250, 0.4);
}
.sw-cluster-count {
font-size: 16px;
font-weight: 800;
color: #fff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.sw-cluster-icon {
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
}
.sw-cluster-pointer {
position: absolute;
bottom: -10px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-top: 14px solid rgba(59, 130, 246, 0.95);
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
}
/* Category badges on markers */
.sw-marker-badge {
position: absolute;
top: -6px;
right: -6px;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: linear-gradient(135deg, #ef4444, #dc2626);
border: 2px solid #fff;
font-size: 10px;
font-weight: 700;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
/* Modal overlay improvements */
.sw-centered-modal {
animation: modalFadeIn 0.35s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes modalFadeIn {
0% {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* Modal backdrop blur */
.sw-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(5, 8, 22, 0.8);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
animation: backdropFadeIn 0.3s ease;
}
@keyframes backdropFadeIn {
0% { opacity: 0; }
100% { opacity: 1; }
}
/* Improved hero section with gradient overlay */
.sw-modal-hero {
position: relative;
overflow: hidden;
border-radius: 12px;
}
.sw-modal-hero::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
to bottom,
transparent 0%,
transparent 50%,
rgba(15, 23, 42, 0.8) 100%
);
pointer-events: none;
}
.sw-modal-hero img {
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
.sw-modal-hero:hover img {
transform: scale(1.05);
}
/* Stats bar with micro-interactions */
.sw-stat-pill {
transition: all 0.2s ease;
cursor: pointer;
}
.sw-stat-pill:hover {
transform: translateY(-2px);
background: rgba(96, 165, 250, 0.25) !important;
border-color: rgba(96, 165, 250, 0.5) !important;
}
.sw-stat-pill:active {
transform: scale(0.95);
}
/* Truth score rail animation */
.sw-truth-rail-marker {
transition: top 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* CTA button hover effect */
.sw-cta-primary {
position: relative;
overflow: hidden;
transition: all 0.25s ease;
}
.sw-cta-primary::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.15), transparent);
transform: translateX(-100%);
transition: transform 0.5s ease;
}
.sw-cta-primary:hover::before {
transform: translateX(100%);
}
.sw-cta-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(96, 165, 250, 0.3);
}

View File

@ -282,3 +282,305 @@ html {
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
/*
Enhanced Theme System - Dark/Blue/Light modes
*/
/* Theme variables - Blue (default) */
:root,
body[data-theme="blue"] {
--theme-bg-primary: #050816;
--theme-bg-secondary: #0a1628;
--theme-bg-tertiary: #0f1d32;
--theme-bg-elevated: #142238;
--theme-text-primary: #f1f5f9;
--theme-text-secondary: #94a3b8;
--theme-text-muted: #64748b;
--theme-border: rgba(59, 130, 246, 0.2);
--theme-border-hover: rgba(96, 165, 250, 0.4);
--theme-accent: #60a5fa;
--theme-accent-soft: rgba(96, 165, 250, 0.15);
--theme-gradient-start: #0a1628;
--theme-gradient-end: #050816;
--theme-glow: rgba(96, 165, 250, 0.3);
}
/* Theme variables - Dark */
body[data-theme="dark"] {
--theme-bg-primary: #09090b;
--theme-bg-secondary: #18181b;
--theme-bg-tertiary: #27272a;
--theme-bg-elevated: #3f3f46;
--theme-text-primary: #fafafa;
--theme-text-secondary: #a1a1aa;
--theme-text-muted: #71717a;
--theme-border: rgba(255, 255, 255, 0.1);
--theme-border-hover: rgba(255, 255, 255, 0.2);
--theme-accent: #a78bfa;
--theme-accent-soft: rgba(167, 139, 250, 0.15);
--theme-gradient-start: #18181b;
--theme-gradient-end: #09090b;
--theme-glow: rgba(167, 139, 250, 0.3);
}
/* Theme variables - Light */
body[data-theme="light"] {
--theme-bg-primary: #ffffff;
--theme-bg-secondary: #f8fafc;
--theme-bg-tertiary: #f1f5f9;
--theme-bg-elevated: #ffffff;
--theme-text-primary: #0f172a;
--theme-text-secondary: #475569;
--theme-text-muted: #94a3b8;
--theme-border: rgba(15, 23, 42, 0.1);
--theme-border-hover: rgba(59, 130, 246, 0.3);
--theme-accent: #3b82f6;
--theme-accent-soft: rgba(59, 130, 246, 0.1);
--theme-gradient-start: #ffffff;
--theme-gradient-end: #f1f5f9;
--theme-glow: rgba(59, 130, 246, 0.2);
}
/*
Theme-aware components
*/
/* Themed container */
.themed-container {
background: linear-gradient(180deg, var(--theme-gradient-start), var(--theme-gradient-end));
color: var(--theme-text-primary);
}
/* Themed card */
.themed-card {
background: var(--theme-bg-elevated);
border: 1px solid var(--theme-border);
border-radius: 1rem;
transition: all 0.3s ease;
}
.themed-card:hover {
border-color: var(--theme-border-hover);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2), 0 0 16px var(--theme-glow);
}
/* Themed text */
.text-themed-primary { color: var(--theme-text-primary); }
.text-themed-secondary { color: var(--theme-text-secondary); }
.text-themed-muted { color: var(--theme-text-muted); }
.text-themed-accent { color: var(--theme-accent); }
/* Themed backgrounds */
.bg-themed-primary { background: var(--theme-bg-primary); }
.bg-themed-secondary { background: var(--theme-bg-secondary); }
.bg-themed-elevated { background: var(--theme-bg-elevated); }
.bg-themed-accent { background: var(--theme-accent-soft); }
/* Themed borders */
.border-themed { border-color: var(--theme-border); }
.border-themed-hover:hover { border-color: var(--theme-border-hover); }
/*
Improved Contrast Utilities
*/
/* High contrast text for accessibility */
.text-high-contrast {
color: #ffffff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
body[data-theme="light"] .text-high-contrast {
color: #0f172a;
text-shadow: none;
}
/* Readable text on images */
.text-on-image {
color: #ffffff;
text-shadow:
0 1px 2px rgba(0, 0, 0, 0.8),
0 2px 8px rgba(0, 0, 0, 0.4);
}
/* Focus ring for accessibility */
.focus-ring:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--theme-accent-soft), 0 0 0 5px var(--theme-accent);
}
/*
Interactive Elements
*/
/* Hover lift effect */
.hover-lift {
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1),
box-shadow 0.25s ease;
}
.hover-lift:hover {
transform: translateY(-4px);
}
/* Press scale effect */
.press-scale {
transition: transform 0.15s ease;
}
.press-scale:active {
transform: scale(0.97);
}
/* Glow on hover */
.hover-glow {
transition: box-shadow 0.3s ease;
}
.hover-glow:hover {
box-shadow: 0 0 20px var(--theme-glow);
}
/*
Status & State Indicators
*/
/* Online indicator */
.status-online {
position: relative;
}
.status-online::after {
content: "";
position: absolute;
bottom: 0;
right: 0;
width: 12px;
height: 12px;
border-radius: 50%;
background: #22c55e;
border: 2px solid var(--theme-bg-primary);
box-shadow: 0 0 8px rgba(34, 197, 94, 0.5);
}
/* New content indicator */
.has-new::before {
content: "";
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
border-radius: 50%;
background: #ef4444;
animation: livePulse 1.5s ease-in-out infinite;
}
/* Loading shimmer overlay */
.shimmer-overlay {
position: relative;
overflow: hidden;
}
.shimmer-overlay::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.05) 50%,
transparent 100%
);
background-size: 200% 100%;
animation: shimmer 2s linear infinite;
}
/*
Spacing & Layout Helpers
*/
/* Safe area padding for mobile */
.safe-area-top { padding-top: env(safe-area-inset-top, 0); }
.safe-area-bottom { padding-bottom: env(safe-area-inset-bottom, 0); }
.safe-area-left { padding-left: env(safe-area-inset-left, 0); }
.safe-area-right { padding-right: env(safe-area-inset-right, 0); }
/* Content container with max-width */
.content-container {
width: 100%;
max-width: 1400px;
margin-left: auto;
margin-right: auto;
padding-left: 1rem;
padding-right: 1rem;
}
@media (min-width: 640px) {
.content-container {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
}
/*
Motion & Transitions
*/
/* Reduced motion preference */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Smooth entrance animations */
@keyframes enterFromBottom {
0% {
opacity: 0;
transform: translateY(16px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes enterFromLeft {
0% {
opacity: 0;
transform: translateX(-16px);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
@keyframes enterFromRight {
0% {
opacity: 0;
transform: translateX(16px);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
.animate-enter-bottom {
animation: enterFromBottom 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.animate-enter-left {
animation: enterFromLeft 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.animate-enter-right {
animation: enterFromRight 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}