diff --git a/src/components/Search/SmartSearchBar.jsx b/src/components/Search/SmartSearchBar.jsx
index 7eb253d..7c3a615 100644
--- a/src/components/Search/SmartSearchBar.jsx
+++ b/src/components/Search/SmartSearchBar.jsx
@@ -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 (
-
+
{showList && (
diff --git a/src/components/World/IslandsWorld.css b/src/components/World/IslandsWorld.css
index ed675cd..96603be 100644
--- a/src/components/World/IslandsWorld.css
+++ b/src/components/World/IslandsWorld.css
@@ -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 */
diff --git a/src/components/World/IslandsWorld.jsx b/src/components/World/IslandsWorld.jsx
index 0c4246d..2155576 100644
--- a/src/components/World/IslandsWorld.jsx
+++ b/src/components/World/IslandsWorld.jsx
@@ -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
- {/* Interactive terrain area */}
+ {/* Interactive terrain area - map-like pan/zoom */}
{
+ // 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 */}
-
+ {/* Transformable content container (pan/zoom like a map) */}
+
+ {/* Full-screen island terrain background */}
+
- {/* Loading */}
- {loadingPosts && (
-
- )}
+ {/* Loading */}
+ {loadingPosts && (
+
+ )}
- {/* Empty state hint */}
- {!loadingPosts && islandPosts.length === 0 && !pendingPostPosition && (
-
-
-
{t('worlds.emptyIsland')}
- {username && username.toLowerCase() === (zoomedIsland.username || '').toLowerCase() && (
-
{t('worlds.clickToPlacePost')}
- )}
-
- )}
+ {/* Empty state hint */}
+ {!loadingPosts && islandPosts.length === 0 && !pendingPostPosition && (
+
+
+
{t('worlds.emptyIsland')}
+ {username && username.toLowerCase() === (zoomedIsland.username || '').toLowerCase() && (
+
{t('worlds.clickToPlacePost')}
+ )}
+
+ )}
- {/* Posts displayed at their coordinates */}
- {!loadingPosts && islandPosts.map((post, idx) => (
-
- ))}
+ {/* Posts displayed at their coordinates */}
+ {!loadingPosts && islandPosts.map((post, idx) => (
+
+ ))}
- {/* Pending post marker (where user clicked) */}
- {pendingPostPosition && (
-
+
+
+
+
+
+
+
+
+ )}
+
{/* End transform container */}
+
+ {/* Zoom controls */}
+
+
- )}
+
+
+
setTerrainTransform(prev => ({ ...prev, scale: Math.max(0.5, prev.scale / 1.3) }))}
+ title="Zoom out"
+ >
+
+
+
setTerrainTransform({ x: 0, y: 0, scale: 1 })}
+ title="Reset view"
+ >
+
+
+