diff --git a/src/App.jsx b/src/App.jsx index 6a56178..09fa25e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -30,6 +30,24 @@ export default function App() { setWallError(next.error || ""); }, []); + const handlePostDeleted = useCallback((postId) => { + setWallPosts((prev) => prev.filter((p) => (p.id ?? p._id) !== postId)); + if (selectedPost && (selectedPost.id ?? selectedPost._id) === postId) { + setSelectedPost(null); + } + }, [selectedPost]); + + const handlePostUpdated = useCallback((updatedPost) => { + if (!updatedPost) return; + setWallPosts((prev) => + prev.map((p) => + (p.id ?? p._id) === (updatedPost.id ?? updatedPost._id) + ? { ...p, ...updatedPost } + : p + ) + ); + }, []); + // charge le thème au démarrage useEffect(() => { if (typeof window === "undefined") return; @@ -186,6 +204,8 @@ export default function App() { error={wallError} selectedPost={selectedPost} onSelectPost={setSelectedPost} + onPostDeleted={handlePostDeleted} + onPostUpdated={handlePostUpdated} /> {/* CHAT PANEL SUPPRIMÉ */} diff --git a/src/components/Friends/Friends.css b/src/components/Friends/Friends.css index 7f39724..896cd6b 100644 --- a/src/components/Friends/Friends.css +++ b/src/components/Friends/Friends.css @@ -225,3 +225,117 @@ background: rgba(248,113,113,0.25); border-color: rgba(248,113,113,0.7); } + +/* Add Friend Form */ +.friends-add-form { + display: flex; + gap: .25rem; + margin-bottom: .4rem; +} + +.friends-add-input { + flex: 1; + padding: .35rem .5rem; + border-radius: 8px; + border: 1px solid rgba(71, 85, 105, 0.5); + background: rgba(15, 23, 42, 0.8); + color: #e5e7eb; + font-size: .75rem; +} + +.friends-add-input::placeholder { + color: #64748b; +} + +.friends-add-input:focus { + outline: none; + border-color: rgba(56, 189, 248, 0.6); +} + +.friends-add-btn { + width: 32px; + height: 32px; + border-radius: 8px; + border: 1px solid rgba(56, 189, 248, 0.4); + background: rgba(56, 189, 248, 0.15); + color: #7dd3fc; + font-size: .8rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.friends-add-btn:hover:not(:disabled) { + background: rgba(56, 189, 248, 0.25); + border-color: rgba(56, 189, 248, 0.7); +} + +.friends-add-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.friends-add-error { + font-size: .7rem; + color: #fca5a5; + padding: .2rem .3rem; + margin-bottom: .3rem; +} + +.friends-add-success { + font-size: .7rem; + color: #4ade80; + padding: .2rem .3rem; + margin-bottom: .3rem; +} + +/* Suggestions dropdown */ +.friends-add-wrap { + position: relative; + margin-bottom: .4rem; +} + +.friends-suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: rgba(15, 23, 42, 0.98); + border: 1px solid rgba(56, 189, 248, 0.4); + border-radius: 8px; + margin-top: 4px; + max-height: 180px; + overflow-y: auto; + z-index: 10; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); +} + +.friends-suggestion-item { + display: flex; + align-items: center; + gap: .4rem; + padding: .4rem .5rem; + cursor: pointer; + font-size: .75rem; + color: #e5e7eb; + transition: background 0.15s; +} + +.friends-suggestion-item:hover { + background: rgba(56, 189, 248, 0.15); +} + +.friends-suggestion-item .friend-avatar { + width: 24px; + height: 24px; + font-size: .65rem; +} + +.friends-suggestions-loading { + font-size: .7rem; + color: #64748b; + padding: .3rem .4rem; + text-align: center; +} diff --git a/src/components/Friends/FriendsList.jsx b/src/components/Friends/FriendsList.jsx index 250c137..d69c316 100644 --- a/src/components/Friends/FriendsList.jsx +++ b/src/components/Friends/FriendsList.jsx @@ -1,12 +1,14 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { fetchFriends, fetchFriendRequests, acceptFriendRequest, rejectFriendRequest, removeFriend, + sendFriendRequest, } from '../../api/client'; import { useAuth } from '../../auth/AuthContext'; +import { recoSearch } from '../../services/recoSearch'; import './Friends.css'; export default function FriendsList({ onSelectUser, onClose }) { @@ -16,6 +18,19 @@ export default function FriendsList({ onSelectUser, onClose }) { const [requests, setRequests] = useState([]); const [loading, setLoading] = useState(true); + // Add friend state + const [addUsername, setAddUsername] = useState(''); + const [addLoading, setAddLoading] = useState(false); + const [addError, setAddError] = useState(''); + const [addSuccess, setAddSuccess] = useState(''); + + // Autocomplete state + const [suggestions, setSuggestions] = useState([]); + const [showSuggestions, setShowSuggestions] = useState(false); + const [suggestionsLoading, setSuggestionsLoading] = useState(false); + const searchTimeoutRef = useRef(null); + const inputRef = useRef(null); + useEffect(() => { if (!authenticated) return; @@ -33,6 +48,58 @@ export default function FriendsList({ onSelectUser, onClose }) { loadData(); }, [authenticated]); + // Search for users with debounce + const searchUsers = async (query) => { + if (!query || query.length < 2) { + setSuggestions([]); + setShowSuggestions(false); + return; + } + + 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); + } catch { + setSuggestions([]); + } + setSuggestionsLoading(false); + }; + + const handleInputChange = (e) => { + const value = e.target.value; + setAddUsername(value); + + // Debounce search + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + searchTimeoutRef.current = setTimeout(() => { + searchUsers(value); + }, 300); + }; + + const handleSelectSuggestion = (username) => { + setAddUsername(username); + setSuggestions([]); + setShowSuggestions(false); + inputRef.current?.focus(); + }; + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + }; + }, []); + const handleAccept = async (username) => { const res = await acceptFriendRequest(username); if (res?.ok) { @@ -58,6 +125,28 @@ export default function FriendsList({ onSelectUser, onClose }) { } }; + const handleAddFriend = async (e) => { + e.preventDefault(); + const uname = addUsername.trim(); + if (!uname) return; + + setAddLoading(true); + setAddError(''); + setAddSuccess(''); + + const res = await sendFriendRequest(uname); + setAddLoading(false); + + if (res?.ok) { + setAddSuccess(`Friend request sent to ${uname}`); + setAddUsername(''); + setTimeout(() => setAddSuccess(''), 3000); + } else { + setAddError(res?.error || res?.text || 'Could not send request'); + setTimeout(() => setAddError(''), 4000); + } + }; + if (!authenticated) { return (
@@ -92,6 +181,53 @@ export default function FriendsList({ onSelectUser, onClose }) {
+ {/* Add Friend Form */} +
+
+ suggestions.length > 0 && setShowSuggestions(true)} + onBlur={() => setTimeout(() => setShowSuggestions(false), 200)} + disabled={addLoading} + autoComplete="off" + /> + + + + {/* Suggestions dropdown */} + {showSuggestions && suggestions.length > 0 && ( +
+ {suggestions.map((s) => ( +
handleSelectSuggestion(s.title || s.raw?.username || s.id)} + > +
+ {(s.title || s.id)[0]?.toUpperCase() || '?'} +
+ {s.title || s.raw?.username || s.id} +
+ ))} +
+ )} + {suggestionsLoading &&
Searching...
} +
+ {addError &&
{addError}
} + {addSuccess &&
{addSuccess}
} +
{loading ? (
Loading...
diff --git a/src/components/Layout/TopBar.jsx b/src/components/Layout/TopBar.jsx index 8286878..16a3ec3 100644 --- a/src/components/Layout/TopBar.jsx +++ b/src/components/Layout/TopBar.jsx @@ -1,9 +1,10 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { useAuth } from "../../auth/AuthContext"; -import { registerUser, updateProfileUsername } from "../../api/client"; +import { registerUser, updateProfileUsername, fetchFriendRequests } from "../../api/client"; import AuthToast from "../Auth/AuthToast"; import { promptInstall, isRunningAsPWA, canInstall } from '../../pwaInstall'; import { trackEvent } from "../../utils/analytics"; +import FriendsList from "../Friends/FriendsList"; const THEME_LABELS = { dark: "Dark", blue: "Blue", light: "Light" }; const LAST_SIGNUP_KEY = "sociowire:lastSignup"; @@ -77,6 +78,8 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick, const [showAuth, setShowAuth] = useState(false); const [mode, setMode] = useState("login"); const [showUsernameModal, setShowUsernameModal] = useState(false); + const [showFriends, setShowFriends] = useState(false); + const [pendingRequestsCount, setPendingRequestsCount] = useState(0); const [desiredUsername, setDesiredUsername] = useState(""); const [usernameError, setUsernameError] = useState(""); const [usernameInfo, setUsernameInfo] = useState(""); @@ -162,6 +165,22 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick, if (authenticated) setShowAuth(false); }, [authenticated]); + // Fetch pending friend requests count + useEffect(() => { + if (!authenticated) { + setPendingRequestsCount(0); + return; + } + const loadRequests = async () => { + const res = await fetchFriendRequests(); + setPendingRequestsCount(res?.requests?.length || 0); + }; + loadRequests(); + // Refresh every 30 seconds + const interval = setInterval(loadRequests, 30000); + return () => clearInterval(interval); + }, [authenticated]); + useEffect(() => { if (!authenticated) { setStableUsername(""); @@ -477,6 +496,21 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick, )} + {/* Friends button - only when authenticated */} + {authenticated && ( + + )} + {/* Login/Logout button */}
) : null} + + {/* Friends Panel - dropdown from topbar */} + {showFriends && ( +
+ { + setShowFriends(false); + if (onUserIsland) onUserIsland(uname); + }} + onClose={() => setShowFriends(false)} + /> +
+ )} ); } diff --git a/src/components/Map/MapView.jsx b/src/components/Map/MapView.jsx index 65fd980..3ec8adc 100644 --- a/src/components/Map/MapView.jsx +++ b/src/components/Map/MapView.jsx @@ -1275,8 +1275,9 @@ export default function MapView({ const variants = data && data.variants ? data.variants : null; const pickRef = (v) => { if (!v) return ""; + if (v.url) return v.url; if (v.asset_id) return `asset://${v.asset_id}`; - return v.url || ""; + return ""; }; const smallRef = variants ? pickRef(variants.small) : ""; const mediumRef = variants ? pickRef(variants.medium) : ""; diff --git a/src/components/Posts/PostList.jsx b/src/components/Posts/PostList.jsx index 759d540..3d9b602 100644 --- a/src/components/Posts/PostList.jsx +++ b/src/components/Posts/PostList.jsx @@ -5,7 +5,7 @@ function getKmFromPost(post) { return post.km ?? post.distance_km ?? post.distanceKm ?? post.dist_km ?? null; } -export default function PostList({ posts, loading, error, selectedPost, onSelectPost }) { +export default function PostList({ posts, loading, error, selectedPost, onSelectPost, onPostDeleted, onPostUpdated }) { const [kmFilter, setKmFilter] = useState(1000); const [visibleCount, setVisibleCount] = useState(6); const listRef = useRef(null); @@ -66,6 +66,8 @@ export default function PostList({ posts, loading, error, selectedPost, onSelect post={p} selected={selectedPost && (selectedPost.id ?? selectedPost._id) === (p.id ?? p._id)} onSelect={onSelectPost} + onPostDeleted={onPostDeleted} + onPostUpdated={onPostUpdated} /> ))} diff --git a/src/styles/topbar.css b/src/styles/topbar.css index 4ccc2d8..cfa3b72 100644 --- a/src/styles/topbar.css +++ b/src/styles/topbar.css @@ -715,3 +715,79 @@ body[data-theme="light"] .sw-avatar{ .brand-under{ padding-left: 0; } } /* SW_TOPBAR_ONE_LINE_FIX:END */ + +/* SW_FRIENDS_BUTTON:BEGIN */ +.btn-action-friends{ + background: linear-gradient(135deg, #f59e0b, #d97706); + border-color: rgba(245, 158, 11, 0.5); + color: white; +} + +.btn-action-friends:hover{ + background: linear-gradient(135deg, #fbbf24, #f59e0b); + box-shadow: 0 4px 16px rgba(245, 158, 11, 0.6); + border-color: #fbbf24; +} + +.btn-action-friends.is-active{ + background: linear-gradient(135deg, #fbbf24, #f59e0b); + box-shadow: 0 4px 16px rgba(245, 158, 11, 0.6); + border-color: #fbbf24; +} + +.btn-action-friends{ + position: relative; +} + +.friends-badge-count{ + position: absolute; + top: -6px; + right: -6px; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 999px; + background: #ef4444; + color: white; + font-size: 0.65rem; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid rgba(15, 23, 42, 0.9); + box-shadow: 0 2px 6px rgba(239, 68, 68, 0.5); +} + +/* Friends dropdown panel positioning */ +.friends-dropdown-wrap{ + position: fixed; + top: 70px; + right: 16px; + z-index: 200; + animation: friendsSlideIn 0.2s ease-out; +} + +@keyframes friendsSlideIn{ + from{ + opacity: 0; + transform: translateY(-10px); + } + to{ + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 640px){ + .friends-dropdown-wrap{ + top: auto; + bottom: 80px; + right: 10px; + left: 10px; + } + .friends-dropdown-wrap .friends-panel{ + max-width: none; + width: 100%; + } +} +/* SW_FRIENDS_BUTTON:END */