${escapeHtml(cat)}
- ${metaLeft ? `${escapeHtml(metaLeft)}` : ""}
+ ${metaLeft ? `${escapeHtml(metaLeft)}` : ""}
${metaRight ? `• ${escapeHtml(metaRight)}` : ""}
• ${escapeHtml(relTime)}
@@ -319,7 +319,7 @@ function calculateSmartOffset(post, markersRef, map) {
if (createdAt) {
const ageSeconds = (Date.now() - new Date(createdAt).getTime()) / 1000;
if (ageSeconds < 10) {
- return { x: 0, y: -80 }; // Offset vers le haut (négatif = monte)
+ return { x: 0, y: -110 }; // Offset vers le haut (négatif = monte)
}
}
@@ -379,7 +379,9 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
const lngLat = [lon, lat];
// Calculate smart offset for nearby markers
- const offset = calculateSmartOffset(post, markersRef, map);
+ // DISABLED: Using geographic coordinate variation instead of pixel offset
+ // const offset = calculateSmartOffset(post, markersRef, map);
+ const offset = { x: 0, y: 0 };
const root = document.createElement("div");
root.classList.add("sw-appear");
@@ -400,13 +402,9 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
root.style.zIndex = "1";
// Add connecting line if offset is significant
- const hasOffset = Math.abs(offset.x) > 5 || Math.abs(offset.y) > 5;
- const lineHTML = hasOffset
- ? `
`
- : '';
+ // DISABLED: No longer using pixel offsets
+ const hasOffset = false;
+ const lineHTML = '';
root.innerHTML = `
@@ -608,3 +606,130 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
markersRef.current.push({ id: post.id, marker, el: root, post, type: "simple" });
}
+
+/**
+ * ISLAND MARKER: Creates a circular avatar marker for user islands
+ * Islands are displayed with avatar, username, and 🏝️ badge
+ */
+export function createIslandMarker(island, mapRef, markersRef, onIslandClick, theme = "blue") {
+ const map = mapRef.current;
+ if (!map) return;
+
+ const lat = island.virtual_y; // Virtual coords
+ const lon = island.virtual_x;
+
+ if (lat == null || lon == null) {
+ console.warn("[createIslandMarker] Missing virtual coordinates", island);
+ return;
+ }
+
+ const root = document.createElement("div");
+ root.classList.add("island-marker", "sw-appear");
+
+ // Fade-in animation
+ requestAnimationFrame(() => root.classList.add("sw-appear-in"));
+
+ // Marker circulaire avec avatar
+ root.innerHTML = `
+
+
+ ${island.avatar_url
+ ? `

`
+ : `
${island.username[0].toUpperCase()}
`
+ }
+
+
🏝️ ISLAND
+
@${island.username}
+
+
+ `;
+
+ // Hover effect
+ const avatar = root.querySelector(".island-avatar");
+ root.addEventListener("mouseenter", () => {
+ if (avatar) {
+ avatar.style.transform = "scale(1.1)";
+ avatar.style.boxShadow = "0 6px 20px rgba(102, 126, 234, 0.6)";
+ }
+ });
+
+ root.addEventListener("mouseleave", () => {
+ if (avatar) {
+ avatar.style.transform = "scale(1)";
+ avatar.style.boxShadow = "0 4px 12px rgba(0,0,0,0.4)";
+ }
+ });
+
+ // Click handler
+ root.addEventListener("click", (ev) => {
+ ev.stopPropagation();
+ console.log("[Island Marker] Clicked:", island.username, island.id);
+ if (onIslandClick) {
+ onIslandClick(island);
+ }
+ });
+
+ const marker = new maplibregl.Marker({
+ element: root,
+ anchor: "bottom",
+ })
+ .setLngLat([lon, lat])
+ .addTo(map);
+
+ markersRef.current.push({
+ id: island.id,
+ marker,
+ el: root,
+ island,
+ type: "island"
+ });
+
+ console.log("[Island Marker] Created for", island.username, "at", [lon, lat]);
+}
diff --git a/src/components/Map/templateSpecs.js b/src/components/Map/templateSpecs.js
index e8ef9fd..38bf3c7 100644
--- a/src/components/Map/templateSpecs.js
+++ b/src/components/Map/templateSpecs.js
@@ -172,15 +172,16 @@ function parseDateAny(raw) {
}
function isBreaking(post) {
+ // Only show as BREAKING if explicitly tagged
if (post?.breaking === true || post?.is_breaking === true) return true;
+ // Or if title contains breaking/urgent/alerte keywords
const t = (post?.title || "").toString().toLowerCase();
if (t.includes("breaking") || t.includes("urgent") || t.includes("alerte")) return true;
- const d = parseDateAny(post?.created_at || post?.CreatedAt || post?.createdAt);
- if (!d) return false;
- const mins = (Date.now() - d.getTime()) / 60000;
- return mins >= 0 && mins <= 45;
+ // Removed: automatic breaking news for recent posts
+ // Now only explicit tags or keywords trigger breaking news style
+ return false;
}
function normCat(post) {
diff --git a/src/components/Map/useMapCore.js b/src/components/Map/useMapCore.js
index 081e837..e872ab7 100644
--- a/src/components/Map/useMapCore.js
+++ b/src/components/Map/useMapCore.js
@@ -98,6 +98,9 @@ export function useMapCore(theme) {
mapRef.current = map;
try { setViewParams(getViewFromMap(map)); } catch {}
+ const initialTimer = setTimeout(() => {
+ try { setViewParams(getViewFromMap(map)); } catch {}
+ }, 300);
const reOcclude = () => {
const el = expandedElRef.current;
@@ -135,6 +138,7 @@ export function useMapCore(theme) {
});
return () => {
+ clearTimeout(initialTimer);
clearAllMarkers(markersRef, expandedElRef);
map.remove();
mapRef.current = null;
@@ -148,4 +152,4 @@ export function useMapCore(theme) {
}, [theme]);
return { containerRef, mapRef, markersRef, expandedElRef, viewParams, hasLastView };
-}
\ No newline at end of file
+}
diff --git a/src/components/Map/usePostsEngine.js b/src/components/Map/usePostsEngine.js
index 47e34f1..8ec2fe2 100644
--- a/src/components/Map/usePostsEngine.js
+++ b/src/components/Map/usePostsEngine.js
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
-import { fetchSmartFeed } from "../../api/client";
+import { fetchPosts, fetchSmartFeed } from "../../api/client";
import { categoryCode, matchesSubFilter, matchesTimeFilter } from "./mapFilter";
-import { haversineKm } from "./mapGeo";
+import { getCoords, getViewFromMap, haversineKm } from "./mapGeo";
import { createMarkerForPost, createSimpleMarkerForPost } from "./markerManager";
import { prioritizePosts } from "./postPriority";
@@ -9,6 +9,19 @@ function getId(p) {
return p?.id ?? p?._id ?? null;
}
+function getPostKey(p) {
+ const id = getId(p);
+ if (id != null && id !== 0) return `id:${id}`;
+ const urlHash = (p?.url_hash || p?.urlHash || "").toString().trim();
+ if (urlHash) return `urlhash:${urlHash}`;
+ const url = (p?.url || "").toString().trim();
+ if (url) return `url:${url}`;
+ const title = (p?.title || p?.Title || "").toString().trim();
+ const created = (p?.created_at || p?.CreatedAt || p?.createdAt || "").toString().trim();
+ if (title || created) return `t:${title}|${created}`;
+ return `fallback:${JSON.stringify(p)}`;
+}
+
function sameIdList(a, b) {
if (a === b) return true;
if (!Array.isArray(a) || !Array.isArray(b)) return false;
@@ -54,6 +67,20 @@ function matchesSearch(p, qRaw) {
return true;
}
+function hasPostsInRadius(posts, centerLat, centerLon, radiusKm) {
+ if (!Array.isArray(posts) || posts.length === 0) return false;
+ if (!Number.isFinite(centerLat) || !Number.isFinite(centerLon)) return true;
+ const limit = Math.max(radiusKm || 0, 1);
+ for (const p of posts) {
+ const coords = getCoords(p);
+ if (!coords) continue;
+ const [lon, lat] = coords;
+ const dKm = haversineKm(centerLat, centerLon, lat, lon);
+ if (dKm <= limit) return true;
+ }
+ return false;
+}
+
export function usePostsEngine({
mapRef,
viewParams,
@@ -63,11 +90,31 @@ export function usePostsEngine({
searchQuery,
markersRef,
expandedElRef,
+ onAutoWidenTimeFilter,
theme = "blue",
}) {
const allPostsRef = useRef([]);
- const lastFetchRef = useRef({ center: null, radiusKm: null, filterKey: "" });
+ const lastFetchRef = useRef({ center: null, radiusKm: null, filterKey: "", timeFilter: "" });
const delayedFetchRef = useRef(null);
+ const autoWidenRef = useRef(false);
+ const [mapReady, setMapReady] = useState(false);
+
+ useEffect(() => {
+ if (mapReady) return;
+ let cancelled = false;
+ const timer = setInterval(() => {
+ if (cancelled) return;
+ if (mapRef.current) {
+ mapRef.current.__swForceFetchAt = Date.now();
+ setMapReady(true);
+ clearInterval(timer);
+ }
+ }, 150);
+ return () => {
+ cancelled = true;
+ clearInterval(timer);
+ };
+ }, [mapReady, mapRef]);
const [status, setStatus] = useState("");
const [visiblePosts, setVisiblePosts] = useState([]);
@@ -195,12 +242,14 @@ export function usePostsEngine({
useEffect(() => {
const map = mapRef.current;
if (!map) return;
- if (!viewParams.center) return;
let cancelled = false;
async function load() {
const mapObj = mapRef.current;
+ const resolvedView =
+ viewParams && viewParams.center ? viewParams : getViewFromMap(mapObj);
+ if (!resolvedView?.center) return;
// If expanded overlay is open: delay fetch (do NOT disturb UI)
if (expandedElRef?.current) {
@@ -220,20 +269,25 @@ export function usePostsEngine({
}
} catch {}
- const [lng, lat] = viewParams.center;
- const radiusKm = viewParams.radiusKm || 750;
+ const [lng, lat] = resolvedView.center;
+ const radiusKm = resolvedView.radiusKm || 750;
const filterKey = `${mainFilter}|${subFilter}`;
const last = lastFetchRef.current;
+ const forceFetchAt = mapObj?.__swForceFetchAt || 0;
+ const forceFetch = forceFetchAt && Date.now() - forceFetchAt < 15000;
+ if (forceFetch) {
+ mapObj.__swForceFetchAt = 0;
+ }
- if (last.center && last.filterKey === filterKey) {
+ if (!forceFetch && last.center && last.filterKey === filterKey && last.timeFilter === timeFilter) {
const [lastLng, lastLat] = last.center;
const distKm = haversineKm(lastLat, lastLng, lat, lng);
const lastR = last.radiusKm || 0;
- const radiusChanged = !lastR || Math.abs(radiusKm - lastR) / Math.max(lastR, 1) >= 0.25;
+ const radiusChanged = !lastR || Math.abs(radiusKm - lastR) / Math.max(lastR, 1) >= 0.1;
- const minMoveKm = Math.max(40, radiusKm * 0.22);
+ const minMoveKm = Math.max(5, radiusKm * 0.1);
if (!radiusChanged && distKm < minMoveKm) return;
}
@@ -247,35 +301,87 @@ export function usePostsEngine({
subFilter && subFilter.toUpperCase() !== "ALL" && subFilter !== "All" ? subFilter : "";
// Use smart feed for personalized, trending posts
- const newPosts = await fetchSmartFeed({
+ let bounds = null;
+ try {
+ const b = mapObj.getBounds();
+ if (b) {
+ const sw = b.getSouthWest();
+ const ne = b.getNorthEast();
+ bounds = {
+ minLat: sw?.lat,
+ minLon: sw?.lng,
+ maxLat: ne?.lat,
+ maxLon: ne?.lng,
+ };
+ }
+ } catch {}
+
+ let newPosts = await fetchSmartFeed({
lat,
lon: lng,
radius_km: radiusKm,
limit: 80,
+ bounds,
// TODO: Add user_id from auth context when available
- // TODO: Add bounds parameter for screen-aware prioritization
});
+ const timeParam = timeFilter && timeFilter !== "PAST" ? timeFilter : undefined;
+ if (!hasPostsInRadius(newPosts, lat, lng, radiusKm)) {
+ newPosts = await fetchPosts({
+ category: catCode || undefined,
+ subCategory: subCatParam || undefined,
+ time: timeParam,
+ lat,
+ lon: lng,
+ radiusKm,
+ });
+ if ((!Array.isArray(newPosts) || newPosts.length === 0) && timeFilter !== "PAST") {
+ newPosts = await fetchPosts({
+ category: catCode || undefined,
+ subCategory: subCatParam || undefined,
+ time: "PAST",
+ lat,
+ lon: lng,
+ radiusKm,
+ });
+ }
+ }
if (cancelled) return;
const existing = allPostsRef.current || [];
const byId = new Map();
for (const p of existing) {
- const id = getId(p);
- if (id != null) byId.set(id, p);
+ const key = getPostKey(p);
+ if (key) byId.set(key, p);
}
if (Array.isArray(newPosts)) {
for (const p of newPosts) {
- const id = getId(p);
- if (id != null && !byId.has(id)) byId.set(id, p);
+ const key = getPostKey(p);
+ if (key && !byId.has(key)) byId.set(key, p);
}
}
allPostsRef.current = Array.from(byId.values());
- lastFetchRef.current = { center: [...viewParams.center], radiusKm, filterKey };
+ lastFetchRef.current = { center: [...resolvedView.center], radiusKm, filterKey, timeFilter };
// Smooth update: sync markers + wall without clearing everything
- refreshVisibleAndMarkers(timeFilter);
+ const visible = computeVisible(timeFilter);
+ setVisiblePosts((prev) => (sameIdList(prev, visible) ? prev : visible));
+ setStatus("");
+ syncMarkers(visible);
+ if (
+ !autoWidenRef.current &&
+ timeFilter &&
+ timeFilter !== "PAST" &&
+ Array.isArray(newPosts) &&
+ newPosts.length > 0 &&
+ visible.length === 0
+ ) {
+ autoWidenRef.current = true;
+ if (typeof onAutoWidenTimeFilter === "function") {
+ onAutoWidenTimeFilter("PAST");
+ }
+ }
setLoadingPosts(false);
} catch (err) {
@@ -291,10 +397,15 @@ export function usePostsEngine({
delayedFetchRef.current = null;
}
};
- }, [viewParams, mainFilter, subFilter, mapRef, timeFilter, expandedElRef, refreshVisibleAndMarkers]);
+ }, [viewParams, mapReady, mainFilter, subFilter, mapRef, timeFilter, expandedElRef, refreshVisibleAndMarkers, computeVisible, onAutoWidenTimeFilter, syncMarkers]);
const handleIncomingPost = useCallback(
(p) => {
+ const key = getPostKey(p);
+ if (key) {
+ const exists = (allPostsRef.current || []).some((x) => getPostKey(x) === key);
+ if (exists) return;
+ }
allPostsRef.current = [...allPostsRef.current, p];
const catCode = categoryCode(mainFilter);
diff --git a/src/components/Profile/UserProfile.jsx b/src/components/Profile/UserProfile.jsx
new file mode 100644
index 0000000..bbdcd54
--- /dev/null
+++ b/src/components/Profile/UserProfile.jsx
@@ -0,0 +1,118 @@
+import React, { useEffect, useState } from 'react';
+import { fetchIsland } from '../../api/client';
+import '../../styles/profile.css';
+
+/**
+ * UserProfile: Modal showing user info with "Visit Island" button
+ *
+ * Props:
+ * - username: string (required)
+ * - onClose: function
+ * - onVisitIsland: function(island) - called when "Visit Island" is clicked
+ */
+export default function UserProfile({ username, onClose, onVisitIsland }) {
+ const [island, setIsland] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ if (!username) return;
+
+ // Find island by username (mock data uses island-{number})
+ // In real backend, we'd call /api/islands/by-username/{username}
+ const mockIslandMap = {
+ 'alice': 'island-1',
+ 'bob': 'island-2',
+ 'charlie': 'island-3',
+ 'diana': 'island-4',
+ 'eve': 'island-5',
+ };
+
+ const islandId = mockIslandMap[username.toLowerCase()];
+
+ if (islandId) {
+ fetchIsland(islandId)
+ .then(data => {
+ setIsland(data);
+ setLoading(false);
+ })
+ .catch(err => {
+ console.error('[UserProfile] Error fetching island:', err);
+ setLoading(false);
+ });
+ } else {
+ setLoading(false);
+ }
+ }, [username]);
+
+ const handleVisitIsland = () => {
+ if (island && onVisitIsland) {
+ onVisitIsland(island);
+ }
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
+
+
+
+ {island?.avatar_url ? (
+

+ ) : (
+
+ {username[0].toUpperCase()}
+
+ )}
+
+
@{username}
+ {island?.display_name && island.display_name !== `${username}'s Island` && (
+
{island.display_name}
+ )}
+
+
+ {loading ? (
+
Loading profile...
+ ) : island ? (
+ <>
+
+
{island.bio || 'No bio yet.'}
+
+
+
+
+ {island.content_count || 0}
+ Posts
+
+
+ {island.visitors || 0}
+ Visitors
+
+
+ {island.terrain_type || 'tropical'}
+ Terrain
+
+
+
+
+
+
+
+
+ >
+ ) : (
+
+
This user doesn't have an island yet.
+
+ )}
+
+
+ );
+}
diff --git a/src/components/Search/SmartSearchBar.jsx b/src/components/Search/SmartSearchBar.jsx
index 5d2b920..938e72c 100644
--- a/src/components/Search/SmartSearchBar.jsx
+++ b/src/components/Search/SmartSearchBar.jsx
@@ -9,13 +9,13 @@ function defaultZoomForKind(kind) {
if (k === "post") return 16; // Close zoom for posts
if (k === "profile") return 14; // Medium zoom for profiles
if (k === "city") return 11; // City-level zoom
- if (k === "region") return 7; // Wide zoom for regions/provinces
+ if (k === "region") return 5; // Wide zoom for regions/provinces
if (k === "place" || k === "poi") return 13; // Places zoom
// Legacy/fallback
if (k.includes("post")) return 15;
if (k.includes("city")) return 11;
- if (k.includes("region") || k.includes("area") || k.includes("province") || k.includes("state")) return 7;
+ if (k.includes("region") || k.includes("area") || k.includes("province") || k.includes("state")) return 5;
if (k.includes("place")) return 13;
return 12; // Default
@@ -32,22 +32,26 @@ function IconPin(props) {
);
}
-function IconWaves(props) {
+function IconPlus(props) {
return (
);
}
+function truncateText(value, maxLen) {
+ const s = (value || "").toString();
+ if (!maxLen || s.length <= maxLen) return s;
+ return s.slice(0, Math.max(0, maxLen - 1)) + "…";
+}
+
/**
* Props:
* - placeholder
@@ -72,6 +76,7 @@ export default function SmartSearchBar({
const [hasSearched, setHasSearched] = useState(false);
const abortRef = useRef(null);
+ const skipSearchRef = useRef(false);
const boxRef = useRef(null);
useEffect(() => {
@@ -113,6 +118,11 @@ export default function SmartSearchBar({
useEffect(() => {
const query = q.trim();
+ if (skipSearchRef.current) {
+ skipSearchRef.current = false;
+ return;
+ }
+
if (abortRef.current) abortRef.current.abort();
const t = setTimeout(() => {
@@ -130,6 +140,7 @@ export default function SmartSearchBar({
const pick = (it, reason = "click") => {
if (!it) return;
const zoom = defaultZoomForKind(it.kind);
+ skipSearchRef.current = true;
setOpen(false);
setQ(it.title || q);
@@ -152,6 +163,7 @@ export default function SmartSearchBar({
pick(items[active], "enter");
} else if (q.trim()) {
// No item selected, trigger search/filter
+ skipSearchRef.current = true;
setOpen(false);
onSearch && onSearch(q.trim());
}
@@ -178,7 +190,14 @@ export default function SmartSearchBar({
setQ(e.target.value)}
+ onChange={(e) => {
+ const newValue = e.target.value;
+ setQ(newValue);
+ // Si on efface tout le texte, clear les filtres
+ if (!newValue.trim() && q.trim()) {
+ onSearch && onSearch("");
+ }
+ }}
onFocus={() => {
setOpen(true);
// Si pas encore cherché et query vide, charger suggestions populaires
@@ -212,7 +231,7 @@ export default function SmartSearchBar({
title={rightTitle}
onClick={() => onPlaceTourWire && onPlaceTourWire()}
>
-
+
))}
diff --git a/src/pwaInstall.js b/src/pwaInstall.js
index 38f4622..c934048 100644
--- a/src/pwaInstall.js
+++ b/src/pwaInstall.js
@@ -46,6 +46,33 @@ function markAsInstalled() {
let deferredPrompt = null;
let promptShown = false;
+let iosHintShown = false;
+
+function isIOS() {
+ if (typeof navigator === "undefined") return false;
+ const ua = navigator.userAgent || "";
+ const iOSDevice = /iphone|ipad|ipod/i.test(ua);
+ const iPadOS = ua.includes("Mac") && "ontouchend" in document;
+ return iOSDevice || iPadOS;
+}
+
+function isSafari() {
+ if (typeof navigator === "undefined") return false;
+ const ua = navigator.userAgent || "";
+ const isIOSChrome = /crios/i.test(ua);
+ const isIOSFirefox = /fxios/i.test(ua);
+ const isIOSEdge = /edgios/i.test(ua);
+ const isSafariUA = /safari/i.test(ua);
+ return isSafariUA && !isIOSChrome && !isIOSFirefox && !isIOSEdge;
+}
+
+function showIOSInstallHint() {
+ if (iosHintShown) return;
+ iosHintShown = true;
+ alert(
+ "iOS: pour installer l'app, ouvre le menu Partager (icône carré + flèche), puis choisis “Sur l’écran d’accueil”."
+ );
+}
// Écoute l'événement d'installation
window.addEventListener('beforeinstallprompt', (e) => {
@@ -63,15 +90,8 @@ window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
- console.log('PWA install prompt ready');
-
- // Affiche le prompt automatiquement après 10 secondes (une seule fois)
- // Délai augmenté pour ne pas déranger immédiatement
- setTimeout(() => {
- if (!promptShown && deferredPrompt && !isRunningAsPWA()) {
- showInstallPrompt();
- }
- }, 10000); // 10 secondes
+ console.log('PWA install prompt ready - waiting for manual click');
+ // Pas de prompt automatique - l'utilisateur cliquera sur le bouton s'il veut installer
});
// Écoute l'événement quand l'app est installée
@@ -103,18 +123,40 @@ function showInstallPrompt() {
/**
* Fonction exportée pour déclencher manuellement l'installation
+ * IMPORTANT: Le clic manuel ignore promptShown pour permettre de réessayer
*/
export function promptInstall() {
if (isRunningAsPWA()) {
console.log('Already running as PWA, no need to install');
+ alert('App is already installed!');
return false;
}
- if (deferredPrompt && !promptShown) {
- showInstallPrompt();
+ if (isIOS() && isSafari()) {
+ showIOSInstallHint();
+ return true;
+ }
+
+ if (deferredPrompt) {
+ // Clic manuel - force l'affichage même si déjà montré
+ console.log('Showing install prompt (manual trigger)');
+ deferredPrompt.prompt();
+
+ deferredPrompt.userChoice.then((choice) => {
+ console.log('Install outcome:', choice.outcome);
+ if (choice.outcome === 'accepted') {
+ markAsInstalled();
+ }
+ deferredPrompt = null;
+ });
return true;
} else {
- console.log('No install prompt available or already shown');
+ console.log('No install prompt available - browser may not support PWA install or already installed');
+ if (isIOS() && isSafari()) {
+ showIOSInstallHint();
+ return true;
+ }
+ alert('PWA installation not available. Make sure you are using a supported browser (Chrome, Edge, Safari).');
return false;
}
}
@@ -123,5 +165,7 @@ export function promptInstall() {
* Vérifie si le prompt d'installation est disponible
*/
export function canInstall() {
- return !isRunningAsPWA() && !isAlreadyInstalled() && deferredPrompt !== null;
-}
\ No newline at end of file
+ if (isRunningAsPWA() || isAlreadyInstalled()) return false;
+ if (isIOS() && isSafari()) return true;
+ return deferredPrompt !== null;
+}
diff --git a/src/styles/filters.css b/src/styles/filters.css
index a3da26a..82a02cb 100644
--- a/src/styles/filters.css
+++ b/src/styles/filters.css
@@ -5,10 +5,11 @@
background: rgba(15, 23, 42, 0.86);
border: 1.5px solid rgba(148, 163, 184, 0.7);
display:flex; align-items:center; justify-content:center;
+ color:#e5e7eb;
box-shadow:0 3px 10px rgba(0,0,0,.75);
transition: transform 0.36s ease, box-shadow 0.36s ease, border-color 0.36s ease, background 0.36s ease;
}
-.sw-icon-circle i { font-size:22px; }
+.sw-icon-circle i { font-size:22px; color: currentColor; }
.sw-icon-label { font-size:.7rem; line-height:1; text-shadow:0 1px 2px rgba(0,0,0,.9); }
.sw-icon-btn:hover .sw-icon-circle { transform: translateY(-1px); box-shadow:0 5px 14px rgba(0,0,0,.85); }
.sw-icon-btn:active .sw-icon-circle { transform: scale(.96); box-shadow:0 2px 8px rgba(0,0,0,.9); }
@@ -154,6 +155,7 @@ body[data-theme="light"] .sw-icon-circle{
background: rgba(255,255,255,0.92);
border-color: rgba(148,163,184,0.85);
box-shadow: 0 3px 10px rgba(0,0,0,.10);
+ color:#0B1220;
}
body[data-theme="light"] .sw-icon-label{
color:#0B1220;
diff --git a/src/styles/island.css b/src/styles/island.css
new file mode 100644
index 0000000..06129cf
--- /dev/null
+++ b/src/styles/island.css
@@ -0,0 +1,260 @@
+/* ===== ISLAND VIEWER MODAL ===== */
+
+/* Animations for MapLibre-based islands */
+@keyframes islandFloat {
+ 0%, 100% {
+ transform: translateY(0px);
+ }
+ 50% {
+ transform: translateY(-10px);
+ }
+}
+
+@keyframes treeSway {
+ 0%, 100% {
+ transform: rotate(0deg);
+ }
+ 50% {
+ transform: rotate(3deg);
+ }
+}
+
+@keyframes contentFloat {
+ 0%, 100% {
+ transform: translateY(0px);
+ }
+ 50% {
+ transform: translateY(-5px);
+ }
+}
+
+.island-viewer-modal {
+ position: fixed;
+ inset: 0;
+ z-index: 9999;
+ background: linear-gradient(135deg, rgba(0, 20, 40, 0.95) 0%, rgba(0, 10, 30, 0.98) 100%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ backdrop-filter: blur(8px);
+ animation: fadeIn 0.4s ease;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+.island-viewer-container {
+ position: relative;
+ width: 92%;
+ max-width: 1300px;
+ background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #334155 100%);
+ border-radius: 24px;
+ padding: 2.5rem;
+ box-shadow:
+ 0 25px 80px rgba(0, 0, 0, 0.7),
+ 0 0 0 1px rgba(148, 163, 184, 0.1),
+ inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
+ animation: slideUp 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
+ border: 1px solid rgba(56, 189, 248, 0.2);
+}
+
+@keyframes slideUp {
+ from {
+ transform: translateY(20px);
+ opacity: 0;
+ }
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+
+.island-viewer-close {
+ position: absolute;
+ top: 1rem;
+ right: 1rem;
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.2);
+ border: none;
+ color: white;
+ font-size: 24px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+ z-index: 10;
+}
+
+.island-viewer-close:hover {
+ background: rgba(255, 255, 255, 0.3);
+ transform: rotate(90deg);
+}
+
+.island-viewer-close:active {
+ transform: rotate(90deg) scale(0.95);
+}
+
+/* Header */
+
+.island-viewer-header {
+ text-align: center;
+ margin-bottom: 2rem;
+ padding-bottom: 1rem;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.15);
+}
+
+.island-viewer-header h2 {
+ color: #f1f5f9;
+ font-size: 2.25rem;
+ margin-bottom: 0.75rem;
+ font-weight: 800;
+ text-shadow: 0 2px 12px rgba(56, 189, 248, 0.4);
+ letter-spacing: -0.02em;
+}
+
+.island-viewer-header p {
+ color: #cbd5e1;
+ font-size: 1.1rem;
+ font-style: italic;
+ text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
+}
+
+/* Canvas */
+
+.island-viewer-canvas {
+ border-radius: 16px;
+ overflow: hidden;
+ box-shadow:
+ 0 12px 40px rgba(0, 0, 0, 0.5),
+ 0 0 0 1px rgba(56, 189, 248, 0.15),
+ inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
+ display: block;
+ margin: 0 auto;
+ border: 1px solid rgba(148, 163, 184, 0.1);
+}
+
+/* Stats */
+
+.island-viewer-stats {
+ display: flex;
+ gap: 2rem;
+ justify-content: center;
+ margin-top: 1.5rem;
+}
+
+.stat {
+ text-align: center;
+}
+
+.stat-value {
+ display: block;
+ color: white;
+ font-size: 2rem;
+ font-weight: bold;
+ margin-bottom: 0.25rem;
+}
+
+.stat-label {
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 0.9rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+/* Footer */
+
+.island-viewer-footer {
+ text-align: center;
+ margin-top: 1rem;
+}
+
+.island-info {
+ color: rgba(255, 255, 255, 0.6);
+ font-size: 0.85rem;
+ margin: 0;
+}
+
+/* ===== ISLAND MARKERS (on map) ===== */
+
+.island-marker {
+ opacity: 0;
+ transition: opacity 0.4s ease;
+}
+
+.island-marker.sw-appear-in {
+ opacity: 1;
+}
+
+.island-marker-container {
+ pointer-events: auto;
+}
+
+/* Responsive */
+
+@media (max-width: 768px) {
+ .island-viewer-container {
+ width: 95%;
+ padding: 1.5rem;
+ }
+
+ .island-viewer-header h2 {
+ font-size: 1.5rem;
+ }
+
+ .island-viewer-canvas {
+ height: 400px !important;
+ }
+
+ .island-viewer-stats {
+ flex-wrap: wrap;
+ gap: 1rem;
+ }
+
+ .stat-value {
+ font-size: 1.5rem;
+ }
+}
+
+/* Light theme support */
+
+body[data-theme="light"] .island-viewer-container {
+ background: linear-gradient(135deg, #e0e7ff 0%, #ddd6fe 100%);
+}
+
+body[data-theme="light"] .island-viewer-header h2 {
+ color: #1e3a8a;
+}
+
+body[data-theme="light"] .island-viewer-header p {
+ color: rgba(30, 58, 138, 0.8);
+}
+
+body[data-theme="light"] .stat-value {
+ color: #1e3a8a;
+}
+
+body[data-theme="light"] .stat-label {
+ color: rgba(30, 58, 138, 0.7);
+}
+
+body[data-theme="light"] .island-info {
+ color: rgba(30, 58, 138, 0.6);
+}
+
+body[data-theme="light"] .island-viewer-close {
+ background: rgba(30, 58, 138, 0.2);
+ color: #1e3a8a;
+}
+
+body[data-theme="light"] .island-viewer-close:hover {
+ background: rgba(30, 58, 138, 0.3);
+}
diff --git a/src/styles/profile.css b/src/styles/profile.css
new file mode 100644
index 0000000..cbb60cf
--- /dev/null
+++ b/src/styles/profile.css
@@ -0,0 +1,280 @@
+/* ===== USER PROFILE MODAL ===== */
+
+.user-profile-modal {
+ position: fixed;
+ inset: 0;
+ z-index: 9998; /* Just below island viewer */
+ background: rgba(0, 0, 0, 0.8);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ backdrop-filter: blur(3px);
+ animation: fadeIn 0.25s ease;
+}
+
+.user-profile-container {
+ position: relative;
+ width: 90%;
+ max-width: 500px;
+ background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%);
+ border-radius: 16px;
+ padding: 2rem;
+ box-shadow: 0 15px 40px rgba(0, 0, 0, 0.4);
+ animation: slideUp 0.3s ease;
+}
+
+.user-profile-close {
+ position: absolute;
+ top: 0.75rem;
+ right: 0.75rem;
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.15);
+ border: none;
+ color: white;
+ font-size: 20px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+ z-index: 10;
+}
+
+.user-profile-close:hover {
+ background: rgba(255, 255, 255, 0.25);
+ transform: rotate(90deg);
+}
+
+/* Header */
+
+.user-profile-header {
+ text-align: center;
+ margin-bottom: 1.5rem;
+}
+
+.user-profile-avatar {
+ width: 100px;
+ height: 100px;
+ border-radius: 50%;
+ margin: 0 auto 1rem;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ border: 4px solid rgba(255, 255, 255, 0.2);
+ overflow: hidden;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.user-profile-avatar img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.user-profile-avatar-letter {
+ color: white;
+ font-size: 48px;
+ font-weight: bold;
+}
+
+.user-profile-username {
+ color: white;
+ font-size: 1.75rem;
+ margin: 0 0 0.25rem 0;
+ font-weight: bold;
+}
+
+.user-profile-display-name {
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 1rem;
+ margin: 0;
+ font-style: italic;
+}
+
+/* Bio */
+
+.user-profile-bio {
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 10px;
+ padding: 1rem;
+ margin-bottom: 1.5rem;
+}
+
+.user-profile-bio p {
+ color: rgba(255, 255, 255, 0.9);
+ margin: 0;
+ font-size: 0.95rem;
+ line-height: 1.5;
+}
+
+/* Stats */
+
+.user-profile-stats {
+ display: flex;
+ gap: 1.5rem;
+ justify-content: center;
+ margin-bottom: 1.5rem;
+}
+
+.user-profile-stat {
+ text-align: center;
+}
+
+.user-profile-stat-value {
+ display: block;
+ color: white;
+ font-size: 1.5rem;
+ font-weight: bold;
+ margin-bottom: 0.25rem;
+}
+
+.user-profile-stat-label {
+ color: rgba(255, 255, 255, 0.6);
+ font-size: 0.8rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+/* Actions */
+
+.user-profile-actions {
+ display: flex;
+ gap: 0.75rem;
+ justify-content: center;
+ margin-bottom: 1rem;
+}
+
+.user-profile-btn {
+ padding: 0.75rem 1.5rem;
+ border-radius: 999px;
+ border: none;
+ font-size: 1rem;
+ font-weight: bold;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.user-profile-btn-primary {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
+}
+
+.user-profile-btn-primary:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 16px rgba(102, 126, 234, 0.6);
+}
+
+.user-profile-btn-primary:active {
+ transform: translateY(0);
+}
+
+/* Footer */
+
+.user-profile-footer {
+ text-align: center;
+ padding-top: 1rem;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.user-profile-seed {
+ color: rgba(255, 255, 255, 0.5);
+ font-size: 0.8rem;
+ margin: 0;
+}
+
+/* Loading & No Island */
+
+.user-profile-loading,
+.user-profile-no-island {
+ text-align: center;
+ padding: 2rem;
+ color: rgba(255, 255, 255, 0.7);
+}
+
+/* Responsive */
+
+@media (max-width: 600px) {
+ .user-profile-container {
+ width: 95%;
+ padding: 1.5rem;
+ }
+
+ .user-profile-avatar {
+ width: 80px;
+ height: 80px;
+ }
+
+ .user-profile-avatar-letter {
+ font-size: 36px;
+ }
+
+ .user-profile-username {
+ font-size: 1.5rem;
+ }
+
+ .user-profile-stats {
+ gap: 1rem;
+ }
+
+ .user-profile-stat-value {
+ font-size: 1.25rem;
+ }
+}
+
+/* Light theme */
+
+body[data-theme="light"] .user-profile-container {
+ background: linear-gradient(135deg, #e0e7ff 0%, #f5f3ff 100%);
+}
+
+body[data-theme="light"] .user-profile-username {
+ color: #1e3a8a;
+}
+
+body[data-theme="light"] .user-profile-display-name {
+ color: rgba(30, 58, 138, 0.7);
+}
+
+body[data-theme="light"] .user-profile-bio {
+ background: rgba(255, 255, 255, 0.5);
+}
+
+body[data-theme="light"] .user-profile-bio p {
+ color: #1e3a8a;
+}
+
+body[data-theme="light"] .user-profile-stat-value {
+ color: #1e3a8a;
+}
+
+body[data-theme="light"] .user-profile-stat-label {
+ color: rgba(30, 58, 138, 0.6);
+}
+
+body[data-theme="light"] .user-profile-seed {
+ color: rgba(30, 58, 138, 0.5);
+}
+
+body[data-theme="light"] .user-profile-loading,
+body[data-theme="light"] .user-profile-no-island {
+ color: rgba(30, 58, 138, 0.7);
+}
+
+body[data-theme="light"] .user-profile-close {
+ background: rgba(30, 58, 138, 0.15);
+ color: #1e3a8a;
+}
+
+body[data-theme="light"] .user-profile-close:hover {
+ background: rgba(30, 58, 138, 0.25);
+}
+
+body[data-theme="light"] .user-profile-footer {
+ border-top-color: rgba(30, 58, 138, 0.1);
+}
diff --git a/src/styles/topbar.css b/src/styles/topbar.css
index e661632..5237078 100644
--- a/src/styles/topbar.css
+++ b/src/styles/topbar.css
@@ -1,12 +1,15 @@
.topbar{
position: sticky; top:0; z-index:100;
width:100%;
- padding: .55rem .9rem;
- display:flex; align-items:center; justify-content:space-between; gap:.6rem;
+ padding: .55rem .4rem .55rem 1.2rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
background: linear-gradient(135deg,#071225 0%,#0b2b5f 38%,#0e65c0 72%,#27a8ff 100%);
border-bottom-left-radius:18px; border-bottom-right-radius:18px;
box-shadow: 0 10px 24px rgba(0,0,0,.55), 0 0 18px rgba(56,189,248,.45);
- overflow:hidden;
+ overflow: visible;
}
.logo-circle{
@@ -21,7 +24,24 @@
.main-title{ font-size:1.02rem; font-weight:800; letter-spacing:.04em; color:#f9fafb; }
.sub-title{ font-size:.74rem; color:#dbeafe; opacity:.95; }
-.topbar-right{ display:flex; align-items:center; gap:.5rem; margin-left:auto; flex-wrap:wrap; }
+.topbar-right{
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ margin-left: auto;
+ margin-right: 0;
+ padding-right: 0;
+ flex-wrap: nowrap;
+ flex-shrink: 0;
+ width: auto;
+ justify-content: flex-end;
+}
+
+.topbar-user-section{
+ display: inline-flex;
+ align-items: center;
+ flex-shrink: 0;
+}
.theme-switch{ display:flex; gap:.28rem; }
.theme-dot{
@@ -53,11 +73,195 @@
}
.btn-secondary:hover{ background:rgba(15,23,42,.45); }
+.btn-icon{
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ border: 1px solid rgba(148,163,184,.5);
+ background: rgba(15,23,42,.6);
+ color: #38bdf8;
+ font-size: 1rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ cursor: pointer;
+ box-shadow: 0 0 10px rgba(56,189,248,.3);
+ transition: all 0.3s ease;
+}
+.btn-icon:hover{
+ background: rgba(56,189,248,.2);
+ box-shadow: 0 0 16px rgba(56,189,248,.6);
+ transform: scale(1.1);
+ border-color: #38bdf8;
+}
+
+/* New action buttons design */
+.topbar-actions{
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+ flex-shrink: 0;
+ margin: 0;
+ padding: 0;
+}
+
+.btn-action{
+ height: 32px;
+ padding: 0 0.5rem;
+ border-radius: 999px;
+ border: 1px solid rgba(148, 163, 184, 0.3);
+ background: linear-gradient(135deg, rgba(15, 23, 42, 0.8), rgba(30, 41, 59, 0.8));
+ color: #f1f5f9;
+ font-size: 0.8rem;
+ font-weight: 600;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.35rem;
+ cursor: pointer;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ white-space: nowrap;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
+}
+
+.btn-action i{
+ font-size: 0.9rem;
+}
+
+.btn-action .btn-label{
+ font-size: 0.8rem;
+ letter-spacing: 0.01em;
+}
+
+.btn-action:hover{
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(56, 189, 248, 0.4);
+ border-color: rgba(56, 189, 248, 0.6);
+}
+
+.btn-action:active{
+ transform: translateY(0);
+}
+
+.btn-action:disabled{
+ opacity: 0.5;
+ cursor: not-allowed;
+ transform: none;
+}
+
+/* Specific button colors */
+.btn-action-install{
+ background: linear-gradient(135deg, #8b5cf6, #7c3aed);
+ border-color: rgba(139, 92, 246, 0.5);
+ color: white;
+}
+
+.btn-action-install:hover{
+ background: linear-gradient(135deg, #a78bfa, #8b5cf6);
+ box-shadow: 0 4px 16px rgba(139, 92, 246, 0.6);
+ border-color: #a78bfa;
+}
+
+.btn-action-islands{
+ background: linear-gradient(135deg, #06b6d4, #0891b2);
+ border-color: rgba(6, 182, 212, 0.5);
+ color: white;
+}
+
+.btn-action-islands:hover{
+ background: linear-gradient(135deg, #22d3ee, #06b6d4);
+ box-shadow: 0 4px 16px rgba(6, 182, 212, 0.6);
+ border-color: #22d3ee;
+}
+
+.btn-action-auth{
+ background: linear-gradient(135deg, #10b981, #059669);
+ border-color: rgba(16, 185, 129, 0.5);
+ color: white;
+}
+
+.btn-action-auth:hover{
+ background: linear-gradient(135deg, #34d399, #10b981);
+ box-shadow: 0 4px 16px rgba(16, 185, 129, 0.6);
+ border-color: #34d399;
+}
+
+@media (max-width:900px){
+ .topbar-right{
+ gap: 0.6rem;
+ }
+
+ .btn-action .btn-label{
+ display: none;
+ }
+
+ .btn-action{
+ padding: 0 0.5rem;
+ }
+}
+
@media (max-width:640px){
- .topbar{ padding:.5rem .7rem; border-bottom-left-radius:16px; border-bottom-right-radius:16px; }
- .main-title{ font-size:1rem; }
- .sub-title{ font-size:.7rem; }
- .topbar-right{ width:100%; justify-content:flex-start; gap:.45rem; }
+ .topbar{
+ padding: .5rem .4rem .5rem .7rem;
+ border-bottom-left-radius: 16px;
+ border-bottom-right-radius: 16px;
+ flex-wrap: wrap;
+ }
+
+ .main-title{ font-size: 1rem; }
+ .sub-title{ font-size: .7rem; }
+
+ .topbar-right{
+ width: fit-content !important;
+ max-width: fit-content !important;
+ justify-content: flex-end;
+ gap: 0.4rem;
+ margin-left: auto;
+ margin-top: 0.5rem;
+ flex-grow: 0;
+ flex-shrink: 1;
+ }
+
+ .topbar-user-section{
+ order: 1;
+ width: fit-content !important;
+ max-width: fit-content !important;
+ flex-grow: 0;
+ }
+
+ .sw-userchip{
+ width: fit-content !important;
+ max-width: fit-content !important;
+ flex-grow: 0;
+ }
+
+ .sw-usertext{
+ width: fit-content !important;
+ max-width: fit-content !important;
+ flex-grow: 0;
+ }
+
+ .sw-userline, .sw-userhandle{
+ max-width: fit-content !important;
+ }
+
+ .topbar-actions{
+ order: 2;
+ gap: 0.3rem;
+ width: fit-content !important;
+ flex-grow: 0;
+ }
+
+ .btn-action{
+ height: 34px;
+ min-width: 34px;
+ padding: 0 0.5rem;
+ }
+
+ .btn-action i{
+ font-size: 0.9rem;
+ }
}
/* SW_AUTH_UI:BEGIN */
@@ -264,48 +468,52 @@ body[data-theme="light"] .user-avatar{
/* User chip used by TopBar.jsx (missing styles before) */
.sw-userchip{
- display:flex;
- align-items:center;
- gap:.5rem;
- padding:.28rem .55rem;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+ padding: 0.2rem 0.4rem;
border-radius: 999px;
background: rgba(2,6,23,.22);
border: 1px solid rgba(148,163,184,.45);
box-shadow: 0 4px 14px rgba(0,0,0,.25);
+ margin: 0;
}
.sw-avatar{
- width: 30px;
- height: 30px;
+ width: 26px;
+ height: 26px;
border-radius: 999px;
- display:grid;
- place-items:center;
+ display: grid;
+ place-items: center;
background: rgba(56,189,248,0.18);
border: 1px solid rgba(56,189,248,0.35);
color: #e5e7eb;
font-weight: 950;
- overflow:hidden;
+ overflow: hidden;
+ flex-shrink: 0;
}
-.sw-avatar i{ font-size: 14px; }
+.sw-avatar i{ font-size: 12px; }
.sw-usertext{
- display:flex;
- flex-direction:column;
+ display: flex;
+ flex-direction: column;
line-height: 1.05;
min-width: 0;
+ max-width: max-content;
}
.sw-userline{
- font-size: .72rem;
+ font-size: 0.68rem;
font-weight: 950;
color: #f9fafb;
+ white-space: nowrap;
}
.sw-userhandle{
- font-size: .68rem;
+ font-size: 0.64rem;
font-weight: 800;
color: #dbeafe;
opacity: .92;
- max-width: 160px;
- overflow:hidden;
+ max-width: max-content;
+ overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -474,17 +682,16 @@ body[data-theme="light"] .sw-avatar{
flex-wrap: nowrap !important;
white-space: nowrap;
min-width: 0;
- max-width: 52vw;
- overflow: hidden;
+ max-width: max-content;
+ overflow: visible;
}
-/* Make user handle truncate instead of forcing width */
-.sw-userhandle{ max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.sw-userchip{ min-width: 0; }
+/* Make user handle and chip take only needed space */
+.sw-userchip{ min-width: 0; max-width: max-content; width: max-content; }
@media (max-width: 640px){
.main-title, .sub-title{ max-width: 62vw; }
- .topbar-right{ max-width: 45vw; }
+ .topbar-right{ max-width: 100%; }
.brand-under{ padding-left: 0; }
}
/* SW_TOPBAR_ONE_LINE_FIX:END */
diff --git a/src/styles/universe.css b/src/styles/universe.css
new file mode 100644
index 0000000..5324fd3
--- /dev/null
+++ b/src/styles/universe.css
@@ -0,0 +1,316 @@
+/* ===== ISLAND UNIVERSE VIEW ===== */
+
+.island-universe-modal {
+ position: fixed;
+ inset: 0;
+ z-index: 9999;
+ background: linear-gradient(135deg, rgba(0, 0, 0, 0.97) 0%, rgba(10, 14, 39, 0.98) 100%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ backdrop-filter: blur(12px);
+ animation: fadeIn 0.5s ease;
+}
+
+.island-universe-container {
+ position: relative;
+ width: 96%;
+ max-width: 1500px;
+ height: 92vh;
+ background: linear-gradient(135deg, #0a0e27 0%, #0f1624 30%, #1a1f3a 70%, #16213e 100%);
+ border-radius: 24px;
+ padding: 2.5rem;
+ box-shadow:
+ 0 30px 100px rgba(0, 0, 0, 0.9),
+ 0 0 0 1px rgba(56, 189, 248, 0.15),
+ inset 0 1px 0 0 rgba(255, 255, 255, 0.03);
+ animation: slideUp 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
+ display: flex;
+ flex-direction: column;
+ border: 1px solid rgba(148, 163, 184, 0.1);
+}
+
+.island-universe-close {
+ position: absolute;
+ top: 1rem;
+ right: 1rem;
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.1);
+ border: 2px solid rgba(255, 255, 255, 0.2);
+ color: white;
+ font-size: 28px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+ z-index: 10;
+ font-weight: 300;
+ line-height: 1;
+}
+
+.island-universe-close:hover {
+ background: rgba(255, 255, 255, 0.2);
+ transform: rotate(90deg) scale(1.1);
+ border-color: rgba(255, 255, 255, 0.4);
+}
+
+/* Header */
+
+.island-universe-header {
+ text-align: center;
+ margin-bottom: 2rem;
+ padding-bottom: 1.25rem;
+ border-bottom: 1px solid rgba(56, 189, 248, 0.15);
+}
+
+.island-universe-header h1 {
+ color: #f1f5f9;
+ font-size: 3rem;
+ margin: 0 0 0.75rem 0;
+ font-weight: 900;
+ text-shadow: 0 4px 20px rgba(56, 189, 248, 0.5), 0 2px 8px rgba(0, 0, 0, 0.7);
+ letter-spacing: -0.03em;
+ background: linear-gradient(135deg, #38bdf8 0%, #818cf8 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.island-universe-header p {
+ color: #cbd5e1;
+ font-size: 1.15rem;
+ margin: 0;
+ text-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
+ font-weight: 500;
+}
+
+/* Canvas */
+
+.island-universe-canvas {
+ border-radius: 12px;
+ overflow: hidden;
+ box-shadow:
+ 0 10px 40px rgba(0, 0, 0, 0.4),
+ inset 0 0 0 1px rgba(255, 255, 255, 0.05);
+ flex: 1;
+ min-height: 0;
+}
+
+/* Tooltip (appears on hover) */
+
+.island-universe-tooltip {
+ position: absolute;
+ bottom: 120px;
+ left: 50%;
+ transform: translateX(-50%);
+ background: rgba(20, 30, 50, 0.95);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 12px;
+ padding: 1rem 1.5rem;
+ min-width: 250px;
+ text-align: center;
+ box-shadow: 0 8px 25px rgba(0, 0, 0, 0.5);
+ animation: tooltipAppear 0.2s ease;
+ backdrop-filter: blur(10px);
+}
+
+@keyframes tooltipAppear {
+ from {
+ opacity: 0;
+ transform: translateX(-50%) translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0);
+ }
+}
+
+.tooltip-username {
+ color: #60a5fa;
+ font-size: 1.25rem;
+ font-weight: bold;
+ margin-bottom: 0.5rem;
+ text-shadow: 0 0 10px rgba(96, 165, 250, 0.5);
+}
+
+.tooltip-bio {
+ color: rgba(255, 255, 255, 0.9);
+ font-size: 0.95rem;
+ margin-bottom: 0.5rem;
+ line-height: 1.4;
+}
+
+.tooltip-stats {
+ color: rgba(255, 255, 255, 0.6);
+ font-size: 0.85rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+/* Help text */
+
+.island-universe-help {
+ text-align: center;
+ margin-top: 1rem;
+ padding-top: 1rem;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.island-universe-help p {
+ color: rgba(255, 255, 255, 0.5);
+ font-size: 0.9rem;
+ margin: 0;
+}
+
+/* Animations */
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes slideUp {
+ from {
+ opacity: 0;
+ transform: translateY(30px) scale(0.95);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+@keyframes universeIslandFloat {
+ 0%, 100% {
+ transform: translateY(0px) translateX(0px);
+ }
+ 25% {
+ transform: translateY(-12px) translateX(5px);
+ }
+ 50% {
+ transform: translateY(-5px) translateX(-3px);
+ }
+ 75% {
+ transform: translateY(-15px) translateX(3px);
+ }
+}
+
+/* Responsive */
+
+@media (max-width: 900px) {
+ .island-universe-container {
+ width: 98%;
+ height: 95vh;
+ padding: 1.5rem;
+ border-radius: 15px;
+ }
+
+ .island-universe-header h1 {
+ font-size: 2rem;
+ }
+
+ .island-universe-header p {
+ font-size: 1rem;
+ }
+
+ .island-universe-canvas {
+ height: 500px !important;
+ }
+
+ .island-universe-tooltip {
+ bottom: 100px;
+ min-width: 200px;
+ padding: 0.75rem 1rem;
+ }
+
+ .tooltip-username {
+ font-size: 1.1rem;
+ }
+
+ .tooltip-bio {
+ font-size: 0.85rem;
+ }
+
+ .tooltip-stats {
+ font-size: 0.75rem;
+ }
+}
+
+@media (max-width: 600px) {
+ .island-universe-container {
+ padding: 1rem;
+ }
+
+ .island-universe-header h1 {
+ font-size: 1.5rem;
+ }
+
+ .island-universe-canvas {
+ height: 400px !important;
+ }
+
+ .island-universe-close {
+ width: 36px;
+ height: 36px;
+ font-size: 24px;
+ }
+}
+
+/* Light theme support */
+
+body[data-theme="light"] .island-universe-container {
+ background: linear-gradient(135deg, #e0e7ff 0%, #dbeafe 100%);
+}
+
+body[data-theme="light"] .island-universe-header h1 {
+ color: #1e3a8a;
+}
+
+body[data-theme="light"] .island-universe-header p {
+ color: rgba(30, 58, 138, 0.7);
+}
+
+body[data-theme="light"] .island-universe-close {
+ background: rgba(30, 58, 138, 0.1);
+ border-color: rgba(30, 58, 138, 0.2);
+ color: #1e3a8a;
+}
+
+body[data-theme="light"] .island-universe-close:hover {
+ background: rgba(30, 58, 138, 0.2);
+ border-color: rgba(30, 58, 138, 0.4);
+}
+
+body[data-theme="light"] .island-universe-tooltip {
+ background: rgba(255, 255, 255, 0.95);
+ border-color: rgba(30, 58, 138, 0.2);
+}
+
+body[data-theme="light"] .tooltip-username {
+ color: #2563eb;
+}
+
+body[data-theme="light"] .tooltip-bio {
+ color: #1e3a8a;
+}
+
+body[data-theme="light"] .tooltip-stats {
+ color: rgba(30, 58, 138, 0.6);
+}
+
+body[data-theme="light"] .island-universe-help p {
+ color: rgba(30, 58, 138, 0.5);
+}
+
+body[data-theme="light"] .island-universe-canvas {
+ box-shadow:
+ 0 10px 40px rgba(0, 0, 0, 0.1),
+ inset 0 0 0 1px rgba(30, 58, 138, 0.1);
+}