v112: Scatter islands randomly + full-screen terrain zoom with posts
- Islands now scattered across 300° world area using seeded random positions - When clicking island, zooms to fill screen (zoom 16, pitch 0) - Full-screen terrain overlay shows posts scattered on the island - Posts positioned pseudo-randomly based on island seed - Loading state while fetching island posts - Empty state with "create first post" CTA for island owner Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b6b176271b
commit
6422aeebfe
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "sociowire-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.111",
|
||||
"version": "0.0.112",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
|
|
|
|||
|
|
@ -366,26 +366,38 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* Island Zoomed Overlay */
|
||||
.island-zoomed-overlay {
|
||||
/* Island Terrain Overlay - Full screen when zoomed */
|
||||
.island-terrain-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 500;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.island-terrain-overlay.zooming {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.island-terrain-overlay.ready {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.island-terrain-header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 500;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.island-zoomed-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
background: linear-gradient(180deg, rgba(10, 22, 40, 0.95) 0%, rgba(10, 22, 40, 0) 100%);
|
||||
pointer-events: auto;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.island-zoomed-back {
|
||||
.island-terrain-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
|
@ -398,41 +410,41 @@
|
|||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.island-zoomed-back:hover {
|
||||
.island-terrain-back:hover {
|
||||
background: rgba(56, 189, 248, 0.2);
|
||||
border-color: rgba(56, 189, 248, 0.5);
|
||||
}
|
||||
|
||||
.island-zoomed-info {
|
||||
.island-terrain-title {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.island-zoomed-info h2 {
|
||||
.island-terrain-title h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
.island-zoomed-info span {
|
||||
.island-terrain-title span {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.island-zoomed-actions {
|
||||
.island-terrain-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.island-zoomed-action {
|
||||
.island-terrain-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
|
|
@ -440,23 +452,161 @@
|
|||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.island-zoomed-action:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
.island-terrain-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.island-zoomed-post {
|
||||
.island-terrain-post-btn {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.island-zoomed-post:hover {
|
||||
.island-terrain-post-btn:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
/* Posts container - overlay for posts on island */
|
||||
.island-terrain-posts {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.island-terrain-loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.island-terrain-loading .loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.island-terrain-empty {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.island-terrain-empty i {
|
||||
font-size: 48px;
|
||||
color: rgba(56, 189, 248, 0.4);
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.island-terrain-empty p {
|
||||
font-size: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.island-terrain-first-post {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.island-terrain-first-post:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
/* Post markers on the island terrain */
|
||||
.island-post-marker {
|
||||
position: absolute;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
animation: postFloat 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.island-post-marker:hover {
|
||||
transform: scale(1.1) !important;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@keyframes postFloat {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-6px); }
|
||||
}
|
||||
|
||||
.island-post-image {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
border: 2px solid rgba(56, 189, 248, 0.4);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.island-post-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.island-post-text {
|
||||
max-width: 150px;
|
||||
padding: 12px 16px;
|
||||
background: linear-gradient(135deg, rgba(15, 23, 42, 0.95), rgba(30, 41, 59, 0.9));
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(56, 189, 248, 0.3);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.island-post-text p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.island-post-meta {
|
||||
position: absolute;
|
||||
bottom: -8px;
|
||||
right: -8px;
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
.island-post-likes {
|
||||
font-size: 11px;
|
||||
color: #ef4444;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.island-post-likes i {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.islands-world-title {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState, useCallback } from 'react';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { fetchIslands, fetchIslandPosts } from '../../api/client';
|
||||
import { fetchIslands, fetchIslandPosts, fetchPosts } from '../../api/client';
|
||||
import { useAuth } from '../../auth/AuthContext';
|
||||
import './IslandsWorld.css';
|
||||
|
||||
|
|
@ -27,6 +27,8 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
|||
// Zoomed island state
|
||||
const [zoomedIsland, setZoomedIsland] = useState(null);
|
||||
const [isZooming, setIsZooming] = useState(false);
|
||||
const [islandPosts, setIslandPosts] = useState([]);
|
||||
const [loadingPosts, setLoadingPosts] = useState(false);
|
||||
|
||||
// Fetch islands
|
||||
useEffect(() => {
|
||||
|
|
@ -56,8 +58,8 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
|||
)
|
||||
: islands;
|
||||
|
||||
// Handle zoom to island
|
||||
const handleZoomToIsland = useCallback((island) => {
|
||||
// Handle zoom to island - fills the screen with the island terrain
|
||||
const handleZoomToIsland = useCallback(async (island) => {
|
||||
if (!mapRef.current || isZooming) return;
|
||||
|
||||
const pos = islandPositionsRef.current.get(island.username || island.id);
|
||||
|
|
@ -65,6 +67,7 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
|||
|
||||
setIsZooming(true);
|
||||
setZoomedIsland(island);
|
||||
setLoadingPosts(true);
|
||||
|
||||
// Stop the rotation animation
|
||||
if (animationRef.current) {
|
||||
|
|
@ -72,16 +75,26 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
|||
animationRef.current = null;
|
||||
}
|
||||
|
||||
// Fly to island position with high zoom
|
||||
// Fly to island - zoom high enough so island fills screen
|
||||
mapRef.current.flyTo({
|
||||
center: [pos.lng, pos.lat],
|
||||
zoom: 6,
|
||||
pitch: 60,
|
||||
zoom: 16,
|
||||
pitch: 0,
|
||||
bearing: 0,
|
||||
duration: 1500,
|
||||
duration: 2000,
|
||||
essential: true
|
||||
});
|
||||
|
||||
// Fetch posts for this island
|
||||
try {
|
||||
const posts = await fetchPosts({ username: island.username, limit: 50 });
|
||||
setIslandPosts(posts || []);
|
||||
} catch (err) {
|
||||
console.warn('[IslandsWorld] Failed to fetch island posts:', err);
|
||||
setIslandPosts([]);
|
||||
}
|
||||
setLoadingPosts(false);
|
||||
|
||||
mapRef.current.once('moveend', () => {
|
||||
setIsZooming(false);
|
||||
});
|
||||
|
|
@ -92,6 +105,7 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
|||
if (!mapRef.current || isZooming) return;
|
||||
|
||||
setIsZooming(true);
|
||||
setIslandPosts([]);
|
||||
|
||||
mapRef.current.flyTo({
|
||||
center: [0, 0],
|
||||
|
|
@ -181,20 +195,16 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
|||
}
|
||||
});
|
||||
|
||||
// Create island markers
|
||||
// Create island markers - scattered randomly across a large area
|
||||
const markers = [];
|
||||
const gridSize = Math.ceil(Math.sqrt(filteredIslands.length || 1));
|
||||
const spacing = 20;
|
||||
const worldSize = 300; // Large world area
|
||||
|
||||
filteredIslands.forEach((island, idx) => {
|
||||
const gridX = idx % gridSize;
|
||||
const gridY = Math.floor(idx / gridSize);
|
||||
const baseX = (gridX - gridSize / 2) * spacing;
|
||||
const baseY = (gridY - gridSize / 2) * spacing;
|
||||
const jitterX = (Math.random() - 0.5) * spacing * 0.4;
|
||||
const jitterY = (Math.random() - 0.5) * spacing * 0.4;
|
||||
const lng = centerLng + baseX + jitterX;
|
||||
const lat = centerLat + baseY + jitterY;
|
||||
// Use seeded random for consistent positions per island
|
||||
const rng = seededRandom((island.username || island.id || '').charCodeAt(0) * 1000 + idx);
|
||||
// Random position across the entire world
|
||||
const lng = (rng() - 0.5) * worldSize;
|
||||
const lat = (rng() - 0.5) * worldSize;
|
||||
|
||||
// Store position for zoom
|
||||
islandPositionsRef.current.set(island.username || island.id, { lng, lat });
|
||||
|
|
@ -301,34 +311,68 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Zoomed island UI overlay */}
|
||||
{zoomedIsland && !isZooming && (
|
||||
<div className="island-zoomed-overlay">
|
||||
<div className="island-zoomed-header">
|
||||
<button className="island-zoomed-back" onClick={handleZoomOut}>
|
||||
{/* Zoomed island - full screen terrain view */}
|
||||
{zoomedIsland && (
|
||||
<div className={`island-terrain-overlay ${isZooming ? 'zooming' : 'ready'}`}>
|
||||
{/* Header bar */}
|
||||
<div className="island-terrain-header">
|
||||
<button className="island-terrain-back" onClick={handleZoomOut}>
|
||||
<i className="fa-solid fa-arrow-left" />
|
||||
<span>{t('worlds.backToIslands')}</span>
|
||||
</button>
|
||||
<div className="island-zoomed-info">
|
||||
<div className="island-terrain-title">
|
||||
<h2>@{zoomedIsland.username}</h2>
|
||||
<span>{zoomedIsland.terrain_type || 'tropical'}</span>
|
||||
<span>{zoomedIsland.display_name || zoomedIsland.terrain_type || 'Island'}</span>
|
||||
</div>
|
||||
<div className="island-zoomed-actions">
|
||||
<button className="island-zoomed-action" onClick={() => window.location.href = `/profile/${zoomedIsland.username}`}>
|
||||
<div className="island-terrain-actions">
|
||||
<button className="island-terrain-btn" onClick={() => window.location.href = `/profile/${zoomedIsland.username}`}>
|
||||
<i className="fa-solid fa-user" />
|
||||
</button>
|
||||
{username && username.toLowerCase() === (zoomedIsland.username || '').toLowerCase() && (
|
||||
<button className="island-zoomed-action island-zoomed-post" onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent('sociowire:create-post', {
|
||||
detail: { island: zoomedIsland.username }
|
||||
}));
|
||||
}}>
|
||||
<i className="fa-solid fa-plus" />
|
||||
<span>{t('worlds.postHere')}</span>
|
||||
</button>
|
||||
<>
|
||||
<button className="island-terrain-btn" onClick={() => window.location.href = `/island/${zoomedIsland.username}/edit`}>
|
||||
<i className="fa-solid fa-cog" />
|
||||
</button>
|
||||
<button className="island-terrain-btn island-terrain-post-btn" onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent('sociowire:create-post', {
|
||||
detail: { island: zoomedIsland.username }
|
||||
}));
|
||||
}}>
|
||||
<i className="fa-solid fa-plus" />
|
||||
<span>{t('worlds.postHere')}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Posts on the island terrain */}
|
||||
<div className="island-terrain-posts">
|
||||
{loadingPosts && (
|
||||
<div className="island-terrain-loading">
|
||||
<div className="loading-spinner" />
|
||||
</div>
|
||||
)}
|
||||
{!loadingPosts && islandPosts.length === 0 && (
|
||||
<div className="island-terrain-empty">
|
||||
<i className="fa-solid fa-island-tropical" />
|
||||
<p>{t('worlds.emptyIsland')}</p>
|
||||
{username && username.toLowerCase() === (zoomedIsland.username || '').toLowerCase() && (
|
||||
<button className="island-terrain-first-post" onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent('sociowire:create-post', {
|
||||
detail: { island: zoomedIsland.username }
|
||||
}));
|
||||
}}>
|
||||
<i className="fa-solid fa-plus" />
|
||||
{t('worlds.createFirstPost')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!loadingPosts && islandPosts.map((post, idx) => (
|
||||
<IslandPost key={post.id || idx} post={post} index={idx} islandSeed={zoomedIsland.seed || 0} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -495,3 +539,44 @@ function seededRandom(seed) {
|
|||
return s / 233280;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* IslandPost - A post displayed on the island terrain
|
||||
*/
|
||||
function IslandPost({ post, index, islandSeed }) {
|
||||
// Position posts pseudo-randomly on the island
|
||||
const rng = seededRandom(islandSeed * 100 + index + (post.id || 0));
|
||||
const left = 10 + rng() * 75; // 10-85% from left
|
||||
const top = 15 + rng() * 65; // 15-80% from top
|
||||
const rotation = (rng() - 0.5) * 12; // -6 to +6 degrees
|
||||
|
||||
const hasImage = post.media_urls?.length > 0 || post.image_url;
|
||||
const imageUrl = post.media_urls?.[0] || post.image_url;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="island-post-marker"
|
||||
style={{
|
||||
left: `${left}%`,
|
||||
top: `${top}%`,
|
||||
transform: `rotate(${rotation}deg)`
|
||||
}}
|
||||
onClick={() => window.location.href = `/post/${post.id}`}
|
||||
>
|
||||
{hasImage ? (
|
||||
<div className="island-post-image">
|
||||
<img src={imageUrl} alt="" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="island-post-text">
|
||||
<p>{(post.content || post.text || '').slice(0, 80)}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="island-post-meta">
|
||||
<span className="island-post-likes">
|
||||
<i className="fa-solid fa-heart" /> {post.like_count || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue