feat: Add 3 separate worlds system (Map, Islands, Planets)

- Add world navigation bar with 3 buttons to switch between worlds
- Create IslandsWorld component: personal metaverse with floating islands
- Create PlanetsWorld component: group metaverse with orbiting planets
- Each world has full-screen immersive MapLibre visualization
- Add search functionality with icon badges in Islands/Planets views
- Always start on Map view on page load
- Add translations for EN, FR, ES
- v0.0.104

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-01-24 19:53:32 +00:00
parent ac99f0f1e4
commit 9706d673c3
13 changed files with 1986 additions and 125 deletions

4
package-lock.json generated
View File

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

View File

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

View File

@ -4,23 +4,29 @@ import { version as appVersion } from "../package.json";
import TopBar from "./components/Layout/TopBarNew";
import PostList from "./components/Posts/PostList";
import ContentFilters from "./components/Filters/ContentFilters";
import IslandUniverse from "./components/Island/IslandUniverse";
import IslandViewer from "./components/Island/IslandViewer";
import ChatContainer from "./components/Chat/ChatContainer";
import PostDetailModal from "./components/Posts/PostDetailModal";
import { CreatePostFAB } from "./components/Posts/PostComposer";
import { AnimatePresence } from "framer-motion";
import { AnimatePresence, motion } from "framer-motion";
import { fetchIslandByUsername } from "./api/client";
const MapView = React.lazy(() => import("./components/Map/MapView"));
const IslandsWorld = React.lazy(() => import("./components/World/IslandsWorld"));
const PlanetsWorld = React.lazy(() => import("./components/World/PlanetsWorld"));
const THEME_KEY = "sociowire:theme";
const CONTENT_FILTER_KEY = "sociowire:contentFilter";
const VIEW_MODE_KEY = "sociowire:viewMode"; // "map" | "islands" | "planets"
export default function App() {
const { t } = useTranslation();
const [theme, setTheme] = useState("blue");
// Current world view: "map" | "islands" | "planets"
// Always start on map for now
const [currentView, setCurrentView] = useState("map");
// Clean up Facebook OAuth hash fragment (#_=_)
useEffect(() => {
if (window.location.hash === "#_=_") {
@ -57,8 +63,7 @@ export default function App() {
} catch {}
}, [contentFilter]);
// Island Universe & Island Viewer state
const [showUniverse, setShowUniverse] = useState(false);
// Island Viewer state (for viewing individual islands)
const [selectedIsland, setSelectedIsland] = useState(null);
// Post detail modal state (triggered from map markers)
@ -159,17 +164,20 @@ export default function App() {
return () => window.removeEventListener('sociowire:create-post', handleIslandPost);
}, []);
// Island Universe handlers
const handleOpenUniverse = () => {
setShowUniverse(true);
// World navigation handlers
const handleOpenIslands = () => {
setCurrentView("islands");
};
const handleCloseUniverse = () => {
setShowUniverse(false);
const handleOpenPlanets = () => {
setCurrentView("planets");
};
const handleOpenMap = () => {
setCurrentView("map");
};
const handleIslandClick = (island) => {
setShowUniverse(false); // Close universe
setSelectedIsland(island); // Open island viewer
};
@ -179,52 +187,72 @@ export default function App() {
try {
const island = await fetchIslandByUsername(u);
if (island) {
setShowUniverse(false);
setSelectedIsland(island);
}
} catch {}
};
// World loading placeholder
const WorldPlaceholder = ({ title, subtitle }) => (
<div className="world-placeholder">
<div className="world-placeholder-content">
<div className="world-placeholder-title">{title}</div>
<div className="world-placeholder-subtitle">{subtitle}</div>
</div>
</div>
);
return (
<div className="app-root">
<TopBar
theme={theme}
onChangeTheme={setTheme}
onIslandsClick={handleOpenUniverse}
onIslandsClick={handleOpenIslands}
onPlanetsClick={handleOpenPlanets}
onMapClick={handleOpenMap}
onUserIsland={handleOpenUserIsland}
currentView={currentView}
/>
<div className="main-shell">
<div className="map-shell" id="map">
<Suspense
fallback={
<div className="map-page">
<div className="map-stage map-stage--placeholder">
<div className="map-placeholder-content">
<div className="map-placeholder-title">{t('map.loadingMap')}</div>
<div className="map-placeholder-subtitle">{t('map.warmingUp')}</div>
</div>
<div className="map-placeholder-overlay map-placeholder-top">
<div className="map-placeholder-pill map-placeholder-pill--wide" />
</div>
<div className="map-placeholder-overlay map-placeholder-left">
<div className="map-placeholder-pill" />
<div className="map-placeholder-pill" />
<div className="map-placeholder-pill" />
</div>
<div className="map-placeholder-overlay map-placeholder-right">
<div className="map-placeholder-pill" />
<div className="map-placeholder-pill" />
</div>
<div className="map-placeholder-overlay map-placeholder-bottom">
<div className="map-placeholder-chip" />
<div className="map-placeholder-chip" />
<div className="map-placeholder-chip" />
</div>
</div>
</div>
}
{/* World Switcher Navigation */}
<div className="world-nav">
<button
className={`world-nav-btn ${currentView === 'map' ? 'is-active' : ''}`}
onClick={handleOpenMap}
>
<i className="fa-solid fa-earth-americas" />
<span>{t('worlds.map')}</span>
</button>
<button
className={`world-nav-btn ${currentView === 'islands' ? 'is-active' : ''}`}
onClick={handleOpenIslands}
>
<i className="fa-solid fa-umbrella-beach" />
<span>{t('worlds.islands')}</span>
</button>
<button
className={`world-nav-btn ${currentView === 'planets' ? 'is-active' : ''}`}
onClick={handleOpenPlanets}
>
<i className="fa-solid fa-globe" />
<span>{t('worlds.planets')}</span>
</button>
</div>
<div className="main-shell">
<AnimatePresence mode="wait">
{/* MAP WORLD - Reality */}
{currentView === "map" && (
<motion.div
key="map-world"
className="world-container"
initial={{ opacity: 0, x: -50 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 50 }}
transition={{ duration: 0.3 }}
>
<div className="map-shell" id="map">
<Suspense fallback={<WorldPlaceholder title={t('map.loadingMap')} subtitle={t('map.warmingUp')} />}>
<MapView
theme={theme}
contentFilter={contentFilter}
@ -233,21 +261,19 @@ export default function App() {
onSelectPost={setSelectedPost}
selectedIsland={selectedIsland}
onSelectIsland={setSelectedIsland}
onOpenIslands={handleOpenUniverse}
onOpenIslands={handleOpenIslands}
/>
</Suspense>
</div>
{/* BELOW THE MAP (page scroll like Facebook) */}
<div className="below-stage">
{/* Content filters - shared between map and sociowall */}
<ContentFilters
activeFilter={contentFilter}
onFilterChange={setContentFilter}
/>
<div className="below-row">
<div className="sociowall-panel" id="wall">
{/* SocioWall header inside panel */}
<div className="sociowall-header">
<h2 className="sociowall-title">SOCIOWALL</h2>
</div>
@ -264,26 +290,55 @@ export default function App() {
</div>
</div>
</div>
</motion.div>
)}
<div style={{ height: 18 }} />
{/* ISLANDS WORLD - Personal Metaverse */}
{currentView === "islands" && (
<motion.div
key="islands-world"
className="world-container world-fullscreen"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.3 }}
>
<Suspense fallback={<WorldPlaceholder title={t('worlds.loadingIslands')} subtitle={t('worlds.enteringMetaverse')} />}>
<IslandsWorld
theme={theme}
onIslandClick={handleIslandClick}
onOpenMap={handleOpenMap}
/>
</Suspense>
</motion.div>
)}
<section className="seo-section" id="islands">
<div className="seo-card">
<div className="seo-kicker">{t('islands.communities')}</div>
<h2>{t('islands.exploreIslands')}</h2>
<p>
{t('islands.islandsDescription')}
</p>
<button className="seo-cta" type="button" onClick={handleOpenUniverse}>
{t('islands.openIslands')}
</button>
</div>
</section>
{/* PLANETS WORLD - Group Metaverse */}
{currentView === "planets" && (
<motion.div
key="planets-world"
className="world-container world-fullscreen"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.3 }}
>
<Suspense fallback={<WorldPlaceholder title={t('worlds.loadingPlanets')} subtitle={t('worlds.enteringMetaverse')} />}>
<PlanetsWorld
theme={theme}
onOpenMap={handleOpenMap}
/>
</Suspense>
</motion.div>
)}
</AnimatePresence>
{/* Chat System */}
{/* Chat System - available in all worlds */}
<ChatContainer />
</div>
{/* Footer only shows in map view */}
{currentView === "map" && (
<footer className="sw-footer">
<div className="sw-footer-inner">
<div className="sw-footer-brand">SOCIOWIRE</div>
@ -300,13 +355,6 @@ export default function App() {
</nav>
</div>
</footer>
{/* Island Universe Modal */}
{showUniverse && (
<IslandUniverse
onClose={handleCloseUniverse}
onIslandClick={handleIslandClick}
/>
)}
{/* Island Viewer Modal */}

View File

@ -93,7 +93,7 @@ export default function IslandUniverse({ onClose, onIslandClick }) {
// Use grid-based positioning to prevent overlap
const gridSize = Math.ceil(Math.sqrt(displayIslands.length || 1));
const spacing = 8; // degrees between islands
const spacing = 15; // degrees between islands (increased for better separation)
displayIslands.forEach((island, idx) => {
// Grid position with random offset for organic feel
@ -101,9 +101,9 @@ export default function IslandUniverse({ onClose, onIslandClick }) {
const gridY = Math.floor(idx / gridSize);
const baseX = (gridX - gridSize / 2) * spacing;
const baseY = (gridY - gridSize / 2) * spacing;
// Add controlled randomness within cell
const jitterX = (Math.random() - 0.5) * spacing * 0.6;
const jitterY = (Math.random() - 0.5) * spacing * 0.6;
// Add controlled randomness within cell (reduced jitter to prevent overlap)
const jitterX = (Math.random() - 0.5) * spacing * 0.3;
const jitterY = (Math.random() - 0.5) * spacing * 0.3;
const lng = centerLng + baseX + jitterX;
const lat = centerLat + baseY + jitterY;
@ -189,10 +189,10 @@ export default function IslandUniverse({ onClose, onIslandClick }) {
markers.push({ marker, island, el });
});
// Add planet markers (positioned in outer ring)
// Add planet markers (positioned in outer ring with more space)
displayPlanets.forEach((planet, idx) => {
const angle = (idx / Math.max(planets.length, 1)) * Math.PI * 2;
const radius = 18 + (idx % 3) * 4;
const radius = 30 + (idx % 3) * 8; // increased radius for better separation
const lng = centerLng + Math.cos(angle) * radius;
const lat = centerLat + Math.sin(angle) * radius;

View File

@ -235,7 +235,7 @@ function UserChip({ authenticated, loading, username, avatarUrl, onClick }) {
// Main Component
//
export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClick, onUserIsland, onMyLocation, onSearch }) {
export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClick, onPlanetsClick, onMapClick, onUserIsland, onMyLocation, onSearch, currentView }) {
const { t, i18n } = useTranslation();
const {
loading, authenticated, username, login, logout, restore, loginWithProvider, ssoEnabled,

View File

@ -0,0 +1,335 @@
/* Islands World - Full screen metaverse */
.islands-world {
position: relative;
width: 100%;
min-height: 100vh;
background: linear-gradient(180deg, #0a1628 0%, #0d2847 50%, #1e3a5f 100%);
overflow: hidden;
}
.islands-world-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
/* Search bar */
.islands-world-search {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 100;
display: flex;
align-items: center;
gap: 10px;
background: rgba(15, 23, 42, 0.9);
backdrop-filter: blur(20px);
border: 1px solid rgba(56, 189, 248, 0.3);
border-radius: 50px;
padding: 10px 16px;
width: 90%;
max-width: 360px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.islands-world-search .search-icon {
color: #38bdf8;
font-size: 18px;
}
.islands-world-search i {
color: rgba(255, 255, 255, 0.5);
font-size: 14px;
}
.islands-world-search .search-count {
font-size: 11px;
color: rgba(56, 189, 248, 0.8);
font-weight: 600;
padding: 2px 8px;
background: rgba(56, 189, 248, 0.15);
border-radius: 20px;
}
.islands-world-search input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: white;
font-size: 14px;
font-weight: 500;
}
.islands-world-search input::placeholder {
color: rgba(255, 255, 255, 0.5);
}
.islands-world-search .search-clear {
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
color: white;
cursor: pointer;
transition: background 0.2s;
}
.islands-world-search .search-clear:hover {
background: rgba(255, 255, 255, 0.2);
}
/* Island markers */
.island-marker {
cursor: pointer;
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.island-marker:hover {
transform: scale(1.15) translateY(-8px);
z-index: 100;
}
.island-marker-inner {
position: relative;
animation: islandFloat 6s ease-in-out infinite;
filter: drop-shadow(0 8px 24px rgba(0, 0, 0, 0.6));
}
.island-marker:hover .island-marker-inner {
filter: drop-shadow(0 12px 32px rgba(56, 189, 248, 0.4));
}
.island-svg {
overflow: visible;
}
.island-marker-label {
position: absolute;
bottom: -30px;
left: 50%;
transform: translateX(-50%);
background: linear-gradient(135deg, rgba(15, 23, 42, 0.95), rgba(30, 41, 59, 0.9));
padding: 6px 14px;
border-radius: 16px;
border: 1px solid rgba(56, 189, 248, 0.3);
white-space: nowrap;
text-align: center;
backdrop-filter: blur(8px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
}
.island-marker-name {
display: block;
font-size: 12px;
font-weight: 700;
}
.island-marker-meta {
display: block;
font-size: 9px;
color: rgba(255, 255, 255, 0.6);
margin-top: 2px;
}
/* Tooltip */
.islands-world-tooltip {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
z-index: 200;
display: flex;
align-items: center;
gap: 16px;
background: linear-gradient(135deg, rgba(15, 23, 42, 0.98), rgba(30, 41, 59, 0.95));
backdrop-filter: blur(20px);
border: 1px solid rgba(56, 189, 248, 0.4);
border-radius: 20px;
padding: 16px 24px;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.5);
animation: tooltipSlide 0.3s ease;
}
@keyframes tooltipSlide {
from {
opacity: 0;
transform: translateX(-50%) translateY(20px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
.tooltip-avatar {
width: 56px;
height: 56px;
border-radius: 50%;
overflow: hidden;
background: linear-gradient(135deg, #38bdf8, #06b6d4);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4px 16px rgba(56, 189, 248, 0.4);
}
.tooltip-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.tooltip-avatar span {
font-size: 24px;
font-weight: 700;
color: white;
}
.tooltip-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.tooltip-username {
font-size: 16px;
font-weight: 700;
color: #38bdf8;
}
.tooltip-bio {
font-size: 13px;
color: rgba(255, 255, 255, 0.8);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tooltip-stats {
font-size: 11px;
color: rgba(255, 255, 255, 0.5);
font-weight: 600;
}
/* Create island button */
.islands-world-create {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 200;
display: flex;
align-items: center;
gap: 10px;
background: linear-gradient(135deg, #10b981, #059669);
border: none;
border-radius: 50px;
padding: 14px 24px;
color: white;
font-size: 14px;
font-weight: 700;
cursor: pointer;
box-shadow: 0 8px 32px rgba(16, 185, 129, 0.4);
transition: all 0.3s ease;
}
.islands-world-create:hover {
transform: scale(1.05);
box-shadow: 0 12px 40px rgba(16, 185, 129, 0.5);
}
.islands-world-create i {
font-size: 16px;
}
/* Loading overlay */
.islands-world-loading {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
background: rgba(10, 22, 40, 0.95);
z-index: 300;
}
.loading-spinner {
width: 60px;
height: 60px;
border: 4px solid rgba(56, 189, 248, 0.2);
border-top-color: #38bdf8;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.islands-world-loading p {
color: rgba(255, 255, 255, 0.8);
font-size: 16px;
font-weight: 600;
}
/* Float animation */
@keyframes islandFloat {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-12px);
}
}
/* Responsive */
@media (max-width: 768px) {
.islands-world-title {
font-size: 22px;
}
.islands-world-title i {
font-size: 24px;
}
.islands-world-search {
top: 90px;
padding: 10px 16px;
}
.islands-world-tooltip {
bottom: 70px;
padding: 12px 16px;
gap: 12px;
}
.tooltip-avatar {
width: 44px;
height: 44px;
}
.tooltip-avatar span {
font-size: 18px;
}
.tooltip-username {
font-size: 14px;
}
.islands-world-create {
bottom: 16px;
right: 16px;
padding: 12px 18px;
font-size: 13px;
}
}

View File

@ -0,0 +1,408 @@
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 { 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 }) {
const { t } = useTranslation();
const { authenticated, username } = useAuth();
const containerRef = useRef(null);
const mapRef = useRef(null);
const markersRef = useRef([]);
const animationRef = useRef(null);
const [islands, setIslands] = useState([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [hoveredIsland, setHoveredIsland] = useState(null);
// 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;
// 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' // Deep ocean
}
}
]
},
center: [centerLng, centerLat],
zoom: 1,
pitch: 50,
bearing: 0,
antialias: true,
attributionControl: false
});
mapRef.current = map;
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
const markers = [];
const gridSize = Math.ceil(Math.sqrt(filteredIslands.length || 1));
const spacing = 20;
filteredIslands.forEach((island, idx) => {
const gridX = idx % gridSize;
const gridY = Math.floor(idx / gridSize);
const baseX = (gridX - gridSize / 2) * spacing;
const baseY = (gridY - gridSize / 2) * spacing;
const jitterX = (Math.random() - 0.5) * spacing * 0.4;
const jitterY = (Math.random() - 0.5) * spacing * 0.4;
const lng = centerLng + baseX + jitterX;
const lat = centerLat + baseY + jitterY;
const el = createIslandMarker(island, idx, t);
el.addEventListener('mouseenter', () => setHoveredIsland(island));
el.addEventListener('mouseleave', () => setHoveredIsland(null));
el.addEventListener('click', (e) => {
e.stopPropagation();
onIslandClick?.(island);
});
const marker = new maplibregl.Marker({
element: el,
anchor: 'center'
})
.setLngLat([lng, lat])
.addTo(map);
markers.push({ marker, island, el });
});
markersRef.current = markers;
// Gentle rotation
let bearing = 0;
function animate() {
bearing += 0.05;
if (bearing >= 360) bearing = 0;
map.rotateTo(bearing, { duration: 100 });
animationRef.current = requestAnimationFrame(animate);
}
animate();
});
return () => {
if (animationRef.current) cancelAnimationFrame(animationRef.current);
markersRef.current.forEach(({ marker }) => marker.remove());
markersRef.current = [];
map.remove();
};
}, [filteredIslands, loading, onIslandClick, t]);
return (
<div className="islands-world">
{/* Search bar with icon */}
<div className="islands-world-search">
<i className="fa-solid fa-umbrella-beach search-icon" />
<i className="fa-solid fa-search" />
<input
type="text"
placeholder={t('worlds.searchIslands')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<button onClick={() => setSearchQuery('')} className="search-clear">
<i className="fa-solid fa-xmark" />
</button>
)}
<span className="search-count">{islands.length}</span>
</div>
{/* Main canvas */}
<div ref={containerRef} className="islands-world-canvas" />
{/* Hovered island tooltip */}
{hoveredIsland && (
<div className="islands-world-tooltip">
<div className="tooltip-avatar">
{hoveredIsland.avatar_url ? (
<img src={hoveredIsland.avatar_url} alt={hoveredIsland.username} />
) : (
<span>{(hoveredIsland.username || 'U')[0].toUpperCase()}</span>
)}
</div>
<div className="tooltip-info">
<div className="tooltip-username">@{hoveredIsland.username}</div>
<div className="tooltip-bio">{hoveredIsland.display_name || hoveredIsland.bio || t('worlds.clickToVisit')}</div>
<div className="tooltip-stats">
{hoveredIsland.content_count || 0} {t('worlds.posts')} {hoveredIsland.terrain_type || 'tropical'}
</div>
</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 && (
<div className="islands-world-loading">
<div className="loading-spinner" />
<p>{t('worlds.loadingIslands')}</p>
</div>
)}
</div>
);
}
/**
* Create a stylized island marker element
*/
function createIslandMarker(island, idx, t) {
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;
// Generate unique island shape
const islandSVG = generateIslandSVG(seed, color);
const postCount = island.content_count || 0;
el.innerHTML = `
<div class="island-marker-inner" style="animation-delay: ${(idx % 12) * 0.25}s;">
${islandSVG}
<div class="island-marker-label">
<span class="island-marker-name" style="color: ${color}">@${island.username}</span>
<span class="island-marker-meta">${postCount} posts</span>
</div>
</div>
`;
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;
// Generate organic shape
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
});
}
// Create smooth path
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';
// Add decorations
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 += `
<g transform="translate(${tx}, ${ty})">
<rect x="-1" y="${-treeSize}" width="2" height="${treeSize}" fill="#5D4037" rx="0.5"/>
<ellipse cx="0" cy="${-treeSize}" rx="${treeSize * 0.8}" ry="${treeSize * 0.5}" fill="#228B22" opacity="0.9"/>
</g>
`;
}
return `
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" class="island-svg">
<defs>
<radialGradient id="ig-${seed}" cx="30%" cy="30%">
<stop offset="0%" stop-color="${color}" stop-opacity="1" />
<stop offset="70%" stop-color="${color}" stop-opacity="0.85" />
<stop offset="100%" stop-color="${color}" stop-opacity="0.6" />
</radialGradient>
<filter id="glow-${seed}">
<feGaussianBlur stdDeviation="4" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- Beach ring -->
<path d="${path}" fill="#F5DEB3" transform="scale(1.08) translate(-5.6, -5.6)" opacity="0.6"/>
<!-- Main island -->
<path d="${path}" fill="url(#ig-${seed})" filter="url(#glow-${seed})"/>
<!-- Trees -->
${trees}
</svg>
`;
}
/**
* Create starfield GeoJSON
*/
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 };
}
/**
* Create ocean gradient GeoJSON
*/
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 };
}
/**
* Seeded random number generator
*/
function seededRandom(seed) {
let s = seed;
return function() {
s = (s * 9301 + 49297) % 233280;
return s / 233280;
};
}

