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", "name": "sociowire-frontend",
"private": true, "private": true,
"version": "0.0.111", "version": "0.0.112",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",

View File

@ -366,26 +366,38 @@
} }
} }
/* Island Zoomed Overlay */ /* Island Terrain Overlay - Full screen when zoomed */
.island-zoomed-overlay { .island-terrain-overlay {
position: fixed; 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; top: 0;
left: 0; left: 0;
right: 0; right: 0;
z-index: 500;
pointer-events: none;
}
.island-zoomed-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 16px 24px; padding: 16px 24px;
background: linear-gradient(180deg, rgba(10, 22, 40, 0.95) 0%, rgba(10, 22, 40, 0) 100%); background: linear-gradient(180deg, rgba(10, 22, 40, 0.95) 0%, rgba(10, 22, 40, 0) 100%);
pointer-events: auto; pointer-events: auto;
z-index: 10;
} }
.island-zoomed-back { .island-terrain-back {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
@ -398,41 +410,41 @@
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
backdrop-filter: blur(8px);
} }
.island-zoomed-back:hover { .island-terrain-back:hover {
background: rgba(56, 189, 248, 0.2); background: rgba(56, 189, 248, 0.2);
border-color: rgba(56, 189, 248, 0.5); border-color: rgba(56, 189, 248, 0.5);
} }
.island-zoomed-info { .island-terrain-title {
text-align: center; text-align: center;
} }
.island-zoomed-info h2 { .island-terrain-title h2 {
margin: 0; margin: 0;
font-size: 20px; font-size: 20px;
font-weight: 700; font-weight: 700;
color: #38bdf8; color: #38bdf8;
} }
.island-zoomed-info span { .island-terrain-title span {
font-size: 12px; font-size: 12px;
color: rgba(255, 255, 255, 0.6); color: rgba(255, 255, 255, 0.6);
text-transform: uppercase;
letter-spacing: 1px;
} }
.island-zoomed-actions { .island-terrain-actions {
display: flex; display: flex;
gap: 10px; gap: 10px;
} }
.island-zoomed-action { .island-terrain-btn {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
gap: 8px; gap: 8px;
padding: 10px 16px; padding: 10px 14px;
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 12px; border-radius: 12px;
@ -440,23 +452,161 @@
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
backdrop-filter: blur(8px);
} }
.island-zoomed-action:hover { .island-terrain-btn:hover {
background: rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.2);
} }
.island-zoomed-post { .island-terrain-post-btn {
background: linear-gradient(135deg, #10b981, #059669); background: linear-gradient(135deg, #10b981, #059669);
border: none; border: none;
font-weight: 600; font-weight: 600;
} }
.island-zoomed-post:hover { .island-terrain-post-btn:hover {
transform: scale(1.05); transform: scale(1.05);
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.4); 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 */ /* Responsive */
@media (max-width: 768px) { @media (max-width: 768px) {
.islands-world-title { .islands-world-title {

View File

@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import maplibregl from 'maplibre-gl'; import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css'; 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 { useAuth } from '../../auth/AuthContext';
import './IslandsWorld.css'; import './IslandsWorld.css';
@ -27,6 +27,8 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
// Zoomed island state // Zoomed island state
const [zoomedIsland, setZoomedIsland] = useState(null); const [zoomedIsland, setZoomedIsland] = useState(null);
const [isZooming, setIsZooming] = useState(false); const [isZooming, setIsZooming] = useState(false);
const [islandPosts, setIslandPosts] = useState([]);
const [loadingPosts, setLoadingPosts] = useState(false);
// Fetch islands // Fetch islands
useEffect(() => { useEffect(() => {
@ -56,8 +58,8 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
) )
: islands; : islands;
// Handle zoom to island // Handle zoom to island - fills the screen with the island terrain
const handleZoomToIsland = useCallback((island) => { const handleZoomToIsland = useCallback(async (island) => {
if (!mapRef.current || isZooming) return; if (!mapRef.current || isZooming) return;
const pos = islandPositionsRef.current.get(island.username || island.id); const pos = islandPositionsRef.current.get(island.username || island.id);
@ -65,6 +67,7 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
setIsZooming(true); setIsZooming(true);
setZoomedIsland(island); setZoomedIsland(island);
setLoadingPosts(true);
// Stop the rotation animation // Stop the rotation animation
if (animationRef.current) { if (animationRef.current) {
@ -72,16 +75,26 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
animationRef.current = null; animationRef.current = null;
} }
// Fly to island position with high zoom // Fly to island - zoom high enough so island fills screen
mapRef.current.flyTo({ mapRef.current.flyTo({
center: [pos.lng, pos.lat], center: [pos.lng, pos.lat],
zoom: 6, zoom: 16,
pitch: 60, pitch: 0,
bearing: 0, bearing: 0,
duration: 1500, duration: 2000,
essential: true 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', () => { mapRef.current.once('moveend', () => {
setIsZooming(false); setIsZooming(false);
}); });
@ -92,6 +105,7 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
if (!mapRef.current || isZooming) return; if (!mapRef.current || isZooming) return;
setIsZooming(true); setIsZooming(true);
setIslandPosts([]);
mapRef.current.flyTo({ mapRef.current.flyTo({
center: [0, 0], 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 markers = [];
const gridSize = Math.ceil(Math.sqrt(filteredIslands.length || 1)); const worldSize = 300; // Large world area
const spacing = 20;
filteredIslands.forEach((island, idx) => { filteredIslands.forEach((island, idx) => {
const gridX = idx % gridSize; // Use seeded random for consistent positions per island
const gridY = Math.floor(idx / gridSize); const rng = seededRandom((island.username || island.id || '').charCodeAt(0) * 1000 + idx);
const baseX = (gridX - gridSize / 2) * spacing; // Random position across the entire world
const baseY = (gridY - gridSize / 2) * spacing; const lng = (rng() - 0.5) * worldSize;
const jitterX = (Math.random() - 0.5) * spacing * 0.4; const lat = (rng() - 0.5) * worldSize;
const jitterY = (Math.random() - 0.5) * spacing * 0.4;
const lng = centerLng + baseX + jitterX;
const lat = centerLat + baseY + jitterY;
// Store position for zoom // Store position for zoom
islandPositionsRef.current.set(island.username || island.id, { lng, lat }); islandPositionsRef.current.set(island.username || island.id, { lng, lat });
@ -301,34 +311,68 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
</div> </div>
)} )}
{/* Zoomed island UI overlay */} {/* Zoomed island - full screen terrain view */}
{zoomedIsland && !isZooming && ( {zoomedIsland && (
<div className="island-zoomed-overlay"> <div className={`island-terrain-overlay ${isZooming ? 'zooming' : 'ready'}`}>
<div className="island-zoomed-header"> {/* Header bar */}
<button className="island-zoomed-back" onClick={handleZoomOut}> <div className="island-terrain-header">
<button className="island-terrain-back" onClick={handleZoomOut}>
<i className="fa-solid fa-arrow-left" /> <i className="fa-solid fa-arrow-left" />
<span>{t('worlds.backToIslands')}</span> <span>{t('worlds.backToIslands')}</span>
</button> </button>
<div className="island-zoomed-info"> <div className="island-terrain-title">
<h2>@{zoomedIsland.username}</h2> <h2>@{zoomedIsland.username}</h2>
<span>{zoomedIsland.terrain_type || 'tropical'}</span> <span>{zoomedIsland.display_name || zoomedIsland.terrain_type || 'Island'}</span>
</div> </div>
<div className="island-zoomed-actions"> <div className="island-terrain-actions">
<button className="island-zoomed-action" onClick={() => window.location.href = `/profile/${zoomedIsland.username}`}> <button className="island-terrain-btn" onClick={() => window.location.href = `/profile/${zoomedIsland.username}`}>
<i className="fa-solid fa-user" /> <i className="fa-solid fa-user" />
</button> </button>
{username && username.toLowerCase() === (zoomedIsland.username || '').toLowerCase() && ( {username && username.toLowerCase() === (zoomedIsland.username || '').toLowerCase() && (
<button className="island-zoomed-action island-zoomed-post" onClick={() => { <>
window.dispatchEvent(new CustomEvent('sociowire:create-post', { <button className="island-terrain-btn" onClick={() => window.location.href = `/island/${zoomedIsland.username}/edit`}>
detail: { island: zoomedIsland.username } <i className="fa-solid fa-cog" />
})); </button>
}}> <button className="island-terrain-btn island-terrain-post-btn" onClick={() => {
<i className="fa-solid fa-plus" /> window.dispatchEvent(new CustomEvent('sociowire:create-post', {
<span>{t('worlds.postHere')}</span> detail: { island: zoomedIsland.username }
</button> }));
}}>
<i className="fa-solid fa-plus" />
<span>{t('worlds.postHere')}</span>
</button>
</>
)} )}
</div> </div>
</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> </div>
)} )}
@ -495,3 +539,44 @@ function seededRandom(seed) {
return s / 233280; 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>
);
}