TopBar redesign & PostDetailModal improvements (v0.0.87)
TopBar: - Responsive logo size (adapts to mobile) - Logo positioned correctly (+5px down, +2px right) - Brand text "SOCIOWIRE" visible on all sizes - Tagline "Wired to Life" hidden on mobile - Smaller action buttons on mobile (w-7 → w-9 responsive) - Friends button always visible (opens login if not auth) - Removed duplicate search bar and location button PostDetailModal: - Truth score block made more compact - Comments use correct API fields (username, body) - Comments auto-open when there are existing comments - Fixed comment API response handling (ok at end of spread) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
37f879556e
commit
68c1fce064
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "sociowire-frontend",
|
"name": "sociowire-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.75",
|
"version": "0.0.87",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
|
|
||||||
|
|
@ -821,7 +821,7 @@ export async function createPostComment(postId, body) {
|
||||||
body: JSON.stringify({ post_id: postId, body }),
|
body: JSON.stringify({ post_id: postId, body }),
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
return { ok: res.ok, ...data };
|
return { ...data, ok: res.ok };
|
||||||
} catch {
|
} catch {
|
||||||
return { ok: false };
|
return { ok: false };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ function parseMergedParams() {
|
||||||
return { qs, hs, get, hash };
|
return { qs, hs, get, hash };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick, onUserIsland }) {
|
export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick, onUserIsland, onSearch, onMyLocation }) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const {
|
const {
|
||||||
loading,
|
loading,
|
||||||
|
|
@ -79,45 +79,23 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
||||||
} = useAuth();
|
} = useAuth();
|
||||||
|
|
||||||
const [showLangMenu, setShowLangMenu] = useState(false);
|
const [showLangMenu, setShowLangMenu] = useState(false);
|
||||||
const [langMenuPos, setLangMenuPos] = useState({ top: 0, right: 0 });
|
|
||||||
const langBtnRef = useRef(null);
|
|
||||||
|
|
||||||
const [showThemeMenu, setShowThemeMenu] = useState(false);
|
const [showThemeMenu, setShowThemeMenu] = useState(false);
|
||||||
const [themeMenuPos, setThemeMenuPos] = useState({ top: 0, right: 0 });
|
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||||
|
const langBtnRef = useRef(null);
|
||||||
const themeBtnRef = useRef(null);
|
const themeBtnRef = useRef(null);
|
||||||
|
const userMenuRef = useRef(null);
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [searchFocused, setSearchFocused] = useState(false);
|
||||||
|
|
||||||
const changeLanguage = (lang) => {
|
const changeLanguage = (lang) => {
|
||||||
i18n.changeLanguage(lang);
|
i18n.changeLanguage(lang);
|
||||||
setShowLangMenu(false);
|
setShowLangMenu(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleLangMenu = () => {
|
// Close menus when clicking outside
|
||||||
if (!showLangMenu && langBtnRef.current) {
|
|
||||||
const rect = langBtnRef.current.getBoundingClientRect();
|
|
||||||
setLangMenuPos({
|
|
||||||
top: rect.bottom + 6,
|
|
||||||
right: window.innerWidth - rect.right,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setShowLangMenu(!showLangMenu);
|
|
||||||
setShowThemeMenu(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleThemeMenu = () => {
|
|
||||||
if (!showThemeMenu && themeBtnRef.current) {
|
|
||||||
const rect = themeBtnRef.current.getBoundingClientRect();
|
|
||||||
setThemeMenuPos({
|
|
||||||
top: rect.bottom + 6,
|
|
||||||
right: window.innerWidth - rect.right,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setShowThemeMenu(!showThemeMenu);
|
|
||||||
setShowLangMenu(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Close lang/theme menu when clicking outside
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showLangMenu && !showThemeMenu) return;
|
if (!showLangMenu && !showThemeMenu && !showUserMenu) return;
|
||||||
const handleClick = (e) => {
|
const handleClick = (e) => {
|
||||||
if (showLangMenu && langBtnRef.current && !langBtnRef.current.contains(e.target)) {
|
if (showLangMenu && langBtnRef.current && !langBtnRef.current.contains(e.target)) {
|
||||||
setShowLangMenu(false);
|
setShowLangMenu(false);
|
||||||
|
|
@ -125,10 +103,13 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
||||||
if (showThemeMenu && themeBtnRef.current && !themeBtnRef.current.contains(e.target)) {
|
if (showThemeMenu && themeBtnRef.current && !themeBtnRef.current.contains(e.target)) {
|
||||||
setShowThemeMenu(false);
|
setShowThemeMenu(false);
|
||||||
}
|
}
|
||||||
|
if (showUserMenu && userMenuRef.current && !userMenuRef.current.contains(e.target)) {
|
||||||
|
setShowUserMenu(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener("click", handleClick);
|
document.addEventListener("click", handleClick);
|
||||||
return () => document.removeEventListener("click", handleClick);
|
return () => document.removeEventListener("click", handleClick);
|
||||||
}, [showLangMenu, showThemeMenu]);
|
}, [showLangMenu, showThemeMenu, showUserMenu]);
|
||||||
|
|
||||||
const [showAuth, setShowAuth] = useState(false);
|
const [showAuth, setShowAuth] = useState(false);
|
||||||
const [mode, setMode] = useState("login");
|
const [mode, setMode] = useState("login");
|
||||||
|
|
@ -140,14 +121,13 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
||||||
const [usernameInfo, setUsernameInfo] = useState("");
|
const [usernameInfo, setUsernameInfo] = useState("");
|
||||||
const [usernameSaving, setUsernameSaving] = useState(false);
|
const [usernameSaving, setUsernameSaving] = useState(false);
|
||||||
const usernamePromptTimer = useRef(null);
|
const usernamePromptTimer = useRef(null);
|
||||||
const invalidSinceRef = useRef(null);
|
|
||||||
const [stableUsername, setStableUsername] = useState("");
|
const [stableUsername, setStableUsername] = useState("");
|
||||||
|
|
||||||
const displayUsername = (() => {
|
const displayUsername = (() => {
|
||||||
const raw = (stableUsername || username || "user").toString();
|
const raw = (stableUsername || username || "user").toString();
|
||||||
if (!stableUsername && raw.includes("@")) return "user";
|
if (!stableUsername && raw.includes("@")) return "user";
|
||||||
if (raw.length <= 9) return raw;
|
if (raw.length <= 12) return raw;
|
||||||
return raw.slice(0, 9) + "..";
|
return raw.slice(0, 12) + "..";
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const [loginUser, setLoginUser] = useState("");
|
const [loginUser, setLoginUser] = useState("");
|
||||||
|
|
@ -229,16 +209,13 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
||||||
const loadRequests = async () => {
|
const loadRequests = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetchFriendRequests();
|
const res = await fetchFriendRequests();
|
||||||
// Only count received requests (not sent ones)
|
|
||||||
const received = (res?.requests || []).filter(r => r?.direction === 'received');
|
const received = (res?.requests || []).filter(r => r?.direction === 'received');
|
||||||
setPendingRequestsCount(received.length);
|
setPendingRequestsCount(received.length);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[TopBar] Error fetching friend requests:', err);
|
|
||||||
setPendingRequestsCount(0);
|
setPendingRequestsCount(0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadRequests();
|
loadRequests();
|
||||||
// Refresh every 30 seconds
|
|
||||||
const interval = setInterval(loadRequests, 30000);
|
const interval = setInterval(loadRequests, 30000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [authenticated]);
|
}, [authenticated]);
|
||||||
|
|
@ -303,9 +280,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e) => {
|
const handler = (e) => {
|
||||||
const detail = e?.detail || {};
|
const detail = e?.detail || {};
|
||||||
const msg =
|
const msg = (detail.message || "").toString().trim() || t('auth.pleaseSignIn');
|
||||||
(detail.message || "").toString().trim() ||
|
|
||||||
t('auth.pleaseSignIn');
|
|
||||||
setAuthNotice(msg);
|
setAuthNotice(msg);
|
||||||
setMode(detail.mode === "signup" ? "signup" : "login");
|
setMode(detail.mode === "signup" ? "signup" : "login");
|
||||||
setShowAuth(true);
|
setShowAuth(true);
|
||||||
|
|
@ -314,27 +289,19 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
||||||
return () => window.removeEventListener("sociowire:auth-required", handler);
|
return () => window.removeEventListener("sociowire:auth-required", handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Check if PWA install should be shown (simple: hide if already PWA)
|
// Check if PWA install should be shown
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkInstall = () => {
|
const checkInstall = () => {
|
||||||
// Hide if already running as PWA
|
|
||||||
if (isRunningAsPWA()) {
|
if (isRunningAsPWA()) {
|
||||||
setShowInstallBtn(false);
|
setShowInstallBtn(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Show if can install
|
|
||||||
setShowInstallBtn(canInstall());
|
setShowInstallBtn(canInstall());
|
||||||
};
|
};
|
||||||
|
|
||||||
checkInstall();
|
checkInstall();
|
||||||
|
const handlePrompt = () => setTimeout(checkInstall, 100);
|
||||||
const handlePrompt = () => {
|
|
||||||
setTimeout(checkInstall, 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('beforeinstallprompt', handlePrompt);
|
window.addEventListener('beforeinstallprompt', handlePrompt);
|
||||||
window.addEventListener('appinstalled', () => setShowInstallBtn(false));
|
window.addEventListener('appinstalled', () => setShowInstallBtn(false));
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('beforeinstallprompt', handlePrompt);
|
window.removeEventListener('beforeinstallprompt', handlePrompt);
|
||||||
window.removeEventListener('appinstalled', () => setShowInstallBtn(false));
|
window.removeEventListener('appinstalled', () => setShowInstallBtn(false));
|
||||||
|
|
@ -344,28 +311,11 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const { get, qs, hs, hash } = parseMergedParams();
|
const { get, qs, hs, hash } = parseMergedParams();
|
||||||
|
const verified = get("verified") || get("validated") || get("email_verified") || get("emailVerified") || get("kc_verified");
|
||||||
const verified =
|
|
||||||
get("verified") ||
|
|
||||||
get("validated") ||
|
|
||||||
get("email_verified") ||
|
|
||||||
get("emailVerified") ||
|
|
||||||
get("kc_verified");
|
|
||||||
|
|
||||||
const signup = get("signup");
|
const signup = get("signup");
|
||||||
const notice = get("notice");
|
const notice = get("notice");
|
||||||
|
const email = get("email") || get("user_email") || get("mail");
|
||||||
const email =
|
const uname = get("username") || get("user") || get("preferred_username") || get("login");
|
||||||
get("email") ||
|
|
||||||
get("user_email") ||
|
|
||||||
get("mail");
|
|
||||||
|
|
||||||
const uname =
|
|
||||||
get("username") ||
|
|
||||||
get("user") ||
|
|
||||||
get("preferred_username") ||
|
|
||||||
get("login");
|
|
||||||
|
|
||||||
const last = readLastSignup();
|
const last = readLastSignup();
|
||||||
|
|
||||||
if (truthyParam(verified)) {
|
if (truthyParam(verified)) {
|
||||||
|
|
@ -373,11 +323,9 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
||||||
setSignupInfo("✅ " + t('auth.emailConfirmedMsg') + (who ? " (" + who + ")" : ""));
|
setSignupInfo("✅ " + t('auth.emailConfirmedMsg') + (who ? " (" + who + ")" : ""));
|
||||||
setMode("login");
|
setMode("login");
|
||||||
setShowAuth(true);
|
setShowAuth(true);
|
||||||
|
|
||||||
const prefill = (uname || email || last?.username || last?.email || "").trim();
|
const prefill = (uname || email || last?.username || last?.email || "").trim();
|
||||||
if (prefill) setLoginUser(prefill);
|
if (prefill) setLoginUser(prefill);
|
||||||
} else if (truthyParam(signup)) {
|
} else if (truthyParam(signup)) {
|
||||||
// Open FRESH signup form - clear all old data
|
|
||||||
try { localStorage.removeItem(LAST_SIGNUP_KEY); } catch {}
|
try { localStorage.removeItem(LAST_SIGNUP_KEY); } catch {}
|
||||||
setSignupInfo("");
|
setSignupInfo("");
|
||||||
setSignupError("");
|
setSignupError("");
|
||||||
|
|
@ -394,8 +342,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const touched =
|
const touched = qs.has("verified") || qs.has("validated") || qs.has("email_verified") || qs.has("emailVerified") || qs.has("signup") || qs.has("notice") ||
|
||||||
qs.has("verified") || qs.has("validated") || qs.has("email_verified") || qs.has("emailVerified") || qs.has("signup") || qs.has("notice") ||
|
|
||||||
hs.has("verified") || hs.has("validated") || hs.has("email_verified") || hs.has("emailVerified") || hs.has("signup") || hs.has("notice");
|
hs.has("verified") || hs.has("validated") || hs.has("email_verified") || hs.has("emailVerified") || hs.has("signup") || hs.has("notice");
|
||||||
|
|
||||||
if (touched) {
|
if (touched) {
|
||||||
|
|
@ -403,9 +350,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
||||||
const clean = window.location.pathname + cleanHash;
|
const clean = window.location.pathname + cleanHash;
|
||||||
window.history.replaceState({}, "", clean);
|
window.history.replaceState({}, "", clean);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {}
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLoginSubmit = async (e) => {
|
const handleLoginSubmit = async (e) => {
|
||||||
|
|
@ -428,35 +373,29 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
||||||
try {
|
try {
|
||||||
setSignupLoading(true);
|
setSignupLoading(true);
|
||||||
trackEvent("signup_submit");
|
trackEvent("signup_submit");
|
||||||
|
|
||||||
const u = regUser.trim();
|
const u = regUser.trim();
|
||||||
const em = regEmail.trim();
|
const em = regEmail.trim();
|
||||||
|
await registerUser({ username: u, email: em, password: regPass, firstName: firstName.trim(), lastName: lastName.trim() });
|
||||||
await registerUser({
|
try { localStorage.setItem(LAST_SIGNUP_KEY, JSON.stringify({ username: u, email: em })); } catch {}
|
||||||
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 + ")");
|
setSignupInfo("✅ " + t('auth.accountCreatedMsg') + " (" + em + ")");
|
||||||
trackEvent("signup_success");
|
trackEvent("signup_success");
|
||||||
setMode("login");
|
setMode("login");
|
||||||
setLoginUser(u || em);
|
setLoginUser(u || em);
|
||||||
setLoginPass("");
|
setLoginPass("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
|
||||||
setSignupError(err.message || t('auth.registrationError'));
|
setSignupError(err.message || t('auth.registrationError'));
|
||||||
} finally {
|
} finally {
|
||||||
setSignupLoading(false);
|
setSignupLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSearchSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (searchQuery.trim() && onSearch) {
|
||||||
|
onSearch(searchQuery.trim());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const toast = useMemo(() => {
|
const toast = useMemo(() => {
|
||||||
if (lastError) {
|
if (lastError) {
|
||||||
const toastType = needsEmailVerification ? "warn" : "error";
|
const toastType = needsEmailVerification ? "warn" : "error";
|
||||||
|
|
@ -469,10 +408,12 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
||||||
return null;
|
return null;
|
||||||
}, [lastError, lastInfo, needsEmailVerification, t]);
|
}, [lastError, lastInfo, needsEmailVerification, t]);
|
||||||
|
|
||||||
|
const themeIcon = theme === 'dark' ? 'fa-moon' : theme === 'light' ? 'fa-sun' : 'fa-droplet';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Toast area (under topbar) */}
|
{/* Toast area */}
|
||||||
{toast?.message ? (
|
{toast?.message && (
|
||||||
<div className="sw-toast-wrap">
|
<div className="sw-toast-wrap">
|
||||||
<AuthToast
|
<AuthToast
|
||||||
type={toast.type}
|
type={toast.type}
|
||||||
|
|
@ -484,137 +425,119 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
|
|
||||||
<header className="topbar">
|
|
||||||
<div className="brand-stack">
|
|
||||||
<div className="brand-toprow">
|
|
||||||
<div className="logo-circle"><img src="/logo.png" alt="SocioWire" /></div>
|
|
||||||
<div className="title-block">
|
|
||||||
<div className="main-title">SOCIOWIRE.com</div>
|
|
||||||
<div className="sub-title">{t('topbar.wiredToLife')}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Theme dots */}
|
|
||||||
<div className="brand-under">
|
|
||||||
<div className="theme-switch">
|
|
||||||
{["dark", "blue", "light"].map((themeKey) => (
|
|
||||||
<button
|
|
||||||
key={themeKey}
|
|
||||||
type="button"
|
|
||||||
className={"theme-dot" + (theme === themeKey ? " theme-dot-active" : "")}
|
|
||||||
title={t(`theme.${themeKey}`)}
|
|
||||||
onClick={() => onChangeTheme && onChangeTheme(themeKey)}
|
|
||||||
>
|
|
||||||
{t(`theme.${themeKey}`)[0]}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="topbar-right">
|
|
||||||
{/* User info */}
|
|
||||||
<div className="topbar-user-section">
|
|
||||||
{authenticated ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="sw-userchip sw-userchip-btn"
|
|
||||||
title={username}
|
|
||||||
onClick={() => onUserIsland && onUserIsland(username)}
|
|
||||||
>
|
|
||||||
<div className="sw-avatar">
|
|
||||||
{avatarUrl ? <img src={toSmallImageUrl(avatarUrl)} alt={username} /> : <span>{initialLetter(username)}</span>}
|
|
||||||
</div>
|
|
||||||
<div className="sw-usertext">
|
|
||||||
<div className="sw-userline">{t('common.online')}</div>
|
|
||||||
<div className="sw-userhandle">@{displayUsername}</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className="sw-userchip" title={t('common.guest')}>
|
|
||||||
<div className="sw-avatar sw-avatar-guest">
|
|
||||||
<i className="fa-solid fa-user" />
|
|
||||||
</div>
|
|
||||||
<div className="sw-usertext">
|
|
||||||
<div className="sw-userline">{loading ? t('common.loading') : t('common.guest')}</div>
|
|
||||||
<div className="sw-userhandle">{t('common.anon')}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<header className="sw-topbar">
|
||||||
|
{/* LEFT: Logo + Brand */}
|
||||||
|
<div className="sw-topbar-left">
|
||||||
|
<div className="sw-logo">
|
||||||
|
<img src="/logo.png" alt="SocioWire" />
|
||||||
|
</div>
|
||||||
|
<div className="sw-brand">
|
||||||
|
<span className="sw-brand-name">SocioWire</span>
|
||||||
|
<span className="sw-brand-tagline">{t('topbar.wiredToLife')}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons - separate section */}
|
{/* CENTER: Search Bar */}
|
||||||
<div className="topbar-actions">
|
<div className={`sw-topbar-center ${searchFocused ? 'is-focused' : ''}`}>
|
||||||
{/* PWA Install button - no dismiss X */}
|
<form onSubmit={handleSearchSubmit} className="sw-search-form">
|
||||||
|
<i className="fa-solid fa-magnifying-glass sw-search-icon" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
onFocus={() => setSearchFocused(true)}
|
||||||
|
onBlur={() => setSearchFocused(false)}
|
||||||
|
placeholder={t('map.searchPlaceholder')}
|
||||||
|
className="sw-search-input"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button type="button" className="sw-search-clear" onClick={() => setSearchQuery("")}>
|
||||||
|
<i className="fa-solid fa-times" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT: Actions */}
|
||||||
|
<div className="sw-topbar-right">
|
||||||
|
{/* My Location */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sw-topbar-btn sw-topbar-btn-location"
|
||||||
|
onClick={() => onMyLocation && onMyLocation()}
|
||||||
|
title={t('map.mySpot')}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-location-crosshairs" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Islands */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sw-topbar-btn sw-topbar-btn-islands"
|
||||||
|
onClick={() => onIslandsClick && onIslandsClick()}
|
||||||
|
title={t('nav.islands')}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-globe" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Install PWA */}
|
||||||
{showInstallBtn && (
|
{showInstallBtn && (
|
||||||
<button
|
<button
|
||||||
className="btn-action btn-action-install"
|
|
||||||
type="button"
|
type="button"
|
||||||
|
className="sw-topbar-btn sw-topbar-btn-install"
|
||||||
onClick={() => promptInstall()}
|
onClick={() => promptInstall()}
|
||||||
title={t('topbar.installApp')}
|
title={t('topbar.installApp')}
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-download"></i>
|
<i className="fa-solid fa-download" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Theme selector (mobile dropdown) */}
|
{/* Theme */}
|
||||||
<div className="theme-selector-wrap">
|
<div className="sw-topbar-dropdown" ref={themeBtnRef}>
|
||||||
<button
|
<button
|
||||||
ref={themeBtnRef}
|
|
||||||
className={"btn-action btn-action-theme" + (showThemeMenu ? " is-active" : "")}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleThemeMenu}
|
className={`sw-topbar-btn sw-topbar-btn-theme ${showThemeMenu ? 'is-active' : ''}`}
|
||||||
|
onClick={() => { setShowThemeMenu(!showThemeMenu); setShowLangMenu(false); }}
|
||||||
title={t(`theme.${theme}`)}
|
title={t(`theme.${theme}`)}
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-palette"></i>
|
<i className={`fa-solid ${themeIcon}`} />
|
||||||
</button>
|
</button>
|
||||||
{showThemeMenu && (
|
{showThemeMenu && (
|
||||||
<div
|
<div className="sw-dropdown-menu sw-dropdown-theme">
|
||||||
className="theme-dropdown"
|
|
||||||
style={{ top: themeMenuPos.top, right: themeMenuPos.right }}
|
|
||||||
>
|
|
||||||
{THEMES.map((themeKey) => (
|
{THEMES.map((themeKey) => (
|
||||||
<button
|
<button
|
||||||
key={themeKey}
|
key={themeKey}
|
||||||
type="button"
|
type="button"
|
||||||
className={"theme-option" + (theme === themeKey ? " is-active" : "")}
|
className={`sw-dropdown-item ${theme === themeKey ? 'is-active' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => { onChangeTheme && onChangeTheme(themeKey); setShowThemeMenu(false); }}
|
||||||
onChangeTheme && onChangeTheme(themeKey);
|
|
||||||
setShowThemeMenu(false);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t(`theme.${themeKey}`)}
|
<i className={`fa-solid ${themeKey === 'dark' ? 'fa-moon' : themeKey === 'light' ? 'fa-sun' : 'fa-droplet'}`} />
|
||||||
|
<span>{t(`theme.${themeKey}`)}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Language selector */}
|
{/* Language */}
|
||||||
<div className="lang-selector-wrap">
|
<div className="sw-topbar-dropdown" ref={langBtnRef}>
|
||||||
<button
|
<button
|
||||||
ref={langBtnRef}
|
|
||||||
className={"btn-action btn-action-lang" + (showLangMenu ? " is-active" : "")}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleLangMenu}
|
className={`sw-topbar-btn sw-topbar-btn-lang ${showLangMenu ? 'is-active' : ''}`}
|
||||||
|
onClick={() => { setShowLangMenu(!showLangMenu); setShowThemeMenu(false); }}
|
||||||
title={t('language.selectLanguage')}
|
title={t('language.selectLanguage')}
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-globe"></i>
|
<span className="sw-lang-code">{i18n.language?.toUpperCase().slice(0, 2)}</span>
|
||||||
<span className="btn-label">{i18n.language?.toUpperCase().slice(0, 2)}</span>
|
|
||||||
</button>
|
</button>
|
||||||
{showLangMenu && (
|
{showLangMenu && (
|
||||||
<div
|
<div className="sw-dropdown-menu sw-dropdown-lang">
|
||||||
className="lang-dropdown"
|
|
||||||
style={{ top: langMenuPos.top, right: langMenuPos.right }}
|
|
||||||
>
|
|
||||||
{SUPPORTED_LANGS.map((lang) => (
|
{SUPPORTED_LANGS.map((lang) => (
|
||||||
<button
|
<button
|
||||||
key={lang}
|
key={lang}
|
||||||
type="button"
|
type="button"
|
||||||
className={"lang-option" + (i18n.language?.startsWith(lang) ? " is-active" : "")}
|
className={`sw-dropdown-item ${i18n.language?.startsWith(lang) ? 'is-active' : ''}`}
|
||||||
onClick={() => changeLanguage(lang)}
|
onClick={() => changeLanguage(lang)}
|
||||||
>
|
>
|
||||||
{t(`language.${lang}`)}
|
{t(`language.${lang}`)}
|
||||||
|
|
@ -624,44 +547,78 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Friends button - only when authenticated */}
|
{/* Friends - only when authenticated */}
|
||||||
{authenticated && (
|
{authenticated && (
|
||||||
<button
|
<button
|
||||||
className={"btn-action btn-action-friends" + (showFriends ? " is-active" : "")}
|
|
||||||
type="button"
|
type="button"
|
||||||
|
className={`sw-topbar-btn sw-topbar-btn-friends ${showFriends ? 'is-active' : ''}`}
|
||||||
onClick={() => setShowFriends(!showFriends)}
|
onClick={() => setShowFriends(!showFriends)}
|
||||||
title={t('topbar.friends')}
|
title={t('topbar.friends')}
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-user-group"></i>
|
<i className="fa-solid fa-user-group" />
|
||||||
{pendingRequestsCount > 0 && (
|
{pendingRequestsCount > 0 && (
|
||||||
<span className="friends-badge-count">{pendingRequestsCount}</span>
|
<span className="sw-badge">{pendingRequestsCount}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Login/Logout button */}
|
{/* User / Login */}
|
||||||
|
<div className="sw-topbar-dropdown" ref={userMenuRef}>
|
||||||
<button
|
<button
|
||||||
className="btn-action btn-action-auth"
|
|
||||||
type="button"
|
type="button"
|
||||||
|
className={`sw-topbar-user ${showUserMenu ? 'is-active' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (authenticated) {
|
if (authenticated) {
|
||||||
trackEvent("logout_click");
|
setShowUserMenu(!showUserMenu);
|
||||||
logout();
|
|
||||||
} else {
|
} else {
|
||||||
openModal("login");
|
openModal("login");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={loading}
|
title={authenticated ? username : t('topbar.login')}
|
||||||
title={authenticated ? t('topbar.logout') : t('topbar.login')}
|
|
||||||
>
|
>
|
||||||
<i className={authenticated ? "fa-solid fa-right-from-bracket" : "fa-solid fa-right-to-bracket"}></i>
|
<div className="sw-user-avatar">
|
||||||
<span className="btn-label">{authenticated ? t('topbar.logout') : t('topbar.login')}</span>
|
{authenticated && avatarUrl ? (
|
||||||
|
<img src={toSmallImageUrl(avatarUrl)} alt={username} />
|
||||||
|
) : authenticated ? (
|
||||||
|
<span>{initialLetter(username)}</span>
|
||||||
|
) : (
|
||||||
|
<i className="fa-solid fa-user" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{authenticated && (
|
||||||
|
<div className="sw-user-info">
|
||||||
|
<span className="sw-user-name">@{displayUsername}</span>
|
||||||
|
<span className="sw-user-status">{t('common.online')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<i className="fa-solid fa-chevron-down sw-user-chevron" />
|
||||||
</button>
|
</button>
|
||||||
|
{showUserMenu && authenticated && (
|
||||||
|
<div className="sw-dropdown-menu sw-dropdown-user">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sw-dropdown-item"
|
||||||
|
onClick={() => { setShowUserMenu(false); onUserIsland && onUserIsland(username); }}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-user" />
|
||||||
|
<span>{t('map.profileIsland')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sw-dropdown-item sw-dropdown-logout"
|
||||||
|
onClick={() => { setShowUserMenu(false); trackEvent("logout_click"); logout(); }}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-right-from-bracket" />
|
||||||
|
<span>{t('topbar.logout')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{showAuth ? (
|
{/* Auth Modal */}
|
||||||
|
{showAuth && (
|
||||||
<div className="auth-modal-backdrop" onClick={closeModal}>
|
<div className="auth-modal-backdrop" onClick={closeModal}>
|
||||||
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="auth-modal-header">
|
<div className="auth-modal-header">
|
||||||
|
|
@ -679,88 +636,57 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
||||||
>
|
>
|
||||||
{t('auth.signup')}
|
{t('auth.signup')}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="auth-close" onClick={closeModal}>
|
<button type="button" className="auth-close" onClick={closeModal}>✕</button>
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{signupInfo ? <div className="auth-notice auth-notice-success">{signupInfo}</div> : null}
|
{signupInfo && <div className="auth-notice auth-notice-success">{signupInfo}</div>}
|
||||||
{authNotice ? <div className="auth-notice auth-notice-warn">{authNotice}</div> : null}
|
{authNotice && <div className="auth-notice auth-notice-warn">{authNotice}</div>}
|
||||||
{mode === "login" && lastError ? (
|
{mode === "login" && lastError && (
|
||||||
<div className={"auth-notice " + (needsEmailVerification ? "auth-notice-warn" : "auth-notice-error")}>
|
<div className={"auth-notice " + (needsEmailVerification ? "auth-notice-warn" : "auth-notice-error")}>
|
||||||
{lastError}
|
{lastError}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
|
|
||||||
{mode === "login" ? (
|
{mode === "login" ? (
|
||||||
<form className="auth-form" onSubmit={handleLoginSubmit}>
|
<form className="auth-form" onSubmit={handleLoginSubmit}>
|
||||||
<label>
|
<label>
|
||||||
{t('auth.usernameOrEmail')}
|
{t('auth.usernameOrEmail')}
|
||||||
<input
|
<input type="text" value={loginUser} onChange={(e) => setLoginUser(e.target.value)} autoComplete="username" />
|
||||||
type="text"
|
|
||||||
value={loginUser}
|
|
||||||
onChange={(e) => setLoginUser(e.target.value)}
|
|
||||||
autoComplete="username"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t('auth.password')}
|
{t('auth.password')}
|
||||||
<input
|
<input type="password" value={loginPass} onChange={(e) => setLoginPass(e.target.value)} autoComplete="current-password" />
|
||||||
type="password"
|
|
||||||
value={loginPass}
|
|
||||||
onChange={(e) => setLoginPass(e.target.value)}
|
|
||||||
autoComplete="current-password"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button type="button" className="auth-link" onClick={() => window.open(resetPasswordUrl, "_blank", "noopener,noreferrer")}>
|
||||||
type="button"
|
|
||||||
className="auth-link"
|
|
||||||
onClick={() => window.open(resetPasswordUrl, "_blank", "noopener,noreferrer")}
|
|
||||||
>
|
|
||||||
{t('auth.forgotPassword')}
|
{t('auth.forgotPassword')}
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="btn-primary btn-full" disabled={loading}>
|
<button type="submit" className="btn-primary btn-full" disabled={loading}>
|
||||||
{loading ? "..." : t('auth.signIn')}
|
{loading ? "..." : t('auth.signIn')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{needsEmailVerification ? (
|
{needsEmailVerification && (
|
||||||
<div className="auth-notice auth-notice-warn" style={{ marginTop: 10 }}>
|
<div className="auth-notice auth-notice-warn" style={{ marginTop: 10 }}>
|
||||||
⚠️ {t('auth.verifyEmail')}
|
⚠️ {t('auth.verifyEmail')}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
|
|
||||||
{ssoEnabled ? (
|
{ssoEnabled && (
|
||||||
<>
|
<>
|
||||||
<div className="auth-divider">{t('auth.orContinueWith')}</div>
|
<div className="auth-divider">{t('auth.orContinueWith')}</div>
|
||||||
<div className="auth-oauth">
|
<div className="auth-oauth">
|
||||||
<button
|
<button type="button" className="btn-oauth btn-oauth-google" disabled={loading}
|
||||||
type="button"
|
onClick={() => { trackEvent("login_provider_click", { provider: "google" }); loginWithProvider && loginWithProvider("google"); }}>
|
||||||
className="btn-oauth btn-oauth-google"
|
|
||||||
disabled={loading}
|
|
||||||
onClick={() => {
|
|
||||||
trackEvent("login_provider_click", { provider: "google" });
|
|
||||||
loginWithProvider && loginWithProvider("google");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<i className="fa-brands fa-google"></i>
|
<i className="fa-brands fa-google"></i>
|
||||||
<span>{t('auth.continueWithGoogle')}</span>
|
<span>{t('auth.continueWithGoogle')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" className="btn-oauth btn-oauth-facebook" disabled={loading}
|
||||||
type="button"
|
onClick={() => { trackEvent("login_provider_click", { provider: "facebook" }); loginWithProvider && loginWithProvider("facebook"); }}>
|
||||||
className="btn-oauth btn-oauth-facebook"
|
|
||||||
disabled={loading}
|
|
||||||
onClick={() => {
|
|
||||||
trackEvent("login_provider_click", { provider: "facebook" });
|
|
||||||
loginWithProvider && loginWithProvider("facebook");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<i className="fa-brands fa-facebook-f"></i>
|
<i className="fa-brands fa-facebook-f"></i>
|
||||||
<span>{t('auth.continueWithFacebook')}</span>
|
<span>{t('auth.continueWithFacebook')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
)}
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<form className="auth-form" onSubmit={handleSignupSubmit}>
|
<form className="auth-form" onSubmit={handleSignupSubmit}>
|
||||||
|
|
@ -786,9 +712,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
||||||
{t('auth.password')}
|
{t('auth.password')}
|
||||||
<input type="password" value={regPass} onChange={(e) => setRegPass(e.target.value)} />
|
<input type="password" value={regPass} onChange={(e) => setRegPass(e.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
|
{signupError && <div className="auth-notice auth-notice-error">{signupError}</div>}
|
||||||
{signupError ? <div className="auth-notice auth-notice-error">{signupError}</div> : null}
|
|
||||||
|
|
||||||
<button type="submit" className="btn-primary btn-full" disabled={signupLoading}>
|
<button type="submit" className="btn-primary btn-full" disabled={signupLoading}>
|
||||||
{signupLoading ? "..." : t('auth.createAccount')}
|
{signupLoading ? "..." : t('auth.createAccount')}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -796,28 +720,22 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
|
|
||||||
{showUsernameModal ? (
|
{/* Username Modal */}
|
||||||
|
{showUsernameModal && (
|
||||||
<div className="auth-modal-backdrop" onClick={() => {}}>
|
<div className="auth-modal-backdrop" onClick={() => {}}>
|
||||||
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="auth-modal-header">
|
<div className="auth-modal-header">
|
||||||
<div className="auth-tab auth-tab-active">{t('auth.chooseUsername')}</div>
|
<div className="auth-tab auth-tab-active">{t('auth.chooseUsername')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="auth-notice auth-notice-info">
|
<div className="auth-notice auth-notice-info">{t('auth.pickUsername')}</div>
|
||||||
{t('auth.pickUsername')}
|
{usernameError && <div className="auth-notice auth-notice-error">{usernameError}</div>}
|
||||||
</div>
|
{usernameInfo && <div className="auth-notice auth-notice-success">{usernameInfo}</div>}
|
||||||
{usernameError ? <div className="auth-notice auth-notice-error">{usernameError}</div> : null}
|
|
||||||
{usernameInfo ? <div className="auth-notice auth-notice-success">{usernameInfo}</div> : null}
|
|
||||||
<form className="auth-form" onSubmit={handleUsernameSave}>
|
<form className="auth-form" onSubmit={handleUsernameSave}>
|
||||||
<label>
|
<label>
|
||||||
{t('auth.username')}
|
{t('auth.username')}
|
||||||
<input
|
<input type="text" value={desiredUsername} onChange={(e) => setDesiredUsername(e.target.value)} autoComplete="username" />
|
||||||
type="text"
|
|
||||||
value={desiredUsername}
|
|
||||||
onChange={(e) => setDesiredUsername(e.target.value)}
|
|
||||||
autoComplete="username"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<button type="submit" className="btn-primary btn-full" disabled={usernameSaving}>
|
<button type="submit" className="btn-primary btn-full" disabled={usernameSaving}>
|
||||||
{usernameSaving ? t('auth.savingUsername') : t('auth.saveUsername')}
|
{usernameSaving ? t('auth.savingUsername') : t('auth.saveUsername')}
|
||||||
|
|
@ -825,16 +743,13 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
|
|
||||||
{/* Friends Panel - dropdown from topbar */}
|
{/* Friends Panel */}
|
||||||
{showFriends && (
|
{showFriends && (
|
||||||
<div className="friends-dropdown-wrap">
|
<div className="friends-dropdown-wrap">
|
||||||
<FriendsList
|
<FriendsList
|
||||||
onSelectUser={(uname) => {
|
onSelectUser={(uname) => { setShowFriends(false); if (onUserIsland) onUserIsland(uname); }}
|
||||||
setShowFriends(false);
|
|
||||||
if (onUserIsland) onUserIsland(uname);
|
|
||||||
}}
|
|
||||||
onClose={() => setShowFriends(false)}
|
onClose={() => setShowFriends(false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -207,13 +207,13 @@ function UserChip({ authenticated, loading, username, avatarUrl, onClick }) {
|
||||||
whileTap={{ scale: 0.98 }}
|
whileTap={{ scale: 0.98 }}
|
||||||
>
|
>
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<div className="w-7 h-7 rounded-full overflow-hidden
|
<div className="w-6 h-6 sm:w-7 sm:h-7 rounded-full overflow-hidden
|
||||||
bg-gradient-to-br from-blue-500 via-cyan-500 to-blue-400 p-0.5">
|
bg-gradient-to-br from-blue-500 via-cyan-500 to-blue-400 p-0.5">
|
||||||
{avatarUrl ? (
|
{avatarUrl ? (
|
||||||
<img src={toSmallImageUrl(avatarUrl)} alt={username} className="w-full h-full rounded-full object-cover bg-surface-900" />
|
<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">
|
<div className="w-full h-full rounded-full bg-surface-800 flex items-center justify-center text-white font-bold text-[10px] sm:text-xs">
|
||||||
{authenticated ? initialLetter(username) : <i className="fa-solid fa-user text-[10px]" />}
|
{authenticated ? initialLetter(username) : <i className="fa-solid fa-user text-[8px] sm:text-[10px]" />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -235,7 +235,7 @@ function UserChip({ authenticated, loading, username, avatarUrl, onClick }) {
|
||||||
// Main Component
|
// Main Component
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClick, onUserIsland }) {
|
export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClick, onUserIsland, onMyLocation, onSearch }) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const {
|
const {
|
||||||
loading, authenticated, username, login, logout, restore, loginWithProvider, ssoEnabled,
|
loading, authenticated, username, login, logout, restore, loginWithProvider, ssoEnabled,
|
||||||
|
|
@ -257,6 +257,7 @@ export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClic
|
||||||
const [showFriends, setShowFriends] = useState(false);
|
const [showFriends, setShowFriends] = useState(false);
|
||||||
const [pendingRequestsCount, setPendingRequestsCount] = useState(0);
|
const [pendingRequestsCount, setPendingRequestsCount] = useState(0);
|
||||||
const [showInstallBtn, setShowInstallBtn] = useState(false);
|
const [showInstallBtn, setShowInstallBtn] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
// Form states
|
// Form states
|
||||||
const [loginUser, setLoginUser] = useState("");
|
const [loginUser, setLoginUser] = useState("");
|
||||||
|
|
@ -387,6 +388,13 @@ export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClic
|
||||||
}, [authenticated, username, showUsernameModal, loading, needsUsername]);
|
}, [authenticated, username, showUsernameModal, loading, needsUsername]);
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
|
const handleSearchSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (searchQuery.trim() && onSearch) {
|
||||||
|
onSearch(searchQuery.trim());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleLoginSubmit = async (e) => {
|
const handleLoginSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
trackEvent("login_submit");
|
trackEvent("login_submit");
|
||||||
|
|
@ -503,19 +511,17 @@ export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClic
|
||||||
animate={{ opacity: [0.3, 0.6, 0.3] }}
|
animate={{ opacity: [0.3, 0.6, 0.3] }}
|
||||||
transition={{ duration: 2, repeat: Infinity }}
|
transition={{ duration: 2, repeat: Infinity }}
|
||||||
/>
|
/>
|
||||||
<div className="relative w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 via-cyan-500 to-emerald-500 p-[2px] shadow-lg shadow-cyan-500/25">
|
<div className="relative w-8 h-8 sm:w-10 sm:h-10 md:w-11 md:h-11 lg:w-12 lg:h-12 rounded-lg sm:rounded-xl bg-gradient-to-br from-blue-500 via-cyan-500 to-emerald-500 shadow-lg shadow-cyan-500/25 flex items-center justify-center flex-shrink-0">
|
||||||
<div className="w-full h-full rounded-[10px] bg-surface-900 flex items-center justify-center">
|
<img src="/logo.png" alt="SocioWire" className="w-[130%] h-[130%] max-w-none object-cover" style={{ marginTop: '5px', marginLeft: '2px' }} />
|
||||||
<img src="/logo.png" alt="S" className="w-6 h-6 object-contain" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex flex-col min-w-0">
|
||||||
<div className="hidden sm:flex flex-col">
|
<span className="text-xs sm:text-sm md:text-lg font-black text-transparent bg-clip-text bg-gradient-to-r from-white via-blue-100 to-cyan-200 truncate">
|
||||||
<span className="text-lg font-black text-transparent bg-clip-text bg-gradient-to-r from-white via-blue-100 to-cyan-200">
|
|
||||||
SOCIOWIRE
|
SOCIOWIRE
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1.5 text-[10px] font-medium text-surface-400">
|
<span className="hidden sm:flex items-center gap-1 sm:gap-1.5 text-[8px] sm:text-[10px] font-medium text-surface-400">
|
||||||
<motion.span
|
<motion.span
|
||||||
className="w-2 h-2 rounded-full bg-emerald-500"
|
className="w-1.5 h-1.5 sm:w-2 sm:h-2 rounded-full bg-emerald-500"
|
||||||
animate={{ scale: [1, 1.3, 1], opacity: [1, 0.7, 1] }}
|
animate={{ scale: [1, 1.3, 1], opacity: [1, 0.7, 1] }}
|
||||||
transition={{ duration: 1.5, repeat: Infinity }}
|
transition={{ duration: 1.5, repeat: Infinity }}
|
||||||
/>
|
/>
|
||||||
|
|
@ -524,23 +530,8 @@ export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClic
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Center: Theme pills (desktop) */}
|
{/* Spacer */}
|
||||||
<div className="hidden md:flex items-center gap-1 px-1.5 py-1 rounded-full bg-surface-800/40 border border-white/5">
|
<div className="flex-1" />
|
||||||
{THEMES.map((themeKey) => (
|
|
||||||
<motion.button
|
|
||||||
key={themeKey}
|
|
||||||
className={`px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-wider transition-all
|
|
||||||
${theme === themeKey
|
|
||||||
? "bg-gradient-to-r from-blue-500 to-cyan-500 text-white shadow-lg shadow-cyan-500/25"
|
|
||||||
: "text-surface-400 hover:text-white hover:bg-white/5"}`}
|
|
||||||
onClick={() => onChangeTheme?.(themeKey)}
|
|
||||||
whileHover={{ scale: 1.05 }}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
>
|
|
||||||
{t(`theme.${themeKey}`)}
|
|
||||||
</motion.button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: Action buttons */}
|
{/* Right: Action buttons */}
|
||||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||||
|
|
@ -556,14 +547,15 @@ export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClic
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-download" />
|
<i className="fa-solid fa-download" />
|
||||||
<span className="hidden sm:inline">{t('topbar.install')}</span>
|
<span className="hidden sm:inline">{t('topbar.installApp')}</span>
|
||||||
</motion.button>
|
</motion.button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Theme (mobile) */}
|
|
||||||
<div className="md:hidden" ref={themeBtnRef}>
|
{/* Theme */}
|
||||||
|
<div ref={themeBtnRef}>
|
||||||
<motion.button
|
<motion.button
|
||||||
className={`w-9 h-9 rounded-xl flex items-center justify-center transition-all
|
className={`w-7 h-7 sm:w-8 sm:h-8 md:w-9 md:h-9 rounded-lg sm:rounded-xl flex items-center justify-center transition-all text-xs sm:text-sm
|
||||||
${showThemeMenu ? "bg-cyan-500/20 text-cyan-400 border-cyan-500/40" : "bg-surface-800/50 text-surface-400 border-white/5"} border`}
|
${showThemeMenu ? "bg-cyan-500/20 text-cyan-400 border-cyan-500/40" : "bg-surface-800/50 text-surface-400 border-white/5"} border`}
|
||||||
onClick={toggleThemeMenu}
|
onClick={toggleThemeMenu}
|
||||||
whileHover={{ scale: 1.1 }}
|
whileHover={{ scale: 1.1 }}
|
||||||
|
|
@ -576,7 +568,7 @@ export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClic
|
||||||
{/* Language */}
|
{/* Language */}
|
||||||
<div ref={langBtnRef}>
|
<div ref={langBtnRef}>
|
||||||
<motion.button
|
<motion.button
|
||||||
className={`w-9 h-9 rounded-xl flex items-center justify-center transition-all
|
className={`w-7 h-7 sm:w-8 sm:h-8 md:w-9 md:h-9 rounded-lg sm:rounded-xl flex items-center justify-center transition-all text-xs sm:text-sm
|
||||||
${showLangMenu ? "bg-blue-500/20 text-blue-400 border-blue-500/40" : "bg-surface-800/50 text-surface-400 border-white/5"} border`}
|
${showLangMenu ? "bg-blue-500/20 text-blue-400 border-blue-500/40" : "bg-surface-800/50 text-surface-400 border-white/5"} border`}
|
||||||
onClick={toggleLangMenu}
|
onClick={toggleLangMenu}
|
||||||
whileHover={{ scale: 1.1 }}
|
whileHover={{ scale: 1.1 }}
|
||||||
|
|
@ -587,19 +579,19 @@ export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClic
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Friends */}
|
{/* Friends */}
|
||||||
{authenticated && (
|
|
||||||
<motion.button
|
<motion.button
|
||||||
className={`relative w-9 h-9 rounded-xl flex items-center justify-center transition-all
|
className={`relative w-7 h-7 sm:w-8 sm:h-8 md:w-9 md:h-9 rounded-lg sm:rounded-xl flex items-center justify-center transition-all text-xs sm:text-sm
|
||||||
${showFriends ? "bg-amber-500/20 text-amber-400 border-amber-500/40" : "bg-surface-800/50 text-surface-400 border-white/5"} border`}
|
${showFriends ? "bg-amber-500/20 text-amber-400 border-amber-500/40" : "bg-surface-800/50 text-surface-400 border-white/5"} border`}
|
||||||
onClick={() => setShowFriends(!showFriends)}
|
onClick={() => authenticated ? setShowFriends(!showFriends) : openModal("login")}
|
||||||
whileHover={{ scale: 1.1 }}
|
whileHover={{ scale: 1.1 }}
|
||||||
whileTap={{ scale: 0.9 }}
|
whileTap={{ scale: 0.9 }}
|
||||||
|
title={t('topbar.friends')}
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-user-group" />
|
<i className="fa-solid fa-user-group" />
|
||||||
{pendingRequestsCount > 0 && (
|
{authenticated && pendingRequestsCount > 0 && (
|
||||||
<motion.span
|
<motion.span
|
||||||
className="absolute -top-1.5 -right-1.5 min-w-[18px] h-[18px] px-1
|
className="absolute -top-1 -right-1 sm:-top-1.5 sm:-right-1.5 min-w-[14px] sm:min-w-[18px] h-[14px] sm:h-[18px] px-0.5 sm:px-1
|
||||||
rounded-full bg-gradient-to-r from-red-500 to-pink-500 text-white text-[10px] font-bold
|
rounded-full bg-gradient-to-r from-red-500 to-pink-500 text-white text-[8px] sm:text-[10px] font-bold
|
||||||
flex items-center justify-center shadow-lg shadow-red-500/50"
|
flex items-center justify-center shadow-lg shadow-red-500/50"
|
||||||
initial={{ scale: 0 }}
|
initial={{ scale: 0 }}
|
||||||
animate={{ scale: 1 }}
|
animate={{ scale: 1 }}
|
||||||
|
|
@ -608,7 +600,6 @@ export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClic
|
||||||
</motion.span>
|
</motion.span>
|
||||||
)}
|
)}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Separator */}
|
{/* Separator */}
|
||||||
<div className="hidden sm:block w-px h-7 bg-gradient-to-b from-transparent via-white/20 to-transparent mx-1" />
|
<div className="hidden sm:block w-px h-7 bg-gradient-to-b from-transparent via-white/20 to-transparent mx-1" />
|
||||||
|
|
@ -651,7 +642,7 @@ export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClic
|
||||||
className="absolute inset-0 bg-gradient-to-r from-emerald-400 to-teal-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
className="absolute inset-0 bg-gradient-to-r from-emerald-400 to-teal-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
/>
|
/>
|
||||||
<i className="fa-solid fa-user-plus relative" />
|
<i className="fa-solid fa-user-plus relative" />
|
||||||
<span className="relative hidden sm:inline">{t('topbar.joinNow')}</span>
|
<span className="relative hidden sm:inline">{t('topbar.login')}</span>
|
||||||
</motion.button>
|
</motion.button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -505,13 +505,17 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
|
||||||
}, [postId]);
|
}, [postId]);
|
||||||
|
|
||||||
// Fetch comments
|
// Fetch comments
|
||||||
const refreshComments = useCallback(async () => {
|
const refreshComments = useCallback(async (autoOpen = false) => {
|
||||||
if (!postId) return;
|
if (!postId) return;
|
||||||
const items = await fetchPostComments(postId, 50);
|
const items = await fetchPostComments(postId, 50);
|
||||||
if (Array.isArray(items)) { setComments(items); setCounts(c => ({ ...c, comments: items.length })); }
|
if (Array.isArray(items)) {
|
||||||
|
setComments(items);
|
||||||
|
setCounts(c => ({ ...c, comments: items.length }));
|
||||||
|
if (autoOpen && items.length > 0) setShowComments(true);
|
||||||
|
}
|
||||||
}, [postId]);
|
}, [postId]);
|
||||||
|
|
||||||
useEffect(() => { refreshComments(); }, [refreshComments]);
|
useEffect(() => { refreshComments(true); }, [refreshComments]);
|
||||||
useEffect(() => { if (showComments) commentsEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [comments, showComments]);
|
useEffect(() => { if (showComments) commentsEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [comments, showComments]);
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
|
|
@ -545,7 +549,7 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
|
||||||
setCommentInput("");
|
setCommentInput("");
|
||||||
setCommentSending(true);
|
setCommentSending(true);
|
||||||
const tempId = `temp_${Date.now()}`;
|
const tempId = `temp_${Date.now()}`;
|
||||||
const newComment = { id: tempId, body, author: username || "You", created_at: new Date().toISOString(), _pending: true };
|
const newComment = { id: tempId, body, username: username || "You", created_at: new Date().toISOString(), _pending: true };
|
||||||
setComments(prev => [...prev, newComment]);
|
setComments(prev => [...prev, newComment]);
|
||||||
setCounts(c => ({ ...c, comments: c.comments + 1 }));
|
setCounts(c => ({ ...c, comments: c.comments + 1 }));
|
||||||
const res = await createPostComment(postId, body);
|
const res = await createPostComment(postId, body);
|
||||||
|
|
@ -764,28 +768,28 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Truth Score */}
|
{/* Truth Score */}
|
||||||
<div className="mb-4 p-4 rounded-2xl bg-themed-elevated">
|
<div className="mb-3 p-2.5 rounded-xl bg-themed-elevated">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-3">
|
||||||
<motion.button whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }} onClick={() => handleTruthVote("false")}
|
<motion.button whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }} onClick={() => handleTruthVote("false")}
|
||||||
className="w-12 h-12 rounded-full bg-red-500/20 border border-red-500/30 text-red-400 flex items-center justify-center hover:bg-red-500/30">
|
className="w-9 h-9 rounded-full bg-red-500/20 border border-red-500/30 text-red-400 flex items-center justify-center hover:bg-red-500/30">
|
||||||
<i className="fa-solid fa-thumbs-down text-lg" />
|
<i className="fa-solid fa-thumbs-down text-sm" />
|
||||||
</motion.button>
|
</motion.button>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center justify-center gap-2 mb-2">
|
<div className="flex items-center justify-center gap-1.5 mb-1">
|
||||||
<span className={`text-3xl font-black ${truth.score >= 70 ? 'text-emerald-400' : truth.score >= 50 ? 'text-amber-400' : 'text-red-400'}`}>
|
<span className={`text-xl font-black ${truth.score >= 70 ? 'text-emerald-400' : truth.score >= 50 ? 'text-amber-400' : 'text-red-400'}`}>
|
||||||
{Math.round(truth.score)}
|
{Math.round(truth.score)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-themed-muted">/ 100</span>
|
<span className="text-themed-muted text-xs">/ 100</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2.5 rounded-full bg-surface-800 overflow-hidden">
|
<div className="h-1.5 rounded-full bg-surface-800 overflow-hidden">
|
||||||
<motion.div className={`h-full rounded-full ${truth.score >= 70 ? 'bg-gradient-to-r from-emerald-500 to-teal-400' : truth.score >= 50 ? 'bg-gradient-to-r from-amber-500 to-orange-400' : 'bg-gradient-to-r from-red-500 to-pink-400'}`}
|
<motion.div className={`h-full rounded-full ${truth.score >= 70 ? 'bg-gradient-to-r from-emerald-500 to-teal-400' : truth.score >= 50 ? 'bg-gradient-to-r from-amber-500 to-orange-400' : 'bg-gradient-to-r from-red-500 to-pink-400'}`}
|
||||||
initial={{ width: 0 }} animate={{ width: `${truth.score}%` }} transition={{ duration: 0.6 }} />
|
initial={{ width: 0 }} animate={{ width: `${truth.score}%` }} transition={{ duration: 0.6 }} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-center text-xs text-themed-muted mt-1">{truth.count} votes</p>
|
<p className="text-center text-[10px] text-themed-muted mt-0.5">{truth.count} votes</p>
|
||||||
</div>
|
</div>
|
||||||
<motion.button whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }} onClick={() => handleTruthVote("true")}
|
<motion.button whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }} onClick={() => handleTruthVote("true")}
|
||||||
className="w-12 h-12 rounded-full bg-emerald-500/20 border border-emerald-500/30 text-emerald-400 flex items-center justify-center hover:bg-emerald-500/30">
|
className="w-9 h-9 rounded-full bg-emerald-500/20 border border-emerald-500/30 text-emerald-400 flex items-center justify-center hover:bg-emerald-500/30">
|
||||||
<i className="fa-solid fa-thumbs-up text-lg" />
|
<i className="fa-solid fa-thumbs-up text-sm" />
|
||||||
</motion.button>
|
</motion.button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -834,12 +838,19 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
|
||||||
comments.map((c) => (
|
comments.map((c) => (
|
||||||
<motion.div key={c.id} className={`p-3 rounded-xl bg-themed-primary border border-themed ${c._pending ? "opacity-50" : ""} ${c._failed ? "border-red-500/30" : ""}`}
|
<motion.div key={c.id} className={`p-3 rounded-xl bg-themed-primary border border-themed ${c._pending ? "opacity-50" : ""} ${c._failed ? "border-red-500/30" : ""}`}
|
||||||
initial={{ opacity: 0, y: 10 }} animate={{ opacity: c._pending ? 0.5 : 1, y: 0 }}>
|
initial={{ opacity: 0, y: 10 }} animate={{ opacity: c._pending ? 0.5 : 1, y: 0 }}>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-start gap-2">
|
||||||
<span className="text-xs font-bold text-accent">@{c.author}</span>
|
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-accent to-blue-500 flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
|
||||||
|
{(c.username || "?")[0]?.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
|
<span className="text-xs font-bold text-accent">@{c.username || "user"}</span>
|
||||||
<span className="text-xs text-themed-muted">{relativeTime(c.created_at, t)}</span>
|
<span className="text-xs text-themed-muted">{relativeTime(c.created_at, t)}</span>
|
||||||
{c._failed && <span className="text-xs text-red-400">Failed</span>}
|
{c._failed && <span className="text-xs text-red-400">Failed</span>}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-themed-secondary">{c.body}</p>
|
<p className="text-sm text-themed-secondary">{c.body}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue