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:
parent
36626fa091
commit
e8af1a6a08
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "sociowire-frontend",
|
||||
"version": "0.0.109",
|
||||
"version": "0.0.110",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sociowire-frontend",
|
||||
"version": "0.0.109",
|
||||
"version": "0.0.110",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||
"axios": "^1.13.2",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "sociowire-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.109",
|
||||
"version": "0.0.110",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
|
|
|
|||
|
|
@ -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 = {}) {
|
||||
if (!file) return { ok: false };
|
||||
const url = `${apiBase()}/profile/avatar`;
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@
|
|||
height: 100%;
|
||||
}
|
||||
|
||||
/* Search bar - matches map overlay position */
|
||||
/* Search bar - fixed below topbar */
|
||||
.islands-world-search {
|
||||
position: absolute;
|
||||
top: 0.7rem;
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
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 */
|
||||
@media (max-width: 768px) {
|
||||
.islands-world-title {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ 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 } from '../../api/client';
|
||||
import { fetchIslands, fetchIslandPosts } from '../../api/client';
|
||||
import { useAuth } from '../../auth/AuthContext';
|
||||
import './IslandsWorld.css';
|
||||
|
||||
|
|
@ -14,7 +14,9 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
|||
const { t } = useTranslation();
|
||||
const { authenticated, username } = useAuth();
|
||||
const containerRef = useRef(null);
|
||||
const terrainRef = useRef(null);
|
||||
const mapRef = useRef(null);
|
||||
const terrainMapRef = useRef(null);
|
||||
const markersRef = useRef([]);
|
||||
const animationRef = useRef(null);
|
||||
|
||||
|
|
@ -23,6 +25,11 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
|||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [hoveredIsland, setHoveredIsland] = useState(null);
|
||||
|
||||
// Terrain view state
|
||||
const [zoomedIsland, setZoomedIsland] = useState(null);
|
||||
const [islandPosts, setIslandPosts] = useState([]);
|
||||
const [terrainLoading, setTerrainLoading] = useState(false);
|
||||
|
||||
// Fetch islands
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
|
@ -51,6 +58,35 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
|||
)
|
||||
: 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
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || loading) return;
|
||||
|
|
@ -155,8 +191,8 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
|||
window.location.href = `/island/${island.username}/edit`;
|
||||
return;
|
||||
}
|
||||
// Default: open island viewer
|
||||
onIslandClick?.(island);
|
||||
// Default: zoom into island terrain
|
||||
handleZoomToIsland(island);
|
||||
});
|
||||
|
||||
const marker = new maplibregl.Marker({
|
||||
|
|
@ -188,7 +224,7 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
|||
markersRef.current = [];
|
||||
map.remove();
|
||||
};
|
||||
}, [filteredIslands, loading, onIslandClick, t]);
|
||||
}, [filteredIslands, loading, handleZoomToIsland, t, username]);
|
||||
|
||||
return (
|
||||
<div className="islands-world">
|
||||
|
|
@ -241,13 +277,6 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
|||
</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 && (
|
||||
|
|
@ -256,10 +285,250 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
|||
<p>{t('worlds.loadingIslands')}</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@
|
|||
height: 100%;
|
||||
}
|
||||
|
||||
/* Search bar - matches map overlay position */
|
||||
/* Search bar - fixed below topbar */
|
||||
.planets-world-search {
|
||||
position: absolute;
|
||||
top: 0.7rem;
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 100;
|
||||
|
|
@ -399,36 +399,147 @@
|
|||
box-shadow: 0 6px 20px rgba(155, 89, 182, 0.5);
|
||||
}
|
||||
|
||||
/* Create Planet button */
|
||||
.planets-world-create {
|
||||
/* Planet Terrain View */
|
||||
.planet-terrain-view {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 200;
|
||||
inset: 0;
|
||||
z-index: 500;
|
||||
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;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: linear-gradient(135deg, #9b59b6, #8e44ad);
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
padding: 14px 24px;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
background: linear-gradient(180deg, rgba(15, 15, 26, 0.95) 0%, rgba(15, 15, 26, 0) 100%);
|
||||
}
|
||||
|
||||
.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;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 32px rgba(155, 89, 182, 0.4);
|
||||
transition: all 0.3s ease;
|
||||
transition: all 0.2s 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);
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
.planets-world-loading {
|
||||
position: absolute;
|
||||
|
|
|
|||
|
|
@ -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 maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
|
|
@ -12,7 +12,7 @@ import './PlanetsWorld.css';
|
|||
*/
|
||||
export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
|
||||
const { t } = useTranslation();
|
||||
const { authenticated } = useAuth();
|
||||
const { authenticated, username } = useAuth();
|
||||
const containerRef = useRef(null);
|
||||
const mapRef = useRef(null);
|
||||
const markersRef = useRef([]);
|
||||
|
|
@ -22,7 +22,11 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
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
|
||||
useEffect(() => {
|
||||
|
|
@ -52,6 +56,31 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
|
|||
)
|
||||
: 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
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || loading) return;
|
||||
|
|
@ -115,7 +144,6 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
|
|||
|
||||
// Create planet markers in orbital rings
|
||||
const markers = [];
|
||||
const ringCount = Math.ceil(filteredPlanets.length / 6);
|
||||
|
||||
filteredPlanets.forEach((planet, idx) => {
|
||||
const ring = Math.floor(idx / 6);
|
||||
|
|
@ -132,7 +160,7 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
|
|||
el.addEventListener('mouseleave', () => setHoveredPlanet(null));
|
||||
el.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedPlanet(planet);
|
||||
handleZoomToPlanet(planet);
|
||||
});
|
||||
|
||||
const marker = new maplibregl.Marker({
|
||||
|
|
@ -164,7 +192,7 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
|
|||
markersRef.current = [];
|
||||
map.remove();
|
||||
};
|
||||
}, [filteredPlanets, loading, t]);
|
||||
}, [filteredPlanets, loading, t, handleZoomToPlanet]);
|
||||
|
||||
return (
|
||||
<div className="planets-world">
|
||||
|
|
@ -198,7 +226,7 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
|
|||
<div ref={containerRef} className="planets-world-canvas" />
|
||||
|
||||
{/* Hovered planet tooltip */}
|
||||
{hoveredPlanet && (
|
||||
{hoveredPlanet && !zoomedPlanet && (
|
||||
<div className="planets-world-tooltip">
|
||||
<div className="tooltip-icon" style={{ background: getPlanetGradient(hoveredPlanet.theme) }}>
|
||||
<i className="fa-solid fa-globe" />
|
||||
|
|
@ -213,59 +241,6 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
|
|||
</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 && (
|
||||
<div className="planets-world-loading">
|
||||
|
|
@ -273,10 +248,232 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
|
|||
<p>{t('worlds.loadingPlanets')}</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in New Issue