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:
parent
d77dac74a2
commit
f9e1505147
|
|
@ -10,11 +10,15 @@
|
|||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||
"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",
|
||||
"livekit-client": "^2.0.0",
|
||||
"maplibre-gl": "^5.14.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-i18next": "^16.5.1",
|
||||
"react-router-dom": "^7.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -247,6 +251,14 @@
|
|||
"@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": {
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
||||
|
|
@ -1709,6 +1721,14 @@
|
|||
"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": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
|
|
@ -2347,6 +2367,60 @@
|
|||
"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": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
|
|
@ -2694,6 +2768,25 @@
|
|||
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
||||
"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": {
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
|
|
@ -2879,6 +2972,32 @@
|
|||
"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": {
|
||||
"version": "0.18.0",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-debounce/-/ts-debounce-4.0.0.tgz",
|
||||
|
|
@ -3167,6 +3291,14 @@
|
|||
"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": {
|
||||
"version": "5.4.21",
|
||||
"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": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.3.tgz",
|
||||
|
|
@ -3238,6 +3383,15 @@
|
|||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -11,12 +11,16 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||
"livekit-client": "^2.0.0",
|
||||
"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",
|
||||
"livekit-client": "^2.0.0",
|
||||
"maplibre-gl": "^5.14.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-i18next": "^16.5.1",
|
||||
"react-router-dom": "^7.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
88
src/App.jsx
88
src/App.jsx
|
|
@ -1,16 +1,18 @@
|
|||
import React, { Suspense, useEffect, useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import TopBar from "./components/Layout/TopBar";
|
||||
import PostList from "./components/Posts/PostList";
|
||||
import IslandUniverse from "./components/Island/IslandUniverse";
|
||||
import IslandViewer from "./components/Island/IslandViewer";
|
||||
import ChatContainer from "./components/Chat/ChatContainer";
|
||||
import { fetchIslandByUsername } from "./api/client";
|
||||
|
||||
const MapView = React.lazy(() => import("./components/Map/MapView"));
|
||||
|
||||
const THEME_KEY = "sociowire:theme";
|
||||
const ACTIVE_CHATS_KEY = "sociowire:activeChats";
|
||||
|
||||
export default function App() {
|
||||
const { t } = useTranslation();
|
||||
const [theme, setTheme] = useState("blue");
|
||||
|
||||
// Wall state (lifted from MapView)
|
||||
|
|
@ -77,10 +79,6 @@ export default function App() {
|
|||
}
|
||||
}, [theme]);
|
||||
|
||||
// Chats actifs (s'ajoutent quand on clique sur un contact)
|
||||
const [activeChats, setActiveChats] = useState([]);
|
||||
const [showContactsList, setShowContactsList] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
const threshold = window.innerHeight * 0.55;
|
||||
|
|
@ -92,29 +90,6 @@ export default function App() {
|
|||
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
|
||||
const handleOpenUniverse = () => {
|
||||
setShowUniverse(true);
|
||||
|
|
@ -157,8 +132,8 @@ export default function App() {
|
|||
<div className="map-page">
|
||||
<div className="map-stage map-stage--placeholder">
|
||||
<div className="map-placeholder-content">
|
||||
<div className="map-placeholder-title">Loading map</div>
|
||||
<div className="map-placeholder-subtitle">Warming up tiles and live data.</div>
|
||||
<div className="map-placeholder-title">{t('map.loadingMap')}</div>
|
||||
<div className="map-placeholder-subtitle">{t('map.warmingUp')}</div>
|
||||
</div>
|
||||
<div className="map-placeholder-overlay map-placeholder-top">
|
||||
<div className="map-placeholder-pill map-placeholder-pill--wide" />
|
||||
|
|
@ -216,46 +191,19 @@ export default function App() {
|
|||
|
||||
<section className="seo-section" id="islands">
|
||||
<div className="seo-card">
|
||||
<div className="seo-kicker">Communities</div>
|
||||
<h2>Explore Islands</h2>
|
||||
<div className="seo-kicker">{t('islands.communities')}</div>
|
||||
<h2>{t('islands.exploreIslands')}</h2>
|
||||
<p>
|
||||
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.
|
||||
{t('islands.islandsDescription')}
|
||||
</p>
|
||||
<button className="seo-cta" type="button" onClick={handleOpenUniverse}>
|
||||
Open Islands
|
||||
{t('islands.openIslands')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Bouton Contacts flottant en bas à droite */}
|
||||
<button className="contacts-float-btn" onClick={() => setShowContactsList(!showContactsList)}>
|
||||
<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>
|
||||
{/* Chat System */}
|
||||
<ChatContainer />
|
||||
</div>
|
||||
|
||||
<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 v0.0008</div>
|
||||
<nav className="sw-footer-nav" aria-label="Site">
|
||||
<a href="/">Home</a>
|
||||
<a href="/#map">Live Map</a>
|
||||
<a href="/#wall">SocioWall</a>
|
||||
<a href="/#islands">Islands</a>
|
||||
<a href="/about.html">About</a>
|
||||
<a href="/contact.html">Contact</a>
|
||||
<a href="/privacy.html">Privacy</a>
|
||||
<a href="/">{t('nav.home')}</a>
|
||||
<a href="/#map">{t('nav.liveMap')}</a>
|
||||
<a href="/#wall">{t('nav.sociowall')}</a>
|
||||
<a href="/#islands">{t('nav.islands')}</a>
|
||||
<a href="/about.html">{t('nav.about')}</a>
|
||||
<a href="/contact.html">{t('nav.contact')}</a>
|
||||
<a href="/privacy.html">{t('nav.privacy')}</a>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
try {
|
||||
const res = await fetch(`${apiBase()}/friends/remove`, {
|
||||
|
|
@ -961,3 +976,41 @@ export async function fetchFriendStatus(username) {
|
|||
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 "";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -458,18 +458,38 @@ export function AuthProvider({ children }) {
|
|||
async function logout() {
|
||||
if (refreshTimer.current) clearInterval(refreshTimer.current);
|
||||
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
|
||||
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 {
|
||||
sessionStorage.setItem("sociowire:logout", String(Date.now()));
|
||||
} catch {}
|
||||
|
||||
// Clear local auth state
|
||||
setAnon("");
|
||||
|
||||
if (!doSsoLogout) return;
|
||||
try {
|
||||
const kc = getKeycloak();
|
||||
await kc.logout({ redirectUri });
|
||||
} catch {}
|
||||
|
||||
// Build Keycloak logout URL
|
||||
const logoutUrl = `${KC_URL}/realms/${KC_REALM}/protocol/openid-connect/logout`;
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,12 +1,15 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function FilterButtons({ active, onSelect }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const buttons = [
|
||||
{ code: "All", label: "All", icon: "fa-solid fa-globe" },
|
||||
{ code: "News", label: "News", icon: "fa-solid fa-newspaper" },
|
||||
{ code: "Friends", label: "Friends", icon: "fa-solid fa-user-group" },
|
||||
{ code: "Events", label: "Events", icon: "fa-solid fa-calendar-days" },
|
||||
{ code: "Market", label: "Market", icon: "fa-solid fa-chart-line" },
|
||||
{ code: "All", labelKey: "filters.all", icon: "fa-solid fa-globe" },
|
||||
{ code: "News", labelKey: "filters.news", icon: "fa-solid fa-newspaper" },
|
||||
{ code: "Friends", labelKey: "filters.friends", icon: "fa-solid fa-user-group" },
|
||||
{ code: "Events", labelKey: "filters.events", icon: "fa-solid fa-calendar-days" },
|
||||
{ code: "Market", labelKey: "filters.market", icon: "fa-solid fa-chart-line" },
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
@ -21,7 +24,7 @@ export default function FilterButtons({ active, onSelect }) {
|
|||
<div className="sw-icon-circle">
|
||||
<i className={btn.icon} />
|
||||
</div>
|
||||
<div className="sw-icon-label">{btn.label}</div>
|
||||
<div className="sw-icon-label">{t(btn.labelKey)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function TimeFilterButtons({ activeHours, onSelect }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const buttons = [
|
||||
{ hours: 3, label: "Now", icon: "fa-solid fa-bolt" },
|
||||
{ hours: 24, label: "Today", icon: "fa-solid fa-sun" },
|
||||
{ hours: 96, label: "4Days", icon: "fa-solid fa-calendar-days" },
|
||||
{ hours: 336, label: "Old", icon: "fa-solid fa-hourglass-half" },
|
||||
{ hours: 3, labelKey: "time_filter.now", icon: "fa-solid fa-bolt" },
|
||||
{ hours: 24, labelKey: "time_filter.today", icon: "fa-solid fa-sun" },
|
||||
{ hours: 96, labelKey: "time_filter.recents", icon: "fa-solid fa-calendar-days" },
|
||||
{ hours: 336, labelKey: "time_filter.old", icon: "fa-solid fa-hourglass-half" },
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
@ -20,7 +23,7 @@ export default function TimeFilterButtons({ activeHours, onSelect }) {
|
|||
<div className="sw-icon-circle">
|
||||
<i className={btn.icon} />
|
||||
</div>
|
||||
<div className="sw-icon-label">{btn.label}</div>
|
||||
<div className="sw-icon-label">{t(btn.labelKey)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -127,6 +127,42 @@
|
|||
display: flex;
|
||||
align-items: 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 {
|
||||
|
|
@ -180,6 +216,15 @@
|
|||
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-btn {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
fetchFriends,
|
||||
fetchFriendRequests,
|
||||
acceptFriendRequest,
|
||||
rejectFriendRequest,
|
||||
cancelFriendRequest,
|
||||
removeFriend,
|
||||
sendFriendRequest,
|
||||
searchUsers,
|
||||
} from '../../api/client';
|
||||
import { useAuth } from '../../auth/AuthContext';
|
||||
import { recoSearch } from '../../services/recoSearch';
|
||||
import './Friends.css';
|
||||
|
||||
export default function FriendsList({ onSelectUser, onClose }) {
|
||||
const { t } = useTranslation();
|
||||
const { authenticated } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState('friends');
|
||||
const [friends, setFriends] = useState([]);
|
||||
|
|
@ -40,6 +43,8 @@ export default function FriendsList({ onSelectUser, onClose }) {
|
|||
fetchFriends(),
|
||||
fetchFriendRequests(),
|
||||
]);
|
||||
console.log('[Friends] requestsRes:', requestsRes);
|
||||
console.log('[Friends] requests:', requestsRes?.requests);
|
||||
setFriends(friendsRes?.friends || []);
|
||||
setRequests(requestsRes?.requests || []);
|
||||
setLoading(false);
|
||||
|
|
@ -49,7 +54,7 @@ export default function FriendsList({ onSelectUser, onClose }) {
|
|||
}, [authenticated]);
|
||||
|
||||
// Search for users with debounce
|
||||
const searchUsers = async (query) => {
|
||||
const doSearchUsers = async (query) => {
|
||||
if (!query || query.length < 2) {
|
||||
setSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
|
|
@ -58,13 +63,10 @@ export default function FriendsList({ onSelectUser, onClose }) {
|
|||
|
||||
setSuggestionsLoading(true);
|
||||
try {
|
||||
const results = await recoSearch(query, { limit: 8 });
|
||||
// Filter only profiles
|
||||
const profiles = results.filter(
|
||||
(r) => r.type === 'profile' || r.kind === 'profile'
|
||||
);
|
||||
setSuggestions(profiles);
|
||||
setShowSuggestions(profiles.length > 0);
|
||||
const res = await searchUsers(query);
|
||||
const users = res?.users || [];
|
||||
setSuggestions(users);
|
||||
setShowSuggestions(users.length > 0);
|
||||
} catch {
|
||||
setSuggestions([]);
|
||||
}
|
||||
|
|
@ -80,7 +82,7 @@ export default function FriendsList({ onSelectUser, onClose }) {
|
|||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
searchUsers(value);
|
||||
doSearchUsers(value);
|
||||
}, 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) => {
|
||||
if (!window.confirm(`Remove ${username} from friends?`)) return;
|
||||
if (!window.confirm(t('friends.removeConfirm', { username }))) return;
|
||||
const res = await removeFriend(username);
|
||||
if (res?.ok) {
|
||||
setFriends((prev) => prev.filter((f) => f.username !== username));
|
||||
|
|
@ -138,11 +149,11 @@ export default function FriendsList({ onSelectUser, onClose }) {
|
|||
setAddLoading(false);
|
||||
|
||||
if (res?.ok) {
|
||||
setAddSuccess(`Friend request sent to ${uname}`);
|
||||
setAddSuccess(t('friends.requestSent', { username: uname }));
|
||||
setAddUsername('');
|
||||
setTimeout(() => setAddSuccess(''), 3000);
|
||||
} else {
|
||||
setAddError(res?.error || res?.text || 'Could not send request');
|
||||
setAddError(res?.error || res?.text || t('friends.couldNotSend'));
|
||||
setTimeout(() => setAddError(''), 4000);
|
||||
}
|
||||
};
|
||||
|
|
@ -151,10 +162,10 @@ export default function FriendsList({ onSelectUser, onClose }) {
|
|||
return (
|
||||
<div className="friends-panel">
|
||||
<div className="friends-header">
|
||||
<span>Friends</span>
|
||||
<span>{t('friends.title')}</span>
|
||||
{onClose && <button className="friends-close" onClick={onClose}>×</button>}
|
||||
</div>
|
||||
<div className="friends-empty">Sign in to see your friends</div>
|
||||
<div className="friends-empty">{t('friends.signInToSee')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -162,7 +173,7 @@ export default function FriendsList({ onSelectUser, onClose }) {
|
|||
return (
|
||||
<div className="friends-panel">
|
||||
<div className="friends-header">
|
||||
<span>Friends</span>
|
||||
<span>{t('friends.title')}</span>
|
||||
{onClose && <button className="friends-close" onClick={onClose}>×</button>}
|
||||
</div>
|
||||
|
||||
|
|
@ -171,13 +182,13 @@ export default function FriendsList({ onSelectUser, onClose }) {
|
|||
className={`friends-tab ${activeTab === 'friends' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('friends')}
|
||||
>
|
||||
Friends ({friends.length})
|
||||
{t('friends.friendsTab')} ({friends.length})
|
||||
</button>
|
||||
<button
|
||||
className={`friends-tab ${activeTab === 'requests' ? 'active' : ''}`}
|
||||
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>
|
||||
</div>
|
||||
|
||||
|
|
@ -188,7 +199,7 @@ export default function FriendsList({ onSelectUser, onClose }) {
|
|||
ref={inputRef}
|
||||
type="text"
|
||||
className="friends-add-input"
|
||||
placeholder="Add friend by username..."
|
||||
placeholder={t('friends.addPlaceholder')}
|
||||
value={addUsername}
|
||||
onChange={handleInputChange}
|
||||
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
|
||||
|
|
@ -200,7 +211,7 @@ export default function FriendsList({ onSelectUser, onClose }) {
|
|||
type="submit"
|
||||
className="friends-add-btn"
|
||||
disabled={addLoading || !addUsername.trim()}
|
||||
title="Send friend request"
|
||||
title={t('friends.sendRequest')}
|
||||
>
|
||||
{addLoading ? '...' : <i className="fa-solid fa-user-plus" />}
|
||||
</button>
|
||||
|
|
@ -213,27 +224,31 @@ export default function FriendsList({ onSelectUser, onClose }) {
|
|||
<div
|
||||
key={s.id}
|
||||
className="friends-suggestion-item"
|
||||
onMouseDown={() => handleSelectSuggestion(s.title || s.raw?.username || s.id)}
|
||||
onMouseDown={() => handleSelectSuggestion(s.username)}
|
||||
>
|
||||
<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>
|
||||
<span>{s.title || s.raw?.username || s.id}</span>
|
||||
<span>{s.username}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{suggestionsLoading && <div className="friends-suggestions-loading">Searching...</div>}
|
||||
{suggestionsLoading && <div className="friends-suggestions-loading">{t('friends.searching')}</div>}
|
||||
</div>
|
||||
{addError && <div className="friends-add-error">{addError}</div>}
|
||||
{addSuccess && <div className="friends-add-success">{addSuccess}</div>}
|
||||
|
||||
<div className="friends-content">
|
||||
{loading ? (
|
||||
<div className="friends-loading">Loading...</div>
|
||||
<div className="friends-loading">{t('common.loading')}</div>
|
||||
) : activeTab === 'friends' ? (
|
||||
friends.length === 0 ? (
|
||||
<div className="friends-empty">No friends yet</div>
|
||||
<div className="friends-empty">{t('friends.noFriends')}</div>
|
||||
) : (
|
||||
<div className="friends-list">
|
||||
{friends.map((friend) => (
|
||||
|
|
@ -250,7 +265,7 @@ export default function FriendsList({ onSelectUser, onClose }) {
|
|||
<button
|
||||
className="friend-action friend-action-remove"
|
||||
onClick={() => handleRemove(friend.username)}
|
||||
title="Remove friend"
|
||||
title={t('friends.removeFriend')}
|
||||
>
|
||||
<i className="fa-solid fa-user-minus" />
|
||||
</button>
|
||||
|
|
@ -259,38 +274,64 @@ export default function FriendsList({ onSelectUser, onClose }) {
|
|||
</div>
|
||||
)
|
||||
) : requests.length === 0 ? (
|
||||
<div className="friends-empty">No pending requests</div>
|
||||
<div className="friends-empty">{t('friends.noPending')}</div>
|
||||
) : (
|
||||
<div className="friends-list">
|
||||
{requests.map((request) => (
|
||||
<div key={request.id} className="friend-item">
|
||||
{requests.map((request) => {
|
||||
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
|
||||
className="friend-info"
|
||||
onClick={() => onSelectUser && onSelectUser(request.username)}
|
||||
>
|
||||
<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 className="friend-info-text">
|
||||
<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 className="friend-actions">
|
||||
{request.direction === 'received' ? (
|
||||
<>
|
||||
<button
|
||||
className="friend-action friend-action-accept"
|
||||
onClick={() => handleAccept(request.username)}
|
||||
title="Accept"
|
||||
title={t('friends.accept')}
|
||||
>
|
||||
<i className="fa-solid fa-check" />
|
||||
</button>
|
||||
<button
|
||||
className="friend-action friend-action-reject"
|
||||
onClick={() => handleReject(request.username)}
|
||||
title="Reject"
|
||||
title={t('friends.reject')}
|
||||
>
|
||||
<i className="fa-solid fa-times" />
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { fetchIslands } from '../../api/client';
|
||||
|
|
@ -9,6 +10,7 @@ import '../../styles/universe.css';
|
|||
* Lightweight alternative to Three.js - uses existing MapLibre dependency
|
||||
*/
|
||||
export default function IslandUniverse({ onClose, onIslandClick }) {
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef(null);
|
||||
const mapRef = useRef(null);
|
||||
const markersRef = useRef([]);
|
||||
|
|
@ -187,8 +189,8 @@ export default function IslandUniverse({ onClose, onIslandClick }) {
|
|||
<button className="island-universe-close" onClick={onClose}>×</button>
|
||||
|
||||
<div className="island-universe-header">
|
||||
<h1>🌌 Island Universe</h1>
|
||||
<p>{islands.length} islands floating in virtual space</p>
|
||||
<h1>🌌 {t('universe.title')}</h1>
|
||||
<p>{t('universe.islandsCount', { count: islands.length })}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
@ -200,15 +202,15 @@ export default function IslandUniverse({ onClose, onIslandClick }) {
|
|||
{hoveredIsland && (
|
||||
<div className="island-universe-tooltip">
|
||||
<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">
|
||||
{hoveredIsland.content_count} posts • {hoveredIsland.terrain_type}
|
||||
{hoveredIsland.content_count} {t('universe.posts')} • {hoveredIsland.terrain_type}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="island-universe-help">
|
||||
<p>🖱️ Hover over islands to see details • Click to visit</p>
|
||||
<p>🖱️ {t('universe.help')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import React, { useEffect, useRef, useState } from 'react';
|
|||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.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 { trackEvent } from "../../utils/analytics";
|
||||
|
||||
|
|
@ -31,6 +32,9 @@ export default function IslandViewer({ island, onClose }) {
|
|||
const [usernameSaving, setUsernameSaving] = useState(false);
|
||||
const [usernameError, setUsernameError] = useState("");
|
||||
const [usernameInfo, setUsernameInfo] = useState("");
|
||||
const [timezone, setTimezone] = useState(getStoredTimezone());
|
||||
const [timezoneSaving, setTimezoneSaving] = useState(false);
|
||||
const [timezoneInfo, setTimezoneInfo] = useState("");
|
||||
const islandUsername = localUsername || island?.username || "";
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -340,6 +344,61 @@ export default function IslandViewer({ island, onClose }) {
|
|||
{uploadInfo ? <div className="island-profile-info">{uploadInfo}</div> : null}
|
||||
{usernameError ? <div className="island-profile-error">{usernameError}</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>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "../../auth/AuthContext";
|
||||
import { registerUser, updateProfileUsername, fetchFriendRequests } from "../../api/client";
|
||||
import AuthToast from "../Auth/AuthToast";
|
||||
|
|
@ -6,7 +7,8 @@ import { promptInstall, isRunningAsPWA, canInstall } from '../../pwaInstall';
|
|||
import { trackEvent } from "../../utils/analytics";
|
||||
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";
|
||||
|
||||
function initialLetter(name = "") {
|
||||
|
|
@ -56,6 +58,7 @@ function parseMergedParams() {
|
|||
}
|
||||
|
||||
export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick, onUserIsland }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const {
|
||||
loading,
|
||||
authenticated,
|
||||
|
|
@ -75,6 +78,58 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
|||
clearInfo,
|
||||
} = 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 [mode, setMode] = useState("login");
|
||||
const [showUsernameModal, setShowUsernameModal] = useState(false);
|
||||
|
|
@ -140,7 +195,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
|||
const next = (desiredUsername || "").trim();
|
||||
const re = /^[A-Za-z0-9_.]{3,24}$/;
|
||||
if (!re.test(next) || next.includes("@")) {
|
||||
setUsernameError("Use 3-24 letters, numbers, _ or .");
|
||||
setUsernameError(t('auth.usernameHint'));
|
||||
return;
|
||||
}
|
||||
setUsernameSaving(true);
|
||||
|
|
@ -150,13 +205,13 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
|||
setUsernameSaving(false);
|
||||
if (!res.ok) {
|
||||
if (res.status === 409) {
|
||||
setUsernameError("Username already taken.");
|
||||
setUsernameError(t('auth.usernameTaken'));
|
||||
} else {
|
||||
setUsernameError(res.text || "Could not update username.");
|
||||
setUsernameError(res.text || t('auth.usernameUpdateError'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
setUsernameInfo("Username saved.");
|
||||
setUsernameInfo(t('auth.usernameSaved'));
|
||||
setShowUsernameModal(false);
|
||||
await restore();
|
||||
};
|
||||
|
|
@ -172,8 +227,15 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
|||
return;
|
||||
}
|
||||
const loadRequests = async () => {
|
||||
try {
|
||||
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();
|
||||
// Refresh every 30 seconds
|
||||
|
|
@ -243,7 +305,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
|||
const detail = e?.detail || {};
|
||||
const msg =
|
||||
(detail.message || "").toString().trim() ||
|
||||
"Please sign in or create a free account to continue.";
|
||||
t('auth.pleaseSignIn');
|
||||
setAuthNotice(msg);
|
||||
setMode(detail.mode === "signup" ? "signup" : "login");
|
||||
setShowAuth(true);
|
||||
|
|
@ -307,7 +369,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
|||
|
||||
if (truthyParam(verified)) {
|
||||
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");
|
||||
setShowAuth(true);
|
||||
|
||||
|
|
@ -315,7 +377,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
|||
if (prefill) setLoginUser(prefill);
|
||||
} else if (truthyParam(signup)) {
|
||||
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");
|
||||
setShowAuth(true);
|
||||
|
||||
|
|
@ -357,7 +419,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
|||
setSignupInfo("");
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -380,14 +442,14 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
|||
localStorage.setItem(LAST_SIGNUP_KEY, JSON.stringify({ username: u, email: em }));
|
||||
} catch {}
|
||||
|
||||
setSignupInfo("✅ Account created (" + em + "). Check your emails (spam too), then log in.");
|
||||
setSignupInfo("✅ " + t('auth.accountCreatedMsg') + " (" + em + ")");
|
||||
trackEvent("signup_success");
|
||||
setMode("login");
|
||||
setLoginUser(u || em);
|
||||
setLoginPass("");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setSignupError(err.message || "Registration error.");
|
||||
setSignupError(err.message || t('auth.registrationError'));
|
||||
} finally {
|
||||
setSignupLoading(false);
|
||||
}
|
||||
|
|
@ -395,15 +457,15 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
|||
|
||||
const toast = useMemo(() => {
|
||||
if (lastError) {
|
||||
const t = needsEmailVerification ? "warn" : "error";
|
||||
const title = needsEmailVerification ? "Email verification required" : "Login failed";
|
||||
return { type: t, title, message: lastError };
|
||||
const toastType = needsEmailVerification ? "warn" : "error";
|
||||
const title = needsEmailVerification ? t('auth.emailVerificationRequired') : t('auth.loginFailed');
|
||||
return { type: toastType, title, message: lastError };
|
||||
}
|
||||
if (lastInfo) {
|
||||
return { type: "success", title: "", message: lastInfo };
|
||||
}
|
||||
return null;
|
||||
}, [lastError, lastInfo, needsEmailVerification]);
|
||||
}, [lastError, lastInfo, needsEmailVerification, t]);
|
||||
|
||||
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="title-block">
|
||||
<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>
|
||||
|
||||
{/* Theme dots */}
|
||||
<div className="brand-under">
|
||||
<div className="theme-switch">
|
||||
{["dark", "blue", "light"].map((t) => (
|
||||
{["dark", "blue", "light"].map((themeKey) => (
|
||||
<button
|
||||
key={t}
|
||||
key={themeKey}
|
||||
type="button"
|
||||
className={"theme-dot" + (theme === t ? " theme-dot-active" : "")}
|
||||
title={THEME_LABELS[t]}
|
||||
onClick={() => onChangeTheme && onChangeTheme(t)}
|
||||
className={"theme-dot" + (theme === themeKey ? " theme-dot-active" : "")}
|
||||
title={t(`theme.${themeKey}`)}
|
||||
onClick={() => onChangeTheme && onChangeTheme(themeKey)}
|
||||
>
|
||||
{THEME_LABELS[t][0]}
|
||||
{t(`theme.${themeKey}`)[0]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -464,18 +526,18 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
|||
{avatarUrl ? <img src={avatarUrl} alt={username} /> : <span>{initialLetter(username)}</span>}
|
||||
</div>
|
||||
<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>
|
||||
</button>
|
||||
) : (
|
||||
<div className="sw-userchip" title="Guest">
|
||||
<div className="sw-userchip" title={t('common.guest')}>
|
||||
<div className="sw-avatar">
|
||||
<i className="fa-solid fa-user" />
|
||||
</div>
|
||||
<div className="sw-usertext">
|
||||
<div className="sw-userline">{loading ? "Loading…" : "Guest"}</div>
|
||||
<div className="sw-userhandle">anon</div>
|
||||
<div className="sw-userline">{loading ? t('common.loading') : t('common.guest')}</div>
|
||||
<div className="sw-userhandle">{t('common.anon')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -490,19 +552,83 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
|||
className="btn-action btn-action-install"
|
||||
type="button"
|
||||
onClick={() => promptInstall()}
|
||||
title="Install App"
|
||||
title={t('topbar.installApp')}
|
||||
>
|
||||
<i className="fa-solid fa-download"></i>
|
||||
</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 */}
|
||||
{authenticated && (
|
||||
<button
|
||||
className={"btn-action btn-action-friends" + (showFriends ? " is-active" : "")}
|
||||
type="button"
|
||||
onClick={() => setShowFriends(!showFriends)}
|
||||
title="Friends"
|
||||
title={t('topbar.friends')}
|
||||
>
|
||||
<i className="fa-solid fa-user-group"></i>
|
||||
{pendingRequestsCount > 0 && (
|
||||
|
|
@ -524,10 +650,10 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
|||
}
|
||||
}}
|
||||
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>
|
||||
<span className="btn-label">{authenticated ? "Logout" : "Login"}</span>
|
||||
<span className="btn-label">{authenticated ? t('topbar.logout') : t('topbar.login')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -542,14 +668,14 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
|||
className={"auth-tab" + (mode === "login" ? " auth-tab-active" : "")}
|
||||
onClick={() => setMode("login")}
|
||||
>
|
||||
Login
|
||||
{t('auth.login')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={"auth-tab" + (mode === "signup" ? " auth-tab-active" : "")}
|
||||
onClick={() => setMode("signup")}
|
||||
>
|
||||
Sign up
|
||||
{t('auth.signup')}
|
||||
</button>
|
||||
<button type="button" className="auth-close" onClick={closeModal}>
|
||||
✕
|
||||
|
|
@ -567,7 +693,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
|||
{mode === "login" ? (
|
||||
<form className="auth-form" onSubmit={handleLoginSubmit}>
|
||||
<label>
|
||||
Username or email
|
||||
{t('auth.usernameOrEmail')}
|
||||
<input
|
||||
type="text"
|
||||
value={loginUser}
|
||||
|
|
@ -576,7 +702,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
|||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
{t('auth.password')}
|
||||
<input
|
||||
type="password"
|
||||
value={loginPass}
|
||||
|
|
@ -589,21 +715,21 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
|||
className="auth-link"
|
||||
onClick={() => window.open(resetPasswordUrl, "_blank", "noopener,noreferrer")}
|
||||
>
|
||||
Forgot password?
|
||||
{t('auth.forgotPassword')}
|
||||
</button>
|
||||
<button type="submit" className="btn-primary btn-full" disabled={loading}>
|
||||
{loading ? "..." : "Sign in"}
|
||||
{loading ? "..." : t('auth.signIn')}
|
||||
</button>
|
||||
|
||||
{needsEmailVerification ? (
|
||||
<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>
|
||||
) : null}
|
||||
|
||||
{ssoEnabled ? (
|
||||
<>
|
||||
<div className="auth-divider">or continue with</div>
|
||||
<div className="auth-divider">{t('auth.orContinueWith')}</div>
|
||||
<div className="auth-oauth">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -615,7 +741,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
|||
}}
|
||||
>
|
||||
<i className="fa-brands fa-google"></i>
|
||||
<span>Continue with Google</span>
|
||||
<span>{t('auth.continueWithGoogle')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -627,7 +753,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
|||
}}
|
||||
>
|
||||
<i className="fa-brands fa-facebook-f"></i>
|
||||
<span>Continue with Facebook</span>
|
||||
<span>{t('auth.continueWithFacebook')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -638,31 +764,31 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
|||
<form className="auth-form" onSubmit={handleSignupSubmit}>
|
||||
<div className="auth-row">
|
||||
<label>
|
||||
First name
|
||||
{t('auth.firstName')}
|
||||
<input type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Last name
|
||||
{t('auth.lastName')}
|
||||
<input type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
Username
|
||||
{t('auth.username')}
|
||||
<input type="text" value={regUser} onChange={(e) => setRegUser(e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Email
|
||||
{t('auth.email')}
|
||||
<input type="email" value={regEmail} onChange={(e) => setRegEmail(e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
{t('auth.password')}
|
||||
<input type="password" value={regPass} onChange={(e) => setRegPass(e.target.value)} />
|
||||
</label>
|
||||
|
||||
{signupError ? <div className="auth-notice auth-notice-error">{signupError}</div> : null}
|
||||
|
||||
<button type="submit" className="btn-primary btn-full" disabled={signupLoading}>
|
||||
{signupLoading ? "..." : "Create account"}
|
||||
{signupLoading ? "..." : t('auth.createAccount')}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
|
@ -674,16 +800,16 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
|||
<div className="auth-modal-backdrop" onClick={() => {}}>
|
||||
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<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 className="auth-notice auth-notice-info">
|
||||
Pick a public username to complete your profile.
|
||||
{t('auth.pickUsername')}
|
||||
</div>
|
||||
{usernameError ? <div className="auth-notice auth-notice-error">{usernameError}</div> : null}
|
||||
{usernameInfo ? <div className="auth-notice auth-notice-success">{usernameInfo}</div> : null}
|
||||
<form className="auth-form" onSubmit={handleUsernameSave}>
|
||||
<label>
|
||||
Username
|
||||
{t('auth.username')}
|
||||
<input
|
||||
type="text"
|
||||
value={desiredUsername}
|
||||
|
|
@ -692,7 +818,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick,
|
|||
/>
|
||||
</label>
|
||||
<button type="submit" className="btn-primary btn-full" disabled={usernameSaving}>
|
||||
{usernameSaving ? "Saving..." : "Save username"}
|
||||
{usernameSaving ? t('auth.savingUsername') : t('auth.saveUsername')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "../../styles/mapControls.css";
|
||||
|
||||
function getCurrentTheme() {
|
||||
|
|
@ -16,6 +17,7 @@ function getCurrentTheme() {
|
|||
}
|
||||
|
||||
export default function MapControls({ onMySpot, onPostWire }) {
|
||||
const { t } = useTranslation();
|
||||
const theme = getCurrentTheme();
|
||||
|
||||
return (
|
||||
|
|
@ -25,7 +27,7 @@ export default function MapControls({ onMySpot, onPostWire }) {
|
|||
className="map-controls__button map-controls__button--myspot"
|
||||
onClick={onMySpot}
|
||||
>
|
||||
My spot
|
||||
{t('controls.mySpot')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
|
@ -33,7 +35,7 @@ export default function MapControls({ onMySpot, onPostWire }) {
|
|||
className="map-controls__button map-controls__button--wire"
|
||||
onClick={onPostWire}
|
||||
>
|
||||
Put a wire
|
||||
{t('controls.putWire')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
updatePostVisibility,
|
||||
} from "../../api/client";
|
||||
import { trackEvent } from "../../utils/analytics";
|
||||
import { formatRelativeTime } from "../../utils/timezone";
|
||||
|
||||
const avatarCache = new Map();
|
||||
const avatarInflight = new Map();
|
||||
|
|
@ -62,7 +63,12 @@ function refreshMarkerFromPost(map, postId, fresh) {
|
|||
const list = ref?.current || [];
|
||||
for (const item of list) {
|
||||
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 || {}) };
|
||||
if (Number.isFinite(oldTruth) && !Number.isFinite(Number(fresh?.truth_score))) {
|
||||
nextPost.truth_score = oldTruth;
|
||||
}
|
||||
item.post = nextPost;
|
||||
if (!item.el) continue;
|
||||
item.el.__post = nextPost;
|
||||
|
|
@ -319,7 +325,9 @@ function renderComments(container, items) {
|
|||
.map((c) => {
|
||||
const who = (c?.username || "anon").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 `
|
||||
<div class="sw-chat-bubble">
|
||||
<div class="sw-chat-head">
|
||||
|
|
@ -714,25 +722,6 @@ function collectGalleryImages(post) {
|
|||
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) {
|
||||
const published =
|
||||
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) {
|
||||
// First check if we have an AI-verified score
|
||||
const raw = Number(
|
||||
post?.truth_score ??
|
||||
post?.truthScore ??
|
||||
|
|
@ -768,7 +874,8 @@ function truthScore(post) {
|
|||
if (Number.isFinite(raw)) {
|
||||
return clamp(Math.round(raw), 0, 100);
|
||||
}
|
||||
return 65;
|
||||
// Calculate initial score from available data
|
||||
return calculateInitialTruthScore(post);
|
||||
}
|
||||
|
||||
function truthColor(score) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { loadAuth } from "../../auth/authStorage";
|
||||
import {
|
||||
fetchPostLikeState,
|
||||
|
|
@ -32,6 +33,8 @@ function requestAuth(message) {
|
|||
trackEvent("auth_required", { context: "wall", mode: "login" });
|
||||
}
|
||||
|
||||
// Time formatting is now handled in the component with translations
|
||||
|
||||
function formatRelativeTime(createdAt) {
|
||||
try {
|
||||
if (!createdAt) return "now";
|
||||
|
|
@ -132,6 +135,7 @@ async function getAvatarForUser(name) {
|
|||
}
|
||||
|
||||
export default function PostCard({ post, selected, onSelect, onPostDeleted, onPostUpdated }) {
|
||||
const { t } = useTranslation();
|
||||
const [liked, setLiked] = useState(false);
|
||||
const [authed, setAuthed] = useState(isAuthed());
|
||||
const [activeImageIndex, setActiveImageIndex] = useState(0);
|
||||
|
|
@ -411,7 +415,7 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo
|
|||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!authed) {
|
||||
requestAuth("Please sign in or create a free account to like this post.");
|
||||
requestAuth(t('post.signInToLike'));
|
||||
return;
|
||||
}
|
||||
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
|
||||
type="button"
|
||||
|
|
@ -436,14 +440,14 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo
|
|||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!authed) {
|
||||
requestAuth("Please sign in or create a free account to comment.");
|
||||
requestAuth(t('post.signInToComment'));
|
||||
return;
|
||||
}
|
||||
trackEvent("post_comment", { post_id: postId, post_title: title?.slice(0, 100), post_category: cat });
|
||||
if (onSelect) onSelect(post);
|
||||
}}
|
||||
>
|
||||
<i className="fa-regular fa-comment" /> Comment
|
||||
<i className="fa-regular fa-comment" /> {t('post.comment')}
|
||||
</button>
|
||||
<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>
|
||||
|
||||
{isAuthor ? (
|
||||
|
|
@ -483,7 +487,7 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo
|
|||
setShowEditDelete(false);
|
||||
}}
|
||||
>
|
||||
<i className="fa-solid fa-lock" /> Visibility
|
||||
<i className="fa-solid fa-lock" /> {t('post.visibility')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -507,7 +511,7 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo
|
|||
{isAuthor && showPrivacy ? (
|
||||
<div className="sw-wall-privacy" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="sw-wall-privacy-row">
|
||||
<label>Post</label>
|
||||
<label>{t('common.post')}</label>
|
||||
<select
|
||||
value={postVisibility}
|
||||
onChange={(e) => {
|
||||
|
|
@ -517,17 +521,17 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo
|
|||
if (next !== "custom") setPostUsers("");
|
||||
}}
|
||||
>
|
||||
<option value="public">Public</option>
|
||||
<option value="friends">Friends Only</option>
|
||||
<option value="private">Private</option>
|
||||
<option value="group">Group</option>
|
||||
<option value="custom">Custom</option>
|
||||
<option value="public">{t('post.public')}</option>
|
||||
<option value="friends">{t('post.friendsOnly')}</option>
|
||||
<option value="private">{t('post.private')}</option>
|
||||
<option value="group">{t('post.group')}</option>
|
||||
<option value="custom">{t('post.custom')}</option>
|
||||
</select>
|
||||
</div>
|
||||
{postVisibility === "group" ? (
|
||||
<input
|
||||
className="sw-wall-privacy-input"
|
||||
placeholder="Group ID"
|
||||
placeholder={t('post.groupId')}
|
||||
value={postGroupId}
|
||||
onChange={(e) => setPostGroupId(e.target.value)}
|
||||
/>
|
||||
|
|
@ -535,7 +539,7 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo
|
|||
{postVisibility === "custom" ? (
|
||||
<input
|
||||
className="sw-wall-privacy-input"
|
||||
placeholder="Usernames (comma separated)"
|
||||
placeholder={t('post.usernamesComma')}
|
||||
value={postUsers}
|
||||
onChange={(e) => setPostUsers(e.target.value)}
|
||||
/>
|
||||
|
|
@ -546,7 +550,7 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo
|
|||
checked={applyToImages}
|
||||
onChange={(e) => setApplyToImages(e.target.checked)}
|
||||
/>
|
||||
Apply same visibility to images
|
||||
{t('post.applyToImages')}
|
||||
</label>
|
||||
{privacyError ? <div className="sw-wall-privacy-error">{privacyError}</div> : null}
|
||||
<button
|
||||
|
|
@ -573,12 +577,12 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo
|
|||
await updatePostVisibility(postId, payload, token);
|
||||
setPrivacySaving(false);
|
||||
} catch (err) {
|
||||
setPrivacyError("Failed to update visibility.");
|
||||
setPrivacyError(t('post.visibilityError'));
|
||||
setPrivacySaving(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{privacySaving ? "Saving..." : "Save"}
|
||||
{privacySaving ? t('post.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -592,14 +596,14 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo
|
|||
className="sw-wall-btn"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
<i className="fa-solid fa-pen" /> Edit
|
||||
<i className="fa-solid fa-pen" /> {t('common.edit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="sw-wall-btn sw-wall-btn-danger"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
>
|
||||
<i className="fa-solid fa-trash" /> Delete
|
||||
<i className="fa-solid fa-trash" /> {t('common.delete')}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
|
|
@ -609,13 +613,13 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo
|
|||
<input
|
||||
type="text"
|
||||
className="sw-wall-edit-input"
|
||||
placeholder="Title"
|
||||
placeholder={t('post.title')}
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
className="sw-wall-edit-textarea"
|
||||
placeholder="Description"
|
||||
placeholder={t('post.description')}
|
||||
value={editSnippet}
|
||||
onChange={(e) => setEditSnippet(e.target.value)}
|
||||
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
|
||||
type="button"
|
||||
className="sw-wall-btn"
|
||||
onClick={() => setIsEditing(false)}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -654,7 +658,7 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo
|
|||
|
||||
{showDeleteConfirm ? (
|
||||
<div className="sw-wall-delete-confirm">
|
||||
<p>Delete this post?</p>
|
||||
<p>{t('post.deletePost')}</p>
|
||||
<div className="sw-wall-edit-actions">
|
||||
<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
|
||||
type="button"
|
||||
className="sw-wall-btn"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PostCard from "./PostCard";
|
||||
|
||||
function getKmFromPost(post) {
|
||||
|
|
@ -6,6 +7,7 @@ function getKmFromPost(post) {
|
|||
}
|
||||
|
||||
export default function PostList({ posts, loading, error, selectedPost, onSelectPost, onPostDeleted, onPostUpdated }) {
|
||||
const { t } = useTranslation();
|
||||
const [kmFilter, setKmFilter] = useState(1000);
|
||||
const [visibleCount, setVisibleCount] = useState(6);
|
||||
const listRef = useRef(null);
|
||||
|
|
@ -46,7 +48,7 @@ export default function PostList({ posts, loading, error, selectedPost, onSelect
|
|||
<div className="post-list-header">
|
||||
<div className="km-filter">
|
||||
<label>
|
||||
Rayon : <span>{kmFilter} km</span>
|
||||
{t('postlist.radius')} <span>{kmFilter} {t('postlist.km')}</span>
|
||||
</label>
|
||||
<input
|
||||
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}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { recoSearch } from "../../services/recoSearch";
|
||||
import "../../styles/smartSearchBar.css";
|
||||
import { trackEvent } from "../../utils/analytics";
|
||||
|
|
@ -62,12 +63,12 @@ function truncateText(value, maxLen) {
|
|||
* - onPlaceTourWire() // keep same function (place/tour/wire)
|
||||
*/
|
||||
export default function SmartSearchBar({
|
||||
placeholder = "Search places, regions, posts…",
|
||||
onPick,
|
||||
onSearch,
|
||||
onMySpot,
|
||||
onPlaceTourWire,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [q, setQ] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -276,17 +277,17 @@ export default function SmartSearchBar({
|
|||
setTimeout(() => setFocused(false), 120);
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={placeholder}
|
||||
aria-label="Search"
|
||||
placeholder={t('map.searchPlaceholder')}
|
||||
aria-label={t('common.search')}
|
||||
/>
|
||||
|
||||
{q && (
|
||||
<button
|
||||
type="button"
|
||||
className="sw-search__clearBtn"
|
||||
title="Clear search"
|
||||
title={t('map.clearSearch')}
|
||||
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">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
|
|
@ -298,7 +299,7 @@ export default function SmartSearchBar({
|
|||
<button
|
||||
type="button"
|
||||
className="sw-search__iconBtn"
|
||||
title="My spot"
|
||||
title={t('map.mySpot')}
|
||||
onClick={() => {
|
||||
trackEvent("search_my_spot");
|
||||
onMySpot && onMySpot();
|
||||
|
|
@ -311,7 +312,7 @@ export default function SmartSearchBar({
|
|||
|
||||
{showList && (
|
||||
<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 && items.map((it, idx) => (
|
||||
|
|
@ -328,7 +329,7 @@ export default function SmartSearchBar({
|
|||
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">
|
||||
{truncateText((it.kind ? it.kind : "result") + (it.subtitle ? " • " + it.subtitle : ""), 120)}
|
||||
</div>
|
||||
|
|
@ -343,7 +344,7 @@ export default function SmartSearchBar({
|
|||
))}
|
||||
|
||||
{!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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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..."
|
||||
}
|
||||
}
|
||||
|
|
@ -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..."
|
||||
}
|
||||
}
|
||||
|
|
@ -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..."
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import "@fortawesome/fontawesome-free/css/all.min.css"; // local via npm (no CDN
|
|||
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "./i18n"; // Initialize i18n before App
|
||||
import App from "./App.jsx";
|
||||
|
||||
import "./styles/base.css";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
.topbar{
|
||||
position: sticky; top:0; z-index:100;
|
||||
position: sticky; top:0; z-index:1000;
|
||||
width:100%;
|
||||
padding: .55rem .4rem .55rem 1.2rem;
|
||||
display: flex;
|
||||
|
|
@ -763,7 +763,7 @@ body[data-theme="light"] .sw-avatar{
|
|||
position: fixed;
|
||||
top: 70px;
|
||||
right: 16px;
|
||||
z-index: 200;
|
||||
z-index: 10000005;
|
||||
animation: friendsSlideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
|
|
@ -791,3 +791,309 @@ body[data-theme="light"] .sw-avatar{
|
|||
}
|
||||
}
|
||||
/* 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 */
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue