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:
Your Name 2026-01-24 22:52:05 +00:00
parent b6b176271b
commit 6422aeebfe
3 changed files with 292 additions and 57 deletions

View File

@ -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",

View File

@ -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 {

View File

@ -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,24 +311,29 @@ 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={() => {
<>
<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 }
}));
@ -326,9 +341,38 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
<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>
);
}