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:
parent
ac99f0f1e4
commit
9706d673c3
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "sociowire-frontend",
|
"name": "sociowire-frontend",
|
||||||
"version": "0.0.102",
|
"version": "0.0.104",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sociowire-frontend",
|
"name": "sociowire-frontend",
|
||||||
"version": "0.0.102",
|
"version": "0.0.104",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "sociowire-frontend",
|
"name": "sociowire-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.103",
|
"version": "0.0.104",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
|
|
||||||
180
src/App.jsx
180
src/App.jsx
|
|
@ -4,23 +4,29 @@ import { version as appVersion } from "../package.json";
|
||||||
import TopBar from "./components/Layout/TopBarNew";
|
import TopBar from "./components/Layout/TopBarNew";
|
||||||
import PostList from "./components/Posts/PostList";
|
import PostList from "./components/Posts/PostList";
|
||||||
import ContentFilters from "./components/Filters/ContentFilters";
|
import ContentFilters from "./components/Filters/ContentFilters";
|
||||||
import IslandUniverse from "./components/Island/IslandUniverse";
|
|
||||||
import IslandViewer from "./components/Island/IslandViewer";
|
import IslandViewer from "./components/Island/IslandViewer";
|
||||||
import ChatContainer from "./components/Chat/ChatContainer";
|
import ChatContainer from "./components/Chat/ChatContainer";
|
||||||
import PostDetailModal from "./components/Posts/PostDetailModal";
|
import PostDetailModal from "./components/Posts/PostDetailModal";
|
||||||
import { CreatePostFAB } from "./components/Posts/PostComposer";
|
import { CreatePostFAB } from "./components/Posts/PostComposer";
|
||||||
import { AnimatePresence } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { fetchIslandByUsername } from "./api/client";
|
import { fetchIslandByUsername } from "./api/client";
|
||||||
|
|
||||||
const MapView = React.lazy(() => import("./components/Map/MapView"));
|
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 THEME_KEY = "sociowire:theme";
|
||||||
const CONTENT_FILTER_KEY = "sociowire:contentFilter";
|
const CONTENT_FILTER_KEY = "sociowire:contentFilter";
|
||||||
|
const VIEW_MODE_KEY = "sociowire:viewMode"; // "map" | "islands" | "planets"
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [theme, setTheme] = useState("blue");
|
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 (#_=_)
|
// Clean up Facebook OAuth hash fragment (#_=_)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (window.location.hash === "#_=_") {
|
if (window.location.hash === "#_=_") {
|
||||||
|
|
@ -57,8 +63,7 @@ export default function App() {
|
||||||
} catch {}
|
} catch {}
|
||||||
}, [contentFilter]);
|
}, [contentFilter]);
|
||||||
|
|
||||||
// Island Universe & Island Viewer state
|
// Island Viewer state (for viewing individual islands)
|
||||||
const [showUniverse, setShowUniverse] = useState(false);
|
|
||||||
const [selectedIsland, setSelectedIsland] = useState(null);
|
const [selectedIsland, setSelectedIsland] = useState(null);
|
||||||
|
|
||||||
// Post detail modal state (triggered from map markers)
|
// Post detail modal state (triggered from map markers)
|
||||||
|
|
@ -159,17 +164,20 @@ export default function App() {
|
||||||
return () => window.removeEventListener('sociowire:create-post', handleIslandPost);
|
return () => window.removeEventListener('sociowire:create-post', handleIslandPost);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Island Universe handlers
|
// World navigation handlers
|
||||||
const handleOpenUniverse = () => {
|
const handleOpenIslands = () => {
|
||||||
setShowUniverse(true);
|
setCurrentView("islands");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseUniverse = () => {
|
const handleOpenPlanets = () => {
|
||||||
setShowUniverse(false);
|
setCurrentView("planets");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenMap = () => {
|
||||||
|
setCurrentView("map");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleIslandClick = (island) => {
|
const handleIslandClick = (island) => {
|
||||||
setShowUniverse(false); // Close universe
|
|
||||||
setSelectedIsland(island); // Open island viewer
|
setSelectedIsland(island); // Open island viewer
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -179,52 +187,72 @@ export default function App() {
|
||||||
try {
|
try {
|
||||||
const island = await fetchIslandByUsername(u);
|
const island = await fetchIslandByUsername(u);
|
||||||
if (island) {
|
if (island) {
|
||||||
setShowUniverse(false);
|
|
||||||
setSelectedIsland(island);
|
setSelectedIsland(island);
|
||||||
}
|
}
|
||||||
} catch {}
|
} 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 (
|
return (
|
||||||
<div className="app-root">
|
<div className="app-root">
|
||||||
<TopBar
|
<TopBar
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onChangeTheme={setTheme}
|
onChangeTheme={setTheme}
|
||||||
onIslandsClick={handleOpenUniverse}
|
onIslandsClick={handleOpenIslands}
|
||||||
|
onPlanetsClick={handleOpenPlanets}
|
||||||
|
onMapClick={handleOpenMap}
|
||||||
onUserIsland={handleOpenUserIsland}
|
onUserIsland={handleOpenUserIsland}
|
||||||
|
currentView={currentView}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="main-shell">
|
{/* World Switcher Navigation */}
|
||||||
<div className="map-shell" id="map">
|
<div className="world-nav">
|
||||||
<Suspense
|
<button
|
||||||
fallback={
|
className={`world-nav-btn ${currentView === 'map' ? 'is-active' : ''}`}
|
||||||
<div className="map-page">
|
onClick={handleOpenMap}
|
||||||
<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>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
|
<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
|
<MapView
|
||||||
theme={theme}
|
theme={theme}
|
||||||
contentFilter={contentFilter}
|
contentFilter={contentFilter}
|
||||||
|
|
@ -233,21 +261,19 @@ export default function App() {
|
||||||
onSelectPost={setSelectedPost}
|
onSelectPost={setSelectedPost}
|
||||||
selectedIsland={selectedIsland}
|
selectedIsland={selectedIsland}
|
||||||
onSelectIsland={setSelectedIsland}
|
onSelectIsland={setSelectedIsland}
|
||||||
onOpenIslands={handleOpenUniverse}
|
onOpenIslands={handleOpenIslands}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* BELOW THE MAP (page scroll like Facebook) */}
|
{/* BELOW THE MAP (page scroll like Facebook) */}
|
||||||
<div className="below-stage">
|
<div className="below-stage">
|
||||||
{/* Content filters - shared between map and sociowall */}
|
|
||||||
<ContentFilters
|
<ContentFilters
|
||||||
activeFilter={contentFilter}
|
activeFilter={contentFilter}
|
||||||
onFilterChange={setContentFilter}
|
onFilterChange={setContentFilter}
|
||||||
/>
|
/>
|
||||||
<div className="below-row">
|
<div className="below-row">
|
||||||
<div className="sociowall-panel" id="wall">
|
<div className="sociowall-panel" id="wall">
|
||||||
{/* SocioWall header inside panel */}
|
|
||||||
<div className="sociowall-header">
|
<div className="sociowall-header">
|
||||||
<h2 className="sociowall-title">SOCIOWALL</h2>
|
<h2 className="sociowall-title">SOCIOWALL</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -264,26 +290,55 @@ export default function App() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
{/* PLANETS WORLD - Group Metaverse */}
|
||||||
<div className="seo-card">
|
{currentView === "planets" && (
|
||||||
<div className="seo-kicker">{t('islands.communities')}</div>
|
<motion.div
|
||||||
<h2>{t('islands.exploreIslands')}</h2>
|
key="planets-world"
|
||||||
<p>
|
className="world-container world-fullscreen"
|
||||||
{t('islands.islandsDescription')}
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
</p>
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
<button className="seo-cta" type="button" onClick={handleOpenUniverse}>
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
{t('islands.openIslands')}
|
transition={{ duration: 0.3 }}
|
||||||
</button>
|
>
|
||||||
</div>
|
<Suspense fallback={<WorldPlaceholder title={t('worlds.loadingPlanets')} subtitle={t('worlds.enteringMetaverse')} />}>
|
||||||
</section>
|
<PlanetsWorld
|
||||||
|
theme={theme}
|
||||||
|
onOpenMap={handleOpenMap}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Chat System */}
|
{/* Chat System - available in all worlds */}
|
||||||
<ChatContainer />
|
<ChatContainer />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Footer only shows in map view */}
|
||||||
|
{currentView === "map" && (
|
||||||
<footer className="sw-footer">
|
<footer className="sw-footer">
|
||||||
<div className="sw-footer-inner">
|
<div className="sw-footer-inner">
|
||||||
<div className="sw-footer-brand">SOCIOWIRE</div>
|
<div className="sw-footer-brand">SOCIOWIRE</div>
|
||||||
|
|
@ -300,13 +355,6 @@ export default function App() {
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{/* Island Universe Modal */}
|
|
||||||
{showUniverse && (
|
|
||||||
<IslandUniverse
|
|
||||||
onClose={handleCloseUniverse}
|
|
||||||
onIslandClick={handleIslandClick}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Island Viewer Modal */}
|
{/* Island Viewer Modal */}
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ export default function IslandUniverse({ onClose, onIslandClick }) {
|
||||||
|
|
||||||
// Use grid-based positioning to prevent overlap
|
// Use grid-based positioning to prevent overlap
|
||||||
const gridSize = Math.ceil(Math.sqrt(displayIslands.length || 1));
|
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) => {
|
displayIslands.forEach((island, idx) => {
|
||||||
// Grid position with random offset for organic feel
|
// 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 gridY = Math.floor(idx / gridSize);
|
||||||
const baseX = (gridX - gridSize / 2) * spacing;
|
const baseX = (gridX - gridSize / 2) * spacing;
|
||||||
const baseY = (gridY - gridSize / 2) * spacing;
|
const baseY = (gridY - gridSize / 2) * spacing;
|
||||||
// Add controlled randomness within cell
|
// Add controlled randomness within cell (reduced jitter to prevent overlap)
|
||||||
const jitterX = (Math.random() - 0.5) * spacing * 0.6;
|
const jitterX = (Math.random() - 0.5) * spacing * 0.3;
|
||||||
const jitterY = (Math.random() - 0.5) * spacing * 0.6;
|
const jitterY = (Math.random() - 0.5) * spacing * 0.3;
|
||||||
const lng = centerLng + baseX + jitterX;
|
const lng = centerLng + baseX + jitterX;
|
||||||
const lat = centerLat + baseY + jitterY;
|
const lat = centerLat + baseY + jitterY;
|
||||||
|
|
||||||
|
|
@ -189,10 +189,10 @@ export default function IslandUniverse({ onClose, onIslandClick }) {
|
||||||
markers.push({ marker, island, el });
|
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) => {
|
displayPlanets.forEach((planet, idx) => {
|
||||||
const angle = (idx / Math.max(planets.length, 1)) * Math.PI * 2;
|
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 lng = centerLng + Math.cos(angle) * radius;
|
||||||
const lat = centerLat + Math.sin(angle) * radius;
|
const lat = centerLat + Math.sin(angle) * radius;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -235,7 +235,7 @@ function UserChip({ authenticated, loading, username, avatarUrl, onClick }) {
|
||||||
// Main Component
|
// 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 { t, i18n } = useTranslation();
|
||||||
const {
|
const {
|
||||||
loading, authenticated, username, login, logout, restore, loginWithProvider, ssoEnabled,
|
loading, authenticated, username, login, logout, restore, loginWithProvider, ssoEnabled,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -277,6 +277,30 @@
|
||||||
"planetsTab": "Planets",
|
"planetsTab": "Planets",
|
||||||
"members": "members"
|
"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": {
|
"controls": {
|
||||||
"mySpot": "My spot",
|
"mySpot": "My spot",
|
||||||
"putWire": "Put a wire"
|
"putWire": "Put a wire"
|
||||||
|
|
|
||||||
|
|
@ -277,6 +277,30 @@
|
||||||
"planetsTab": "Planetas",
|
"planetsTab": "Planetas",
|
||||||
"members": "miembros"
|
"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": {
|
"controls": {
|
||||||
"mySpot": "Mi ubicación",
|
"mySpot": "Mi ubicación",
|
||||||
"putWire": "Publicar un wire"
|
"putWire": "Publicar un wire"
|
||||||
|
|
|
||||||
|
|
@ -280,6 +280,30 @@
|
||||||
"planetsTab": "Planètes",
|
"planetsTab": "Planètes",
|
||||||
"members": "membres"
|
"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": {
|
"controls": {
|
||||||
"mySpot": "Ma position",
|
"mySpot": "Ma position",
|
||||||
"putWire": "Poster un wire"
|
"putWire": "Poster un wire"
|
||||||
|
|
|
||||||
|
|
@ -154,3 +154,111 @@
|
||||||
|
|
||||||
/* Espace pour les bulles */
|
/* Espace pour les bulles */
|
||||||
.below-stage { padding-bottom: 70px; }
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue