439 lines
15 KiB
JavaScript
439 lines
15 KiB
JavaScript
import React, { Suspense, useEffect, useState, useCallback } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { version as appVersion } from "../package.json";
|
|
import TopBar from "./components/Layout/TopBarNew";
|
|
import PostList from "./components/Posts/PostList";
|
|
import ContentFilters from "./components/Filters/ContentFilters";
|
|
import IslandViewer from "./components/Island/IslandViewer";
|
|
import ChatContainer from "./components/Chat/ChatContainer";
|
|
import PostDetailModal from "./components/Posts/PostDetailModal";
|
|
import PostComposer, { CreatePostFAB } from "./components/Posts/PostComposer";
|
|
import { AppStore } from "./components/Apps";
|
|
import { AnimatePresence, motion } from "framer-motion";
|
|
import { fetchIslandByUsername } from "./api/client";
|
|
|
|
const MapView = React.lazy(() => import("./components/Map/MapView"));
|
|
const IslandsWorld = React.lazy(() => import("./components/World/IslandsWorld"));
|
|
const PlanetsWorld = React.lazy(() => import("./components/World/PlanetsWorld"));
|
|
|
|
const THEME_KEY = "sociowire:theme";
|
|
const CONTENT_FILTER_KEY = "sociowire:contentFilter";
|
|
const VIEW_MODE_KEY = "sociowire:viewMode"; // "map" | "islands" | "planets"
|
|
|
|
export default function App() {
|
|
const { t } = useTranslation();
|
|
const [theme, setTheme] = useState("blue");
|
|
|
|
// Current world view: "map" | "islands" | "planets"
|
|
// Always start on map for now
|
|
const [currentView, setCurrentView] = useState("map");
|
|
|
|
// Clean up Facebook OAuth hash fragment (#_=_)
|
|
useEffect(() => {
|
|
if (window.location.hash === "#_=_") {
|
|
if (window.history.replaceState) {
|
|
window.history.replaceState(null, "", window.location.pathname + window.location.search);
|
|
} else {
|
|
window.location.hash = "";
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
// Wall state (lifted from MapView)
|
|
const [wallPosts, setWallPosts] = useState([]);
|
|
const [wallLoading, setWallLoading] = useState(false);
|
|
const [wallError, setWallError] = useState("");
|
|
const [selectedPost, setSelectedPost] = useState(null);
|
|
|
|
// Content filter (shared between map and sociowall) - persisted
|
|
// "all" | "links" | "video" | "image" | "text" | "live" | "cluster"
|
|
const [contentFilter, setContentFilter] = useState(() => {
|
|
try {
|
|
const saved = localStorage.getItem(CONTENT_FILTER_KEY);
|
|
if (saved && ["all", "links", "video", "image", "text", "live", "cluster"].includes(saved)) {
|
|
return saved;
|
|
}
|
|
} catch {}
|
|
return "all";
|
|
});
|
|
|
|
// Save content filter to localStorage
|
|
useEffect(() => {
|
|
try {
|
|
localStorage.setItem(CONTENT_FILTER_KEY, contentFilter);
|
|
} catch {}
|
|
}, [contentFilter]);
|
|
|
|
// Island Viewer state (for viewing individual islands)
|
|
const [selectedIsland, setSelectedIsland] = useState(null);
|
|
|
|
// 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);
|
|
|
|
// App Store state
|
|
const [showAppStore, setShowAppStore] = useState(false);
|
|
|
|
const handlePostsState = useCallback((next) => {
|
|
if (!next) return;
|
|
if (Array.isArray(next.posts)) setWallPosts(next.posts);
|
|
setWallLoading(!!next.loading);
|
|
setWallError(next.error || "");
|
|
}, []);
|
|
|
|
const handlePostDeleted = useCallback((postId) => {
|
|
setWallPosts((prev) => prev.filter((p) => (p.id ?? p._id) !== postId));
|
|
if (selectedPost && (selectedPost.id ?? selectedPost._id) === postId) {
|
|
setSelectedPost(null);
|
|
}
|
|
}, [selectedPost]);
|
|
|
|
const handlePostUpdated = useCallback((updatedPost) => {
|
|
if (!updatedPost) return;
|
|
setWallPosts((prev) =>
|
|
prev.map((p) =>
|
|
(p.id ?? p._id) === (updatedPost.id ?? updatedPost._id)
|
|
? { ...p, ...updatedPost }
|
|
: p
|
|
)
|
|
);
|
|
}, []);
|
|
|
|
const handlePostCreated = useCallback((newPost) => {
|
|
if (!newPost) return;
|
|
// Add to beginning of wall posts
|
|
setWallPosts((prev) => [newPost, ...prev]);
|
|
}, []);
|
|
|
|
// charge le thème au démarrage
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return;
|
|
try {
|
|
const saved = localStorage.getItem(THEME_KEY);
|
|
if (saved === "dark" || saved === "blue" || saved === "light") {
|
|
setTheme(saved);
|
|
document.body.setAttribute("data-theme", saved);
|
|
} else {
|
|
setTheme("blue");
|
|
document.body.setAttribute("data-theme", "blue");
|
|
}
|
|
} catch (e) {
|
|
setTheme("blue");
|
|
document.body.setAttribute("data-theme", "blue");
|
|
}
|
|
}, []);
|
|
|
|
// applique / sauvegarde le thème
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return;
|
|
try {
|
|
document.body.setAttribute("data-theme", theme);
|
|
localStorage.setItem(THEME_KEY, theme);
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
}, [theme]);
|
|
|
|
useEffect(() => {
|
|
const onScroll = () => {
|
|
const threshold = window.innerHeight * 0.55;
|
|
const expanded = window.scrollY > threshold;
|
|
document.body.classList.toggle("sw-wall-expanded", expanded);
|
|
};
|
|
window.addEventListener("scroll", onScroll, { passive: true });
|
|
onScroll();
|
|
return () => window.removeEventListener("scroll", onScroll);
|
|
}, []);
|
|
|
|
// Listen for post detail modal open event from map markers
|
|
useEffect(() => {
|
|
const handleOpenPostDetail = (e) => {
|
|
const post = e.detail?.post;
|
|
if (post) {
|
|
setDetailPost(post);
|
|
}
|
|
};
|
|
window.addEventListener("sw:openPostDetail", handleOpenPostDetail);
|
|
return () => window.removeEventListener("sw:openPostDetail", handleOpenPostDetail);
|
|
}, []);
|
|
|
|
// Listen for post creator events
|
|
useEffect(() => {
|
|
// Handle opening post composer with island context and coordinates
|
|
const handleOpenPostCreator = (e) => {
|
|
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);
|
|
};
|
|
|
|
// Legacy island post event
|
|
const handleIslandPost = (e) => {
|
|
const { island } = e.detail || {};
|
|
if (island) {
|
|
setPostComposerIsland(island);
|
|
setPostComposerPosition(null);
|
|
setShowPostComposer(true);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('sw:openPostCreator', handleOpenPostCreator);
|
|
window.addEventListener('sociowire:create-post', handleIslandPost);
|
|
return () => {
|
|
window.removeEventListener('sw:openPostCreator', handleOpenPostCreator);
|
|
window.removeEventListener('sociowire:create-post', handleIslandPost);
|
|
};
|
|
}, []);
|
|
|
|
// World navigation handlers
|
|
const handleOpenIslands = () => {
|
|
setCurrentView("islands");
|
|
};
|
|
|
|
const handleOpenPlanets = () => {
|
|
setCurrentView("planets");
|
|
};
|
|
|
|
const handleOpenMap = () => {
|
|
setCurrentView("map");
|
|
};
|
|
|
|
const handleIslandClick = (island) => {
|
|
setSelectedIsland(island); // Open island viewer
|
|
};
|
|
|
|
const handleOpenUserIsland = async (uname) => {
|
|
const u = (uname || "").trim();
|
|
if (!u) return;
|
|
try {
|
|
const island = await fetchIslandByUsername(u);
|
|
if (island) {
|
|
setSelectedIsland(island);
|
|
} else {
|
|
// Island doesn't exist - switch to Islands view to create it
|
|
setCurrentView('islands');
|
|
}
|
|
} catch (err) {
|
|
console.error("Error fetching island:", err);
|
|
// On error, switch to Islands view
|
|
setCurrentView('islands');
|
|
}
|
|
};
|
|
|
|
// World loading placeholder
|
|
const WorldPlaceholder = ({ title, subtitle }) => (
|
|
<div className="world-placeholder">
|
|
<div className="world-placeholder-content">
|
|
<div className="world-placeholder-title">{title}</div>
|
|
<div className="world-placeholder-subtitle">{subtitle}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="app-root">
|
|
<TopBar
|
|
theme={theme}
|
|
onChangeTheme={setTheme}
|
|
onIslandsClick={handleOpenIslands}
|
|
onPlanetsClick={handleOpenPlanets}
|
|
onMapClick={handleOpenMap}
|
|
onUserIsland={handleOpenUserIsland}
|
|
currentView={currentView}
|
|
onOpenAppStore={() => setShowAppStore(true)}
|
|
/>
|
|
|
|
<div className="main-shell">
|
|
<AnimatePresence mode="wait">
|
|
{/* MAP WORLD - Reality */}
|
|
{currentView === "map" && (
|
|
<motion.div
|
|
key="map-world"
|
|
className="world-container"
|
|
initial={{ opacity: 0, x: -50 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: 50 }}
|
|
transition={{ duration: 0.3 }}
|
|
>
|
|
<div className="map-shell" id="map">
|
|
<Suspense fallback={<WorldPlaceholder title={t('map.loadingMap')} subtitle={t('map.warmingUp')} />}>
|
|
<MapView
|
|
theme={theme}
|
|
contentFilter={contentFilter}
|
|
onPostsState={handlePostsState}
|
|
selectedPost={selectedPost}
|
|
onSelectPost={setSelectedPost}
|
|
selectedIsland={selectedIsland}
|
|
onSelectIsland={setSelectedIsland}
|
|
onOpenIslands={handleOpenIslands}
|
|
onOpenPlanets={handleOpenPlanets}
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
|
|
{/* BELOW THE MAP (page scroll like Facebook) */}
|
|
<div className="below-stage">
|
|
<ContentFilters
|
|
activeFilter={contentFilter}
|
|
onFilterChange={setContentFilter}
|
|
/>
|
|
<div className="below-row">
|
|
<div className="sociowall-panel" id="wall">
|
|
<div className="sociowall-header">
|
|
<h2 className="sociowall-title">SOCIOWALL</h2>
|
|
</div>
|
|
<PostList
|
|
posts={wallPosts}
|
|
loading={wallLoading}
|
|
error={wallError}
|
|
selectedPost={selectedPost}
|
|
onSelectPost={setSelectedPost}
|
|
onPostDeleted={handlePostDeleted}
|
|
onPostUpdated={handlePostUpdated}
|
|
contentFilter={contentFilter}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* ISLANDS WORLD - Personal Metaverse */}
|
|
{currentView === "islands" && (
|
|
<motion.div
|
|
key="islands-world"
|
|
className="world-container world-fullscreen"
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.95 }}
|
|
transition={{ duration: 0.3 }}
|
|
>
|
|
<Suspense fallback={<WorldPlaceholder title={t('worlds.loadingIslands')} subtitle={t('worlds.enteringMetaverse')} />}>
|
|
<IslandsWorld
|
|
theme={theme}
|
|
onIslandClick={handleIslandClick}
|
|
onOpenMap={handleOpenMap}
|
|
onOpenPlanets={handleOpenPlanets}
|
|
/>
|
|
</Suspense>
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* PLANETS WORLD - Group Metaverse */}
|
|
{currentView === "planets" && (
|
|
<motion.div
|
|
key="planets-world"
|
|
className="world-container world-fullscreen"
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.95 }}
|
|
transition={{ duration: 0.3 }}
|
|
>
|
|
<Suspense fallback={<WorldPlaceholder title={t('worlds.loadingPlanets')} subtitle={t('worlds.enteringMetaverse')} />}>
|
|
<PlanetsWorld
|
|
theme={theme}
|
|
onOpenMap={handleOpenMap}
|
|
onOpenIslands={handleOpenIslands}
|
|
/>
|
|
</Suspense>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Chat System - available in all worlds */}
|
|
<ChatContainer />
|
|
</div>
|
|
|
|
{/* Footer only shows in map view */}
|
|
{currentView === "map" && (
|
|
<footer className="sw-footer">
|
|
<div className="sw-footer-inner">
|
|
<div className="sw-footer-brand">SOCIOWIRE</div>
|
|
<div className="sw-footer-brand">© Sociowire v{appVersion}</div>
|
|
<div id="sw-weather-debug" style={{color: '#0ea5e9', fontSize: '10px'}}></div>
|
|
<nav className="sw-footer-nav" aria-label="Site">
|
|
<a href="/">{t('nav.home')}</a>
|
|
<a href="/#map">{t('nav.liveMap')}</a>
|
|
<a href="/#wall">{t('nav.sociowall')}</a>
|
|
<a href="/#islands">{t('nav.islands')}</a>
|
|
<a href="/about.html">{t('nav.about')}</a>
|
|
<a href="/contact.html">{t('nav.contact')}</a>
|
|
<a href="/privacy.html">{t('nav.privacy')}</a>
|
|
</nav>
|
|
</div>
|
|
</footer>
|
|
)}
|
|
|
|
{/* Island Viewer Modal */}
|
|
{selectedIsland && (
|
|
<IslandViewer
|
|
island={selectedIsland}
|
|
onClose={() => setSelectedIsland(null)}
|
|
/>
|
|
)}
|
|
|
|
{/* Post Detail Modal (from map markers) */}
|
|
<AnimatePresence>
|
|
{detailPost && (
|
|
<PostDetailModal
|
|
key="post-detail"
|
|
post={detailPost}
|
|
onClose={() => setDetailPost(null)}
|
|
onPostUpdated={(updated) => {
|
|
handlePostUpdated(updated);
|
|
setDetailPost((prev) => (prev ? { ...prev, ...updated } : null));
|
|
}}
|
|
onPostDeleted={(postId) => {
|
|
handlePostDeleted(postId);
|
|
setDetailPost(null);
|
|
}}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Post Composer Modal (for island posts) */}
|
|
<AnimatePresence>
|
|
{showPostComposer && (
|
|
<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
|
|
if (currentView === 'map') {
|
|
setWallPosts((prev) => [newPost, ...prev]);
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Floating Action Button for creating posts */}
|
|
<CreatePostFAB />
|
|
|
|
{/* App Store Modal */}
|
|
<AnimatePresence>
|
|
{showAppStore && (
|
|
<AppStore
|
|
onClose={() => setShowAppStore(false)}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|