v116: Interactive island terrain with click-to-place posts

- Island terrain now interactive like a map
- Click anywhere on your island to place a post at that position
- Pending post marker shows where you clicked with confirm/cancel buttons
- Posts saved with island_x and island_y coordinates (0-100%)
- PostComposer accepts islandX/islandY and includes in payload
- Posts display at their saved coordinates, fallback to random if not set
- Added translations for new UI elements (EN/FR)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-01-24 23:26:06 +00:00
parent fa90d7426b
commit 90e9f79fcd
7 changed files with 236 additions and 30 deletions

View File

@ -1,7 +1,7 @@
{
"name": "sociowire-frontend",
"private": true,
"version": "0.0.115",
"version": "0.0.116",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",

View File

@ -69,6 +69,7 @@ export default function App() {
// Post Composer state (for creating posts with island context)
const [showPostComposer, setShowPostComposer] = useState(false);
const [postComposerIsland, setPostComposerIsland] = useState(null);
const [postComposerPosition, setPostComposerPosition] = useState(null); // { x, y }
// Post detail modal state (triggered from map markers)
const [detailPost, setDetailPost] = useState(null);
@ -158,10 +159,15 @@ export default function App() {
// Listen for post creator events
useEffect(() => {
// Handle opening post composer with island context
// Handle opening post composer with island context and coordinates
const handleOpenPostCreator = (e) => {
const { island_username } = e.detail || {};
const { island_username, island_x, island_y } = e.detail || {};
setPostComposerIsland(island_username || null);
if (island_x !== undefined && island_y !== undefined) {
setPostComposerPosition({ x: island_x, y: island_y });
} else {
setPostComposerPosition(null);
}
setShowPostComposer(true);
};
@ -170,6 +176,7 @@ export default function App() {
const { island } = e.detail || {};
if (island) {
setPostComposerIsland(island);
setPostComposerPosition(null);
setShowPostComposer(true);
}
};
@ -386,9 +393,12 @@ export default function App() {
<PostComposer
key="post-composer"
islandUsername={postComposerIsland}
islandX={postComposerPosition?.x}
islandY={postComposerPosition?.y}
onClose={() => {
setShowPostComposer(false);
setPostComposerIsland(null);
setPostComposerPosition(null);
}}
onPostCreated={(newPost) => {
// Refresh wall posts if on map view

View File

@ -15,7 +15,7 @@ const CATEGORIES = [
{ id: "weather", icon: "fa-cloud-sun", label: "Weather", color: "from-sky-500 to-blue-500" },
];
export default function PostComposer({ onClose, onPostCreated, islandUsername }) {
export default function PostComposer({ onClose, onPostCreated, islandUsername, islandX, islandY }) {
const { t } = useTranslation();
const { token, username, isAuthenticated } = useAuth();
const fileInputRef = useRef(null);
@ -162,6 +162,11 @@ export default function PostComposer({ onClose, onPostCreated, islandUsername })
// Add island context if posting to an island
if (islandUsername) {
payload.island_username = islandUsername;
// Include island coordinates if provided
if (islandX !== undefined && islandY !== undefined) {
payload.island_x = islandX;
payload.island_y = islandY;
}
}
// Add media URLs

View File

@ -506,6 +506,22 @@
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.4);
}
/* Interactive terrain area - full screen clickable */
.island-terrain-interactive {
position: absolute;
inset: 0;
top: 70px;
cursor: crosshair;
overflow: hidden;
}
.island-terrain-interactive .island-terrain-bg {
position: absolute;
inset: -10%;
width: 120%;
height: 120%;
}
/* Posts container - overlay for posts on island */
.island-terrain-content {
position: absolute;
@ -644,6 +660,88 @@
margin-right: 4px;
}
/* Pending post marker - shows where user clicked to place a post */
.island-pending-post-marker {
position: absolute;
transform: translate(-50%, -50%);
z-index: 100;
pointer-events: auto;
animation: pendingPulse 1.5s ease-in-out infinite;
}
@keyframes pendingPulse {
0%, 100% { transform: translate(-50%, -50%) scale(1); }
50% { transform: translate(-50%, -50%) scale(1.1); }
}
.pending-marker-icon {
width: 50px;
height: 50px;
border-radius: 50%;
background: linear-gradient(135deg, #10b981, #059669);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
box-shadow: 0 8px 32px rgba(16, 185, 129, 0.5);
cursor: pointer;
}
.pending-marker-actions {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: 10px;
display: flex;
gap: 8px;
white-space: nowrap;
}
.pending-marker-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 16px;
border: none;
border-radius: 25px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
backdrop-filter: blur(8px);
}
.pending-marker-confirm {
background: linear-gradient(135deg, #10b981, #059669);
color: white;
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.4);
}
.pending-marker-confirm:hover {
transform: scale(1.05);
box-shadow: 0 6px 24px rgba(16, 185, 129, 0.5);
}
.pending-marker-cancel {
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.pending-marker-cancel:hover {
background: rgba(239, 68, 68, 0.3);
border-color: rgba(239, 68, 68, 0.5);
}
/* Hint text in empty terrain */
.island-terrain-hint {
font-size: 14px;
color: rgba(56, 189, 248, 0.8);
margin-top: 10px;
}
/* Floating create post button */
.island-create-post-fab {
position: fixed;

View File

@ -30,12 +30,17 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
const [islandPosts, setIslandPosts] = useState([]);
const [loadingPosts, setLoadingPosts] = useState(false);
// Click-to-place post state
const [pendingPostPosition, setPendingPostPosition] = useState(null);
const terrainRef = useRef(null);
// Set global island context when zoomed (for FAB to use)
useEffect(() => {
if (zoomedIsland) {
window.__activeIslandContext = {
username: zoomedIsland.username,
isOwner: username && username.toLowerCase() === (zoomedIsland.username || '').toLowerCase()
isOwner: username && username.toLowerCase() === (zoomedIsland.username || '').toLowerCase(),
pendingPosition: pendingPostPosition
};
} else {
window.__activeIslandContext = null;
@ -43,7 +48,7 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
return () => {
window.__activeIslandContext = null;
};
}, [zoomedIsland, username]);
}, [zoomedIsland, username, pendingPostPosition]);
// Fetch islands
useEffect(() => {
@ -283,15 +288,15 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
</div>
)}
{/* Zoomed island - full screen terrain view */}
{/* Zoomed island - full screen terrain view (like a map) */}
{zoomedIsland && (
<div className="island-fullscreen-overlay">
{/* Full-screen island terrain background */}
<IslandTerrainBackground island={zoomedIsland} />
{/* Header bar */}
<div className="island-terrain-header">
<button className="island-terrain-back" onClick={handleZoomOut}>
<button className="island-terrain-back" onClick={() => {
handleZoomOut();
setPendingPostPosition(null);
}}>
<i className="fa-solid fa-arrow-left" />
<span>{t('worlds.backToIslands')}</span>
</button>
@ -311,27 +316,99 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
</div>
</div>
{/* Posts on the island terrain */}
<div className="island-terrain-content">
{/* Interactive terrain area */}
<div
ref={terrainRef}
className="island-terrain-interactive"
onClick={(e) => {
// Only owner can place posts
if (!username || username.toLowerCase() !== (zoomedIsland.username || '').toLowerCase()) return;
const rect = terrainRef.current?.getBoundingClientRect();
if (!rect) return;
// Calculate position as percentage (0-100)
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
setPendingPostPosition({ x, y });
}}
>
{/* Full-screen island terrain background */}
<IslandTerrainBackground island={zoomedIsland} />
{/* Loading */}
{loadingPosts && (
<div className="island-terrain-loading">
<div className="loading-spinner" />
</div>
)}
{!loadingPosts && islandPosts.length === 0 && (
{/* Empty state hint */}
{!loadingPosts && islandPosts.length === 0 && !pendingPostPosition && (
<div className="island-terrain-empty">
<i className="fa-solid fa-umbrella-beach" />
<p>{t('worlds.emptyIsland')}</p>
{username && username.toLowerCase() === (zoomedIsland.username || '').toLowerCase() && (
<p className="island-terrain-hint">{t('worlds.useButtonToPost')}</p>
<p className="island-terrain-hint">{t('worlds.clickToPlacePost')}</p>
)}
</div>
)}
{!loadingPosts && islandPosts.map((post, idx) => (
<IslandPost key={post.id || idx} post={post} index={idx} islandSeed={zoomedIsland.seed || 0} />
))}
</div>
{/* Posts displayed at their coordinates */}
{!loadingPosts && islandPosts.map((post, idx) => (
<IslandPost
key={post.id || idx}
post={post}
index={idx}
islandSeed={zoomedIsland.seed || 0}
/>
))}
{/* Pending post marker (where user clicked) */}
{pendingPostPosition && (
<div
className="island-pending-post-marker"
style={{
left: `${pendingPostPosition.x}%`,
top: `${pendingPostPosition.y}%`
}}
>
<div className="pending-marker-icon">
<i className="fa-solid fa-plus" />
</div>
<div className="pending-marker-actions">
<button
className="pending-marker-btn pending-marker-confirm"
onClick={(e) => {
e.stopPropagation();
// Open post composer with position
window.dispatchEvent(new CustomEvent('sw:openPostCreator', {
detail: {
island_username: zoomedIsland.username,
island_x: pendingPostPosition.x,
island_y: pendingPostPosition.y
}
}));
setPendingPostPosition(null);
}}
>
<i className="fa-solid fa-check" />
<span>{t('worlds.postHere')}</span>
</button>
<button
className="pending-marker-btn pending-marker-cancel"
onClick={(e) => {
e.stopPropagation();
setPendingPostPosition(null);
}}
>
<i className="fa-solid fa-times" />
</button>
</div>
</div>
)}
</div>
</div>
)}
@ -610,14 +687,20 @@ function IslandDetails({ seed, cx, cy }) {
}
/**
* IslandPost - A post displayed on the island terrain
* IslandPost - A post displayed on the island terrain at its coordinates
*/
function IslandPost({ post, index, islandSeed }) {
// Position posts pseudo-randomly on the island
// Use island coordinates if available, otherwise fall back to random placement
let left, top;
if (post.island_x !== undefined && post.island_y !== undefined) {
left = post.island_x;
top = post.island_y;
} else {
// Fallback: random position based on seed
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
left = 15 + rng() * 70; // 15-85% from left
top = 20 + rng() * 60; // 20-80% from top
}
const hasImage = post.media_urls?.length > 0 || post.image_url;
const imageUrl = post.media_urls?.[0] || post.image_url;
@ -627,10 +710,12 @@ function IslandPost({ post, index, islandSeed }) {
className="island-post-marker"
style={{
left: `${left}%`,
top: `${top}%`,
transform: `rotate(${rotation}deg)`
top: `${top}%`
}}
onClick={(e) => {
e.stopPropagation();
window.location.href = `/post/${post.id}`;
}}
onClick={() => window.location.href = `/post/${post.id}`}
>
{hasImage ? (
<div className="island-post-image">

View File

@ -299,7 +299,11 @@
"createPlanet": "Create Planet",
"joinPlanet": "Join Planet",
"share": "Share",
"noDescription": "No description yet"
"noDescription": "No description yet",
"emptyIsland": "This island is empty",
"clickToPlacePost": "Click anywhere to place a post",
"postHere": "Post here",
"backToIslands": "Back"
},
"controls": {
"mySpot": "My spot",

View File

@ -302,7 +302,11 @@
"createPlanet": "Créer une Planète",
"joinPlanet": "Rejoindre la Planète",
"share": "Partager",
"noDescription": "Pas de description"
"noDescription": "Pas de description",
"emptyIsland": "Cette île est vide",
"clickToPlacePost": "Clique pour placer un post",
"postHere": "Poster ici",
"backToIslands": "Retour"
},
"controls": {
"mySpot": "Ma position",