feat: add real-time chat system with i18n support

- Add WebSocket-based chat with presence indicators
- Add message delivery status (sending/sent/read)
- Add notification badges for unread messages
- Add body scroll lock on mobile to prevent keyboard issues
- Add visibility change handling to refresh data on browser resume
- Add internationalization (i18n) with English/French support
- Add timezone-aware message timestamps

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
SocioWire 2026-01-09 20:40:28 +00:00
parent d77dac74a2
commit f9e1505147
30 changed files with 4251 additions and 254 deletions

154
package-lock.json generated
View File

@ -10,11 +10,15 @@
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^7.1.0", "@fortawesome/fontawesome-free": "^7.1.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"i18next": "^25.7.4",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"keycloak-js": "^26.2.1", "keycloak-js": "^26.2.1",
"livekit-client": "^2.0.0", "livekit-client": "^2.0.0",
"maplibre-gl": "^5.14.0", "maplibre-gl": "^5.14.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-i18next": "^16.5.1",
"react-router-dom": "^7.10.1" "react-router-dom": "^7.10.1"
}, },
"devDependencies": { "devDependencies": {
@ -247,6 +251,14 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.27.2", "version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
@ -1709,6 +1721,14 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/cross-fetch": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
"dependencies": {
"node-fetch": "^2.6.12"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -2347,6 +2367,60 @@
"hermes-estree": "0.25.1" "hermes-estree": "0.25.1"
} }
}, },
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "25.7.4",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.4.tgz",
"integrity": "sha512-hRkpEblXXcXSNbw8mBNq9042OEetgyB/ahc/X17uV/khPwzV+uB8RHceHh3qavyrkPJvmXFKXME2Sy1E0KjAfw==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"dependencies": {
"@babel/runtime": "^7.28.4"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz",
"integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/i18next-http-backend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz",
"integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==",
"dependencies": {
"cross-fetch": "4.0.0"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -2694,6 +2768,25 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true "dev": true
}, },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.27", "version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@ -2879,6 +2972,32 @@
"react": "^19.2.3" "react": "^19.2.3"
} }
}, },
"node_modules/react-i18next": {
"version": "16.5.1",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.1.tgz",
"integrity": "sha512-Hks6UIRZWW4c+qDAnx1csVsCGYeIR4MoBGQgJ+NUoNnO6qLxXuf8zu0xdcinyXUORgGzCdRsexxO1Xzv3sTdnw==",
"dependencies": {
"@babel/runtime": "^7.28.4",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 25.6.2",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.18.0", "version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@ -3098,6 +3217,11 @@
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==" "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g=="
}, },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"node_modules/ts-debounce": { "node_modules/ts-debounce": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/ts-debounce/-/ts-debounce-4.0.0.tgz", "resolved": "https://registry.npmjs.org/ts-debounce/-/ts-debounce-4.0.0.tgz",
@ -3167,6 +3291,14 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
@ -3226,6 +3358,19 @@
} }
} }
}, },
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"node_modules/webrtc-adapter": { "node_modules/webrtc-adapter": {
"version": "9.0.3", "version": "9.0.3",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.3.tgz", "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.3.tgz",
@ -3238,6 +3383,15 @@
"npm": ">=3.10.0" "npm": ">=3.10.0"
} }
}, },
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@ -11,12 +11,16 @@
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^7.1.0", "@fortawesome/fontawesome-free": "^7.1.0",
"livekit-client": "^2.0.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"i18next": "^25.7.4",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"keycloak-js": "^26.2.1", "keycloak-js": "^26.2.1",
"livekit-client": "^2.0.0",
"maplibre-gl": "^5.14.0", "maplibre-gl": "^5.14.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-i18next": "^16.5.1",
"react-router-dom": "^7.10.1" "react-router-dom": "^7.10.1"
}, },
"devDependencies": { "devDependencies": {

View File

@ -1,16 +1,18 @@
import React, { Suspense, useEffect, useState, useCallback } from "react"; import React, { Suspense, useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import TopBar from "./components/Layout/TopBar"; import TopBar from "./components/Layout/TopBar";
import PostList from "./components/Posts/PostList"; import PostList from "./components/Posts/PostList";
import IslandUniverse from "./components/Island/IslandUniverse"; import IslandUniverse from "./components/Island/IslandUniverse";
import IslandViewer from "./components/Island/IslandViewer"; import IslandViewer from "./components/Island/IslandViewer";
import ChatContainer from "./components/Chat/ChatContainer";
import { fetchIslandByUsername } from "./api/client"; import { fetchIslandByUsername } from "./api/client";
const MapView = React.lazy(() => import("./components/Map/MapView")); const MapView = React.lazy(() => import("./components/Map/MapView"));
const THEME_KEY = "sociowire:theme"; const THEME_KEY = "sociowire:theme";
const ACTIVE_CHATS_KEY = "sociowire:activeChats";
export default function App() { export default function App() {
const { t } = useTranslation();
const [theme, setTheme] = useState("blue"); const [theme, setTheme] = useState("blue");
// Wall state (lifted from MapView) // Wall state (lifted from MapView)
@ -77,10 +79,6 @@ export default function App() {
} }
}, [theme]); }, [theme]);
// Chats actifs (s'ajoutent quand on clique sur un contact)
const [activeChats, setActiveChats] = useState([]);
const [showContactsList, setShowContactsList] = useState(false);
useEffect(() => { useEffect(() => {
const onScroll = () => { const onScroll = () => {
const threshold = window.innerHeight * 0.55; const threshold = window.innerHeight * 0.55;
@ -92,29 +90,6 @@ export default function App() {
return () => window.removeEventListener("scroll", onScroll); return () => window.removeEventListener("scroll", onScroll);
}, []); }, []);
useEffect(() => {
try {
const saved = localStorage.getItem(ACTIVE_CHATS_KEY);
if (saved) setActiveChats(JSON.parse(saved));
} catch {}
}, []);
useEffect(() => {
try {
localStorage.setItem(ACTIVE_CHATS_KEY, JSON.stringify(activeChats));
} catch {}
}, [activeChats]);
// Ouvrir un chat
const openChat = (name) => {
if (!activeChats.includes(name)) {
setActiveChats(prev => [...prev, name]);
}
setShowContactsList(false);
};
const contactsList = ["Amely", "Nancy", "Stéphanie"];
// Island Universe handlers // Island Universe handlers
const handleOpenUniverse = () => { const handleOpenUniverse = () => {
setShowUniverse(true); setShowUniverse(true);
@ -157,8 +132,8 @@ export default function App() {
<div className="map-page"> <div className="map-page">
<div className="map-stage map-stage--placeholder"> <div className="map-stage map-stage--placeholder">
<div className="map-placeholder-content"> <div className="map-placeholder-content">
<div className="map-placeholder-title">Loading map</div> <div className="map-placeholder-title">{t('map.loadingMap')}</div>
<div className="map-placeholder-subtitle">Warming up tiles and live data.</div> <div className="map-placeholder-subtitle">{t('map.warmingUp')}</div>
</div> </div>
<div className="map-placeholder-overlay map-placeholder-top"> <div className="map-placeholder-overlay map-placeholder-top">
<div className="map-placeholder-pill map-placeholder-pill--wide" /> <div className="map-placeholder-pill map-placeholder-pill--wide" />
@ -216,46 +191,19 @@ export default function App() {
<section className="seo-section" id="islands"> <section className="seo-section" id="islands">
<div className="seo-card"> <div className="seo-card">
<div className="seo-kicker">Communities</div> <div className="seo-kicker">{t('islands.communities')}</div>
<h2>Explore Islands</h2> <h2>{t('islands.exploreIslands')}</h2>
<p> <p>
Islands are local hubs where people share updates, deals, and stories in one place. {t('islands.islandsDescription')}
Browse nearby islands or jump into a new community to see what is trending.
</p> </p>
<button className="seo-cta" type="button" onClick={handleOpenUniverse}> <button className="seo-cta" type="button" onClick={handleOpenUniverse}>
Open Islands {t('islands.openIslands')}
</button> </button>
</div> </div>
</section> </section>
{/* Bouton Contacts flottant en bas à droite */} {/* Chat System */}
<button className="contacts-float-btn" onClick={() => setShowContactsList(!showContactsList)}> <ChatContainer />
<span className="contact-icon">👥</span>
Contacts
</button>
{/* Liste des contacts (dropdown) */}
{showContactsList && (
<div className="contacts-dropdown">
{contactsList.map((name, idx) => (
<div key={idx} className="contact-item" onClick={() => openChat(name)}>
<div className="contact-avatar">👤</div>
<span>{name}</span>
</div>
))}
</div>
)}
{/* Bulles de chats actifs flottantes à côté du bouton */}
<div className="active-chats-bubbles">
{activeChats.map((name, idx) => (
<div key={idx} className="chat-bubble">
<div className="chat-avatar">👤</div>
<span className="chat-name">{name}</span>
<button className="bubble-remove" onClick={() => setActiveChats(prev => prev.filter(n => n !== name))}></button>
</div>
))}
</div>
</div> </div>
<footer className="sw-footer"> <footer className="sw-footer">
@ -263,13 +211,13 @@ export default function App() {
<div className="sw-footer-brand">SOCIOWIRE</div> <div className="sw-footer-brand">SOCIOWIRE</div>
<div className="sw-footer-brand">© Sociowire v0.0008</div> <div className="sw-footer-brand">© Sociowire v0.0008</div>
<nav className="sw-footer-nav" aria-label="Site"> <nav className="sw-footer-nav" aria-label="Site">
<a href="/">Home</a> <a href="/">{t('nav.home')}</a>
<a href="/#map">Live Map</a> <a href="/#map">{t('nav.liveMap')}</a>
<a href="/#wall">SocioWall</a> <a href="/#wall">{t('nav.sociowall')}</a>
<a href="/#islands">Islands</a> <a href="/#islands">{t('nav.islands')}</a>
<a href="/about.html">About</a> <a href="/about.html">{t('nav.about')}</a>
<a href="/contact.html">Contact</a> <a href="/contact.html">{t('nav.contact')}</a>
<a href="/privacy.html">Privacy</a> <a href="/privacy.html">{t('nav.privacy')}</a>
</nav> </nav>
</div> </div>
</footer> </footer>

View File

@ -931,6 +931,21 @@ export async function rejectFriendRequest(username) {
} }
} }
export async function cancelFriendRequest(username) {
try {
const res = await fetch(`${apiBase()}/friends/cancel`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify({ username }),
});
const data = await res.json().catch(() => ({}));
return { ok: res.ok, ...data };
} catch {
return { ok: false };
}
}
export async function removeFriend(username) { export async function removeFriend(username) {
try { try {
const res = await fetch(`${apiBase()}/friends/remove`, { const res = await fetch(`${apiBase()}/friends/remove`, {
@ -961,3 +976,41 @@ export async function fetchFriendStatus(username) {
return { ok: false, status: "none" }; return { ok: false, status: "none" };
} }
} }
export async function searchUsers(query) {
if (!query || query.length < 2) return { ok: true, users: [] };
const qs = new URLSearchParams();
qs.set("q", query);
try {
const res = await fetch(`${apiBase()}/users/search?${qs.toString()}`, {
credentials: "include",
});
const data = await res.json().catch(() => ({}));
return { ok: res.ok, ...data };
} catch {
return { ok: false, users: [] };
}
}
export async function updateTimezone(timezone) {
try {
const res = await fetch(`${apiBase()}/profile/timezone`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify({ timezone }),
});
const data = await res.json().catch(() => ({}));
return { ok: res.ok, ...data };
} catch {
return { ok: false };
}
}
export function getBrowserTimezone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || "";
} catch {
return "";
}
}

View File

@ -458,18 +458,38 @@ export function AuthProvider({ children }) {
async function logout() { async function logout() {
if (refreshTimer.current) clearInterval(refreshTimer.current); if (refreshTimer.current) clearInterval(refreshTimer.current);
refreshTimer.current = null; refreshTimer.current = null;
const redirectUri = window.location.origin + window.location.pathname; const redirectUri = window.location.origin;
// Don't do SSO logout for apps subdomain - keep it isolated // Don't do SSO logout for apps subdomain - keep it isolated
const doSsoLogout = keycloakEnabled() && !isAppsSubdomain(); const doSsoLogout = keycloakEnabled() && !isAppsSubdomain();
// Get id_token before clearing auth (needed for proper Keycloak logout)
const idToken = tokens?.id_token || "";
console.log("[Auth] Logout initiated, doSsoLogout:", doSsoLogout, "hasIdToken:", !!idToken);
try { try {
sessionStorage.setItem("sociowire:logout", String(Date.now())); sessionStorage.setItem("sociowire:logout", String(Date.now()));
} catch {} } catch {}
// Clear local auth state
setAnon(""); setAnon("");
if (!doSsoLogout) return; if (!doSsoLogout) return;
try {
const kc = getKeycloak(); // Build Keycloak logout URL
await kc.logout({ redirectUri }); const logoutUrl = `${KC_URL}/realms/${KC_REALM}/protocol/openid-connect/logout`;
} catch {} const params = new URLSearchParams();
params.set("client_id", KC_CLIENT_ID);
params.set("post_logout_redirect_uri", redirectUri);
if (idToken) {
params.set("id_token_hint", idToken);
}
const fullLogoutUrl = `${logoutUrl}?${params.toString()}`;
console.log("[Auth] Redirecting to logout URL:", fullLogoutUrl);
// Redirect to Keycloak logout - this will clear the SSO session
window.location.href = fullLogoutUrl;
} }
async function resendVerificationEmail() { async function resendVerificationEmail() {

View File

@ -0,0 +1,458 @@
/* Chat Bottom Bar */
.chat-bottom-bar {
position: fixed;
bottom: 16px;
right: 16px;
display: flex;
align-items: center;
gap: 10px;
z-index: 9980;
}
/* Chat Tab (minimized chat) */
.chat-tab {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px 6px 6px;
background: linear-gradient(135deg, rgba(30, 58, 138, 0.95), rgba(59, 130, 246, 0.9));
border-radius: 999px;
color: #f1f5f9;
font-size: 13px;
font-weight: 600;
border: 1px solid rgba(148, 163, 184, 0.3);
cursor: pointer;
backdrop-filter: blur(10px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
transition: transform 0.2s, box-shadow 0.2s, background 0.2s;
}
.chat-tab:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.4);
}
.chat-tab.online {
border-color: rgba(34, 197, 94, 0.5);
box-shadow: 0 4px 16px rgba(34, 197, 94, 0.3);
}
.chat-tab-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 12px;
overflow: hidden;
flex-shrink: 0;
}
.chat-tab-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.chat-tab-name {
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-tab-close {
width: 20px;
height: 20px;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.15);
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
margin-left: 4px;
transition: background 0.2s, color 0.2s;
}
.chat-tab-close:hover {
background: rgba(239, 68, 68, 0.8);
color: white;
}
/* Unread badge on chat tab */
.chat-tab-unread {
min-width: 18px;
height: 18px;
padding: 0 5px;
background: #ef4444;
color: white;
font-size: 10px;
font-weight: 700;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
animation: pulse 2s infinite;
}
.chat-tab.has-unread {
animation: shake 0.5s ease-in-out;
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
}
@keyframes shake {
0%, 100% {
transform: translateX(0);
}
25% {
transform: translateX(-3px);
}
75% {
transform: translateX(3px);
}
}
/* Contacts Float Button */
.contacts-float-btn {
padding: 10px 16px;
background: linear-gradient(135deg, rgba(30, 58, 138, 0.9), rgba(59, 130, 246, 0.85));
border-radius: 999px;
color: #f1f5f9;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
border: 1px solid rgba(148, 163, 184, 0.3);
cursor: pointer;
backdrop-filter: blur(10px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
transition: transform 0.2s, box-shadow 0.2s;
position: relative;
}
.contacts-float-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.4);
}
.contacts-float-btn .contact-icon {
font-size: 18px;
}
.contacts-float-btn .contact-label {
display: inline;
}
.contacts-badge {
position: absolute;
top: -6px;
right: -6px;
min-width: 20px;
height: 20px;
padding: 0 6px;
background: #ef4444;
color: white;
font-size: 11px;
font-weight: 700;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
}
.connection-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(148, 163, 184, 0.5);
margin-left: 4px;
}
.connection-dot.online {
background: #22c55e;
box-shadow: 0 0 8px rgba(34, 197, 94, 0.6);
}
/* Contacts Dropdown */
.contacts-dropdown {
position: fixed;
bottom: 70px;
right: 16px;
width: 280px;
max-height: 400px;
background: rgba(15, 23, 42, 0.98);
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 16px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
z-index: 10000;
overflow: hidden;
backdrop-filter: blur(12px);
animation: slideUp 0.2s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.contacts-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
font-weight: 600;
font-size: 15px;
color: #f1f5f9;
}
.contacts-close {
width: 28px;
height: 28px;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
color: #94a3b8;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
transition: background 0.2s;
}
.contacts-close:hover {
background: rgba(255, 255, 255, 0.2);
}
.contacts-list {
max-height: 340px;
overflow-y: auto;
padding: 8px;
}
.contacts-empty {
padding: 30px 16px;
text-align: center;
color: rgba(148, 163, 184, 0.7);
font-size: 13px;
}
.contact-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 10px;
cursor: pointer;
transition: background 0.2s;
}
.contact-item:hover {
background: rgba(59, 130, 246, 0.15);
}
.contact-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 16px;
position: relative;
overflow: hidden;
flex-shrink: 0;
}
.contact-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.contact-avatar.online::after {
content: "";
position: absolute;
bottom: 1px;
right: 1px;
width: 12px;
height: 12px;
background: #22c55e;
border: 2px solid rgba(15, 23, 42, 0.98);
border-radius: 50%;
}
.contact-name {
flex: 1;
font-weight: 500;
font-size: 14px;
color: #e2e8f0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.online-badge {
width: 8px;
height: 8px;
border-radius: 50%;
background: #22c55e;
}
/* Unread badge in contacts list */
.contact-unread-badge {
min-width: 18px;
height: 18px;
padding: 0 5px;
background: #ef4444;
color: white;
font-size: 11px;
font-weight: 700;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
}
.contact-item.has-unread {
background: rgba(239, 68, 68, 0.1);
}
.contact-item.has-unread .contact-name {
font-weight: 600;
color: #f1f5f9;
}
/* Chat Windows Container */
.chat-windows-container {
position: fixed;
bottom: 0;
right: 0;
left: 0;
top: 0;
z-index: 9990;
pointer-events: none;
}
.chat-window-wrapper {
position: fixed;
bottom: 0;
pointer-events: auto;
z-index: 9990;
}
/* Light theme */
body[data-theme="light"] .chat-tab {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.95), rgba(99, 102, 241, 0.9));
}
body[data-theme="light"] .contacts-float-btn {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.95), rgba(99, 102, 241, 0.9));
}
body[data-theme="light"] .contacts-dropdown {
background: rgba(255, 255, 255, 0.98);
border-color: rgba(148, 163, 184, 0.4);
}
body[data-theme="light"] .contacts-header {
color: #1e293b;
border-color: rgba(148, 163, 184, 0.3);
}
body[data-theme="light"] .contact-item:hover {
background: rgba(59, 130, 246, 0.1);
}
body[data-theme="light"] .contact-name {
color: #334155;
}
body[data-theme="light"] .contacts-empty {
color: #64748b;
}
/* Mobile responsive */
@media (max-width: 640px) {
.chat-bottom-bar {
right: 8px;
left: 8px;
gap: 6px;
/* Handle virtual keyboard */
bottom: max(16px, env(safe-area-inset-bottom, 16px));
}
.chat-tab {
padding: 5px 8px 5px 5px;
}
.chat-tab-name {
max-width: 60px;
font-size: 12px;
}
.chat-tab-avatar {
width: 24px;
height: 24px;
font-size: 10px;
}
.contacts-float-btn .contact-label {
display: none;
}
.contacts-float-btn {
padding: 12px;
}
.contacts-dropdown {
right: 8px;
left: 8px;
width: auto;
bottom: 70px;
}
.chat-windows-container {
left: 0;
right: 0;
}
.chat-window-wrapper {
left: 0 !important;
right: 0 !important;
width: 100%;
/* Smooth transition for keyboard adjustment */
transition: bottom 0.15s ease-out;
}
/* Hide bottom bar when chat is open to avoid z-index conflicts */
.chat-windows-container:has(.chat-window:not(.chat-window--minimized)) ~ .chat-bottom-bar {
z-index: 9980;
}
}

View File