View File

@ -0,0 +1,498 @@
/* Planets World - Full screen group metaverse */
.planets-world {
position: relative;
width: 100%;
min-height: 100vh;
background: radial-gradient(ellipse at center, #0f0f1a 0%, #050510 70%, #000005 100%);
overflow: hidden;
}
.planets-world-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
/* Search bar */
.planets-world-search {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 100;
display: flex;
align-items: center;
gap: 10px;
background: rgba(15, 15, 26, 0.9);
backdrop-filter: blur(20px);
border: 1px solid rgba(155, 89, 182, 0.3);
border-radius: 50px;
padding: 10px 16px;
width: 90%;
max-width: 360px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
}
.planets-world-search .search-icon {
color: #9b59b6;
font-size: 18px;
}
.planets-world-search i {
color: rgba(255, 255, 255, 0.5);
font-size: 14px;
}
.planets-world-search .search-count {
font-size: 11px;
color: rgba(155, 89, 182, 0.9);
font-weight: 600;
padding: 2px 8px;
background: rgba(155, 89, 182, 0.15);
border-radius: 20px;
}
.planets-world-search input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: white;
font-size: 14px;
font-weight: 500;
}
.planets-world-search input::placeholder {
color: rgba(255, 255, 255, 0.5);
}
.planets-world-search .search-clear {
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
color: white;
cursor: pointer;
transition: background 0.2s;
}
.planets-world-search .search-clear:hover {
background: rgba(255, 255, 255, 0.2);
}
/* Planet markers */
.planet-marker {
cursor: pointer;
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.planet-marker:hover {
transform: scale(1.2);
z-index: 100;
}
.planet-marker-inner {
position: relative;
animation: planetOrbit 12s ease-in-out infinite;
filter: drop-shadow(0 0 20px rgba(155, 89, 182, 0.4));
}
.planet-marker:hover .planet-marker-inner {
filter: drop-shadow(0 0 30px rgba(155, 89, 182, 0.7));
}
.planet-svg {
overflow: visible;
}
.planet-marker-label {
position: absolute;
bottom: -25px;
left: 50%;
transform: translateX(-50%);
background: linear-gradient(135deg, rgba(15, 15, 26, 0.95), rgba(30, 30, 50, 0.9));
padding: 6px 14px;
border-radius: 16px;
border: 1px solid rgba(155, 89, 182, 0.3);
white-space: nowrap;
text-align: center;
backdrop-filter: blur(8px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
}
.planet-marker-name {
display: block;
font-size: 12px;
font-weight: 700;
color: #d4a5ff;
}
.planet-marker-meta {
display: block;
font-size: 9px;
color: rgba(255, 255, 255, 0.6);
margin-top: 2px;
}
/* Tooltip */
.planets-world-tooltip {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
z-index: 200;
display: flex;
align-items: center;
gap: 16px;
background: linear-gradient(135deg, rgba(15, 15, 26, 0.98), rgba(30, 30, 50, 0.95));
backdrop-filter: blur(20px);
border: 1px solid rgba(155, 89, 182, 0.4);
border-radius: 20px;
padding: 16px 24px;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.6);
animation: tooltipSlide 0.3s ease;
}
@keyframes tooltipSlide {
from {
opacity: 0;
transform: translateX(-50%) translateY(20px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
.tooltip-icon {
width: 56px;
height: 56px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4px 16px rgba(155, 89, 182, 0.4);
}
.tooltip-icon i {
font-size: 24px;
color: white;
}
.tooltip-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.tooltip-name {
font-size: 16px;
font-weight: 700;
color: #d4a5ff;
}
.tooltip-desc {
font-size: 13px;
color: rgba(255, 255, 255, 0.8);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tooltip-stats {
font-size: 11px;
color: rgba(255, 255, 255, 0.5);
font-weight: 600;
}
/* Planet detail modal */
.planet-modal-backdrop {
position: fixed;
inset: 0;
z-index: 500;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(10px);
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.planet-modal {
width: 90%;
max-width: 480px;
background: linear-gradient(135deg, #1a1a2e 0%, #0f0f1a 100%);
border: 1px solid rgba(155, 89, 182, 0.3);
border-radius: 24px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
animation: modalSlide 0.3s ease;
}
@keyframes modalSlide {
from {
opacity: 0;
transform: scale(0.9) translateY(20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.planet-modal-close {
position: absolute;
top: 16px;
right: 16px;
width: 36px;
height: 36px;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
color: white;
font-size: 16px;
cursor: pointer;
z-index: 10;
transition: background 0.2s;
}
.planet-modal-close:hover {
background: rgba(255, 255, 255, 0.2);
}
.planet-modal-header {
position: relative;
padding: 40px 24px 24px;
text-align: center;
}
.planet-modal-icon {
width: 80px;
height: 80px;
margin: 0 auto 16px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.15);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.planet-modal-icon i {
font-size: 36px;
color: white;
}
.planet-modal-header h2 {
font-size: 24px;
font-weight: 800;
color: white;
margin: 0 0 8px;
}
.planet-modal-header p {
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
margin: 0;
}
.planet-modal-stats {
display: flex;
justify-content: center;
gap: 32px;
padding: 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.planet-modal-stats .stat {
text-align: center;
}
.planet-modal-stats .stat-value {
display: block;
font-size: 24px;
font-weight: 800;
color: #d4a5ff;
}
.planet-modal-stats .stat-label {
display: block;
font-size: 11px;
color: rgba(255, 255, 255, 0.5);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 4px;
}
.planet-modal-actions {
display: flex;
gap: 12px;
padding: 24px;
}
.planet-action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 14px 20px;
border: none;
border-radius: 12px;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: all 0.3s ease;
background: rgba(255, 255, 255, 0.1);
color: white;
}
.planet-action-btn:hover {
background: rgba(255, 255, 255, 0.15);
}
.planet-action-btn.planet-action-primary {
background: linear-gradient(135deg, #9b59b6, #8e44ad);
box-shadow: 0 4px 16px rgba(155, 89, 182, 0.4);
}
.planet-action-btn.planet-action-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(155, 89, 182, 0.5);
}
/* Create Planet button */
.planets-world-create {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 200;
display: flex;
align-items: center;
gap: 10px;
background: linear-gradient(135deg, #9b59b6, #8e44ad);
border: none;
border-radius: 50px;
padding: 14px 24px;
color: white;
font-size: 14px;
font-weight: 700;
cursor: pointer;
box-shadow: 0 8px 32px rgba(155, 89, 182, 0.4);
transition: all 0.3s ease;
}
.planets-world-create:hover {
transform: scale(1.05);
box-shadow: 0 12px 40px rgba(155, 89, 182, 0.5);
}
.planets-world-create i {
font-size: 16px;
}
/* Loading overlay */
.planets-world-loading {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
background: rgba(5, 5, 16, 0.95);
z-index: 300;
}
.planets-world-loading .loading-spinner {
width: 60px;
height: 60px;
border: 4px solid rgba(155, 89, 182, 0.2);
border-top-color: #9b59b6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.planets-world-loading p {
color: rgba(255, 255, 255, 0.8);
font-size: 16px;
font-weight: 600;
}
/* Orbit animation */
@keyframes planetOrbit {
0%, 100% {
transform: translateY(0) rotate(0deg);
}
25% {
transform: translateY(-8px) rotate(2deg);
}
50% {
transform: translateY(0) rotate(0deg);
}
75% {
transform: translateY(8px) rotate(-2deg);
}
}
/* Responsive */
@media (max-width: 768px) {
.planets-world-title {
font-size: 22px;
}
.planets-world-title i {
font-size: 24px;
}
.planets-world-search {
top: 90px;
padding: 10px 16px;
}
.planets-world-tooltip {
bottom: 70px;
padding: 12px 16px;
gap: 12px;
}
.tooltip-icon {
width: 44px;
height: 44px;
}
.tooltip-icon i {
font-size: 18px;
}
.tooltip-name {
font-size: 14px;
}
.planets-world-create {
bottom: 16px;
right: 16px;
padding: 12px 18px;
font-size: 13px;
}
.planet-modal {
width: 95%;
}
}

View File

@ -0,0 +1,392 @@
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { fetchPlanets } from '../../api/client';
import { useAuth } from '../../auth/AuthContext';
import './PlanetsWorld.css';
/**
* PlanetsWorld: Full-screen metaverse for group spaces (planets)
* Each planet represents a community/group floating in space
*/
export default function PlanetsWorld({ theme, onOpenMap }) {
const { t } = useTranslation();
const { authenticated } = useAuth();
const containerRef = useRef(null);
const mapRef = useRef(null);
const markersRef = useRef([]);
const animationRef = useRef(null);
const [planets, setPlanets] = useState([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [hoveredPlanet, setHoveredPlanet] = useState(null);
const [selectedPlanet, setSelectedPlanet] = useState(null);
// Fetch planets
useEffect(() => {
let mounted = true;
setLoading(true);
fetchPlanets({ limit: 100 })
.then(data => {
if (mounted) {
setPlanets(data || []);
setLoading(false);
}
})
.catch(err => {
console.warn('[PlanetsWorld] Failed to fetch planets:', err);
if (mounted) setLoading(false);
});
return () => { mounted = false; };
}, []);
// Filter planets by search
const filteredPlanets = searchQuery
? planets.filter(p =>
(p.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
(p.description || '').toLowerCase().includes(searchQuery.toLowerCase())
)
: planets;
// 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': '#050510' // Deep space black
}
}
]
},
center: [centerLng, centerLat],
zoom: 0.8,
pitch: 45,
bearing: 0,
antialias: true,
attributionControl: false
});
mapRef.current = map;
map.on('load', () => {
// Add nebula effect
const nebulaData = createNebulaEffect();
map.addSource('nebula', { type: 'geojson', data: nebulaData });
map.addLayer({
id: 'nebula',
type: 'fill',
source: 'nebula',
paint: {
'fill-color': ['get', 'color'],
'fill-opacity': ['get', 'opacity']
}
});
// Add starfield
const starsData = createStarfield(800);
map.addSource('stars', { type: 'geojson', data: starsData });
map.addLayer({
id: 'stars',
type: 'circle',
source: 'stars',
paint: {
'circle-radius': ['interpolate', ['linear'], ['zoom'], 0, 0.8, 5, 2.5],
'circle-color': ['get', 'color'],
'circle-opacity': ['get', 'opacity'],
'circle-blur': 0.2
}
});
// 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);
const posInRing = idx % 6;
const baseRadius = 25 + ring * 30;
const angle = (posInRing / 6) * Math.PI * 2 + (ring * 0.5);
const lng = centerLng + Math.cos(angle) * baseRadius;
const lat = centerLat + Math.sin(angle) * baseRadius;
const el = createPlanetMarker(planet, idx, t);
el.addEventListener('mouseenter', () => setHoveredPlanet(planet));
el.addEventListener('mouseleave', () => setHoveredPlanet(null));
el.addEventListener('click', (e) => {
e.stopPropagation();
setSelectedPlanet(planet);
});
const marker = new maplibregl.Marker({
element: el,
anchor: 'center'
})
.setLngLat([lng, lat])
.addTo(map);
markers.push({ marker, planet, el });
});
markersRef.current = markers;
// Slow cosmic rotation
let bearing = 0;
function animate() {
bearing += 0.03;
if (bearing >= 360) bearing = 0;
map.rotateTo(bearing, { duration: 100 });
animationRef.current = requestAnimationFrame(animate);
}
animate();
});
return () => {
if (animationRef.current) cancelAnimationFrame(animationRef.current);
markersRef.current.forEach(({ marker }) => marker.remove());
markersRef.current = [];
map.remove();
};
}, [filteredPlanets, loading, t]);
return (
<div className="planets-world">
{/* Search bar with icon */}
<div className="planets-world-search">
<i className="fa-solid fa-globe search-icon" />
<i className="fa-solid fa-search" />
<input
type="text"
placeholder={t('worlds.searchPlanets')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<button onClick={() => setSearchQuery('')} className="search-clear">
<i className="fa-solid fa-xmark" />
</button>
)}
<span className="search-count">{planets.length}</span>
</div>
{/* Main canvas */}
<div ref={containerRef} className="planets-world-canvas" />
{/* Hovered planet tooltip */}
{hoveredPlanet && (
<div className="planets-world-tooltip">
<div className="tooltip-icon" style={{ background: getPlanetGradient(hoveredPlanet.theme) }}>
<i className="fa-solid fa-globe" />
</div>
<div className="tooltip-info">
<div className="tooltip-name">{hoveredPlanet.name || 'Unknown Planet'}</div>
<div className="tooltip-desc">{hoveredPlanet.description || t('worlds.clickToExplore')}</div>
<div className="tooltip-stats">
{hoveredPlanet.member_count || 0} {t('worlds.members')} {hoveredPlanet.theme || 'default'}
</div>
</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 && (
<div className="planets-world-loading">
<div className="loading-spinner" />
<p>{t('worlds.loadingPlanets')}</p>
</div>
)}
</div>
);
}
/**
* Create a stylized planet marker element
*/
function createPlanetMarker(planet, idx, t) {
const el = document.createElement('div');
el.className = 'planet-marker';
const themeColors = {
'default': ['#9b59b6', '#8e44ad'],
'tech': ['#3498db', '#2980b9'],
'gaming': ['#e74c3c', '#c0392b'],
'music': ['#1abc9c', '#16a085'],
'art': ['#e91e63', '#c2185b'],
'science': ['#00bcd4', '#0097a7'],
'sports': ['#ff9800', '#f57c00'],
};
const colors = themeColors[planet.theme] || themeColors['default'];
const memberCount = planet.member_count || 0;
el.innerHTML = `
<div class="planet-marker-inner" style="animation-delay: ${(idx % 8) * 0.4}s;">
<svg width="120" height="120" viewBox="0 0 120 120" class="planet-svg">
<defs>
<radialGradient id="pg-${idx}" cx="35%" cy="35%">
<stop offset="0%" stop-color="${colors[0]}"/>
<stop offset="100%" stop-color="${colors[1]}"/>
</radialGradient>
<filter id="planet-glow-${idx}">
<feGaussianBlur stdDeviation="6" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- Ring -->
<ellipse cx="60" cy="60" rx="55" ry="18" fill="none" stroke="${colors[0]}" stroke-width="4" opacity="0.5" transform="rotate(-25 60 60)"/>
<!-- Planet body -->
<circle cx="60" cy="60" r="38" fill="url(#pg-${idx})" filter="url(#planet-glow-${idx})"/>
<!-- Highlight -->
<ellipse cx="48" cy="48" rx="12" ry="8" fill="rgba(255,255,255,0.25)" transform="rotate(-30 48 48)"/>
</svg>
<div class="planet-marker-label">
<span class="planet-marker-name">${planet.name || 'Planet'}</span>
<span class="planet-marker-meta">${memberCount} members</span>
</div>
</div>
`;
return el;
}
/**
* Get planet gradient for backgrounds
*/
function getPlanetGradient(theme) {
const gradients = {
'default': 'linear-gradient(135deg, #9b59b6, #8e44ad)',
'tech': 'linear-gradient(135deg, #3498db, #2980b9)',
'gaming': 'linear-gradient(135deg, #e74c3c, #c0392b)',
'music': 'linear-gradient(135deg, #1abc9c, #16a085)',
'art': 'linear-gradient(135deg, #e91e63, #c2185b)',
'science': 'linear-gradient(135deg, #00bcd4, #0097a7)',
'sports': 'linear-gradient(135deg, #ff9800, #f57c00)',
};
return gradients[theme] || gradients['default'];
}
/**
* Create starfield GeoJSON
*/
function createStarfield(count) {
const features = [];
for (let i = 0; i < count; i++) {
const lng = (Math.random() - 0.5) * 500;
const lat = (Math.random() - 0.5) * 500;
const brightness = Math.random();
features.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: [lng, lat] },
properties: {
color: brightness > 0.85 ? '#ffeedd' : brightness > 0.6 ? '#aaccff' : '#ffffff',
opacity: 0.2 + brightness * 0.6
}
});
}
return { type: 'FeatureCollection', features };
}
/**
* Create nebula effect GeoJSON
*/
function createNebulaEffect() {
const features = [];
const nebulaColors = [
{ color: 'rgba(155, 89, 182, 0.15)', x: -50, y: 30 },
{ color: 'rgba(52, 152, 219, 0.12)', x: 60, y: -40 },
{ color: 'rgba(231, 76, 60, 0.1)', x: -30, y: -60 },
{ color: 'rgba(26, 188, 156, 0.08)', x: 80, y: 50 },
];
nebulaColors.forEach((nebula, i) => {
const coords = [];
const numPoints = 32;
for (let j = 0; j <= numPoints; j++) {
const angle = (j / numPoints) * Math.PI * 2;
const radius = 60 + Math.random() * 30;
coords.push([
nebula.x + Math.cos(angle) * radius,
nebula.y + Math.sin(angle) * radius
]);
}
features.push({
type: 'Feature',
geometry: { type: 'Polygon', coordinates: [coords] },
properties: { color: nebula.color, opacity: 1 }
});
});
return { type: 'FeatureCollection', features };
}

View File

@ -277,6 +277,30 @@
"planetsTab": "Planets",
"members": "members"
},
"worlds": {
"map": "Reality",
"islands": "Islands",
"planets": "Planets",
"islandsTitle": "Island Metaverse",
"islandsSubtitle": "{{count}} personal islands floating in virtual space",
"planetsTitle": "Planet Metaverse",
"planetsSubtitle": "{{count}} community planets orbiting in space",
"loadingIslands": "Loading Islands",
"loadingPlanets": "Loading Planets",
"enteringMetaverse": "Entering the metaverse...",
"searchIslands": "Search islands by username...",
"searchPlanets": "Search planets by name...",
"clickToVisit": "Click to visit",
"clickToExplore": "Click to explore",
"posts": "posts",
"members": "members",
"theme": "Theme",
"createIsland": "Create My Island",
"createPlanet": "Create Planet",
"joinPlanet": "Join Planet",
"share": "Share",
"noDescription": "No description yet"
},
"controls": {
"mySpot": "My spot",
"putWire": "Put a wire"

View File

@ -277,6 +277,30 @@
"planetsTab": "Planetas",
"members": "miembros"
},
"worlds": {
"map": "Realidad",
"islands": "Islas",
"planets": "Planetas",
"islandsTitle": "Metaverso de Islas",
"islandsSubtitle": "{{count}} islas personales flotando en el espacio virtual",
"planetsTitle": "Metaverso de Planetas",
"planetsSubtitle": "{{count}} planetas comunitarios orbitando en el espacio",
"loadingIslands": "Cargando Islas",
"loadingPlanets": "Cargando Planetas",
"enteringMetaverse": "Entrando al metaverso...",
"searchIslands": "Buscar islas por nombre de usuario...",
"searchPlanets": "Buscar planetas por nombre...",
"clickToVisit": "Clic para visitar",
"clickToExplore": "Clic para explorar",
"posts": "posts",
"members": "miembros",
"theme": "Tema",
"createIsland": "Crear mi Isla",
"createPlanet": "Crear Planeta",
"joinPlanet": "Unirse al Planeta",
"share": "Compartir",
"noDescription": "Sin descripción"
},
"controls": {
"mySpot": "Mi ubicación",
"putWire": "Publicar un wire"

View File

@ -280,6 +280,30 @@
"planetsTab": "Planètes",
"members": "membres"
},
"worlds": {
"map": "Réalité",
"islands": "Îles",
"planets": "Planètes",
"islandsTitle": "Métaverse des Îles",
"islandsSubtitle": "{{count}} îles personnelles flottant dans l'espace virtuel",
"planetsTitle": "Métaverse des Planètes",
"planetsSubtitle": "{{count}} planètes communautaires en orbite dans l'espace",
"loadingIslands": "Chargement des Îles",
"loadingPlanets": "Chargement des Planètes",
"enteringMetaverse": "Entrée dans le métaverse...",
"searchIslands": "Rechercher des îles par nom d'utilisateur...",
"searchPlanets": "Rechercher des planètes par nom...",
"clickToVisit": "Cliquez pour visiter",
"clickToExplore": "Cliquez pour explorer",
"posts": "posts",
"members": "membres",
"theme": "Thème",
"createIsland": "Créer mon Île",
"createPlanet": "Créer une Planète",
"joinPlanet": "Rejoindre la Planète",
"share": "Partager",
"noDescription": "Pas de description"
},
"controls": {
"mySpot": "Ma position",
"putWire": "Poster un wire"

View File

@ -154,3 +154,111 @@
/* Espace pour les bulles */
.below-stage { padding-bottom: 70px; }
/*
World Navigation & Containers
*/
.world-nav {
position: sticky;
top: 52px;
z-index: 900;
display: flex;
justify-content: center;
gap: 8px;
padding: 8px 16px;
background: rgba(2, 8, 23, 0.95);
backdrop-filter: blur(20px);
}
.world-nav-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 50px;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.7);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.world-nav-btn i {
font-size: 14px;
}
.world-nav-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: white;
border-color: rgba(255, 255, 255, 0.2);
}
.world-nav-btn.is-active {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.3), rgba(6, 182, 212, 0.3));
border-color: rgba(59, 130, 246, 0.5);
color: white;
box-shadow: 0 4px 20px rgba(59, 130, 246, 0.25);
}
.world-nav-btn.is-active i {
color: #38bdf8;
}
/* World containers */
.world-container {
width: 100%;
}
.world-fullscreen {
min-height: calc(100vh - 110px);
}
/* World placeholder */
.world-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
background: linear-gradient(180deg, #0a1628 0%, #0d2847 100%);
}
.world-placeholder-content {
text-align: center;
}
.world-placeholder-title {
font-size: 24px;
font-weight: 700;
color: white;
margin-bottom: 8px;
}
.world-placeholder-subtitle {
font-size: 14px;
color: rgba(255, 255, 255, 0.6);
}
/* Responsive */
@media (max-width: 640px) {
.world-nav {
gap: 6px;
padding: 8px 12px;
}
.world-nav-btn {
padding: 8px 14px;
font-size: 12px;
}
.world-nav-btn span {
display: none;
}
.world-nav-btn i {
font-size: 16px;
}
}