Improve map fetch, search UX, and filters
This commit is contained in:
parent
e38238d199
commit
eca8c6c270
|
|
@ -59,6 +59,7 @@
|
||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
|
|
@ -1084,6 +1085,7 @@
|
||||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
|
|
@ -1134,6 +1136,7 @@
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
|
|
@ -1256,6 +1259,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
|
|
@ -1557,6 +1561,7 @@
|
||||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
|
|
@ -2777,6 +2782,7 @@
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -2862,6 +2868,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
|
||||||
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
|
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
|
|
@ -2871,6 +2878,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
|
||||||
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
|
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
|
|
@ -3173,6 +3181,7 @@
|
||||||
"integrity": "sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==",
|
"integrity": "sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/runtime": "0.97.0",
|
"@oxc-project/runtime": "0.97.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
|
|
@ -3295,6 +3304,7 @@
|
||||||
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
|
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
44
src/App.jsx
44
src/App.jsx
|
|
@ -1,6 +1,8 @@
|
||||||
import React, { Suspense, useEffect, useState, useCallback } from "react";
|
import React, { Suspense, useEffect, useState, useCallback } from "react";
|
||||||
import TopBar from "./components/Layout/TopBar";
|
import TopBar from "./components/Layout/TopBar";
|
||||||
import PostList from "./components/Posts/PostList";
|
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"));
|
const MapView = React.lazy(() => import("./components/Map/MapView"));
|
||||||
|
|
||||||
|
|
@ -16,6 +18,10 @@ export default function App() {
|
||||||
const [wallError, setWallError] = useState("");
|
const [wallError, setWallError] = useState("");
|
||||||
const [selectedPost, setSelectedPost] = useState(null);
|
const [selectedPost, setSelectedPost] = useState(null);
|
||||||
|
|
||||||
|
// Island Universe & Island Viewer state
|
||||||
|
const [showUniverse, setShowUniverse] = useState(false);
|
||||||
|
const [selectedIsland, setSelectedIsland] = useState(null);
|
||||||
|
|
||||||
const handlePostsState = useCallback((next) => {
|
const handlePostsState = useCallback((next) => {
|
||||||
if (!next) return;
|
if (!next) return;
|
||||||
if (Array.isArray(next.posts)) setWallPosts(next.posts);
|
if (Array.isArray(next.posts)) setWallPosts(next.posts);
|
||||||
|
|
@ -79,9 +85,27 @@ export default function App() {
|
||||||
|
|
||||||
const contactsList = ["Amely", "Nancy", "Stéphanie"];
|
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 (
|
return (
|
||||||
<div className="app-root">
|
<div className="app-root">
|
||||||
<TopBar theme={theme} onChangeTheme={setTheme} />
|
<TopBar
|
||||||
|
theme={theme}
|
||||||
|
onChangeTheme={setTheme}
|
||||||
|
onIslandsClick={handleOpenUniverse}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="main-shell">
|
<div className="main-shell">
|
||||||
<div className="map-shell">
|
<div className="map-shell">
|
||||||
|
|
@ -91,6 +115,8 @@ export default function App() {
|
||||||
onPostsState={handlePostsState}
|
onPostsState={handlePostsState}
|
||||||
selectedPost={selectedPost}
|
selectedPost={selectedPost}
|
||||||
onSelectPost={setSelectedPost}
|
onSelectPost={setSelectedPost}
|
||||||
|
selectedIsland={selectedIsland}
|
||||||
|
onSelectIsland={setSelectedIsland}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -143,6 +169,22 @@ export default function App() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Island Universe Modal */}
|
||||||
|
{showUniverse && (
|
||||||
|
<IslandUniverse
|
||||||
|
onClose={handleCloseUniverse}
|
||||||
|
onIslandClick={handleIslandClick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Island Viewer Modal */}
|
||||||
|
{selectedIsland && (
|
||||||
|
<IslandViewer
|
||||||
|
island={selectedIsland}
|
||||||
|
onClose={() => setSelectedIsland(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -214,13 +214,16 @@ export async function fetchUnifiedSearch(query, params = {}) {
|
||||||
const res = await fetch(url, { credentials: "include" });
|
const res = await fetch(url, { credentials: "include" });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.warn("fetchUnifiedSearch failed", res.status);
|
console.warn("fetchUnifiedSearch failed", res.status);
|
||||||
return [];
|
return { posts: [], profiles: [], places: [] };
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
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) {
|
} catch (err) {
|
||||||
console.warn("fetchUnifiedSearch error:", 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 (params.category) qs.set("category", params.category);
|
||||||
if (typeof params.limit === "number") qs.set("limit", String(params.limit));
|
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 {
|
try {
|
||||||
const res = await fetch(url, { credentials: "include" });
|
const res = await fetch(url, { credentials: "include" });
|
||||||
|
|
@ -252,3 +255,262 @@ export async function pollNewPosts(params = {}) {
|
||||||
return { posts: [], count: 0, max_id: 0 };
|
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;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -2,8 +2,6 @@ import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { useAuth } from "../../auth/AuthContext";
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
import { registerUser } from "../../api/client";
|
import { registerUser } from "../../api/client";
|
||||||
import AuthToast from "../Auth/AuthToast";
|
import AuthToast from "../Auth/AuthToast";
|
||||||
|
|
||||||
// Import pour le bouton PWA
|
|
||||||
import { promptInstall, isRunningAsPWA, canInstall } from '../../pwaInstall';
|
import { promptInstall, isRunningAsPWA, canInstall } from '../../pwaInstall';
|
||||||
|
|
||||||
const THEME_LABELS = { dark: "Dark", blue: "Blue", light: "Light" };
|
const THEME_LABELS = { dark: "Dark", blue: "Blue", light: "Light" };
|
||||||
|
|
@ -55,7 +53,7 @@ function parseMergedParams() {
|
||||||
return { qs, hs, get, hash };
|
return { qs, hs, get, hash };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TopBar({ theme = "dark", onChangeTheme }) {
|
export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick }) {
|
||||||
const {
|
const {
|
||||||
loading,
|
loading,
|
||||||
authenticated,
|
authenticated,
|
||||||
|
|
@ -101,21 +99,20 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
|
||||||
if (authenticated) setShowAuth(false);
|
if (authenticated) setShowAuth(false);
|
||||||
}, [authenticated]);
|
}, [authenticated]);
|
||||||
|
|
||||||
// Check if install button should be shown
|
// Check if PWA install should be shown (simple: hide if already PWA)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkInstall = () => {
|
const checkInstall = () => {
|
||||||
// Hide button if already running as PWA
|
// Hide if already running as PWA
|
||||||
if (isRunningAsPWA()) {
|
if (isRunningAsPWA()) {
|
||||||
setShowInstallBtn(false);
|
setShowInstallBtn(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Show button if installation is available
|
// Show if can install
|
||||||
setShowInstallBtn(canInstall());
|
setShowInstallBtn(canInstall());
|
||||||
};
|
};
|
||||||
|
|
||||||
checkInstall();
|
checkInstall();
|
||||||
|
|
||||||
// Listen for beforeinstallprompt to update button visibility
|
|
||||||
const handlePrompt = () => {
|
const handlePrompt = () => {
|
||||||
setTimeout(checkInstall, 100);
|
setTimeout(checkInstall, 100);
|
||||||
};
|
};
|
||||||
|
|
@ -278,7 +275,7 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Theme dots + BOUTON INSTALL PWA */}
|
{/* Theme dots */}
|
||||||
<div className="brand-under">
|
<div className="brand-under">
|
||||||
<div className="theme-switch">
|
<div className="theme-switch">
|
||||||
{["dark", "blue", "light"].map((t) => (
|
{["dark", "blue", "light"].map((t) => (
|
||||||
|
|
@ -293,64 +290,71 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div className="topbar-right">
|
<div className="topbar-right">
|
||||||
{authenticated ? (
|
{/* User info */}
|
||||||
<div className="sw-userchip" title={username}>
|
<div className="topbar-user-section">
|
||||||
<div className="sw-avatar">
|
{authenticated ? (
|
||||||
<span>{initialLetter(username)}</span>
|
<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>
|
||||||
<div className="sw-usertext">
|
) : (
|
||||||
<div className="sw-userline">Online</div>
|
<div className="sw-userchip" title="Guest">
|
||||||
<div className="sw-userhandle">@{username || "user"}</div>
|
<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>
|
)}
|
||||||
) : (
|
</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
{/* Action buttons - separate section */}
|
||||||
className="btn-primary"
|
<div className="topbar-actions">
|
||||||
type="button"
|
{/* PWA Install button - no dismiss X */}
|
||||||
onClick={() => (authenticated ? logout() : openModal("login"))}
|
{showInstallBtn && (
|
||||||
disabled={loading}
|
<button
|
||||||
title={authenticated ? "Logoff" : "Logon"}
|
className="btn-action btn-action-install"
|
||||||
>
|
type="button"
|
||||||
{authenticated ? "Logoff" : "Logon"}
|
onClick={() => promptInstall()}
|
||||||
</button>
|
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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,15 @@ import { useAuth } from "../../auth/AuthContext";
|
||||||
import FilterButtons from "../Filters/FilterButtons";
|
import FilterButtons from "../Filters/FilterButtons";
|
||||||
import TimeFilterButtons from "../Filters/TimeFilterButtons";
|
import TimeFilterButtons from "../Filters/TimeFilterButtons";
|
||||||
import SmartSearchBar from "../Search/SmartSearchBar";
|
import SmartSearchBar from "../Search/SmartSearchBar";
|
||||||
|
import UserProfile from "../Profile/UserProfile";
|
||||||
|
|
||||||
export default function MapView({
|
export default function MapView({
|
||||||
theme = "dark",
|
theme = "dark",
|
||||||
onPostsState,
|
onPostsState,
|
||||||
selectedPost,
|
selectedPost,
|
||||||
onSelectPost,
|
onSelectPost,
|
||||||
|
selectedIsland,
|
||||||
|
onSelectIsland,
|
||||||
}) {
|
}) {
|
||||||
const { authenticated, username, token, needsEmailVerification } = useAuth();
|
const { authenticated, username, token, needsEmailVerification } = useAuth();
|
||||||
|
|
||||||
|
|
@ -36,9 +39,9 @@ export default function MapView({
|
||||||
try {
|
try {
|
||||||
const v = localStorage.getItem(TIME_FILTER_KEY);
|
const v = localStorage.getItem(TIME_FILTER_KEY);
|
||||||
const ok = ["NOW", "TODAY", "RECENT", "PAST"].includes(v);
|
const ok = ["NOW", "TODAY", "RECENT", "PAST"].includes(v);
|
||||||
return ok ? v : "TODAY";
|
return ok ? v : "PAST";
|
||||||
} catch {
|
} catch {
|
||||||
return "TODAY";
|
return "PAST";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const [subFilter, setSubFilter] = useState("All");
|
const [subFilter, setSubFilter] = useState("All");
|
||||||
|
|
@ -55,6 +58,10 @@ export default function MapView({
|
||||||
const userPosition = useUserPosition(mapRef, hasLastView);
|
const userPosition = useUserPosition(mapRef, hasLastView);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
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 } =
|
const { status, visiblePosts, loadingPosts, loadError, handleIncomingPost } =
|
||||||
usePostsEngine({
|
usePostsEngine({
|
||||||
|
|
@ -67,6 +74,7 @@ export default function MapView({
|
||||||
searchQuery,
|
searchQuery,
|
||||||
markersRef,
|
markersRef,
|
||||||
expandedElRef,
|
expandedElRef,
|
||||||
|
onAutoWidenTimeFilter: (next) => setTimeFilter(next),
|
||||||
});
|
});
|
||||||
|
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
|
@ -233,31 +241,61 @@ export default function MapView({
|
||||||
speed: 1.4,
|
speed: 1.4,
|
||||||
essential: true
|
essential: true
|
||||||
});
|
});
|
||||||
|
map.__swForceFetchAt = Date.now();
|
||||||
|
|
||||||
// If picking a post, filter by its title AND clear category filters to ensure visibility
|
// If picking a post, clear filters and avoid query-locking
|
||||||
if (item.type === 'post' && item.title) {
|
if (item.type === 'post') {
|
||||||
setSearchQuery(item.title);
|
skipAutoZoomRef.current = true;
|
||||||
|
setSearchQuery("");
|
||||||
setMainFilter("All");
|
setMainFilter("All");
|
||||||
setSubFilter("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 {
|
} else {
|
||||||
|
skipAutoZoomRef.current = false;
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSearch = (query) => {
|
const handleSearch = (query) => {
|
||||||
console.log('[MapView] Search/filter by:', query);
|
console.log('[MapView] Search/filter by:', query);
|
||||||
|
skipAutoZoomRef.current = false;
|
||||||
setSearchQuery(query);
|
setSearchQuery(query);
|
||||||
|
|
||||||
// Clear category filters to show all search results
|
// Clear ALL filters to show all search results
|
||||||
if (query.trim()) {
|
if (query.trim()) {
|
||||||
setMainFilter("All");
|
setMainFilter("All");
|
||||||
setSubFilter("All");
|
setSubFilter("All");
|
||||||
|
setTimeFilter("PAST"); // Show all time periods
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Zoom intelligent: quand on cherche, zoom pour voir tous les résultats
|
// Zoom intelligent: quand on cherche, zoom pour voir tous les résultats
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!searchQuery || !visiblePosts.length) return;
|
if (!searchQuery || !visiblePosts.length) return;
|
||||||
|
if (skipAutoZoomRef.current) {
|
||||||
|
skipAutoZoomRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
|
@ -307,6 +345,29 @@ export default function MapView({
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [searchQuery, visiblePosts, mapRef]);
|
}, [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 = () => {
|
const handleOpenCreate = () => {
|
||||||
if (!authenticated) {
|
if (!authenticated) {
|
||||||
alert("You must be logged in to place a wire.");
|
alert("You must be logged in to place a wire.");
|
||||||
|
|
@ -419,25 +480,31 @@ export default function MapView({
|
||||||
|
|
||||||
const authorName = (username || "").trim() || "anon";
|
const authorName = (username || "").trim() || "anon";
|
||||||
|
|
||||||
let baseLng;
|
let lng;
|
||||||
let baseLat;
|
let lat;
|
||||||
|
|
||||||
if (draftCoords && draftCoords.length === 2) {
|
if (draftCoords && draftCoords.length === 2) {
|
||||||
baseLng = draftCoords[0];
|
lng = draftCoords[0];
|
||||||
baseLat = draftCoords[1];
|
lat = draftCoords[1];
|
||||||
} else {
|
} else {
|
||||||
const center = map.getCenter();
|
const stage = stageRef.current;
|
||||||
baseLng = center.lng;
|
const crosshairEl = stage ? stage.querySelector(".crosshair-aim") : null;
|
||||||
baseLat = center.lat;
|
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()) {
|
if (!draftTitle.trim()) {
|
||||||
setSaveError("Title required.");
|
setSaveError("Title required.");
|
||||||
return;
|
return;
|
||||||
|
|
@ -707,6 +774,15 @@ export default function MapView({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* User Profile Modal */}
|
||||||
|
{selectedProfile && (
|
||||||
|
<UserProfile
|
||||||
|
username={selectedProfile}
|
||||||
|
onClose={() => setSelectedProfile(null)}
|
||||||
|
onVisitIsland={handleProfileVisitIsland}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,18 @@ export function haversineKm(lat1, lon1, lat2, lon2) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getViewFromMap(map) {
|
export function getViewFromMap(map) {
|
||||||
const bounds = map.getBounds();
|
const mapCenter = map.getCenter();
|
||||||
const center = bounds.getCenter();
|
let center = mapCenter;
|
||||||
const ne = bounds.getNorthEast();
|
let radiusKm = 750;
|
||||||
|
try {
|
||||||
let radiusKm = haversineKm(center.lat, center.lng, ne.lat, ne.lng);
|
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) {
|
if (!Number.isFinite(radiusKm) || radiusKm <= 0) {
|
||||||
radiusKm = 750;
|
radiusKm = 750;
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,7 @@ function openCenteredOverlay({ map, post, theme }) {
|
||||||
<div class="sw-modal-left">
|
<div class="sw-modal-left">
|
||||||
<div class="sw-modal-badge">${escapeHtml(cat)}</div>
|
<div class="sw-modal-badge">${escapeHtml(cat)}</div>
|
||||||
<div class="sw-modal-meta">
|
<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>` : ""}
|
${metaRight ? `<span>• ${escapeHtml(metaRight)}</span>` : ""}
|
||||||
<span>• ${escapeHtml(relTime)}</span>
|
<span>• ${escapeHtml(relTime)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -319,7 +319,7 @@ function calculateSmartOffset(post, markersRef, map) {
|
||||||
if (createdAt) {
|
if (createdAt) {
|
||||||
const ageSeconds = (Date.now() - new Date(createdAt).getTime()) / 1000;
|
const ageSeconds = (Date.now() - new Date(createdAt).getTime()) / 1000;
|
||||||
if (ageSeconds < 10) {
|
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];
|
const lngLat = [lon, lat];
|
||||||
|
|
||||||
// Calculate smart offset for nearby markers
|
// 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");
|
const root = document.createElement("div");
|
||||||
root.classList.add("sw-appear");
|
root.classList.add("sw-appear");
|
||||||
|
|
@ -400,13 +402,9 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
|
||||||
root.style.zIndex = "1";
|
root.style.zIndex = "1";
|
||||||
|
|
||||||
// Add connecting line if offset is significant
|
// Add connecting line if offset is significant
|
||||||
const hasOffset = Math.abs(offset.x) > 5 || Math.abs(offset.y) > 5;
|
// DISABLED: No longer using pixel offsets
|
||||||
const lineHTML = hasOffset
|
const hasOffset = false;
|
||||||
? `<svg class="sw-offset-line" style="position:absolute;bottom:0;left:50%;transform:translateX(-50%);overflow:visible;pointer-events:none;z-index:-1;">
|
const lineHTML = '';
|
||||||
<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>`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="sw-template-mini-wrap"></div>
|
<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" });
|
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]);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -172,15 +172,16 @@ function parseDateAny(raw) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBreaking(post) {
|
function isBreaking(post) {
|
||||||
|
// Only show as BREAKING if explicitly tagged
|
||||||
if (post?.breaking === true || post?.is_breaking === true) return true;
|
if (post?.breaking === true || post?.is_breaking === true) return true;
|
||||||
|
|
||||||
|
// Or if title contains breaking/urgent/alerte keywords
|
||||||
const t = (post?.title || "").toString().toLowerCase();
|
const t = (post?.title || "").toString().toLowerCase();
|
||||||
if (t.includes("breaking") || t.includes("urgent") || t.includes("alerte")) return true;
|
if (t.includes("breaking") || t.includes("urgent") || t.includes("alerte")) return true;
|
||||||
|
|
||||||
const d = parseDateAny(post?.created_at || post?.CreatedAt || post?.createdAt);
|
// Removed: automatic breaking news for recent posts
|
||||||
if (!d) return false;
|
// Now only explicit tags or keywords trigger breaking news style
|
||||||
const mins = (Date.now() - d.getTime()) / 60000;
|
return false;
|
||||||
return mins >= 0 && mins <= 45;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normCat(post) {
|
function normCat(post) {
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,9 @@ export function useMapCore(theme) {
|
||||||
mapRef.current = map;
|
mapRef.current = map;
|
||||||
|
|
||||||
try { setViewParams(getViewFromMap(map)); } catch {}
|
try { setViewParams(getViewFromMap(map)); } catch {}
|
||||||
|
const initialTimer = setTimeout(() => {
|
||||||
|
try { setViewParams(getViewFromMap(map)); } catch {}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
const reOcclude = () => {
|
const reOcclude = () => {
|
||||||
const el = expandedElRef.current;
|
const el = expandedElRef.current;
|
||||||
|
|
@ -135,6 +138,7 @@ export function useMapCore(theme) {
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
clearTimeout(initialTimer);
|
||||||
clearAllMarkers(markersRef, expandedElRef);
|
clearAllMarkers(markersRef, expandedElRef);
|
||||||
map.remove();
|
map.remove();
|
||||||
mapRef.current = null;
|
mapRef.current = null;
|
||||||
|
|
@ -148,4 +152,4 @@ export function useMapCore(theme) {
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
return { containerRef, mapRef, markersRef, expandedElRef, viewParams, hasLastView };
|
return { containerRef, mapRef, markersRef, expandedElRef, viewParams, hasLastView };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
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 { categoryCode, matchesSubFilter, matchesTimeFilter } from "./mapFilter";
|
||||||
import { haversineKm } from "./mapGeo";
|
import { getCoords, getViewFromMap, haversineKm } from "./mapGeo";
|
||||||
import { createMarkerForPost, createSimpleMarkerForPost } from "./markerManager";
|
import { createMarkerForPost, createSimpleMarkerForPost } from "./markerManager";
|
||||||
import { prioritizePosts } from "./postPriority";
|
import { prioritizePosts } from "./postPriority";
|
||||||
|
|
||||||
|
|
@ -9,6 +9,19 @@ function getId(p) {
|
||||||
return p?.id ?? p?._id ?? null;
|
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) {
|
function sameIdList(a, b) {
|
||||||
if (a === b) return true;
|
if (a === b) return true;
|
||||||
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
||||||
|
|
@ -54,6 +67,20 @@ function matchesSearch(p, qRaw) {
|
||||||
return true;
|
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({
|
export function usePostsEngine({
|
||||||
mapRef,
|
mapRef,
|
||||||
viewParams,
|
viewParams,
|
||||||
|
|
@ -63,11 +90,31 @@ export function usePostsEngine({
|
||||||
searchQuery,
|
searchQuery,
|
||||||
markersRef,
|
markersRef,
|
||||||
expandedElRef,
|
expandedElRef,
|
||||||
|
onAutoWidenTimeFilter,
|
||||||
theme = "blue",
|
theme = "blue",
|
||||||
}) {
|
}) {
|
||||||
const allPostsRef = useRef([]);
|
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 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 [status, setStatus] = useState("");
|
||||||
const [visiblePosts, setVisiblePosts] = useState([]);
|
const [visiblePosts, setVisiblePosts] = useState([]);
|
||||||
|
|
@ -195,12 +242,14 @@ export function usePostsEngine({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
if (!viewParams.center) return;
|
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const mapObj = mapRef.current;
|
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 expanded overlay is open: delay fetch (do NOT disturb UI)
|
||||||
if (expandedElRef?.current) {
|
if (expandedElRef?.current) {
|
||||||
|
|
@ -220,20 +269,25 @@ export function usePostsEngine({
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const [lng, lat] = viewParams.center;
|
const [lng, lat] = resolvedView.center;
|
||||||
const radiusKm = viewParams.radiusKm || 750;
|
const radiusKm = resolvedView.radiusKm || 750;
|
||||||
const filterKey = `${mainFilter}|${subFilter}`;
|
const filterKey = `${mainFilter}|${subFilter}`;
|
||||||
|
|
||||||
const last = lastFetchRef.current;
|
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 [lastLng, lastLat] = last.center;
|
||||||
const distKm = haversineKm(lastLat, lastLng, lat, lng);
|
const distKm = haversineKm(lastLat, lastLng, lat, lng);
|
||||||
|
|
||||||
const lastR = last.radiusKm || 0;
|
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;
|
if (!radiusChanged && distKm < minMoveKm) return;
|
||||||
}
|
}
|
||||||
|
|
@ -247,35 +301,87 @@ export function usePostsEngine({
|
||||||
subFilter && subFilter.toUpperCase() !== "ALL" && subFilter !== "All" ? subFilter : "";
|
subFilter && subFilter.toUpperCase() !== "ALL" && subFilter !== "All" ? subFilter : "";
|
||||||
|
|
||||||
// Use smart feed for personalized, trending posts
|
// 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,
|
lat,
|
||||||
lon: lng,
|
lon: lng,
|
||||||
radius_km: radiusKm,
|
radius_km: radiusKm,
|
||||||
limit: 80,
|
limit: 80,
|
||||||
|
bounds,
|
||||||
// TODO: Add user_id from auth context when available
|
// 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;
|
if (cancelled) return;
|
||||||
|
|
||||||
const existing = allPostsRef.current || [];
|
const existing = allPostsRef.current || [];
|
||||||
const byId = new Map();
|
const byId = new Map();
|
||||||
for (const p of existing) {
|
for (const p of existing) {
|
||||||
const id = getId(p);
|
const key = getPostKey(p);
|
||||||
if (id != null) byId.set(id, p);
|
if (key) byId.set(key, p);
|
||||||
}
|
}
|
||||||
if (Array.isArray(newPosts)) {
|
if (Array.isArray(newPosts)) {
|
||||||
for (const p of newPosts) {
|
for (const p of newPosts) {
|
||||||
const id = getId(p);
|
const key = getPostKey(p);
|
||||||
if (id != null && !byId.has(id)) byId.set(id, p);
|
if (key && !byId.has(key)) byId.set(key, p);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
allPostsRef.current = Array.from(byId.values());
|
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
|
// 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);
|
setLoadingPosts(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -291,10 +397,15 @@ export function usePostsEngine({
|
||||||
delayedFetchRef.current = null;
|
delayedFetchRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [viewParams, mainFilter, subFilter, mapRef, timeFilter, expandedElRef, refreshVisibleAndMarkers]);
|
}, [viewParams, mapReady, mainFilter, subFilter, mapRef, timeFilter, expandedElRef, refreshVisibleAndMarkers, computeVisible, onAutoWidenTimeFilter, syncMarkers]);
|
||||||
|
|
||||||
const handleIncomingPost = useCallback(
|
const handleIncomingPost = useCallback(
|
||||||
(p) => {
|
(p) => {
|
||||||
|
const key = getPostKey(p);
|
||||||
|
if (key) {
|
||||||
|
const exists = (allPostsRef.current || []).some((x) => getPostKey(x) === key);
|
||||||
|
if (exists) return;
|
||||||
|
}
|
||||||
allPostsRef.current = [...allPostsRef.current, p];
|
allPostsRef.current = [...allPostsRef.current, p];
|
||||||
|
|
||||||
const catCode = categoryCode(mainFilter);
|
const catCode = categoryCode(mainFilter);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -9,13 +9,13 @@ function defaultZoomForKind(kind) {
|
||||||
if (k === "post") return 16; // Close zoom for posts
|
if (k === "post") return 16; // Close zoom for posts
|
||||||
if (k === "profile") return 14; // Medium zoom for profiles
|
if (k === "profile") return 14; // Medium zoom for profiles
|
||||||
if (k === "city") return 11; // City-level zoom
|
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
|
if (k === "place" || k === "poi") return 13; // Places zoom
|
||||||
|
|
||||||
// Legacy/fallback
|
// Legacy/fallback
|
||||||
if (k.includes("post")) return 15;
|
if (k.includes("post")) return 15;
|
||||||
if (k.includes("city")) return 11;
|
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;
|
if (k.includes("place")) return 13;
|
||||||
|
|
||||||
return 12; // Default
|
return 12; // Default
|
||||||
|
|
@ -32,22 +32,26 @@ function IconPin(props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function IconWaves(props) {
|
function IconPlus(props) {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" {...props}>
|
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" {...props}>
|
||||||
<path
|
<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"
|
d="M12 5v14m-7-7h14"
|
||||||
fill="currentColor"
|
fill="none"
|
||||||
/>
|
stroke="currentColor"
|
||||||
<path
|
strokeWidth="2.5"
|
||||||
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"
|
strokeLinecap="round"
|
||||||
fill="currentColor"
|
|
||||||
opacity="0.75"
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
</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:
|
* Props:
|
||||||
* - placeholder
|
* - placeholder
|
||||||
|
|
@ -72,6 +76,7 @@ export default function SmartSearchBar({
|
||||||
const [hasSearched, setHasSearched] = useState(false);
|
const [hasSearched, setHasSearched] = useState(false);
|
||||||
|
|
||||||
const abortRef = useRef(null);
|
const abortRef = useRef(null);
|
||||||
|
const skipSearchRef = useRef(false);
|
||||||
const boxRef = useRef(null);
|
const boxRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -113,6 +118,11 @@ export default function SmartSearchBar({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const query = q.trim();
|
const query = q.trim();
|
||||||
|
|
||||||
|
if (skipSearchRef.current) {
|
||||||
|
skipSearchRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (abortRef.current) abortRef.current.abort();
|
if (abortRef.current) abortRef.current.abort();
|
||||||
|
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
|
|
@ -130,6 +140,7 @@ export default function SmartSearchBar({
|
||||||
const pick = (it, reason = "click") => {
|
const pick = (it, reason = "click") => {
|
||||||
if (!it) return;
|
if (!it) return;
|
||||||
const zoom = defaultZoomForKind(it.kind);
|
const zoom = defaultZoomForKind(it.kind);
|
||||||
|
skipSearchRef.current = true;
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setQ(it.title || q);
|
setQ(it.title || q);
|
||||||
|
|
||||||
|
|
@ -152,6 +163,7 @@ export default function SmartSearchBar({
|
||||||
pick(items[active], "enter");
|
pick(items[active], "enter");
|
||||||
} else if (q.trim()) {
|
} else if (q.trim()) {
|
||||||
// No item selected, trigger search/filter
|
// No item selected, trigger search/filter
|
||||||
|
skipSearchRef.current = true;
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
onSearch && onSearch(q.trim());
|
onSearch && onSearch(q.trim());
|
||||||
}
|
}
|
||||||
|
|
@ -178,7 +190,14 @@ export default function SmartSearchBar({
|
||||||
<input
|
<input
|
||||||
className="sw-search__input"
|
className="sw-search__input"
|
||||||
value={q}
|
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={() => {
|
onFocus={() => {
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
// Si pas encore cherché et query vide, charger suggestions populaires
|
// Si pas encore cherché et query vide, charger suggestions populaires
|
||||||
|
|
@ -212,7 +231,7 @@ export default function SmartSearchBar({
|
||||||
title={rightTitle}
|
title={rightTitle}
|
||||||
onClick={() => onPlaceTourWire && onPlaceTourWire()}
|
onClick={() => onPlaceTourWire && onPlaceTourWire()}
|
||||||
>
|
>
|
||||||
<IconWaves />
|
<IconPlus />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
@ -239,10 +258,9 @@ export default function SmartSearchBar({
|
||||||
onMouseEnter={() => setActive(idx)}
|
onMouseEnter={() => setActive(idx)}
|
||||||
onClick={() => pick(it, "click")}
|
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">
|
<div className="sw-search__sub">
|
||||||
{(it.kind ? it.kind : "result")}
|
{truncateText((it.kind ? it.kind : "result") + (it.subtitle ? " • " + it.subtitle : ""), 120)}
|
||||||
{it.subtitle ? " • " + it.subtitle : ""}
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,33 @@ function markAsInstalled() {
|
||||||
|
|
||||||
let deferredPrompt = null;
|
let deferredPrompt = null;
|
||||||
let promptShown = false;
|
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
|
// Écoute l'événement d'installation
|
||||||
window.addEventListener('beforeinstallprompt', (e) => {
|
window.addEventListener('beforeinstallprompt', (e) => {
|
||||||
|
|
@ -63,15 +90,8 @@ window.addEventListener('beforeinstallprompt', (e) => {
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
deferredPrompt = e;
|
deferredPrompt = e;
|
||||||
console.log('PWA install prompt ready');
|
console.log('PWA install prompt ready - waiting for manual click');
|
||||||
|
// Pas de prompt automatique - l'utilisateur cliquera sur le bouton s'il veut installer
|
||||||
// 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
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Écoute l'événement quand l'app est installée
|
// É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
|
* Fonction exportée pour déclencher manuellement l'installation
|
||||||
|
* IMPORTANT: Le clic manuel ignore promptShown pour permettre de réessayer
|
||||||
*/
|
*/
|
||||||
export function promptInstall() {
|
export function promptInstall() {
|
||||||
if (isRunningAsPWA()) {
|
if (isRunningAsPWA()) {
|
||||||
console.log('Already running as PWA, no need to install');
|
console.log('Already running as PWA, no need to install');
|
||||||
|
alert('App is already installed!');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deferredPrompt && !promptShown) {
|
if (isIOS() && isSafari()) {
|
||||||
showInstallPrompt();
|
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;
|
return true;
|
||||||
} else {
|
} 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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -123,5 +165,7 @@ export function promptInstall() {
|
||||||
* Vérifie si le prompt d'installation est disponible
|
* Vérifie si le prompt d'installation est disponible
|
||||||
*/
|
*/
|
||||||
export function canInstall() {
|
export function canInstall() {
|
||||||
return !isRunningAsPWA() && !isAlreadyInstalled() && deferredPrompt !== null;
|
if (isRunningAsPWA() || isAlreadyInstalled()) return false;
|
||||||
}
|
if (isIOS() && isSafari()) return true;
|
||||||
|
return deferredPrompt !== null;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,11 @@
|
||||||
background: rgba(15, 23, 42, 0.86);
|
background: rgba(15, 23, 42, 0.86);
|
||||||
border: 1.5px solid rgba(148, 163, 184, 0.7);
|
border: 1.5px solid rgba(148, 163, 184, 0.7);
|
||||||
display:flex; align-items:center; justify-content:center;
|
display:flex; align-items:center; justify-content:center;
|
||||||
|
color:#e5e7eb;
|
||||||
box-shadow:0 3px 10px rgba(0,0,0,.75);
|
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;
|
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-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: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); }
|
.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);
|
background: rgba(255,255,255,0.92);
|
||||||
border-color: rgba(148,163,184,0.85);
|
border-color: rgba(148,163,184,0.85);
|
||||||
box-shadow: 0 3px 10px rgba(0,0,0,.10);
|
box-shadow: 0 3px 10px rgba(0,0,0,.10);
|
||||||
|
color:#0B1220;
|
||||||
}
|
}
|
||||||
body[data-theme="light"] .sw-icon-label{
|
body[data-theme="light"] .sw-icon-label{
|
||||||
color:#0B1220;
|
color:#0B1220;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
.topbar{
|
.topbar{
|
||||||
position: sticky; top:0; z-index:100;
|
position: sticky; top:0; z-index:100;
|
||||||
width:100%;
|
width:100%;
|
||||||
padding: .55rem .9rem;
|
padding: .55rem .4rem .55rem 1.2rem;
|
||||||
display:flex; align-items:center; justify-content:space-between; gap:.6rem;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
background: linear-gradient(135deg,#071225 0%,#0b2b5f 38%,#0e65c0 72%,#27a8ff 100%);
|
background: linear-gradient(135deg,#071225 0%,#0b2b5f 38%,#0e65c0 72%,#27a8ff 100%);
|
||||||
border-bottom-left-radius:18px; border-bottom-right-radius:18px;
|
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);
|
box-shadow: 0 10px 24px rgba(0,0,0,.55), 0 0 18px rgba(56,189,248,.45);
|
||||||
overflow:hidden;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-circle{
|
.logo-circle{
|
||||||
|
|
@ -21,7 +24,24 @@
|
||||||
.main-title{ font-size:1.02rem; font-weight:800; letter-spacing:.04em; color:#f9fafb; }
|
.main-title{ font-size:1.02rem; font-weight:800; letter-spacing:.04em; color:#f9fafb; }
|
||||||
.sub-title{ font-size:.74rem; color:#dbeafe; opacity:.95; }
|
.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-switch{ display:flex; gap:.28rem; }
|
||||||
.theme-dot{
|
.theme-dot{
|
||||||
|
|
@ -53,11 +73,195 @@
|
||||||
}
|
}
|
||||||
.btn-secondary:hover{ background:rgba(15,23,42,.45); }
|
.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){
|
@media (max-width:640px){
|
||||||
.topbar{ padding:.5rem .7rem; border-bottom-left-radius:16px; border-bottom-right-radius:16px; }
|
.topbar{
|
||||||
.main-title{ font-size:1rem; }
|
padding: .5rem .4rem .5rem .7rem;
|
||||||
.sub-title{ font-size:.7rem; }
|
border-bottom-left-radius: 16px;
|
||||||
.topbar-right{ width:100%; justify-content:flex-start; gap:.45rem; }
|
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 */
|
/* SW_AUTH_UI:BEGIN */
|
||||||
|
|
@ -264,48 +468,52 @@ body[data-theme="light"] .user-avatar{
|
||||||
|
|
||||||
/* User chip used by TopBar.jsx (missing styles before) */
|
/* User chip used by TopBar.jsx (missing styles before) */
|
||||||
.sw-userchip{
|
.sw-userchip{
|
||||||
display:flex;
|
display: inline-flex;
|
||||||
align-items:center;
|
align-items: center;
|
||||||
gap:.5rem;
|
gap: 0.3rem;
|
||||||
padding:.28rem .55rem;
|
padding: 0.2rem 0.4rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(2,6,23,.22);
|
background: rgba(2,6,23,.22);
|
||||||
border: 1px solid rgba(148,163,184,.45);
|
border: 1px solid rgba(148,163,184,.45);
|
||||||
box-shadow: 0 4px 14px rgba(0,0,0,.25);
|
box-shadow: 0 4px 14px rgba(0,0,0,.25);
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sw-avatar{
|
.sw-avatar{
|
||||||
width: 30px;
|
width: 26px;
|
||||||
height: 30px;
|
height: 26px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
display:grid;
|
display: grid;
|
||||||
place-items:center;
|
place-items: center;
|
||||||
background: rgba(56,189,248,0.18);
|
background: rgba(56,189,248,0.18);
|
||||||
border: 1px solid rgba(56,189,248,0.35);
|
border: 1px solid rgba(56,189,248,0.35);
|
||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
font-weight: 950;
|
font-weight: 950;
|
||||||
overflow:hidden;
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.sw-avatar i{ font-size: 14px; }
|
.sw-avatar i{ font-size: 12px; }
|
||||||
|
|
||||||
.sw-usertext{
|
.sw-usertext{
|
||||||
display:flex;
|
display: flex;
|
||||||
flex-direction:column;
|
flex-direction: column;
|
||||||
line-height: 1.05;
|
line-height: 1.05;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
max-width: max-content;
|
||||||
}
|
}
|
||||||
.sw-userline{
|
.sw-userline{
|
||||||
font-size: .72rem;
|
font-size: 0.68rem;
|
||||||
font-weight: 950;
|
font-weight: 950;
|
||||||
color: #f9fafb;
|
color: #f9fafb;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.sw-userhandle{
|
.sw-userhandle{
|
||||||
font-size: .68rem;
|
font-size: 0.64rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #dbeafe;
|
color: #dbeafe;
|
||||||
opacity: .92;
|
opacity: .92;
|
||||||
max-width: 160px;
|
max-width: max-content;
|
||||||
overflow:hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
@ -474,17 +682,16 @@ body[data-theme="light"] .sw-avatar{
|
||||||
flex-wrap: nowrap !important;
|
flex-wrap: nowrap !important;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 52vw;
|
max-width: max-content;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Make user handle truncate instead of forcing width */
|
/* Make user handle and chip take only needed space */
|
||||||
.sw-userhandle{ max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.sw-userchip{ min-width: 0; max-width: max-content; width: max-content; }
|
||||||
.sw-userchip{ min-width: 0; }
|
|
||||||
|
|
||||||
@media (max-width: 640px){
|
@media (max-width: 640px){
|
||||||
.main-title, .sub-title{ max-width: 62vw; }
|
.main-title, .sub-title{ max-width: 62vw; }
|
||||||
.topbar-right{ max-width: 45vw; }
|
.topbar-right{ max-width: 100%; }
|
||||||
.brand-under{ padding-left: 0; }
|
.brand-under{ padding-left: 0; }
|
||||||
}
|
}
|
||||||
/* SW_TOPBAR_ONE_LINE_FIX:END */
|
/* SW_TOPBAR_ONE_LINE_FIX:END */
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue