Content filters + SocioWall header + localStorage persistence
- Add shared ContentFilters component (Links, Video, Photos, Blogs, Live, Events) - Server-side content_type filtering via reco-service - SocioWall business-style header inside panel - Save filter preferences to localStorage - Theme-aware filter bar (blue/dark/light themes) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
774839e408
commit
b49dd448c1
37
src/App.jsx
37
src/App.jsx
|
|
@ -2,6 +2,7 @@ import React, { Suspense, useEffect, useState, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import TopBar from "./components/Layout/TopBar";
|
import TopBar from "./components/Layout/TopBar";
|
||||||
import PostList from "./components/Posts/PostList";
|
import PostList from "./components/Posts/PostList";
|
||||||
|
import ContentFilters from "./components/Filters/ContentFilters";
|
||||||
import IslandUniverse from "./components/Island/IslandUniverse";
|
import IslandUniverse from "./components/Island/IslandUniverse";
|
||||||
import IslandViewer from "./components/Island/IslandViewer";
|
import IslandViewer from "./components/Island/IslandViewer";
|
||||||
import ChatContainer from "./components/Chat/ChatContainer";
|
import ChatContainer from "./components/Chat/ChatContainer";
|
||||||
|
|
@ -10,6 +11,7 @@ import { fetchIslandByUsername } from "./api/client";
|
||||||
const MapView = React.lazy(() => import("./components/Map/MapView"));
|
const MapView = React.lazy(() => import("./components/Map/MapView"));
|
||||||
|
|
||||||
const THEME_KEY = "sociowire:theme";
|
const THEME_KEY = "sociowire:theme";
|
||||||
|
const CONTENT_FILTER_KEY = "sociowire:contentFilter";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -21,10 +23,30 @@ export default function App() {
|
||||||
const [wallError, setWallError] = useState("");
|
const [wallError, setWallError] = useState("");
|
||||||
const [selectedPost, setSelectedPost] = useState(null);
|
const [selectedPost, setSelectedPost] = useState(null);
|
||||||
|
|
||||||
|
// Content filter (shared between map and sociowall) - persisted
|
||||||
|
// "all" | "links" | "video" | "image" | "text" | "live" | "event"
|
||||||
|
const [contentFilter, setContentFilter] = useState(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(CONTENT_FILTER_KEY);
|
||||||
|
if (saved && ["all", "links", "video", "image", "text", "live", "event"].includes(saved)) {
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return "all";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save content filter to localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CONTENT_FILTER_KEY, contentFilter);
|
||||||
|
} catch {}
|
||||||
|
}, [contentFilter]);
|
||||||
|
|
||||||
// Island Universe & Island Viewer state
|
// Island Universe & Island Viewer state
|
||||||
const [showUniverse, setShowUniverse] = useState(false);
|
const [showUniverse, setShowUniverse] = useState(false);
|
||||||
const [selectedIsland, setSelectedIsland] = useState(null);
|
const [selectedIsland, setSelectedIsland] = useState(null);
|
||||||
|
|
||||||
|
|
||||||
const handlePostsState = useCallback((next) => {
|
const handlePostsState = useCallback((next) => {
|
||||||
if (!next) return;
|
if (!next) return;
|
||||||
if (Array.isArray(next.posts)) setWallPosts(next.posts);
|
if (Array.isArray(next.posts)) setWallPosts(next.posts);
|
||||||
|
|
@ -158,6 +180,7 @@ export default function App() {
|
||||||
>
|
>
|
||||||
<MapView
|
<MapView
|
||||||
theme={theme}
|
theme={theme}
|
||||||
|
contentFilter={contentFilter}
|
||||||
onPostsState={handlePostsState}
|
onPostsState={handlePostsState}
|
||||||
selectedPost={selectedPost}
|
selectedPost={selectedPost}
|
||||||
onSelectPost={setSelectedPost}
|
onSelectPost={setSelectedPost}
|
||||||
|
|
@ -170,9 +193,17 @@ export default function App() {
|
||||||
|
|
||||||
{/* BELOW THE MAP (page scroll like Facebook) */}
|
{/* BELOW THE MAP (page scroll like Facebook) */}
|
||||||
<div className="below-stage">
|
<div className="below-stage">
|
||||||
|
{/* Content filters - shared between map and sociowall */}
|
||||||
|
<ContentFilters
|
||||||
|
activeFilter={contentFilter}
|
||||||
|
onFilterChange={setContentFilter}
|
||||||
|
/>
|
||||||
<div className="below-row">
|
<div className="below-row">
|
||||||
<div className="sociowall-panel" id="wall">
|
<div className="sociowall-panel" id="wall">
|
||||||
<div className="sociowall-header">SOCIOWALL</div>
|
{/* SocioWall header inside panel */}
|
||||||
|
<div className="sociowall-header">
|
||||||
|
<h2 className="sociowall-title">SOCIOWALL</h2>
|
||||||
|
</div>
|
||||||
<PostList
|
<PostList
|
||||||
posts={wallPosts}
|
posts={wallPosts}
|
||||||
loading={wallLoading}
|
loading={wallLoading}
|
||||||
|
|
@ -181,9 +212,9 @@ export default function App() {
|
||||||
onSelectPost={setSelectedPost}
|
onSelectPost={setSelectedPost}
|
||||||
onPostDeleted={handlePostDeleted}
|
onPostDeleted={handlePostDeleted}
|
||||||
onPostUpdated={handlePostUpdated}
|
onPostUpdated={handlePostUpdated}
|
||||||
|
contentFilter={contentFilter}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* CHAT PANEL SUPPRIMÉ */}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -209,7 +240,7 @@ export default function App() {
|
||||||
<footer className="sw-footer">
|
<footer className="sw-footer">
|
||||||
<div className="sw-footer-inner">
|
<div className="sw-footer-inner">
|
||||||
<div className="sw-footer-brand">SOCIOWIRE</div>
|
<div className="sw-footer-brand">SOCIOWIRE</div>
|
||||||
<div className="sw-footer-brand">© Sociowire v0.0009</div>
|
<div className="sw-footer-brand">© Sociowire v0.0010</div>
|
||||||
<div id="sw-weather-debug" style={{color: '#0ea5e9', fontSize: '10px'}}></div>
|
<div id="sw-weather-debug" style={{color: '#0ea5e9', fontSize: '10px'}}></div>
|
||||||
<nav className="sw-footer-nav" aria-label="Site">
|
<nav className="sw-footer-nav" aria-label="Site">
|
||||||
<a href="/">{t('nav.home')}</a>
|
<a href="/">{t('nav.home')}</a>
|
||||||
|
|
|
||||||
|
|
@ -468,6 +468,7 @@ export async function fetchSmartFeed(params = {}) {
|
||||||
if (params.category) qs.set("category", String(params.category));
|
if (params.category) qs.set("category", String(params.category));
|
||||||
if (params.sub_category) qs.set("sub_category", String(params.sub_category));
|
if (params.sub_category) qs.set("sub_category", String(params.sub_category));
|
||||||
if (params.time_hours) qs.set("time_hours", String(params.time_hours));
|
if (params.time_hours) qs.set("time_hours", String(params.time_hours));
|
||||||
|
if (params.content_type) qs.set("content_type", String(params.content_type));
|
||||||
|
|
||||||
// NEW: Screen bounds for on-screen prioritization
|
// NEW: Screen bounds for on-screen prioritization
|
||||||
if (params.bounds) {
|
if (params.bounds) {
|
||||||
|
|
@ -1053,7 +1054,8 @@ export async function fetchWeatherByPostId(postId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchWeatherPosts() {
|
export async function fetchWeatherPosts() {
|
||||||
const url = `${apiBase()}/weather-posts`;
|
// Fetch all weather cities globally (lat=0,lon=0 with huge radius)
|
||||||
|
const url = `${apiBase()}/posts?content_type=weather&lat=0&lon=0&radius_km=50000&limit=100`;
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
if (!res.ok) return [];
|
if (!res.ok) return [];
|
||||||
return res.json();
|
return res.json();
|
||||||
|
|
|
||||||
|
|
@ -402,10 +402,9 @@ body[data-theme="light"] .contacts-empty {
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.chat-bottom-bar {
|
.chat-bottom-bar {
|
||||||
right: 8px;
|
right: 8px;
|
||||||
left: 8px;
|
left: auto;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
/* Handle virtual keyboard */
|
bottom: 12px;
|
||||||
bottom: max(16px, env(safe-area-inset-bottom, 16px));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-tab {
|
.chat-tab {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
.content-filters {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
/* Match sociowall panel colors */
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(22, 26, 38, 0.95) 0%,
|
||||||
|
rgba(18, 22, 32, 0.96) 100%
|
||||||
|
);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(100, 120, 160, 0.2);
|
||||||
|
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.3);
|
||||||
|
margin: 0 auto 12px auto;
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-filter-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(148, 163, 184, 0.9);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-filter-btn:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-filter-btn.active {
|
||||||
|
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-filter-btn i {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Colored icons */
|
||||||
|
.content-filter-btn.filter-links i {
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-filter-btn.filter-video i {
|
||||||
|
color: #8b5cf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-filter-btn.filter-image i {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-filter-btn.filter-text i {
|
||||||
|
color: #06b6d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-filter-btn.filter-live i {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-filter-btn.filter-event i {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep white icon when active */
|
||||||
|
.content-filter-btn.active i {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter-specific active colors */
|
||||||
|
.content-filter-btn.filter-live.active {
|
||||||
|
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||||
|
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-filter-btn.filter-video.active {
|
||||||
|
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
|
||||||
|
box-shadow: 0 2px 8px rgba(139, 92, 246, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-filter-btn.filter-image.active {
|
||||||
|
background: linear-gradient(135deg, #10b981, #059669);
|
||||||
|
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-filter-btn.filter-text.active {
|
||||||
|
background: linear-gradient(135deg, #06b6d4, #0891b2);
|
||||||
|
box-shadow: 0 2px 8px rgba(6, 182, 212, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-filter-btn.filter-event.active {
|
||||||
|
background: linear-gradient(135deg, #f59e0b, #d97706);
|
||||||
|
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blue theme (default dark) */
|
||||||
|
body[data-theme="blue"] .content-filters {
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(22, 26, 38, 0.95) 0%,
|
||||||
|
rgba(18, 22, 32, 0.96) 100%
|
||||||
|
);
|
||||||
|
border-color: rgba(100, 120, 160, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme */
|
||||||
|
body[data-theme="dark"] .content-filters {
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(15, 15, 20, 0.95) 0%,
|
||||||
|
rgba(10, 10, 15, 0.96) 100%
|
||||||
|
);
|
||||||
|
border-color: rgba(60, 60, 80, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light theme */
|
||||||
|
body[data-theme="light"] .content-filters {
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(248, 250, 252, 0.95) 0%,
|
||||||
|
rgba(241, 245, 249, 0.96) 100%
|
||||||
|
);
|
||||||
|
border-color: rgba(148, 163, 184, 0.3);
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme="light"] .content-filter-btn {
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme="light"] .content-filter-btn:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.content-filters {
|
||||||
|
padding: 8px 12px;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: calc(100% - 24px);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-filter-btn {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-filter-btn i {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import React from "react";
|
||||||
|
import "./ContentFilters.css";
|
||||||
|
|
||||||
|
const FILTERS = [
|
||||||
|
{ key: "links", icon: "fa-solid fa-link", label: "Links" },
|
||||||
|
{ key: "video", icon: "fa-solid fa-circle-play", label: "Video" },
|
||||||
|
{ key: "image", icon: "fa-solid fa-camera", label: "Photos" },
|
||||||
|
{ key: "text", icon: "fa-solid fa-pen-to-square", label: "Blogs" },
|
||||||
|
{ key: "live", icon: "fa-solid fa-tower-broadcast", label: "Live" },
|
||||||
|
{ key: "event", icon: "fa-regular fa-calendar-days", label: "Events" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ContentFilters({ activeFilter, onFilterChange }) {
|
||||||
|
return (
|
||||||
|
<div className="content-filters">
|
||||||
|
{FILTERS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.key}
|
||||||
|
type="button"
|
||||||
|
className={`content-filter-btn filter-${f.key}${activeFilter === f.key ? " active" : ""}`}
|
||||||
|
onClick={() => onFilterChange(activeFilter === f.key ? "all" : f.key)}
|
||||||
|
>
|
||||||
|
<i className={f.icon} />
|
||||||
|
<span className="filter-label">{f.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export filter matching function for reuse
|
||||||
|
export function matchesContentFilter(post, filter) {
|
||||||
|
if (!filter || filter === "all") return true;
|
||||||
|
const ct = (post.content_type || post.contentType || "").toLowerCase();
|
||||||
|
const cat = (post.category || "").toLowerCase();
|
||||||
|
const hasVideo = !!(post.video_url || post.videoUrl || post.embed_url || post.embedUrl);
|
||||||
|
const hasImage = !!(post.image_small || post.image_medium || post.image_large || (post.media_urls && post.media_urls.length > 0));
|
||||||
|
|
||||||
|
switch (filter) {
|
||||||
|
case "links":
|
||||||
|
return cat === "news" || ct === "news";
|
||||||
|
case "video":
|
||||||
|
return hasVideo || ct === "video" || ct === "camera";
|
||||||
|
case "image":
|
||||||
|
return hasImage && !hasVideo && ct !== "camera";
|
||||||
|
case "text":
|
||||||
|
return ct === "post" || ct === "text" || ct === "blog" || cat === "friends";
|
||||||
|
case "live":
|
||||||
|
return ct === "live" || ct === "stream" || ct === "camera" || hasVideo;
|
||||||
|
case "event":
|
||||||
|
return cat === "event" || cat === "events" || ct === "event";
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ import "../../styles/mapMarkers.css";
|
||||||
import "../../styles/posts.css";
|
import "../../styles/posts.css";
|
||||||
import "../../styles/filters.css";
|
import "../../styles/filters.css";
|
||||||
|
|
||||||
import { createPost, fetchPostPreview, resolveAssetRef, fetchWeatherPosts } from "../../api/client";
|
import { createPost, fetchPostPreview, resolveAssetRef } from "../../api/client";
|
||||||
import { LiveKitBroadcast, LiveKitViewerModal } from "../Live";
|
import { LiveKitBroadcast, LiveKitViewerModal } from "../Live";
|
||||||
import { WeatherModal } from "../Weather";
|
import { WeatherModal } from "../Weather";
|
||||||
import {
|
import {
|
||||||
|
|
@ -117,6 +117,7 @@ const LINK_PREVIEW_PLACEHOLDER =
|
||||||
|
|
||||||
export default function MapView({
|
export default function MapView({
|
||||||
theme = "dark",
|
theme = "dark",
|
||||||
|
contentFilter = "all",
|
||||||
onPostsState,
|
onPostsState,
|
||||||
selectedPost,
|
selectedPost,
|
||||||
onSelectPost,
|
onSelectPost,
|
||||||
|
|
@ -131,8 +132,18 @@ export default function MapView({
|
||||||
const { containerRef, mapRef, markersRef, expandedElRef, viewParams, hasLastView } =
|
const { containerRef, mapRef, markersRef, expandedElRef, viewParams, hasLastView } =
|
||||||
useMapCore(theme);
|
useMapCore(theme);
|
||||||
|
|
||||||
const [mainFilter, setMainFilter] = useState("All");
|
const MAIN_FILTER_KEY = "sociowire:mainFilter";
|
||||||
|
const SUB_FILTER_KEY = "sociowire:subFilter";
|
||||||
const TIME_FILTER_KEY = "sociowire:timeHours";
|
const TIME_FILTER_KEY = "sociowire:timeHours";
|
||||||
|
|
||||||
|
const [mainFilter, setMainFilter] = useState(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(MAIN_FILTER_KEY);
|
||||||
|
if (saved && FILTER_MAIN_CATEGORIES.includes(saved)) return saved;
|
||||||
|
} catch {}
|
||||||
|
return "All";
|
||||||
|
});
|
||||||
|
|
||||||
const [timeHours, setTimeHours] = useState(() => {
|
const [timeHours, setTimeHours] = useState(() => {
|
||||||
try {
|
try {
|
||||||
const v = localStorage.getItem(TIME_FILTER_KEY);
|
const v = localStorage.getItem(TIME_FILTER_KEY);
|
||||||
|
|
@ -142,7 +153,14 @@ export default function MapView({
|
||||||
return 336;
|
return 336;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const [subFilter, setSubFilter] = useState("All");
|
|
||||||
|
const [subFilter, setSubFilter] = useState(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(SUB_FILTER_KEY);
|
||||||
|
if (saved) return saved;
|
||||||
|
} catch {}
|
||||||
|
return "All";
|
||||||
|
});
|
||||||
const [mainNewsOnly, setMainNewsOnly] = useState(false);
|
const [mainNewsOnly, setMainNewsOnly] = useState(false);
|
||||||
const WEATHER_VIEW_KEY = "sociowire:weatherView";
|
const WEATHER_VIEW_KEY = "sociowire:weatherView";
|
||||||
const [weatherViewEnabled, setWeatherViewEnabled] = useState(() => {
|
const [weatherViewEnabled, setWeatherViewEnabled] = useState(() => {
|
||||||
|
|
@ -169,6 +187,18 @@ export default function MapView({
|
||||||
} catch {}
|
} catch {}
|
||||||
}, [timeHours]);
|
}, [timeHours]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(MAIN_FILTER_KEY, mainFilter);
|
||||||
|
} catch {}
|
||||||
|
}, [mainFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SUB_FILTER_KEY, subFilter);
|
||||||
|
} catch {}
|
||||||
|
}, [subFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(WEATHER_VIEW_KEY, weatherViewEnabled ? "true" : "false");
|
localStorage.setItem(WEATHER_VIEW_KEY, weatherViewEnabled ? "true" : "false");
|
||||||
|
|
@ -199,8 +229,7 @@ export default function MapView({
|
||||||
const [liveCoords, setLiveCoords] = useState(null);
|
const [liveCoords, setLiveCoords] = useState(null);
|
||||||
// Weather modal state
|
// Weather modal state
|
||||||
const [activeWeatherPost, setActiveWeatherPost] = useState(null);
|
const [activeWeatherPost, setActiveWeatherPost] = useState(null);
|
||||||
// Weather cities widget (top left under search)
|
// Nearest weather city (fetched from /api/weather?lat=X&lon=Y)
|
||||||
const [weatherCities, setWeatherCities] = useState([]);
|
|
||||||
const [nearestWeather, setNearestWeather] = useState(null);
|
const [nearestWeather, setNearestWeather] = useState(null);
|
||||||
const openedPostRef = useRef(null);
|
const openedPostRef = useRef(null);
|
||||||
const queryPostRef = useRef(false);
|
const queryPostRef = useRef(false);
|
||||||
|
|
@ -438,6 +467,7 @@ export default function MapView({
|
||||||
viewParams,
|
viewParams,
|
||||||
mainFilter,
|
mainFilter,
|
||||||
subFilter,
|
subFilter,
|
||||||
|
contentFilter,
|
||||||
timeHours,
|
timeHours,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
clusterFocus,
|
clusterFocus,
|
||||||
|
|
@ -1211,81 +1241,79 @@ export default function MapView({
|
||||||
return () => map.off("movestart", handleMove);
|
return () => map.off("movestart", handleMove);
|
||||||
}, [mapRef, searchQuery]);
|
}, [mapRef, searchQuery]);
|
||||||
|
|
||||||
// Fetch weather cities on mount
|
// Fetch nearest weather from backend based on map center
|
||||||
useEffect(() => {
|
const fetchNearestWeatherRef = useRef(null);
|
||||||
const loadWeatherCities = async () => {
|
const lastWeatherFetchRef = useRef({ lat: null, lon: null });
|
||||||
try {
|
|
||||||
const posts = await fetchWeatherPosts();
|
|
||||||
if (Array.isArray(posts) && posts.length > 0) {
|
|
||||||
// Parse weather data from posts
|
|
||||||
const cities = posts.map(p => {
|
|
||||||
const title = p.title || '';
|
|
||||||
const tempMatch = title.match(/:\s*(-?\d+)°/);
|
|
||||||
const cityMatch = title.match(/^[^\w]*([A-Za-zÀ-ÿ\s]+):/);
|
|
||||||
const temp = tempMatch ? parseInt(tempMatch[1], 10) : null;
|
|
||||||
const city = cityMatch ? cityMatch[1].trim() : '';
|
|
||||||
|
|
||||||
// Detect weather icon from emoji in title
|
const fetchNearestWeather = useCallback(async () => {
|
||||||
let icon = '🌡️';
|
const map = mapRef.current;
|
||||||
if (title.includes('☀️')) icon = '☀️';
|
if (!map) return;
|
||||||
else if (title.includes('🌙')) icon = '🌙';
|
|
||||||
else if (title.includes('⛅')) icon = '⛅';
|
|
||||||
else if (title.includes('☁️')) icon = '☁️';
|
|
||||||
else if (title.includes('🌫️')) icon = '🌫️';
|
|
||||||
else if (title.includes('🌧️')) icon = '🌧️';
|
|
||||||
else if (title.includes('❄️')) icon = '❄️';
|
|
||||||
else if (title.includes('🌦️')) icon = '🌦️';
|
|
||||||
else if (title.includes('🌨️')) icon = '🌨️';
|
|
||||||
else if (title.includes('⛈️')) icon = '⛈️';
|
|
||||||
|
|
||||||
return { ...p, city, temp, icon };
|
try {
|
||||||
}).filter(c => c.city && c.temp !== null);
|
const center = map.getCenter();
|
||||||
|
if (!center) return;
|
||||||
|
|
||||||
setWeatherCities(cities);
|
// Skip if we already fetched for similar position
|
||||||
}
|
const last = lastWeatherFetchRef.current;
|
||||||
} catch (err) {
|
if (last.lat !== null) {
|
||||||
console.warn('[Weather] Failed to load cities:', err);
|
const dist = Math.sqrt(
|
||||||
|
Math.pow(center.lat - last.lat, 2) + Math.pow(center.lng - last.lon, 2)
|
||||||
|
);
|
||||||
|
if (dist < 0.5) return; // Skip if moved less than ~50km
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
loadWeatherCities();
|
lastWeatherFetchRef.current = { lat: center.lat, lon: center.lng };
|
||||||
// Refresh every 10 minutes
|
|
||||||
const interval = setInterval(loadWeatherCities, 10 * 60 * 1000);
|
// Call /api/weather?lat=X&lon=Y which returns nearest city from weather_data
|
||||||
return () => clearInterval(interval);
|
const url = `${window.VITE_API_BASE_URL || ''}/api/weather?lat=${center.lat}&lon=${center.lng}`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return;
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data && data.city) {
|
||||||
|
// Convert backend response to our format
|
||||||
|
const iconMap = {
|
||||||
|
'sun': '☀️', 'moon': '🌙', 'cloud-sun': '⛅', 'cloud-moon': '⛅',
|
||||||
|
'cloud': '☁️', 'smog': '🌫️', 'cloud-rain': '🌧️', 'cloud-showers-heavy': '🌧️',
|
||||||
|
'snowflake': '❄️', 'cloud-sun-rain': '🌦️', 'cloud-meatball': '🌨️', 'bolt': '⛈️'
|
||||||
|
};
|
||||||
|
const icon = iconMap[data.current?.weather_icon] || '🌡️';
|
||||||
|
const temp = Math.round(data.current?.temperature ?? 0);
|
||||||
|
|
||||||
|
setNearestWeather({
|
||||||
|
city: data.city,
|
||||||
|
temp,
|
||||||
|
icon,
|
||||||
|
lat: data.lat,
|
||||||
|
lon: data.lon,
|
||||||
|
post_id: data.post_id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Weather] Failed to fetch:', err);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Find nearest weather city when map moves
|
// Update weather on map move (debounced)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map || weatherCities.length === 0) return;
|
if (!map) return;
|
||||||
|
|
||||||
const updateNearest = () => {
|
const handleMoveEnd = () => {
|
||||||
try {
|
if (fetchNearestWeatherRef.current) clearTimeout(fetchNearestWeatherRef.current);
|
||||||
const center = map.getCenter();
|
fetchNearestWeatherRef.current = setTimeout(fetchNearestWeather, 500);
|
||||||
if (!center) return;
|
|
||||||
|
|
||||||
let nearest = null;
|
|
||||||
let minDist = Infinity;
|
|
||||||
|
|
||||||
for (const w of weatherCities) {
|
|
||||||
if (w.lat == null || w.lon == null) continue;
|
|
||||||
const dist = Math.sqrt(
|
|
||||||
Math.pow(center.lat - w.lat, 2) + Math.pow(center.lng - w.lon, 2)
|
|
||||||
);
|
|
||||||
if (dist < minDist) {
|
|
||||||
minDist = dist;
|
|
||||||
nearest = w;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nearest) setNearestWeather(nearest);
|
|
||||||
} catch {}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
updateNearest();
|
map.on('moveend', handleMoveEnd);
|
||||||
map.on("idle", updateNearest);
|
|
||||||
return () => map.off("idle", updateNearest);
|
// Initial fetch
|
||||||
}, [mapRef, weatherCities]);
|
fetchNearestWeather();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off('moveend', handleMoveEnd);
|
||||||
|
if (fetchNearestWeatherRef.current) clearTimeout(fetchNearestWeatherRef.current);
|
||||||
|
};
|
||||||
|
}, [fetchNearestWeather]);
|
||||||
|
|
||||||
const handleProfileVisitIsland = (island) => {
|
const handleProfileVisitIsland = (island) => {
|
||||||
console.log('[MapView] Visiting island from profile:', island.username);
|
console.log('[MapView] Visiting island from profile:', island.username);
|
||||||
|
|
@ -1953,34 +1981,36 @@ export default function MapView({
|
||||||
<FilterButtons active={mainFilter} onSelect={handleMainFilterSelect} />
|
<FilterButtons active={mainFilter} onSelect={handleMainFilterSelect} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="map-overlay map-overlay-bottom">
|
{mainFilter !== "All" && (
|
||||||
<div className="sw-bottom-row">
|
<div className="map-overlay map-overlay-bottom">
|
||||||
{bottomCategories.map((c) => (
|
<div className="sw-bottom-row">
|
||||||
<button
|
{bottomCategories.map((c) => (
|
||||||
key={c}
|
<button
|
||||||
type="button"
|
key={c}
|
||||||
className={
|
type="button"
|
||||||
"sw-bottom-item" + (subFilter === c ? " sw-bottom-active" : "")
|
className={
|
||||||
}
|
"sw-bottom-item" + (subFilter === c ? " sw-bottom-active" : "")
|
||||||
onClick={() => setSubFilter(c)}
|
}
|
||||||
>
|
onClick={() => setSubFilter(c)}
|
||||||
<div className="sw-bottom-circle">
|
>
|
||||||
{(() => {
|
<div className="sw-bottom-circle">
|
||||||
const ic = getSubcatIcon(mainFilter, c);
|
{(() => {
|
||||||
return ic ? (
|
const ic = getSubcatIcon(mainFilter, c);
|
||||||
<i className={ic} />
|
return ic ? (
|
||||||
) : c === "All" ? (
|
<i className={ic} />
|
||||||
"All"
|
) : c === "All" ? (
|
||||||
) : (
|
"All"
|
||||||
c.slice(0, 2)
|
) : (
|
||||||
);
|
c.slice(0, 2)
|
||||||
})()}
|
);
|
||||||
</div>
|
})()}
|
||||||
<div className="sw-bottom-label">{c}</div>
|
</div>
|
||||||
</button>
|
<div className="sw-bottom-label">{c}</div>
|
||||||
))}
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{isCreating && modeChosen && (
|
{isCreating && modeChosen && (
|
||||||
<div className="map-overlay map-crosshair">
|
<div className="map-overlay map-crosshair">
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { createMarkerForPost, createSimpleMarkerForPost } from "./markerManager"
|
||||||
import { getTemplateKeyForPost } from "./templateSpecs";
|
import { getTemplateKeyForPost } from "./templateSpecs";
|
||||||
import { prioritizePosts } from "./postPriority";
|
import { prioritizePosts } from "./postPriority";
|
||||||
import { trackEvent } from "../../utils/analytics";
|
import { trackEvent } from "../../utils/analytics";
|
||||||
|
import { matchesContentFilter } from "../Filters/ContentFilters";
|
||||||
|
|
||||||
function getId(p) {
|
function getId(p) {
|
||||||
return p?.id ?? p?._id ?? null;
|
return p?.id ?? p?._id ?? null;
|
||||||
|
|
@ -373,6 +374,7 @@ export function usePostsEngine({
|
||||||
viewParams,
|
viewParams,
|
||||||
mainFilter,
|
mainFilter,
|
||||||
subFilter,
|
subFilter,
|
||||||
|
contentFilter = "all",
|
||||||
timeHours,
|
timeHours,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
clusterFocus,
|
clusterFocus,
|
||||||
|
|
@ -837,7 +839,7 @@ export function usePostsEngine({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
replacePoolRef.current = true;
|
replacePoolRef.current = true;
|
||||||
pendingFilterRef.current = `${mainFilter}|${subFilter}|${timeHours}`;
|
pendingFilterRef.current = `${mainFilter}|${subFilter}|${contentFilter}|${timeHours}`;
|
||||||
allPostsRef.current = [];
|
allPostsRef.current = [];
|
||||||
autoWidenRef.current = true; // Prevent auto-widen when user manually changes filter (keep their time choice)
|
autoWidenRef.current = true; // Prevent auto-widen when user manually changes filter (keep their time choice)
|
||||||
if (map && clustersEnabled && heatmapEnabled) {
|
if (map && clustersEnabled && heatmapEnabled) {
|
||||||
|
|
@ -851,7 +853,7 @@ export function usePostsEngine({
|
||||||
} catch {}
|
} catch {}
|
||||||
}, 60);
|
}, 60);
|
||||||
}
|
}
|
||||||
}, [mainFilter, subFilter, timeHours, mapRef, clustersEnabled, heatmapEnabled, updateClusterAreas]);
|
}, [mainFilter, subFilter, contentFilter, timeHours, mapRef, clustersEnabled, heatmapEnabled, updateClusterAreas]);
|
||||||
|
|
||||||
const computeVisible = useCallback(
|
const computeVisible = useCallback(
|
||||||
(tf) => {
|
(tf) => {
|
||||||
|
|
@ -868,6 +870,9 @@ export function usePostsEngine({
|
||||||
const isClusterFocus = !!(clustersEnabled && clusterIDs && clusterIDs.length > 0);
|
const isClusterFocus = !!(clustersEnabled && clusterIDs && clusterIDs.length > 0);
|
||||||
|
|
||||||
return posts.filter((p) => {
|
return posts.filter((p) => {
|
||||||
|
// Apply content filter (links, video, image, live, event)
|
||||||
|
if (!matchesContentFilter(p, contentFilter)) return false;
|
||||||
|
|
||||||
if (isClusterFocus) {
|
if (isClusterFocus) {
|
||||||
const id = Number(getId(p));
|
const id = Number(getId(p));
|
||||||
const clusterPostId = Number(p?.cluster_post_id);
|
const clusterPostId = Number(p?.cluster_post_id);
|
||||||
|
|
@ -886,7 +891,7 @@ export function usePostsEngine({
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[mainFilter, subFilter, searchQuery, clusterFocus, mainNewsOnly, clustersEnabled]
|
[mainFilter, subFilter, contentFilter, searchQuery, clusterFocus, mainNewsOnly, clustersEnabled]
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshVisibleAndMarkers = useCallback(
|
const refreshVisibleAndMarkers = useCallback(
|
||||||
|
|
@ -952,7 +957,7 @@ export function usePostsEngine({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pendingFilterRef.current) return;
|
if (pendingFilterRef.current) return;
|
||||||
refreshVisibleAndMarkers(timeHours);
|
refreshVisibleAndMarkers(timeHours);
|
||||||
}, [timeHours, mainFilter, subFilter, searchQuery, clusterFocus, refreshVisibleAndMarkers]);
|
}, [timeHours, mainFilter, subFilter, contentFilter, searchQuery, clusterFocus, refreshVisibleAndMarkers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!clustersEnabled) return;
|
if (!clustersEnabled) return;
|
||||||
|
|
@ -1082,6 +1087,17 @@ export function usePostsEngine({
|
||||||
}
|
}
|
||||||
if (limit > 120) limit = 120;
|
if (limit > 120) limit = 120;
|
||||||
|
|
||||||
|
// Map contentFilter to content_type for API
|
||||||
|
let contentTypeParam = undefined;
|
||||||
|
if (contentFilter && contentFilter !== "all") {
|
||||||
|
if (contentFilter === "live") contentTypeParam = "camera";
|
||||||
|
else if (contentFilter === "video") contentTypeParam = "video,camera";
|
||||||
|
else if (contentFilter === "image") contentTypeParam = "image";
|
||||||
|
else if (contentFilter === "text") contentTypeParam = "post";
|
||||||
|
else if (contentFilter === "links") contentTypeParam = "news";
|
||||||
|
else if (contentFilter === "event") contentTypeParam = "event";
|
||||||
|
}
|
||||||
|
|
||||||
let newPosts = await fetchSmartFeed({
|
let newPosts = await fetchSmartFeed({
|
||||||
lat,
|
lat,
|
||||||
lon: lng,
|
lon: lng,
|
||||||
|
|
@ -1093,6 +1109,7 @@ export function usePostsEngine({
|
||||||
time_hours: timeHours || undefined,
|
time_hours: timeHours || undefined,
|
||||||
category: catCode || undefined,
|
category: catCode || undefined,
|
||||||
sub_category: subCatParam || undefined,
|
sub_category: subCatParam || undefined,
|
||||||
|
content_type: contentTypeParam || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (cancelled || reqId !== requestSeqRef.current) return;
|
if (cancelled || reqId !== requestSeqRef.current) return;
|
||||||
|
|
@ -1201,7 +1218,7 @@ export function usePostsEngine({
|
||||||
delayedFetchRef.current = null;
|
delayedFetchRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [viewParams, mapReady, mainFilter, subFilter, mapRef, timeHours, expandedElRef, refreshVisibleAndMarkers, computeVisible, onAutoWidenTimeFilter, syncMarkers, username]);
|
}, [viewParams, mapReady, mainFilter, subFilter, contentFilter, mapRef, timeHours, expandedElRef, refreshVisibleAndMarkers, computeVisible, onAutoWidenTimeFilter, syncMarkers, username]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const q = (searchQuery || "").trim();
|
const q = (searchQuery || "").trim();
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,31 @@
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import PostCard from "./PostCard";
|
import PostCard from "./PostCard";
|
||||||
|
import { matchesContentFilter } from "../Filters/ContentFilters";
|
||||||
|
|
||||||
function getKmFromPost(post) {
|
export default function PostList({ posts, loading, error, selectedPost, onSelectPost, onPostDeleted, onPostUpdated, contentFilter = "all" }) {
|
||||||
return post.km ?? post.distance_km ?? post.distanceKm ?? post.dist_km ?? null;
|
const [visibleCount, setVisibleCount] = useState(1);
|
||||||
}
|
|
||||||
|
|
||||||
export default function PostList({ posts, loading, error, selectedPost, onSelectPost, onPostDeleted, onPostUpdated }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [kmFilter, setKmFilter] = useState(1000);
|
|
||||||
const [visibleCount, setVisibleCount] = useState(6);
|
|
||||||
const listRef = useRef(null);
|
const listRef = useRef(null);
|
||||||
const sentinelRef = useRef(null);
|
const sentinelRef = useRef(null);
|
||||||
|
|
||||||
const filtered = useMemo(
|
const filtered = useMemo(
|
||||||
() =>
|
() => posts.filter((p) => matchesContentFilter(p, contentFilter)),
|
||||||
posts.filter((p) => {
|
[posts, contentFilter]
|
||||||
const km = getKmFromPost(p);
|
|
||||||
if (km == null) return true;
|
|
||||||
return km <= kmFilter;
|
|
||||||
}),
|
|
||||||
[posts, kmFilter]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVisibleCount(6);
|
setVisibleCount(1);
|
||||||
}, [kmFilter]);
|
}, [contentFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = listRef.current || null;
|
|
||||||
const sentinel = sentinelRef.current;
|
const sentinel = sentinelRef.current;
|
||||||
if (!sentinel) return;
|
if (!sentinel) return;
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
const hit = entries && entries[0];
|
const hit = entries && entries[0];
|
||||||
if (!hit || !hit.isIntersecting) return;
|
if (!hit || !hit.isIntersecting) return;
|
||||||
setVisibleCount((c) => Math.min(filtered.length, c + 6));
|
setVisibleCount((c) => Math.min(filtered.length, c + 2));
|
||||||
},
|
},
|
||||||
{ root, rootMargin: "200px 0px", threshold: 0.01 }
|
{ root: null, rootMargin: "400px 0px", threshold: 0 }
|
||||||
);
|
);
|
||||||
observer.observe(sentinel);
|
observer.observe(sentinel);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
|
|
@ -45,35 +33,20 @@ export default function PostList({ posts, loading, error, selectedPost, onSelect
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="post-list" ref={listRef}>
|
<div className="post-list" ref={listRef}>
|
||||||
<div className="post-list-header">
|
{error && <p className="status-text status-error">{error}</p>}
|
||||||
<div className="km-filter">
|
|
||||||
<label>
|
{filtered.slice(0, visibleCount).map((p) => (
|
||||||
{t('postlist.radius')} <span>{kmFilter} {t('postlist.km')}</span>
|
<PostCard
|
||||||
</label>
|
key={p.id ?? p._id}
|
||||||
<input
|
post={p}
|
||||||
type="range"
|
selected={selectedPost && (selectedPost.id ?? selectedPost._id) === (p.id ?? p._id)}
|
||||||
min={0}
|
onSelect={onSelectPost}
|
||||||
max={1000}
|
onPostDeleted={onPostDeleted}
|
||||||
value={kmFilter}
|
onPostUpdated={onPostUpdated}
|
||||||
onChange={(e) => setKmFilter(Number(e.target.value))}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
))}
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <p className="status-text status-error">{error}</p>}
|
{!loading && filtered.length === 0 && <p className="status-text">No posts found</p>}
|
||||||
|
|
||||||
{filtered.slice(0, visibleCount).map((p) => (
|
|
||||||
<PostCard
|
|
||||||
key={p.id ?? p._id}
|
|
||||||
post={p}
|
|
||||||
selected={selectedPost && (selectedPost.id ?? selectedPost._id) === (p.id ?? p._id)}
|
|
||||||
onSelect={onSelectPost}
|
|
||||||
onPostDeleted={onPostDeleted}
|
|
||||||
onPostUpdated={onPostUpdated}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{!loading && filtered.length === 0 && <p className="status-text">{t('postlist.noPostsInRadius')}</p>}
|
|
||||||
{filtered.length > visibleCount ? <div ref={sentinelRef} className="sw-wall-sentinel" /> : null}
|
{filtered.length > visibleCount ? <div ref={sentinelRef} className="sw-wall-sentinel" /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@
|
||||||
"contact": "Contact",
|
"contact": "Contact",
|
||||||
"privacy": "Privacy"
|
"privacy": "Privacy"
|
||||||
},
|
},
|
||||||
|
"wall": {
|
||||||
|
"latestPosts": "Latest posts from around the world"
|
||||||
|
},
|
||||||
"topbar": {
|
"topbar": {
|
||||||
"wiredToLife": "Wired to life",
|
"wiredToLife": "Wired to life",
|
||||||
"installApp": "Install App",
|
"installApp": "Install App",
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@
|
||||||
"contact": "Contacto",
|
"contact": "Contacto",
|
||||||
"privacy": "Privacidad"
|
"privacy": "Privacidad"
|
||||||
},
|
},
|
||||||
|
"wall": {
|
||||||
|
"latestPosts": "Últimas publicaciones de todo el mundo"
|
||||||
|
},
|
||||||
"topbar": {
|
"topbar": {
|
||||||
"wiredToLife": "Conectado a la vida",
|
"wiredToLife": "Conectado a la vida",
|
||||||
"installApp": "Instalar App",
|
"installApp": "Instalar App",
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@
|
||||||
"contact": "Contact",
|
"contact": "Contact",
|
||||||
"privacy": "Confidentialité"
|
"privacy": "Confidentialité"
|
||||||
},
|
},
|
||||||
|
"wall": {
|
||||||
|
"latestPosts": "Dernières publications du monde entier"
|
||||||
|
},
|
||||||
"topbar": {
|
"topbar": {
|
||||||
"wiredToLife": "Connecté à la vie",
|
"wiredToLife": "Connecté à la vie",
|
||||||
"installApp": "Installer l'app",
|
"installApp": "Installer l'app",
|
||||||
|
|
|
||||||
|
|
@ -84,57 +84,7 @@
|
||||||
color: #38bdf8;
|
color: #38bdf8;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bouton Contacts flottant en bas à droite */
|
/* Chat styles moved to ChatContainer.css */
|
||||||
.contacts-float-btn {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 16px;
|
|
||||||
right: 16px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: rgba(30, 41, 59, 0.8);
|
|
||||||
border-radius: 999px;
|
|
||||||
color: #e5e7eb;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
z-index: 51;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-icon { font-size: 16px; }
|
|
||||||
|
|
||||||
/* Liste des contacts (dropdown) */
|
|
||||||
.contacts-dropdown {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 70px;
|
|
||||||
right: 16px;
|
|
||||||
background: rgba(30, 41, 59, 0.95);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 10px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
z-index: 52;
|
|
||||||
box-shadow: 0 10px 20px rgba(0,0,0,0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
color: #e5e7eb;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-item:hover {
|
|
||||||
background: rgba(56,189,248,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-avatar {
|
.contact-avatar {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
:root{
|
:root{
|
||||||
--sw-subcats-nudge: -45px; /* subcats vertical nudge */
|
--sw-subcats-nudge: -45px; /* subcats vertical nudge */
|
||||||
--sw-below-overlap: 80px; /* sociowall overlaps map */
|
--sw-below-overlap: 120px; /* sociowall overlaps map */
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-page{
|
.map-page{
|
||||||
|
|
@ -117,7 +117,7 @@
|
||||||
|
|
||||||
/* Landscape: vh is small, but still keep map tall */
|
/* Landscape: vh is small, but still keep map tall */
|
||||||
@media (orientation: landscape){
|
@media (orientation: landscape){
|
||||||
:root{ --sw-below-overlap: 60px; }
|
:root{ --sw-below-overlap: 90px; }
|
||||||
.map-stage{ height: clamp(400px, 80vh, 900px); }
|
.map-stage{ height: clamp(400px, 80vh, 900px); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,23 @@
|
||||||
SOCIOWALL - Premium Facebook-Style Feed
|
SOCIOWALL - Premium Facebook-Style Feed
|
||||||
═══════════════════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* SocioWall Header */
|
||||||
|
.sociowall-header {
|
||||||
|
text-align: center;
|
||||||
|
padding: 6px 0 10px;
|
||||||
|
border-bottom: 1px solid rgba(100, 120, 160, 0.12);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sociowall-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(148, 163, 184, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
/* Container below map */
|
/* Container below map */
|
||||||
.below-row {
|
.below-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -17,20 +34,19 @@
|
||||||
.sociowall-panel {
|
.sociowall-panel {
|
||||||
flex: 1 1 100%;
|
flex: 1 1 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 680px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
padding: 1.5rem 1rem;
|
padding: 1.25rem 1.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.25rem;
|
gap: 0.75rem;
|
||||||
|
|
||||||
/* Glassmorphism dark gray */
|
/* Glassmorphism dark gray */
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
165deg,
|
180deg,
|
||||||
rgba(30, 35, 50, 0.95) 0%,
|
rgba(22, 26, 38, 0.98) 0%,
|
||||||
rgba(22, 27, 42, 0.98) 50%,
|
rgba(18, 22, 32, 0.99) 100%
|
||||||
rgba(18, 22, 35, 0.99) 100%
|
|
||||||
);
|
);
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
-webkit-backdrop-filter: blur(20px);
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
|
@ -38,14 +54,67 @@
|
||||||
/* Rounded corners */
|
/* Rounded corners */
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
|
|
||||||
/* Subtle border glow */
|
/* Border */
|
||||||
border: 1px solid rgba(100, 120, 160, 0.15);
|
border: 1px solid rgba(100, 120, 160, 0.2);
|
||||||
box-shadow:
|
box-shadow: 0 4px 32px rgba(0, 0, 0, 0.4);
|
||||||
0 4px 32px rgba(0, 0, 0, 0.4),
|
|
||||||
0 0 0 1px rgba(255, 255, 255, 0.03) inset,
|
|
||||||
0 1px 0 rgba(255, 255, 255, 0.05) inset;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
FILTER BAR - Content type filters
|
||||||
|
───────────────────────────────────────────────────────────────────────────── */
|
||||||
|
.sociowall-filters {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
border-bottom: 1px solid rgba(100, 120, 160, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sociowall-filter-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
border-radius: 0;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sociowall-filter-btn:hover {
|
||||||
|
color: #94a3b8;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sociowall-filter-btn.active {
|
||||||
|
color: #60a5fa;
|
||||||
|
border-bottom-color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sociowall-filter-btn i {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sociowall-filter-btn.filter-all i { color: #a78bfa; }
|
||||||
|
.sociowall-filter-btn.filter-news i { color: #60a5fa; }
|
||||||
|
.sociowall-filter-btn.filter-video i { color: #f472b6; }
|
||||||
|
.sociowall-filter-btn.filter-image i { color: #34d399; }
|
||||||
|
.sociowall-filter-btn.filter-live i { color: #f87171; }
|
||||||
|
.sociowall-filter-btn.filter-event i { color: #fbbf24; }
|
||||||
|
|
||||||
|
.sociowall-filter-btn.active i { color: inherit; }
|
||||||
|
|
||||||
/* Header with gradient underline */
|
/* Header with gradient underline */
|
||||||
.sociowall-header {
|
.sociowall-header {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
|
@ -985,6 +1054,6 @@ body[data-theme="light"] .sw-wall-edit-textarea {
|
||||||
}
|
}
|
||||||
|
|
||||||
.sociowall-panel {
|
.sociowall-panel {
|
||||||
max-width: 680px;
|
max-width: 1200px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,13 @@ body[data-theme="dark"] { background:#050816; color:#ffffff; }
|
||||||
/* Blue theme - sociowall uses dark gray glassmorphism */
|
/* Blue theme - sociowall uses dark gray glassmorphism */
|
||||||
body[data-theme="blue"] .sociowall-panel {
|
body[data-theme="blue"] .sociowall-panel {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
165deg,
|
180deg,
|
||||||
rgba(28, 32, 42, 0.97) 0%,
|
rgba(22, 26, 38, 0.98) 0%,
|
||||||
rgba(22, 26, 36, 0.98) 50%,
|
rgba(18, 22, 32, 0.99) 100%
|
||||||
rgba(18, 21, 30, 0.99) 100%
|
|
||||||
);
|
);
|
||||||
border-color: rgba(80, 90, 110, 0.25);
|
border-radius: 24px;
|
||||||
box-shadow:
|
border: 1px solid rgba(80, 100, 140, 0.25);
|
||||||
0 4px 32px rgba(0, 0, 0, 0.5),
|
box-shadow: 0 4px 32px rgba(0, 0, 0, 0.4);
|
||||||
0 0 0 1px rgba(255, 255, 255, 0.04) inset;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-theme="blue"] .post-card {
|
body[data-theme="blue"] .post-card {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue