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:
parent
8f2e5b8c18
commit
a1ffc300ff
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue