Improve map fetch, search UX, and filters

This commit is contained in:
Your Name 2025-12-24 14:30:44 -05:00
parent e38238d199
commit eca8c6c270
20 changed files with 2798 additions and 182 deletions

10
package-lock.json generated
View File

@ -59,6 +59,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@ -1084,6 +1085,7 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@ -1134,6 +1136,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -1256,6 +1259,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -1557,6 +1561,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -2777,6 +2782,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -2862,6 +2868,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -2871,6 +2878,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@ -3173,6 +3181,7 @@
"integrity": "sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/runtime": "0.97.0",
"fdir": "^6.5.0",
@ -3295,6 +3304,7 @@
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@ -1,6 +1,8 @@
import React, { Suspense, useEffect, useState, useCallback } from "react";
import TopBar from "./components/Layout/TopBar";
import PostList from "./components/Posts/PostList";
import IslandUniverse from "./components/Island/IslandUniverse";
import IslandViewer from "./components/Island/IslandViewer";
const MapView = React.lazy(() => import("./components/Map/MapView"));
@ -16,6 +18,10 @@ export default function App() {
const [wallError, setWallError] = useState("");
const [selectedPost, setSelectedPost] = useState(null);
// Island Universe & Island Viewer state
const [showUniverse, setShowUniverse] = useState(false);
const [selectedIsland, setSelectedIsland] = useState(null);
const handlePostsState = useCallback((next) => {
if (!next) return;
if (Array.isArray(next.posts)) setWallPosts(next.posts);
@ -79,9 +85,27 @@ export default function App() {
const contactsList = ["Amely", "Nancy", "Stéphanie"];
// Island Universe handlers
const handleOpenUniverse = () => {
setShowUniverse(true);
};
const handleCloseUniverse = () => {
setShowUniverse(false);
};
const handleIslandClick = (island) => {
setShowUniverse(false); // Close universe
setSelectedIsland(island); // Open island viewer
};
return (
<div className="app-root">
<TopBar theme={theme} onChangeTheme={setTheme} />
<TopBar
theme={theme}
onChangeTheme={setTheme}
onIslandsClick={handleOpenUniverse}
/>
<div className="main-shell">
<div className="map-shell">
@ -91,6 +115,8 @@ export default function App() {
onPostsState={handlePostsState}
selectedPost={selectedPost}
onSelectPost={setSelectedPost}
selectedIsland={selectedIsland}
onSelectIsland={setSelectedIsland}
/>
</Suspense>
</div>
@ -143,6 +169,22 @@ export default function App() {
))}
</div>
</div>
{/* Island Universe Modal */}
{showUniverse && (
<IslandUniverse
onClose={handleCloseUniverse}
onIslandClick={handleIslandClick}
/>
)}
{/* Island Viewer Modal */}
{selectedIsland && (
<IslandViewer
island={selectedIsland}
onClose={() => setSelectedIsland(null)}
/>
)}
</div>
);
}

View File

@ -214,13 +214,16 @@ export async function fetchUnifiedSearch(query, params = {}) {
const res = await fetch(url, { credentials: "include" });
if (!res.ok) {
console.warn("fetchUnifiedSearch failed", res.status);
return [];
return { posts: [], profiles: [], places: [] };
}
const data = await res.json();
return Array.isArray(data) ? data : [];
if (data && typeof data === "object" && !Array.isArray(data)) {
return data;
}
return { posts: [], profiles: [], places: [] };
} catch (err) {
console.warn("fetchUnifiedSearch error:", err);
return [];
return { posts: [], profiles: [], places: [] };
}
}
@ -238,7 +241,7 @@ export async function pollNewPosts(params = {}) {
if (params.category) qs.set("category", params.category);
if (typeof params.limit === "number") qs.set("limit", String(params.limit));
const url = `http://localhost:8091/api/poll?${qs.toString()}`;
const url = `${API_BASE}/poll?${qs.toString()}`;
try {
const res = await fetch(url, { credentials: "include" });
@ -252,3 +255,262 @@ export async function pollNewPosts(params = {}) {
return { posts: [], count: 0, max_id: 0 };
}
}
/**
* ISLAND SERVICE: Fetch islands in viewport
* Queries island-service microservice (Go) for user islands
*
* NOTE: Currently using MOCK DATA for testing
* Replace with real API call when island-service is ready
*/
export async function fetchIslands(params = {}) {
// MOCK DATA - Remove this and uncomment API call when backend is ready
console.log('[fetchIslands] Using MOCK data', params);
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 300));
const mockIslands = [
{
id: 'island-1',
user_id: 'user-alice',
username: 'alice',
seed: 12345678,
virtual_x: -73.5,
virtual_y: 45.5,
virtual_z: 0,
terrain_type: 'tropical',
color_palette: 'vibrant',
size: 'medium',
display_name: "Alice's Paradise",
bio: 'Welcome to my tropical island! 🌴',
avatar_url: null,
content_count: 12,
visitors: 234,
created_at: '2025-01-15T10:00:00Z'
},
{
id: 'island-2',
user_id: 'user-bob',
username: 'bob',
seed: 87654321,
virtual_x: -74.0,
virtual_y: 45.8,
virtual_z: 0,
terrain_type: 'desert',
color_palette: 'sunset',
size: 'large',
display_name: "Bob's Oasis",
bio: 'Desert vibes only ☀️',
avatar_url: null,
content_count: 8,
visitors: 156,
created_at: '2025-01-10T14:30:00Z'
},
{
id: 'island-3',
user_id: 'user-charlie',
username: 'charlie',
seed: 11223344,
virtual_x: -73.2,
virtual_y: 45.3,
virtual_z: 0,
terrain_type: 'arctic',
color_palette: 'monochrome',
size: 'small',
display_name: "Charlie's Iceberg",
bio: 'Chill zone ❄️',
avatar_url: null,
content_count: 5,
visitors: 89,
created_at: '2025-01-20T09:15:00Z'
},
{
id: 'island-4',
user_id: 'user-diana',
username: 'diana',
seed: 99887766,
virtual_x: -73.8,
virtual_y: 45.6,
virtual_z: 0,
terrain_type: 'volcanic',
color_palette: 'vibrant',
size: 'medium',
display_name: "Diana's Volcano",
bio: 'Hot takes and lava cakes 🌋',
avatar_url: null,
content_count: 15,
visitors: 312,
created_at: '2025-01-12T16:45:00Z'
},
{
id: 'island-5',
user_id: 'user-eve',
username: 'eve',
seed: 55443322,
virtual_x: -74.2,
virtual_y: 45.4,
virtual_z: 0,
terrain_type: 'meadow',
color_palette: 'pastel',
size: 'large',
display_name: "Eve's Garden",
bio: 'Peace, love, and flowers 🌸',
avatar_url: null,
content_count: 20,
visitors: 445,
created_at: '2025-01-08T11:20:00Z'
}
];
return mockIslands;
/*
// REAL API CALL - Uncomment when island-service is ready:
const qs = new URLSearchParams();
if (typeof params.lat === "number") qs.set("lat", String(params.lat));
if (typeof params.lon === "number") qs.set("lon", String(params.lon));
if (typeof params.radius_km === "number") qs.set("radius_km", String(params.radius_km));
if (typeof params.limit === "number") qs.set("limit", String(params.limit));
const url = `http://localhost:8090/api/islands/nearby?${qs.toString()}`;
try {
const res = await fetch(url, { credentials: "include" });
if (!res.ok) {
console.warn("fetchIslands failed", res.status);
return [];
}
const data = await res.json();
return Array.isArray(data) ? data : data.islands || [];
} catch (err) {
console.warn("fetchIslands error:", err);
return [];
}
*/
}
/**
* ISLAND SERVICE: Fetch single island details
* Gets full island data including content (posts, apps)
*
* NOTE: Currently using MOCK DATA for testing
*/
export async function fetchIsland(islandId) {
// MOCK DATA - Remove this and uncomment API call when backend is ready
console.log('[fetchIsland] Using MOCK data for island:', islandId);
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 200));
// Mock island with content
const mockIslandData = {
'island-1': {
id: 'island-1',
user_id: 'user-alice',
username: 'alice',
seed: 12345678,
virtual_x: -73.5,
virtual_y: 45.5,
virtual_z: 0,
terrain_type: 'tropical',
color_palette: 'vibrant',
size: 'medium',
display_name: "Alice's Paradise",
bio: 'Welcome to my tropical island! 🌴',
avatar_url: null,
content_count: 3,
visitors: 234,
created_at: '2025-01-15T10:00:00Z',
content: [
{
id: 'content-1',
island_id: 'island-1',
content_type: 'post',
content_id: 'post-123',
position_x: 0.3,
position_y: 0.7,
position_z: 0.5,
scale: 1.0,
rotation: 0,
created_at: '2025-01-16T10:00:00Z'
},
{
id: 'content-2',
island_id: 'island-1',
content_type: 'app',
content_id: 'app-456',
position_x: 0.6,
position_y: 0.4,
position_z: 0.5,
scale: 1.0,
rotation: 45,
created_at: '2025-01-17T14:30:00Z'
},
{
id: 'content-3',
island_id: 'island-1',
content_type: 'post',
content_id: 'post-789',
position_x: 0.8,
position_y: 0.8,
position_z: 0.5,
scale: 1.0,
rotation: 90,
created_at: '2025-01-18T09:15:00Z'
}
]
},
'island-2': {
id: 'island-2',
user_id: 'user-bob',
username: 'bob',
seed: 87654321,
virtual_x: -74.0,
virtual_y: 45.8,
terrain_type: 'desert',
color_palette: 'sunset',
size: 'large',
display_name: "Bob's Oasis",
bio: 'Desert vibes only ☀️',
content_count: 2,
visitors: 156,
content: [
{
id: 'content-4',
content_type: 'post',
position_x: 0.5,
position_y: 0.5,
position_z: 0.5
},
{
id: 'content-5',
content_type: 'document',
position_x: 0.2,
position_y: 0.3,
position_z: 0.5
}
]
}
};
return mockIslandData[islandId] || null;
/*
// REAL API CALL - Uncomment when island-service is ready:
const url = `http://localhost:8090/api/islands/${islandId}`;
try {
const res = await fetch(url, { credentials: "include" });
if (!res.ok) {
console.warn("fetchIsland failed", res.status);
return null;
}
return await res.json();
} catch (err) {
console.warn("fetchIsland error:", err);
return null;
}
*/
}

View File

@ -0,0 +1,324 @@
import React, { useEffect, useRef, useState } from 'react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { fetchIslands } from '../../api/client';
import '../../styles/universe.css';
/**
* IslandUniverse: View all user islands floating in virtual space using MapLibre 3D
* Lightweight alternative to Three.js - uses existing MapLibre dependency
*/
export default function IslandUniverse({ onClose, onIslandClick }) {
const containerRef = useRef(null);
const mapRef = useRef(null);
const markersRef = useRef([]);
const [islands, setIslands] = useState([]);
const [hoveredIsland, setHoveredIsland] = useState(null);
// Fetch all islands
useEffect(() => {
fetchIslands({ limit: 100 }).then(islandData => {
console.log('[IslandUniverse] Loaded', islandData.length, 'islands');
setIslands(islandData);
});
}, []);
useEffect(() => {
if (!containerRef.current || islands.length === 0) return;
console.log('[IslandUniverse] Rendering universe with MapLibre:', islands.length, 'islands');
// Center point in virtual space
const centerLat = 0;
const centerLng = 0;
// Create MapLibre map with space theme
const map = new maplibregl.Map({
container: containerRef.current,
style: {
version: 8,
sources: {},
layers: [
{
id: 'background',
type: 'background',
paint: {
'background-color': '#000510' // Deep space
}
}
]
},
center: [centerLng, centerLat],
zoom: 0.8,
pitch: 70, // 3D angle
bearing: 0,
antialias: true,
attributionControl: false
});
mapRef.current = map;
map.on('load', () => {
// Create island markers
const markers = [];
islands.forEach((island, idx) => {
// Truly scatter islands randomly across wide area
const randomAngle = Math.random() * Math.PI * 2;
const randomRadius = 5 + Math.random() * 15; // Large random radius 5-20 degrees
const randomOffsetX = (Math.random() - 0.5) * 10; // Additional random scatter
const randomOffsetY = (Math.random() - 0.5) * 10;
const lng = centerLng + Math.cos(randomAngle) * randomRadius + randomOffsetX;
const lat = centerLat + Math.sin(randomAngle) * randomRadius + randomOffsetY;
// Create marker element
const el = document.createElement('div');
el.className = 'universe-island-marker';
const terrainColor = getTerrainColor(island.terrain_type);
const islandSVG = generateMiniIsland(island.seed || idx, terrainColor);
el.innerHTML = `
<div class="universe-island-shape" style="
position: relative;
cursor: pointer;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
animation: universeIslandFloat 6s ease-in-out infinite;
animation-delay: ${idx * 0.5}s;
filter: drop-shadow(0 0 15px ${terrainColor}aa) drop-shadow(0 8px 20px rgba(0,0,0,0.6));
">
${islandSVG}
<div style="
position: absolute;
bottom: -18px;
left: 50%;
transform: translateX(-50%);
background: rgba(20, 30, 50, 0.95);
color: white;
padding: 2px 8px;
border-radius: 8px;
font-size: 10px;
font-weight: 600;
white-space: nowrap;
border: 1px solid rgba(255,255,255,0.25);
text-shadow: 0 1px 2px rgba(0,0,0,0.5);
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
">@${island.username}</div>
</div>
`;
// Hover effect
el.addEventListener('mouseenter', () => {
el.querySelector('.universe-island-shape').style.transform = 'scale(1.25) translateY(-8px)';
el.querySelector('.universe-island-shape').style.filter = `drop-shadow(0 0 30px ${terrainColor}ff) drop-shadow(0 15px 35px rgba(0,0,0,0.8))`;
setHoveredIsland(island);
});
el.addEventListener('mouseleave', () => {
el.querySelector('.universe-island-shape').style.transform = 'scale(1)';
el.querySelector('.universe-island-shape').style.filter = `drop-shadow(0 0 15px ${terrainColor}aa) drop-shadow(0 8px 20px rgba(0,0,0,0.6))`;
setHoveredIsland(null);
});
// Click handler
el.addEventListener('click', (e) => {
e.stopPropagation();
console.log('[IslandUniverse] Clicked island:', island.username);
onIslandClick && onIslandClick(island);
});
// Create marker
const marker = new maplibregl.Marker({
element: el,
anchor: 'center'
})
.setLngLat([lng, lat])
.addTo(map);
markers.push({ marker, island, el });
});
markersRef.current = markers;
// Slow rotation animation
let bearing = 0;
function rotateCamera() {
bearing += 0.1;
if (bearing >= 360) bearing = 0;
map.rotateTo(bearing, { duration: 100 });
requestAnimationFrame(rotateCamera);
}
rotateCamera();
// Add starfield effect with custom layer
map.addLayer({
id: 'stars',
type: 'circle',
source: {
type: 'geojson',
data: createStarfieldGeoJSON(centerLng, centerLat, 500)
},
paint: {
'circle-radius': ['interpolate', ['linear'], ['zoom'], 0, 0.5, 10, 2],
'circle-color': [
'case',
['<', ['random'], 0.7], '#ffffff',
['<', ['random'], 0.9], '#aabbff',
'#ffeeaa'
],
'circle-opacity': 0.8,
'circle-blur': 0.2
}
});
});
// Cleanup
return () => {
markersRef.current.forEach(({ marker }) => marker.remove());
markersRef.current = [];
map.remove();
};
}, [islands, onIslandClick]);
return (
<div className="island-universe-modal" onClick={onClose}>
<div className="island-universe-container" onClick={(e) => e.stopPropagation()}>
<button className="island-universe-close" onClick={onClose}>×</button>
<div className="island-universe-header">
<h1>🌌 Island Universe</h1>
<p>{islands.length} islands floating in virtual space</p>
</div>
<div
ref={containerRef}
className="island-universe-canvas"
style={{ width: '100%', height: '700px' }}
/>
{hoveredIsland && (
<div className="island-universe-tooltip">
<div className="tooltip-username">@{hoveredIsland.username}</div>
<div className="tooltip-bio">{hoveredIsland.display_name || hoveredIsland.bio || 'Click to visit'}</div>
<div className="tooltip-stats">
{hoveredIsland.content_count} posts {hoveredIsland.terrain_type}
</div>
</div>
)}
<div className="island-universe-help">
<p>🖱 Hover over islands to see details Click to visit</p>
</div>
</div>
</div>
);
}
/**
* Create starfield as GeoJSON for MapLibre
*/
function createStarfieldGeoJSON(centerLng, centerLat, count) {
const features = [];
for (let i = 0; i < count; i++) {
const lng = centerLng + (Math.random() - 0.5) * 20;
const lat = centerLat + (Math.random() - 0.5) * 20;
features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [lng, lat]
},
properties: {
brightness: Math.random()
}
});
}
return {
type: 'FeatureCollection',
features
};
}
/**
* Get terrain color as CSS color string
*/
function getTerrainColor(terrainType) {
const palettes = {
'tropical': '#2ecc71',
'desert': '#f39c12',
'arctic': '#3498db',
'volcanic': '#e74c3c',
'meadow': '#27ae60',
};
return palettes[terrainType] || palettes['tropical'];
}
/**
* Generate a mini island SVG shape (organic, irregular)
*/
function generateMiniIsland(seed, terrainColor) {
// Seeded random
const rng = () => {
seed = (seed * 9301 + 49297) % 233280;
return seed / 233280;
};
// Generate organic island shape using bezier curves
const size = 50;
const centerX = size / 2;
const centerY = size / 2;
const numPoints = 8;
let path = '';
const points = [];
for (let i = 0; i < numPoints; i++) {
const angle = (i / numPoints) * Math.PI * 2;
const radiusVariation = 0.7 + rng() * 0.6; // Random radius
const radius = (size / 2) * radiusVariation;
const x = centerX + Math.cos(angle) * radius;
const y = centerY + Math.sin(angle) * radius;
points.push({ x, y });
}
// Create smooth curve using quadratic bezier
path = `M ${points[0].x} ${points[0].y}`;
for (let i = 0; i < numPoints; i++) {
const current = points[i];
const next = points[(i + 1) % numPoints];
const controlX = (current.x + next.x) / 2 + (rng() - 0.5) * 5;
const controlY = (current.y + next.y) / 2 + (rng() - 0.5) * 5;
path += ` Q ${controlX} ${controlY} ${next.x} ${next.y}`;
}
path += ' Z';
// Add some trees/details
const numTrees = 2 + Math.floor(rng() * 3);
let trees = '';
for (let i = 0; i < numTrees; i++) {
const tx = centerX + (rng() - 0.5) * (size * 0.4);
const ty = centerY + (rng() - 0.5) * (size * 0.4);
const treeSize = 3 + rng() * 2;
trees += `<circle cx="${tx}" cy="${ty}" r="${treeSize}" fill="rgba(0,80,0,0.6)" />`;
}
return `
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" style="overflow: visible;">
<defs>
<radialGradient id="island-grad-${seed}" cx="35%" cy="35%">
<stop offset="0%" stop-color="${terrainColor}" stop-opacity="1" />
<stop offset="70%" stop-color="${terrainColor}" stop-opacity="0.9" />
<stop offset="100%" stop-color="${terrainColor}" stop-opacity="0.7" />
</radialGradient>
</defs>
<!-- Island shape -->
<path d="${path}" fill="url(#island-grad-${seed})" stroke="rgba(255,255,255,0.3)" stroke-width="1.5" />
<!-- Trees/details -->
${trees}
</svg>
`;
}

View File

