From 90e9f79fcdaf8b3c40f664ccbf324983c56424e5 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 24 Jan 2026 23:26:06 +0000 Subject: [PATCH] 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 --- package.json | 2 +- src/App.jsx | 14 ++- src/components/Posts/PostComposer.jsx | 7 +- src/components/World/IslandsWorld.css | 98 +++++++++++++++++++ src/components/World/IslandsWorld.jsx | 133 +++++++++++++++++++++----- src/locales/en/translation.json | 6 +- src/locales/fr/translation.json | 6 +- 7 files changed, 236 insertions(+), 30 deletions(-) diff --git a/package.json b/package.json index 7e364f4..9836e2c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.jsx b/src/App.jsx index 9de4b84..f5f1ef2 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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() { { setShowPostComposer(false); setPostComposerIsland(null); + setPostComposerPosition(null); }} onPostCreated={(newPost) => { // Refresh wall posts if on map view diff --git a/src/components/Posts/PostComposer.jsx b/src/components/Posts/PostComposer.jsx index 406498e..df45298 100644 --- a/src/components/Posts/PostComposer.jsx +++ b/src/components/Posts/PostComposer.jsx @@ -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 diff --git a/src/components/World/IslandsWorld.css b/src/components/World/IslandsWorld.css index e90ef67..ed675cd 100644 --- a/src/components/World/IslandsWorld.css +++ b/src/components/World/IslandsWorld.css @@ -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; diff --git a/src/components/World/IslandsWorld.jsx b/src/components/World/IslandsWorld.jsx index 7ec9832..0c4246d 100644 --- a/src/components/World/IslandsWorld.jsx +++ b/src/components/World/IslandsWorld.jsx @@ -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 )} - {/* Zoomed island - full screen terrain view */} + {/* Zoomed island - full screen terrain view (like a map) */} {zoomedIsland && (
- {/* Full-screen island terrain background */} - - {/* Header bar */}
- @@ -311,27 +316,99 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
- {/* Posts on the island terrain */} -
+ {/* Interactive terrain area */} +
{ + // 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 */} + + + {/* Loading */} {loadingPosts && (
)} - {!loadingPosts && islandPosts.length === 0 && ( + + {/* Empty state hint */} + {!loadingPosts && islandPosts.length === 0 && !pendingPostPosition && (

{t('worlds.emptyIsland')}

{username && username.toLowerCase() === (zoomedIsland.username || '').toLowerCase() && ( -

{t('worlds.useButtonToPost')}

+

{t('worlds.clickToPlacePost')}

)}
)} - {!loadingPosts && islandPosts.map((post, idx) => ( - - ))} -
+ {/* Posts displayed at their coordinates */} + {!loadingPosts && islandPosts.map((post, idx) => ( + + ))} + + {/* Pending post marker (where user clicked) */} + {pendingPostPosition && ( +
+
+ +
+
+ + +
+
+ )} +
)} @@ -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 - 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 + // 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)); + 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 ? (
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index da4147b..0cfad39 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -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", diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index ec54893..da502c1 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -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",