@ -0,0 +1,532 @@
import React, { useState, useEffect, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useAuth } from "../../auth/AuthContext";
import { fetchFriends } from "../../api/client";
import { useChat } from "./useChat";
import ChatWindow from "./ChatWindow";
import "./ChatContainer.css";
const ACTIVE_CHATS_KEY = "sociowire:activeChats";
// Play notification sound using Web Audio API
function playNotificationSound() {
try {
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioCtx.createOscillator();
const gainNode = audioCtx.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioCtx.destination);
oscillator.frequency.value = 800;
oscillator.type = "sine";
gainNode.gain.value = 0.3;
oscillator.start();
gainNode.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.2);
oscillator.stop(audioCtx.currentTime + 0.2);
} catch (e) {
// Audio not supported
}
}
export default function ChatContainer() {
const { t } = useTranslation();
const { authenticated, loading: authLoading, username } = useAuth();
const [friends, setFriends] = useState([]);
const [showContacts, setShowContacts] = useState(false);
const [activeChats, setActiveChats] = useState([]); // [{ username, avatar }]
const [minimizedChats, setMinimizedChats] = useState({}); // { username: boolean }
const [unreadCounts, setUnreadCounts] = useState({}); // { username: number }
const notificationPermissionRef = useRef("default");
const minimizedChatsRef = useRef(minimizedChats);
const activeChatsRef = useRef(activeChats);
// Keep refs updated
useEffect(() => {
minimizedChatsRef.current = minimizedChats;
}, [minimizedChats]);
useEffect(() => {
activeChatsRef.current = activeChats;
}, [activeChats]);
// Lock body scroll on mobile when chat is open (prevents keyboard scroll issues)
useEffect(() => {
const hasOpenChat = activeChats.some((chat) => !minimizedChats[chat.username]);
const isMobile = window.innerWidth <= 640;
if (hasOpenChat && isMobile) {
// Save current scroll position
const scrollY = window.scrollY;
document.body.style.position = 'fixed';
document.body.style.top = `-${scrollY}px`;
document.body.style.left = '0';
document.body.style.right = '0';
document.body.dataset.chatScrollY = scrollY;
} else if (document.body.style.position === 'fixed') {
// Restore scroll position
const scrollY = document.body.dataset.chatScrollY || 0;
document.body.style.position = '';
document.body.style.top = '';
document.body.style.left = '';
document.body.style.right = '';
window.scrollTo(0, parseInt(scrollY, 10));
}
return () => {
if (document.body.style.position === 'fixed') {
const scrollY = document.body.dataset.chatScrollY || 0;
document.body.style.position = '';
document.body.style.top = '';
document.body.style.left = '';
document.body.style.right = '';
window.scrollTo(0, parseInt(scrollY, 10));
}
};
}, [activeChats, minimizedChats]);
// Request notification permission
useEffect(() => {
if ("Notification" in window) {
notificationPermissionRef.current = Notification.permission;
if (Notification.permission === "default") {
Notification.requestPermission().then((permission) => {
notificationPermissionRef.current = permission;
});
}
}
}, []);
// Handle new incoming message
const handleNewMessage = useCallback((msg) => {
const senderName = msg.sender_name;
// Check if chat is open and not minimized
const isChatOpen = activeChatsRef.current.some((c) => c.username === senderName);
const isChatMinimized = minimizedChatsRef.current[senderName];
const isDocumentFocused = document.hasFocus();
// Update unread count if chat is not visible
if (!isChatOpen || isChatMinimized || !isDocumentFocused) {
setUnreadCounts((prev) => ({
...prev,
[senderName]: (prev[senderName] || 0) + 1,
}));
}
// Show notification if:
// - Chat is not open, or is minimized, or document is not focused
// - Notification permission is granted
const shouldNotify = !isChatOpen || isChatMinimized || !isDocumentFocused;
if (shouldNotify) {
// Play notification sound
playNotificationSound();
// Show browser notification
if (notificationPermissionRef.current === "granted") {
const notification = new Notification(`${senderName}`, {
body: msg.content?.substring(0, 100) || "New message",
icon: "/favicon.ico",
tag: `chat-${senderName}`,
silent: true, // We play our own sound
});
notification.onclick = () => {
window.focus();
// Open or unminimize the chat
const friend = { username: senderName };
if (!activeChatsRef.current.some((c) => c.username === senderName)) {
setActiveChats((prev) => [...prev, friend]);
}
setMinimizedChats((prev) => ({ ...prev, [senderName]: false }));
notification.close();
};
}
}
}, []);
// Chat hook
const {
connected,
messages,
typing,
presence,
conversations,
sendMessage,
sendTyping,
markAsRead,
fetchMessages,
fetchConversations,
} = useChat({
username,
enabled: authenticated,
onNewMessage: handleNewMessage,
});
// Load friends when authenticated
useEffect(() => {
if (!authenticated) {
setFriends([]);
return;
}
const loadFriends = async () => {
const res = await fetchFriends();
if (res?.ok && res.friends) {
setFriends(res.friends);
}
};
loadFriends();
}, [authenticated]);
// Load conversations to get initial unread counts
useEffect(() => {
if (!authenticated || !connected) return;
const loadConversations = async () => {
const convs = await fetchConversations();
if (convs && convs.length > 0) {
const newUnread = {};
convs.forEach((c) => {
if (c.unread_count > 0) {
newUnread[c.username] = c.unread_count;
}
});
if (Object.keys(newUnread).length > 0) {
setUnreadCounts((prev) => ({ ...prev, ...newUnread }));
}
}
};
loadConversations();
}, [authenticated, connected, fetchConversations]);
// Load active chats from localStorage
useEffect(() => {
try {
const saved = localStorage.getItem(ACTIVE_CHATS_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed)) {
setActiveChats(parsed);
}
}
} catch {}
}, []);
// Save active chats to localStorage
useEffect(() => {
try {
localStorage.setItem(ACTIVE_CHATS_KEY, JSON.stringify(activeChats));
} catch {}
}, [activeChats]);
// Refresh messages and unread counts when page becomes visible (mobile resume)
useEffect(() => {
const handleVisibilityChange = async () => {
if (document.visibilityState === 'visible' && authenticated) {
console.log('[Chat] Page visible, refreshing data...');
// Refresh messages for active chats
if (activeChats.length > 0) {
activeChats.forEach((chat) => {
fetchMessages(chat.username);
});
}
// Refresh conversations to update unread counts/badges
const convs = await fetchConversations();
if (convs && convs.length > 0) {
const newUnread = {};
convs.forEach((c) => {
if (c.unread_count > 0) {
newUnread[c.username] = c.unread_count;
}
});
setUnreadCounts((prev) => {
// Merge but don't overwrite zeros (already read in this session)
const merged = { ...prev };
Object.entries(newUnread).forEach(([user, count]) => {
// Only update if we don't have a local zero (user already read it)
if (merged[user] === undefined || merged[user] > 0) {
merged[user] = count;
}
});
return merged;
});
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [activeChats, fetchMessages, fetchConversations, authenticated]);
// Open chat with a friend
const openChat = useCallback(
(friend) => {
if (!authenticated) {
// Trigger auth modal
window.dispatchEvent(
new CustomEvent("sociowire:auth-required", {
detail: {
message: t("chat.signInToChat", "Sign in to chat with friends"),
mode: "login",
},
})
);
setShowContacts(false);
return;
}
// Save scroll position before any state changes
const scrollY = window.scrollY;
const friendUsername = friend.username || friend.name;
const chatData = {
username: friendUsername,
avatar: friend.avatar_url || friend.avatar,
};
// Check if already open
if (!activeChats.some((c) => c.username === chatData.username)) {
setActiveChats((prev) => [...prev, chatData]);
// Fetch message history
fetchMessages(friendUsername);
}
// Unminimize if minimized
setMinimizedChats((prev) => ({ ...prev, [friendUsername]: false }));
// Clear unread count and mark as read
setUnreadCounts((prev) => ({ ...prev, [friendUsername]: 0 }));
markAsRead(friendUsername);
setShowContacts(false);
// Restore scroll position after React updates
requestAnimationFrame(() => {
window.scrollTo(0, scrollY);
});
},
[authenticated, activeChats, fetchMessages, markAsRead, t]
);
// Close a chat
const closeChat = useCallback((chatUsername) => {
// Save scroll position before closing
const scrollY = window.scrollY;
setActiveChats((prev) => prev.filter((c) => c.username !== chatUsername));
setMinimizedChats((prev) => {
const next = { ...prev };
delete next[chatUsername];
return next;
});
// Restore scroll position after React updates
requestAnimationFrame(() => {
window.scrollTo(0, scrollY);
});
}, []);
// Toggle minimize
const toggleMinimize = useCallback((chatUsername) => {
const scrollY = window.scrollY;
setMinimizedChats((prev) => {
const wasMinimized = prev[chatUsername];
// Clear unread count and mark as read when unminimizing
if (wasMinimized) {
setUnreadCounts((uc) => ({ ...uc, [chatUsername]: 0 }));
markAsRead(chatUsername);
}
return { ...prev, [chatUsername]: !wasMinimized };
});
requestAnimationFrame(() => {
window.scrollTo(0, scrollY);
});
}, [markAsRead]);
// Handle send message
const handleSend = useCallback(
(chatUsername) => (content) => {
sendMessage(chatUsername, content);
},
[sendMessage]
);
// Handle typing
const handleTyping = useCallback(
(chatUsername) => (isTyping) => {
sendTyping(chatUsername, isTyping);
},
[sendTyping]
);
// Handle contacts button click
const handleContactsClick = () => {
if (!authenticated) {
// Trigger auth modal
window.dispatchEvent(
new CustomEvent("sociowire:auth-required", {
detail: {
message: t("chat.signInToChat", "Sign in to chat with friends"),
mode: "login",
},
})
);
return;
}
setShowContacts(!showContacts);
};
// Calculate total unread count
const totalUnread = Object.values(unreadCounts).reduce((sum, count) => sum + count, 0);
// Use portal to render chat outside of normal DOM hierarchy
// This ensures chat is completely independent and always on top
return createPortal(
<>
{/* Bottom Bar: Active Chat Tabs + Contacts Button */}
<div className="chat-bottom-bar">
{/* Active Chat Tabs (minimized chats) */}
{activeChats.filter(chat => minimizedChats[chat.username]).map((chat) => {
const unread = unreadCounts[chat.username] || 0;
return (
<button
key={chat.username}
className={"chat-tab" + (presence[chat.username] ? " online" : "") + (unread > 0 ? " has-unread" : "")}
onClick={() => toggleMinimize(chat.username)}
>
<div className="chat-tab-avatar">
{chat.avatar ? (
<img src={chat.avatar} alt={chat.username} />
) : (
<span>{(chat.username || "?")[0].toUpperCase()}</span>
)}
</div>
<span className="chat-tab-name">{chat.username}</span>
{unread > 0 && <span className="chat-tab-unread">{unread}</span>}
<button
className="chat-tab-close"
onClick={(e) => { e.stopPropagation(); closeChat(chat.username); }}
>
<i className="fa-solid fa-xmark" />
</button>
</button>
);
})}
{/* Contacts Button */}
<button
className={"contacts-float-btn" + (connected ? " connected" : "")}
onClick={handleContactsClick}
>
<span className="contact-icon">
<i className="fa-solid fa-comments" />
</span>
<span className="contact-label">{t("common.contacts")}</span>
{totalUnread > 0 && <span className="contacts-badge">{totalUnread}</span>}
{authenticated && (
<span className={"connection-dot" + (connected ? " online" : "")} />
)}
</button>
</div>
{/* Contacts Dropdown */}
{showContacts && (
<div className="contacts-dropdown">
<div className="contacts-header">
<span>{t("common.contacts")}</span>
<button
type="button"
className="contacts-close"
onClick={() => setShowContacts(false)}
>
<i className="fa-solid fa-xmark" />
</button>
</div>
{!authenticated ? (
<div className="contacts-empty">
{t("friends.signInToSee")}
</div>
) : friends.length === 0 ? (
<div className="contacts-empty">
{t("friends.noFriends")}
</div>
) : (
<div className="contacts-list">
{friends.map((friend) => {
const friendUsername = friend.username || friend.name;
const avatar = friend.avatar_url || friend.avatar;
const isOnline = presence[friendUsername] || false;
const unreadCount = unreadCounts[friendUsername] || 0;
return (
<div
key={friendUsername}
className={"contact-item" + (unreadCount > 0 ? " has-unread" : "")}
onClick={() => openChat(friend)}
>
<div className={"contact-avatar" + (isOnline ? " online" : "")}>
{avatar ? (
<img src={avatar} alt={friendUsername} />
) : (
<span>{(friendUsername || "?")[0].toUpperCase()}</span>
)}
</div>
<span className="contact-name">{friendUsername}</span>
{unreadCount > 0 && (
<span className="contact-unread-badge">{unreadCount}</span>
)}
{isOnline && <span className="online-badge" />}
</div>
);
})}
</div>
)}
</div>
)}
{/* Active Chat Windows (only non-minimized) */}
<div className="chat-windows-container">
{activeChats
.filter((chat) => !minimizedChats[chat.username])
.map((chat, index) => (
<div
key={chat.username}
className="chat-window-wrapper"
style={{ right: 16 + index * 340 }}
>
<ChatWindow
recipientID={chat.username}
recipientName={chat.username}
recipientAvatar={chat.avatar}
messages={messages[chat.username] || []}
typing={typing[chat.username] || false}
online={presence[chat.username] || false}
minimized={false}
onSend={handleSend(chat.username)}
onTyping={handleTyping(chat.username)}
onClose={() => closeChat(chat.username)}
onMinimize={() => toggleMinimize(chat.username)}
/>
</div>
))}
</div>
</>,
document.body
);
}

View File

@ -0,0 +1,324 @@
.chat-window {
width: 320px;
height: 420px;
background: rgba(15, 23, 42, 0.95);
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 12px 12px 0 0;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
overflow: hidden;
backdrop-filter: blur(12px);
}
.chat-window--minimized {
height: auto;
cursor: pointer;
}
.chat-window--minimized:hover {
background: rgba(30, 41, 59, 0.95);
}
/* Header */
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: linear-gradient(135deg, rgba(30, 58, 138, 0.8), rgba(59, 130, 246, 0.6));
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
}
.chat-user {
display: flex;
align-items: center;
gap: 10px;
}
.chat-avatar-sm {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 14px;
position: relative;
overflow: hidden;
}
.chat-avatar-sm img {
width: 100%;
height: 100%;
object-fit: cover;
}
.chat-avatar-sm.online::after {
content: "";
position: absolute;
bottom: 0;
right: 0;
width: 10px;
height: 10px;
background: #22c55e;
border: 2px solid rgba(15, 23, 42, 0.95);
border-radius: 50%;
}
.chat-user-info {
display: flex;
flex-direction: column;
}
.chat-username {
font-weight: 600;
font-size: 14px;
color: #f1f5f9;
}
.chat-status {
font-size: 11px;
color: #22c55e;
}
.chat-actions {
display: flex;
gap: 4px;
}
.chat-minimize,
.chat-close {
width: 26px;
height: 26px;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
color: #e2e8f0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
transition: background 0.2s;
}
.chat-minimize:hover,
.chat-close:hover {
background: rgba(255, 255, 255, 0.2);
}
/* Messages */
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.chat-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: rgba(148, 163, 184, 0.7);
font-size: 13px;
}
.chat-msg {
max-width: 80%;
padding: 8px 12px;
border-radius: 16px;
background: rgba(59, 130, 246, 0.15);
color: #e2e8f0;
font-size: 13px;
line-height: 1.4;
align-self: flex-start;
}
.chat-msg--mine {
background: linear-gradient(135deg, #3b82f6, #2563eb);
color: white;
align-self: flex-end;
}
.chat-msg-content {
word-break: break-word;
}
.chat-msg-meta {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
margin-top: 4px;
}
.chat-msg-time {
font-size: 10px;
color: rgba(148, 163, 184, 0.7);
}
.chat-msg--mine .chat-msg-time {
color: rgba(255, 255, 255, 0.7);
}
.chat-msg-status {
font-size: 10px;
color: rgba(255, 255, 255, 0.6);
}
.chat-msg-status i {
font-size: 10px;
}
.chat-msg-status i.read {
color: #22c55e;
}
/* Typing indicator */
.chat-typing {
display: flex;
gap: 4px;
padding: 8px 12px;
align-self: flex-start;
}
.chat-typing-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(148, 163, 184, 0.5);
animation: typingBounce 1.4s infinite ease-in-out both;
}
.chat-typing-dot:nth-child(1) {
animation-delay: 0s;
}
.chat-typing-dot:nth-child(2) {
animation-delay: 0.2s;
}
.chat-typing-dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typingBounce {
0%,
80%,
100% {
transform: scale(0.6);
opacity: 0.4;
}
40% {
transform: scale(1);
opacity: 1;
}
}
/* Input */
.chat-input-area {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-top: 1px solid rgba(148, 163, 184, 0.2);
background: rgba(15, 23, 42, 0.6);
}
.chat-input {
flex: 1;
padding: 8px 14px;
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 20px;
background: rgba(30, 41, 59, 0.8);
color: #f1f5f9;
font-size: 13px;
outline: none;
}
.chat-input::placeholder {
color: rgba(148, 163, 184, 0.6);
}
.chat-input:focus {
border-color: #3b82f6;
}
.chat-send {
width: 36px;
height: 36px;
border: none;
border-radius: 50%;
background: linear-gradient(135deg, #3b82f6, #2563eb);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: transform 0.2s, opacity 0.2s;
}
.chat-send:hover:not(:disabled) {
transform: scale(1.05);
}
.chat-send:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Light theme */
body[data-theme="light"] .chat-window {
background: rgba(255, 255, 255, 0.98);
border-color: rgba(148, 163, 184, 0.4);
}
body[data-theme="light"] .chat-header {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.9), rgba(99, 102, 241, 0.8));
}
body[data-theme="light"] .chat-username {
color: #f1f5f9;
}
body[data-theme="light"] .chat-messages {
background: #f8fafc;
}
body[data-theme="light"] .chat-msg {
background: rgba(59, 130, 246, 0.1);
color: #1e293b;
}
body[data-theme="light"] .chat-input-area {
background: rgba(255, 255, 255, 0.9);
}
body[data-theme="light"] .chat-input {
background: #f1f5f9;
color: #1e293b;
border-color: rgba(148, 163, 184, 0.4);
}
/* Responsive */
@media (max-width: 640px) {
.chat-window {
width: 100%;
max-width: 100vw;
height: 50vh;
height: 50dvh; /* Dynamic viewport height - accounts for keyboard */
max-height: calc(100vh - 60px);
max-height: calc(100dvh - 60px);
border-radius: 16px 16px 0 0;
}
.chat-input-area {
padding-bottom: max(10px, env(safe-area-inset-bottom, 10px));
}
}

View File

@ -0,0 +1,218 @@
import React, { useState, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { formatChatTime } from "../../utils/timezone";
import "./ChatWindow.css";
export default function ChatWindow({
recipientID,
recipientName,
recipientAvatar,
messages = [],
typing = false,
online = false,
onSend,
onTyping,
onClose,
onMinimize,
minimized = false,
}) {
const { t } = useTranslation();
const [input, setInput] = useState("");
const messagesEndRef = useRef(null);
const inputRef = useRef(null);
const typingTimeout = useRef(null);
// Auto-scroll to bottom on new messages
useEffect(() => {
if (!minimized && messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [messages, minimized]);
// Focus input when opening (without scrolling the page)
useEffect(() => {
if (!minimized && inputRef.current) {
// Use preventScroll to avoid page scroll when focusing
inputRef.current.focus({ preventScroll: true });
}
}, [minimized]);
const handleSend = () => {
const text = input.trim();
if (!text) return;
onSend?.(text);
setInput("");
// Clear typing indicator
if (typingTimeout.current) {
clearTimeout(typingTimeout.current);
}
onTyping?.(false);
};
const handleKeyDown = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleInputChange = (e) => {
setInput(e.target.value);
// Send typing indicator
onTyping?.(true);
if (typingTimeout.current) {
clearTimeout(typingTimeout.current);
}
typingTimeout.current = setTimeout(() => {
onTyping?.(false);
}, 2000);
};
// Use timezone-aware formatting
const formatTime = formatChatTime;
if (minimized) {
return (
<div className="chat-window chat-window--minimized" onClick={onMinimize}>
<div className="chat-header">
<div className="chat-user">
<div className={"chat-avatar-sm" + (online ? " online" : "")}>
{recipientAvatar ? (
<img src={recipientAvatar} alt={recipientName} />
) : (
<span>{(recipientName || "?")[0].toUpperCase()}</span>
)}
</div>
<span className="chat-username">{recipientName}</span>
</div>
<button
type="button"
className="chat-close"
onClick={(e) => {
e.stopPropagation();
onClose?.();
}}
>
<i className="fa-solid fa-xmark" />
</button>
</div>
</div>
);
}
return (
<div className="chat-window">
{/* Header */}
<div className="chat-header">
<div className="chat-user">
<div className={"chat-avatar-sm" + (online ? " online" : "")}>
{recipientAvatar ? (
<img src={recipientAvatar} alt={recipientName} />
) : (
<span>{(recipientName || "?")[0].toUpperCase()}</span>
)}
</div>
<div className="chat-user-info">
<span className="chat-username">{recipientName}</span>
<span className="chat-status">
{online ? t("common.online") : ""}
</span>
</div>
</div>
<div className="chat-actions">
<button
type="button"
className="chat-minimize"
onClick={onMinimize}
title={t("common.close")}
>
<i className="fa-solid fa-minus" />
</button>
<button
type="button"
className="chat-close"
onClick={onClose}
title={t("common.close")}
>
<i className="fa-solid fa-xmark" />
</button>
</div>
</div>
{/* Messages */}
<div className="chat-messages">
{messages.length === 0 ? (
<div className="chat-empty">
{t("chat.startConversation", "Start a conversation")}
</div>
) : (
messages.map((msg, idx) => {
// Compare by username (sender_name), case-insensitive
const isMine = msg.sender_name?.toLowerCase() !== recipientName?.toLowerCase();
// Determine message status for sent messages
const isSending = msg.id?.toString().startsWith("temp-");
const isRead = msg.read_at != null;
const isSent = !isSending && msg.id;
return (
<div
key={msg.id || idx}
className={"chat-msg" + (isMine ? " chat-msg--mine" : "")}
>
<div className="chat-msg-content">{msg.content}</div>
<div className="chat-msg-meta">
<span className="chat-msg-time">{formatTime(msg.created_at)}</span>
{isMine && (
<span className="chat-msg-status">
{isSending ? (
<i className="fa-regular fa-clock" title={t("chat.sending", "Sending...")} />
) : isRead ? (
<i className="fa-solid fa-check-double read" title={t("chat.read", "Read")} />
) : isSent ? (
<i className="fa-solid fa-check" title={t("chat.sent", "Sent")} />
) : null}
</span>
)}
</div>
</div>
);
})
)}
{typing && (
<div className="chat-typing">
<span className="chat-typing-dot" />
<span className="chat-typing-dot" />
<span className="chat-typing-dot" />
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="chat-input-area">
<input
ref={inputRef}
type="text"
className="chat-input"
placeholder={t("chat.typeMessage", "Type a message...")}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
/>
<button
type="button"
className="chat-send"
onClick={handleSend}
disabled={!input.trim()}
>
<i className="fa-solid fa-paper-plane" />
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,192 @@
/**
* End-to-End Encryption utilities using Web Crypto API
* Uses ECDH for key exchange and AES-GCM for message encryption
*/
const STORAGE_KEY = "sociowire:e2e:keypair";
const PEER_KEYS_KEY = "sociowire:e2e:peerKeys";
// Generate or retrieve the user's key pair
export async function getOrCreateKeyPair() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const { publicKey, privateKey } = JSON.parse(stored);
return {
publicKey: await importPublicKey(publicKey),
privateKey: await importPrivateKey(privateKey),
publicKeyExport: publicKey,
};
}
} catch (e) {
console.warn("[E2E] Failed to load stored keys, generating new ones", e);
}
// Generate new ECDH key pair
const keyPair = await crypto.subtle.generateKey(
{ name: "ECDH", namedCurve: "P-256" },
true,
["deriveBits"]
);
// Export keys for storage
const publicKeyExport = await exportPublicKey(keyPair.publicKey);
const privateKeyExport = await exportPrivateKey(keyPair.privateKey);
// Store in localStorage
localStorage.setItem(STORAGE_KEY, JSON.stringify({
publicKey: publicKeyExport,
privateKey: privateKeyExport,
}));
return {
publicKey: keyPair.publicKey,
privateKey: keyPair.privateKey,
publicKeyExport,
};
}
// Export public key to base64
async function exportPublicKey(key) {
const exported = await crypto.subtle.exportKey("spki", key);
return btoa(String.fromCharCode(...new Uint8Array(exported)));
}
// Export private key to base64
async function exportPrivateKey(key) {
const exported = await crypto.subtle.exportKey("pkcs8", key);
return btoa(String.fromCharCode(...new Uint8Array(exported)));
}
// Import public key from base64
async function importPublicKey(base64) {
const binary = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
return crypto.subtle.importKey(
"spki",
binary,
{ name: "ECDH", namedCurve: "P-256" },
true,
[]
);
}
// Import private key from base64
async function importPrivateKey(base64) {
const binary = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
return crypto.subtle.importKey(
"pkcs8",
binary,
{ name: "ECDH", namedCurve: "P-256" },
true,
["deriveBits"]
);
}
// Store peer's public key
export function storePeerPublicKey(username, publicKeyBase64) {
try {
const peerKeys = JSON.parse(localStorage.getItem(PEER_KEYS_KEY) || "{}");
peerKeys[username] = publicKeyBase64;
localStorage.setItem(PEER_KEYS_KEY, JSON.stringify(peerKeys));
} catch (e) {
console.warn("[E2E] Failed to store peer key", e);
}
}
// Get peer's public key
export function getPeerPublicKey(username) {
try {
const peerKeys = JSON.parse(localStorage.getItem(PEER_KEYS_KEY) || "{}");
return peerKeys[username] || null;
} catch (e) {
return null;
}
}
// Derive shared secret from own private key and peer's public key
async function deriveSharedKey(privateKey, peerPublicKeyBase64) {
const peerPublicKey = await importPublicKey(peerPublicKeyBase64);
// Derive shared bits using ECDH
const sharedBits = await crypto.subtle.deriveBits(
{ name: "ECDH", public: peerPublicKey },
privateKey,
256
);
// Import as AES-GCM key
return crypto.subtle.importKey(
"raw",
sharedBits,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
}
// Encrypt a message
export async function encryptMessage(privateKey, peerPublicKeyBase64, plaintext) {
try {
const sharedKey = await deriveSharedKey(privateKey, peerPublicKeyBase64);
// Generate random IV
const iv = crypto.getRandomValues(new Uint8Array(12));
// Encrypt
const encoder = new TextEncoder();
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
sharedKey,
encoder.encode(plaintext)
);
// Combine IV + ciphertext and encode as base64
const combined = new Uint8Array(iv.length + encrypted.byteLength);
combined.set(iv);
combined.set(new Uint8Array(encrypted), iv.length);
return "e2e:" + btoa(String.fromCharCode(...combined));
} catch (e) {
console.error("[E2E] Encryption failed", e);
return plaintext; // Fallback to plaintext
}
}
// Decrypt a message
export async function decryptMessage(privateKey, peerPublicKeyBase64, ciphertext) {
try {
// Check if message is encrypted
if (!ciphertext.startsWith("e2e:")) {
return ciphertext; // Not encrypted
}
const sharedKey = await deriveSharedKey(privateKey, peerPublicKeyBase64);
// Decode base64
const combined = Uint8Array.from(atob(ciphertext.slice(4)), c => c.charCodeAt(0));
// Extract IV and ciphertext
const iv = combined.slice(0, 12);
const encrypted = combined.slice(12);
// Decrypt
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
sharedKey,
encrypted
);
const decoder = new TextDecoder();
return decoder.decode(decrypted);
} catch (e) {
console.error("[E2E] Decryption failed", e);
return "[Encrypted message - decryption failed]";
}
}
// Check if E2E is available (Web Crypto API support)
export function isE2EAvailable() {
return typeof crypto !== "undefined" &&
typeof crypto.subtle !== "undefined" &&
typeof crypto.subtle.generateKey === "function";
}

View File

@ -0,0 +1,382 @@
import { useState, useEffect, useRef, useCallback } from "react";
import {
getOrCreateKeyPair,
storePeerPublicKey,
getPeerPublicKey,
encryptMessage,
decryptMessage,
isE2EAvailable,
} from "./cryptoUtils";
const WS_BASE = import.meta.env.VITE_CHAT_WS_URL ||
(window.location.protocol === "https:" ? "wss://" : "ws://") +
window.location.host + "/chat/ws";
const API_BASE = import.meta.env.VITE_CHAT_API_URL || "/chat/api";
export function useChat({ username, enabled = true, onNewMessage }) {
const [connected, setConnected] = useState(false);
const [messages, setMessages] = useState({}); // { recipientUsername: Message[] }
const [typing, setTyping] = useState({}); // { username: boolean }
const [presence, setPresence] = useState({}); // { username: boolean }
const [conversations, setConversations] = useState([]);
const [e2eReady, setE2eReady] = useState(false);
const wsRef = useRef(null);
const reconnectTimer = useRef(null);
const reconnectAttempts = useRef(0);
const keyPairRef = useRef(null);
const usernameRef = useRef(username);
const enabledRef = useRef(enabled);
const onNewMessageRef = useRef(onNewMessage);
// Keep refs updated
useEffect(() => {
usernameRef.current = username;
}, [username]);
useEffect(() => {
enabledRef.current = enabled;
}, [enabled]);
useEffect(() => {
onNewMessageRef.current = onNewMessage;
}, [onNewMessage]);
// Initialize E2E encryption keys
useEffect(() => {
if (!isE2EAvailable()) {
console.warn("[E2E] Web Crypto API not available");
return;
}
getOrCreateKeyPair().then((kp) => {
keyPairRef.current = kp;
setE2eReady(true);
console.log("[E2E] Key pair ready");
}).catch((err) => {
console.error("[E2E] Failed to initialize keys", err);
});
}, []);
// Send public key to a peer for key exchange
const sendPublicKey = useCallback((recipientUsername) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
if (!keyPairRef.current?.publicKeyExport) return;
const msg = {
type: "key_exchange",
payload: {
recipient_username: recipientUsername,
public_key: keyPairRef.current.publicKeyExport,
},
};
wsRef.current.send(JSON.stringify(msg));
}, []);
// Handle incoming WebSocket messages (uses ref to avoid dependency changes)
const handleMessage = useCallback((data) => {
const currentUsername = usernameRef.current;
// Parse payload if it's a string (backwards compat), otherwise use as-is
const parsePayload = (payload) => {
if (typeof payload === "string") {
try {
return JSON.parse(payload);
} catch {
return payload;
}
}
return payload;
};
switch (data.type) {
case "message": {
const msg = parsePayload(data.payload);
// Case-insensitive comparison for username matching
const isMySent = msg.sender_name?.toLowerCase() === currentUsername?.toLowerCase();
const otherUser = isMySent ? msg.recipient_name : msg.sender_name;
const isIncoming = !isMySent;
console.log("[Chat] Received message from", msg.sender_name, "to", msg.recipient_name);
setMessages((prev) => {
const existing = prev[otherUser] || [];
// Avoid duplicates (check by id or by temp-id prefix)
if (existing.some((m) => m.id === msg.id || (m.id?.toString().startsWith("temp-") && m.content === msg.content && m.sender_name === msg.sender_name))) {
// Replace temp message with real one
return {
...prev,
[otherUser]: existing.map((m) =>
m.id?.toString().startsWith("temp-") && m.content === msg.content && m.sender_name === msg.sender_name
? msg
: m
),
};
}
return { ...prev, [otherUser]: [...existing, msg] };
});
// Notify parent component of new incoming message
if (isIncoming && onNewMessageRef.current) {
onNewMessageRef.current(msg);
}
break;
}
case "typing": {
const payload = parsePayload(data.payload);
setTyping((prev) => ({
...prev,
[payload.username]: payload.is_typing,
}));
// Clear typing indicator after 3 seconds
setTimeout(() => {
setTyping((prev) => ({ ...prev, [payload.username]: false }));
}, 3000);
break;
}
case "presence": {
const payload = parsePayload(data.payload);
setPresence((prev) => ({
...prev,
[payload.username]: payload.online,
}));
break;
}
default:
console.log("[Chat] Unknown message type", data.type);
}
}, []); // No dependencies - uses refs
// Connect to WebSocket (uses refs to avoid dependency changes)
const connect = useCallback(() => {
const currentUsername = usernameRef.current;
const currentEnabled = enabledRef.current;
if (!currentEnabled || !currentUsername) return;
// Don't reconnect if already connected
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
console.log("[Chat] Already connected");
return;
}
const wsUrl = `${WS_BASE}?username=${encodeURIComponent(currentUsername)}`;
console.log("[Chat] Connecting to", wsUrl);
try {
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log("[Chat] WebSocket connected as", currentUsername);
setConnected(true);
reconnectAttempts.current = 0;
};
ws.onclose = (e) => {
console.log("[Chat] WebSocket closed", e.code, e.reason);
setConnected(false);
wsRef.current = null;
// Reconnect with exponential backoff (check ref values)
if (enabledRef.current && reconnectAttempts.current < 10) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000);
reconnectAttempts.current++;
console.log("[Chat] Reconnecting in", delay, "ms (attempt", reconnectAttempts.current, ")");
reconnectTimer.current = setTimeout(connect, delay);
}
};
ws.onerror = (e) => {
console.error("[Chat] WebSocket error", e);
};
ws.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
handleMessage(data);
} catch (err) {
console.error("[Chat] Parse error", err);
}
};
wsRef.current = ws;
} catch (err) {
console.error("[Chat] Connection error", err);
}
}, [handleMessage]); // Only depends on stable handleMessage
// Send a chat message
const sendMessage = useCallback((recipientUsername, content) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
console.error("[Chat] WebSocket not connected");
return false;
}
const currentUsername = usernameRef.current;
// Optimistic update - add message to local state immediately
const optimisticMsg = {
id: `temp-${Date.now()}`,
sender_name: currentUsername,
recipient_name: recipientUsername,
content: content,
created_at: new Date().toISOString(),
};
setMessages((prev) => {
const existing = prev[recipientUsername] || [];
return { ...prev, [recipientUsername]: [...existing, optimisticMsg] };
});
const msg = {
type: "message",
payload: {
recipient_username: recipientUsername,
content: content,
},
};
wsRef.current.send(JSON.stringify(msg));
return true;
}, []);
// Send typing indicator
const sendTyping = useCallback((recipientUsername, isTyping) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
const msg = {
type: "typing",
payload: {
recipient_username: recipientUsername,
is_typing: isTyping,
},
};
wsRef.current.send(JSON.stringify(msg));
}, []);
// Mark messages as read
const markAsRead = useCallback((senderUsername) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
const msg = {
type: "read",
payload: { sender_username: senderUsername },
};
wsRef.current.send(JSON.stringify(msg));
}, []);
// Fetch message history
const fetchMessages = useCallback(async (otherUsername) => {
if (!username || !otherUsername) return [];
try {
const res = await fetch(`${API_BASE}/messages?username=${encodeURIComponent(username)}&other=${encodeURIComponent(otherUsername)}`);
const data = await res.json();
if (data.ok && data.messages) {
setMessages((prev) => ({ ...prev, [otherUsername]: data.messages }));
return data.messages;
}
} catch (err) {
console.error("[Chat] Fetch messages error", err);
}
return [];
}, [username]);
// Fetch conversations list
const fetchConversations = useCallback(async () => {
if (!username) return [];
try {
const res = await fetch(`${API_BASE}/conversations?username=${encodeURIComponent(username)}`);
const data = await res.json();
if (data.ok && data.conversations) {
setConversations(data.conversations);
// Update presence from conversations
const newPresence = {};
data.conversations.forEach((c) => {
newPresence[c.username] = c.online;
});
setPresence((prev) => ({ ...prev, ...newPresence }));
return data.conversations;
}
} catch (err) {
console.error("[Chat] Fetch conversations error", err);
}
return [];
}, [username]);
// Connect on mount / when enabled or username changes
useEffect(() => {
if (enabled && username) {
// Only connect if not already connected
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
connect();
}
} else {
// Disconnect if disabled or no username
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
}
}, [enabled, username, connect]);
// Reconnect when page becomes visible (mobile app resume)
useEffect(() => {
if (!enabled || !username) return;
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
console.log('[Chat] Page visible, checking connection...');
// Check if WebSocket is disconnected or in bad state
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
console.log('[Chat] Reconnecting after visibility change...');
reconnectAttempts.current = 0; // Reset attempts
connect();
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [enabled, username, connect]);
// Cleanup on unmount only
useEffect(() => {
return () => {
if (reconnectTimer.current) {
clearTimeout(reconnectTimer.current);
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
};
}, []);
return {
connected,
messages,
typing,
presence,
conversations,
sendMessage,
sendTyping,
markAsRead,
fetchMessages,
fetchConversations,
};
}

View File

@ -1,12 +1,15 @@
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next";
export default function FilterButtons({ active, onSelect }) { export default function FilterButtons({ active, onSelect }) {
const { t } = useTranslation();
const buttons = [ const buttons = [
{ code: "All", label: "All", icon: "fa-solid fa-globe" }, { code: "All", labelKey: "filters.all", icon: "fa-solid fa-globe" },
{ code: "News", label: "News", icon: "fa-solid fa-newspaper" }, { code: "News", labelKey: "filters.news", icon: "fa-solid fa-newspaper" },
{ code: "Friends", label: "Friends", icon: "fa-solid fa-user-group" }, { code: "Friends", labelKey: "filters.friends", icon: "fa-solid fa-user-group" },
{ code: "Events", label: "Events", icon: "fa-solid fa-calendar-days" }, { code: "Events", labelKey: "filters.events", icon: "fa-solid fa-calendar-days" },
{ code: "Market", label: "Market", icon: "fa-solid fa-chart-line" }, { code: "Market", labelKey: "filters.market", icon: "fa-solid fa-chart-line" },
]; ];
return ( return (
@ -21,7 +24,7 @@ export default function FilterButtons({ active, onSelect }) {
<div className="sw-icon-circle"> <div className="sw-icon-circle">
<i className={btn.icon} /> <i className={btn.icon} />
</div> </div>
<div className="sw-icon-label">{btn.label}</div> <div className="sw-icon-label">{t(btn.labelKey)}</div>
</button> </button>
))} ))}
</div> </div>

View File

@ -1,11 +1,14 @@
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next";
export default function TimeFilterButtons({ activeHours, onSelect }) { export default function TimeFilterButtons({ activeHours, onSelect }) {
const { t } = useTranslation();
const buttons = [ const buttons = [
{ hours: 3, label: "Now", icon: "fa-solid fa-bolt" }, { hours: 3, labelKey: "time_filter.now", icon: "fa-solid fa-bolt" },
{ hours: 24, label: "Today", icon: "fa-solid fa-sun" }, { hours: 24, labelKey: "time_filter.today", icon: "fa-solid fa-sun" },
{ hours: 96, label: "4Days", icon: "fa-solid fa-calendar-days" }, { hours: 96, labelKey: "time_filter.recents", icon: "fa-solid fa-calendar-days" },
{ hours: 336, label: "Old", icon: "fa-solid fa-hourglass-half" }, { hours: 336, labelKey: "time_filter.old", icon: "fa-solid fa-hourglass-half" },
]; ];
return ( return (
@ -20,7 +23,7 @@ export default function TimeFilterButtons({ activeHours, onSelect }) {
<div className="sw-icon-circle"> <div className="sw-icon-circle">
<i className={btn.icon} /> <i className={btn.icon} />
</div> </div>
<div className="sw-icon-label">{btn.label}</div> <div className="sw-icon-label">{t(btn.labelKey)}</div>
</button> </button>
))} ))}
</div> </div>

View File

