diff --git a/package-lock.json b/package-lock.json index 5087bcf..c9e6ef6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } diff --git a/src/App.jsx b/src/App.jsx index f1593ad..0fe3982 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 (
- +
@@ -91,6 +115,8 @@ export default function App() { onPostsState={handlePostsState} selectedPost={selectedPost} onSelectPost={setSelectedPost} + selectedIsland={selectedIsland} + onSelectIsland={setSelectedIsland} />
@@ -143,6 +169,22 @@ export default function App() { ))}
+ + {/* Island Universe Modal */} + {showUniverse && ( + + )} + + {/* Island Viewer Modal */} + {selectedIsland && ( + setSelectedIsland(null)} + /> + )} ); } \ No newline at end of file diff --git a/src/api/client.js b/src/api/client.js index 0257ca0..b3b4762 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -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; + } + */ +} diff --git a/src/components/Island/IslandUniverse.jsx b/src/components/Island/IslandUniverse.jsx new file mode 100644 index 0000000..06d7916 --- /dev/null +++ b/src/components/Island/IslandUniverse.jsx @@ -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 = ` +
+ ${islandSVG} +
@${island.username}
+
+ `; + + // 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 ( +
+
e.stopPropagation()}> + + +
+

🌌 Island Universe

+

{islands.length} islands floating in virtual space

+
+ +
+ + {hoveredIsland && ( +
+
@{hoveredIsland.username}
+
{hoveredIsland.display_name || hoveredIsland.bio || 'Click to visit'}
+
+ {hoveredIsland.content_count} posts • {hoveredIsland.terrain_type} +
+
+ )} + +
+

🖱️ Hover over islands to see details • Click to visit

+
+
+
+ ); +} + +/** + * 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 += ``; + } + + return ` + + + + + + + + + + + + ${trees} + + `; +} diff --git a/src/components/Island/IslandViewer.jsx b/src/components/Island/IslandViewer.jsx new file mode 100644 index 0000000..5037097 --- /dev/null +++ b/src/components/Island/IslandViewer.jsx @@ -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 ( +
+
e.stopPropagation()}> + + +
+

{island.display_name || `${island.username}'s Island`}

+

{island.bio || 'Welcome to my island'}

+
+ +
+ +
+
+ {island.content_count || 0} + Posts +
+
+ {island.visitors || 0} + Visitors +
+
+ {island.terrain_type || 'tropical'} + Terrain +
+
+ +
+

+ 🌱 Seed: {island.seed} • 🎨 {island.color_palette || 'vibrant'} palette +

+
+
+
+ ); +} + +/** + * 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 = ` +
+ ${createIslandTexture(seed, terrainType)} +
+ `; + + 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 += ` +
+ `; + } + + 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 = ` +
+ `; + } else if (terrainType === 'desert') { + treeHTML = ` +
+ `; + } else { + // Tropical/meadow: stylized tree + treeHTML = ` +
+
+
+
+
+ `; + } + + 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 = ` +
+ ${content.content_type === 'post' ? '📄' : content.content_type === 'app' ? '📱' : '📁'} +
+ `; + + return el; +} + +/** + * Seeded random number generator (deterministic) + */ +function seededRandom(seed) { + let s = seed; + return function() { + s = (s * 9301 + 49297) % 233280; + return s / 233280; + }; +} + diff --git a/src/components/Layout/TopBar.jsx b/src/components/Layout/TopBar.jsx index f7387c5..3291fa5 100644 --- a/src/components/Layout/TopBar.jsx +++ b/src/components/Layout/TopBar.jsx @@ -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 }) {
- {/* Theme dots + BOUTON INSTALL PWA */} + {/* Theme dots */}
{["dark", "blue", "light"].map((t) => ( @@ -293,64 +290,71 @@ export default function TopBar({ theme = "dark", onChangeTheme }) { ))}
- - {/* BOUTON INSTALL PWA ici en haut - seulement si pas déjà installé */} - {showInstallBtn && ( - - )}
- {authenticated ? ( -
-
- {initialLetter(username)} + {/* User info */} +
+ {authenticated ? ( +
+
+ {initialLetter(username)} +
+
+
Online
+
@{username || "user"}
+
-
-
Online
-
@{username || "user"}
+ ) : ( +
+
+ +
+
+
{loading ? "Loading…" : "Guest"}
+
anon
+
-
- ) : ( -
-
- -
-
-
{loading ? "Loading…" : "Guest"}
-
anon
-
-
- )} + )} +
- + {/* Action buttons - separate section */} +
+ {/* PWA Install button - no dismiss X */} + {showInstallBtn && ( + + )} + + {/* Islands Universe button */} + + + {/* Login/Logout button */} + +
diff --git a/src/components/Map/MapView.jsx b/src/components/Map/MapView.jsx index 19bb7b0..fd3806c 100644 --- a/src/components/Map/MapView.jsx +++ b/src/components/Map/MapView.jsx @@ -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({
)}
+ + {/* User Profile Modal */} + {selectedProfile && ( + setSelectedProfile(null)} + onVisitIsland={handleProfileVisitIsland} + /> + )} ); } diff --git a/src/components/Map/mapGeo.js b/src/components/Map/mapGeo.js index 0ed6d66..10fff56 100644 --- a/src/components/Map/mapGeo.js +++ b/src/components/Map/mapGeo.js @@ -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; diff --git a/src/components/Map/markerManager.js b/src/components/Map/markerManager.js index 3e4c362..c03e94e 100644 --- a/src/components/Map/markerManager.js +++ b/src/components/Map/markerManager.js @@ -162,7 +162,7 @@ function openCenteredOverlay({ map, post, theme }) {
${escapeHtml(cat)}
- ${metaLeft ? `${escapeHtml(metaLeft)}` : ""} + ${metaLeft ? `${escapeHtml(metaLeft)}` : ""} ${metaRight ? `• ${escapeHtml(metaRight)}` : ""} • ${escapeHtml(relTime)}
@@ -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 - ? ` - - - ` - : ''; + // DISABLED: No longer using pixel offsets + const hasOffset = false; + const lineHTML = ''; root.innerHTML = `
@@ -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 = ` +
+
+ ${island.avatar_url + ? `${island.username}` + : `
${island.username[0].toUpperCase()}
` + } +
+
🏝️ ISLAND
+
@${island.username}
+
+
+ `; + + // 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]); +} diff --git a/src/components/Map/templateSpecs.js b/src/components/Map/templateSpecs.js index e8ef9fd..38bf3c7 100644 --- a/src/components/Map/templateSpecs.js +++ b/src/components/Map/templateSpecs.js @@ -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) { diff --git a/src/components/Map/useMapCore.js b/src/components/Map/useMapCore.js index 081e837..e872ab7 100644 --- a/src/components/Map/useMapCore.js +++ b/src/components/Map/useMapCore.js @@ -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 }; -} \ No newline at end of file +} diff --git a/src/components/Map/usePostsEngine.js b/src/components/Map/usePostsEngine.js index 47e34f1..8ec2fe2 100644 --- a/src/components/Map/usePostsEngine.js +++ b/src/components/Map/usePostsEngine.js @@ -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); diff --git a/src/components/Profile/UserProfile.jsx b/src/components/Profile/UserProfile.jsx new file mode 100644 index 0000000..bbdcd54 --- /dev/null +++ b/src/components/Profile/UserProfile.jsx @@ -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 ( +
+
e.stopPropagation()}> + + +
+
+ {island?.avatar_url ? ( + {username} + ) : ( +
+ {username[0].toUpperCase()} +
+ )} +
+

