feat: Islands v116 - Search bar fixes and island post improvements

- Fix search bar: single X button, form submit for Enter key
- Fix search clearing to properly refetch normal posts
- Hide native browser search cancel button with CSS
- Add island post support with island_username context
- Interactive island terrain with click-to-place posts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-01-26 00:51:57 +00:00
parent 90e9f79fcd
commit 3e780268ba
9 changed files with 434 additions and 154 deletions

View File

@ -1,7 +1,7 @@
{
"name": "sociowire-frontend",
"private": true,
"version": "0.0.116",
"version": "0.0.117",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",

View File

@ -2200,10 +2200,10 @@ export default function MapView({
<button
type="button"
className="sw-search__iconBtn"
title="New Post"
onClick={() => handleOpenCreate && handleOpenCreate()}
title="My Position"
onClick={handleFlyToMe}
>
<i className="fa-solid fa-plus"></i>
<i className="fa-solid fa-location-crosshairs"></i>
</button>
<button
type="button"

View File

@ -1343,71 +1343,91 @@ export function usePostsEngine({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewParams, mapReady, mainFilter, mainFilters.join(","), subFilter, subFilters.join(","), contentFilter, mapRef, timeHours, expandedElRef, refreshVisibleAndMarkers, computeVisible, onAutoWidenTimeFilter, syncMarkers, username]);
// Quand searchQuery change, chercher via API dans le viewport actuel
// Track previous search to detect when search is cleared
const prevSearchRef = useRef("");
useEffect(() => {
const q = (searchQuery || "").trim();
if (!q) return;
const prevQ = prevSearchRef.current;
prevSearchRef.current = q;
if (!q) {
// Search was cleared - force refetch of normal posts
if (prevQ) {
// Had a search before, now cleared -> trigger full refetch
console.log('[usePostsEngine] Search cleared, forcing refetch');
searchResultsRef.current = false;
allPostsRef.current = [];
replacePoolRef.current = true;
const map = mapRef.current;
if (map) {
map.__swForceFetchAt = Date.now();
setTimeout(() => {
try { map.fire("moveend"); } catch {}
}, 50);
}
}
return;
}
// Mark that we're showing search results
searchResultsRef.current = true;
let cancelled = false;
async function runSearch() {
try {
const map = mapRef.current;
let lat;
let lon;
let radiusKm;
try {
const view = map ? getViewFromMap(map) : null;
if (view?.center) {
lon = view.center[0];
lat = view.center[1];
} else {
const center = map?.getCenter?.();
lat = center?.lat;
lon = center?.lng;
}
if (view?.radiusKm) {
radiusKm = Math.min(Math.max(view.radiusKm * 1.2, 25), 2500);
}
} catch {}
if (!map) return;
// Obtenir le viewport actuel
const center = map.getCenter();
const bounds = map.getBounds();
let radiusKm = 50; // default
if (bounds) {
const sw = bounds.getSouthWest();
const ne = bounds.getNorthEast();
// Calculer le rayon approximatif du viewport
const latDiff = Math.abs(ne.lat - sw.lat);
const lonDiff = Math.abs(ne.lng - sw.lng);
radiusKm = Math.max(latDiff, lonDiff) * 111 / 2; // ~111km per degree
radiusKm = Math.min(Math.max(radiusKm, 10), 2000);
}
const data = await fetchUnifiedSearch(q, {
type: "posts",
lat,
lon,
lat: center?.lat,
lon: center?.lng,
radius_km: radiusKm,
limit: 60,
limit: 100,
});
if (cancelled) return;
const posts = Array.isArray(data?.posts) ? data.posts : [];
const normalized = posts.map((p) => normalizePostId(p));
let bounds = null;
try {
const b = map?.getBounds?.();
if (b) {
const sw = b.getSouthWest();
const ne = b.getNorthEast();
bounds = { minLat: sw?.lat, minLon: sw?.lng, maxLat: ne?.lat, maxLon: ne?.lng };
}
} catch {}
allPostsRef.current = prunePosts(normalized, [lon, lat], MAX_POOL_POSTS, bounds);
searchResultsRef.current = true;
refreshVisibleAndMarkers(timeHours);
trackEvent("search_filter_apply", {
// Remplacer les posts par les résultats de recherche
allPostsRef.current = normalized;
refreshVisibleAndMarkers(timeHours, true);
trackEvent("search_viewport", {
query_len: q.length,
results: posts.length,
context: "map",
radius_km: Math.round(radiusKm),
});
} catch {}
} catch (err) {
console.error('[usePostsEngine] Search error:', err);
}
}
runSearch();
return () => {
cancelled = true;
};
}, [searchQuery, viewParams, mapRef, refreshVisibleAndMarkers, timeHours]);
useEffect(() => {
if ((searchQuery || "").trim()) return;
searchResultsRef.current = false;
}, [searchQuery]);
}, [searchQuery, mapRef, refreshVisibleAndMarkers, timeHours]);
useEffect(() => {
const map = mapRef.current;

View File

@ -250,9 +250,10 @@ export default function PostComposer({ onClose, onPostCreated, islandUsername, i
onClick={onClose}
>
<motion.div
className="w-full sm:max-w-lg max-h-[90vh] overflow-hidden rounded-t-3xl sm:rounded-3xl
className="w-full sm:max-w-lg max-h-[85vh] max-h-[85dvh] overflow-hidden rounded-t-3xl sm:rounded-3xl
bg-gradient-to-b from-surface-800 to-surface-900
border border-white/10 shadow-2xl"
style={{ maxHeight: 'min(85vh, 85dvh)' }}
initial={{ y: "100%", opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: "100%", opacity: 0 }}
@ -288,7 +289,7 @@ export default function PostComposer({ onClose, onPostCreated, islandUsername, i
</div>
{/* Content */}
<div className="p-4 overflow-y-auto max-h-[calc(90vh-140px)]">
<div className="p-4 overflow-y-auto" style={{ maxHeight: 'calc(85dvh - 140px)' }}>
{/* User info */}
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center text-white text-lg font-bold">

View File

@ -242,11 +242,25 @@ export default function SmartSearchBar({
onSearch && onSearch(""); // Clear filters
};
const handleFormSubmit = (e) => {
e.preventDefault();
if (q.trim()) {
skipSearchRef.current = true;
setOpen(false);
trackEvent("search_submit", { query_len: q.trim().length });
onSearch && onSearch(q.trim());
if (inputRef.current) {
try { inputRef.current.blur(); } catch {}
}
}
};
return (
<div className="sw-search" ref={boxRef}>
<div className="sw-search__bar">
<form className="sw-search__bar" onSubmit={handleFormSubmit}>
<input
ref={inputRef}
type="search"
className="sw-search__input"
value={q}
onChange={(e) => {
@ -294,21 +308,7 @@ export default function SmartSearchBar({
</svg>
</button>
)}
<div className="sw-search__icons" aria-label="Search actions">
<button
type="button"
className="sw-search__iconBtn"
title={t('map.mySpot')}
onClick={() => {
trackEvent("search_my_spot");
onMySpot && onMySpot();
}}
>
<IconPin />
</button>
</div>
</div>
</form>
{showList && (
<div className="sw-search__dropdown">

View File

@ -7,6 +7,8 @@
padding: 0;
background: linear-gradient(180deg, #0a1628 0%, #0d2847 50%, #1e3a5f 100%);
overflow: hidden;
/* Prevent browser zoom on touch devices - only our map should zoom */
touch-action: pan-x pan-y;
}
.islands-world-canvas {
@ -403,6 +405,10 @@
z-index: 700;
background: #0a1628;
animation: islandFadeIn 0.4s ease;
/* Prevent browser zoom - only our island terrain should zoom */
touch-action: none;
-webkit-user-select: none;
user-select: none;
}
@keyframes islandFadeIn {
@ -506,20 +512,72 @@
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.4);
}
/* Interactive terrain area - full screen clickable */
/* Interactive terrain area - full screen with map-like navigation */
.island-terrain-interactive {
position: absolute;
inset: 0;
top: 70px;
cursor: crosshair;
cursor: grab;
overflow: hidden;
/* Prevent browser zoom */
touch-action: none;
}
.island-terrain-interactive .island-terrain-bg {
.island-terrain-interactive:active {
cursor: grabbing;
}
/* Transform container for pan/zoom */
.island-terrain-transform {
position: absolute;
inset: -10%;
width: 120%;
height: 120%;
inset: 0;
transform-origin: center center;
will-change: transform;
transition: none; /* No transition for smooth dragging */
}
.island-terrain-transform .island-terrain-bg {
position: absolute;
inset: -50%;
width: 200%;
height: 200%;
}
/* Zoom controls (like map controls) */
.island-terrain-zoom-controls {
position: absolute;
bottom: 24px;
right: 24px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 10;
}
.island-zoom-btn {
width: 40px;
height: 40px;
border: none;
border-radius: 8px;
background: rgba(15, 23, 42, 0.9);
border: 1px solid rgba(56, 189, 248, 0.3);
color: white;
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
backdrop-filter: blur(8px);
}
.island-zoom-btn:hover {
background: rgba(56, 189, 248, 0.3);
border-color: rgba(56, 189, 248, 0.5);
}
.island-zoom-btn:active {
transform: scale(0.95);
}
/* Posts container - overlay for posts on island */

View File

@ -34,6 +34,12 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
const [pendingPostPosition, setPendingPostPosition] = useState(null);
const terrainRef = useRef(null);
// Island terrain pan/zoom state (map-like behavior)
const [terrainTransform, setTerrainTransform] = useState({ x: 0, y: 0, scale: 1 });
const isDraggingTerrain = useRef(false);
const lastPointerPos = useRef({ x: 0, y: 0 });
const lastPinchDist = useRef(0);
// Set global island context when zoomed (for FAB to use)
useEffect(() => {
if (zoomedIsland) {
@ -105,8 +111,108 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
setZoomedIsland(null);
setIslandPosts([]);
setIsZooming(false);
setTerrainTransform({ x: 0, y: 0, scale: 1 }); // Reset transform
}, [isZooming]);
// Terrain pan handlers (map-like navigation)
const handleTerrainPointerDown = useCallback((e) => {
// Don't start drag if clicking on a post or button
if (e.target.closest('.island-post-marker, .island-pending-post-marker, button')) return;
isDraggingTerrain.current = true;
lastPointerPos.current = { x: e.clientX, y: e.clientY };
e.currentTarget.style.cursor = 'grabbing';
}, []);
const handleTerrainPointerMove = useCallback((e) => {
if (!isDraggingTerrain.current) return;
const dx = e.clientX - lastPointerPos.current.x;
const dy = e.clientY - lastPointerPos.current.y;
lastPointerPos.current = { x: e.clientX, y: e.clientY };
setTerrainTransform(prev => ({
...prev,
x: prev.x + dx,
y: prev.y + dy
}));
}, []);
const handleTerrainPointerUp = useCallback((e) => {
isDraggingTerrain.current = false;
if (e.currentTarget) e.currentTarget.style.cursor = 'grab';
}, []);
// Terrain wheel zoom (map-like)
const handleTerrainWheel = useCallback((e) => {
e.preventDefault();
const rect = terrainRef.current?.getBoundingClientRect();
if (!rect) return;
// Mouse position relative to terrain center
const mouseX = e.clientX - rect.left - rect.width / 2;
const mouseY = e.clientY - rect.top - rect.height / 2;
const delta = e.deltaY > 0 ? 0.9 : 1.1;
setTerrainTransform(prev => {
const newScale = Math.max(0.5, Math.min(4, prev.scale * delta));
const scaleDiff = newScale / prev.scale;
return {
scale: newScale,
x: prev.x * scaleDiff + mouseX * (1 - scaleDiff),
y: prev.y * scaleDiff + mouseY * (1 - scaleDiff)
};
});
}, []);
// Touch pinch zoom support
const handleTerrainTouchStart = useCallback((e) => {
if (e.touches.length === 2) {
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
lastPinchDist.current = Math.sqrt(dx * dx + dy * dy);
} else if (e.touches.length === 1) {
isDraggingTerrain.current = true;
lastPointerPos.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
}
}, []);
const handleTerrainTouchMove = useCallback((e) => {
if (e.touches.length === 2) {
e.preventDefault();
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (lastPinchDist.current > 0) {
const scaleDelta = dist / lastPinchDist.current;
setTerrainTransform(prev => ({
...prev,
scale: Math.max(0.5, Math.min(4, prev.scale * scaleDelta))
}));
}
lastPinchDist.current = dist;
} else if (e.touches.length === 1 && isDraggingTerrain.current) {
const dx = e.touches[0].clientX - lastPointerPos.current.x;
const dy = e.touches[0].clientY - lastPointerPos.current.y;
lastPointerPos.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
setTerrainTransform(prev => ({
...prev,
x: prev.x + dx,
y: prev.y + dy
}));
}
}, []);
const handleTerrainTouchEnd = useCallback(() => {
isDraggingTerrain.current = false;
lastPinchDist.current = 0;
}, []);
// Initialize MapLibre
useEffect(() => {
if (!containerRef.current || loading) return;
@ -316,98 +422,147 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
</div>
</div>
{/* Interactive terrain area */}
{/* Interactive terrain area - map-like pan/zoom */}
<div
ref={terrainRef}
className="island-terrain-interactive"
onPointerDown={handleTerrainPointerDown}
onPointerMove={handleTerrainPointerMove}
onPointerUp={handleTerrainPointerUp}
onPointerLeave={handleTerrainPointerUp}
onWheel={handleTerrainWheel}
onTouchStart={handleTerrainTouchStart}
onTouchMove={handleTerrainTouchMove}
onTouchEnd={handleTerrainTouchEnd}
onClick={(e) => {
// Don't place post if we just finished dragging
if (isDraggingTerrain.current) return;
// Only owner can place posts
if (!username || username.toLowerCase() !== (zoomedIsland.username || '').toLowerCase()) return;
const rect = terrainRef.current?.getBoundingClientRect();
if (!rect) return;
// Calculate position as percentage (0-100)
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
// Calculate position as percentage (0-100), accounting for transform
const rawX = e.clientX - rect.left - rect.width / 2 - terrainTransform.x;
const rawY = e.clientY - rect.top - rect.height / 2 - terrainTransform.y;
const x = (rawX / terrainTransform.scale / rect.width * 100) + 50;
const y = (rawY / terrainTransform.scale / rect.height * 100) + 50;
// Keep within bounds
if (x < 0 || x > 100 || y < 0 || y > 100) return;
setPendingPostPosition({ x, y });
}}
>
{/* Full-screen island terrain background */}
<IslandTerrainBackground island={zoomedIsland} />
{/* Transformable content container (pan/zoom like a map) */}
<div
className="island-terrain-transform"
style={{
transform: `translate(${terrainTransform.x}px, ${terrainTransform.y}px) scale(${terrainTransform.scale})`,
}}
>
{/* Full-screen island terrain background */}
<IslandTerrainBackground island={zoomedIsland} />
{/* Loading */}
{loadingPosts && (
<div className="island-terrain-loading">
<div className="loading-spinner" />
</div>
)}
{/* Loading */}
{loadingPosts && (
<div className="island-terrain-loading">
<div className="loading-spinner" />
</div>
)}
{/* Empty state hint */}
{!loadingPosts && islandPosts.length === 0 && !pendingPostPosition && (
<div className="island-terrain-empty">
<i className="fa-solid fa-umbrella-beach" />
<p>{t('worlds.emptyIsland')}</p>
{username && username.toLowerCase() === (zoomedIsland.username || '').toLowerCase() && (
<p className="island-terrain-hint">{t('worlds.clickToPlacePost')}</p>
)}
</div>
)}
{/* Empty state hint */}
{!loadingPosts && islandPosts.length === 0 && !pendingPostPosition && (
<div className="island-terrain-empty">
<i className="fa-solid fa-umbrella-beach" />
<p>{t('worlds.emptyIsland')}</p>
{username && username.toLowerCase() === (zoomedIsland.username || '').toLowerCase() && (
<p className="island-terrain-hint">{t('worlds.clickToPlacePost')}</p>
)}
</div>
)}
{/* Posts displayed at their coordinates */}
{!loadingPosts && islandPosts.map((post, idx) => (
<IslandPost
key={post.id || idx}
post={post}
index={idx}
islandSeed={zoomedIsland.seed || 0}
/>
))}
{/* Posts displayed at their coordinates */}
{!loadingPosts && islandPosts.map((post, idx) => (
<IslandPost
key={post.id || idx}
post={post}
index={idx}
islandSeed={zoomedIsland.seed || 0}
/>
))}
{/* Pending post marker (where user clicked) */}
{pendingPostPosition && (
<div
className="island-pending-post-marker"
style={{
left: `${pendingPostPosition.x}%`,
top: `${pendingPostPosition.y}%`
}}
{/* Pending post marker (where user clicked) */}
{pendingPostPosition && (
<div
className="island-pending-post-marker"
style={{
left: `${pendingPostPosition.x}%`,
top: `${pendingPostPosition.y}%`
}}
>
<div className="pending-marker-icon">
<i className="fa-solid fa-plus" />
</div>
<div className="pending-marker-actions">
<button
className="pending-marker-btn pending-marker-confirm"
onClick={(e) => {
e.stopPropagation();
// Open post composer with position
window.dispatchEvent(new CustomEvent('sw:openPostCreator', {
detail: {
island_username: zoomedIsland.username,
island_x: pendingPostPosition.x,
island_y: pendingPostPosition.y
}
}));
setPendingPostPosition(null);
}}
>
<i className="fa-solid fa-check" />
<span>{t('worlds.postHere')}</span>
</button>
<button
className="pending-marker-btn pending-marker-cancel"
onClick={(e) => {
e.stopPropagation();
setPendingPostPosition(null);
}}
>
<i className="fa-solid fa-times" />
</button>
</div>
</div>
)}
</div>{/* End transform container */}
{/* Zoom controls */}
<div className="island-terrain-zoom-controls">
<button
className="island-zoom-btn"
onClick={() => setTerrainTransform(prev => ({ ...prev, scale: Math.min(4, prev.scale * 1.3) }))}
title="Zoom in"
>
<div className="pending-marker-icon">
<i className="fa-solid fa-plus" />
</div>
<div className="pending-marker-actions">
<button
className="pending-marker-btn pending-marker-confirm"
onClick={(e) => {
e.stopPropagation();
// Open post composer with position
window.dispatchEvent(new CustomEvent('sw:openPostCreator', {
detail: {
island_username: zoomedIsland.username,
island_x: pendingPostPosition.x,
island_y: pendingPostPosition.y
}
}));
setPendingPostPosition(null);
}}
>
<i className="fa-solid fa-check" />
<span>{t('worlds.postHere')}</span>
</button>
<button
className="pending-marker-btn pending-marker-cancel"
onClick={(e) => {
e.stopPropagation();
setPendingPostPosition(null);
}}
>
<i className="fa-solid fa-times" />
</button>
</div>
</div>
)}
<i className="fa-solid fa-plus" />
</button>
<button
className="island-zoom-btn"
onClick={() => setTerrainTransform(prev => ({ ...prev, scale: Math.max(0.5, prev.scale / 1.3) }))}
title="Zoom out"
>
<i className="fa-solid fa-minus" />
</button>
<button
className="island-zoom-btn"
onClick={() => setTerrainTransform({ x: 0, y: 0, scale: 1 })}
title="Reset view"
>
<i className="fa-solid fa-expand" />
</button>
</div>
</div>
</div>
)}
@ -478,7 +633,7 @@ function createIslandMarker(island, idx, t, currentUsername) {
*/
function generateIslandSVG(seed, color) {
const rng = seededRandom(seed);
const size = 140;
const size = 200; // Larger islands for better visibility
const cx = size / 2;
const cy = size / 2;
const numPoints = 10;
@ -633,7 +788,7 @@ function IslandTerrainBackground({ island }) {
*/
function IslandShape({ seed, cx, cy, scale, fill, opacity = 1, filter }) {
const rng = seededRandom(seed);
const baseRadius = 30;
const baseRadius = 38; // Larger island to fill more space
const numPoints = 12;
const points = [];

View File

@ -1,7 +1,35 @@
* { box-sizing: border-box; }
html {
/* Prevent zoom/scaling on mobile inputs */
touch-action: manipulation;
}
body {
margin: 0;
background: #050816;
color: #ffffff;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
/* Prevent horizontal scroll */
overflow-x: hidden;
}
/* Global modal container fixes */
[class*="modal"],
[class*="Modal"],
[class*="overlay"],
[class*="Overlay"] {
/* Prevent modals from exceeding viewport */
max-height: 100vh;
max-height: 100dvh;
max-width: 100vw;
}
/* Prevent zoom on input focus (iOS) */
input, select, textarea {
font-size: 16px !important;
}
@media (min-width: 640px) {
input, select, textarea {
font-size: inherit !important;
}
}

View File

@ -75,6 +75,7 @@ body[data-theme="light"] {
background: var(--sw-panel-bg, rgba(15, 18, 28, 0.62));
border: 1px solid var(--sw-panel-border, rgba(255, 255, 255, 0.10));
backdrop-filter: blur(10px);
overflow: visible;
}
.sw-search__bar{
@ -92,7 +93,8 @@ body[data-theme="light"] {
}
.sw-search__input {
flex: 1;
flex: 1 1 auto;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
@ -102,6 +104,13 @@ body[data-theme="light"] {
padding: 2px 4px;
}
/* Hide native search clear button */
.sw-search__input::-webkit-search-cancel-button {
-webkit-appearance: none;
appearance: none;
display: none;
}
.sw-search__input::placeholder {
color: var(--sw-muted, rgba(255,255,255,0.55));
}
@ -117,6 +126,8 @@ body[data-theme="light"] {
align-items: center;
justify-content: center;
transition: color 0.15s ease;
flex-shrink: 0;
margin-right: 4px;
}
.sw-search__clearBtn:hover {
@ -132,11 +143,7 @@ body[data-theme="light"] {
align-items: center;
gap: 6px;
flex-shrink: 0;
margin-left: -6px;
}
.sw-search__icons .sw-search__iconBtn:first-child{
margin-left: -2px;
margin-left: 4px;
}
.sw-search__iconBtn {
@ -144,9 +151,9 @@ body[data-theme="light"] {
outline: 0;
cursor: pointer;
width: 34px;
height: 34px;
border-radius: 12px;
width: 32px;
height: 32px;
border-radius: 8px;
display: inline-flex;
align-items: center;
@ -154,6 +161,17 @@ body[data-theme="light"] {
background: var(--sw-chip-bg, rgba(255,255,255,0.08));
color: var(--sw-text, rgba(255,255,255,0.92));
transition: all 0.2s ease;
}
.sw-search__iconBtn:hover {
background: var(--sw-hover, rgba(255,255,255,0.15));
color: var(--sw-text, #fff);
}
/* My Position button inside search bar */
.sw-search__mySpotBtn {
font-size: 16px;
}
.sw-search__iconBtn.is-active {