@ -127,6 +127,42 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.friend-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.friend-info-text {
display: flex;
flex-direction: column;
gap: .1rem;
min-width: 0;
}
.friend-request-direction {
font-size: .65rem;
color: #64748b;
}
.friend-item-sent {
border-color: rgba(251, 191, 36, 0.3);
}
.friend-item-sent .friend-request-direction {
color: #fbbf24;
}
.friend-item-received {
border-color: rgba(56, 189, 248, 0.4);
}
.friend-item-received .friend-request-direction {
color: #7dd3fc;
} }
.friend-name { .friend-name {
@ -180,6 +216,15 @@
border-color: rgba(248,113,113,0.6); border-color: rgba(248,113,113,0.6);
} }
.friend-action-cancel {
color: #fbbf24;
border-color: rgba(251,191,36,0.3);
}
.friend-action-cancel:hover {
background: rgba(251,191,36,0.2);
border-color: rgba(251,191,36,0.6);
}
/* Friend button in profile */ /* Friend button in profile */
.friend-btn { .friend-btn {
display: flex; display: flex;

View File

@ -1,17 +1,20 @@
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { import {
fetchFriends, fetchFriends,
fetchFriendRequests, fetchFriendRequests,
acceptFriendRequest, acceptFriendRequest,
rejectFriendRequest, rejectFriendRequest,
cancelFriendRequest,
removeFriend, removeFriend,
sendFriendRequest, sendFriendRequest,
searchUsers,
} from '../../api/client'; } from '../../api/client';
import { useAuth } from '../../auth/AuthContext'; import { useAuth } from '../../auth/AuthContext';
import { recoSearch } from '../../services/recoSearch';
import './Friends.css'; import './Friends.css';
export default function FriendsList({ onSelectUser, onClose }) { export default function FriendsList({ onSelectUser, onClose }) {
const { t } = useTranslation();
const { authenticated } = useAuth(); const { authenticated } = useAuth();
const [activeTab, setActiveTab] = useState('friends'); const [activeTab, setActiveTab] = useState('friends');
const [friends, setFriends] = useState([]); const [friends, setFriends] = useState([]);
@ -40,6 +43,8 @@ export default function FriendsList({ onSelectUser, onClose }) {
fetchFriends(), fetchFriends(),
fetchFriendRequests(), fetchFriendRequests(),
]); ]);
console.log('[Friends] requestsRes:', requestsRes);
console.log('[Friends] requests:', requestsRes?.requests);
setFriends(friendsRes?.friends || []); setFriends(friendsRes?.friends || []);
setRequests(requestsRes?.requests || []); setRequests(requestsRes?.requests || []);
setLoading(false); setLoading(false);
@ -49,7 +54,7 @@ export default function FriendsList({ onSelectUser, onClose }) {
}, [authenticated]); }, [authenticated]);
// Search for users with debounce // Search for users with debounce
const searchUsers = async (query) => { const doSearchUsers = async (query) => {
if (!query || query.length < 2) { if (!query || query.length < 2) {
setSuggestions([]); setSuggestions([]);
setShowSuggestions(false); setShowSuggestions(false);
@ -58,13 +63,10 @@ export default function FriendsList({ onSelectUser, onClose }) {
setSuggestionsLoading(true); setSuggestionsLoading(true);
try { try {
const results = await recoSearch(query, { limit: 8 }); const res = await searchUsers(query);
// Filter only profiles const users = res?.users || [];
const profiles = results.filter( setSuggestions(users);
(r) => r.type === 'profile' || r.kind === 'profile' setShowSuggestions(users.length > 0);
);
setSuggestions(profiles);
setShowSuggestions(profiles.length > 0);
} catch { } catch {
setSuggestions([]); setSuggestions([]);
} }
@ -80,7 +82,7 @@ export default function FriendsList({ onSelectUser, onClose }) {
clearTimeout(searchTimeoutRef.current); clearTimeout(searchTimeoutRef.current);
} }
searchTimeoutRef.current = setTimeout(() => { searchTimeoutRef.current = setTimeout(() => {
searchUsers(value); doSearchUsers(value);
}, 300); }, 300);
}; };
@ -117,8 +119,17 @@ export default function FriendsList({ onSelectUser, onClose }) {
} }
}; };
const handleCancel = async (username) => {
console.log('[Friends] Canceling request to:', username);
const res = await cancelFriendRequest(username);
console.log('[Friends] Cancel response:', res);
if (res?.ok) {
setRequests((prev) => prev.filter((r) => r.username !== username));
}
};
const handleRemove = async (username) => { const handleRemove = async (username) => {
if (!window.confirm(`Remove ${username} from friends?`)) return; if (!window.confirm(t('friends.removeConfirm', { username }))) return;
const res = await removeFriend(username); const res = await removeFriend(username);
if (res?.ok) { if (res?.ok) {
setFriends((prev) => prev.filter((f) => f.username !== username)); setFriends((prev) => prev.filter((f) => f.username !== username));
@ -138,11 +149,11 @@ export default function FriendsList({ onSelectUser, onClose }) {
setAddLoading(false); setAddLoading(false);
if (res?.ok) { if (res?.ok) {
setAddSuccess(`Friend request sent to ${uname}`); setAddSuccess(t('friends.requestSent', { username: uname }));
setAddUsername(''); setAddUsername('');
setTimeout(() => setAddSuccess(''), 3000); setTimeout(() => setAddSuccess(''), 3000);
} else { } else {
setAddError(res?.error || res?.text || 'Could not send request'); setAddError(res?.error || res?.text || t('friends.couldNotSend'));
setTimeout(() => setAddError(''), 4000); setTimeout(() => setAddError(''), 4000);
} }
}; };
@ -151,10 +162,10 @@ export default function FriendsList({ onSelectUser, onClose }) {
return ( return (
<div className="friends-panel"> <div className="friends-panel">
<div className="friends-header"> <div className="friends-header">
<span>Friends</span> <span>{t('friends.title')}</span>
{onClose && <button className="friends-close" onClick={onClose}>×</button>} {onClose && <button className="friends-close" onClick={onClose}>×</button>}
</div> </div>
<div className="friends-empty">Sign in to see your friends</div> <div className="friends-empty">{t('friends.signInToSee')}</div>
</div> </div>
); );
} }
@ -162,7 +173,7 @@ export default function FriendsList({ onSelectUser, onClose }) {
return ( return (
<div className="friends-panel"> <div className="friends-panel">
<div className="friends-header"> <div className="friends-header">
<span>Friends</span> <span>{t('friends.title')}</span>
{onClose && <button className="friends-close" onClick={onClose}>×</button>} {onClose && <button className="friends-close" onClick={onClose}>×</button>}
</div> </div>
@ -171,13 +182,13 @@ export default function FriendsList({ onSelectUser, onClose }) {
className={`friends-tab ${activeTab === 'friends' ? 'active' : ''}`} className={`friends-tab ${activeTab === 'friends' ? 'active' : ''}`}
onClick={() => setActiveTab('friends')} onClick={() => setActiveTab('friends')}
> >
Friends ({friends.length}) {t('friends.friendsTab')} ({friends.length})
</button> </button>
<button <button
className={`friends-tab ${activeTab === 'requests' ? 'active' : ''}`} className={`friends-tab ${activeTab === 'requests' ? 'active' : ''}`}
onClick={() => setActiveTab('requests')} onClick={() => setActiveTab('requests')}
> >
Requests {requests.length > 0 && <span className="friends-badge">{requests.length}</span>} {t('friends.requestsTab')} {requests.filter(r => r.direction === 'received').length > 0 && <span className="friends-badge">{requests.filter(r => r.direction === 'received').length}</span>}
</button> </button>
</div> </div>
@ -188,7 +199,7 @@ export default function FriendsList({ onSelectUser, onClose }) {
ref={inputRef} ref={inputRef}
type="text" type="text"
className="friends-add-input" className="friends-add-input"
placeholder="Add friend by username..." placeholder={t('friends.addPlaceholder')}
value={addUsername} value={addUsername}
onChange={handleInputChange} onChange={handleInputChange}
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)} onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
@ -200,7 +211,7 @@ export default function FriendsList({ onSelectUser, onClose }) {
type="submit" type="submit"
className="friends-add-btn" className="friends-add-btn"
disabled={addLoading || !addUsername.trim()} disabled={addLoading || !addUsername.trim()}
title="Send friend request" title={t('friends.sendRequest')}
> >
{addLoading ? '...' : <i className="fa-solid fa-user-plus" />} {addLoading ? '...' : <i className="fa-solid fa-user-plus" />}
</button> </button>
@ -213,27 +224,31 @@ export default function FriendsList({ onSelectUser, onClose }) {
<div <div
key={s.id} key={s.id}
className="friends-suggestion-item" className="friends-suggestion-item"
onMouseDown={() => handleSelectSuggestion(s.title || s.raw?.username || s.id)} onMouseDown={() => handleSelectSuggestion(s.username)}
> >
<div className="friend-avatar"> <div className="friend-avatar">
{(s.title || s.id)[0]?.toUpperCase() || '?'} {s.avatar_url ? (
<img src={s.avatar_url} alt={s.username} />
) : (
s.username[0]?.toUpperCase() || '?'
)}
</div> </div>
<span>{s.title || s.raw?.username || s.id}</span> <span>{s.username}</span>
</div> </div>
))} ))}
</div> </div>
)} )}
{suggestionsLoading && <div className="friends-suggestions-loading">Searching...</div>} {suggestionsLoading && <div className="friends-suggestions-loading">{t('friends.searching')}</div>}
</div> </div>
{addError && <div className="friends-add-error">{addError}</div>} {addError && <div className="friends-add-error">{addError}</div>}
{addSuccess && <div className="friends-add-success">{addSuccess}</div>} {addSuccess && <div className="friends-add-success">{addSuccess}</div>}
<div className="friends-content"> <div className="friends-content">
{loading ? ( {loading ? (
<div className="friends-loading">Loading...</div> <div className="friends-loading">{t('common.loading')}</div>
) : activeTab === 'friends' ? ( ) : activeTab === 'friends' ? (
friends.length === 0 ? ( friends.length === 0 ? (
<div className="friends-empty">No friends yet</div> <div className="friends-empty">{t('friends.noFriends')}</div>
) : ( ) : (
<div className="friends-list"> <div className="friends-list">
{friends.map((friend) => ( {friends.map((friend) => (
@ -250,7 +265,7 @@ export default function FriendsList({ onSelectUser, onClose }) {
<button <button
className="friend-action friend-action-remove" className="friend-action friend-action-remove"
onClick={() => handleRemove(friend.username)} onClick={() => handleRemove(friend.username)}
title="Remove friend" title={t('friends.removeFriend')}
> >
<i className="fa-solid fa-user-minus" /> <i className="fa-solid fa-user-minus" />
</button> </button>
@ -259,38 +274,64 @@ export default function FriendsList({ onSelectUser, onClose }) {
</div> </div>
) )
) : requests.length === 0 ? ( ) : requests.length === 0 ? (
<div className="friends-empty">No pending requests</div> <div className="friends-empty">{t('friends.noPending')}</div>
) : ( ) : (
<div className="friends-list"> <div className="friends-list">
{requests.map((request) => ( {requests.map((request) => {
<div key={request.id} className="friend-item"> console.log('[Friends] Rendering request:', request.username, 'direction:', request.direction);
return (
<div key={request.id} className={`friend-item ${request.direction === 'sent' ? 'friend-item-sent' : 'friend-item-received'}`}>
<div <div
className="friend-info" className="friend-info"
onClick={() => onSelectUser && onSelectUser(request.username)} onClick={() => onSelectUser && onSelectUser(request.username)}
> >
<div className="friend-avatar"> <div className="friend-avatar">
{request.username[0].toUpperCase()} {request.avatar_url ? (
<img src={request.avatar_url} alt={request.username} />
) : (
request.username[0]?.toUpperCase() || '?'
)}
</div> </div>
<div className="friend-info-text">
<span className="friend-name">{request.username}</span> <span className="friend-name">{request.username}</span>
<span className="friend-request-direction">
{request.direction === 'sent'
? t('friends.requestSentLabel', 'Request sent')
: t('friends.requestReceivedLabel', 'Wants to be your friend')}
</span>
</div>
</div> </div>
<div className="friend-actions"> <div className="friend-actions">
{request.direction === 'received' ? (
<>
<button <button
className="friend-action friend-action-accept" className="friend-action friend-action-accept"
onClick={() => handleAccept(request.username)} onClick={() => handleAccept(request.username)}
title="Accept" title={t('friends.accept')}
> >
<i className="fa-solid fa-check" /> <i className="fa-solid fa-check" />
</button> </button>
<button <button
className="friend-action friend-action-reject" className="friend-action friend-action-reject"
onClick={() => handleReject(request.username)} onClick={() => handleReject(request.username)}
title="Reject" title={t('friends.reject')}
> >
<i className="fa-solid fa-times" /> <i className="fa-solid fa-times" />
</button> </button>
</>
) : (
<button
className="friend-action friend-action-cancel"
onClick={() => handleCancel(request.username)}
title={t('friends.cancelRequest', 'Cancel request')}
>
<i className="fa-solid fa-times" />
</button>
)}
</div> </div>
</div> </div>
))} );
})}
</div> </div>
)} )}
</div> </div>

View File

@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import maplibregl from 'maplibre-gl'; import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import { fetchIslands } from '../../api/client'; import { fetchIslands } from '../../api/client';
@ -9,6 +10,7 @@ import '../../styles/universe.css';
* Lightweight alternative to Three.js - uses existing MapLibre dependency * Lightweight alternative to Three.js - uses existing MapLibre dependency
*/ */
export default function IslandUniverse({ onClose, onIslandClick }) { export default function IslandUniverse({ onClose, onIslandClick }) {
const { t } = useTranslation();
const containerRef = useRef(null); const containerRef = useRef(null);
const mapRef = useRef(null); const mapRef = useRef(null);
const markersRef = useRef([]); const markersRef = useRef([]);
@ -187,8 +189,8 @@ export default function IslandUniverse({ onClose, onIslandClick }) {
<button className="island-universe-close" onClick={onClose}>×</button> <button className="island-universe-close" onClick={onClose}>×</button>
<div className="island-universe-header"> <div className="island-universe-header">
<h1>🌌 Island Universe</h1> <h1>🌌 {t('universe.title')}</h1>
<p>{islands.length} islands floating in virtual space</p> <p>{t('universe.islandsCount', { count: islands.length })}</p>
</div> </div>
<div <div
@ -200,15 +202,15 @@ export default function IslandUniverse({ onClose, onIslandClick }) {
{hoveredIsland && ( {hoveredIsland && (
<div className="island-universe-tooltip"> <div className="island-universe-tooltip">
<div className="tooltip-username">@{hoveredIsland.username}</div> <div className="tooltip-username">@{hoveredIsland.username}</div>
<div className="tooltip-bio">{hoveredIsland.display_name || hoveredIsland.bio || 'Click to visit'}</div> <div className="tooltip-bio">{hoveredIsland.display_name || hoveredIsland.bio || t('universe.clickToVisit')}</div>
<div className="tooltip-stats"> <div className="tooltip-stats">
{hoveredIsland.content_count} posts {hoveredIsland.terrain_type} {hoveredIsland.content_count} {t('universe.posts')} {hoveredIsland.terrain_type}
</div> </div>
</div> </div>
)} )}
<div className="island-universe-help"> <div className="island-universe-help">
<p>🖱 Hover over islands to see details Click to visit</p> <p>🖱 {t('universe.help')}</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -2,7 +2,8 @@ import React, { useEffect, useRef, useState } from 'react';
import maplibregl from 'maplibre-gl'; import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import '../../styles/island.css'; import '../../styles/island.css';
import { fetchPosts, updateProfileUsername, uploadProfileAvatar } from '../../api/client'; import { fetchPosts, updateProfileUsername, uploadProfileAvatar, updateTimezone, getBrowserTimezone } from '../../api/client';
import { setUserTimezone as saveTimezoneLocal, getUserTimezone as getStoredTimezone } from '../../utils/timezone';
import { useAuth } from '../../auth/AuthContext'; import { useAuth } from '../../auth/AuthContext';
import { trackEvent } from "../../utils/analytics"; import { trackEvent } from "../../utils/analytics";
@ -31,6 +32,9 @@ export default function IslandViewer({ island, onClose }) {
const [usernameSaving, setUsernameSaving] = useState(false); const [usernameSaving, setUsernameSaving] = useState(false);
const [usernameError, setUsernameError] = useState(""); const [usernameError, setUsernameError] = useState("");
const [usernameInfo, setUsernameInfo] = useState(""); const [usernameInfo, setUsernameInfo] = useState("");
const [timezone, setTimezone] = useState(getStoredTimezone());
const [timezoneSaving, setTimezoneSaving] = useState(false);
const [timezoneInfo, setTimezoneInfo] = useState("");
const islandUsername = localUsername || island?.username || ""; const islandUsername = localUsername || island?.username || "";
useEffect(() => { useEffect(() => {
@ -340,6 +344,61 @@ export default function IslandViewer({ island, onClose }) {
{uploadInfo ? <div className="island-profile-info">{uploadInfo}</div> : null} {uploadInfo ? <div className="island-profile-info">{uploadInfo}</div> : null}
{usernameError ? <div className="island-profile-error">{usernameError}</div> : null} {usernameError ? <div className="island-profile-error">{usernameError}</div> : null}
{usernameInfo ? <div className="island-profile-info">{usernameInfo}</div> : null} {usernameInfo ? <div className="island-profile-info">{usernameInfo}</div> : null}
<div className="island-profile-row" style={{ marginTop: '16px' }}>
<label>Timezone</label>
<select
value={timezone}
onChange={async (e) => {
const tz = e.target.value;
setTimezone(tz);
saveTimezoneLocal(tz); // Save locally for immediate use
setTimezoneSaving(true);
setTimezoneInfo("");
const res = await updateTimezone(tz);
if (res?.ok) {
setTimezoneInfo("Timezone saved");
setTimeout(() => setTimezoneInfo(""), 2000);
}
setTimezoneSaving(false);
}}
disabled={timezoneSaving}
>
<option value="">Auto-detect</option>
<optgroup label="Americas">
<option value="America/New_York">New York (EST)</option>
<option value="America/Chicago">Chicago (CST)</option>
<option value="America/Denver">Denver (MST)</option>
<option value="America/Los_Angeles">Los Angeles (PST)</option>
<option value="America/Toronto">Toronto (EST)</option>
<option value="America/Montreal">Montreal (EST)</option>
<option value="America/Vancouver">Vancouver (PST)</option>
<option value="America/Mexico_City">Mexico City</option>
<option value="America/Sao_Paulo">São Paulo</option>
</optgroup>
<optgroup label="Europe">
<option value="Europe/London">London (GMT)</option>
<option value="Europe/Paris">Paris (CET)</option>
<option value="Europe/Berlin">Berlin (CET)</option>
<option value="Europe/Madrid">Madrid (CET)</option>
<option value="Europe/Rome">Rome (CET)</option>
<option value="Europe/Amsterdam">Amsterdam (CET)</option>
<option value="Europe/Brussels">Brussels (CET)</option>
<option value="Europe/Zurich">Zurich (CET)</option>
</optgroup>
<optgroup label="Asia/Pacific">
<option value="Asia/Tokyo">Tokyo (JST)</option>
<option value="Asia/Shanghai">Shanghai (CST)</option>
<option value="Asia/Hong_Kong">Hong Kong (HKT)</option>
<option value="Asia/Singapore">Singapore (SGT)</option>
<option value="Asia/Dubai">Dubai (GST)</option>
<option value="Australia/Sydney">Sydney (AEST)</option>
<option value="Australia/Melbourne">Melbourne (AEST)</option>
<option value="Pacific/Auckland">Auckland (NZST)</option>
</optgroup>
</select>
</div>
{timezoneInfo ? <div className="island-profile-info">{timezoneInfo}</div> : null}
</div> </div>
) : null} ) : null}
</div> </div>

View File

@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "../../auth/AuthContext"; import { useAuth } from "../../auth/AuthContext";
import { registerUser, updateProfileUsername, fetchFriendRequests } from "../../api/client"; import { registerUser, updateProfileUsername, fetchFriendRequests } from "../../api/client";
import AuthToast from "../Auth/AuthToast"; import AuthToast from "../Auth/AuthToast";
@ -6,7 +7,8 @@ import { promptInstall, isRunningAsPWA, canInstall } from '../../pwaInstall';
import { trackEvent } from "../../utils/analytics"; import { trackEvent } from "../../utils/analytics";
import FriendsList from "../Friends/FriendsList"; import FriendsList from "../Friends/FriendsList";
const THEME_LABELS = { dark: "Dark", blue: "Blue", light: "Light" }; const SUPPORTED_LANGS = ['en', 'fr', 'es'];
const THEMES = ['dark', 'blue', 'light'];
const LAST_SIGNUP_KEY = "sociowire:lastSignup"; const LAST_SIGNUP_KEY = "sociowire:lastSignup";
function initialLetter(name = "") { function initialLetter(name = "") {
@ -56,6 +58,7 @@ function parseMergedParams() {
} }
export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick, onUserIsland }) { export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick, onUserIsland }) {
const { t, i18n } = useTranslation();
const { const {
loading, loading,
authenticated, authenticated,
@ -75,6 +78,58 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
clearInfo, clearInfo,
} = useAuth(); } = useAuth();
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 changeLanguage = (lang) => {
i18n.changeLanguage(lang);
setShowLangMenu(false);
};
const toggleLangMenu = () => {
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(() => {
if (!showLangMenu && !showThemeMenu) return;
const handleClick = (e) => {
if (showLangMenu && langBtnRef.current && !langBtnRef.current.contains(e.target)) {
setShowLangMenu(false);
}
if (showThemeMenu && themeBtnRef.current && !themeBtnRef.current.contains(e.target)) {
setShowThemeMenu(false);
}
};
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, [showLangMenu, showThemeMenu]);
const [showAuth, setShowAuth] = useState(false); const [showAuth, setShowAuth] = useState(false);
const [mode, setMode] = useState("login"); const [mode, setMode] = useState("login");
const [showUsernameModal, setShowUsernameModal] = useState(false); const [showUsernameModal, setShowUsernameModal] = useState(false);
@ -140,7 +195,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
const next = (desiredUsername || "").trim(); const next = (desiredUsername || "").trim();
const re = /^[A-Za-z0-9_.]{3,24}$/; const re = /^[A-Za-z0-9_.]{3,24}$/;
if (!re.test(next) || next.includes("@")) { if (!re.test(next) || next.includes("@")) {
setUsernameError("Use 3-24 letters, numbers, _ or ."); setUsernameError(t('auth.usernameHint'));
return; return;
} }
setUsernameSaving(true); setUsernameSaving(true);
@ -150,13 +205,13 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
setUsernameSaving(false); setUsernameSaving(false);
if (!res.ok) { if (!res.ok) {
if (res.status === 409) { if (res.status === 409) {
setUsernameError("Username already taken."); setUsernameError(t('auth.usernameTaken'));
} else { } else {
setUsernameError(res.text || "Could not update username."); setUsernameError(res.text || t('auth.usernameUpdateError'));
} }
return; return;
} }
setUsernameInfo("Username saved."); setUsernameInfo(t('auth.usernameSaved'));
setShowUsernameModal(false); setShowUsernameModal(false);
await restore(); await restore();
}; };
@ -172,8 +227,15 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
return; return;
} }
const loadRequests = async () => { const loadRequests = async () => {
try {
const res = await fetchFriendRequests(); const res = await fetchFriendRequests();
setPendingRequestsCount(res?.requests?.length || 0); // Only count received requests (not sent ones)
const received = (res?.requests || []).filter(r => r?.direction === 'received');
setPendingRequestsCount(received.length);
} catch (err) {
console.error('[TopBar] Error fetching friend requests:', err);
setPendingRequestsCount(0);
}
}; };
loadRequests(); loadRequests();
// Refresh every 30 seconds // Refresh every 30 seconds
@ -243,7 +305,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
const detail = e?.detail || {}; const detail = e?.detail || {};
const msg = const msg =
(detail.message || "").toString().trim() || (detail.message || "").toString().trim() ||
"Please sign in or create a free account to continue."; t('auth.pleaseSignIn');
setAuthNotice(msg); setAuthNotice(msg);
setMode(detail.mode === "signup" ? "signup" : "login"); setMode(detail.mode === "signup" ? "signup" : "login");
setShowAuth(true); setShowAuth(true);
@ -307,7 +369,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
if (truthyParam(verified)) { if (truthyParam(verified)) {
const who = (email || uname || last?.email || last?.username || "").trim(); const who = (email || uname || last?.email || last?.username || "").trim();
setSignupInfo("✅ Email confirmed" + (who ? " (" + who + ")" : "") + ". You can log in."); setSignupInfo("✅ " + t('auth.emailConfirmedMsg') + (who ? " (" + who + ")" : ""));
setMode("login"); setMode("login");
setShowAuth(true); setShowAuth(true);
@ -315,7 +377,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
if (prefill) setLoginUser(prefill); if (prefill) setLoginUser(prefill);
} else if (truthyParam(signup)) { } else if (truthyParam(signup)) {
const who = (email || uname || last?.email || last?.username || "").trim(); const who = (email || uname || last?.email || last?.username || "").trim();
setSignupInfo("✅ Account created" + (who ? " (" + who + ")" : "") + ". Check your emails (spam too), then log in."); setSignupInfo("✅ " + t('auth.accountCreatedMsg') + (who ? " (" + who + ")" : ""));
setMode("login"); setMode("login");
setShowAuth(true); setShowAuth(true);
@ -357,7 +419,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
setSignupInfo(""); setSignupInfo("");
if (!firstName.trim() || !lastName.trim() || !regUser.trim() || !regEmail.trim() || !regPass) { if (!firstName.trim() || !lastName.trim() || !regUser.trim() || !regEmail.trim() || !regPass) {
setSignupError("First name, last name, username, email and password are required."); setSignupError(t('auth.fieldsRequired'));
return; return;
} }
@ -380,14 +442,14 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
localStorage.setItem(LAST_SIGNUP_KEY, JSON.stringify({ username: u, email: em })); localStorage.setItem(LAST_SIGNUP_KEY, JSON.stringify({ username: u, email: em }));
} catch {} } catch {}
setSignupInfo("✅ Account created (" + em + "). Check your emails (spam too), then log in."); 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); console.error(err);
setSignupError(err.message || "Registration error."); setSignupError(err.message || t('auth.registrationError'));
} finally { } finally {
setSignupLoading(false); setSignupLoading(false);
} }
@ -395,15 +457,15 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
const toast = useMemo(() => { const toast = useMemo(() => {
if (lastError) { if (lastError) {
const t = needsEmailVerification ? "warn" : "error"; const toastType = needsEmailVerification ? "warn" : "error";
const title = needsEmailVerification ? "Email verification required" : "Login failed"; const title = needsEmailVerification ? t('auth.emailVerificationRequired') : t('auth.loginFailed');
return { type: t, title, message: lastError }; return { type: toastType, title, message: lastError };
} }
if (lastInfo) { if (lastInfo) {
return { type: "success", title: "", message: lastInfo }; return { type: "success", title: "", message: lastInfo };
} }
return null; return null;
}, [lastError, lastInfo, needsEmailVerification]); }, [lastError, lastInfo, needsEmailVerification, t]);
return ( return (
<> <>
@ -428,22 +490,22 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
<div className="logo-circle"><img src="/logo2026.png" alt="SocioWire" /></div> <div className="logo-circle"><img src="/logo2026.png" alt="SocioWire" /></div>
<div className="title-block"> <div className="title-block">
<div className="main-title">SOCIOWIRE.com</div> <div className="main-title">SOCIOWIRE.com</div>
<div className="sub-title">Wired to life</div> <div className="sub-title">{t('topbar.wiredToLife')}</div>
</div> </div>
</div> </div>
{/* Theme dots */} {/* Theme dots */}
<div className="brand-under"> <div className="brand-under">
<div className="theme-switch"> <div className="theme-switch">
{["dark", "blue", "light"].map((t) => ( {["dark", "blue", "light"].map((themeKey) => (
<button <button
key={t} key={themeKey}
type="button" type="button"
className={"theme-dot" + (theme === t ? " theme-dot-active" : "")} className={"theme-dot" + (theme === themeKey ? " theme-dot-active" : "")}
title={THEME_LABELS[t]} title={t(`theme.${themeKey}`)}
onClick={() => onChangeTheme && onChangeTheme(t)} onClick={() => onChangeTheme && onChangeTheme(themeKey)}
> >
{THEME_LABELS[t][0]} {t(`theme.${themeKey}`)[0]}
</button> </button>
))} ))}
</div> </div>
@ -464,18 +526,18 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
{avatarUrl ? <img src={avatarUrl} alt={username} /> : <span>{initialLetter(username)}</span>} {avatarUrl ? <img src={avatarUrl} alt={username} /> : <span>{initialLetter(username)}</span>}
</div> </div>
<div className="sw-usertext"> <div className="sw-usertext">
<div className="sw-userline">Online</div> <div className="sw-userline">{t('common.online')}</div>
<div className="sw-userhandle">@{displayUsername}</div> <div className="sw-userhandle">@{displayUsername}</div>
</div> </div>
</button> </button>
) : ( ) : (
<div className="sw-userchip" title="Guest"> <div className="sw-userchip" title={t('common.guest')}>
<div className="sw-avatar"> <div className="sw-avatar">
<i className="fa-solid fa-user" /> <i className="fa-solid fa-user" />
</div> </div>
<div className="sw-usertext"> <div className="sw-usertext">
<div className="sw-userline">{loading ? "Loading…" : "Guest"}</div> <div className="sw-userline">{loading ? t('common.loading') : t('common.guest')}</div>
<div className="sw-userhandle">anon</div> <div className="sw-userhandle">{t('common.anon')}</div>
</div> </div>
</div> </div>
)} )}
@ -490,19 +552,83 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
className="btn-action btn-action-install" className="btn-action btn-action-install"
type="button" type="button"
onClick={() => promptInstall()} onClick={() => promptInstall()}
title="Install App" title={t('topbar.installApp')}
> >
<i className="fa-solid fa-download"></i> <i className="fa-solid fa-download"></i>
</button> </button>
)} )}
{/* Theme selector (mobile dropdown) */}
<div className="theme-selector-wrap">
<button
ref={themeBtnRef}
className={"btn-action btn-action-theme" + (showThemeMenu ? " is-active" : "")}
type="button"
onClick={toggleThemeMenu}
title={t(`theme.${theme}`)}
>
<i className="fa-solid fa-palette"></i>
</button>
{showThemeMenu && (
<div
className="theme-dropdown"
style={{ top: themeMenuPos.top, right: themeMenuPos.right }}
>
{THEMES.map((themeKey) => (
<button
key={themeKey}
type="button"
className={"theme-option" + (theme === themeKey ? " is-active" : "")}
onClick={() => {
onChangeTheme && onChangeTheme(themeKey);
setShowThemeMenu(false);
}}
>
{t(`theme.${themeKey}`)}
</button>
))}
</div>
)}
</div>
{/* Language selector */}
<div className="lang-selector-wrap">
<button
ref={langBtnRef}
className={"btn-action btn-action-lang" + (showLangMenu ? " is-active" : "")}
type="button"
onClick={toggleLangMenu}
title={t('language.selectLanguage')}
>
<i className="fa-solid fa-globe"></i>
<span className="btn-label">{i18n.language?.toUpperCase().slice(0, 2)}</span>
</button>
{showLangMenu && (
<div
className="lang-dropdown"
style={{ top: langMenuPos.top, right: langMenuPos.right }}
>
{SUPPORTED_LANGS.map((lang) => (
<button
key={lang}
type="button"
className={"lang-option" + (i18n.language?.startsWith(lang) ? " is-active" : "")}
onClick={() => changeLanguage(lang)}
>
{t(`language.${lang}`)}
</button>
))}
</div>
)}
</div>
{/* Friends button - only when authenticated */} {/* Friends button - only when authenticated */}
{authenticated && ( {authenticated && (
<button <button
className={"btn-action btn-action-friends" + (showFriends ? " is-active" : "")} className={"btn-action btn-action-friends" + (showFriends ? " is-active" : "")}
type="button" type="button"
onClick={() => setShowFriends(!showFriends)} onClick={() => setShowFriends(!showFriends)}
title="Friends" title={t('topbar.friends')}
> >
<i className="fa-solid fa-user-group"></i> <i className="fa-solid fa-user-group"></i>
{pendingRequestsCount > 0 && ( {pendingRequestsCount > 0 && (
@ -524,10 +650,10 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
} }
}} }}
disabled={loading} disabled={loading}
title={authenticated ? "Logout" : "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> <i className={authenticated ? "fa-solid fa-right-from-bracket" : "fa-solid fa-right-to-bracket"}></i>
<span className="btn-label">{authenticated ? "Logout" : "Login"}</span> <span className="btn-label">{authenticated ? t('topbar.logout') : t('topbar.login')}</span>
</button> </button>
</div> </div>
</div> </div>
@ -542,14 +668,14 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
className={"auth-tab" + (mode === "login" ? " auth-tab-active" : "")} className={"auth-tab" + (mode === "login" ? " auth-tab-active" : "")}
onClick={() => setMode("login")} onClick={() => setMode("login")}
> >
Login {t('auth.login')}
</button> </button>
<button <button
type="button" type="button"
className={"auth-tab" + (mode === "signup" ? " auth-tab-active" : "")} className={"auth-tab" + (mode === "signup" ? " auth-tab-active" : "")}
onClick={() => setMode("signup")} onClick={() => setMode("signup")}
> >
Sign up {t('auth.signup')}
</button> </button>
<button type="button" className="auth-close" onClick={closeModal}> <button type="button" className="auth-close" onClick={closeModal}>
@ -567,7 +693,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
{mode === "login" ? ( {mode === "login" ? (
<form className="auth-form" onSubmit={handleLoginSubmit}> <form className="auth-form" onSubmit={handleLoginSubmit}>
<label> <label>
Username or email {t('auth.usernameOrEmail')}
<input <input
type="text" type="text"
value={loginUser} value={loginUser}
@ -576,7 +702,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
/> />
</label> </label>
<label> <label>
Password {t('auth.password')}
<input <input
type="password" type="password"
value={loginPass} value={loginPass}
@ -589,21 +715,21 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
className="auth-link" className="auth-link"
onClick={() => window.open(resetPasswordUrl, "_blank", "noopener,noreferrer")} onClick={() => window.open(resetPasswordUrl, "_blank", "noopener,noreferrer")}
> >
Forgot password? {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 ? "..." : "Sign in"} {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 }}>
Your account requires email verification. Confirm your email, then try logging in again. {t('auth.verifyEmail')}
</div> </div>
) : null} ) : null}
{ssoEnabled ? ( {ssoEnabled ? (
<> <>
<div className="auth-divider">or continue with</div> <div className="auth-divider">{t('auth.orContinueWith')}</div>
<div className="auth-oauth"> <div className="auth-oauth">
<button <button
type="button" type="button"
@ -615,7 +741,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
}} }}
> >
<i className="fa-brands fa-google"></i> <i className="fa-brands fa-google"></i>
<span>Continue with Google</span> <span>{t('auth.continueWithGoogle')}</span>
</button> </button>
<button <button
type="button" type="button"
@ -627,7 +753,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
}} }}
> >
<i className="fa-brands fa-facebook-f"></i> <i className="fa-brands fa-facebook-f"></i>
<span>Continue with Facebook</span> <span>{t('auth.continueWithFacebook')}</span>
</button> </button>
</div> </div>
</> </>
@ -638,31 +764,31 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
<form className="auth-form" onSubmit={handleSignupSubmit}> <form className="auth-form" onSubmit={handleSignupSubmit}>
<div className="auth-row"> <div className="auth-row">
<label> <label>
First name {t('auth.firstName')}
<input type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} /> <input type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</label> </label>
<label> <label>
Last name {t('auth.lastName')}
<input type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} /> <input type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} />
</label> </label>
</div> </div>
<label> <label>
Username {t('auth.username')}
<input type="text" value={regUser} onChange={(e) => setRegUser(e.target.value)} /> <input type="text" value={regUser} onChange={(e) => setRegUser(e.target.value)} />
</label> </label>
<label> <label>
Email {t('auth.email')}
<input type="email" value={regEmail} onChange={(e) => setRegEmail(e.target.value)} /> <input type="email" value={regEmail} onChange={(e) => setRegEmail(e.target.value)} />
</label> </label>
<label> <label>
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> : null} {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 ? "..." : "Create account"} {signupLoading ? "..." : t('auth.createAccount')}
</button> </button>
</form> </form>
)} )}
@ -674,16 +800,16 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
<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">Choose username</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">
Pick a public username to complete your profile. {t('auth.pickUsername')}
</div> </div>
{usernameError ? <div className="auth-notice auth-notice-error">{usernameError}</div> : null} {usernameError ? <div className="auth-notice auth-notice-error">{usernameError}</div> : null}
{usernameInfo ? <div className="auth-notice auth-notice-success">{usernameInfo}</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>
Username {t('auth.username')}
<input <input
type="text" type="text"
value={desiredUsername} value={desiredUsername}
@ -692,7 +818,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
/> />
</label> </label>
<button type="submit" className="btn-primary btn-full" disabled={usernameSaving}> <button type="submit" className="btn-primary btn-full" disabled={usernameSaving}>
{usernameSaving ? "Saving..." : "Save username"} {usernameSaving ? t('auth.savingUsername') : t('auth.saveUsername')}
</button> </button>
</form> </form>
</div> </div>

View File

@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next";
import "../../styles/mapControls.css"; import "../../styles/mapControls.css";
function getCurrentTheme() { function getCurrentTheme() {
@ -16,6 +17,7 @@ function getCurrentTheme() {
} }
export default function MapControls({ onMySpot, onPostWire }) { export default function MapControls({ onMySpot, onPostWire }) {
const { t } = useTranslation();
const theme = getCurrentTheme(); const theme = getCurrentTheme();
return ( return (
@ -25,7 +27,7 @@ export default function MapControls({ onMySpot, onPostWire }) {
className="map-controls__button map-controls__button--myspot" className="map-controls__button map-controls__button--myspot"
onClick={onMySpot} onClick={onMySpot}
> >
My spot {t('controls.mySpot')}
</button> </button>
<button <button
@ -33,7 +35,7 @@ export default function MapControls({ onMySpot, onPostWire }) {
className="map-controls__button map-controls__button--wire" className="map-controls__button map-controls__button--wire"
onClick={onPostWire} onClick={onPostWire}
> >
Put a wire {t('controls.putWire')}
</button> </button>
</div> </div>
); );

View File

@ -25,6 +25,7 @@ import {
updatePostVisibility, updatePostVisibility,
} from "../../api/client"; } from "../../api/client";
import { trackEvent } from "../../utils/analytics"; import { trackEvent } from "../../utils/analytics";
import { formatRelativeTime } from "../../utils/timezone";
const avatarCache = new Map(); const avatarCache = new Map();
const avatarInflight = new Map(); const avatarInflight = new Map();
@ -62,7 +63,12 @@ function refreshMarkerFromPost(map, postId, fresh) {
const list = ref?.current || []; const list = ref?.current || [];
for (const item of list) { for (const item of list) {
if (!item || item.id !== postId) continue; if (!item || item.id !== postId) continue;
// Preserve fetched truth_score if fresh data doesn't have it
const oldTruth = item.post?.truth_score;
const nextPost = { ...(item.post || {}), ...(fresh || {}) }; const nextPost = { ...(item.post || {}), ...(fresh || {}) };
if (Number.isFinite(oldTruth) && !Number.isFinite(Number(fresh?.truth_score))) {
nextPost.truth_score = oldTruth;
}
item.post = nextPost; item.post = nextPost;
if (!item.el) continue; if (!item.el) continue;
item.el.__post = nextPost; item.el.__post = nextPost;
@ -319,7 +325,9 @@ function renderComments(container, items) {
.map((c) => { .map((c) => {
const who = (c?.username || "anon").toString().trim(); const who = (c?.username || "anon").toString().trim();
const body = (c?.body || "").toString().trim(); const body = (c?.body || "").toString().trim();
const ts = (c?.created_at || "").toString().trim(); const rawTs = (c?.created_at || "").toString().trim();
// Format timestamp in user's timezone
const ts = rawTs ? formatRelativeTime(rawTs) : "";
return ` return `
<div class="sw-chat-bubble"> <div class="sw-chat-bubble">
<div class="sw-chat-head"> <div class="sw-chat-head">
@ -714,25 +722,6 @@ function collectGalleryImages(post) {
return result; return result;
} }
function formatRelativeTime(createdAt) {
try {
if (!createdAt) return "now";
const d = createdAt instanceof Date ? createdAt : new Date(String(createdAt));
if (Number.isNaN(d.getTime())) return "now";
const s = Math.floor((Date.now() - d.getTime()) / 1000);
if (s < 60) return "now";
const m = Math.floor(s / 60);
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 48) return `${h}h`;
const days = Math.floor(h / 24);
return `${days}d`;
} catch {
return "now";
}
}
function getPostTime(post) { function getPostTime(post) {
const published = const published =
post?.published_at || post?.published_at ||
@ -756,7 +745,124 @@ function getPostTime(post) {
); );
} }
// Known trusted sources get higher base scores
const SOURCE_REPUTATION = {
// Major news agencies
'reuters': 85, 'ap': 85, 'afp': 82,
// Government/official
'gov': 80, 'gouv': 80, 'canada.ca': 82, 'quebec.ca': 80,
// Major outlets
'bbc': 78, 'cbc': 78, 'radio-canada': 78, 'nytimes': 76, 'theguardian': 75,
'lapresse': 74, 'ledevoir': 74, 'journaldemontreal': 70,
// Tech
'techcrunch': 72, 'theverge': 70, 'arstechnica': 74,
// Social/user content (lower trust)
'twitter': 45, 'x.com': 45, 'facebook': 48, 'reddit': 50, 'tiktok': 42,
};
// Clickbait/sensational words reduce score
const CLICKBAIT_WORDS = [
'shocking', 'unbelievable', 'you won\'t believe', 'mind-blowing', 'insane',
'choquant', 'incroyable', 'hallucinant', 'fou', 'dingue',
'breaking', 'urgent', 'exclusive', 'leaked', 'secret',
'!!!', '???', 'exposed', 'révélé', 'scandale'
];
// Credibility indicators increase score
const CREDIBILITY_WORDS = [
'study', 'research', 'according to', 'report', 'official',
'étude', 'recherche', 'selon', 'rapport', 'officiel',
'confirmed', 'confirmé', 'announced', 'annoncé', 'statement'
];
function calculateInitialTruthScore(post) {
let score = 55; // Base score
let factors = 0;
// 1. Source reputation (weight: 40%)
const source = (post?.source || post?.Source || '').toLowerCase();
const domain = (post?.domain || post?.url || '').toLowerCase();
for (const [key, rep] of Object.entries(SOURCE_REPUTATION)) {
if (source.includes(key) || domain.includes(key)) {
score = rep;
factors++;
break;
}
}
// 2. Author info (weight: 15%)
const author = post?.author || post?.Author || '';
if (author) {
score += 3; // Has attributed author
factors++;
// Verified authors get bonus
if (post?.author_verified || post?.verified) {
score += 5;
}
}
// 3. Content analysis (weight: 25%)
const title = (post?.title || post?.headline || '').toLowerCase();
const content = (post?.content || post?.description || post?.summary || '').toLowerCase();
const text = title + ' ' + content;
// Check for clickbait
let clickbaitCount = 0;
for (const word of CLICKBAIT_WORDS) {
if (text.includes(word.toLowerCase())) {
clickbaitCount++;
}
}
if (clickbaitCount > 0) {
score -= Math.min(clickbaitCount * 4, 15); // Max -15 for clickbait
factors++;
}
// Check for credibility indicators
let credibilityCount = 0;
for (const word of CREDIBILITY_WORDS) {
if (text.includes(word.toLowerCase())) {
credibilityCount++;
}
}
if (credibilityCount > 0) {
score += Math.min(credibilityCount * 3, 10); // Max +10 for credibility
factors++;
}
// 4. Content quality indicators (weight: 20%)
// Longer content tends to be more detailed/trustworthy
const contentLength = (content || '').length;
if (contentLength > 500) score += 3;
else if (contentLength > 200) score += 1;
else if (contentLength < 50) score -= 3;
// Has image/media (more complete reporting)
if (post?.image || post?.media_url || post?.image_url) {
score += 2;
}
// Has URL to original source
if (post?.url || post?.link) {
score += 2;
}
// 5. Engagement signals (if available)
const likes = Number(post?.like_count || post?.likes || 0);
const shares = Number(post?.share_count || post?.shares || 0);
const views = Number(post?.view_count || post?.views || 0);
// High engagement can be positive or negative - slight positive bias for established content
if (views > 1000 && likes > 50) {
score += 2;
}
// Clamp to valid range
return clamp(Math.round(score), 25, 85); // Never go below 25 or above 85 without AI verification
}
function truthScore(post) { function truthScore(post) {
// First check if we have an AI-verified score
const raw = Number( const raw = Number(
post?.truth_score ?? post?.truth_score ??
post?.truthScore ?? post?.truthScore ??
@ -768,7 +874,8 @@ function truthScore(post) {
if (Number.isFinite(raw)) { if (Number.isFinite(raw)) {
return clamp(Math.round(raw), 0, 100); return clamp(Math.round(raw), 0, 100);
} }
return 65; // Calculate initial score from available data
return calculateInitialTruthScore(post);
} }
function truthColor(score) { function truthColor(score) {

View File

@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { loadAuth } from "../../auth/authStorage"; import { loadAuth } from "../../auth/authStorage";
import { import {
fetchPostLikeState, fetchPostLikeState,
@ -32,6 +33,8 @@ function requestAuth(message) {
trackEvent("auth_required", { context: "wall", mode: "login" }); trackEvent("auth_required", { context: "wall", mode: "login" });
} }
// Time formatting is now handled in the component with translations
function formatRelativeTime(createdAt) { function formatRelativeTime(createdAt) {
try { try {
if (!createdAt) return "now"; if (!createdAt) return "now";
@ -132,6 +135,7 @@ async function getAvatarForUser(name) {
} }
export default function PostCard({ post, selected, onSelect, onPostDeleted, onPostUpdated }) { export default function PostCard({ post, selected, onSelect, onPostDeleted, onPostUpdated }) {
const { t } = useTranslation();
const [liked, setLiked] = useState(false); const [liked, setLiked] = useState(false);
const [authed, setAuthed] = useState(isAuthed()); const [authed, setAuthed] = useState(isAuthed());
const [activeImageIndex, setActiveImageIndex] = useState(0); const [activeImageIndex, setActiveImageIndex] = useState(0);
@ -411,7 +415,7 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo
onClick={async (e) => { onClick={async (e) => {
e.stopPropagation(); e.stopPropagation();
if (!authed) { if (!authed) {
requestAuth("Please sign in or create a free account to like this post."); requestAuth(t('post.signInToLike'));
return; return;
} }
trackEvent("post_like", { post_id: postId, post_title: title?.slice(0, 100), post_category: cat }); trackEvent("post_like", { post_id: postId, post_title: title?.slice(0, 100), post_category: cat });
@ -428,7 +432,7 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo
} }
}} }}
> >
<i className="fa-regular fa-heart" /> Like <i className="fa-regular fa-heart" /> {t('post.like')}
</button> </button>
<button <button
type="button" type="button"
@ -436,14 +440,14 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (!authed) { if (!authed) {
requestAuth("Please sign in or create a free account to comment."); requestAuth(t('post.signInToComment'));
return; return;
} }
trackEvent("post_comment", { post_id: postId, post_title: title?.slice(0, 100), post_category: cat }); trackEvent("post_comment", { post_id: postId, post_title: title?.slice(0, 100), post_category: cat });
if (onSelect) onSelect(post); if (onSelect) onSelect(post);
}} }}
> >
<i className="fa-regular fa-comment" /> Comment <i className="fa-regular fa-comment" /> {t('post.comment')}
</button> </button>
<button <button
type="button" type="button"
@ -469,7 +473,7 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo
} }
}} }}
> >
<i className="fa-solid fa-share-nodes" /> Share <i className="fa-solid fa-share-nodes" /> {t('post.share')}
</button> </button>
{isAuthor ? ( {isAuthor ? (
@ -483,7 +487,7 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo
setShowEditDelete(false); setShowEditDelete(false);
}} }}
> >
<i className="fa-solid fa-lock" /> Visibility <i className="fa-solid fa-lock" /> {t('post.visibility')}
</button> </button>
<button <button
type="button" type="button"
@ -507,7 +511,7 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo
{isAuthor && showPrivacy ? ( {isAuthor && showPrivacy ? (
<div className="sw-wall-privacy" onClick={(e) => e.stopPropagation()}> <div className="sw-wall-privacy" onClick={(e) => e.stopPropagation()}>
<div className="sw-wall-privacy-row"> <div className="sw-wall-privacy-row">
<label>Post</label> <label>{t('common.post')}</label>
<select <select
value={postVisibility} value={postVisibility}
onChange={(e) => { onChange={(e) => {
@ -517,17 +521,17 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo
if (next !== "custom") setPostUsers(""); if (next !== "custom") setPostUsers("");
}} }}
> >
<option value="public">Public</option> <option value="public">{t('post.public')}</option>
<option value="friends">Friends Only</option> <option value="friends">{t('post.friendsOnly')}</option>
<option value="private">Private</option> <option value="private">{t('post.private')}</option>
<option value="group">Group</option> <option value="group">{t('post.group')}</option>
<option value="custom">Custom</option> <option value="custom">{t('post.custom')}</option>
</select> </select>
</div> </div>
{postVisibility === "group" ? ( {postVisibility === "group" ? (
<input <input
className="sw-wall-privacy-input" className="sw-wall-privacy-input"
placeholder="Group ID" placeholder={t('post.groupId')}
value={postGroupId} value={postGroupId}
onChange={(e) => setPostGroupId(e.target.value)} onChange={(e) => setPostGroupId(e.target.value)}
/> />
@ -535,7 +539,7 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo
{postVisibility === "custom" ? ( {postVisibility === "custom" ? (
<input <input
className="sw-wall-privacy-input" className="sw-wall-privacy-input"
placeholder="Usernames (comma separated)" placeholder={t('post.usernamesComma')}
value={postUsers} value={postUsers}
onChange={(e) => setPostUsers(e.target.value)} onChange={(e) => setPostUsers(e.target.value)}
/> />
@ -546,7 +550,7 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo
checked={applyToImages} checked={applyToImages}
onChange={(e) => setApplyToImages(e.target.checked)} onChange={(e) => setApplyToImages(e.target.checked)}
/> />
Apply same visibility to images {t('post.applyToImages')}
</label> </label>
{privacyError ? <div className="sw-wall-privacy-error">{privacyError}</div> : null} {privacyError ? <div className="sw-wall-privacy-error">{privacyError}</div> : null}
<button <button
@ -573,12 +577,12 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo
await updatePostVisibility(postId, payload, token); await updatePostVisibility(postId, payload, token);
setPrivacySaving(false); setPrivacySaving(false);
} catch (err) { } catch (err) {
setPrivacyError("Failed to update visibility."); setPrivacyError(t('post.visibilityError'));
setPrivacySaving(false); setPrivacySaving(false);
} }
}} }}
> >
{privacySaving ? "Saving..." : "Save"} {privacySaving ? t('post.saving') : t('common.save')}
</button> </button>
</div> </div>
) : null} ) : null}
@ -592,14 +596,14 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo
className="sw-wall-btn" className="sw-wall-btn"
onClick={() => setIsEditing(true)} onClick={() => setIsEditing(true)}
> >
<i className="fa-solid fa-pen" /> Edit <i className="fa-solid fa-pen" /> {t('common.edit')}
</button> </button>
<button <button
type="button" type="button"
className="sw-wall-btn sw-wall-btn-danger" className="sw-wall-btn sw-wall-btn-danger"
onClick={() => setShowDeleteConfirm(true)} onClick={() => setShowDeleteConfirm(true)}
> >
<i className="fa-solid fa-trash" /> Delete <i className="fa-solid fa-trash" /> {t('common.delete')}
</button> </button>
</> </>
) : null} ) : null}
@ -609,13 +613,13 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo
<input <input
type="text" type="text"
className="sw-wall-edit-input" className="sw-wall-edit-input"
placeholder="Title" placeholder={t('post.title')}
value={editTitle} value={editTitle}
onChange={(e) => setEditTitle(e.target.value)} onChange={(e) => setEditTitle(e.target.value)}
/> />
<textarea <textarea
className="sw-wall-edit-textarea" className="sw-wall-edit-textarea"
placeholder="Description" placeholder={t('post.description')}
value={editSnippet} value={editSnippet}
onChange={(e) => setEditSnippet(e.target.value)} onChange={(e) => setEditSnippet(e.target.value)}
rows={3} rows={3}
@ -639,14 +643,14 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo
} }
}} }}
> >
{editSaving ? "Saving..." : "Save"} {editSaving ? t('post.saving') : t('common.save')}
</button> </button>
<button <button
type="button" type="button"
className="sw-wall-btn" className="sw-wall-btn"
onClick={() => setIsEditing(false)} onClick={() => setIsEditing(false)}
> >
Cancel {t('common.cancel')}
</button> </button>
</div> </div>
</div> </div>
@ -654,7 +658,7 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo
{showDeleteConfirm ? ( {showDeleteConfirm ? (
<div className="sw-wall-delete-confirm"> <div className="sw-wall-delete-confirm">
<p>Delete this post?</p> <p>{t('post.deletePost')}</p>
<div className="sw-wall-edit-actions"> <div className="sw-wall-edit-actions">
<button <button
type="button" type="button"
@ -671,14 +675,14 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo
} }
}} }}
> >
{deleting ? "Deleting..." : "Delete"} {deleting ? t('post.deleting') : t('common.delete')}
</button> </button>
<button <button
type="button" type="button"
className="sw-wall-btn" className="sw-wall-btn"
onClick={() => setShowDeleteConfirm(false)} onClick={() => setShowDeleteConfirm(false)}
> >
Cancel {t('common.cancel')}
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import PostCard from "./PostCard"; import PostCard from "./PostCard";
function getKmFromPost(post) { function getKmFromPost(post) {
@ -6,6 +7,7 @@ function getKmFromPost(post) {
} }
export default function PostList({ posts, loading, error, selectedPost, onSelectPost, onPostDeleted, onPostUpdated }) { export default function PostList({ posts, loading, error, selectedPost, onSelectPost, onPostDeleted, onPostUpdated }) {
const { t } = useTranslation();
const [kmFilter, setKmFilter] = useState(1000); const [kmFilter, setKmFilter] = useState(1000);
const [visibleCount, setVisibleCount] = useState(6); const [visibleCount, setVisibleCount] = useState(6);
const listRef = useRef(null); const listRef = useRef(null);
@ -46,7 +48,7 @@ export default function PostList({ posts, loading, error, selectedPost, onSelect
<div className="post-list-header"> <div className="post-list-header">
<div className="km-filter"> <div className="km-filter">
<label> <label>
Rayon : <span>{kmFilter} km</span> {t('postlist.radius')} <span>{kmFilter} {t('postlist.km')}</span>
</label> </label>
<input <input
type="range" type="range"
@ -71,7 +73,7 @@ export default function PostList({ posts, loading, error, selectedPost, onSelect
/> />
))} ))}
{!loading && filtered.length === 0 && <p className="status-text">Aucun post dans ce rayon.</p>} {!loading && filtered.length === 0 && <p className="status-text">{t('postlist.noPostsInRadius')}</p>}
{filtered.length > visibleCount ? <div ref={sentinelRef} className="sw-wall-sentinel" /> : null} {filtered.length > visibleCount ? <div ref={sentinelRef} className="sw-wall-sentinel" /> : null}
</div> </div>
); );

