import React, { useEffect, useRef, useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import { fetchIslands, fetchIslandPosts, fetchPosts } from '../../api/client'; import { useAuth } from '../../auth/AuthContext'; import './IslandsWorld.css'; /** * IslandsWorld: Full-screen metaverse for personal islands * Each user has their own floating island in this virtual ocean */ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPlanets }) { const { t } = useTranslation(); const { authenticated, username } = useAuth(); const containerRef = useRef(null); const mapRef = useRef(null); const markersRef = useRef([]); const animationRef = useRef(null); const islandPositionsRef = useRef(new Map()); // Store island positions const [islands, setIslands] = useState([]); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); const [hoveredIsland, setHoveredIsland] = useState(null); // Zoomed island state const [zoomedIsland, setZoomedIsland] = useState(null); const [isZooming, setIsZooming] = useState(false); const [islandPosts, setIslandPosts] = useState([]); const [loadingPosts, setLoadingPosts] = useState(false); // Fetch islands useEffect(() => { let mounted = true; setLoading(true); fetchIslands({ limit: 200 }) .then(data => { if (mounted) { setIslands(data || []); setLoading(false); } }) .catch(err => { console.warn('[IslandsWorld] Failed to fetch islands:', err); if (mounted) setLoading(false); }); return () => { mounted = false; }; }, []); // Filter islands by search const filteredIslands = searchQuery ? islands.filter(i => (i.username || '').toLowerCase().includes(searchQuery.toLowerCase()) || (i.display_name || '').toLowerCase().includes(searchQuery.toLowerCase()) ) : islands; // Handle zoom to island - shows full-screen island terrain overlay const handleZoomToIsland = useCallback(async (island) => { if (isZooming) return; setIsZooming(true); setZoomedIsland(island); setLoadingPosts(true); // Fetch posts for this island try { const posts = await fetchPosts({ username: island.username, limit: 50 }); setIslandPosts(posts || []); } catch (err) { console.warn('[IslandsWorld] Failed to fetch island posts:', err); setIslandPosts([]); } setLoadingPosts(false); setIsZooming(false); }, [isZooming]); // Handle zoom out (back to world view) const handleZoomOut = useCallback(() => { if (isZooming) return; setZoomedIsland(null); setIslandPosts([]); setIsZooming(false); }, [isZooming]); // Initialize MapLibre useEffect(() => { if (!containerRef.current || loading) return; const centerLng = 0; const centerLat = 0; const map = new maplibregl.Map({ container: containerRef.current, style: { version: 8, sources: {}, layers: [ { id: 'background', type: 'background', paint: { 'background-color': '#0a1628' } } ] }, center: [centerLng, centerLat], zoom: 2, minZoom: 1, maxZoom: 18, pitch: 0, bearing: 0, antialias: true, attributionControl: false, dragRotate: false // Disable rotation, keep simple 2D navigation }); mapRef.current = map; // Add navigation controls map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'bottom-right'); map.on('load', () => { // Add ocean gradient layer const oceanGradientData = createOceanGradient(centerLng, centerLat); map.addSource('ocean-gradient', { type: 'geojson', data: oceanGradientData }); map.addLayer({ id: 'ocean-glow', type: 'fill', source: 'ocean-gradient', paint: { 'fill-color': ['interpolate', ['linear'], ['get', 'intensity'], 0, '#0a1628', 0.5, '#0d2847', 1, '#1e3a5f' ], 'fill-opacity': 0.8 } }); // Add starfield const starsData = createStarfield(500); map.addSource('stars', { type: 'geojson', data: starsData }); map.addLayer({ id: 'stars', type: 'circle', source: 'stars', paint: { 'circle-radius': ['interpolate', ['linear'], ['zoom'], 0, 1, 5, 3], 'circle-color': ['get', 'color'], 'circle-opacity': ['get', 'opacity'], 'circle-blur': 0.3 } }); // Create island markers - spread in a grid with some randomization const markers = []; const numIslands = filteredIslands.length; const gridSize = Math.ceil(Math.sqrt(numIslands)); const spacing = 25; // Degrees between islands filteredIslands.forEach((island, idx) => { // Grid position with jitter const gridX = idx % gridSize; const gridY = Math.floor(idx / gridSize); const rng = seededRandom((island.username || '').charCodeAt(0) || idx); const jitterX = (rng() - 0.5) * spacing * 0.6; const jitterY = (rng() - 0.5) * spacing * 0.6; const lng = (gridX - gridSize / 2) * spacing + jitterX; const lat = (gridY - gridSize / 2) * spacing + jitterY; // Store position for zoom islandPositionsRef.current.set(island.username || island.id, { lng, lat }); const el = createIslandMarker(island, idx, t, username); el.addEventListener('mouseenter', () => setHoveredIsland(island)); el.addEventListener('mouseleave', () => setHoveredIsland(null)); el.addEventListener('click', (e) => { e.stopPropagation(); const action = e.target.closest('[data-action]')?.dataset?.action; if (action === 'profile') { window.location.href = `/profile/${island.username}`; return; } if (action === 'edit') { window.location.href = `/island/${island.username}/edit`; return; } // Zoom to island handleZoomToIsland(island); }); const marker = new maplibregl.Marker({ element: el, anchor: 'center' }) .setLngLat([lng, lat]) .addTo(map); markers.push({ marker, island, el }); }); markersRef.current = markers; }); return () => { if (animationRef.current) cancelAnimationFrame(animationRef.current); markersRef.current.forEach(({ marker }) => marker.remove()); markersRef.current = []; islandPositionsRef.current.clear(); map.remove(); }; }, [filteredIslands, loading, t, username, handleZoomToIsland]); return (
{/* Search bar with icon and world nav */}
setSearchQuery(e.target.value)} /> {searchQuery && ( )} {islands.length}
{/* Main canvas */}
{/* Hovered island tooltip (only when not zoomed) */} {hoveredIsland && !zoomedIsland && (
{hoveredIsland.avatar_url ? ( {hoveredIsland.username} ) : ( {(hoveredIsland.username || 'U')[0].toUpperCase()} )}
@{hoveredIsland.username}
{hoveredIsland.display_name || hoveredIsland.bio || t('worlds.clickToVisit')}
{hoveredIsland.content_count || 0} {t('worlds.posts')} • {hoveredIsland.terrain_type || 'tropical'}
)} {/* Zoomed island - full screen terrain view */} {zoomedIsland && (
{/* Full-screen island terrain background */} {/* Header bar */}

@{zoomedIsland.username}

{zoomedIsland.display_name || zoomedIsland.terrain_type || 'Island'}
{username && username.toLowerCase() === (zoomedIsland.username || '').toLowerCase() && ( <> )}
{/* Posts on the island terrain */}
{loadingPosts && (
)} {!loadingPosts && islandPosts.length === 0 && (

{t('worlds.emptyIsland')}

{username && username.toLowerCase() === (zoomedIsland.username || '').toLowerCase() && ( )}
)} {!loadingPosts && islandPosts.map((post, idx) => ( ))}
)} {/* Loading overlay */} {loading && (

{t('worlds.loadingIslands')}

)}
); } /** * Create a stylized island marker element */ function createIslandMarker(island, idx, t, currentUsername) { const el = document.createElement('div'); el.className = 'island-marker'; const terrainColors = { 'tropical': '#2ecc71', 'desert': '#f39c12', 'arctic': '#3498db', 'volcanic': '#e74c3c', 'meadow': '#27ae60', }; const color = terrainColors[island.terrain_type] || terrainColors['tropical']; const seed = island.seed || idx; const islandSVG = generateIslandSVG(seed, color); const postCount = island.content_count || 0; const isOwner = currentUsername && island.username && currentUsername.toLowerCase() === island.username.toLowerCase(); el.innerHTML = `
${islandSVG}
@${island.username} ${postCount} posts
${isOwner ? ` ` : ''}
`; return el; } /** * Generate island SVG with organic shape */ function generateIslandSVG(seed, color) { const rng = seededRandom(seed); const size = 140; const cx = size / 2; const cy = size / 2; const numPoints = 10; const points = []; for (let i = 0; i < numPoints; i++) { const angle = (i / numPoints) * Math.PI * 2; const radiusVar = 0.55 + rng() * 0.45; const r = (size / 2.5) * radiusVar; points.push({ x: cx + Math.cos(angle) * r, y: cy + Math.sin(angle) * r }); } let path = `M ${points[0].x} ${points[0].y}`; for (let i = 0; i < numPoints; i++) { const curr = points[i]; const next = points[(i + 1) % numPoints]; const cpx = (curr.x + next.x) / 2 + (rng() - 0.5) * 10; const cpy = (curr.y + next.y) / 2 + (rng() - 0.5) * 10; path += ` Q ${cpx} ${cpy} ${next.x} ${next.y}`; } path += ' Z'; const numTrees = 3 + Math.floor(rng() * 4); let trees = ''; for (let i = 0; i < numTrees; i++) { const angle = rng() * Math.PI * 2; const dist = 10 + rng() * 20; const tx = cx + Math.cos(angle) * dist; const ty = cy + Math.sin(angle) * dist; const treeSize = 6 + rng() * 6; trees += ` `; } return ` ${trees} `; } function createStarfield(count) { const features = []; for (let i = 0; i < count; i++) { const lng = (Math.random() - 0.5) * 400; const lat = (Math.random() - 0.5) * 400; const brightness = Math.random(); features.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [lng, lat] }, properties: { color: brightness > 0.8 ? '#ffeedd' : brightness > 0.5 ? '#aaccff' : '#ffffff', opacity: 0.3 + brightness * 0.5 } }); } return { type: 'FeatureCollection', features }; } function createOceanGradient(centerLng, centerLat) { const features = []; const rings = 10; for (let i = 0; i < rings; i++) { const radius = (i + 1) * 30; const coords = []; for (let j = 0; j <= 64; j++) { const angle = (j / 64) * Math.PI * 2; coords.push([centerLng + Math.cos(angle) * radius, centerLat + Math.sin(angle) * radius]); } features.push({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [coords] }, properties: { intensity: 1 - (i / rings) } }); } return { type: 'FeatureCollection', features }; } function seededRandom(seed) { let s = seed; return function() { s = (s * 9301 + 49297) % 233280; return s / 233280; }; } /** * IslandTerrainBackground - Full-screen island shape background */ function IslandTerrainBackground({ island }) { const terrainColors = { 'tropical': { main: '#2ecc71', sand: '#F5DEB3', water: '#1e3a5f' }, 'desert': { main: '#f39c12', sand: '#DEB887', water: '#1e3a5f' }, 'arctic': { main: '#3498db', sand: '#E0FFFF', water: '#0d2847' }, 'volcanic': { main: '#e74c3c', sand: '#2C2C2C', water: '#1a1a2e' }, 'meadow': { main: '#27ae60', sand: '#90EE90', water: '#0d2847' }, }; const colors = terrainColors[island.terrain_type] || terrainColors['tropical']; const seed = island.seed || (island.username || '').charCodeAt(0) || 0; return ( {/* Ocean background */} {/* Beach/sand ring */} {/* Main island */} {/* Trees and details */} ); } /** * Generate organic island shape path */ function IslandShape({ seed, cx, cy, scale, fill, opacity = 1, filter }) { const rng = seededRandom(seed); const baseRadius = 30; const numPoints = 12; const points = []; for (let i = 0; i < numPoints; i++) { const angle = (i / numPoints) * Math.PI * 2; const radiusVar = 0.7 + rng() * 0.3; const r = baseRadius * radiusVar * scale; points.push({ x: cx + Math.cos(angle) * r, y: cy + Math.sin(angle) * r }); } let path = `M ${points[0].x} ${points[0].y}`; for (let i = 0; i < numPoints; i++) { const curr = points[i]; const next = points[(i + 1) % numPoints]; const cpx = (curr.x + next.x) / 2 + (rng() - 0.5) * 6; const cpy = (curr.y + next.y) / 2 + (rng() - 0.5) * 6; path += ` Q ${cpx} ${cpy} ${next.x} ${next.y}`; } path += ' Z'; return ; } /** * Island trees and details */ function IslandDetails({ seed, cx, cy }) { const rng = seededRandom(seed + 100); const numTrees = 8 + Math.floor(rng() * 6); const trees = []; for (let i = 0; i < numTrees; i++) { const angle = rng() * Math.PI * 2; const dist = 5 + rng() * 18; const tx = cx + Math.cos(angle) * dist; const ty = cy + Math.sin(angle) * dist; const treeSize = 2 + rng() * 3; trees.push( ); } return {trees}; } /** * IslandPost - A post displayed on the island terrain */ function IslandPost({ post, index, islandSeed }) { // Position posts pseudo-randomly on the island const rng = seededRandom(islandSeed * 100 + index + (post.id || 0)); const left = 10 + rng() * 75; // 10-85% from left const top = 15 + rng() * 65; // 15-80% from top const rotation = (rng() - 0.5) * 12; // -6 to +6 degrees const hasImage = post.media_urls?.length > 0 || post.image_url; const imageUrl = post.media_urls?.[0] || post.image_url; return (
window.location.href = `/post/${post.id}`} > {hasImage ? (
) : (

{(post.content || post.text || '').slice(0, 80)}

)}
{post.like_count || 0}
); }