@{username}

+ {island?.display_name && island.display_name !== `${username}'s Island` && ( +

{island.display_name}

+ )} +
+ + {loading ? ( +
Loading profile...
+ ) : island ? ( + <> +
+

{island.bio || 'No bio yet.'}

+
+ +
+
+ {island.content_count || 0} + Posts +
+
+ {island.visitors || 0} + Visitors +
+
+ {island.terrain_type || 'tropical'} + Terrain +
+
+ +
+ +
+ +
+

Seed: {island.seed}

+
+ + ) : ( +
+

This user doesn't have an island yet.

+
+ )} +
+
+ ); +} diff --git a/src/components/Search/SmartSearchBar.jsx b/src/components/Search/SmartSearchBar.jsx index 5d2b920..938e72c 100644 --- a/src/components/Search/SmartSearchBar.jsx +++ b/src/components/Search/SmartSearchBar.jsx @@ -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 ( ); } +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({ 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()} > - + ))} diff --git a/src/pwaInstall.js b/src/pwaInstall.js index 38f4622..c934048 100644 --- a/src/pwaInstall.js +++ b/src/pwaInstall.js @@ -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 d’accueil”." + ); +} // É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; -} \ No newline at end of file + if (isRunningAsPWA() || isAlreadyInstalled()) return false; + if (isIOS() && isSafari()) return true; + return deferredPrompt !== null; +} diff --git a/src/styles/filters.css b/src/styles/filters.css index a3da26a..82a02cb 100644 --- a/src/styles/filters.css +++ b/src/styles/filters.css @@ -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; diff --git a/src/styles/island.css b/src/styles/island.css new file mode 100644 index 0000000..06129cf --- /dev/null +++ b/src/styles/island.css @@ -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); +} diff --git a/src/styles/profile.css b/src/styles/profile.css new file mode 100644 index 0000000..cbb60cf --- /dev/null +++ b/src/styles/profile.css @@ -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); +} diff --git a/src/styles/topbar.css b/src/styles/topbar.css index e661632..5237078 100644 --- a/src/styles/topbar.css +++ b/src/styles/topbar.css @@ -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 */ diff --git a/src/styles/universe.css b/src/styles/universe.css new file mode 100644 index 0000000..5324fd3 --- /dev/null +++ b/src/styles/universe.css @@ -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); +}