View File

@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { recoSearch } from "../../services/recoSearch"; import { recoSearch } from "../../services/recoSearch";
import "../../styles/smartSearchBar.css"; import "../../styles/smartSearchBar.css";
import { trackEvent } from "../../utils/analytics"; import { trackEvent } from "../../utils/analytics";
@ -62,12 +63,12 @@ function truncateText(value, maxLen) {
* - onPlaceTourWire() // keep same function (place/tour/wire) * - onPlaceTourWire() // keep same function (place/tour/wire)
*/ */
export default function SmartSearchBar({ export default function SmartSearchBar({
placeholder = "Search places, regions, posts…",
onPick, onPick,
onSearch, onSearch,
onMySpot, onMySpot,
onPlaceTourWire, onPlaceTourWire,
}) { }) {
const { t } = useTranslation();
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -276,17 +277,17 @@ export default function SmartSearchBar({
setTimeout(() => setFocused(false), 120); setTimeout(() => setFocused(false), 120);
}} }}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
placeholder={placeholder} placeholder={t('map.searchPlaceholder')}
aria-label="Search" aria-label={t('common.search')}
/> />
{q && ( {q && (
<button <button
type="button" type="button"
className="sw-search__clearBtn" className="sw-search__clearBtn"
title="Clear search" title={t('map.clearSearch')}
onClick={handleClear} onClick={handleClear}
aria-label="Clear search" aria-label={t('map.clearSearch')}
> >
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2"> <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" /> <path d="M18 6L6 18M6 6l12 12" />
@ -298,7 +299,7 @@ export default function SmartSearchBar({
<button <button
type="button" type="button"
className="sw-search__iconBtn" className="sw-search__iconBtn"
title="My spot" title={t('map.mySpot')}
onClick={() => { onClick={() => {
trackEvent("search_my_spot"); trackEvent("search_my_spot");
onMySpot && onMySpot(); onMySpot && onMySpot();
@ -311,7 +312,7 @@ export default function SmartSearchBar({
{showList && ( {showList && (
<div className="sw-search__dropdown"> <div className="sw-search__dropdown">
{loading && <div className="sw-search__row sw-search__muted">Searching</div>} {loading && <div className="sw-search__row sw-search__muted">{t('map.searching')}</div>}
{err && <div className="sw-search__row sw-search__error">{err}</div>} {err && <div className="sw-search__row sw-search__error">{err}</div>}
{!err && items.map((it, idx) => ( {!err && items.map((it, idx) => (
@ -328,7 +329,7 @@ export default function SmartSearchBar({
e.preventDefault(); e.preventDefault();
}} }}
> >
<div className="sw-search__title">{truncateText(it.title || "Untitled", 140)}</div> <div className="sw-search__title">{truncateText(it.title || t('common.untitled'), 140)}</div>
<div className="sw-search__sub"> <div className="sw-search__sub">
{truncateText((it.kind ? it.kind : "result") + (it.subtitle ? " • " + it.subtitle : ""), 120)} {truncateText((it.kind ? it.kind : "result") + (it.subtitle ? " • " + it.subtitle : ""), 120)}
</div> </div>
@ -343,7 +344,7 @@ export default function SmartSearchBar({
))} ))}
{!loading && !err && items.length === 0 && ( {!loading && !err && items.length === 0 && (
<div className="sw-search__row sw-search__muted">No results</div> <div className="sw-search__row sw-search__muted">{t('map.noResults')}</div>
)} )}
</div> </div>
)} )}

30
src/i18n/index.js Normal file
View File

@ -0,0 +1,30 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import en from '../locales/en/translation.json';
import fr from '../locales/fr/translation.json';
import es from '../locales/es/translation.json';
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { translation: en },
fr: { translation: fr },
es: { translation: es },
},
fallbackLng: 'en',
supportedLngs: ['en', 'fr', 'es'],
interpolation: {
escapeValue: false,
},
detection: {
order: ['localStorage', 'navigator', 'htmlTag'],
caches: ['localStorage'],
lookupLocalStorage: 'sociowire:lang',
},
});
export default i18n;

View File

@ -0,0 +1,276 @@
{
"common": {
"loading": "Loading...",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"close": "Close",
"back": "Back",
"next": "Next",
"post": "Post",
"add": "Add",
"search": "Search",
"clear": "Clear",
"online": "Online",
"guest": "Guest",
"anon": "anon",
"contacts": "Contacts",
"untitled": "Untitled"
},
"time": {
"now": "now",
"minutes": "{{count}}m",
"hours": "{{count}}h",
"days": "{{count}}d"
},
"nav": {
"home": "Home",
"liveMap": "Live Map",
"sociowall": "SocioWall",
"islands": "Islands",
"about": "About",
"contact": "Contact",
"privacy": "Privacy"
},
"topbar": {
"wiredToLife": "Wired to life",
"installApp": "Install App",
"friends": "Friends",
"login": "Login",
"logout": "Logout"
},
"theme": {
"dark": "Dark",
"blue": "Blue",
"light": "Light"
},
"auth": {
"login": "Login",
"signup": "Sign up",
"signIn": "Sign in",
"createAccount": "Create account",
"usernameOrEmail": "Username or email",
"password": "Password",
"forgotPassword": "Forgot password?",
"orContinueWith": "or continue with",
"continueWithGoogle": "Continue with Google",
"continueWithFacebook": "Continue with Facebook",
"firstName": "First name",
"lastName": "Last name",
"username": "Username",
"email": "Email",
"emailVerificationRequired": "Email verification required",
"loginFailed": "Login failed",
"usernameHint": "Use 3-24 letters, numbers, _ or .",
"usernameTaken": "Username already taken.",
"usernameUpdateError": "Could not update username.",
"usernameSaved": "Username saved.",
"chooseUsername": "Choose username",
"pickUsername": "Pick a public username to complete your profile.",
"saveUsername": "Save username",
"savingUsername": "Saving...",
"emailConfirmed": "Email confirmed",
"emailConfirmedMsg": "Email confirmed. You can log in.",
"accountCreated": "Account created",
"accountCreatedMsg": "Account created. Check your emails (spam too), then log in.",
"verifyEmail": "Your account requires email verification. Confirm your email, then try logging in again.",
"fieldsRequired": "First name, last name, username, email and password are required.",
"registrationError": "Registration error.",
"pleaseSignIn": "Please sign in or create a free account to continue."
},
"filters": {
"all": "All",
"news": "News",
"friends": "Friends",
"events": "Events",
"market": "Market",
"world": "World",
"politics": "Politics",
"tech": "Tech",
"finance": "Finance",
"local": "Local",
"nearby": "Nearby",
"chats": "Chats",
"groups": "Groups",
"stories": "Stories",
"favs": "Favs",
"today": "Today",
"weekend": "Weekend",
"music": "Music",
"sports": "Sports",
"actu": "Actu",
"finan": "Finan",
"sport": "Sport",
"deals": "Deals"
},
"time_filter": {
"now": "Now",
"today": "Today",
"recents": "Recents",
"old": "Older",
"1h": "1h",
"6h": "6h",
"24h": "24h",
"7d": "7d",
"14d": "14d"
},
"map": {
"loadingMap": "Loading map",
"warmingUp": "Warming up tiles and live data.",
"searchPlaceholder": "Search cities, places, posts...",
"mySpot": "My spot",
"clearSearch": "Clear search",
"searching": "Searching...",
"noResults": "No results",
"mainNews": "Main news",
"placeTourWire": "Place / Tour / Wire",
"profileIsland": "Profile Island"
},
"create": {
"liveVideo": "Live video",
"linkStory": "Link story",
"photoStory": "Photo story",
"writtenUpdate": "Written update",
"other": "Other",
"liveHint": "Broadcast a live scene with a headline or link.",
"liveDetail1": "Drop the stream URL or quick headline to pin the feed instantly.",
"liveDetail2": "We'll open the location where you place the crosshair so viewers know where to tune.",
"linkHint": "Share a URL and we'll prefill the preview.",
"linkDetail1": "Paste a news link, playlist, or update so we can fetch an image and snippet.",
"linkDetail2": "Choose the most relevant pin so the story belongs to the right neighborhood.",
"photoHint": "Upload or link an image with context.",
"photoDetail1": "Upload a photo set or paste an image URL from your gallery.",
"photoDetail2": "Caption quickly so the community knows what they're looking at.",
"textHint": "Type a short report or microblog.",
"textDetail1": "Write the key message in the title field (or add more context in the body).",
"textDetail2": "Use subcategory filters to mark the tone: News, Event, Market, etc.",
"otherHint": "Custom content that spans media types.",
"otherDetail1": "Mix media, public drafts, or reference notes in one place.",
"otherDetail2": "Tag a region or Island to give the update a home.",
"shareAs": "Share as {{mode}}",
"step": "Step {{current}} / {{total}}",
"liveHero": "Pin where the live scene is happening and paste your stream link or headline.",
"livePlaceholder": "Streaming link or headline",
"liveFlowHint": "We'll treat this as a live broadcast and show it as soon as the pin lands.",
"liveBodyPlaceholder": "Context for viewers, optional.",
"linkHero": "Drop the link, describe what it is, and pin the relevant spot.",
"linkPlaceholder": "Article or playlist URL",
"linkFlowHint": "We'll auto-fetch a preview once the link is detected.",
"linkBodyPlaceholder": "What matters about this link?",
"photoHero": "Snap or upload a photo and pin the place you captured it.",
"photoPlaceholder": "Photo caption or image URL",
"photoFlowHint": "Add a quick caption so the community knows what they're seeing.",
"photoBodyPlaceholder": "Describe the scene in a few words.",
"textHero": "Type a short update and pin the area you're reporting from.",
"textPlaceholder": "Headline or short note",
"textFlowHint": "Readers should understand the gist before opening the full card.",
"textBodyPlaceholder": "Provide more context, opinions, or call to actions.",
"otherHero": "Combine media, notes, or references and pin wherever it matters.",
"otherPlaceholder": "Describe your update",
"otherFlowHint": "This mode is flexible - add whatever helps the reader.",
"otherBodyPlaceholder": "Add links, attachments, or extra context if needed.",
"category": "Category",
"titleOrLink": "Title or link",
"linkDetected": "Link detected - fetching preview...",
"useCategoryIcon": "Use category icon",
"addImage": "Add an image",
"categoryIconFallback": "We'll use the category icon if you skip images.",
"chooseFile": "Choose file",
"takePhoto": "Take photo",
"uploading": "Uploading...",
"pasteImageUrl": "Or paste an image URL:",
"imageUrlPlaceholder": "https://example.com/image.jpg",
"goLive": "Go Live",
"posting": "Posting...",
"titleRequired": "Title required.",
"mustBeLoggedIn": "You must be logged in to post.",
"verifyEmailToPost": "Verify your email to post. Use 'Resend email' in the top bar, then login again.",
"createPostError": "Error creating post.",
"previewUnavailable": "Preview unavailable",
"pasteUrlFirst": "Paste an image URL first.",
"invalidImageUrl": "Image URL must start with http:// or https://",
"selectImageFile": "Please select an image file",
"imageTooLarge": "Image must be less than 5MB",
"imageReadError": "Failed to read image",
"imageUploadError": "Failed to upload image",
"signInToPost": "Please sign in or create a free account to create a post."
},
"post": {
"like": "Like",
"comment": "Comment",
"share": "Share",
"visibility": "Visibility",
"public": "Public",
"friendsOnly": "Friends Only",
"private": "Private",
"group": "Group",
"custom": "Custom",
"groupId": "Group ID",
"usernamesComma": "Usernames (comma separated)",
"applyToImages": "Apply same visibility to images",
"visibilityError": "Failed to update visibility.",
"saving": "Saving...",
"deletePost": "Delete this post?",
"deleting": "Deleting...",
"title": "Title",
"description": "Description",
"signInToLike": "Please sign in or create a free account to like this post.",
"signInToComment": "Please sign in or create a free account to comment.",
"previousImage": "Previous image",
"nextImage": "Next image"
},
"postlist": {
"radius": "Radius:",
"km": "km",
"noPostsInRadius": "No posts in this radius."
},
"islands": {
"communities": "Communities",
"exploreIslands": "Explore Islands",
"islandsDescription": "Islands are local hubs where people share updates, deals, and stories in one place. Browse nearby islands or jump into a new community to see what is trending.",
"openIslands": "Open Islands"
},
"language": {
"en": "English",
"fr": "Français",
"es": "Español",
"selectLanguage": "Language"
},
"friends": {
"title": "Friends",
"signInToSee": "Sign in to see your friends",
"friendsTab": "Friends",
"requestsTab": "Requests",
"addPlaceholder": "Add friend by username...",
"sendRequest": "Send friend request",
"requestSent": "Friend request sent to {{username}}",
"couldNotSend": "Could not send request",
"searching": "Searching...",
"noFriends": "No friends yet",
"noPending": "No pending requests",
"removeFriend": "Remove friend",
"removeConfirm": "Remove {{username}} from friends?",
"accept": "Accept",
"reject": "Reject"
},
"universe": {
"title": "Island Universe",
"islandsCount": "{{count}} islands floating in virtual space",
"clickToVisit": "Click to visit",
"posts": "posts",
"help": "Hover over islands to see details - Click to visit"
},
"controls": {
"mySpot": "My spot",
"putWire": "Put a wire"
},
"chat": {
"signInToChat": "Sign in to chat with friends",
"startConversation": "Start a conversation",
"typeMessage": "Type a message...",
"online": "Online",
"offline": "Offline",
"typing": "typing..."
}
}

View File

@ -0,0 +1,276 @@
{
"common": {
"loading": "Cargando...",
"save": "Guardar",
"cancel": "Cancelar",
"delete": "Eliminar",
"edit": "Editar",
"close": "Cerrar",
"back": "Atrás",
"next": "Siguiente",
"post": "Publicar",
"add": "Añadir",
"search": "Buscar",
"clear": "Borrar",
"online": "En línea",
"guest": "Invitado",
"anon": "anon",
"contacts": "Contactos",
"untitled": "Sin título"
},
"time": {
"now": "ahora",
"minutes": "{{count}}m",
"hours": "{{count}}h",
"days": "{{count}}d"
},
"nav": {
"home": "Inicio",
"liveMap": "Mapa en vivo",
"sociowall": "SocioWall",
"islands": "Islas",
"about": "Acerca de",
"contact": "Contacto",
"privacy": "Privacidad"
},
"topbar": {
"wiredToLife": "Conectado a la vida",
"installApp": "Instalar App",
"friends": "Amigos",
"login": "Iniciar sesión",
"logout": "Cerrar sesión"
},
"theme": {
"dark": "Oscuro",
"blue": "Azul",
"light": "Claro"
},
"auth": {
"login": "Iniciar sesión",
"signup": "Registrarse",
"signIn": "Entrar",
"createAccount": "Crear cuenta",
"usernameOrEmail": "Usuario o email",
"password": "Contraseña",
"forgotPassword": "¿Olvidaste tu contraseña?",
"orContinueWith": "o continuar con",
"continueWithGoogle": "Continuar con Google",
"continueWithFacebook": "Continuar con Facebook",
"firstName": "Nombre",
"lastName": "Apellido",
"username": "Nombre de usuario",
"email": "Email",
"emailVerificationRequired": "Verificación de email requerida",
"loginFailed": "Error de inicio de sesión",
"usernameHint": "3-24 caracteres: letras, números, _ o .",
"usernameTaken": "Este nombre de usuario ya está en uso.",
"usernameUpdateError": "No se pudo actualizar el nombre de usuario.",
"usernameSaved": "Nombre de usuario guardado.",
"chooseUsername": "Elegir nombre de usuario",
"pickUsername": "Elige un nombre de usuario público para completar tu perfil.",
"saveUsername": "Guardar nombre",
"savingUsername": "Guardando...",
"emailConfirmed": "Email confirmado",
"emailConfirmedMsg": "Email confirmado. Puedes iniciar sesión.",
"accountCreated": "Cuenta creada",
"accountCreatedMsg": "Cuenta creada. Revisa tus emails (spam también), luego inicia sesión.",
"verifyEmail": "Tu cuenta requiere verificación de email. Confirma tu email e intenta iniciar sesión de nuevo.",
"fieldsRequired": "Nombre, apellido, usuario, email y contraseña son requeridos.",
"registrationError": "Error de registro.",
"pleaseSignIn": "Inicia sesión o crea una cuenta gratuita para continuar."
},
"filters": {
"all": "Todo",
"news": "Noticias",
"friends": "Amigos",
"events": "Eventos",
"market": "Mercado",
"world": "Mundo",
"politics": "Política",
"tech": "Tech",
"finance": "Finanzas",
"local": "Local",
"nearby": "Cercanos",
"chats": "Chats",
"groups": "Grupos",
"stories": "Stories",
"favs": "Favoritos",
"today": "Hoy",
"weekend": "Fin de semana",
"music": "Música",
"sports": "Deportes",
"actu": "Actu",
"finan": "Finan",
"sport": "Deporte",
"deals": "Ofertas"
},
"time_filter": {
"now": "Ahora",
"today": "Hoy",
"recents": "Recientes",
"old": "Antiguos",
"1h": "1h",
"6h": "6h",
"24h": "24h",
"7d": "7d",
"14d": "14d"
},
"map": {
"loadingMap": "Cargando mapa",
"warmingUp": "Preparando tiles y datos en vivo.",
"searchPlaceholder": "Buscar ciudades, lugares, posts...",
"mySpot": "Mi ubicación",
"clearSearch": "Borrar búsqueda",
"searching": "Buscando...",
"noResults": "Sin resultados",
"mainNews": "Noticias principales",
"placeTourWire": "Colocar / Tour / Wire",
"profileIsland": "Isla de perfil"
},
"create": {
"liveVideo": "Video en vivo",
"linkStory": "Enlace compartido",
"photoStory": "Foto",
"writtenUpdate": "Publicación escrita",
"other": "Otro",
"liveHint": "Transmite una escena en vivo con un título o enlace.",
"liveDetail1": "Pega la URL del stream o un título rápido para fijar el feed al instante.",
"liveDetail2": "Abriremos la ubicación donde coloques el cursor para que los espectadores sepan dónde sintonizar.",
"linkHint": "Comparte una URL y prellenaremos la vista previa.",
"linkDetail1": "Pega un enlace de artículo, playlist o actualización para obtener una imagen y extracto.",
"linkDetail2": "Elige el pin más relevante para que la historia pertenezca al vecindario correcto.",
"photoHint": "Sube o enlaza una imagen con contexto.",
"photoDetail1": "Sube un conjunto de fotos o pega una URL de imagen de tu galería.",
"photoDetail2": "Añade un pie de foto rápido para que la comunidad sepa qué está viendo.",
"textHint": "Escribe un informe corto o microblog.",
"textDetail1": "Escribe el mensaje clave en el campo de título (o añade más contexto en el cuerpo).",
"textDetail2": "Usa filtros de subcategoría para marcar el tono: Noticias, Evento, Mercado, etc.",
"otherHint": "Contenido personalizado que abarca tipos de medios.",
"otherDetail1": "Mezcla medios, borradores públicos o notas de referencia en un solo lugar.",
"otherDetail2": "Etiqueta una región o Isla para dar un hogar a la actualización.",
"shareAs": "Compartir como {{mode}}",
"step": "Paso {{current}} / {{total}}",
"liveHero": "Fija dónde está sucediendo la escena en vivo y pega tu enlace de stream o título.",
"livePlaceholder": "Enlace de streaming o título",
"liveFlowHint": "Trataremos esto como una transmisión en vivo y lo mostraremos tan pronto como el pin aterrice.",
"liveBodyPlaceholder": "Contexto para los espectadores, opcional.",
"linkHero": "Suelta el enlace, describe qué es, y fija el lugar relevante.",
"linkPlaceholder": "URL de artículo o playlist",
"linkFlowHint": "Obtendremos automáticamente una vista previa una vez detectado el enlace.",
"linkBodyPlaceholder": "¿Qué importa de este enlace?",
"photoHero": "Toma o sube una foto y fija el lugar donde la capturaste.",
"photoPlaceholder": "Pie de foto o URL de imagen",
"photoFlowHint": "Añade un pie de foto rápido para que la comunidad sepa qué está viendo.",
"photoBodyPlaceholder": "Describe la escena en pocas palabras.",
"textHero": "Escribe una actualización corta y fija el área desde donde reportas.",
"textPlaceholder": "Título o nota corta",
"textFlowHint": "Los lectores deben entender lo esencial antes de abrir la tarjeta completa.",
"textBodyPlaceholder": "Proporciona más contexto, opiniones o llamadas a la acción.",
"otherHero": "Combina medios, notas o referencias y fija donde sea importante.",
"otherPlaceholder": "Describe tu actualización",
"otherFlowHint": "Este modo es flexible - añade lo que ayude al lector.",
"otherBodyPlaceholder": "Añade enlaces, adjuntos o contexto extra si es necesario.",
"category": "Categoría",
"titleOrLink": "Título o enlace",
"linkDetected": "Enlace detectado - obteniendo vista previa...",
"useCategoryIcon": "Usar icono de categoría",
"addImage": "Añadir imagen",
"categoryIconFallback": "Usaremos el icono de categoría si omites las imágenes.",
"chooseFile": "Elegir archivo",
"takePhoto": "Tomar foto",
"uploading": "Subiendo...",
"pasteImageUrl": "O pega una URL de imagen:",
"imageUrlPlaceholder": "https://ejemplo.com/imagen.jpg",
"goLive": "Iniciar Live",
"posting": "Publicando...",
"titleRequired": "Título requerido.",
"mustBeLoggedIn": "Debes iniciar sesión para publicar.",
"verifyEmailToPost": "Verifica tu email para publicar. Usa 'Reenviar email' en la barra superior, luego vuelve a iniciar sesión.",
"createPostError": "Error al crear el post.",
"previewUnavailable": "Vista previa no disponible",
"pasteUrlFirst": "Pega primero una URL de imagen.",
"invalidImageUrl": "La URL de imagen debe comenzar con http:// o https://",
"selectImageFile": "Por favor selecciona un archivo de imagen",
"imageTooLarge": "La imagen debe ser menor a 5MB",
"imageReadError": "Error al leer la imagen",
"imageUploadError": "Error al subir la imagen",
"signInToPost": "Inicia sesión o crea una cuenta gratuita para crear un post."
},
"post": {
"like": "Me gusta",
"comment": "Comentar",
"share": "Compartir",
"visibility": "Visibilidad",
"public": "Público",
"friendsOnly": "Solo amigos",
"private": "Privado",
"group": "Grupo",
"custom": "Personalizado",
"groupId": "ID del grupo",
"usernamesComma": "Usuarios (separados por coma)",
"applyToImages": "Aplicar misma visibilidad a imágenes",
"visibilityError": "Error al actualizar visibilidad.",
"saving": "Guardando...",
"deletePost": "¿Eliminar este post?",
"deleting": "Eliminando...",
"title": "Título",
"description": "Descripción",
"signInToLike": "Inicia sesión o crea una cuenta gratuita para dar me gusta a este post.",
"signInToComment": "Inicia sesión o crea una cuenta gratuita para comentar.",
"previousImage": "Imagen anterior",
"nextImage": "Imagen siguiente"
},
"postlist": {
"radius": "Radio:",
"km": "km",
"noPostsInRadius": "No hay posts en este radio."
},
"islands": {
"communities": "Comunidades",
"exploreIslands": "Explorar Islas",
"islandsDescription": "Las islas son centros locales donde la gente comparte actualizaciones, ofertas e historias en un solo lugar. Navega por las islas cercanas o salta a una nueva comunidad para ver qué está de moda.",
"openIslands": "Abrir Islas"
},
"language": {
"en": "English",
"fr": "Français",
"es": "Español",
"selectLanguage": "Idioma"
},
"friends": {
"title": "Amigos",
"signInToSee": "Inicia sesión para ver tus amigos",
"friendsTab": "Amigos",
"requestsTab": "Solicitudes",
"addPlaceholder": "Añadir amigo por nombre de usuario...",
"sendRequest": "Enviar solicitud de amistad",
"requestSent": "Solicitud de amistad enviada a {{username}}",
"couldNotSend": "No se pudo enviar la solicitud",
"searching": "Buscando...",
"noFriends": "Aún no tienes amigos",
"noPending": "No hay solicitudes pendientes",
"removeFriend": "Eliminar amigo",
"removeConfirm": "¿Eliminar a {{username}} de tus amigos?",
"accept": "Aceptar",
"reject": "Rechazar"
},
"universe": {
"title": "Universo de Islas",
"islandsCount": "{{count}} islas flotando en el espacio virtual",
"clickToVisit": "Clic para visitar",
"posts": "posts",
"help": "Pasa el ratón sobre las islas para ver detalles - Clic para visitar"
},
"controls": {
"mySpot": "Mi ubicación",
"putWire": "Publicar un wire"
},
"chat": {
"signInToChat": "Inicia sesión para chatear con amigos",
"startConversation": "Iniciar una conversación",
"typeMessage": "Escribe un mensaje...",
"online": "En línea",
"offline": "Desconectado",
"typing": "escribiendo..."
}
}

View File

@ -0,0 +1,279 @@
{
"common": {
"loading": "Chargement...",
"save": "Enregistrer",
"cancel": "Annuler",
"delete": "Supprimer",
"edit": "Modifier",
"close": "Fermer",
"back": "Retour",
"next": "Suivant",
"post": "Publier",
"add": "Ajouter",
"search": "Rechercher",
"clear": "Effacer",
"online": "En ligne",
"guest": "Invité",
"anon": "anon",
"contacts": "Contacts",
"untitled": "Sans titre"
},
"time": {
"now": "maintenant",
"minutes": "{{count}}m",
"hours": "{{count}}h",
"days": "{{count}}j"
},
"nav": {
"home": "Accueil",
"liveMap": "Carte en direct",
"sociowall": "SocioWall",
"islands": "Îles",
"about": "À propos",
"contact": "Contact",
"privacy": "Confidentialité"
},
"topbar": {
"wiredToLife": "Connecté à la vie",
"installApp": "Installer l'app",
"friends": "Amis",
"login": "Connexion",
"logout": "Déconnexion"
},
"theme": {
"dark": "Sombre",
"blue": "Bleu",
"light": "Clair"
},
"auth": {
"login": "Connexion",
"signup": "Inscription",
"signIn": "Se connecter",
"createAccount": "Créer un compte",
"usernameOrEmail": "Nom d'utilisateur ou email",
"password": "Mot de passe",
"forgotPassword": "Mot de passe oublié ?",
"orContinueWith": "ou continuer avec",
"continueWithGoogle": "Continuer avec Google",
"continueWithFacebook": "Continuer avec Facebook",
"firstName": "Prénom",
"lastName": "Nom",
"username": "Nom d'utilisateur",
"email": "Email",
"emailVerificationRequired": "Vérification email requise",
"loginFailed": "Échec de connexion",
"usernameHint": "3-24 caractères: lettres, chiffres, _ ou .",
"usernameTaken": "Ce nom d'utilisateur est déjà pris.",
"usernameUpdateError": "Impossible de mettre à jour le nom d'utilisateur.",
"usernameSaved": "Nom d'utilisateur enregistré.",
"chooseUsername": "Choisir un nom d'utilisateur",
"pickUsername": "Choisissez un nom d'utilisateur public pour compléter votre profil.",
"saveUsername": "Enregistrer le nom",
"savingUsername": "Enregistrement...",
"emailConfirmed": "Email confirmé",
"emailConfirmedMsg": "Email confirmé. Vous pouvez vous connecter.",
"accountCreated": "Compte créé",
"accountCreatedMsg": "Compte créé. Vérifiez vos emails (spam aussi), puis connectez-vous.",
"verifyEmail": "Votre compte nécessite une vérification email. Confirmez votre email, puis réessayez de vous connecter.",
"fieldsRequired": "Prénom, nom, nom d'utilisateur, email et mot de passe sont requis.",
"registrationError": "Erreur d'inscription.",
"pleaseSignIn": "Connectez-vous ou créez un compte gratuit pour continuer."
},
"filters": {
"all": "Tout",
"news": "Actualités",
"friends": "Amis",
"events": "Événements",
"market": "Marché",
"world": "Monde",
"politics": "Politique",
"tech": "Tech",
"finance": "Finance",
"local": "Local",
"nearby": "Proches",
"chats": "Chats",
"groups": "Groupes",
"stories": "Stories",
"favs": "Favoris",
"today": "Aujourd'hui",
"weekend": "Weekend",
"music": "Musique",
"sports": "Sports",
"actu": "Actu",
"finan": "Finan",
"sport": "Sport",
"deals": "Promos"
},
"time_filter": {
"now": "Actuel",
"today": "Aujourd'hui",
"recents": "Récents",
"old": "Anciens",
"1h": "1h",
"6h": "6h",
"24h": "24h",
"7d": "7j",
"14d": "14j"
},
"map": {
"loadingMap": "Chargement de la carte",
"warmingUp": "Préparation des tuiles et données en direct.",
"searchPlaceholder": "Rechercher villes, lieux, posts...",
"mySpot": "Ma position",
"clearSearch": "Effacer la recherche",
"searching": "Recherche...",
"noResults": "Aucun résultat",
"mainNews": "Actualités principales",
"placeTourWire": "Placer / Tour / Wire",
"profileIsland": "Île de profil"
},
"create": {
"liveVideo": "Vidéo en direct",
"linkStory": "Lien partagé",
"photoStory": "Photo",
"writtenUpdate": "Publication écrite",
"other": "Autre",
"liveHint": "Diffusez une scène en direct avec un titre ou un lien.",
"liveDetail1": "Collez l'URL du stream ou un titre rapide pour épingler le flux instantanément.",
"liveDetail2": "Nous ouvrirons l'emplacement où vous placez le curseur pour que les spectateurs sachent où regarder.",
"linkHint": "Partagez une URL et nous pré-remplirons l'aperçu.",
"linkDetail1": "Collez un lien d'article, playlist ou mise à jour pour récupérer une image et un extrait.",
"linkDetail2": "Choisissez l'épingle la plus pertinente pour que l'histoire appartienne au bon quartier.",
"photoHint": "Téléchargez ou liez une image avec du contexte.",
"photoDetail1": "Téléchargez un ensemble de photos ou collez une URL d'image de votre galerie.",
"photoDetail2": "Ajoutez une légende rapide pour que la communauté sache ce qu'elle regarde.",
"textHint": "Tapez un court rapport ou microblog.",
"textDetail1": "Écrivez le message clé dans le champ titre (ou ajoutez plus de contexte dans le corps).",
"textDetail2": "Utilisez les filtres de sous-catégorie pour marquer le ton: Actu, Événement, Marché, etc.",
"otherHint": "Contenu personnalisé multi-média.",
"otherDetail1": "Mélangez médias, brouillons publics ou notes de référence en un seul endroit.",
"otherDetail2": "Taguez une région ou une Île pour donner un foyer à la mise à jour.",
"shareAs": "Partager comme {{mode}}",
"step": "Étape {{current}} / {{total}}",
"liveHero": "Épinglez où la scène en direct se passe et collez votre lien de stream ou titre.",
"livePlaceholder": "Lien de streaming ou titre",
"liveFlowHint": "Nous traiterons ceci comme une diffusion en direct et l'afficherons dès que l'épingle atterrit.",
"liveBodyPlaceholder": "Contexte pour les spectateurs, optionnel.",
"linkHero": "Déposez le lien, décrivez ce que c'est, et épinglez l'endroit pertinent.",
"linkPlaceholder": "URL d'article ou playlist",
"linkFlowHint": "Nous récupérerons automatiquement un aperçu une fois le lien détecté.",
"linkBodyPlaceholder": "Qu'est-ce qui est important dans ce lien ?",
"photoHero": "Prenez ou téléchargez une photo et épinglez l'endroit où vous l'avez capturée.",
"photoPlaceholder": "Légende de photo ou URL d'image",
"photoFlowHint": "Ajoutez une légende rapide pour que la communauté sache ce qu'elle voit.",
"photoBodyPlaceholder": "Décrivez la scène en quelques mots.",
"textHero": "Tapez une courte mise à jour et épinglez la zone d'où vous rapportez.",
"textPlaceholder": "Titre ou note courte",
"textFlowHint": "Les lecteurs doivent comprendre l'essentiel avant d'ouvrir la carte complète.",
"textBodyPlaceholder": "Fournissez plus de contexte, opinions ou appels à l'action.",
"otherHero": "Combinez médias, notes ou références et épinglez où ça compte.",
"otherPlaceholder": "Décrivez votre mise à jour",
"otherFlowHint": "Ce mode est flexible - ajoutez ce qui aide le lecteur.",
"otherBodyPlaceholder": "Ajoutez liens, pièces jointes ou contexte supplémentaire si nécessaire.",
"category": "Catégorie",
"titleOrLink": "Titre ou lien",
"linkDetected": "Lien détecté - récupération de l'aperçu...",
"useCategoryIcon": "Utiliser l'icône de catégorie",
"addImage": "Ajouter une image",
"categoryIconFallback": "Nous utiliserons l'icône de catégorie si vous sautez les images.",
"chooseFile": "Choisir fichier",
"takePhoto": "Prendre photo",
"uploading": "Téléchargement...",
"pasteImageUrl": "Ou collez une URL d'image :",
"imageUrlPlaceholder": "https://exemple.com/image.jpg",
"goLive": "Démarrer Live",
"posting": "Publication...",
"titleRequired": "Titre requis.",
"mustBeLoggedIn": "Vous devez être connecté pour publier.",
"verifyEmailToPost": "Vérifiez votre email pour publier. Utilisez 'Renvoyer email' dans la barre du haut, puis reconnectez-vous.",
"createPostError": "Erreur lors de la création du post.",
"previewUnavailable": "Aperçu non disponible",
"pasteUrlFirst": "Collez d'abord une URL d'image.",
"invalidImageUrl": "L'URL d'image doit commencer par http:// ou https://",
"selectImageFile": "Veuillez sélectionner un fichier image",
"imageTooLarge": "L'image doit faire moins de 5Mo",
"imageReadError": "Échec de lecture de l'image",
"imageUploadError": "Échec du téléchargement de l'image",
"signInToPost": "Connectez-vous ou créez un compte gratuit pour créer un post."
},
"post": {
"like": "J'aime",
"comment": "Commenter",
"share": "Partager",
"visibility": "Visibilité",
"public": "Public",
"friendsOnly": "Amis uniquement",
"private": "Privé",
"group": "Groupe",
"custom": "Personnalisé",
"groupId": "ID du groupe",
"usernamesComma": "Noms d'utilisateur (séparés par virgule)",
"applyToImages": "Appliquer la même visibilité aux images",
"visibilityError": "Échec de mise à jour de la visibilité.",
"saving": "Enregistrement...",
"deletePost": "Supprimer ce post ?",
"deleting": "Suppression...",
"title": "Titre",
"description": "Description",
"signInToLike": "Connectez-vous ou créez un compte gratuit pour aimer ce post.",
"signInToComment": "Connectez-vous ou créez un compte gratuit pour commenter.",
"previousImage": "Image précédente",
"nextImage": "Image suivante"
},
"postlist": {
"radius": "Rayon :",
"km": "km",
"noPostsInRadius": "Aucun post dans ce rayon."
},
"islands": {
"communities": "Communautés",
"exploreIslands": "Explorer les Îles",
"islandsDescription": "Les îles sont des centres locaux où les gens partagent mises à jour, offres et histoires en un seul endroit. Parcourez les îles proches ou sautez dans une nouvelle communauté pour voir ce qui est tendance.",
"openIslands": "Ouvrir les Îles"
},
"language": {
"en": "English",
"fr": "Français",
"es": "Español",
"selectLanguage": "Langue"
},
"friends": {
"title": "Amis",
"signInToSee": "Connectez-vous pour voir vos amis",
"friendsTab": "Amis",
"requestsTab": "Demandes",
"addPlaceholder": "Ajouter un ami par nom d'utilisateur...",
"sendRequest": "Envoyer demande d'ami",
"requestSent": "Demande d'ami envoyée à {{username}}",
"couldNotSend": "Impossible d'envoyer la demande",
"searching": "Recherche...",
"noFriends": "Pas encore d'amis",
"noPending": "Aucune demande en attente",
"removeFriend": "Supprimer ami",
"removeConfirm": "Supprimer {{username}} de vos amis ?",
"accept": "Accepter",
"reject": "Refuser",
"requestSentLabel": "Demande envoyée",
"requestReceivedLabel": "Veut être votre ami",
"cancelRequest": "Annuler la demande"
},
"universe": {
"title": "Univers des Îles",
"islandsCount": "{{count}} îles flottant dans l'espace virtuel",
"clickToVisit": "Cliquez pour visiter",
"posts": "posts",
"help": "Survolez les îles pour voir les détails - Cliquez pour visiter"
},
"controls": {
"mySpot": "Ma position",
"putWire": "Poster un wire"
},
"chat": {
"signInToChat": "Connectez-vous pour discuter avec vos amis",
"startConversation": "Commencer une conversation",
"typeMessage": "Tapez un message...",
"online": "En ligne",
"offline": "Hors ligne",
"typing": "en train d'écrire..."
}
}

View File

@ -2,6 +2,7 @@ import "@fortawesome/fontawesome-free/css/all.min.css"; // local via npm (no CDN
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import "./i18n"; // Initialize i18n before App
import App from "./App.jsx"; import App from "./App.jsx";
import "./styles/base.css"; import "./styles/base.css";

View File

@ -1,5 +1,5 @@
.topbar{ .topbar{
position: sticky; top:0; z-index:100; position: sticky; top:0; z-index:1000;
width:100%; width:100%;
padding: .55rem .4rem .55rem 1.2rem; padding: .55rem .4rem .55rem 1.2rem;
display: flex; display: flex;
@ -763,7 +763,7 @@ body[data-theme="light"] .sw-avatar{
position: fixed; position: fixed;
top: 70px; top: 70px;
right: 16px; right: 16px;
z-index: 200; z-index: 10000005;
animation: friendsSlideIn 0.2s ease-out; animation: friendsSlideIn 0.2s ease-out;
} }
@ -791,3 +791,309 @@ body[data-theme="light"] .sw-avatar{
} }
} }
/* SW_FRIENDS_BUTTON:END */ /* SW_FRIENDS_BUTTON:END */
/* SW_LANGUAGE_SELECTOR:BEGIN */
.lang-selector-wrap{
position: relative;
z-index: 9999;
}
.btn-action-lang{
background: rgba(56, 189, 248, 0.12);
border-color: rgba(56, 189, 248, 0.4);
}
.btn-action-lang .btn-label{
font-size: 0.65rem;
font-weight: 700;
}
.btn-action-lang.is-active{
background: rgba(56, 189, 248, 0.25);
border-color: rgba(56, 189, 248, 0.7);
}
.lang-dropdown{
position: fixed;
top: auto;
right: auto;
margin-top: 6px;
min-width: 140px;
background: rgba(15, 23, 42, 0.98);
border: 1px solid rgba(148, 163, 184, 0.5);
border-radius: 14px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255,255,255,0.05);
backdrop-filter: blur(16px);
overflow: hidden;
z-index: 10000010;
animation: langDropIn 0.18s ease-out;
}
@keyframes langDropIn{
from{
opacity: 0;
transform: translateY(-8px) scale(0.95);
}
to{
opacity: 1;
transform: translateY(0) scale(1);
}
}
.lang-option{
display: block;
width: 100%;
padding: 12px 16px;
text-align: left;
background: transparent;
border: none;
color: #e5e7eb;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.lang-option:hover{
background: rgba(56, 189, 248, 0.18);
}
.lang-option.is-active{
background: rgba(56, 189, 248, 0.28);
color: #38bdf8;
}
body[data-theme="light"] .lang-dropdown{
background: rgba(255, 255, 255, 0.98);
border-color: rgba(148, 163, 184, 0.6);
}
body[data-theme="light"] .lang-option{
color: #0f172a;
}
body[data-theme="light"] .lang-option:hover{
background: rgba(56, 189, 248, 0.15);
}
body[data-theme="light"] .lang-option.is-active{
background: rgba(56, 189, 248, 0.25);
color: #0369a1;
}
/* SW_LANGUAGE_SELECTOR:END */
/* SW_THEME_SELECTOR:BEGIN */
.theme-selector-wrap{
position: relative;
}
.btn-action-theme{
background: linear-gradient(135deg, #8b5cf6, #6366f1);
border-color: rgba(139, 92, 246, 0.5);
color: white;
}
.btn-action-theme:hover{
background: linear-gradient(135deg, #a78bfa, #818cf8);
}
.btn-action-theme.is-active{
background: linear-gradient(135deg, #7c3aed, #4f46e5);
box-shadow: 0 0 12px rgba(139, 92, 246, 0.6);
}
.theme-dropdown{
position: fixed;
z-index: 10000010;
background: rgba(15, 23, 42, 0.98);
border: 1px solid rgba(139, 92, 246, 0.5);
border-radius: 8px;
min-width: 120px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
backdrop-filter: blur(12px);
overflow: hidden;
}
.theme-option{
display: block;
width: 100%;
padding: 12px 16px;
text-align: left;
background: transparent;
border: none;
color: #e5e7eb;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.theme-option:hover{
background: rgba(139, 92, 246, 0.18);
}
.theme-option.is-active{
background: rgba(139, 92, 246, 0.28);
color: #a78bfa;
}
body[data-theme="light"] .theme-dropdown{
background: rgba(255, 255, 255, 0.98);
border-color: rgba(139, 92, 246, 0.6);
}
body[data-theme="light"] .theme-option{
color: #0f172a;
}
body[data-theme="light"] .theme-option:hover{
background: rgba(139, 92, 246, 0.15);
}
body[data-theme="light"] .theme-option.is-active{
background: rgba(139, 92, 246, 0.25);
color: #6d28d9;
}
/* SW_THEME_SELECTOR:END */
/* SW_MOBILE_TOPBAR_COMPACT:BEGIN - Facebook-style compact single-row topbar */
@media (max-width: 900px){
.btn-action .btn-label{
display: none;
}
.btn-action{
padding: 0 0.45rem;
min-width: 32px;
height: 30px;
}
}
@media (max-width: 640px){
/* COMPACT SINGLE ROW - like Facebook */
.topbar{
flex-direction: row !important;
flex-wrap: nowrap !important;
align-items: center;
padding: 0.35rem 0.5rem;
gap: 0.4rem;
min-height: 44px;
max-height: 52px;
}
/* Logo smaller */
.logo-circle{
width: 32px;
height: 32px;
margin: 0;
}
/* Brand stack inline, no column */
.brand-stack{
flex-direction: row;
align-items: center;
gap: 0.35rem;
min-width: 0;
flex-shrink: 1;
}
.brand-toprow{
gap: 0.35rem;
min-width: 0;
}
/* Hide subtitle and theme dots on mobile */
.sub-title{
display: none;
}
.brand-under{
display: none;
}
.main-title{
font-size: 0.85rem;
white-space: nowrap;
}
/* Right side: compact pill with small buttons */
.topbar-right{
flex-shrink: 0;
padding: 0.2rem 0.3rem;
border-radius: 999px;
gap: 0.25rem;
min-height: 34px;
max-width: none;
width: auto;
background: rgba(2, 6, 23, 0.25);
border: 1px solid rgba(255, 255, 255, 0.12);
}
/* User chip: just avatar, no text */
.topbar-user-section{
flex-shrink: 0;
}
.sw-userchip{
padding: 0.15rem;
gap: 0;
}
.sw-usertext{
display: none;
}
.sw-avatar{
width: 24px;
height: 24px;
}
/* Compact action buttons */
.topbar-actions{
gap: 0.2rem;
}
.btn-action{
height: 28px;
min-width: 28px;
padding: 0 0.35rem;
border-radius: 999px;
}
.btn-action i{
font-size: 0.8rem;
}
.btn-action .btn-label{
display: none;
}
/* Friends badge smaller */
.friends-badge-count{
top: -4px;
right: -4px;
min-width: 14px;
height: 14px;
font-size: 0.55rem;
padding: 0 3px;
}
}
@media (max-width: 380px){
.topbar{
padding: 0.3rem 0.4rem;
}
.logo-circle{
width: 28px;
height: 28px;
}
.main-title{
font-size: 0.75rem;
white-space: nowrap;
}
.btn-action{
height: 26px;
min-width: 26px;
padding: 0 0.3rem;
}
.btn-action i{
font-size: 0.75rem;
}
.sw-avatar{
width: 22px;
height: 22px;
}
}
/* SW_MOBILE_TOPBAR_COMPACT:END */

149
src/utils/timezone.js Normal file
View File

@ -0,0 +1,149 @@
/**
* Timezone utilities for formatting dates in user's preferred timezone
*/
// Get user's timezone from localStorage or detect from browser
export function getUserTimezone() {
try {
const stored = localStorage.getItem('sociowire:timezone');
if (stored && stored !== 'null' && stored !== 'undefined') {
return stored;
}
} catch {}
// Fallback to browser timezone
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
} catch {
return 'UTC';
}
}
// Save user's timezone preference
export function setUserTimezone(tz) {
try {
localStorage.setItem('sociowire:timezone', tz || '');
} catch {}
}
// Format a date string or Date object in the user's timezone
export function formatInUserTimezone(dateInput, options = {}) {
if (!dateInput) return '';
const tz = getUserTimezone();
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
if (isNaN(date.getTime())) return '';
const defaultOptions = {
timeZone: tz,
...options,
};
try {
return new Intl.DateTimeFormat(undefined, defaultOptions).format(date);
} catch {
return date.toLocaleString();
}
}
// Format relative time (e.g., "2 hours ago")
export function formatRelativeTime(dateInput, locale = 'en') {
if (!dateInput) return '';
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
if (isNaN(date.getTime())) return '';
const now = new Date();
const diffMs = now - date;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
// Less than 1 minute
if (diffSec < 60) return locale === 'fr' ? "à l'instant" : 'just now';
// Less than 1 hour
if (diffMin < 60) {
if (locale === 'fr') return `il y a ${diffMin} min`;
return `${diffMin}m ago`;
}
// Less than 24 hours
if (diffHour < 24) {
if (locale === 'fr') return `il y a ${diffHour}h`;
return `${diffHour}h ago`;
}
// Less than 7 days
if (diffDay < 7) {
if (locale === 'fr') return `il y a ${diffDay}j`;
return `${diffDay}d ago`;
}
// More than 7 days - show formatted date in user's timezone
return formatInUserTimezone(date, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
// Format date for display (short format)
export function formatShortDate(dateInput) {
return formatInUserTimezone(dateInput, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
// Format datetime for display
export function formatDateTime(dateInput) {
return formatInUserTimezone(dateInput, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
// Format time only
export function formatTime(dateInput) {
return formatInUserTimezone(dateInput, {
hour: '2-digit',
minute: '2-digit',
});
}
// Format for chat messages (show time, or date+time if different day)
export function formatChatTime(dateInput) {
if (!dateInput) return '';
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
if (isNaN(date.getTime())) return '';
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
if (isToday) {
return formatTime(date);
}
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = date.toDateString() === yesterday.toDateString();
if (isYesterday) {
return 'Yesterday ' + formatTime(date);
}
return formatInUserTimezone(date, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}