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 TopBar from "./components/Layout/TopBar";
|
||||
import PostList from "./components/Posts/PostList";
|
||||
import ContentFilters from "./components/Filters/ContentFilters";
|
||||
import IslandUniverse from "./components/Island/IslandUniverse";
|
||||
import IslandViewer from "./components/Island/IslandViewer";
|
||||
import ChatContainer from "./components/Chat/ChatContainer";
|
||||
|
|
@ -10,6 +11,7 @@ import { fetchIslandByUsername } from "./api/client";
|
|||
const MapView = React.lazy(() => import("./components/Map/MapView"));
|
||||
|
||||
const THEME_KEY = "sociowire:theme";
|
||||
const CONTENT_FILTER_KEY = "sociowire:contentFilter";
|
||||
|
||||
export default function App() {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -21,10 +23,30 @@ export default function App() {
|
|||
const [wallError, setWallError] = useState("");
|
||||
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
|
||||
const [showUniverse, setShowUniverse] = useState(false);
|
||||
const [selectedIsland, setSelectedIsland] = useState(null);
|
||||
|
||||
|
||||
const handlePostsState = useCallback((next) => {
|
||||
if (!next) return;
|
||||
if (Array.isArray(next.posts)) setWallPosts(next.posts);
|
||||
|
|
@ -158,6 +180,7 @@ export default function App() {
|
|||
>
|
||||
<MapView
|
||||
theme={theme}
|
||||
contentFilter={contentFilter}
|
||||
onPostsState={handlePostsState}
|
||||
selectedPost={selectedPost}
|
||||
onSelectPost={setSelectedPost}
|
||||
|
|
@ -170,9 +193,17 @@ export default function App() {
|
|||
|
||||
{/* BELOW THE MAP (page scroll like Facebook) */}
|
||||
<div className="below-stage">
|
||||
{/* Content filters - shared between map and sociowall */}
|
||||
<ContentFilters
|
||||
activeFilter={contentFilter}
|
||||
onFilterChange={setContentFilter}
|
||||
/>
|
||||
<div className="below-row">
|
||||
<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
|
||||
posts={wallPosts}
|
||||
loading={wallLoading}
|
||||
|
|
@ -181,9 +212,9 @@ export default function App() {
|
|||
onSelectPost={setSelectedPost}
|
||||
onPostDeleted={handlePostDeleted}
|
||||
onPostUpdated={handlePostUpdated}
|
||||
contentFilter={contentFilter}
|
||||
/>
|
||||
</div>
|
||||
{/* CHAT PANEL SUPPRIMÉ */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -209,7 +240,7 @@ export default function App() {
|
|||
<footer className="sw-footer">
|
||||
<div className="sw-footer-inner">
|
||||
<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>
|
||||
<nav className="sw-footer-nav" aria-label="Site">
|
||||
<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.sub_category) qs.set("sub_category", String(params.sub_category));
|
||||
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
|
||||
if (params.bounds) {
|
||||
|
|
@ -1053,7 +1054,8 @@ export async function fetchWeatherByPostId(postId) {
|
|||
}
|
||||
|
||||
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);
|
||||
if (!res.ok) return [];
|
||||
return res.json();
|
||||
|
|
|
|||
|
|
@ -402,10 +402,9 @@ body[data-theme="light"] .contacts-empty {
|
|||
@media (max-width: 640px) {
|
||||
.chat-bottom-bar {
|
||||
right: 8px;
|
||||
left: 8px;
|
||||
left: auto;
|
||||
gap: 6px;
|
||||
/* Handle virtual keyboard */
|
||||
bottom: max(16px, env(safe-area-inset-bottom, 16px));
|
||||
bottom: 12px;
|
||||
}
|
||||
|
||||
.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/filters.css";
|
||||
|
||||
import { createPost, fetchPostPreview, resolveAssetRef, fetchWeatherPosts } from "../../api/client";
|
||||
import { createPost, fetchPostPreview, resolveAssetRef } from "../../api/client";
|
||||
import { LiveKitBroadcast, LiveKitViewerModal } from "../Live";
|
||||
import { WeatherModal } from "../Weather";
|
||||
import {
|
||||
|
|
@ -117,6 +117,7 @@ const LINK_PREVIEW_PLACEHOLDER =
|
|||
|
||||
export default function MapView({
|
||||
theme = "dark",
|
||||
contentFilter = "all",
|
||||
onPostsState,
|
||||
selectedPost,
|
||||
onSelectPost,
|
||||
|
|
@ -131,8 +132,18 @@ export default function MapView({
|
|||
const { containerRef, mapRef, markersRef, expandedElRef, viewParams, hasLastView } =
|
||||
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 [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(() => {
|
||||
try {
|
||||
const v = localStorage.getItem(TIME_FILTER_KEY);
|
||||
|
|
@ -142,7 +153,14 @@ export default function MapView({
|
|||
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 WEATHER_VIEW_KEY = "sociowire:weatherView";
|
||||
const [weatherViewEnabled, setWeatherViewEnabled] = useState(() => {
|
||||
|
|
@ -169,6 +187,18 @@ export default function MapView({
|
|||
} catch {}
|
||||
}, [timeHours]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(MAIN_FILTER_KEY, mainFilter);
|
||||
} catch {}
|
||||
}, [mainFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(SUB_FILTER_KEY, subFilter);
|
||||
} catch {}
|
||||
}, [subFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(WEATHER_VIEW_KEY, weatherViewEnabled ? "true" : "false");
|
||||
|
|
@ -199,8 +229,7 @@ export default function MapView({
|
|||
const [liveCoords, setLiveCoords] = useState(null);
|
||||
// Weather modal state
|
||||
const [activeWeatherPost, setActiveWeatherPost] = useState(null);
|
||||
// Weather cities widget (top left under search)
|
||||
const [weatherCities, setWeatherCities] = useState([]);
|
||||
// Nearest weather city (fetched from /api/weather?lat=X&lon=Y)
|
||||
const [nearestWeather, setNearestWeather] = useState(null);
|
||||
const openedPostRef = useRef(null);
|
||||
const queryPostRef = useRef(false);
|
||||
|
|
@ -438,6 +467,7 @@ export default function MapView({
|
|||
viewParams,
|
||||
mainFilter,
|
||||
subFilter,
|
||||
contentFilter,
|
||||
timeHours,
|
||||
searchQuery,
|
||||
clusterFocus,
|
||||
|
|
@ -1211,81 +1241,79 @@ export default function MapView({
|
|||
return () => map.off("movestart", handleMove);
|
||||
}, [mapRef, searchQuery]);
|
||||
|
||||
// Fetch weather cities on mount
|
||||
useEffect(() => {
|
||||
const loadWeatherCities = async () => {
|
||||
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() : '';
|
||||
// Fetch nearest weather from backend based on map center
|
||||
const fetchNearestWeatherRef = useRef(null);
|
||||
const lastWeatherFetchRef = useRef({ lat: null, lon: null });
|
||||
|
||||
// Detect weather icon from emoji in title
|
||||
let icon = '🌡️';
|
||||
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 = '🌨️';
|
||||
else if (title.includes('⛈️')) icon = '⛈️';
|
||||
const fetchNearestWeather = useCallback(async () => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
return { ...p, city, temp, icon };
|
||||
}).filter(c => c.city && c.temp !== null);
|
||||
try {
|
||||
const center = map.getCenter();
|
||||
if (!center) return;
|
||||
|
||||
setWeatherCities(cities);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Weather] Failed to load cities:', err);
|
||||
// Skip if we already fetched for similar position
|
||||
const last = lastWeatherFetchRef.current;
|
||||
if (last.lat !== null) {
|
||||
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();
|
||||
// Refresh every 10 minutes
|
||||
const interval = setInterval(loadWeatherCities, 10 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
lastWeatherFetchRef.current = { lat: center.lat, lon: center.lng };
|
||||
|
||||
// Call /api/weather?lat=X&lon=Y which returns nearest city from weather_data
|
||||
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(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || weatherCities.length === 0) return;
|
||||
if (!map) return;
|
||||
|
||||
const updateNearest = () => {
|
||||
try {
|
||||
const center = map.getCenter();
|
||||
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 {}
|
||||
const handleMoveEnd = () => {
|
||||
if (fetchNearestWeatherRef.current) clearTimeout(fetchNearestWeatherRef.current);
|
||||
fetchNearestWeatherRef.current = setTimeout(fetchNearestWeather, 500);
|
||||
};
|
||||
|
||||
updateNearest();
|
||||
map.on("idle", updateNearest);
|
||||
return () => map.off("idle", updateNearest);
|
||||
}, [mapRef, weatherCities]);
|
||||
map.on('moveend', handleMoveEnd);
|
||||
|
||||
// Initial fetch
|
||||
fetchNearestWeather();
|
||||
|
||||
return () => {
|
||||
map.off('moveend', handleMoveEnd);
|
||||
if (fetchNearestWeatherRef.current) clearTimeout(fetchNearestWeatherRef.current);
|
||||
};
|
||||
}, [fetchNearestWeather]);
|
||||
|
||||
const handleProfileVisitIsland = (island) => {
|
||||
console.log('[MapView] Visiting island from profile:', island.username);
|
||||
|
|
@ -1953,34 +1981,36 @@ export default function MapView({
|
|||
<FilterButtons active={mainFilter} onSelect={handleMainFilterSelect} />
|
||||
</div>
|
||||
|
||||
<div className="map-overlay map-overlay-bottom">
|
||||
<div className="sw-bottom-row">
|
||||
{bottomCategories.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={
|
||||
"sw-bottom-item" + (subFilter === c ? " sw-bottom-active" : "")
|
||||
}
|
||||
onClick={() => setSubFilter(c)}
|
||||
>
|
||||
<div className="sw-bottom-circle">
|
||||
{(() => {
|
||||
const ic = getSubcatIcon(mainFilter, c);
|
||||
return ic ? (
|
||||
<i className={ic} />
|
||||
) : c === "All" ? (
|
||||
"All"
|
||||
) : (
|
||||
c.slice(0, 2)
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="sw-bottom-label">{c}</div>
|
||||
</button>
|
||||
))}
|
||||
{mainFilter !== "All" && (
|
||||
<div className="map-overlay map-overlay-bottom">
|
||||
<div className="sw-bottom-row">
|
||||
{bottomCategories.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={
|
||||
"sw-bottom-item" + (subFilter === c ? " sw-bottom-active" : "")
|
||||
}
|
||||
onClick={() => setSubFilter(c)}
|
||||
>
|
||||
<div className="sw-bottom-circle">
|
||||
{(() => {
|
||||
const ic = getSubcatIcon(mainFilter, c);
|
||||
return ic ? (
|
||||
<i className={ic} />
|
||||
) : c === "All" ? (
|
||||
"All"
|
||||
) : (
|
||||
c.slice(0, 2)
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="sw-bottom-label">{c}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCreating && modeChosen && (
|
||||
<div className="map-overlay map-crosshair">
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { createMarkerForPost, createSimpleMarkerForPost } from "./markerManager"
|
|||
import { getTemplateKeyForPost } from "./templateSpecs";
|
||||
import { prioritizePosts } from "./postPriority";
|
||||
import { trackEvent } from "../../utils/analytics";
|
||||
import { matchesContentFilter } from "../Filters/ContentFilters";
|
||||
|
||||
function getId(p) {
|
||||
return p?.id ?? p?._id ?? null;
|
||||
|
|
@ -373,6 +374,7 @@ export function usePostsEngine({
|
|||
viewParams,
|
||||
mainFilter,
|
||||
subFilter,
|
||||
contentFilter = "all",
|
||||
timeHours,
|
||||
searchQuery,
|
||||
clusterFocus,
|
||||
|
|
@ -837,7 +839,7 @@ export function usePostsEngine({
|
|||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
replacePoolRef.current = true;
|
||||
pendingFilterRef.current = `${mainFilter}|${subFilter}|${timeHours}`;
|
||||
pendingFilterRef.current = `${mainFilter}|${subFilter}|${contentFilter}|${timeHours}`;
|
||||
allPostsRef.current = [];
|
||||
autoWidenRef.current = true; // Prevent auto-widen when user manually changes filter (keep their time choice)
|
||||
if (map && clustersEnabled && heatmapEnabled) {
|
||||
|
|
@ -851,7 +853,7 @@ export function usePostsEngine({
|
|||
} catch {}
|
||||
}, 60);
|
||||
}
|
||||
}, [mainFilter, subFilter, timeHours, mapRef, clustersEnabled, heatmapEnabled, updateClusterAreas]);
|
||||
}, [mainFilter, subFilter, contentFilter, timeHours, mapRef, clustersEnabled, heatmapEnabled, updateClusterAreas]);
|
||||
|
||||
const computeVisible = useCallback(
|
||||
(tf) => {
|
||||
|
|
@ -868,6 +870,9 @@ export function usePostsEngine({
|
|||
const isClusterFocus = !!(clustersEnabled && clusterIDs && clusterIDs.length > 0);
|
||||
|
||||
return posts.filter((p) => {
|
||||
// Apply content filter (links, video, image, live, event)
|
||||
if (!matchesContentFilter(p, contentFilter)) return false;
|
||||
|
||||
if (isClusterFocus) {
|
||||
const id = Number(getId(p));
|
||||
const clusterPostId = Number(p?.cluster_post_id);
|
||||
|
|
@ -886,7 +891,7 @@ export function usePostsEngine({
|
|||
return true;
|
||||
});
|
||||
},
|
||||
[mainFilter, subFilter, searchQuery, clusterFocus, mainNewsOnly, clustersEnabled]
|
||||
[mainFilter, subFilter, contentFilter, searchQuery, clusterFocus, mainNewsOnly, clustersEnabled]
|
||||
);
|
||||
|
||||
const refreshVisibleAndMarkers = useCallback(
|
||||
|
|
@ -952,7 +957,7 @@ export function usePostsEngine({
|
|||
useEffect(() => {
|
||||
if (pendingFilterRef.current) return;
|
||||
refreshVisibleAndMarkers(timeHours);
|
||||
}, [timeHours, mainFilter, subFilter, searchQuery, clusterFocus, refreshVisibleAndMarkers]);
|
||||
}, [timeHours, mainFilter, subFilter, contentFilter, searchQuery, clusterFocus, refreshVisibleAndMarkers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!clustersEnabled) return;
|
||||
|
|
@ -1082,6 +1087,17 @@ export function usePostsEngine({
|
|||
}
|
||||
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({
|
||||
lat,
|
||||
lon: lng,
|
||||
|
|
@ -1093,6 +1109,7 @@ export function usePostsEngine({
|
|||
time_hours: timeHours || undefined,
|
||||
category: catCode || undefined,
|
||||
sub_category: subCatParam || undefined,
|
||||
content_type: contentTypeParam || undefined,
|
||||
});
|
||||
|
||||
if (cancelled || reqId !== requestSeqRef.current) return;
|
||||
|
|
@ -1201,7 +1218,7 @@ export function usePostsEngine({
|
|||
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(() => {
|
||||
const q = (searchQuery || "").trim();
|
||||
|
|
|
|||
|
|
@ -1,43 +1,31 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PostCard from "./PostCard";
|
||||
import { matchesContentFilter } from "../Filters/ContentFilters";
|
||||
|
||||
function getKmFromPost(post) {
|
||||
return post.km ?? post.distance_km ?? post.distanceKm ?? post.dist_km ?? null;
|
||||
}
|
||||
|
||||
export default function PostList({ posts, loading, error, selectedPost, onSelectPost, onPostDeleted, onPostUpdated }) {
|
||||
const { t } = useTranslation();
|
||||
const [kmFilter, setKmFilter] = useState(1000);
|
||||
const [visibleCount, setVisibleCount] = useState(6);
|
||||
export default function PostList({ posts, loading, error, selectedPost, onSelectPost, onPostDeleted, onPostUpdated, contentFilter = "all" }) {
|
||||
const [visibleCount, setVisibleCount] = useState(1);
|
||||
const listRef = useRef(null);
|
||||
const sentinelRef = useRef(null);
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
posts.filter((p) => {
|
||||
const km = getKmFromPost(p);
|
||||
if (km == null) return true;
|
||||
return km <= kmFilter;
|
||||
}),
|
||||
[posts, kmFilter]
|
||||
() => posts.filter((p) => matchesContentFilter(p, contentFilter)),
|
||||
[posts, contentFilter]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleCount(6);
|
||||
}, [kmFilter]);
|
||||
setVisibleCount(1);
|
||||
}, [contentFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = listRef.current || null;
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const hit = entries && entries[0];
|
||||
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);
|
||||
return () => observer.disconnect();
|
||||
|
|
@ -45,35 +33,20 @@ export default function PostList({ posts, loading, error, selectedPost, onSelect
|
|||
|
||||
return (
|
||||
<div className="post-list" ref={listRef}>
|
||||
<div className="post-list-header">
|
||||
<div className="km-filter">
|
||||
<label>
|
||||
{t('postlist.radius')} <span>{kmFilter} {t('postlist.km')}</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1000}
|
||||
value={kmFilter}
|
||||
onChange={(e) => setKmFilter(Number(e.target.value))}
|
||||
{error && <p className="status-text status-error">{error}</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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{error && <p className="status-text status-error">{error}</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>}
|
||||
{!loading && filtered.length === 0 && <p className="status-text">No posts found</p>}
|
||||
{filtered.length > visibleCount ? <div ref={sentinelRef} className="sw-wall-sentinel" /> : null}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@
|
|||
"contact": "Contact",
|
||||
"privacy": "Privacy"
|
||||
},
|
||||
"wall": {
|
||||
"latestPosts": "Latest posts from around the world"
|
||||
},
|
||||
"topbar": {
|
||||
"wiredToLife": "Wired to life",
|
||||
"installApp": "Install App",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@
|
|||
"contact": "Contacto",
|
||||
"privacy": "Privacidad"
|
||||
},
|
||||
"wall": {
|
||||
"latestPosts": "Últimas publicaciones de todo el mundo"
|
||||
},
|
||||
"topbar": {
|
||||
"wiredToLife": "Conectado a la vida",
|
||||
"installApp": "Instalar App",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@
|
|||
"contact": "Contact",
|
||||
"privacy": "Confidentialité"
|
||||
},
|
||||
"wall": {
|
||||
"latestPosts": "Dernières publications du monde entier"
|
||||
},
|
||||
"topbar": {
|
||||
"wiredToLife": "Connecté à la vie",
|
||||
"installApp": "Installer l'app",
|
||||
|
|
|
|||
|
|
@ -84,57 +84,7 @@
|
|||
color: #38bdf8;
|
||||
}
|
||||
|
||||
/* Bouton Contacts flottant en bas à droite */
|
||||
.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);
|
||||
}
|
||||
/* Chat styles moved to ChatContainer.css */
|
||||
|
||||
.contact-avatar {
|
||||
width: 24px;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
:root{
|
||||
--sw-subcats-nudge: -45px; /* subcats vertical nudge */
|
||||
--sw-below-overlap: 80px; /* sociowall overlaps map */
|
||||
--sw-below-overlap: 120px; /* sociowall overlaps map */
|
||||
}
|
||||
|
||||
.map-page{
|
||||
|
|
@ -117,7 +117,7 @@
|
|||
|
||||
/* Landscape: vh is small, but still keep map tall */
|
||||
@media (orientation: landscape){
|
||||
:root{ --sw-below-overlap: 60px; }
|
||||
:root{ --sw-below-overlap: 90px; }
|
||||
.map-stage{ height: clamp(400px, 80vh, 900px); }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,23 @@
|
|||
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 */
|
||||
.below-row {
|
||||
display: flex;
|
||||
|
|
@ -17,20 +34,19 @@
|
|||
.sociowall-panel {
|
||||
flex: 1 1 100%;
|
||||
width: 100%;
|
||||
max-width: 680px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
min-height: 200px;
|
||||
padding: 1.5rem 1rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
gap: 0.75rem;
|
||||
|
||||
/* Glassmorphism dark gray */
|
||||
background: linear-gradient(
|
||||
165deg,
|
||||
rgba(30, 35, 50, 0.95) 0%,
|
||||
rgba(22, 27, 42, 0.98) 50%,
|
||||
rgba(18, 22, 35, 0.99) 100%
|
||||
180deg,
|
||||
rgba(22, 26, 38, 0.98) 0%,
|
||||
rgba(18, 22, 32, 0.99) 100%
|
||||
);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
|
|
@ -38,14 +54,67 @@
|
|||
/* Rounded corners */
|
||||
border-radius: 24px;
|
||||
|
||||
/* Subtle border glow */
|
||||
border: 1px solid rgba(100, 120, 160, 0.15);
|
||||
box-shadow:
|
||||
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;
|
||||
/* Border */
|
||||
border: 1px solid rgba(100, 120, 160, 0.2);
|
||||
box-shadow: 0 4px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────────
|
||||
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 */
|
||||
.sociowall-header {
|
||||
font-size: 0.85rem;
|
||||
|
|
@ -985,6 +1054,6 @@ body[data-theme="light"] .sw-wall-edit-textarea {
|
|||
}
|
||||
|
||||
.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 */
|
||||
body[data-theme="blue"] .sociowall-panel {
|
||||
background: linear-gradient(
|
||||
165deg,
|
||||
rgba(28, 32, 42, 0.97) 0%,
|
||||
rgba(22, 26, 36, 0.98) 50%,
|
||||
rgba(18, 21, 30, 0.99) 100%
|
||||
180deg,
|
||||
rgba(22, 26, 38, 0.98) 0%,
|
||||
rgba(18, 22, 32, 0.99) 100%
|
||||
);
|
||||
border-color: rgba(80, 90, 110, 0.25);
|
||||
box-shadow:
|
||||
0 4px 32px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.04) inset;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(80, 100, 140, 0.25);
|
||||
box-shadow: 0 4px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
body[data-theme="blue"] .post-card {
|
||||
|
|
|
|||
Loading…
Reference in New Issue