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:
parent
fa90d7426b
commit
90e9f79fcd
|
|
@ -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",
|
||||
|
|
|
|||
14
src/App.jsx
14
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() {
|
|||
<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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue