feat: Add terrain zoom view for Islands and Planets

When clicking an island or planet:
- Zooms into a full-screen terrain view
- Shows the island/planet surface as an interactive map
- Posts are displayed as markers on the terrain
- Back button to return to world view
- Post button for authenticated users (on own island)

Changes:
- Fixed search bar position to fixed top: 56px (below topbar)
- Removed Create Island button (islands auto-create with profile)
- Removed planet detail modal (replaced with terrain view)
- Added Island/Planet TerrainCanvas components
- Added fetchIslandPosts API function
- Added terrain CSS styles

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-01-24 20:57:41 +00:00
parent 36626fa091
commit e8af1a6a08
7 changed files with 860 additions and 96 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "sociowire-frontend", "name": "sociowire-frontend",
"version": "0.0.109", "version": "0.0.110",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sociowire-frontend", "name": "sociowire-frontend",
"version": "0.0.109", "version": "0.0.110",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^7.1.0", "@fortawesome/fontawesome-free": "^7.1.0",
"axios": "^1.13.2", "axios": "^1.13.2",

View File

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

View File

@ -677,6 +677,19 @@ export async function fetchIslandByUsername(username) {
} }
} }
/**
* Fetch posts for an island (by username)
*/
export async function fetchIslandPosts(usernameOrId, params = {}) {
const username = typeof usernameOrId === 'string' ? usernameOrId : String(usernameOrId);
if (!username) return [];
return fetchPosts({
username,
limit: params.limit || 50,
...params
});
}
export async function uploadProfileAvatar(file, opts = {}) { export async function uploadProfileAvatar(file, opts = {}) {
if (!file) return { ok: false }; if (!file) return { ok: false };
const url = `${apiBase()}/profile/avatar`; const url = `${apiBase()}/profile/avatar`;

View File

@ -16,10 +16,10 @@
height: 100%; height: 100%;
} }
/* Search bar - matches map overlay position */ /* Search bar - fixed below topbar */
.islands-world-search { .islands-world-search {
position: absolute; position: fixed;
top: 0.7rem; top: 56px;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
z-index: 100; z-index: 100;
@ -366,6 +366,180 @@
} }
} }
/* Island Terrain View */
.island-terrain-view {
position: fixed;
inset: 0;
z-index: 500;
background: linear-gradient(180deg, #0a1628 0%, #0d2847 100%);
animation: terrainFadeIn 0.4s ease;
}
@keyframes terrainFadeIn {
from {
opacity: 0;
transform: scale(1.1);
}
to {
opacity: 1;
transform: scale(1);
}
}
.island-terrain-header {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: linear-gradient(180deg, rgba(10, 22, 40, 0.95) 0%, rgba(10, 22, 40, 0) 100%);
}
.island-terrain-back {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(56, 189, 248, 0.3);
border-radius: 12px;
color: white;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.island-terrain-back:hover {
background: rgba(56, 189, 248, 0.2);
border-color: rgba(56, 189, 248, 0.5);
}
.island-terrain-info {
text-align: center;
}
.island-terrain-info h2 {
margin: 0;
font-size: 20px;
font-weight: 700;
color: #38bdf8;
}
.island-terrain-type {
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
text-transform: uppercase;
letter-spacing: 1px;
}
.island-terrain-actions {
display: flex;
gap: 10px;
}
.island-terrain-action {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 12px;
color: white;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.island-terrain-action:hover {
background: rgba(255, 255, 255, 0.15);
}
.island-terrain-post {
background: linear-gradient(135deg, #10b981, #059669);
border: none;
font-weight: 600;
}
.island-terrain-post:hover {
transform: scale(1.05);
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.4);
}
.island-terrain-canvas {
position: absolute;
inset: 0;
}
.island-terrain-map {
width: 100%;
height: 100%;
}
.island-terrain-loading {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
}
.island-terrain-loading p {
color: rgba(255, 255, 255, 0.8);
font-size: 16px;
}
/* Terrain post markers */
.terrain-post-marker {
cursor: pointer;
transition: transform 0.2s ease;
}
.terrain-post-marker:hover {
transform: scale(1.1);
z-index: 100;
}
.terrain-post-icon {
width: 40px;
height: 40px;
background: linear-gradient(135deg, #38bdf8, #06b6d4);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 16px;
box-shadow: 0 4px 16px rgba(56, 189, 248, 0.5);
}
.terrain-post-title {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: 4px;
padding: 4px 8px;
background: rgba(15, 23, 42, 0.9);
border-radius: 8px;
font-size: 11px;
color: white;
white-space: nowrap;
opacity: 0;
transition: opacity 0.2s;
}
.terrain-post-marker:hover .terrain-post-title {
opacity: 1;
}
/* Responsive */ /* Responsive */
@media (max-width: 768px) { @media (max-width: 768px) {
.islands-world-title { .islands-world-title {

View File

@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import maplibregl from 'maplibre-gl'; import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import { fetchIslands } from '../../api/client'; import { fetchIslands, fetchIslandPosts } from '../../api/client';
import { useAuth } from '../../auth/AuthContext'; import { useAuth } from '../../auth/AuthContext';
import './IslandsWorld.css'; import './IslandsWorld.css';
@ -14,7 +14,9 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
const { t } = useTranslation(); const { t } = useTranslation();
const { authenticated, username } = useAuth(); const { authenticated, username } = useAuth();
const containerRef = useRef(null); const containerRef = useRef(null);
const terrainRef = useRef(null);
const mapRef = useRef(null); const mapRef = useRef(null);
const terrainMapRef = useRef(null);
const markersRef = useRef([]); const markersRef = useRef([]);
const animationRef = useRef(null); const animationRef = useRef(null);
@ -23,6 +25,11 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [hoveredIsland, setHoveredIsland] = useState(null); const [hoveredIsland, setHoveredIsland] = useState(null);
// Terrain view state
const [zoomedIsland, setZoomedIsland] = useState(null);
const [islandPosts, setIslandPosts] = useState([]);
const [terrainLoading, setTerrainLoading] = useState(false);
// Fetch islands // Fetch islands
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
@ -51,6 +58,35 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
) )
: islands; : islands;
// Load island posts when zooming into terrain view
useEffect(() => {
if (!zoomedIsland) {
setIslandPosts([]);
return;
}
setTerrainLoading(true);
fetchIslandPosts(zoomedIsland.username || zoomedIsland.id, { limit: 50 })
.then(posts => {
setIslandPosts(posts || []);
setTerrainLoading(false);
})
.catch(err => {
console.warn('[IslandsWorld] Failed to fetch island posts:', err);
setIslandPosts([]);
setTerrainLoading(false);
});
}, [zoomedIsland]);
// Handle zoom into island terrain
const handleZoomToIsland = useCallback((island) => {
setZoomedIsland(island);
}, []);
// Handle back from terrain view
const handleBackFromTerrain = useCallback(() => {
setZoomedIsland(null);
}, []);
// Initialize MapLibre // Initialize MapLibre
useEffect(() => { useEffect(() => {
if (!containerRef.current || loading) return; if (!containerRef.current || loading) return;
@ -155,8 +191,8 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
window.location.href = `/island/${island.username}/edit`; window.location.href = `/island/${island.username}/edit`;
return; return;
} }
// Default: open island viewer // Default: zoom into island terrain
onIslandClick?.(island); handleZoomToIsland(island);
}); });
const marker = new maplibregl.Marker({ const marker = new maplibregl.Marker({
@ -188,7 +224,7 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
markersRef.current = []; markersRef.current = [];
map.remove(); map.remove();
}; };
}, [filteredIslands, loading, onIslandClick, t]); }, [filteredIslands, loading, handleZoomToIsland, t, username]);
return ( return (
<div className="islands-world"> <div className="islands-world">
@ -241,13 +277,6 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
</div> </div>
)} )}
{/* Create Island CTA (for authenticated users without island) */}
{authenticated && (
<button className="islands-world-create" onClick={() => {/* TODO: Create island flow */}}>
<i className="fa-solid fa-plus" />
<span>{t('worlds.createIsland')}</span>
</button>
)}
{/* Loading overlay */} {/* Loading overlay */}
{loading && ( {loading && (
@ -256,10 +285,250 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
<p>{t('worlds.loadingIslands')}</p> <p>{t('worlds.loadingIslands')}</p>
</div> </div>
)} )}
{/* Island Terrain View - Full screen when zoomed */}
{zoomedIsland && (
<div className="island-terrain-view">
<div className="island-terrain-header">
<button className="island-terrain-back" onClick={handleBackFromTerrain}>
<i className="fa-solid fa-arrow-left" />
<span>{t('worlds.backToIslands')}</span>
</button>
<div className="island-terrain-info">
<h2>@{zoomedIsland.username}'s Island</h2>
<span className="island-terrain-type">{zoomedIsland.terrain_type || 'tropical'}</span>
</div>
<div className="island-terrain-actions">
<button className="island-terrain-action" onClick={() => window.location.href = `/profile/${zoomedIsland.username}`}>
<i className="fa-solid fa-user" />
</button>
{username && username.toLowerCase() === (zoomedIsland.username || '').toLowerCase() && (
<button className="island-terrain-action island-terrain-post" onClick={() => {
window.dispatchEvent(new CustomEvent('sociowire:create-post', {
detail: { island: zoomedIsland.username }
}));
}}>
<i className="fa-solid fa-plus" />
<span>{t('worlds.postHere')}</span>
</button>
)}
</div>
</div>
{/* Terrain canvas with posts */}
<div ref={terrainRef} className="island-terrain-canvas">
{terrainLoading ? (
<div className="island-terrain-loading">
<div className="loading-spinner" />
<p>{t('worlds.loadingTerrain')}</p>
</div>
) : (
<IslandTerrainCanvas
island={zoomedIsland}
posts={islandPosts}
isOwner={username && username.toLowerCase() === (zoomedIsland.username || '').toLowerCase()}
/>
)}
</div>
</div>
)}
</div> </div>
); );
} }
/**
* Island Terrain Canvas - renders the island as a large terrain with posts
*/
function IslandTerrainCanvas({ island, posts, isOwner }) {
const canvasRef = useRef(null);
const mapRef = useRef(null);
const terrainColors = {
'tropical': { main: '#2ecc71', sand: '#F5DEB3', water: '#1e90ff' },
'desert': { main: '#f39c12', sand: '#EDC9AF', water: '#4aa3df' },
'arctic': { main: '#3498db', sand: '#E8E8E8', water: '#5fa8d3' },
'volcanic': { main: '#e74c3c', sand: '#2C2C2C', water: '#ff6b6b' },
'meadow': { main: '#27ae60', sand: '#C2B280', water: '#5dade2' },
};
const colors = terrainColors[island.terrain_type] || terrainColors['tropical'];
useEffect(() => {
if (!canvasRef.current) return;
const map = new maplibregl.Map({
container: canvasRef.current,
style: {
version: 8,
sources: {},
layers: [
{
id: 'background',
type: 'background',
paint: { 'background-color': colors.water }
}
]
},
center: [0, 0],
zoom: 3,
pitch: 45,
bearing: 0,
attributionControl: false
});
mapRef.current = map;
map.on('load', () => {
// Create island terrain shape
const seed = island.seed || (island.username || '').charCodeAt(0) || 42;
const terrainData = generateTerrainGeoJSON(seed, colors.main, colors.sand);
map.addSource('terrain', { type: 'geojson', data: terrainData.island });
map.addSource('beach', { type: 'geojson', data: terrainData.beach });
// Beach layer
map.addLayer({
id: 'beach',
type: 'fill',
source: 'beach',
paint: {
'fill-color': colors.sand,
'fill-opacity': 0.9
}
});
// Island terrain layer
map.addLayer({
id: 'terrain',
type: 'fill',
source: 'terrain',
paint: {
'fill-color': colors.main,
'fill-opacity': 1
}
});
// Add terrain details (trees, rocks, etc.)
const detailsData = generateTerrainDetails(seed);
map.addSource('details', { type: 'geojson', data: detailsData });
map.addLayer({
id: 'details',
type: 'circle',
source: 'details',
paint: {
'circle-radius': ['get', 'size'],
'circle-color': ['get', 'color'],
'circle-opacity': 0.8
}
});
// Add posts as markers on the terrain
posts.forEach((post, idx) => {
const angle = (idx / Math.max(posts.length, 1)) * Math.PI * 2;
const radius = 0.01 + Math.random() * 0.02;
const lng = Math.cos(angle) * radius;
const lat = Math.sin(angle) * radius;
const el = document.createElement('div');
el.className = 'terrain-post-marker';
el.innerHTML = `
<div class="terrain-post-icon">
<i class="fa-solid ${post.media_type === 'video' ? 'fa-video' : post.media_type === 'image' ? 'fa-image' : 'fa-file-alt'}"></i>
</div>
<div class="terrain-post-title">${(post.title || post.caption || '').slice(0, 30)}...</div>
`;
el.addEventListener('click', () => {
window.dispatchEvent(new CustomEvent('sw:openPostDetail', { detail: { post } }));
});
new maplibregl.Marker({ element: el, anchor: 'bottom' })
.setLngLat([lng, lat])
.addTo(map);
});
});
return () => {
map.remove();
};
}, [island, posts, colors]);
return <div ref={canvasRef} className="island-terrain-map" />;
}
/**
* Generate terrain GeoJSON for island view
*/
function generateTerrainGeoJSON(seed, mainColor, sandColor) {
const rng = seededRandom(seed);
const numPoints = 24;
const islandPoints = [];
const beachPoints = [];
for (let i = 0; i < numPoints; i++) {
const angle = (i / numPoints) * Math.PI * 2;
const islandRadius = 0.025 + rng() * 0.015;
const beachRadius = islandRadius * 1.15;
islandPoints.push([Math.cos(angle) * islandRadius, Math.sin(angle) * islandRadius]);
beachPoints.push([Math.cos(angle) * beachRadius, Math.sin(angle) * beachRadius]);
}
// Close the polygons
islandPoints.push(islandPoints[0]);
beachPoints.push(beachPoints[0]);
return {
island: {
type: 'Feature',
geometry: { type: 'Polygon', coordinates: [islandPoints] }
},
beach: {
type: 'Feature',
geometry: { type: 'Polygon', coordinates: [beachPoints] }
}
};
}
/**
* Generate terrain details (trees, rocks, flowers)
*/
function generateTerrainDetails(seed) {
const rng = seededRandom(seed);
const features = [];
const detailCount = 30 + Math.floor(rng() * 20);
for (let i = 0; i < detailCount; i++) {
const angle = rng() * Math.PI * 2;
const radius = rng() * 0.02;
const lng = Math.cos(angle) * radius;
const lat = Math.sin(angle) * radius;
const type = rng();
let color, size;
if (type < 0.5) {
// Tree
color = '#228B22';
size = 4 + rng() * 3;
} else if (type < 0.75) {
// Rock
color = '#696969';
size = 2 + rng() * 2;
} else {
// Flower
color = ['#FF69B4', '#FFD700', '#FF6347', '#9370DB'][Math.floor(rng() * 4)];
size = 2 + rng();
}
features.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: [lng, lat] },
properties: { color, size }
});
}
return { type: 'FeatureCollection', features };
}
/** /**
* Create a stylized island marker element * Create a stylized island marker element
*/ */

View File

@ -16,10 +16,10 @@
height: 100%; height: 100%;
} }
/* Search bar - matches map overlay position */ /* Search bar - fixed below topbar */
.planets-world-search { .planets-world-search {
position: absolute; position: fixed;
top: 0.7rem; top: 56px;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
z-index: 100; z-index: 100;
@ -399,36 +399,147 @@
box-shadow: 0 6px 20px rgba(155, 89, 182, 0.5); box-shadow: 0 6px 20px rgba(155, 89, 182, 0.5);
} }
/* Create Planet button */ /* Planet Terrain View */
.planets-world-create { .planet-terrain-view {
position: fixed; position: fixed;
bottom: 24px; inset: 0;
right: 24px; z-index: 500;
z-index: 200; background: linear-gradient(180deg, #0f0f1a 0%, #050510 100%);
animation: planetTerrainFadeIn 0.4s ease;
}
@keyframes planetTerrainFadeIn {
from {
opacity: 0;
transform: scale(1.1);
}
to {
opacity: 1;
transform: scale(1);
}
}
.planet-terrain-header {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 100;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; justify-content: space-between;
background: linear-gradient(135deg, #9b59b6, #8e44ad); padding: 16px 24px;
border: none; background: linear-gradient(180deg, rgba(15, 15, 26, 0.95) 0%, rgba(15, 15, 26, 0) 100%);
border-radius: 50px; }
padding: 14px 24px;
.planet-terrain-back {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(155, 89, 182, 0.3);
border-radius: 12px;
color: white; color: white;
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 600;
cursor: pointer; cursor: pointer;
box-shadow: 0 8px 32px rgba(155, 89, 182, 0.4); transition: all 0.2s ease;
transition: all 0.3s ease;
} }
.planets-world-create:hover { .planet-terrain-back:hover {
background: rgba(155, 89, 182, 0.2);
border-color: rgba(155, 89, 182, 0.5);
}
.planet-terrain-info {
text-align: center;
}
.planet-terrain-info h2 {
margin: 0;
font-size: 20px;
font-weight: 700;
color: #d4a5ff;
}
.planet-terrain-theme {
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
text-transform: uppercase;
letter-spacing: 1px;
}
.planet-terrain-actions {
display: flex;
gap: 10px;
}
.planet-terrain-action {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 12px;
color: white;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.planet-terrain-action:hover {
background: rgba(255, 255, 255, 0.15);
}
.planet-terrain-post {
background: linear-gradient(135deg, #9b59b6, #8e44ad);
border: none;
font-weight: 600;
}
.planet-terrain-post:hover {
transform: scale(1.05); transform: scale(1.05);
box-shadow: 0 12px 40px rgba(155, 89, 182, 0.5); box-shadow: 0 4px 16px rgba(155, 89, 182, 0.4);
} }
.planets-world-create i { .planet-terrain-canvas {
position: absolute;
inset: 0;
}
.planet-terrain-map {
width: 100%;
height: 100%;
}
.planet-terrain-loading {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
}
.planet-terrain-loading p {
color: rgba(255, 255, 255, 0.8);
font-size: 16px; font-size: 16px;
} }
/* Planet post markers on terrain */
.planet-post-marker {
cursor: pointer;
transition: transform 0.2s ease;
}
.planet-post-marker:hover {
transform: scale(1.1);
z-index: 100;
}
/* Loading overlay */ /* Loading overlay */
.planets-world-loading { .planets-world-loading {
position: absolute; position: absolute;

View File

@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import maplibregl from 'maplibre-gl'; import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
@ -12,7 +12,7 @@ import './PlanetsWorld.css';
*/ */
export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) { export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { authenticated } = useAuth(); const { authenticated, username } = useAuth();
const containerRef = useRef(null); const containerRef = useRef(null);
const mapRef = useRef(null); const mapRef = useRef(null);
const markersRef = useRef([]); const markersRef = useRef([]);
@ -22,7 +22,11 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [hoveredPlanet, setHoveredPlanet] = useState(null); const [hoveredPlanet, setHoveredPlanet] = useState(null);
const [selectedPlanet, setSelectedPlanet] = useState(null);
// Terrain view state
const [zoomedPlanet, setZoomedPlanet] = useState(null);
const [planetPosts, setPlanetPosts] = useState([]);
const [terrainLoading, setTerrainLoading] = useState(false);
// Fetch planets // Fetch planets
useEffect(() => { useEffect(() => {
@ -52,6 +56,31 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
) )
: planets; : planets;
// Load planet posts when zooming into terrain view
useEffect(() => {
if (!zoomedPlanet) {
setPlanetPosts([]);
return;
}
setTerrainLoading(true);
// TODO: Add fetchPlanetPosts API function
// For now, simulate with empty posts
setTimeout(() => {
setPlanetPosts([]);
setTerrainLoading(false);
}, 500);
}, [zoomedPlanet]);
// Handle zoom into planet terrain
const handleZoomToPlanet = useCallback((planet) => {
setZoomedPlanet(planet);
}, []);
// Handle back from terrain view
const handleBackFromTerrain = useCallback(() => {
setZoomedPlanet(null);
}, []);
// Initialize MapLibre // Initialize MapLibre
useEffect(() => { useEffect(() => {
if (!containerRef.current || loading) return; if (!containerRef.current || loading) return;
@ -115,7 +144,6 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
// Create planet markers in orbital rings // Create planet markers in orbital rings
const markers = []; const markers = [];
const ringCount = Math.ceil(filteredPlanets.length / 6);
filteredPlanets.forEach((planet, idx) => { filteredPlanets.forEach((planet, idx) => {
const ring = Math.floor(idx / 6); const ring = Math.floor(idx / 6);
@ -132,7 +160,7 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
el.addEventListener('mouseleave', () => setHoveredPlanet(null)); el.addEventListener('mouseleave', () => setHoveredPlanet(null));
el.addEventListener('click', (e) => { el.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
setSelectedPlanet(planet); handleZoomToPlanet(planet);
}); });
const marker = new maplibregl.Marker({ const marker = new maplibregl.Marker({
@ -164,7 +192,7 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
markersRef.current = []; markersRef.current = [];
map.remove(); map.remove();
}; };
}, [filteredPlanets, loading, t]); }, [filteredPlanets, loading, t, handleZoomToPlanet]);
return ( return (
<div className="planets-world"> <div className="planets-world">
@ -198,7 +226,7 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
<div ref={containerRef} className="planets-world-canvas" /> <div ref={containerRef} className="planets-world-canvas" />
{/* Hovered planet tooltip */} {/* Hovered planet tooltip */}
{hoveredPlanet && ( {hoveredPlanet && !zoomedPlanet && (
<div className="planets-world-tooltip"> <div className="planets-world-tooltip">
<div className="tooltip-icon" style={{ background: getPlanetGradient(hoveredPlanet.theme) }}> <div className="tooltip-icon" style={{ background: getPlanetGradient(hoveredPlanet.theme) }}>
<i className="fa-solid fa-globe" /> <i className="fa-solid fa-globe" />
@ -213,59 +241,6 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
</div> </div>
)} )}
{/* Planet detail modal */}
{selectedPlanet && (
<div className="planet-modal-backdrop" onClick={() => setSelectedPlanet(null)}>
<div className="planet-modal" onClick={(e) => e.stopPropagation()}>
<button className="planet-modal-close" onClick={() => setSelectedPlanet(null)}>
<i className="fa-solid fa-xmark" />
</button>
<div className="planet-modal-header" style={{ background: getPlanetGradient(selectedPlanet.theme) }}>
<div className="planet-modal-icon">
<i className="fa-solid fa-globe" />
</div>
<h2>{selectedPlanet.name}</h2>
<p>{selectedPlanet.description || t('worlds.noDescription')}</p>
</div>
<div className="planet-modal-stats">
<div className="stat">
<span className="stat-value">{selectedPlanet.member_count || 0}</span>
<span className="stat-label">{t('worlds.members')}</span>
</div>
<div className="stat">
<span className="stat-value">{selectedPlanet.post_count || 0}</span>
<span className="stat-label">{t('worlds.posts')}</span>
</div>
<div className="stat">
<span className="stat-value">{selectedPlanet.theme || 'default'}</span>
<span className="stat-label">{t('worlds.theme')}</span>
</div>
</div>
<div className="planet-modal-actions">
<button className="planet-action-btn planet-action-primary">
<i className="fa-solid fa-rocket" />
<span>{t('worlds.joinPlanet')}</span>
</button>
<button className="planet-action-btn">
<i className="fa-solid fa-share-nodes" />
<span>{t('worlds.share')}</span>
</button>
</div>
</div>
</div>
)}
{/* Create Planet CTA */}
{authenticated && (
<button className="planets-world-create" onClick={() => {/* TODO: Create planet flow */}}>
<i className="fa-solid fa-plus" />
<span>{t('worlds.createPlanet')}</span>
</button>
)}
{/* Loading overlay */} {/* Loading overlay */}
{loading && ( {loading && (
<div className="planets-world-loading"> <div className="planets-world-loading">
@ -273,10 +248,232 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
<p>{t('worlds.loadingPlanets')}</p> <p>{t('worlds.loadingPlanets')}</p>
</div> </div>
)} )}
{/* Planet Terrain View - Full screen when zoomed */}
{zoomedPlanet && (
<div className="planet-terrain-view">
<div className="planet-terrain-header">
<button className="planet-terrain-back" onClick={handleBackFromTerrain}>
<i className="fa-solid fa-arrow-left" />
<span>{t('worlds.backToPlanets')}</span>
</button>
<div className="planet-terrain-info">
<h2>{zoomedPlanet.name || 'Planet'}</h2>
<span className="planet-terrain-theme">{zoomedPlanet.theme || 'default'}</span>
</div>
<div className="planet-terrain-actions">
<button className="planet-terrain-action">
<i className="fa-solid fa-users" />
<span>{zoomedPlanet.member_count || 0}</span>
</button>
{authenticated && (
<button className="planet-terrain-action planet-terrain-post">
<i className="fa-solid fa-plus" />
<span>{t('worlds.postHere')}</span>
</button>
)}
</div>
</div>
{/* Terrain canvas with posts */}
<div className="planet-terrain-canvas">
{terrainLoading ? (
<div className="planet-terrain-loading">
<div className="loading-spinner" />
<p>{t('worlds.loadingTerrain')}</p>
</div>
) : (
<PlanetTerrainCanvas
planet={zoomedPlanet}
posts={planetPosts}
/>
)}
</div>
</div>
)}
</div> </div>
); );
} }
/**
* Planet Terrain Canvas - renders the planet as a large terrain with posts
*/
function PlanetTerrainCanvas({ planet, posts }) {
const canvasRef = useRef(null);
const mapRef = useRef(null);
const themeColors = {
'default': { main: '#9b59b6', accent: '#8e44ad', bg: '#1a0a2e' },
'tech': { main: '#3498db', accent: '#2980b9', bg: '#0a1a2e' },
'gaming': { main: '#e74c3c', accent: '#c0392b', bg: '#2e0a0a' },
'music': { main: '#1abc9c', accent: '#16a085', bg: '#0a2e2a' },
'art': { main: '#e91e63', accent: '#c2185b', bg: '#2e0a1a' },
'science': { main: '#00bcd4', accent: '#0097a7', bg: '#0a2e2e' },
'sports': { main: '#ff9800', accent: '#f57c00', bg: '#2e1a0a' },
};
const colors = themeColors[planet.theme] || themeColors['default'];
useEffect(() => {
if (!canvasRef.current) return;
const map = new maplibregl.Map({
container: canvasRef.current,
style: {
version: 8,
sources: {},
layers: [
{
id: 'background',
type: 'background',
paint: { 'background-color': colors.bg }
}
]
},
center: [0, 0],
zoom: 3,
pitch: 45,
bearing: 0,
attributionControl: false
});
mapRef.current = map;
map.on('load', () => {
// Create planet surface with craters/features
const seed = planet.id || (planet.name || '').charCodeAt(0) || 42;
const surfaceData = generatePlanetSurface(seed, colors.main, colors.accent);
map.addSource('surface', { type: 'geojson', data: surfaceData.terrain });
map.addSource('features', { type: 'geojson', data: surfaceData.features });
// Planet surface
map.addLayer({
id: 'surface',
type: 'fill',
source: 'surface',
paint: {
'fill-color': colors.main,
'fill-opacity': 1
}
});
// Surface features (craters, mountains)
map.addLayer({
id: 'features',
type: 'circle',
source: 'features',
paint: {
'circle-radius': ['get', 'size'],
'circle-color': ['get', 'color'],
'circle-opacity': ['get', 'opacity']
}
});
// Add posts as markers on the terrain
posts.forEach((post, idx) => {
const angle = (idx / Math.max(posts.length, 1)) * Math.PI * 2;
const radius = 0.01 + Math.random() * 0.02;
const lng = Math.cos(angle) * radius;
const lat = Math.sin(angle) * radius;
const el = document.createElement('div');
el.className = 'terrain-post-marker planet-post-marker';
el.innerHTML = `
<div class="terrain-post-icon" style="background: linear-gradient(135deg, ${colors.main}, ${colors.accent})">
<i class="fa-solid ${post.media_type === 'video' ? 'fa-video' : post.media_type === 'image' ? 'fa-image' : 'fa-file-alt'}"></i>
</div>
<div class="terrain-post-title">${(post.title || post.caption || '').slice(0, 30)}...</div>
`;
el.addEventListener('click', () => {
window.dispatchEvent(new CustomEvent('sw:openPostDetail', { detail: { post } }));
});
new maplibregl.Marker({ element: el, anchor: 'bottom' })
.setLngLat([lng, lat])
.addTo(map);
});
});
return () => {
map.remove();
};
}, [planet, posts, colors]);
return <div ref={canvasRef} className="planet-terrain-map" />;
}
/**
* Generate planet surface GeoJSON
*/
function generatePlanetSurface(seed, mainColor, accentColor) {
const rng = seededRandom(seed);
const numPoints = 32;
const surfacePoints = [];
for (let i = 0; i < numPoints; i++) {
const angle = (i / numPoints) * Math.PI * 2;
const radius = 0.03 + rng() * 0.01;
surfacePoints.push([Math.cos(angle) * radius, Math.sin(angle) * radius]);
}
surfacePoints.push(surfacePoints[0]);
// Generate craters and features
const features = [];
const featureCount = 20 + Math.floor(rng() * 15);
for (let i = 0; i < featureCount; i++) {
const angle = rng() * Math.PI * 2;
const radius = rng() * 0.025;
const lng = Math.cos(angle) * radius;
const lat = Math.sin(angle) * radius;
const type = rng();
let color, size, opacity;
if (type < 0.4) {
// Crater
color = accentColor;
size = 3 + rng() * 4;
opacity = 0.6;
} else if (type < 0.7) {
// Mountain/ridge
color = mainColor;
size = 2 + rng() * 3;
opacity = 0.8;
} else {
// Glowing spot
color = '#ffffff';
size = 1 + rng() * 2;
opacity = 0.4;
}
features.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: [lng, lat] },
properties: { color, size, opacity }
});
}
return {
terrain: {
type: 'Feature',
geometry: { type: 'Polygon', coordinates: [surfacePoints] }
},
features: { type: 'FeatureCollection', features }
};
}
/**
* Seeded random number generator
*/
function seededRandom(seed) {
let s = seed;
return function() {
s = (s * 9301 + 49297) % 233280;
return s / 233280;
};
}
/** /**
* Create a stylized planet marker element * Create a stylized planet marker element
*/ */