@ -0,0 +1,405 @@
import React, { useEffect, useRef } from 'react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import '../../styles/island.css';
/**
* IslandViewer: 2.5D immersive island visualization using MapLibre
* Lightweight alternative to Three.js using existing MapLibre dependency
*/
export default function IslandViewer({ island, onClose }) {
const containerRef = useRef(null);
const mapRef = useRef(null);
const animationRef = useRef(null);
useEffect(() => {
if (!island || !containerRef.current) return;
console.log('[IslandViewer] Rendering island with MapLibre:', island.username, island.seed);
// Center of island in virtual coordinates
const centerLng = 0;
const centerLat = 0;
// Create MapLibre map with sky theme
const map = new maplibregl.Map({
container: containerRef.current,
style: {
version: 8,
sources: {},
layers: [
{
id: 'background',
type: 'background',
paint: {
'background-color': '#87ceeb' // Sky blue
}
}
]
},
center: [centerLng, centerLat],
zoom: 16,
pitch: 60, // 2.5D angle
bearing: 0,
antialias: true,
attributionControl: false
});
mapRef.current = map;
map.on('load', () => {
// Add ocean layer (large circle)
map.addLayer({
id: 'ocean',
type: 'fill',
source: {
type: 'geojson',
data: {
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [createCircleCoordinates(centerLng, centerLat, 0.005, 64)]
}
}
},
paint: {
'fill-color': '#1e90ff',
'fill-opacity': 0.6
}
});
// Generate island terrain as custom marker
const islandEl = createIslandElement(island.seed, island.terrain_type);
const islandMarker = new maplibregl.Marker({
element: islandEl,
anchor: 'center'
})
.setLngLat([centerLng, centerLat])
.addTo(map);
// Generate vegetation markers
const vegetationData = generateVegetationData(island.seed, island.terrain_type);
const vegMarkers = [];
vegetationData.forEach(veg => {
const vegEl = createTreeElement(veg.terrainType);
const vegMarker = new maplibregl.Marker({
element: vegEl,
anchor: 'bottom'
})
.setLngLat([centerLng + veg.offsetX, centerLat + veg.offsetY])
.addTo(map);
vegMarkers.push(vegMarker);
});
// Add content items as markers
if (island.content && island.content.length > 0) {
console.log('[IslandViewer] Adding', island.content.length, 'content items');
island.content.forEach(item => {
const contentEl = createContentElement(item);
new maplibregl.Marker({
element: contentEl,
anchor: 'bottom'
})
.setLngLat([
centerLng + (item.position_x - 0.5) * 0.001,
centerLat + (item.position_y - 0.5) * 0.001
])
.addTo(map);
});
}
// Animation: gentle rotation
let bearing = 0;
function animate() {
bearing += 0.1;
if (bearing >= 360) bearing = 0;
map.rotateTo(bearing, { duration: 100, easing: t => t });
animationRef.current = requestAnimationFrame(animate);
}
animate();
});
// Cleanup
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
map.remove();
};
}, [island]);
if (!island) return null;
return (
<div className="island-viewer-modal" onClick={onClose}>
<div className="island-viewer-container" onClick={(e) => e.stopPropagation()}>
<button className="island-viewer-close" onClick={onClose}>×</button>
<div className="island-viewer-header">
<h2>{island.display_name || `${island.username}'s Island`}</h2>
<p>{island.bio || 'Welcome to my island'}</p>
</div>
<div
ref={containerRef}
className="island-viewer-canvas"
style={{ width: '100%', height: '600px' }}
/>
<div className="island-viewer-stats">
<div className="stat">
<span className="stat-value">{island.content_count || 0}</span>
<span className="stat-label">Posts</span>
</div>
<div className="stat">
<span className="stat-value">{island.visitors || 0}</span>
<span className="stat-label">Visitors</span>
</div>
<div className="stat">
<span className="stat-value">{island.terrain_type || 'tropical'}</span>
<span className="stat-label">Terrain</span>
</div>
</div>
<div className="island-viewer-footer">
<p className="island-info">
🌱 Seed: {island.seed} 🎨 {island.color_palette || 'vibrant'} palette
</p>
</div>
</div>
</div>
);
}
/**
* Create circle coordinates for polygon (ocean)
*/
function createCircleCoordinates(lng, lat, radius, points = 64) {
const coords = [];
for (let i = 0; i <= points; i++) {
const angle = (i / points) * Math.PI * 2;
coords.push([
lng + Math.cos(angle) * radius,
lat + Math.sin(angle) * radius
]);
}
return coords;
}
/**
* Create island DOM element with seed-based styling
*/
function createIslandElement(seed, terrainType = 'tropical') {
const rng = seededRandom(seed);
const el = document.createElement('div');
el.className = 'island-terrain-marker';
const terrainColor = getTerrainColorCSS(terrainType);
const size = 200 + rng() * 50; // 200-250px
el.innerHTML = `
<div style="
width: ${size}px;
height: ${size}px;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, ${terrainColor}dd, ${terrainColor}99, ${terrainColor}66);
box-shadow:
0 0 40px ${terrainColor}88,
0 20px 40px rgba(0,0,0,0.4),
inset -10px -10px 30px rgba(0,0,0,0.3),
inset 10px 10px 30px rgba(255,255,255,0.2);
position: relative;
animation: islandFloat 4s ease-in-out infinite;
">
${createIslandTexture(seed, terrainType)}
</div>
`;
return el;
}
function createIslandTexture(seed, terrainType) {
// Add some visual detail with random spots/patches
const rng = seededRandom(seed + 100);
let spots = '';
for (let i = 0; i < 8; i++) {
const x = rng() * 80 + 10; // 10-90%
const y = rng() * 80 + 10;
const size = rng() * 20 + 10; // 10-30px
spots += `
<div style="
position: absolute;
left: ${x}%;
top: ${y}%;
width: ${size}px;
height: ${size}px;
border-radius: 50%;
background: rgba(0,0,0,0.1);
transform: translate(-50%, -50%);
"></div>
`;
}
return spots;
}
/**
* Get terrain color as CSS string
*/
function getTerrainColorCSS(terrainType) {
const palettes = {
'tropical': '#2ecc71',
'desert': '#f39c12',
'arctic': '#ecf0f1',
'volcanic': '#e74c3c',
'meadow': '#27ae60',
};
return palettes[terrainType] || palettes['tropical'];
}
/**
* Generate vegetation data based on seed
*/
function generateVegetationData(seed, terrainType = 'tropical') {
const vegetation = [];
const rng = seededRandom(seed);
const count = terrainType === 'desert' ? 3 : terrainType === 'arctic' ? 1 : 8;
for (let i = 0; i < count; i++) {
const angle = rng() * Math.PI * 2;
const radius = (0.2 + rng() * 0.3) * 0.0001; // Small offset in lng/lat
vegetation.push({
offsetX: Math.cos(angle) * radius,
offsetY: Math.sin(angle) * radius,
terrainType: terrainType,
scale: 0.8 + rng() * 0.4
});
}
return vegetation;
}
/**
* Create tree DOM element
*/
function createTreeElement(terrainType) {
const el = document.createElement('div');
el.className = 'island-tree-marker';
let treeHTML = '';
if (terrainType === 'arctic') {
treeHTML = `
<div style="
width: 0;
height: 0;
border-left: 15px solid transparent;
border-right: 15px solid transparent;
border-bottom: 40px solid #1a4d2e;
position: relative;
animation: treeSwaythe: 3s ease-in-out infinite;
"></div>
`;
} else if (terrainType === 'desert') {
treeHTML = `
<div style="
width: 12px;
height: 50px;
background: linear-gradient(to bottom, #3a5f0b, #2a4f0b);
border-radius: 6px;
position: relative;
animation: treeSway 3s ease-in-out infinite;
"></div>
`;
} else {
// Tropical/meadow: stylized tree
treeHTML = `
<div style="position: relative; animation: treeSway 3s ease-in-out infinite;">
<div style="
width: 8px;
height: 30px;
background: #4a2511;
margin: 0 auto;
"></div>
<div style="
width: 0;
height: 0;
border-left: 20px solid transparent;
border-right: 20px solid transparent;
border-bottom: 30px solid #228b22;
position: relative;
top: -30px;
left: 50%;
transform: translateX(-50%);
"></div>
<div style="
width: 0;
height: 0;
border-left: 16px solid transparent;
border-right: 16px solid transparent;
border-bottom: 25px solid #228b22;
position: relative;
top: -45px;
left: 50%;
transform: translateX(-50%);
"></div>
</div>
`;
}
el.innerHTML = treeHTML;
return el;
}
/**
* Create content DOM element
*/
function createContentElement(content) {
const el = document.createElement('div');
el.className = 'island-content-marker';
const color = content.content_type === 'post' ? '#3498db' :
content.content_type === 'app' ? '#e74c3c' : '#f39c12';
el.innerHTML = `
<div style="
width: 40px;
height: 60px;
background: linear-gradient(135deg, ${color}ee, ${color}aa);
border-radius: 8px;
box-shadow:
0 4px 12px rgba(0,0,0,0.4),
0 0 20px ${color}66;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
cursor: pointer;
transition: all 0.3s ease;
animation: contentFloat 2s ease-in-out infinite;
" onmouseenter="this.style.transform='scale(1.2)'" onmouseleave="this.style.transform='scale(1)'">
${content.content_type === 'post' ? '📄' : content.content_type === 'app' ? '📱' : '📁'}
</div>
`;
return el;
}
/**
* Seeded random number generator (deterministic)
*/
function seededRandom(seed) {
let s = seed;
return function() {
s = (s * 9301 + 49297) % 233280;
return s / 233280;
};
}

View File

@ -2,8 +2,6 @@ import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "../../auth/AuthContext";
import { registerUser } from "../../api/client";
import AuthToast from "../Auth/AuthToast";
// Import pour le bouton PWA
import { promptInstall, isRunningAsPWA, canInstall } from '../../pwaInstall';
const THEME_LABELS = { dark: "Dark", blue: "Blue", light: "Light" };
@ -55,7 +53,7 @@ function parseMergedParams() {
return { qs, hs, get, hash };
}
export default function TopBar({ theme = "dark", onChangeTheme }) {
export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick }) {
const {
loading,
authenticated,
@ -101,21 +99,20 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
if (authenticated) setShowAuth(false);
}, [authenticated]);
// Check if install button should be shown
// Check if PWA install should be shown (simple: hide if already PWA)
useEffect(() => {
const checkInstall = () => {
// Hide button if already running as PWA
// Hide if already running as PWA
if (isRunningAsPWA()) {
setShowInstallBtn(false);
return;
}
// Show button if installation is available
// Show if can install
setShowInstallBtn(canInstall());
};
checkInstall();
// Listen for beforeinstallprompt to update button visibility
const handlePrompt = () => {
setTimeout(checkInstall, 100);
};
@ -278,7 +275,7 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
</div>
</div>
{/* Theme dots + BOUTON INSTALL PWA */}
{/* Theme dots */}
<div className="brand-under">
<div className="theme-switch">
{["dark", "blue", "light"].map((t) => (
@ -293,64 +290,71 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
</button>
))}
</div>
{/* BOUTON INSTALL PWA ici en haut - seulement si pas déjà installé */}
{showInstallBtn && (
<button
onClick={() => promptInstall()}
style={{
marginLeft: '8px',
padding: '5px 10px',
fontSize: '12px',
background: '#0ea5e9',
color: 'white',
border: 'none',
borderRadius: '999px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '6px',
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
}}
>
<i className="fa-solid fa-download"></i> Install
</button>
)}
</div>
</div>
<div className="topbar-right">
{authenticated ? (
<div className="sw-userchip" title={username}>
<div className="sw-avatar">
<span>{initialLetter(username)}</span>
{/* User info */}
<div className="topbar-user-section">
{authenticated ? (
<div className="sw-userchip" title={username}>
<div className="sw-avatar">
<span>{initialLetter(username)}</span>
</div>
<div className="sw-usertext">
<div className="sw-userline">Online</div>
<div className="sw-userhandle">@{username || "user"}</div>
</div>
</div>
<div className="sw-usertext">
<div className="sw-userline">Online</div>
<div className="sw-userhandle">@{username || "user"}</div>
) : (
<div className="sw-userchip" title="Guest">
<div className="sw-avatar">
<i className="fa-solid fa-user" />
</div>
<div className="sw-usertext">
<div className="sw-userline">{loading ? "Loading…" : "Guest"}</div>
<div className="sw-userhandle">anon</div>
</div>
</div>
</div>
) : (
<div className="sw-userchip" title="Guest">
<div className="sw-avatar">
<i className="fa-solid fa-user" />
</div>
<div className="sw-usertext">
<div className="sw-userline">{loading ? "Loading…" : "Guest"}</div>
<div className="sw-userhandle">anon</div>
</div>
</div>
)}
)}
</div>
<button
className="btn-primary"
type="button"
onClick={() => (authenticated ? logout() : openModal("login"))}
disabled={loading}
title={authenticated ? "Logoff" : "Logon"}
>
{authenticated ? "Logoff" : "Logon"}
</button>
{/* Action buttons - separate section */}
<div className="topbar-actions">
{/* PWA Install button - no dismiss X */}
{showInstallBtn && (
<button
className="btn-action btn-action-install"
type="button"
onClick={() => promptInstall()}
title="Install App"
>
<i className="fa-solid fa-download"></i>
</button>
)}
{/* Islands Universe button */}
<button
className="btn-action btn-action-islands"
type="button"
onClick={() => onIslandsClick && onIslandsClick()}
title="Island Universe"
>
<i className="fa-solid fa-earth-americas"></i>
</button>
{/* Login/Logout button */}
<button
className="btn-action btn-action-auth"
type="button"
onClick={() => (authenticated ? logout() : openModal("login"))}
disabled={loading}
title={authenticated ? "Logout" : "Login"}
>
<i className={authenticated ? "fa-solid fa-right-from-bracket" : "fa-solid fa-right-to-bracket"}></i>
<span className="btn-label">{authenticated ? "Logout" : "Login"}</span>
</button>
</div>
</div>
</header>

View File

@ -18,12 +18,15 @@ import { useAuth } from "../../auth/AuthContext";
import FilterButtons from "../Filters/FilterButtons";
import TimeFilterButtons from "../Filters/TimeFilterButtons";
import SmartSearchBar from "../Search/SmartSearchBar";
import UserProfile from "../Profile/UserProfile";
export default function MapView({
theme = "dark",
onPostsState,
selectedPost,
onSelectPost,
selectedIsland,
onSelectIsland,
}) {
const { authenticated, username, token, needsEmailVerification } = useAuth();
@ -36,9 +39,9 @@ export default function MapView({
try {
const v = localStorage.getItem(TIME_FILTER_KEY);
const ok = ["NOW", "TODAY", "RECENT", "PAST"].includes(v);
return ok ? v : "TODAY";
return ok ? v : "PAST";
} catch {
return "TODAY";
return "PAST";
}
});
const [subFilter, setSubFilter] = useState("All");
@ -55,6 +58,10 @@ export default function MapView({
const userPosition = useUserPosition(mapRef, hasLastView);
const [searchQuery, setSearchQuery] = useState("");
const skipAutoZoomRef = useRef(false);
// Profile state (selectedIsland is now passed from App.jsx)
const [selectedProfile, setSelectedProfile] = useState(null);
const { status, visiblePosts, loadingPosts, loadError, handleIncomingPost } =
usePostsEngine({
@ -67,6 +74,7 @@ export default function MapView({
searchQuery,
markersRef,
expandedElRef,
onAutoWidenTimeFilter: (next) => setTimeFilter(next),
});
const [isCreating, setIsCreating] = useState(false);
@ -233,31 +241,61 @@ export default function MapView({
speed: 1.4,
essential: true
});
map.__swForceFetchAt = Date.now();
// If picking a post, filter by its title AND clear category filters to ensure visibility
if (item.type === 'post' && item.title) {
setSearchQuery(item.title);
// If picking a post, clear filters and avoid query-locking
if (item.type === 'post') {
skipAutoZoomRef.current = true;
setSearchQuery("");
setMainFilter("All");
setSubFilter("All");
setTimeFilter("PAST"); // Show all time periods
const raw = item.raw || item;
if (raw && typeof raw === "object") {
const lat = typeof raw.lat === "number" ? raw.lat : coords[1];
const lon = typeof raw.lon === "number" ? raw.lon : coords[0];
handleIncomingPost({
id: raw.post_id ?? raw.id ?? 0,
title: raw.title || item.title || "Post",
snippet: raw.snippet || raw.summary || "",
category: raw.category || "NEWS",
sub_category: raw.sub_category || raw.subCategory || "ALL",
lat,
lon,
url: raw.url || "",
source: raw.source || "",
published_at: raw.published_at || "",
created_at: raw.published_at || "",
url_hash: raw.url_hash || "",
});
}
} else {
skipAutoZoomRef.current = false;
setSearchQuery("");
}
};
const handleSearch = (query) => {
console.log('[MapView] Search/filter by:', query);
skipAutoZoomRef.current = false;
setSearchQuery(query);
// Clear category filters to show all search results
// Clear ALL filters to show all search results
if (query.trim()) {
setMainFilter("All");
setSubFilter("All");
setTimeFilter("PAST"); // Show all time periods
}
};
// Zoom intelligent: quand on cherche, zoom pour voir tous les résultats
useEffect(() => {
if (!searchQuery || !visiblePosts.length) return;
if (skipAutoZoomRef.current) {
skipAutoZoomRef.current = false;
return;
}
const map = mapRef.current;
if (!map) return;
@ -307,6 +345,29 @@ export default function MapView({
return () => clearTimeout(timer);
}, [searchQuery, visiblePosts, mapRef]);
const handleProfileVisitIsland = (island) => {
console.log('[MapView] Visiting island from profile:', island.username);
setSelectedProfile(null); // Close profile modal
onSelectIsland && onSelectIsland(island); // Open island viewer via App.jsx
};
// Global click handler for author links
useEffect(() => {
const handleAuthorClick = (e) => {
const authorLink = e.target.closest('.sw-author-link');
if (authorLink) {
const author = authorLink.getAttribute('data-author');
if (author) {
console.log('[MapView] Author clicked:', author);
setSelectedProfile(author);
}
}
};
document.addEventListener('click', handleAuthorClick);
return () => document.removeEventListener('click', handleAuthorClick);
}, []);
const handleOpenCreate = () => {
if (!authenticated) {
alert("You must be logged in to place a wire.");
@ -419,25 +480,31 @@ export default function MapView({
const authorName = (username || "").trim() || "anon";
let baseLng;
let baseLat;
let lng;
let lat;
if (draftCoords && draftCoords.length === 2) {
baseLng = draftCoords[0];
baseLat = draftCoords[1];
lng = draftCoords[0];
lat = draftCoords[1];
} else {
const center = map.getCenter();
baseLng = center.lng;
baseLat = center.lat;
const stage = stageRef.current;
const crosshairEl = stage ? stage.querySelector(".crosshair-aim") : null;
const containerEl = map.getContainer();
if (crosshairEl && containerEl) {
const crossRect = crosshairEl.getBoundingClientRect();
const containerRect = containerEl.getBoundingClientRect();
const px = crossRect.left + crossRect.width / 2 - containerRect.left;
const py = crossRect.top + crossRect.height / 2 - containerRect.top;
const correctedLngLat = map.unproject([px, py]);
lng = correctedLngLat.lng;
lat = correctedLngLat.lat;
} else {
const center = map.getCenter();
lng = center.lng;
lat = center.lat;
}
}
const pixelOffsetY = -20;
const screenPoint = map.project([baseLng, baseLat]);
const correctedPoint = { x: screenPoint.x, y: screenPoint.y + pixelOffsetY };
const correctedLngLat = map.unproject([correctedPoint.x, correctedPoint.y]);
const lng = correctedLngLat.lng;
const lat = correctedLngLat.lat;
if (!draftTitle.trim()) {
setSaveError("Title required.");
return;
@ -707,6 +774,15 @@ export default function MapView({
</div>
)}
</div>
{/* User Profile Modal */}
{selectedProfile && (
<UserProfile
username={selectedProfile}
onClose={() => setSelectedProfile(null)}
onVisitIsland={handleProfileVisitIsland}
/>
)}
</div>
);
}

View File

@ -14,11 +14,18 @@ export function haversineKm(lat1, lon1, lat2, lon2) {
}
export function getViewFromMap(map) {
const bounds = map.getBounds();
const center = bounds.getCenter();
const ne = bounds.getNorthEast();
let radiusKm = haversineKm(center.lat, center.lng, ne.lat, ne.lng);
const mapCenter = map.getCenter();
let center = mapCenter;
let radiusKm = 750;
try {
const bounds = map.getBounds();
const boundsCenter = bounds.getCenter();
const ne = bounds.getNorthEast();
if (boundsCenter && ne) {
center = boundsCenter;
radiusKm = haversineKm(center.lat, center.lng, ne.lat, ne.lng);
}
} catch {}
if (!Number.isFinite(radiusKm) || radiusKm <= 0) {
radiusKm = 750;

View File

@ -162,7 +162,7 @@ function openCenteredOverlay({ map, post, theme }) {
<div class="sw-modal-left">
<div class="sw-modal-badge">${escapeHtml(cat)}</div>
<div class="sw-modal-meta">
${metaLeft ? `<span>${escapeHtml(metaLeft)}</span>` : ""}
${metaLeft ? `<span class="sw-author-link" data-author="${escapeHtml(author)}" style="cursor: pointer; text-decoration: underline;">${escapeHtml(metaLeft)}</span>` : ""}
${metaRight ? `<span>• ${escapeHtml(metaRight)}</span>` : ""}
<span> ${escapeHtml(relTime)}</span>
</div>
@ -319,7 +319,7 @@ function calculateSmartOffset(post, markersRef, map) {
if (createdAt) {
const ageSeconds = (Date.now() - new Date(createdAt).getTime()) / 1000;
if (ageSeconds < 10) {
return { x: 0, y: -80 }; // Offset vers le haut (négatif = monte)
return { x: 0, y: -110 }; // Offset vers le haut (négatif = monte)
}
}
@ -379,7 +379,9 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
const lngLat = [lon, lat];
// Calculate smart offset for nearby markers
const offset = calculateSmartOffset(post, markersRef, map);
// DISABLED: Using geographic coordinate variation instead of pixel offset
// const offset = calculateSmartOffset(post, markersRef, map);
const offset = { x: 0, y: 0 };
const root = document.createElement("div");
root.classList.add("sw-appear");
@ -400,13 +402,9 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
root.style.zIndex = "1";
// Add connecting line if offset is significant
const hasOffset = Math.abs(offset.x) > 5 || Math.abs(offset.y) > 5;
const lineHTML = hasOffset
? `<svg class="sw-offset-line" style="position:absolute;bottom:0;left:50%;transform:translateX(-50%);overflow:visible;pointer-events:none;z-index:-1;">
<line x1="0" y1="0" x2="${-offset.x}" y2="${-offset.y}" stroke="rgba(56,189,248,0.4)" stroke-width="2" stroke-dasharray="4,4"/>
<circle cx="${-offset.x}" cy="${-offset.y}" r="4" fill="#38bdf8" opacity="0.6"/>
</svg>`
: '';
// DISABLED: No longer using pixel offsets
const hasOffset = false;
const lineHTML = '';
root.innerHTML = `
<div class="sw-template-mini-wrap"></div>
@ -608,3 +606,130 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
markersRef.current.push({ id: post.id, marker, el: root, post, type: "simple" });
}
/**
* ISLAND MARKER: Creates a circular avatar marker for user islands
* Islands are displayed with avatar, username, and 🏝 badge
*/
export function createIslandMarker(island, mapRef, markersRef, onIslandClick, theme = "blue") {
const map = mapRef.current;
if (!map) return;
const lat = island.virtual_y; // Virtual coords
const lon = island.virtual_x;
if (lat == null || lon == null) {
console.warn("[createIslandMarker] Missing virtual coordinates", island);
return;
}
const root = document.createElement("div");
root.classList.add("island-marker", "sw-appear");
// Fade-in animation
requestAnimationFrame(() => root.classList.add("sw-appear-in"));
// Marker circulaire avec avatar
root.innerHTML = `
<div class="island-marker-container" style="
display: flex;
flex-direction: column;
align-items: center;
position: relative;
cursor: pointer;
">
<div class="island-avatar" style="
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: 3px solid white;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
transition: transform 0.2s ease, box-shadow 0.2s ease;
">
${island.avatar_url
? `<img src="${island.avatar_url}" style="width:100%;height:100%;object-fit:cover;" alt="${island.username}" />`
: `<div style="color:white;font-size:32px;font-weight:bold;">${island.username[0].toUpperCase()}</div>`
}
</div>
<div class="island-badge" style="
position: absolute;
top: -5px;
left: 50%;
transform: translateX(-50%);
background: rgba(102, 126, 234, 0.95);
color: white;
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: bold;
letter-spacing: 0.5px;
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
">🏝 ISLAND</div>
<div class="island-username" style="
margin-top: 8px;
text-align: center;
font-weight: bold;
color: white;
font-size: 12px;
text-shadow: 0 2px 4px rgba(0,0,0,0.6);
">@${island.username}</div>
<div class="island-pin-point" style="
width: 0;
height: 0;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-top: 20px solid rgba(102, 126, 234, 0.8);
margin: 0 auto;
margin-top: 4px;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
"></div>
</div>
`;
// Hover effect
const avatar = root.querySelector(".island-avatar");
root.addEventListener("mouseenter", () => {
if (avatar) {
avatar.style.transform = "scale(1.1)";
avatar.style.boxShadow = "0 6px 20px rgba(102, 126, 234, 0.6)";
}
});
root.addEventListener("mouseleave", () => {
if (avatar) {
avatar.style.transform = "scale(1)";
avatar.style.boxShadow = "0 4px 12px rgba(0,0,0,0.4)";
}
});
// Click handler
root.addEventListener("click", (ev) => {
ev.stopPropagation();
console.log("[Island Marker] Clicked:", island.username, island.id);
if (onIslandClick) {
onIslandClick(island);
}
});
const marker = new maplibregl.Marker({
element: root,
anchor: "bottom",
})
.setLngLat([lon, lat])
.addTo(map);
markersRef.current.push({
id: island.id,
marker,
el: root,
island,
type: "island"
});
console.log("[Island Marker] Created for", island.username, "at", [lon, lat]);
}

View File

@ -172,15 +172,16 @@ function parseDateAny(raw) {
}
function isBreaking(post) {
// Only show as BREAKING if explicitly tagged
if (post?.breaking === true || post?.is_breaking === true) return true;
// Or if title contains breaking/urgent/alerte keywords
const t = (post?.title || "").toString().toLowerCase();
if (t.includes("breaking") || t.includes("urgent") || t.includes("alerte")) return true;
const d = parseDateAny(post?.created_at || post?.CreatedAt || post?.createdAt);
if (!d) return false;
const mins = (Date.now() - d.getTime()) / 60000;
return mins >= 0 && mins <= 45;
// Removed: automatic breaking news for recent posts
// Now only explicit tags or keywords trigger breaking news style
return false;
}
function normCat(post) {

View File

@ -98,6 +98,9 @@ export function useMapCore(theme) {
mapRef.current = map;
try { setViewParams(getViewFromMap(map)); } catch {}
const initialTimer = setTimeout(() => {
try { setViewParams(getViewFromMap(map)); } catch {}
}, 300);
const reOcclude = () => {
const el = expandedElRef.current;
@ -135,6 +138,7 @@ export function useMapCore(theme) {
});
return () => {
clearTimeout(initialTimer);
clearAllMarkers(markersRef, expandedElRef);
map.remove();
mapRef.current = null;
@ -148,4 +152,4 @@ export function useMapCore(theme) {
}, [theme]);
return { containerRef, mapRef, markersRef, expandedElRef, viewParams, hasLastView };
}
}

View File

@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchSmartFeed } from "../../api/client";
import { fetchPosts, fetchSmartFeed } from "../../api/client";
import { categoryCode, matchesSubFilter, matchesTimeFilter } from "./mapFilter";
import { haversineKm } from "./mapGeo";
import { getCoords, getViewFromMap, haversineKm } from "./mapGeo";
import { createMarkerForPost, createSimpleMarkerForPost } from "./markerManager";
import { prioritizePosts } from "./postPriority";
@ -9,6 +9,19 @@ function getId(p) {
return p?.id ?? p?._id ?? null;
}
function getPostKey(p) {
const id = getId(p);
if (id != null && id !== 0) return `id:${id}`;
const urlHash = (p?.url_hash || p?.urlHash || "").toString().trim();
if (urlHash) return `urlhash:${urlHash}`;
const url = (p?.url || "").toString().trim();
if (url) return `url:${url}`;
const title = (p?.title || p?.Title || "").toString().trim();
const created = (p?.created_at || p?.CreatedAt || p?.createdAt || "").toString().trim();
if (title || created) return `t:${title}|${created}`;
return `fallback:${JSON.stringify(p)}`;
}
function sameIdList(a, b) {
if (a === b) return true;
if (!Array.isArray(a) || !Array.isArray(b)) return false;
@ -54,6 +67,20 @@ function matchesSearch(p, qRaw) {
return true;
}
function hasPostsInRadius(posts, centerLat, centerLon, radiusKm) {
if (!Array.isArray(posts) || posts.length === 0) return false;
if (!Number.isFinite(centerLat) || !Number.isFinite(centerLon)) return true;
const limit = Math.max(radiusKm || 0, 1);
for (const p of posts) {
const coords = getCoords(p);
if (!coords) continue;
const [lon, lat] = coords;
const dKm = haversineKm(centerLat, centerLon, lat, lon);
if (dKm <= limit) return true;
}
return false;
}
export function usePostsEngine({
mapRef,
viewParams,
@ -63,11 +90,31 @@ export function usePostsEngine({
searchQuery,
markersRef,
expandedElRef,
onAutoWidenTimeFilter,
theme = "blue",
}) {
const allPostsRef = useRef([]);
const lastFetchRef = useRef({ center: null, radiusKm: null, filterKey: "" });
const lastFetchRef = useRef({ center: null, radiusKm: null, filterKey: "", timeFilter: "" });
const delayedFetchRef = useRef(null);
const autoWidenRef = useRef(false);
const [mapReady, setMapReady] = useState(false);
useEffect(() => {
if (mapReady) return;
let cancelled = false;
const timer = setInterval(() => {
if (cancelled) return;
if (mapRef.current) {
mapRef.current.__swForceFetchAt = Date.now();
setMapReady(true);
clearInterval(timer);
}
}, 150);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [mapReady, mapRef]);
const [status, setStatus] = useState("");
const [visiblePosts, setVisiblePosts] = useState([]);
@ -195,12 +242,14 @@ export function usePostsEngine({
useEffect(() => {
const map = mapRef.current;
if (!map) return;
if (!viewParams.center) return;
let cancelled = false;
async function load() {
const mapObj = mapRef.current;
const resolvedView =
viewParams && viewParams.center ? viewParams : getViewFromMap(mapObj);
if (!resolvedView?.center) return;
// If expanded overlay is open: delay fetch (do NOT disturb UI)
if (expandedElRef?.current) {
@ -220,20 +269,25 @@ export function usePostsEngine({
}
} catch {}
const [lng, lat] = viewParams.center;
const radiusKm = viewParams.radiusKm || 750;
const [lng, lat] = resolvedView.center;
const radiusKm = resolvedView.radiusKm || 750;
const filterKey = `${mainFilter}|${subFilter}`;
const last = lastFetchRef.current;
const forceFetchAt = mapObj?.__swForceFetchAt || 0;
const forceFetch = forceFetchAt && Date.now() - forceFetchAt < 15000;
if (forceFetch) {
mapObj.__swForceFetchAt = 0;
}
if (last.center && last.filterKey === filterKey) {
if (!forceFetch && last.center && last.filterKey === filterKey && last.timeFilter === timeFilter) {
const [lastLng, lastLat] = last.center;
const distKm = haversineKm(lastLat, lastLng, lat, lng);
const lastR = last.radiusKm || 0;
const radiusChanged = !lastR || Math.abs(radiusKm - lastR) / Math.max(lastR, 1) >= 0.25;
const radiusChanged = !lastR || Math.abs(radiusKm - lastR) / Math.max(lastR, 1) >= 0.1;
const minMoveKm = Math.max(40, radiusKm * 0.22);
const minMoveKm = Math.max(5, radiusKm * 0.1);
if (!radiusChanged && distKm < minMoveKm) return;
}
@ -247,35 +301,87 @@ export function usePostsEngine({
subFilter && subFilter.toUpperCase() !== "ALL" && subFilter !== "All" ? subFilter : "";
// Use smart feed for personalized, trending posts
const newPosts = await fetchSmartFeed({
let bounds = null;
try {
const b = mapObj.getBounds();
if (b) {
const sw = b.getSouthWest();
const ne = b.getNorthEast();
bounds = {
minLat: sw?.lat,
minLon: sw?.lng,
maxLat: ne?.lat,
maxLon: ne?.lng,
};
}
} catch {}
let newPosts = await fetchSmartFeed({
lat,
lon: lng,
radius_km: radiusKm,
limit: 80,
bounds,
// TODO: Add user_id from auth context when available
// TODO: Add bounds parameter for screen-aware prioritization
});
const timeParam = timeFilter && timeFilter !== "PAST" ? timeFilter : undefined;
if (!hasPostsInRadius(newPosts, lat, lng, radiusKm)) {
newPosts = await fetchPosts({
category: catCode || undefined,
subCategory: subCatParam || undefined,
time: timeParam,
lat,
lon: lng,
radiusKm,
});
if ((!Array.isArray(newPosts) || newPosts.length === 0) && timeFilter !== "PAST") {
newPosts = await fetchPosts({
category: catCode || undefined,
subCategory: subCatParam || undefined,
time: "PAST",
lat,
lon: lng,
radiusKm,
});
}
}
if (cancelled) return;
const existing = allPostsRef.current || [];
const byId = new Map();
for (const p of existing) {
const id = getId(p);
if (id != null) byId.set(id, p);
const key = getPostKey(p);
if (key) byId.set(key, p);
}
if (Array.isArray(newPosts)) {
for (const p of newPosts) {
const id = getId(p);
if (id != null && !byId.has(id)) byId.set(id, p);
const key = getPostKey(p);
if (key && !byId.has(key)) byId.set(key, p);
}
}
allPostsRef.current = Array.from(byId.values());
lastFetchRef.current = { center: [...viewParams.center], radiusKm, filterKey };
lastFetchRef.current = { center: [...resolvedView.center], radiusKm, filterKey, timeFilter };
// Smooth update: sync markers + wall without clearing everything
refreshVisibleAndMarkers(timeFilter);
const visible = computeVisible(timeFilter);
setVisiblePosts((prev) => (sameIdList(prev, visible) ? prev : visible));
setStatus("");
syncMarkers(visible);
if (
!autoWidenRef.current &&
timeFilter &&
timeFilter !== "PAST" &&
Array.isArray(newPosts) &&
newPosts.length > 0 &&
visible.length === 0
) {
autoWidenRef.current = true;
if (typeof onAutoWidenTimeFilter === "function") {
onAutoWidenTimeFilter("PAST");
}
}
setLoadingPosts(false);
} catch (err) {
@ -291,10 +397,15 @@ export function usePostsEngine({
delayedFetchRef.current = null;
}
};
}, [viewParams, mainFilter, subFilter, mapRef, timeFilter, expandedElRef, refreshVisibleAndMarkers]);
}, [viewParams, mapReady, mainFilter, subFilter, mapRef, timeFilter, expandedElRef, refreshVisibleAndMarkers, computeVisible, onAutoWidenTimeFilter, syncMarkers]);
const handleIncomingPost = useCallback(
(p) => {
const key = getPostKey(p);
if (key) {
const exists = (allPostsRef.current || []).some((x) => getPostKey(x) === key);
if (exists) return;
}
allPostsRef.current = [...allPostsRef.current, p];
const catCode = categoryCode(mainFilter);

View File

@ -0,0 +1,118 @@
import React, { useEffect, useState } from 'react';
import { fetchIsland } from '../../api/client';
import '../../styles/profile.css';
/**
* UserProfile: Modal showing user info with "Visit Island" button
*
* Props:
* - username: string (required)
* - onClose: function
* - onVisitIsland: function(island) - called when "Visit Island" is clicked
*/
export default function UserProfile({ username, onClose, onVisitIsland }) {
const [island, setIsland] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!username) return;
// Find island by username (mock data uses island-{number})
// In real backend, we'd call /api/islands/by-username/{username}
const mockIslandMap = {
'alice': 'island-1',
'bob': 'island-2',
'charlie': 'island-3',
'diana': 'island-4',
'eve': 'island-5',
};
const islandId = mockIslandMap[username.toLowerCase()];
if (islandId) {
fetchIsland(islandId)
.then(data => {
setIsland(data);
setLoading(false);
})
.catch(err => {
console.error('[UserProfile] Error fetching island:', err);
setLoading(false);
});
} else {
setLoading(false);
}
}, [username]);
const handleVisitIsland = () => {
if (island && onVisitIsland) {
onVisitIsland(island);
}
};
return (
<div className="user-profile-modal" onClick={onClose}>
<div className="user-profile-container" onClick={(e) => e.stopPropagation()}>
<button className="user-profile-close" onClick={onClose}>×</button>
<div className="user-profile-header">
<div className="user-profile-avatar">
{island?.avatar_url ? (
<img src={island.avatar_url} alt={username} />
) : (
<div className="user-profile-avatar-letter">
{username[0].toUpperCase()}
</div>
)}
</div>
<h2 className="user-profile-username">@{username}</h2>
{island?.display_name && island.display_name !== `${username}'s Island` && (
<p className="user-profile-display-name">{island.display_name}</p>
)}
</div>
{loading ? (
<div className="user-profile-loading">Loading profile...</div>
) : island ? (
<>
<div className="user-profile-bio">
<p>{island.bio || 'No bio yet.'}</p>
</div>
<div className="user-profile-stats">
<div className="user-profile-stat">
<span className="user-profile-stat-value">{island.content_count || 0}</span>
<span className="user-profile-stat-label">Posts</span>
</div>
<div className="user-profile-stat">
<span className="user-profile-stat-value">{island.visitors || 0}</span>
<span className="user-profile-stat-label">Visitors</span>
</div>
<div className="user-profile-stat">
<span className="user-profile-stat-value">{island.terrain_type || 'tropical'}</span>
<span className="user-profile-stat-label">Terrain</span>
</div>
</div>
<div className="user-profile-actions">
<button
className="user-profile-btn user-profile-btn-primary"
onClick={handleVisitIsland}
>
🏝 Visit Island
</button>
</div>
<div className="user-profile-footer">
<p className="user-profile-seed">Seed: {island.seed}</p>
</div>
</>
) : (
<div className="user-profile-no-island">
<p>This user doesn't have an island yet.</p>
</div>
)}
</div>
</div>
);
}

View File

@ -9,13 +9,13 @@ function defaultZoomForKind(kind) {
if (k === "post") return 16; // Close zoom for posts
if (k === "profile") return 14; // Medium zoom for profiles
if (k === "city") return 11; // City-level zoom
if (k === "region") return 7; // Wide zoom for regions/provinces
if (k === "region") return 5; // Wide zoom for regions/provinces
if (k === "place" || k === "poi") return 13; // Places zoom
// Legacy/fallback
if (k.includes("post")) return 15;
if (k.includes("city")) return 11;
if (k.includes("region") || k.includes("area") || k.includes("province") || k.includes("state")) return 7;
if (k.includes("region") || k.includes("area") || k.includes("province") || k.includes("state")) return 5;
if (k.includes("place")) return 13;
return 12; // Default
@ -32,22 +32,26 @@ function IconPin(props) {
);
}
function IconWaves(props) {
function IconPlus(props) {
return (
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" {...props}>
<path
d="M3 10c2 0 2-2 4-2s2 2 4 2 2-2 4-2 2 2 4 2 2-2 4-2v2c-2 0-2 2-4 2s-2-2-4-2-2 2-4 2-2-2-4-2-2 2-4 2V10z"
fill="currentColor"
/>
<path
d="M3 14c2 0 2-2 4-2s2 2 4 2 2-2 4-2 2 2 4 2 2-2 4-2v2c-2 0-2 2-4 2s-2-2-4-2-2 2-4 2-2-2-4-2-2 2-4 2v-2z"
fill="currentColor"
opacity="0.75"
d="M12 5v14m-7-7h14"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
/>
</svg>
);
}
function truncateText(value, maxLen) {
const s = (value || "").toString();
if (!maxLen || s.length <= maxLen) return s;
return s.slice(0, Math.max(0, maxLen - 1)) + "…";
}
/**
* Props:
* - placeholder
@ -72,6 +76,7 @@ export default function SmartSearchBar({
const [hasSearched, setHasSearched] = useState(false);
const abortRef = useRef(null);
const skipSearchRef = useRef(false);
const boxRef = useRef(null);
useEffect(() => {
@ -113,6 +118,11 @@ export default function SmartSearchBar({
useEffect(() => {
const query = q.trim();
if (skipSearchRef.current) {
skipSearchRef.current = false;
return;
}
if (abortRef.current) abortRef.current.abort();
const t = setTimeout(() => {
@ -130,6 +140,7 @@ export default function SmartSearchBar({
const pick = (it, reason = "click") => {
if (!it) return;
const zoom = defaultZoomForKind(it.kind);
skipSearchRef.current = true;
setOpen(false);
setQ(it.title || q);
@ -152,6 +163,7 @@ export default function SmartSearchBar({
pick(items[active], "enter");
} else if (q.trim()) {
// No item selected, trigger search/filter
skipSearchRef.current = true;
setOpen(false);
onSearch && onSearch(q.trim());
}
@ -178,7 +190,14 @@ export default function SmartSearchBar({
<input
className="sw-search__input"
value={q}
onChange={(e) => setQ(e.target.value)}
onChange={(e) => {
const newValue = e.target.value;
setQ(newValue);
// Si on efface tout le texte, clear les filtres
if (!newValue.trim() && q.trim()) {
onSearch && onSearch("");
}
}}
onFocus={() => {
setOpen(true);
// Si pas encore cherché et query vide, charger suggestions populaires
@ -212,7 +231,7 @@ export default function SmartSearchBar({
title={rightTitle}
onClick={() => onPlaceTourWire && onPlaceTourWire()}
>
<IconWaves />
<IconPlus />
</button>
<button
@ -239,10 +258,9 @@ export default function SmartSearchBar({
onMouseEnter={() => setActive(idx)}
onClick={() => pick(it, "click")}
>
<div className="sw-search__title">{it.title || "Untitled"}</div>
<div className="sw-search__title">{truncateText(it.title || "Untitled", 140)}</div>
<div className="sw-search__sub">
{(it.kind ? it.kind : "result")}
{it.subtitle ? " • " + it.subtitle : ""}
{truncateText((it.kind ? it.kind : "result") + (it.subtitle ? " • " + it.subtitle : ""), 120)}
</div>
</button>
))}

View File

@ -46,6 +46,33 @@ function markAsInstalled() {
let deferredPrompt = null;
let promptShown = false;
let iosHintShown = false;
function isIOS() {
if (typeof navigator === "undefined") return false;
const ua = navigator.userAgent || "";
const iOSDevice = /iphone|ipad|ipod/i.test(ua);
const iPadOS = ua.includes("Mac") && "ontouchend" in document;
return iOSDevice || iPadOS;
}
function isSafari() {
if (typeof navigator === "undefined") return false;
const ua = navigator.userAgent || "";
const isIOSChrome = /crios/i.test(ua);
const isIOSFirefox = /fxios/i.test(ua);
const isIOSEdge = /edgios/i.test(ua);
const isSafariUA = /safari/i.test(ua);
return isSafariUA && !isIOSChrome && !isIOSFirefox && !isIOSEdge;
}
function showIOSInstallHint() {
if (iosHintShown) return;
iosHintShown = true;
alert(
"iOS: pour installer l'app, ouvre le menu Partager (icône carré + flèche), puis choisis “Sur lécran daccueil”."
);
}
// Écoute l'événement d'installation
window.addEventListener('beforeinstallprompt', (e) => {
@ -63,15 +90,8 @@ window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
console.log('PWA install prompt ready');
// Affiche le prompt automatiquement après 10 secondes (une seule fois)
// Délai augmenté pour ne pas déranger immédiatement
setTimeout(() => {
if (!promptShown && deferredPrompt && !isRunningAsPWA()) {
showInstallPrompt();
}
}, 10000); // 10 secondes
console.log('PWA install prompt ready - waiting for manual click');
// Pas de prompt automatique - l'utilisateur cliquera sur le bouton s'il veut installer
});
// Écoute l'événement quand l'app est installée
@ -103,18 +123,40 @@ function showInstallPrompt() {
/**
* Fonction exportée pour déclencher manuellement l'installation
* IMPORTANT: Le clic manuel ignore promptShown pour permettre de réessayer
*/
export function promptInstall() {
if (isRunningAsPWA()) {
console.log('Already running as PWA, no need to install');
alert('App is already installed!');
return false;
}
if (deferredPrompt && !promptShown) {
showInstallPrompt();
if (isIOS() && isSafari()) {
showIOSInstallHint();
return true;
}
if (deferredPrompt) {
// Clic manuel - force l'affichage même si déjà montré
console.log('Showing install prompt (manual trigger)');
deferredPrompt.prompt();
deferredPrompt.userChoice.then((choice) => {
console.log('Install outcome:', choice.outcome);
if (choice.outcome === 'accepted') {
markAsInstalled();
}
deferredPrompt = null;
});
return true;
} else {
console.log('No install prompt available or already shown');
console.log('No install prompt available - browser may not support PWA install or already installed');
if (isIOS() && isSafari()) {
showIOSInstallHint();
return true;
}
alert('PWA installation not available. Make sure you are using a supported browser (Chrome, Edge, Safari).');
return false;
}
}
@ -123,5 +165,7 @@ export function promptInstall() {
* Vérifie si le prompt d'installation est disponible
*/
export function canInstall() {
return !isRunningAsPWA() && !isAlreadyInstalled() && deferredPrompt !== null;
}
if (isRunningAsPWA() || isAlreadyInstalled()) return false;
if (isIOS() && isSafari()) return true;
return deferredPrompt !== null;
}

View File

@ -5,10 +5,11 @@
background: rgba(15, 23, 42, 0.86);
border: 1.5px solid rgba(148, 163, 184, 0.7);
display:flex; align-items:center; justify-content:center;
color:#e5e7eb;
box-shadow:0 3px 10px rgba(0,0,0,.75);
transition: transform 0.36s ease, box-shadow 0.36s ease, border-color 0.36s ease, background 0.36s ease;
}
.sw-icon-circle i { font-size:22px; }
.sw-icon-circle i { font-size:22px; color: currentColor; }
.sw-icon-label { font-size:.7rem; line-height:1; text-shadow:0 1px 2px rgba(0,0,0,.9); }
.sw-icon-btn:hover .sw-icon-circle { transform: translateY(-1px); box-shadow:0 5px 14px rgba(0,0,0,.85); }
.sw-icon-btn:active .sw-icon-circle { transform: scale(.96); box-shadow:0 2px 8px rgba(0,0,0,.9); }
@ -154,6 +155,7 @@ body[data-theme="light"] .sw-icon-circle{
background: rgba(255,255,255,0.92);
border-color: rgba(148,163,184,0.85);
box-shadow: 0 3px 10px rgba(0,0,0,.10);
color:#0B1220;
}
body[data-theme="light"] .sw-icon-label{
color:#0B1220;

260
src/styles/island.css Normal file
View File

@ -0,0 +1,260 @@
/* ===== ISLAND VIEWER MODAL ===== */
/* Animations for MapLibre-based islands */
@keyframes islandFloat {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
}
@keyframes treeSway {
0%, 100% {
transform: rotate(0deg);
}
50% {
transform: rotate(3deg);
}
}
@keyframes contentFloat {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-5px);
}
}
.island-viewer-modal {
position: fixed;
inset: 0;
z-index: 9999;
background: linear-gradient(135deg, rgba(0, 20, 40, 0.95) 0%, rgba(0, 10, 30, 0.98) 100%);
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(8px);
animation: fadeIn 0.4s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.island-viewer-container {
position: relative;
width: 92%;
max-width: 1300px;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #334155 100%);
border-radius: 24px;
padding: 2.5rem;
box-shadow:
0 25px 80px rgba(0, 0, 0, 0.7),
0 0 0 1px rgba(148, 163, 184, 0.1),
inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
animation: slideUp 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
border: 1px solid rgba(56, 189, 248, 0.2);
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.island-viewer-close {
position: absolute;
top: 1rem;
right: 1rem;
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
font-size: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
z-index: 10;
}
.island-viewer-close:hover {
background: rgba(255, 255, 255, 0.3);
transform: rotate(90deg);
}
.island-viewer-close:active {
transform: rotate(90deg) scale(0.95);
}
/* Header */
.island-viewer-header {
text-align: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
}
.island-viewer-header h2 {
color: #f1f5f9;
font-size: 2.25rem;
margin-bottom: 0.75rem;
font-weight: 800;
text-shadow: 0 2px 12px rgba(56, 189, 248, 0.4);
letter-spacing: -0.02em;
}
.island-viewer-header p {
color: #cbd5e1;
font-size: 1.1rem;
font-style: italic;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
/* Canvas */
.island-viewer-canvas {
border-radius: 16px;
overflow: hidden;
box-shadow:
0 12px 40px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(56, 189, 248, 0.15),
inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
display: block;
margin: 0 auto;
border: 1px solid rgba(148, 163, 184, 0.1);
}
/* Stats */
.island-viewer-stats {
display: flex;
gap: 2rem;
justify-content: center;
margin-top: 1.5rem;
}
.stat {
text-align: center;
}
.stat-value {
display: block;
color: white;
font-size: 2rem;
font-weight: bold;
margin-bottom: 0.25rem;
}
.stat-label {
color: rgba(255, 255, 255, 0.7);
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Footer */
.island-viewer-footer {
text-align: center;
margin-top: 1rem;
}
.island-info {
color: rgba(255, 255, 255, 0.6);
font-size: 0.85rem;
margin: 0;
}
/* ===== ISLAND MARKERS (on map) ===== */
.island-marker {
opacity: 0;
transition: opacity 0.4s ease;
}
.island-marker.sw-appear-in {
opacity: 1;
}
.island-marker-container {
pointer-events: auto;
}
/* Responsive */
@media (max-width: 768px) {
.island-viewer-container {
width: 95%;
padding: 1.5rem;
}
.island-viewer-header h2 {
font-size: 1.5rem;
}
.island-viewer-canvas {
height: 400px !important;
}
.island-viewer-stats {
flex-wrap: wrap;
gap: 1rem;
}
.stat-value {
font-size: 1.5rem;
}
}
/* Light theme support */
body[data-theme="light"] .island-viewer-container {
background: linear-gradient(135deg, #e0e7ff 0%, #ddd6fe 100%);
}
body[data-theme="light"] .island-viewer-header h2 {
color: #1e3a8a;
}
body[data-theme="light"] .island-viewer-header p {
color: rgba(30, 58, 138, 0.8);
}
body[data-theme="light"] .stat-value {
color: #1e3a8a;
}
body[data-theme="light"] .stat-label {
color: rgba(30, 58, 138, 0.7);
}
body[data-theme="light"] .island-info {
color: rgba(30, 58, 138, 0.6);
}
body[data-theme="light"] .island-viewer-close {
background: rgba(30, 58, 138, 0.2);
color: #1e3a8a;
}
body[data-theme="light"] .island-viewer-close:hover {
background: rgba(30, 58, 138, 0.3);
}

280
src/styles/profile.css Normal file
View File

@ -0,0 +1,280 @@
/* ===== USER PROFILE MODAL ===== */
.user-profile-modal {
position: fixed;
inset: 0;
z-index: 9998; /* Just below island viewer */
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(3px);
animation: fadeIn 0.25s ease;
}
.user-profile-container {
position: relative;
width: 90%;
max-width: 500px;
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%);
border-radius: 16px;
padding: 2rem;
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.4);
animation: slideUp 0.3s ease;
}
.user-profile-close {
position: absolute;
top: 0.75rem;
right: 0.75rem;
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.15);
border: none;
color: white;
font-size: 20px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
z-index: 10;
}
.user-profile-close:hover {
background: rgba(255, 255, 255, 0.25);
transform: rotate(90deg);
}
/* Header */
.user-profile-header {
text-align: center;
margin-bottom: 1.5rem;
}
.user-profile-avatar {
width: 100px;
height: 100px;
border-radius: 50%;
margin: 0 auto 1rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: 4px solid rgba(255, 255, 255, 0.2);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.user-profile-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.user-profile-avatar-letter {
color: white;
font-size: 48px;
font-weight: bold;
}
.user-profile-username {
color: white;
font-size: 1.75rem;
margin: 0 0 0.25rem 0;
font-weight: bold;
}
.user-profile-display-name {
color: rgba(255, 255, 255, 0.7);
font-size: 1rem;
margin: 0;
font-style: italic;
}
/* Bio */
.user-profile-bio {
background: rgba(0, 0, 0, 0.2);
border-radius: 10px;
padding: 1rem;
margin-bottom: 1.5rem;
}
.user-profile-bio p {
color: rgba(255, 255, 255, 0.9);
margin: 0;
font-size: 0.95rem;
line-height: 1.5;
}
/* Stats */
.user-profile-stats {
display: flex;
gap: 1.5rem;
justify-content: center;
margin-bottom: 1.5rem;
}
.user-profile-stat {
text-align: center;
}
.user-profile-stat-value {
display: block;
color: white;
font-size: 1.5rem;
font-weight: bold;
margin-bottom: 0.25rem;
}
.user-profile-stat-label {
color: rgba(255, 255, 255, 0.6);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Actions */
.user-profile-actions {
display: flex;
gap: 0.75rem;
justify-content: center;
margin-bottom: 1rem;
}
.user-profile-btn {
padding: 0.75rem 1.5rem;
border-radius: 999px;
border: none;
font-size: 1rem;
font-weight: bold;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.5rem;
}
.user-profile-btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.user-profile-btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.6);
}
.user-profile-btn-primary:active {
transform: translateY(0);
}
/* Footer */
.user-profile-footer {
text-align: center;
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.user-profile-seed {
color: rgba(255, 255, 255, 0.5);
font-size: 0.8rem;
margin: 0;
}
/* Loading & No Island */
.user-profile-loading,
.user-profile-no-island {
text-align: center;
padding: 2rem;
color: rgba(255, 255, 255, 0.7);
}
/* Responsive */
@media (max-width: 600px) {
.user-profile-container {
width: 95%;
padding: 1.5rem;
}
.user-profile-avatar {
width: 80px;
height: 80px;
}
.user-profile-avatar-letter {
font-size: 36px;
}
.user-profile-username {
font-size: 1.5rem;
}
.user-profile-stats {
gap: 1rem;
}
.user-profile-stat-value {
font-size: 1.25rem;
}
}
/* Light theme */
body[data-theme="light"] .user-profile-container {
background: linear-gradient(135deg, #e0e7ff 0%, #f5f3ff 100%);
}
body[data-theme="light"] .user-profile-username {
color: #1e3a8a;
}
body[data-theme="light"] .user-profile-display-name {
color: rgba(30, 58, 138, 0.7);
}
body[data-theme="light"] .user-profile-bio {
background: rgba(255, 255, 255, 0.5);
}
body[data-theme="light"] .user-profile-bio p {
color: #1e3a8a;
}
body[data-theme="light"] .user-profile-stat-value {
color: #1e3a8a;
}
body[data-theme="light"] .user-profile-stat-label {
color: rgba(30, 58, 138, 0.6);
}
body[data-theme="light"] .user-profile-seed {
color: rgba(30, 58, 138, 0.5);
}
body[data-theme="light"] .user-profile-loading,
body[data-theme="light"] .user-profile-no-island {
color: rgba(30, 58, 138, 0.7);
}
body[data-theme="light"] .user-profile-close {
background: rgba(30, 58, 138, 0.15);
color: #1e3a8a;
}
body[data-theme="light"] .user-profile-close:hover {
background: rgba(30, 58, 138, 0.25);
}
body[data-theme="light"] .user-profile-footer {
border-top-color: rgba(30, 58, 138, 0.1);
}

View File

@ -1,12 +1,15 @@
.topbar{
position: sticky; top:0; z-index:100;
width:100%;
padding: .55rem .9rem;
display:flex; align-items:center; justify-content:space-between; gap:.6rem;
padding: .55rem .4rem .55rem 1.2rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
background: linear-gradient(135deg,#071225 0%,#0b2b5f 38%,#0e65c0 72%,#27a8ff 100%);
border-bottom-left-radius:18px; border-bottom-right-radius:18px;
box-shadow: 0 10px 24px rgba(0,0,0,.55), 0 0 18px rgba(56,189,248,.45);
overflow:hidden;
overflow: visible;
}
.logo-circle{
@ -21,7 +24,24 @@
.main-title{ font-size:1.02rem; font-weight:800; letter-spacing:.04em; color:#f9fafb; }
.sub-title{ font-size:.74rem; color:#dbeafe; opacity:.95; }
.topbar-right{ display:flex; align-items:center; gap:.5rem; margin-left:auto; flex-wrap:wrap; }
.topbar-right{
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-left: auto;
margin-right: 0;
padding-right: 0;
flex-wrap: nowrap;
flex-shrink: 0;
width: auto;
justify-content: flex-end;
}
.topbar-user-section{
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.theme-switch{ display:flex; gap:.28rem; }
.theme-dot{
@ -53,11 +73,195 @@
}
.btn-secondary:hover{ background:rgba(15,23,42,.45); }
.btn-icon{
width: 36px;
height: 36px;
border-radius: 50%;
border: 1px solid rgba(148,163,184,.5);
background: rgba(15,23,42,.6);
color: #38bdf8;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
cursor: pointer;
box-shadow: 0 0 10px rgba(56,189,248,.3);
transition: all 0.3s ease;
}
.btn-icon:hover{
background: rgba(56,189,248,.2);
box-shadow: 0 0 16px rgba(56,189,248,.6);
transform: scale(1.1);
border-color: #38bdf8;
}
/* New action buttons design */
.topbar-actions{
display: inline-flex;
align-items: center;
gap: 0.3rem;
flex-shrink: 0;
margin: 0;
padding: 0;
}
.btn-action{
height: 32px;
padding: 0 0.5rem;
border-radius: 999px;
border: 1px solid rgba(148, 163, 184, 0.3);
background: linear-gradient(135deg, rgba(15, 23, 42, 0.8), rgba(30, 41, 59, 0.8));
color: #f1f5f9;
font-size: 0.8rem;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
white-space: nowrap;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.btn-action i{
font-size: 0.9rem;
}
.btn-action .btn-label{
font-size: 0.8rem;
letter-spacing: 0.01em;
}
.btn-action:hover{
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(56, 189, 248, 0.4);
border-color: rgba(56, 189, 248, 0.6);
}
.btn-action:active{
transform: translateY(0);
}
.btn-action:disabled{
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
/* Specific button colors */
.btn-action-install{
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
border-color: rgba(139, 92, 246, 0.5);
color: white;
}
.btn-action-install:hover{
background: linear-gradient(135deg, #a78bfa, #8b5cf6);
box-shadow: 0 4px 16px rgba(139, 92, 246, 0.6);
border-color: #a78bfa;
}
.btn-action-islands{
background: linear-gradient(135deg, #06b6d4, #0891b2);
border-color: rgba(6, 182, 212, 0.5);
color: white;
}
.btn-action-islands:hover{
background: linear-gradient(135deg, #22d3ee, #06b6d4);
box-shadow: 0 4px 16px rgba(6, 182, 212, 0.6);
border-color: #22d3ee;
}
.btn-action-auth{
background: linear-gradient(135deg, #10b981, #059669);
border-color: rgba(16, 185, 129, 0.5);
color: white;
}
.btn-action-auth:hover{
background: linear-gradient(135deg, #34d399, #10b981);
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.6);
border-color: #34d399;
}
@media (max-width:900px){
.topbar-right{
gap: 0.6rem;
}
.btn-action .btn-label{
display: none;
}
.btn-action{
padding: 0 0.5rem;
}
}
@media (max-width:640px){
.topbar{ padding:.5rem .7rem; border-bottom-left-radius:16px; border-bottom-right-radius:16px; }
.main-title{ font-size:1rem; }
.sub-title{ font-size:.7rem; }
.topbar-right{ width:100%; justify-content:flex-start; gap:.45rem; }
.topbar{
padding: .5rem .4rem .5rem .7rem;
border-bottom-left-radius: 16px;
border-bottom-right-radius: 16px;
flex-wrap: wrap;
}
.main-title{ font-size: 1rem; }
.sub-title{ font-size: .7rem; }
.topbar-right{
width: fit-content !important;
max-width: fit-content !important;
justify-content: flex-end;
gap: 0.4rem;
margin-left: auto;
margin-top: 0.5rem;
flex-grow: 0;
flex-shrink: 1;
}
.topbar-user-section{
order: 1;
width: fit-content !important;
max-width: fit-content !important;
flex-grow: 0;
}
.sw-userchip{
width: fit-content !important;
max-width: fit-content !important;
flex-grow: 0;
}
.sw-usertext{
width: fit-content !important;
max-width: fit-content !important;
flex-grow: 0;
}
.sw-userline, .sw-userhandle{
max-width: fit-content !important;
}
.topbar-actions{
order: 2;
gap: 0.3rem;
width: fit-content !important;
flex-grow: 0;
}
.btn-action{
height: 34px;
min-width: 34px;
padding: 0 0.5rem;
}
.btn-action i{
font-size: 0.9rem;
}
}
/* SW_AUTH_UI:BEGIN */
@ -264,48 +468,52 @@ body[data-theme="light"] .user-avatar{
/* User chip used by TopBar.jsx (missing styles before) */
.sw-userchip{
display:flex;
align-items:center;
gap:.5rem;
padding:.28rem .55rem;
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.4rem;
border-radius: 999px;
background: rgba(2,6,23,.22);
border: 1px solid rgba(148,163,184,.45);
box-shadow: 0 4px 14px rgba(0,0,0,.25);
margin: 0;
}
.sw-avatar{
width: 30px;
height: 30px;
width: 26px;
height: 26px;
border-radius: 999px;
display:grid;
place-items:center;
display: grid;
place-items: center;
background: rgba(56,189,248,0.18);
border: 1px solid rgba(56,189,248,0.35);
color: #e5e7eb;
font-weight: 950;
overflow:hidden;
overflow: hidden;
flex-shrink: 0;
}
.sw-avatar i{ font-size: 14px; }
.sw-avatar i{ font-size: 12px; }
.sw-usertext{
display:flex;
flex-direction:column;
display: flex;
flex-direction: column;
line-height: 1.05;
min-width: 0;
max-width: max-content;
}
.sw-userline{
font-size: .72rem;
font-size: 0.68rem;
font-weight: 950;
color: #f9fafb;
white-space: nowrap;
}
.sw-userhandle{
font-size: .68rem;
font-size: 0.64rem;
font-weight: 800;
color: #dbeafe;
opacity: .92;
max-width: 160px;
overflow:hidden;
max-width: max-content;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@ -474,17 +682,16 @@ body[data-theme="light"] .sw-avatar{
flex-wrap: nowrap !important;
white-space: nowrap;
min-width: 0;
max-width: 52vw;
overflow: hidden;
max-width: max-content;
overflow: visible;
}
/* Make user handle truncate instead of forcing width */
.sw-userhandle{ max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sw-userchip{ min-width: 0; }
/* Make user handle and chip take only needed space */
.sw-userchip{ min-width: 0; max-width: max-content; width: max-content; }
@media (max-width: 640px){
.main-title, .sub-title{ max-width: 62vw; }
.topbar-right{ max-width: 45vw; }
.topbar-right{ max-width: 100%; }
.brand-under{ padding-left: 0; }
}
/* SW_TOPBAR_ONE_LINE_FIX:END */

316
src/styles/universe.css Normal file
View File

@ -0,0 +1,316 @@
/* ===== ISLAND UNIVERSE VIEW ===== */
.island-universe-modal {
position: fixed;
inset: 0;
z-index: 9999;
background: linear-gradient(135deg, rgba(0, 0, 0, 0.97) 0%, rgba(10, 14, 39, 0.98) 100%);
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(12px);
animation: fadeIn 0.5s ease;
}
.island-universe-container {
position: relative;
width: 96%;
max-width: 1500px;
height: 92vh;
background: linear-gradient(135deg, #0a0e27 0%, #0f1624 30%, #1a1f3a 70%, #16213e 100%);
border-radius: 24px;
padding: 2.5rem;
box-shadow:
0 30px 100px rgba(0, 0, 0, 0.9),
0 0 0 1px rgba(56, 189, 248, 0.15),
inset 0 1px 0 0 rgba(255, 255, 255, 0.03);
animation: slideUp 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
display: flex;
flex-direction: column;
border: 1px solid rgba(148, 163, 184, 0.1);
}
.island-universe-close {
position: absolute;
top: 1rem;
right: 1rem;
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
border: 2px solid rgba(255, 255, 255, 0.2);
color: white;
font-size: 28px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
z-index: 10;
font-weight: 300;
line-height: 1;
}
.island-universe-close:hover {
background: rgba(255, 255, 255, 0.2);
transform: rotate(90deg) scale(1.1);
border-color: rgba(255, 255, 255, 0.4);
}
/* Header */
.island-universe-header {
text-align: center;
margin-bottom: 2rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid rgba(56, 189, 248, 0.15);
}
.island-universe-header h1 {
color: #f1f5f9;
font-size: 3rem;
margin: 0 0 0.75rem 0;
font-weight: 900;
text-shadow: 0 4px 20px rgba(56, 189, 248, 0.5), 0 2px 8px rgba(0, 0, 0, 0.7);
letter-spacing: -0.03em;
background: linear-gradient(135deg, #38bdf8 0%, #818cf8 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.island-universe-header p {
color: #cbd5e1;
font-size: 1.15rem;
margin: 0;
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
font-weight: 500;
}
/* Canvas */
.island-universe-canvas {
border-radius: 12px;
overflow: hidden;
box-shadow:
0 10px 40px rgba(0, 0, 0, 0.4),
inset 0 0 0 1px rgba(255, 255, 255, 0.05);
flex: 1;
min-height: 0;
}
/* Tooltip (appears on hover) */
.island-universe-tooltip {
position: absolute;
bottom: 120px;
left: 50%;
transform: translateX(-50%);
background: rgba(20, 30, 50, 0.95);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 12px;
padding: 1rem 1.5rem;
min-width: 250px;
text-align: center;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.5);
animation: tooltipAppear 0.2s ease;
backdrop-filter: blur(10px);
}
@keyframes tooltipAppear {
from {
opacity: 0;
transform: translateX(-50%) translateY(10px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
.tooltip-username {
color: #60a5fa;
font-size: 1.25rem;
font-weight: bold;
margin-bottom: 0.5rem;
text-shadow: 0 0 10px rgba(96, 165, 250, 0.5);
}
.tooltip-bio {
color: rgba(255, 255, 255, 0.9);
font-size: 0.95rem;
margin-bottom: 0.5rem;
line-height: 1.4;
}
.tooltip-stats {
color: rgba(255, 255, 255, 0.6);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Help text */
.island-universe-help {
text-align: center;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.island-universe-help p {
color: rgba(255, 255, 255, 0.5);
font-size: 0.9rem;
margin: 0;
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes universeIslandFloat {
0%, 100% {
transform: translateY(0px) translateX(0px);
}
25% {
transform: translateY(-12px) translateX(5px);
}
50% {
transform: translateY(-5px) translateX(-3px);
}
75% {
transform: translateY(-15px) translateX(3px);
}
}
/* Responsive */
@media (max-width: 900px) {
.island-universe-container {
width: 98%;
height: 95vh;
padding: 1.5rem;
border-radius: 15px;
}
.island-universe-header h1 {
font-size: 2rem;
}
.island-universe-header p {
font-size: 1rem;
}
.island-universe-canvas {
height: 500px !important;
}
.island-universe-tooltip {
bottom: 100px;
min-width: 200px;
padding: 0.75rem 1rem;
}
.tooltip-username {
font-size: 1.1rem;
}
.tooltip-bio {
font-size: 0.85rem;
}
.tooltip-stats {
font-size: 0.75rem;
}
}
@media (max-width: 600px) {
.island-universe-container {
padding: 1rem;
}
.island-universe-header h1 {
font-size: 1.5rem;
}
.island-universe-canvas {
height: 400px !important;
}
.island-universe-close {
width: 36px;
height: 36px;
font-size: 24px;
}
}
/* Light theme support */
body[data-theme="light"] .island-universe-container {
background: linear-gradient(135deg, #e0e7ff 0%, #dbeafe 100%);
}
body[data-theme="light"] .island-universe-header h1 {
color: #1e3a8a;
}
body[data-theme="light"] .island-universe-header p {
color: rgba(30, 58, 138, 0.7);
}
body[data-theme="light"] .island-universe-close {
background: rgba(30, 58, 138, 0.1);
border-color: rgba(30, 58, 138, 0.2);
color: #1e3a8a;
}
body[data-theme="light"] .island-universe-close:hover {
background: rgba(30, 58, 138, 0.2);
border-color: rgba(30, 58, 138, 0.4);
}
body[data-theme="light"] .island-universe-tooltip {
background: rgba(255, 255, 255, 0.95);
border-color: rgba(30, 58, 138, 0.2);
}
body[data-theme="light"] .tooltip-username {
color: #2563eb;
}
body[data-theme="light"] .tooltip-bio {
color: #1e3a8a;
}
body[data-theme="light"] .tooltip-stats {
color: rgba(30, 58, 138, 0.6);
}
body[data-theme="light"] .island-universe-help p {
color: rgba(30, 58, 138, 0.5);
}
body[data-theme="light"] .island-universe-canvas {
box-shadow:
0 10px 40px rgba(0, 0, 0, 0.1),
inset 0 0 0 1px rgba(30, 58, 138, 0.1);
}