feat: Simplify truth score display + UI improvements

- Replace complex truth rail with simple badge in post modal
- Truth score now displays like small cards (colored circle with number)
- Remove API calls that were overwriting correct truth scores
- Add vote functionality to badge (click to cycle vote)
- Fix truth score not updating due to WebSocket race conditions
- Various UI color and style improvements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-01-19 12:51:34 +00:00
parent d120aa9d17
commit 1ea2b58066
23 changed files with 2600 additions and 388 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "sociowire-frontend",
"version": "0.0.22",
"version": "0.0.28",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sociowire-frontend",
"version": "0.0.22",
"version": "0.0.28",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.1.0",
"axios": "^1.13.2",

View File

@ -1,7 +1,7 @@
{
"name": "sociowire-frontend",
"private": true,
"version": "0.0.23",
"version": "0.0.48",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",

View File

@ -7,6 +7,9 @@ import ContentFilters from "./components/Filters/ContentFilters";
import IslandUniverse from "./components/Island/IslandUniverse";
import IslandViewer from "./components/Island/IslandViewer";
import ChatContainer from "./components/Chat/ChatContainer";
import PostDetailModal from "./components/Posts/PostDetailModal";
import { CreatePostFAB } from "./components/Posts/PostComposer";
import { AnimatePresence } from "framer-motion";
import { fetchIslandByUsername } from "./api/client";
const MapView = React.lazy(() => import("./components/Map/MapView"));
@ -58,6 +61,8 @@ export default function App() {
const [showUniverse, setShowUniverse] = useState(false);
const [selectedIsland, setSelectedIsland] = useState(null);
// Post detail modal state (triggered from map markers)
const [detailPost, setDetailPost] = useState(null);
const handlePostsState = useCallback((next) => {
if (!next) return;
@ -84,6 +89,12 @@ export default function App() {
);
}, []);
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;
@ -124,6 +135,18 @@ export default function App() {
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);
}, []);
// Island Universe handlers
const handleOpenUniverse = () => {
setShowUniverse(true);
@ -281,6 +304,28 @@ export default function App() {
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>
{/* Floating Action Button for creating posts */}
<CreatePostFAB />
</div>
);
}

View File

@ -384,7 +384,36 @@ export async function fetchPostTruth(postId) {
});
if (!res.ok) return null;
const data = await res.json().catch(() => null);
return data?.truth || null;
if (!data) return null;
// Normalize response - API may return { truth: {...} } or flat object
const truth = data?.truth || data || {};
const voteCount = truth.vote_count ?? truth.total_votes ?? truth.count ?? 0;
const aiScore = truth.ai_score ?? truth.ai ?? null;
const userScore = truth.user_score ?? truth.user ?? null;
// Separate truth_score field (only if not already captured by ai/user)
const explicitTruth = aiScore === null && userScore === null ? (truth.truth_score ?? null) : null;
// Only return if we have actual meaningful data:
// - Must have votes > 0, OR
// - Must have an AI score that's explicitly set (not 0 with no votes)
const hasAI = aiScore !== null && Number.isFinite(aiScore);
const hasUser = userScore !== null && Number.isFinite(userScore);
const hasExplicit = explicitTruth !== null && Number.isFinite(explicitTruth);
// If no real score data, don't override the initial calculated score
if (!hasAI && !hasUser && !hasExplicit) return null;
// If score is 0 with no votes, it's likely uninitialized - don't update
const mainScore = hasUser ? userScore : (hasAI ? aiScore : explicitTruth);
if (mainScore === 0 && voteCount === 0 && !hasAI) return null;
return {
ai_score: hasAI ? aiScore : null,
user_score: hasUser ? userScore : null,
truth_score: mainScore, // Main display score - no fallback to 50
total_votes: voteCount,
vote_count: voteCount,
user_vote: truth.user_vote ?? truth.UserVote ?? null,
};
} catch {
return null;
}
@ -404,7 +433,19 @@ export async function votePostTruth(postId, vote) {
throw new Error(text || "truth vote failed");
}
const data = await res.json().catch(() => ({}));
return data?.truth || null;
// Normalize response - API may return { truth: {...} } or flat object
const truth = data?.truth || data || {};
const voteCount = truth.vote_count ?? truth.total_votes ?? truth.count ?? 0;
const aiScore = truth.ai_score ?? truth.ai ?? truth.truth_score ?? 50;
const userScore = truth.user_score ?? truth.user ?? truth.truth_score ?? 50;
return {
ai_score: aiScore,
user_score: userScore,
truth_score: userScore ?? aiScore, // Main display score
total_votes: voteCount,
vote_count: voteCount,
user_vote: vote, // The vote that was just cast
};
}
export async function updatePostVisibility(postId, payload = {}, token) {
@ -1110,3 +1151,70 @@ export async function changePassword(oldPassword, newPassword) {
return { ok: false, status: 0, json: null, error: err?.message || "error" };
}
}
/**
* Upload media file (image or video) for posts
* Returns { ok, url, thumbnail_url, error }
*/
export async function uploadPostMedia(file, opts = {}) {
if (!file) return { ok: false, error: "No file provided" };
const mediaBase = "https://media.sociowire.com";
const url = `${mediaBase}/api/upload`;
const form = new FormData();
form.append("file", file);
if (opts.type) form.append("type", opts.type); // "image" or "video"
try {
const res = await fetch(url, {
method: "POST",
body: form,
credentials: "include",
headers: { ...authHeaders() },
});
if (!res.ok) {
const text = await res.text().catch(() => "");
return { ok: false, status: res.status, error: text || `HTTP ${res.status}` };
}
const data = await res.json();
return {
ok: true,
url: data.url || data.file_url || "",
thumbnail_url: data.thumbnail_url || data.thumb_url || "",
...data,
};
} catch (err) {
console.warn("uploadPostMedia error:", err);
return { ok: false, error: err?.message || "Upload failed" };
}
}
/**
* Get user's current geolocation
* Returns { ok, lat, lng, error }
*/
export function getCurrentLocation() {
return new Promise((resolve) => {
if (!navigator.geolocation) {
resolve({ ok: false, error: "Geolocation not supported" });
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
resolve({
ok: true,
lat: pos.coords.latitude,
lng: pos.coords.longitude,
accuracy: pos.coords.accuracy,
});
},
(err) => {
resolve({ ok: false, error: err.message || "Location access denied" });
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
);
});
}

View File

@ -41,7 +41,7 @@
width: 28px;
height: 28px;
border-radius: 50%;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
background: linear-gradient(135deg, #3b82f6, #3b82f6);
display: flex;
align-items: center;
justify-content: center;
@ -278,7 +278,7 @@
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
background: linear-gradient(135deg, #3b82f6, #3b82f6);
display: flex;
align-items: center;
justify-content: center;

View File

@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { useAuth } from "../../auth/AuthContext";
import { fetchFriends } from "../../api/client";
import { useChat } from "./useChat";
import ChatWindow from "./ChatWindow";
import ChatWindow from "./ChatWindowNew";
import "./ChatContainer.css";
const ACTIVE_CHATS_KEY = "sociowire:activeChats";

View File

@ -104,7 +104,7 @@
width: 36px;
height: 36px;
border-radius: 50%;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
background: linear-gradient(135deg, #3b82f6, #3b82f6);
display: flex;
align-items: center;
justify-content: center;
@ -179,7 +179,7 @@
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
background: linear-gradient(135deg, #3b82f6, #3b82f6);
display: flex;
align-items: center;
justify-content: center;

View File

@ -0,0 +1,322 @@
import React, { useState, useEffect, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useTranslation } from "react-i18next";
import { formatChatTime } from "../../utils/timezone";
const messageVariants = {
hidden: { opacity: 0, y: 20, scale: 0.95 },
visible: { opacity: 1, y: 0, scale: 1, transition: { duration: 0.2 } },
};
const windowVariants = {
hidden: { opacity: 0, y: 20, scale: 0.95 },
visible: { opacity: 1, y: 0, scale: 1, transition: { duration: 0.3, ease: "easeOut" } },
exit: { opacity: 0, y: 20, scale: 0.95, transition: { duration: 0.2 } },
};
function Avatar({ src, name, online, size = "md" }) {
const sizeClasses = {
sm: "w-8 h-8 text-xs",
md: "w-10 h-10 text-sm",
lg: "w-12 h-12 text-base",
};
return (
<div className={`relative ${sizeClasses[size]} flex-shrink-0`}>
<div className="w-full h-full rounded-full p-0.5 bg-gradient-to-br from-accent via-purple-500 to-pink-500">
{src ? (
<img src={src} alt={name} className="w-full h-full rounded-full object-cover bg-surface-900" />
) : (
<div className="w-full h-full rounded-full bg-surface-800 flex items-center justify-center text-white font-bold">
{(name || "?")[0].toUpperCase()}
</div>
)}
</div>
{online && (
<span className="absolute bottom-0 right-0 w-3 h-3 rounded-full bg-emerald-500 border-2 border-surface-900 shadow-lg" />
)}
</div>
);
}
function TypingIndicator() {
return (
<div className="flex items-center gap-1 px-4 py-2">
<div className="flex items-center gap-1 px-3 py-2 rounded-2xl bg-surface-800/60 border border-surface-700/30">
{[0, 1, 2].map((i) => (
<motion.span
key={i}
className="w-2 h-2 rounded-full bg-accent/70"
animate={{ y: [0, -4, 0] }}
transition={{ duration: 0.5, repeat: Infinity, delay: i * 0.15 }}
/>
))}
</div>
</div>
);
}
export default function ChatWindowNew({
recipientID,
recipientName,
recipientAvatar,
messages = [],
typing = false,
online = false,
onSend,
onTyping,
onClose,
onMinimize,
minimized = false,
showSidebar = false,
friends = [],
presence = {},
unreadCounts = {},
onSelectFriend,
onToggleSidebar,
}) {
const { t } = useTranslation();
const [input, setInput] = useState("");
const messagesEndRef = useRef(null);
const inputRef = useRef(null);
const typingTimeout = useRef(null);
useEffect(() => {
if (!minimized && messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [messages, minimized]);
useEffect(() => {
if (!minimized && inputRef.current) {
inputRef.current.focus({ preventScroll: true });
}
}, [minimized]);
const handleSend = () => {
const text = input.trim();
if (!text) return;
onSend?.(text);
setInput("");
if (typingTimeout.current) clearTimeout(typingTimeout.current);
onTyping?.(false);
};
const handleKeyDown = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleInputChange = (e) => {
setInput(e.target.value);
onTyping?.(true);
if (typingTimeout.current) clearTimeout(typingTimeout.current);
typingTimeout.current = setTimeout(() => onTyping?.(false), 2000);
};
// Minimized state
if (minimized) {
return (
<motion.div
className="fixed bottom-4 right-4 z-[999] cursor-pointer"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
onClick={onMinimize}
>
<div className="flex items-center gap-3 px-4 py-3 rounded-2xl bg-surface-900/95 backdrop-blur-xl border border-surface-700/50 shadow-2xl hover:border-accent/30 transition-colors">
<Avatar src={recipientAvatar} name={recipientName} online={online} size="sm" />
<span className="font-semibold text-surface-100 text-sm">{recipientName}</span>
<motion.button
className="w-6 h-6 rounded-full bg-surface-800 hover:bg-red-500/20 text-surface-400 hover:text-red-400 flex items-center justify-center transition-colors"
onClick={(e) => { e.stopPropagation(); onClose?.(); }}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<i className="fa-solid fa-xmark text-xs" />
</motion.button>
</div>
</motion.div>
);
}
return (
<motion.div
className="fixed bottom-4 right-4 z-[999] flex"
variants={windowVariants}
initial="hidden"
animate="visible"
exit="exit"
>
{/* Sidebar */}
<AnimatePresence>
{showSidebar && (
<motion.div
className="w-16 mr-2 rounded-2xl bg-surface-900/95 backdrop-blur-xl border border-surface-700/50 shadow-2xl overflow-hidden"
initial={{ opacity: 0, x: 20, width: 0 }}
animate={{ opacity: 1, x: 0, width: 64 }}
exit={{ opacity: 0, x: 20, width: 0 }}
>
<div className="flex flex-col gap-2 p-2 max-h-[400px] overflow-y-auto scrollbar-hide">
{friends.map((friend) => {
const friendUsername = friend.username || friend.name;
const avatar = friend.avatar_url || friend.avatar;
const isOnline = presence[friendUsername] || false;
const unread = unreadCounts[friendUsername] || 0;
const isActive = friendUsername === recipientName;
return (
<motion.button
key={friendUsername}
className={`relative p-1 rounded-xl transition-colors ${
isActive
? "bg-accent/20 ring-2 ring-accent/50"
: "hover:bg-surface-800/80"
}`}
onClick={() => onSelectFriend?.(friend)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<Avatar src={avatar} name={friendUsername} online={isOnline} size="md" />
{unread > 0 && (
<span className="absolute -top-1 -right-1 min-w-[18px] h-[18px] rounded-full bg-red-500 text-white text-[10px] font-bold flex items-center justify-center border-2 border-surface-900">
{unread > 9 ? "9+" : unread}
</span>
)}
</motion.button>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Main Window */}
<div className="w-80 sm:w-96 flex flex-col rounded-2xl bg-surface-900/95 backdrop-blur-xl border border-surface-700/50 shadow-2xl overflow-hidden">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-surface-700/50 bg-gradient-to-r from-surface-900 to-surface-800">
<Avatar src={recipientAvatar} name={recipientName} online={online} />
<div className="flex-1 min-w-0">
<div className="font-semibold text-surface-100 truncate">{recipientName}</div>
<div className={`text-xs font-medium ${online ? "text-emerald-400" : "text-surface-500"}`}>
{online ? t("common.online") : t("common.offline")}
</div>
</div>
<div className="flex items-center gap-1">
{onToggleSidebar && (
<motion.button
className={`w-8 h-8 rounded-full flex items-center justify-center transition-colors ${
showSidebar
? "bg-accent/20 text-accent"
: "bg-surface-800/60 text-surface-400 hover:text-surface-200 hover:bg-surface-800"
}`}
onClick={onToggleSidebar}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<i className="fa-solid fa-users text-xs" />
</motion.button>
)}
<motion.button
className="w-8 h-8 rounded-full bg-surface-800/60 text-surface-400 hover:text-surface-200 hover:bg-surface-800 flex items-center justify-center transition-colors"
onClick={onMinimize}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<i className="fa-solid fa-minus text-xs" />
</motion.button>
<motion.button
className="w-8 h-8 rounded-full bg-surface-800/60 text-surface-400 hover:text-red-400 hover:bg-red-500/10 flex items-center justify-center transition-colors"
onClick={onClose}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<i className="fa-solid fa-xmark text-xs" />
</motion.button>
</div>
</div>
{/* Messages */}
<div className="flex-1 h-72 sm:h-80 overflow-y-auto scrollbar-hide p-4 space-y-3 bg-gradient-to-b from-surface-900/50 to-surface-900/80">
{messages.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-surface-500">
<i className="fa-regular fa-comments text-4xl mb-3 opacity-40" />
<p className="text-sm">{t("chat.startConversation")}</p>
</div>
) : (
messages.map((msg, idx) => {
const isMine = msg.sender_name?.toLowerCase() !== recipientName?.toLowerCase();
const isSending = msg.id?.toString().startsWith("temp-");
const isRead = msg.read_at != null;
const isSent = !isSending && msg.id;
return (
<motion.div
key={msg.id || idx}
className={`flex ${isMine ? "justify-end" : "justify-start"}`}
variants={messageVariants}
initial="hidden"
animate="visible"
>
<div
className={`max-w-[75%] px-4 py-2.5 rounded-2xl ${
isMine
? "bg-gradient-to-r from-accent to-purple-600 text-white rounded-br-md"
: "bg-surface-800/80 text-surface-100 border border-surface-700/30 rounded-bl-md"
}`}
>
<p className="text-sm leading-relaxed break-words">{msg.content}</p>
<div className={`flex items-center gap-1.5 mt-1 ${isMine ? "justify-end" : "justify-start"}`}>
<span className={`text-[10px] ${isMine ? "text-white/60" : "text-surface-500"}`}>
{formatChatTime(msg.created_at)}
</span>
{isMine && (
<span className="text-[10px]">
{isSending ? (
<i className="fa-regular fa-clock text-white/50" />
) : isRead ? (
<i className="fa-solid fa-check-double text-sky-300" />
) : isSent ? (
<i className="fa-solid fa-check text-white/60" />
) : null}
</span>
)}
</div>
</div>
</motion.div>
);
})
)}
{typing && <TypingIndicator />}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-3 border-t border-surface-700/50 bg-surface-900/80">
<div className="flex items-center gap-2">
<input
ref={inputRef}
type="text"
className="flex-1 px-4 py-2.5 rounded-xl bg-surface-800/80 border border-surface-700/30 text-surface-100 placeholder-surface-500 text-sm focus:outline-none focus:border-accent/50 transition-colors"
placeholder={t("chat.typeMessage")}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
/>
<motion.button
className="w-10 h-10 rounded-xl bg-gradient-to-r from-accent to-purple-600 text-white flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleSend}
disabled={!input.trim()}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<i className="fa-solid fa-paper-plane text-sm" />
</motion.button>
</div>
</div>
</div>
</motion.div>
);
}

View File

@ -54,7 +54,7 @@
}
.content-filter-btn.filter-video i {
color: #8b5cf6;
color: #3b82f6;
}
.content-filter-btn.filter-image i {
@ -85,7 +85,7 @@
}
.content-filter-btn.filter-video.active {
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
background: linear-gradient(135deg, #3b82f6, #2563eb);
box-shadow: 0 2px 8px rgba(139, 92, 246, 0.4);
}

View File

@ -208,7 +208,7 @@ function UserChip({ authenticated, loading, username, avatarUrl, onClick }) {
>
{/* Avatar */}
<div className="w-7 h-7 rounded-full overflow-hidden
bg-gradient-to-br from-accent-dark via-purple-500 to-pink-500 p-0.5">
bg-gradient-to-br from-blue-500 via-cyan-500 to-blue-400 p-0.5">
{avatarUrl ? (
<img src={toSmallImageUrl(avatarUrl)} alt={username} className="w-full h-full rounded-full object-cover bg-surface-900" />
) : (
@ -473,131 +473,216 @@ export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClic
</div>
)}
{/* Header */}
{/* Header - Premium Social Network Style */}
<motion.header
className="sticky top-0 z-[1000] w-full px-3 py-2
flex items-center justify-between gap-3
bg-gradient-to-r from-surface-950/95 via-surface-900/95 to-surface-950/95
backdrop-blur-xl
border-b border-surface-700/50
shadow-lg"
className="sticky top-0 z-[1000] w-full overflow-hidden"
initial={{ y: -60, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.4, ease: "easeOut" }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
>
{/* Left: Logo & Brand */}
<div className="flex items-center gap-3">
{/* Animated gradient background */}
<div className="absolute inset-0 bg-gradient-to-r from-surface-950 via-surface-900 to-surface-950" />
<motion.div
className="w-10 h-10 sm:w-11 sm:h-11 flex-shrink-0"
whileHover={{ scale: 1.05, rotate: 5 }}
whileTap={{ scale: 0.95 }}
>
<img src="/logo.png" alt="SocioWire" className="w-full h-full object-contain" />
</motion.div>
className="absolute inset-0 opacity-30"
style={{
background: "linear-gradient(90deg, transparent, rgba(139,92,246,0.1), rgba(236,72,153,0.1), transparent)",
backgroundSize: "200% 100%",
}}
animate={{ backgroundPosition: ["0% 0%", "200% 0%"] }}
transition={{ duration: 8, repeat: Infinity, ease: "linear" }}
/>
{/* Glass overlay */}
<div className="absolute inset-0 backdrop-blur-xl bg-black/20" />
{/* Bottom glow line */}
<div className="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-accent/50 to-transparent" />
<div className="flex flex-col leading-tight">
<span className="text-sm sm:text-base font-black text-surface-50 tracking-wide">
<div className="relative flex items-center justify-between gap-2 px-3 py-2.5 sm:px-5 sm:py-3">
{/* Left: Logo & Brand with glow */}
<motion.div
className="flex items-center gap-2 sm:gap-3 cursor-pointer"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{/* Logo with glow effect */}
<div className="relative">
<motion.div
className="absolute inset-0 rounded-2xl bg-gradient-to-br from-blue-500/40 to-cyan-500/40 blur-xl"
animate={{ opacity: [0.5, 0.8, 0.5] }}
transition={{ duration: 3, repeat: Infinity }}
/>
<div className="relative w-10 h-10 sm:w-11 sm:h-11 rounded-2xl
bg-gradient-to-br from-blue-500 to-cyan-500 p-0.5
shadow-lg shadow-accent/30">
<div className="w-full h-full rounded-[14px] bg-surface-900 p-1.5 flex items-center justify-center">
<img src="/logo.png" alt="SocioWire" className="w-full h-full object-contain" />
</div>
</div>
</div>
{/* Brand text */}
<div className="flex flex-col leading-none">
<motion.span
className="text-base sm:text-lg font-black tracking-tight"
style={{
background: "linear-gradient(135deg, #fff 0%, #60a5fa 50%, #38bdf8 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
}}
>
SOCIOWIRE
</span>
<span className="hidden sm:block text-[10px] font-medium text-surface-400">
</motion.span>
<span className="flex items-center gap-1.5 text-[10px] font-medium text-surface-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
{t('topbar.wiredToLife')}
</span>
</div>
</motion.div>
{/* Theme dots - desktop only */}
<div className="hidden md:flex items-center gap-1.5 ml-2">
{THEMES.map((themeKey) => (
{/* Center: Theme selector - desktop */}
<div className="hidden lg:flex items-center gap-1 p-1 rounded-2xl bg-surface-900/60 border border-white/5 backdrop-blur-sm">
{THEMES.map((themeKey, idx) => (
<motion.button
key={themeKey}
type="button"
className={`
w-5 h-5 rounded-full text-[9px] font-bold
border transition-all duration-200
${theme === themeKey
? "border-amber-400 shadow-[0_0_10px_rgba(251,191,36,0.6)] bg-surface-800"
: "border-surface-500 bg-surface-900/80 hover:border-surface-400"
}
`}
title={t(`theme.${themeKey}`)}
className={`relative px-4 py-2 rounded-xl text-xs font-bold uppercase tracking-wider transition-all
${theme === themeKey ? "text-white" : "text-surface-500 hover:text-surface-300"}`}
onClick={() => onChangeTheme?.(themeKey)}
whileHover={{ scale: 1.15 }}
whileTap={{ scale: 0.9 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{t(`theme.${themeKey}`)[0]}
{theme === themeKey && (
<motion.div
className="absolute inset-0 rounded-xl bg-gradient-to-r from-blue-500 to-cyan-500"
layoutId="themeIndicator"
transition={{ type: "spring", stiffness: 500, damping: 35 }}
/>
)}
<span className="relative">{t(`theme.${themeKey}`)}</span>
</motion.button>
))}
</div>
</div>
{/* Right: Actions */}
<div className="flex items-center gap-1 sm:gap-2 px-1 sm:px-2 py-1 sm:py-1.5 rounded-full
bg-surface-900/50 backdrop-blur-sm border border-surface-700/30
flex-shrink-0 max-w-[60vw] sm:max-w-none overflow-x-auto scrollbar-hide">
{/* User chip */}
<UserChip
authenticated={authenticated}
loading={loading}
username={username}
avatarUrl={avatarUrl}
onClick={() => authenticated && onUserIsland?.(username)}
/>
<div className="flex items-center gap-2 sm:gap-2.5">
{/* PWA Install */}
{showInstallBtn && (
<ActionButton
icon="fa-solid fa-download"
color="purple"
label={t('topbar.install')}
color="default"
onClick={() => promptInstall()}
title={t('topbar.installApp')}
/>
)}
{/* Theme - mobile only */}
<div className="md:hidden" ref={themeBtnRef}>
<ActionButton
icon="fa-solid fa-palette"
color="purple"
active={showThemeMenu}
{/* Theme - mobile */}
<div className="lg:hidden" ref={themeBtnRef}>
<motion.button
type="button"
className={`w-9 h-9 rounded-xl flex items-center justify-center transition-all
${showThemeMenu
? "bg-accent/20 text-accent border border-accent/30"
: "bg-surface-800/50 text-surface-400 border border-white/5 hover:text-surface-200 hover:bg-surface-800"
}`}
onClick={toggleThemeMenu}
title={t(`theme.${theme}`)}
/>
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<i className="fa-solid fa-palette text-sm" />
</motion.button>
</div>
{/* Language - icon only on mobile */}
{/* Language */}
<div ref={langBtnRef}>
<ActionButton
icon="fa-solid fa-globe"
color="cyan"
active={showLangMenu}
<motion.button
type="button"
className={`w-9 h-9 rounded-xl flex items-center justify-center transition-all
${showLangMenu
? "bg-accent/20 text-accent border border-accent/30"
: "bg-surface-800/50 text-surface-400 border border-white/5 hover:text-surface-200 hover:bg-surface-800"
}`}
onClick={toggleLangMenu}
title={t('language.selectLanguage')}
/>
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<i className="fa-solid fa-globe text-sm" />
</motion.button>
</div>
{/* Friends */}
{authenticated && (
<ActionButton
icon="fa-solid fa-user-group"
color="amber"
active={showFriends}
badge={pendingRequestsCount}
<motion.button
type="button"
className={`relative w-9 h-9 rounded-xl flex items-center justify-center transition-all
${showFriends
? "bg-amber-500/20 text-amber-400 border border-amber-500/30"
: "bg-surface-800/50 text-surface-400 border border-white/5 hover:text-amber-400 hover:bg-amber-500/10"
}`}
onClick={() => setShowFriends(!showFriends)}
title={t('topbar.friends')}
/>
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<i className="fa-solid fa-user-group text-sm" />
{pendingRequestsCount > 0 && (
<motion.span
className="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1
rounded-full bg-red-500 text-white text-[10px] font-bold
flex items-center justify-center border-2 border-surface-900"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 500 }}
>
{pendingRequestsCount}
</motion.span>
)}
</motion.button>
)}
{/* Auth - icon only on mobile */}
<ActionButton
icon={authenticated ? "fa-solid fa-right-from-bracket" : "fa-solid fa-right-to-bracket"}
color="green"
onClick={() => {
if (authenticated) { trackEvent("logout_click"); logout(); }
else openModal("login");
}}
disabled={loading}
title={authenticated ? t('topbar.logout') : t('topbar.login')}
{/* Divider */}
<div className="hidden sm:block w-px h-6 bg-gradient-to-b from-transparent via-surface-600/50 to-transparent" />
{/* User/Auth */}
{authenticated ? (
<div className="flex items-center gap-2">
<UserChip
authenticated={authenticated}
loading={loading}
username={username}
avatarUrl={avatarUrl}
onClick={() => onUserIsland?.(username)}
/>
<motion.button
type="button"
className="w-9 h-9 rounded-xl bg-surface-800/50 border border-white/5
text-surface-400 hover:text-red-400 hover:border-red-500/30 hover:bg-red-500/10
flex items-center justify-center transition-all"
onClick={() => { trackEvent("logout_click"); logout(); }}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
title={t('topbar.logout')}
>
<i className="fa-solid fa-arrow-right-from-bracket text-sm" />
</motion.button>
</div>
) : (
<motion.button
type="button"
className="relative group flex items-center gap-2 px-4 py-2.5 rounded-xl overflow-hidden
text-white font-bold text-sm"
onClick={() => openModal("login")}
disabled={loading}
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
>
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500 to-teal-500" />
<motion.div
className="absolute inset-0 bg-gradient-to-r from-emerald-400 to-teal-400 opacity-0 group-hover:opacity-100 transition-opacity"
/>
<i className="fa-solid fa-user-plus relative" />
<span className="relative hidden sm:inline">{t('topbar.joinNow')}</span>
</motion.button>
)}
</div>
</div>
</motion.header>
@ -848,7 +933,7 @@ export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClic
/>
<motion.button
type="submit"
className="w-full py-3 rounded-xl bg-gradient-to-r from-accent to-purple-500 text-white font-bold shadow-lg disabled:opacity-50"
className="w-full py-3 rounded-xl bg-gradient-to-r from-blue-500 to-cyan-500 text-white font-bold shadow-lg disabled:opacity-50"
disabled={usernameSaving}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}

View File

@ -63,6 +63,17 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
stopCameras,
} = useDualCamera();
// Handle real-time comments received via WebSocket
const handleDataReceived = useCallback((data, participant) => {
if (data?.type === "comment" && data?.comment) {
setComments(prev => {
// Avoid duplicates
if (prev.some(c => c.id === data.comment.id)) return prev;
return [...prev, data.comment];
});
}
}, []);
const {
isLive,
isConnecting,
@ -71,11 +82,13 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
startBroadcast,
stopBroadcast,
replaceVideoTrack,
sendData,
} = useLiveKit({
roomName,
userID,
username,
token: authToken,
onDataReceived: handleDataReceived,
});
const error = cameraError || liveKitError;
@ -174,51 +187,86 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
if (!livePostId) return;
const items = await fetchPostComments(livePostId, 50);
if (Array.isArray(items)) {
setComments(items);
// Merge with existing comments, keeping local/pending ones
setComments(prev => {
const serverIds = new Set(items.map(c => String(c.id)));
const serverBodies = new Set(items.map(c => c.body?.toLowerCase().trim()));
// Keep local comments that:
// 1. Have underscore in ID (locally generated)
// 2. Are still pending or failed
// 3. Don't have matching body in server response
const localOnly = prev.filter(c => {
const id = String(c.id);
const isLocal = id.includes('_');
const isPending = c._pending || c._failed;
const bodyMatch = serverBodies.has(c.body?.toLowerCase().trim());
return isLocal && (isPending || !bodyMatch);
});
return [...items, ...localOnly];
});
}
}, [livePostId]);
// Initial load + fallback polling (less frequent since we use WebSocket)
useEffect(() => {
if (!isLive || !livePostId) return;
let active = true;
const poll = async () => {
if (!active) return;
await refreshComments();
};
poll();
const timer = setInterval(poll, 4000);
// Initial load
refreshComments();
// Fallback poll every 15s in case WebSocket misses something
const timer = setInterval(() => {
if (active) refreshComments();
}, 15000);
return () => {
active = false;
clearInterval(timer);
};
}, [isLive, livePostId, refreshComments]);
// Add a comment (persist to post immediately) with optimistic UI update
// Add a comment (broadcast via WebSocket + persist to API with retry)
const handleAddComment = useCallback(async () => {
const body = commentInput.trim();
if (!body || !livePostId) return;
setCommentInput("");
// Optimistic update - show comment immediately
const optimisticComment = {
id: `temp_${Date.now()}`,
const commentId = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const newComment = {
id: commentId,
body,
author: username || "You",
created_at: new Date().toISOString(),
_optimistic: true,
_pending: true, // Mark as pending save
};
setComments(prev => [...prev, optimisticComment]);
// Send to server
// Show locally immediately
setComments(prev => [...prev, newComment]);
// Broadcast via WebSocket to all viewers (real-time)
if (sendData) {
sendData({ type: "comment", comment: newComment });
}
// Persist to API with retry
let saved = false;
for (let attempt = 0; attempt < 3 && !saved; attempt++) {
if (attempt > 0) await new Promise(r => setTimeout(r, 1000 * attempt));
const res = await createPostComment(livePostId, body);
if (res?.ok) {
// Refresh to get the real comment with server ID
await refreshComments();
} else {
// Remove optimistic comment on failure
setComments(prev => prev.filter(c => c.id !== optimisticComment.id));
saved = true;
// Update comment to mark as saved
setComments(prev => prev.map(c =>
c.id === commentId ? { ...c, _pending: false } : c
));
}
}, [commentInput, livePostId, refreshComments, username]);
}
if (!saved) {
console.warn("[Live] Failed to save comment after 3 attempts");
// Mark as failed but keep visible
setComments(prev => prev.map(c =>
c.id === commentId ? { ...c, _failed: true } : c
));
}
}, [commentInput, livePostId, username, sendData]);
const handleGoLive = useCallback(async () => {
// Prevent double-clicks and duplicate calls

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Room, RoomEvent } from "livekit-client";
import { Room, RoomEvent, DataPacket_Kind } from "livekit-client";
import { useAuth } from "../../auth/AuthContext";
import { fetchPostComments, createPostComment } from "../../api/client";
import "./Live.css";
@ -47,6 +47,7 @@ export default function LiveKitViewerModal({ post, onClose }) {
name: username || userID,
canPublish: false,
canSubscribe: true,
canPublishData: true, // Allow sending comments via data channel
}),
});
if (!res.ok) {
@ -87,6 +88,24 @@ export default function LiveKitViewerModal({ post, onClose }) {
setViewerCount(0);
});
// Handle real-time comments via data channel
room.on(RoomEvent.DataReceived, (payload, participant) => {
try {
const decoder = new TextDecoder();
const jsonStr = decoder.decode(payload);
const data = JSON.parse(jsonStr);
if (data?.type === "comment" && data?.comment) {
setComments(prev => {
// Avoid duplicates
if (prev.some(c => c.id === data.comment.id)) return prev;
return [...prev, data.comment];
});
}
} catch (err) {
console.warn("[LiveKit Viewer] Failed to parse data:", err);
}
});
await room.connect(LIVEKIT_URL, token);
setViewerCount(room.participants?.size || 0);
} catch (err) {
@ -116,25 +135,36 @@ export default function LiveKitViewerModal({ post, onClose }) {
};
}, [videoTrack]);
// Fetch comments
// Fetch comments (merge with local to preserve optimistic updates)
const refreshComments = useCallback(async () => {
if (!postId) return;
const items = await fetchPostComments(postId, 50);
if (Array.isArray(items)) {
setComments(items);
setComments(prev => {
const serverIds = new Set(items.map(c => String(c.id)));
// Keep local comments (those with underscore in ID that aren't in server response)
const localOnly = prev.filter(c => {
const id = String(c.id);
return id.includes('_') && !serverIds.has(id);
});
// Combine: server comments + local-only comments (avoid duplicates by body)
const serverBodies = new Set(items.map(c => c.body));
const newLocal = localOnly.filter(c => !serverBodies.has(c.body));
return [...items, ...newLocal];
});
}
}, [postId]);
// Poll for new comments
// Initial load + fallback polling (less frequent since we use WebSocket)
useEffect(() => {
if (!postId) return;
let active = true;
const poll = async () => {
if (!active) return;
await refreshComments();
};
poll();
const timer = setInterval(poll, 3000); // Poll every 3 seconds for live
// Initial load
refreshComments();
// Fallback poll every 15s in case WebSocket misses something
const timer = setInterval(() => {
if (active) refreshComments();
}, 15000);
return () => {
active = false;
clearInterval(timer);
@ -146,7 +176,7 @@ export default function LiveKitViewerModal({ post, onClose }) {
commentsEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [comments]);
// Send comment with optimistic update
// Send comment via WebSocket + persist to API
const handleSendComment = useCallback(async () => {
const body = commentInput.trim();
if (!body || !postId || commentSending) return;
@ -154,26 +184,36 @@ export default function LiveKitViewerModal({ post, onClose }) {
setCommentInput("");
setCommentSending(true);
// Optimistic update
const optimisticComment = {
id: `temp_${Date.now()}`,
const commentId = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const newComment = {
id: commentId,
body,
author: username || "You",
created_at: new Date().toISOString(),
_optimistic: true,
};
setComments(prev => [...prev, optimisticComment]);
// Show locally immediately
setComments(prev => [...prev, newComment]);
// Broadcast via WebSocket to all participants (real-time)
if (roomRef.current?.localParticipant) {
try {
const encoder = new TextEncoder();
const payload = encoder.encode(JSON.stringify({ type: "comment", comment: newComment }));
await roomRef.current.localParticipant.publishData(payload, DataPacket_Kind.RELIABLE);
} catch (err) {
console.warn("[LiveKit Viewer] Failed to broadcast comment:", err);
}
}
// Also persist to API (for replay/history)
const res = await createPostComment(postId, body);
setCommentSending(false);
if (res?.ok) {
await refreshComments();
} else {
// Remove optimistic comment on failure
setComments(prev => prev.filter(c => c.id !== optimisticComment.id));
if (!res?.ok) {
console.warn("[LiveKit Viewer] Failed to save comment to API:", res);
}
}, [commentInput, postId, commentSending, username, refreshComments]);
}, [commentInput, postId, commentSending, username]);
const handleClose = useCallback(() => {
if (roomRef.current) {

View File

@ -3,6 +3,7 @@ import {
Room,
RoomEvent,
VideoPresets,
DataPacket_Kind,
createLocalVideoTrack,
createLocalAudioTrack,
} from "livekit-client";
@ -14,7 +15,7 @@ const LIVEKIT_API = "https://stream.sociowire.com";
* Hook for LiveKit-based live streaming
* Much more reliable than raw WebRTC - handles TURN/ICE automatically
*/
export function useLiveKit({ roomName, userID, username, token: authToken }) {
export function useLiveKit({ roomName, userID, username, token: authToken, onDataReceived }) {
const [isLive, setIsLive] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState(null);
@ -24,6 +25,12 @@ export function useLiveKit({ roomName, userID, username, token: authToken }) {
const roomRef = useRef(null);
const localVideoTrackRef = useRef(null);
const localAudioTrackRef = useRef(null);
const onDataReceivedRef = useRef(onDataReceived);
// Keep callback ref up to date
useEffect(() => {
onDataReceivedRef.current = onDataReceived;
}, [onDataReceived]);
// Get LiveKit token from backend
const getToken = useCallback(async (isHost = true) => {
@ -109,6 +116,21 @@ export function useLiveKit({ roomName, userID, username, token: authToken }) {
console.log("[LiveKit] Reconnected");
});
// Handle data channel messages (for live comments)
room.on(RoomEvent.DataReceived, (payload, participant) => {
try {
const decoder = new TextDecoder();
const jsonStr = decoder.decode(payload);
const data = JSON.parse(jsonStr);
console.log("[LiveKit] Data received:", data, "from:", participant?.identity);
if (onDataReceivedRef.current) {
onDataReceivedRef.current(data, participant);
}
} catch (err) {
console.warn("[LiveKit] Failed to parse data:", err);
}
});
// Connect to room
await room.connect(LIVEKIT_URL, token);
console.log("[LiveKit] Connected to room:", room.name);
@ -208,6 +230,21 @@ export function useLiveKit({ roomName, userID, username, token: authToken }) {
}
}, []);
// Send data to all participants (for live comments)
const sendData = useCallback(async (data) => {
if (!roomRef.current) return false;
try {
const encoder = new TextEncoder();
const payload = encoder.encode(JSON.stringify(data));
await roomRef.current.localParticipant.publishData(payload, DataPacket_Kind.RELIABLE);
console.log("[LiveKit] Data sent:", data);
return true;
} catch (err) {
console.error("[LiveKit] Failed to send data:", err);
return false;
}
}, []);
// Replace video track (for camera switch)
const replaceVideoTrack = useCallback(async (newTrack) => {
if (!roomRef.current || !newTrack) return false;
@ -257,6 +294,7 @@ export function useLiveKit({ roomName, userID, username, token: authToken }) {
toggleAudio,
toggleVideo,
replaceVideoTrack,
sendData,
room: roomRef.current,
};
}

View File

@ -1573,6 +1573,15 @@ export default function MapView({
longPressCallbackRef.current = handleOpenCreate;
}, [authenticated, needsEmailVerification, mainFilter]);
// Listen for FAB button click to open post creator
useEffect(() => {
const handleFABClick = () => {
handleOpenCreate();
};
window.addEventListener("sw:openMapPostCreator", handleFABClick);
return () => window.removeEventListener("sw:openMapPostCreator", handleFABClick);
}, [authenticated, needsEmailVerification, mainFilter]);
useEffect(() => {
setUseCustomImage(contentMode !== "link");
}, [contentMode]);

View File

@ -200,22 +200,22 @@ function updateTruthVoteUI(modalWrap, stats, updateScore = false) {
const userEl = modalWrap.querySelector("[data-truth-user]");
const countEl = modalWrap.querySelector("[data-truth-count]");
const aiScore = Number.isFinite(stats.ai_score) ? Math.round(stats.ai_score) : stats.AIScore;
const userScore = Number.isFinite(stats.user_score) ? Math.round(stats.user_score) : stats.UserScore;
const totalVotes = Number.isFinite(stats.total_votes) ? stats.total_votes : stats.TotalVotes;
const aiScore = stats.ai_score !== null && Number.isFinite(stats.ai_score) ? Math.round(stats.ai_score) : null;
const userScore = stats.user_score !== null && Number.isFinite(stats.user_score) ? Math.round(stats.user_score) : null;
const totalVotes = Number.isFinite(stats.total_votes) ? stats.total_votes : (stats.TotalVotes || 0);
if (aiEl) aiEl.textContent = Number.isFinite(aiScore) ? String(aiScore) : "—";
if (userEl) userEl.textContent = Number.isFinite(userScore) ? String(userScore) : "—";
if (countEl) countEl.textContent = `${totalVotes || 0} votes`;
if (aiEl) aiEl.textContent = aiScore !== null ? String(aiScore) : "—";
if (userEl) userEl.textContent = userScore !== null ? String(userScore) : "—";
if (countEl) countEl.textContent = `${totalVotes} votes`;
// Only update the score display if explicitly requested (e.g., after user votes)
if (updateScore) {
// Only update the score display if explicitly requested AND we have a valid score
if (updateScore && stats.truth_score !== null && Number.isFinite(stats.truth_score)) {
const rail = modalWrap.querySelector(".sw-truth-rail");
const marker = rail ? rail.querySelector(".sw-truth-rail-marker") : null;
const label = rail ? rail.querySelector(".sw-truth-rail-score") : null;
const truthScoreValue = Number.isFinite(stats.truth_score) ? Math.round(stats.truth_score) : stats.TruthScore;
if (marker && Number.isFinite(truthScoreValue)) marker.style.top = `${100 - truthScoreValue}%`;
if (label && Number.isFinite(truthScoreValue)) label.textContent = `${truthScoreValue}`;
const truthScoreValue = Math.round(stats.truth_score);
if (marker) marker.style.top = `${100 - truthScoreValue}%`;
if (label) label.textContent = `${truthScoreValue}`;
}
const vote = (stats.user_vote || stats.UserVote || "").toString();
@ -350,7 +350,7 @@ function applyPostDetails(modalWrap, post) {
updateStat(modalWrap, "likes", likeCount);
updateStat(modalWrap, "shares", shareCount);
updateStat(modalWrap, "comments", commentCount);
updateTruth(modalWrap, post);
// Don't update truth here - let fetchPostTruth handle it to avoid race condition
}
function renderComments(container, items) {
@ -1325,13 +1325,34 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
<div class="sw-cluster-body">
<div class="sw-cluster-left">
<div class="sw-cluster-hero">
<div class="sw-truth-rail" data-score="${truth}">
<button class="sw-truth-rail-btn sw-truth-rail-btn-up" type="button" title="Vote TRUE"></button>
<div class="sw-truth-rail-bar">
<div class="sw-truth-rail-marker" style="top:${100 - truth}%"></div>
</div>
<div class="sw-truth-rail-score">${truth}</div>
<button class="sw-truth-rail-btn sw-truth-rail-btn-down" type="button" title="Vote FALSE"></button>
<div class="sw-truth-badge" data-score="${truth}" style="
position: absolute;
top: 12px;
left: 12px;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px 6px;
background: rgba(15, 23, 42, 0.7);
border-radius: 12px;
backdrop-filter: blur(8px);
">
<div class="sw-truth-badge-score" style="
width: 42px;
height: 42px;
border-radius: 50%;
background: ${truthColor(truth)};
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 700;
color: #fff;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
">${truth}</div>
<span style="font-size: 9px; color: rgba(255,255,255,0.6); text-transform: uppercase; letter-spacing: 0.5px;">Truth</span>
</div>
<div class="sw-cluster-hero-media">
${
@ -1407,13 +1428,30 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
<div class="sw-modal-hero-row">
${!isCamera ? `
<div class="sw-truth-rail" data-score="${truth}">
<button class="sw-truth-rail-btn sw-truth-rail-btn-up" type="button" title="Vote TRUE"></button>
<div class="sw-truth-rail-bar">
<div class="sw-truth-rail-marker" style="top:${100 - truth}%"></div>
</div>
<div class="sw-truth-rail-score">${truth}</div>
<button class="sw-truth-rail-btn sw-truth-rail-btn-down" type="button" title="Vote FALSE"></button>
<div class="sw-truth-badge" data-score="${truth}" style="
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px 6px;
background: rgba(15, 23, 42, 0.6);
border-radius: 12px;
backdrop-filter: blur(8px);
">
<div class="sw-truth-badge-score" style="
width: 42px;
height: 42px;
border-radius: 50%;
background: ${truthColor(truth)};
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 700;
color: #fff;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
">${truth}</div>
<span style="font-size: 9px; color: rgba(255,255,255,0.6); text-transform: uppercase; letter-spacing: 0.5px;">Truth</span>
</div>
` : ''}
<div class="sw-modal-hero" ${(isCamera || isVideo) ? 'style="flex:1;max-width:100%;"' : ''}>
@ -1686,12 +1724,6 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
});
}
if (postID > 0) {
fetchPostTruth(postID).then((stats) => {
if (stats) updateTruthVoteUI(modalWrap, stats);
});
}
let liked = false;
const likeBtn = modalWrap.querySelector('[data-action="like"]');
const commentInput = modalWrap.querySelector(".sw-livechat-input");
@ -2007,11 +2039,10 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
});
}
const rail = modalWrap.querySelector(".sw-truth-rail");
if (rail && postID > 0 && !rail.hasAttribute("data-vote-bound")) {
rail.setAttribute("data-vote-bound", "1");
// Track current user vote to prevent duplicate API calls
let currentVote = null; // "true", "false", or null
const truthBadge = modalWrap.querySelector(".sw-truth-badge");
if (truthBadge && postID > 0 && !truthBadge.hasAttribute("data-vote-bound")) {
truthBadge.setAttribute("data-vote-bound", "1");
let currentVote = null;
let isVoting = false;
const doVote = async (vote) => {
@ -2019,54 +2050,35 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
requestAuth("Please sign in or create a free account to vote on truth.");
return;
}
// Skip if already voted same way
if (currentVote === vote) {
return;
}
if (isVoting) {
return;
}
if (currentVote === vote || isVoting) return;
isVoting = true;
rail.style.opacity = "0.5";
rail.style.pointerEvents = "none";
truthBadge.style.opacity = "0.5";
try {
const stats = await votePostTruth(postID, vote);
if (stats) {
currentVote = vote;
updateTruthVoteUI(modalWrap, stats, true);
// Update badge score display
const scoreEl = truthBadge.querySelector(".sw-truth-badge-score");
if (scoreEl && stats.truth_score !== null && Number.isFinite(stats.truth_score)) {
const newScore = Math.round(stats.truth_score);
scoreEl.textContent = String(newScore);
scoreEl.style.background = truthColor(newScore);
}
} catch (err) {
// Silently fail - don't update UI
} finally {
}
} catch {}
isVoting = false;
rail.style.opacity = "1";
rail.style.pointerEvents = "auto";
}
truthBadge.style.opacity = "1";
};
const voteUp = rail.querySelector(".sw-truth-rail-btn-up");
const voteDown = rail.querySelector(".sw-truth-rail-btn-down");
let touchFired = false;
const handleVote = (vote) => (e) => {
e.preventDefault();
// Click on badge cycles vote: none -> true -> false -> none
truthBadge.style.cursor = "pointer";
truthBadge.addEventListener("click", (e) => {
e.stopPropagation();
// Prevent touch+click double fire
if (e.type === "touchend") {
touchFired = true;
setTimeout(() => { touchFired = false; }, 400);
} else if (e.type === "click" && touchFired) {
return;
}
if (isVoting || currentVote === vote) return;
doVote(vote);
};
if (voteUp) {
voteUp.addEventListener("click", handleVote("true"), { passive: false });
voteUp.addEventListener("touchend", handleVote("true"), { passive: false });
}
if (voteDown) {
voteDown.addEventListener("click", handleVote("false"), { passive: false });
voteDown.addEventListener("touchend", handleVote("false"), { passive: false });
}
if (currentVote === null) doVote("true");
else if (currentVote === "true") doVote("false");
else doVote("true"); // Toggle back
});
// Fetch initial vote state
if (isAuthed()) {
fetchPostTruth(postID).then((stats) => {

View File

@ -1447,12 +1447,11 @@ export function usePostsEngine({
payload?.postData ||
payload?.data ||
null;
const truthScore = Number(
truth?.truth_score ??
truth?.TruthScore ??
payload?.truth_score ??
payload?.truthScore
);
const rawTruthScore = truth?.truth_score ?? truth?.TruthScore ?? payload?.truth_score ?? payload?.truthScore;
const truthScore = rawTruthScore !== null && rawTruthScore !== undefined ? Number(rawTruthScore) : NaN;
const voteCount = Number(truth?.vote_count ?? truth?.total_votes ?? payload?.vote_count ?? 0);
// Only consider score valid if: has actual votes, or score is > 0 (not uninitialized)
const hasMeaningfulScore = Number.isFinite(truthScore) && (voteCount > 0 || truthScore > 0);
const applyPatch = (patch) => {
const updated = [];
@ -1469,7 +1468,7 @@ export function usePostsEngine({
if (Number.isFinite(counts.share_count)) next.share_count = counts.share_count;
if (Number.isFinite(counts.comment_count)) next.comment_count = counts.comment_count;
}
if (Number.isFinite(truthScore)) next.truth_score = truthScore;
if (hasMeaningfulScore) next.truth_score = truthScore;
updated.push(next);
changed = true;
}
@ -1517,7 +1516,7 @@ export function usePostsEngine({
applyPatch(postPatch);
try {
if (Number.isFinite(truthScore)) {
if (hasMeaningfulScore) {
const markers = markersRef.current || [];
for (const item of markers) {
if (!item || item.id !== postId) continue;
@ -1547,7 +1546,7 @@ export function usePostsEngine({
mapStat("shares", counts.share_count);
mapStat("comments", counts.comment_count);
}
if (Number.isFinite(truthScore)) {
if (hasMeaningfulScore) {
const marker = wrap.querySelector(".sw-truth-rail-marker");
const label = wrap.querySelector(".sw-truth-rail-score");
if (marker) marker.style.top = `${100 - truthScore}%`;

View File

@ -766,49 +766,72 @@ export default function PostCardNew({ post, selected, onSelect, onPostDeleted, o
)}
{/*
Footer
Footer - Compact action bar with integrated stats
*/}
<div className="flex items-center justify-between gap-4 p-4 border-t border-white/5 bg-surface-900/30">
{/* Stats */}
<div className="flex gap-5">
<StatItem icon="fa-regular fa-heart" count={counts.like} />
<StatItem icon="fa-regular fa-comment" count={counts.comment} />
<StatItem icon="fa-solid fa-share-nodes" count={counts.share} />
<div className="flex items-center justify-between px-4 py-3 border-t border-white/5 bg-surface-900/30">
{/* Main actions with counts */}
<div className="flex items-center gap-1">
<motion.button
type="button"
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-all
${liked
? "bg-red-500/15 text-red-400"
: "text-surface-400 hover:bg-surface-800/60 hover:text-red-400"
}`}
onClick={handleLike}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.95 }}
>
<i className={liked ? "fa-solid fa-heart" : "fa-regular fa-heart"} />
<span>{formatCount(counts.like)}</span>
</motion.button>
<motion.button
type="button"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium
text-surface-400 hover:bg-surface-800/60 hover:text-accent transition-all"
onClick={handleComment}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.95 }}
>
<i className="fa-regular fa-comment" />
<span>{formatCount(counts.comment)}</span>
</motion.button>
<motion.button
type="button"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium
text-surface-400 hover:bg-surface-800/60 hover:text-emerald-400 transition-all"
onClick={handleShare}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.95 }}
>
<i className="fa-solid fa-share-nodes" />
<span>{formatCount(counts.share)}</span>
</motion.button>
</div>
{/* Actions */}
<div className="flex gap-2 flex-wrap justify-end">
<ActionButton
icon="fa-regular fa-heart"
label={t("post.like")}
active={liked}
onClick={handleLike}
/>
<ActionButton
icon="fa-regular fa-comment"
label={t("post.comment")}
onClick={handleComment}
/>
<ActionButton
icon="fa-solid fa-share-nodes"
label={t("post.share")}
onClick={handleShare}
/>
{/* Author actions */}
{isAuthor && (
<>
<ActionButton
icon="fa-solid fa-lock"
active={showPrivacy}
<div className="flex items-center gap-1">
<motion.button
type="button"
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm transition-all
${showPrivacy ? "bg-accent/20 text-accent" : "text-surface-500 hover:bg-surface-800/60 hover:text-surface-300"}`}
onClick={(e) => {
e.stopPropagation();
setShowPrivacy((prev) => !prev);
setShowEditDelete(false);
}}
/>
<ActionButton
icon="fa-solid fa-ellipsis"
active={showEditDelete}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<i className="fa-solid fa-lock" />
</motion.button>
<motion.button
type="button"
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm transition-all
${showEditDelete ? "bg-accent/20 text-accent" : "text-surface-500 hover:bg-surface-800/60 hover:text-surface-300"}`}
onClick={(e) => {
e.stopPropagation();
setShowEditDelete((prev) => !prev);
@ -818,10 +841,13 @@ export default function PostCardNew({ post, selected, onSelect, onPostDeleted, o
setEditSnippet(post?.snippet || "");
}
}}
/>
</>
)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<i className="fa-solid fa-ellipsis" />
</motion.button>
</div>
)}
</div>
{/*

View File

@ -0,0 +1,523 @@
import React, { useState, useCallback, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useTranslation } from "react-i18next";
import { useAuth } from "../../auth/AuthContext";
import { createPost, uploadPostMedia, getCurrentLocation } from "../../api/client";
const CATEGORIES = [
{ id: "general", icon: "fa-circle-dot", label: "General", color: "from-slate-500 to-zinc-500" },
{ id: "news", icon: "fa-newspaper", label: "News", color: "from-blue-500 to-cyan-500" },
{ id: "sports", icon: "fa-futbol", label: "Sports", color: "from-orange-500 to-amber-500" },
{ id: "entertainment", icon: "fa-film", label: "Entertainment", color: "from-pink-500 to-rose-500" },
{ id: "technology", icon: "fa-microchip", label: "Tech", color: "from-emerald-500 to-teal-500" },
{ id: "politics", icon: "fa-landmark", label: "Politics", color: "from-purple-500 to-indigo-500" },
{ id: "traffic", icon: "fa-car", label: "Traffic", color: "from-yellow-500 to-amber-500" },
{ id: "weather", icon: "fa-cloud-sun", label: "Weather", color: "from-sky-500 to-blue-500" },
];
export default function PostComposer({ onClose, onPostCreated }) {
const { t } = useTranslation();
const { token, username, isAuthenticated } = useAuth();
const fileInputRef = useRef(null);
const videoInputRef = useRef(null);
// Form state
const [content, setContent] = useState("");
const [category, setCategory] = useState("general");
const [media, setMedia] = useState([]); // { file, preview, type, uploading, url }
const [location, setLocation] = useState(null); // { lat, lng }
const [locationName, setLocationName] = useState("");
const [isGettingLocation, setIsGettingLocation] = useState(false);
const [posting, setPosting] = useState(false);
const [error, setError] = useState("");
const [step, setStep] = useState("compose"); // compose, category, preview
// Close on escape
useEffect(() => {
const handleKey = (e) => {
if (e.key === "Escape") onClose?.();
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [onClose]);
// Get current location
const handleGetLocation = useCallback(async () => {
setIsGettingLocation(true);
setError("");
const result = await getCurrentLocation();
setIsGettingLocation(false);
if (result.ok) {
setLocation({ lat: result.lat, lng: result.lng });
// Try to get location name via reverse geocoding
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${result.lat}&lon=${result.lng}&format=json`
);
const data = await res.json();
const name = data.address?.city || data.address?.town || data.address?.village || data.display_name?.split(",")[0];
setLocationName(name || `${result.lat.toFixed(4)}, ${result.lng.toFixed(4)}`);
} catch {
setLocationName(`${result.lat.toFixed(4)}, ${result.lng.toFixed(4)}`);
}
} else {
setError(result.error || "Could not get location");
}
}, []);
// Handle file selection
const handleFileSelect = useCallback((e, type = "image") => {
const files = Array.from(e.target.files || []);
if (files.length === 0) return;
const newMedia = files.map((file) => ({
file,
preview: URL.createObjectURL(file),
type: file.type.startsWith("video/") ? "video" : "image",
uploading: false,
url: null,
}));
setMedia((prev) => [...prev, ...newMedia].slice(0, 4)); // Max 4 media items
e.target.value = "";
}, []);
// Remove media item
const removeMedia = useCallback((index) => {
setMedia((prev) => {
const item = prev[index];
if (item?.preview) URL.revokeObjectURL(item.preview);
return prev.filter((_, i) => i !== index);
});
}, []);
// Upload all media
const uploadAllMedia = useCallback(async () => {
const uploaded = [];
for (let i = 0; i < media.length; i++) {
const item = media[i];
if (item.url) {
uploaded.push(item);
continue;
}
setMedia((prev) =>
prev.map((m, idx) => (idx === i ? { ...m, uploading: true } : m))
);
const result = await uploadPostMedia(item.file, { type: item.type });
if (result.ok) {
const updatedItem = { ...item, uploading: false, url: result.url, thumbnail_url: result.thumbnail_url };
uploaded.push(updatedItem);
setMedia((prev) =>
prev.map((m, idx) => (idx === i ? updatedItem : m))
);
} else {
setMedia((prev) =>
prev.map((m, idx) => (idx === i ? { ...m, uploading: false, error: result.error } : m))
);
throw new Error(result.error || "Upload failed");
}
}
return uploaded;
}, [media]);
// Submit post
const handleSubmit = useCallback(async () => {
if (!content.trim() && media.length === 0) {
setError("Please add some content or media");
return;
}
setPosting(true);
setError("");
try {
// Upload media first if any
let uploadedMedia = [];
if (media.length > 0) {
uploadedMedia = await uploadAllMedia();
}
// Build post payload
const payload = {
title: content.slice(0, 100) || "New Post",
snippet: content,
content: content,
category: category.toUpperCase(),
content_type: uploadedMedia.length > 0
? (uploadedMedia[0].type === "video" ? "video" : "image")
: "text",
};
// Add location if available
if (location) {
payload.latitude = location.lat;
payload.longitude = location.lng;
payload.location_name = locationName || "";
}
// Add media URLs
if (uploadedMedia.length > 0) {
const firstMedia = uploadedMedia[0];
if (firstMedia.type === "video") {
payload.video_url = firstMedia.url;
payload.thumbnail_url = firstMedia.thumbnail_url;
} else {
payload.image_url = firstMedia.url;
}
if (uploadedMedia.length > 1) {
payload.media = uploadedMedia.map((m) => ({
url: m.url,
type: m.type,
thumbnail_url: m.thumbnail_url,
}));
}
}
const result = await createPost(payload, token);
if (result && (result.id || result._id)) {
onPostCreated?.(result);
onClose?.();
} else {
throw new Error("Failed to create post");
}
} catch (err) {
setError(err.message || "Failed to create post");
} finally {
setPosting(false);
}
}, [content, media, category, location, locationName, token, uploadAllMedia, onPostCreated, onClose]);
// Cleanup previews on unmount
useEffect(() => {
return () => {
media.forEach((m) => {
if (m.preview) URL.revokeObjectURL(m.preview);
});
};
}, []);
if (!token) {
return (
<motion.div
className="fixed inset-0 z-[1100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
>
<motion.div
className="bg-surface-900 rounded-3xl p-8 max-w-sm text-center border border-white/10"
initial={{ scale: 0.9, y: 20 }}
animate={{ scale: 1, y: 0 }}
onClick={(e) => e.stopPropagation()}
>
<i className="fa-solid fa-lock text-4xl text-surface-500 mb-4" />
<h2 className="text-xl font-bold text-surface-100 mb-2">Login Required</h2>
<p className="text-surface-400 mb-6">You need to be logged in to create posts.</p>
<button
className="px-6 py-3 rounded-xl bg-gradient-to-r from-blue-500 to-cyan-500 text-white font-bold"
onClick={onClose}
>
Close
</button>
</motion.div>
</motion.div>
);
}
return (
<motion.div
className="fixed inset-0 z-[1100] flex items-end sm:items-center justify-center bg-black/80 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
>
<motion.div
className="w-full sm:max-w-lg max-h-[90vh] overflow-hidden rounded-t-3xl sm:rounded-3xl
bg-gradient-to-b from-surface-800 to-surface-900
border border-white/10 shadow-2xl"
initial={{ y: "100%", opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: "100%", opacity: 0 }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-white/10">
<button
className="text-surface-400 hover:text-surface-200 transition-colors"
onClick={onClose}
>
Cancel
</button>
<h2 className="text-lg font-bold text-surface-100">Create Post</h2>
<motion.button
className={`px-4 py-2 rounded-full font-bold text-sm transition-all
${content.trim() || media.length > 0
? "bg-gradient-to-r from-blue-500 to-cyan-500 text-white"
: "bg-surface-700 text-surface-500 cursor-not-allowed"
}`}
whileHover={content.trim() || media.length > 0 ? { scale: 1.05 } : {}}
whileTap={content.trim() || media.length > 0 ? { scale: 0.95 } : {}}
disabled={posting || (!content.trim() && media.length === 0)}
onClick={handleSubmit}
>
{posting ? (
<i className="fa-solid fa-spinner fa-spin" />
) : (
"Post"
)}
</motion.button>
</div>
{/* Content */}
<div className="p-4 overflow-y-auto max-h-[calc(90vh-140px)]">
{/* User info */}
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center text-white text-lg font-bold">
{username?.charAt(0)?.toUpperCase() || "U"}
</div>
<div>
<p className="font-bold text-surface-100">{username || "You"}</p>
<button
className="flex items-center gap-1 text-xs text-accent hover:text-accent/80"
onClick={() => setStep(step === "category" ? "compose" : "category")}
>
<i className={`fa-solid ${CATEGORIES.find(c => c.id === category)?.icon || "fa-circle-dot"}`} />
<span>{CATEGORIES.find(c => c.id === category)?.label || "Category"}</span>
<i className="fa-solid fa-chevron-down text-[10px]" />
</button>
</div>
</div>
{/* Category selector */}
<AnimatePresence>
{step === "category" && (
<motion.div
className="mb-4 p-3 rounded-2xl bg-surface-800/50 border border-white/5"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
>
<p className="text-xs text-surface-500 mb-2 uppercase tracking-wide">Select Category</p>
<div className="grid grid-cols-4 gap-2">
{CATEGORIES.map((cat) => (
<motion.button
key={cat.id}
className={`flex flex-col items-center gap-1 p-3 rounded-xl transition-all
${category === cat.id
? `bg-gradient-to-br ${cat.color} text-white`
: "bg-surface-700/50 text-surface-400 hover:bg-surface-700"
}`}
whileTap={{ scale: 0.95 }}
onClick={() => {
setCategory(cat.id);
setStep("compose");
}}
>
<i className={`fa-solid ${cat.icon} text-lg`} />
<span className="text-[10px] font-medium">{cat.label}</span>
</motion.button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Text input */}
<textarea
className="w-full min-h-[120px] p-0 bg-transparent text-surface-100 text-lg
placeholder:text-surface-500 resize-none focus:outline-none"
placeholder="What's happening?"
value={content}
onChange={(e) => setContent(e.target.value)}
autoFocus
/>
{/* Media preview */}
{media.length > 0 && (
<div className={`grid gap-2 mb-4 ${media.length === 1 ? "grid-cols-1" : "grid-cols-2"}`}>
{media.map((item, index) => (
<div key={index} className="relative rounded-2xl overflow-hidden bg-surface-800 aspect-video">
{item.type === "video" ? (
<video
src={item.preview}
className="w-full h-full object-cover"
controls={false}
muted
/>
) : (
<img
src={item.preview}
alt=""
className="w-full h-full object-cover"
/>
)}
{item.uploading && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
<i className="fa-solid fa-spinner fa-spin text-2xl text-white" />
</div>
)}
<button
className="absolute top-2 right-2 w-8 h-8 rounded-full bg-black/60 text-white
flex items-center justify-center hover:bg-red-500 transition-colors"
onClick={() => removeMedia(index)}
>
<i className="fa-solid fa-times" />
</button>
{item.type === "video" && (
<div className="absolute bottom-2 left-2 px-2 py-1 rounded bg-black/60 text-white text-xs">
<i className="fa-solid fa-video mr-1" />
Video
</div>
)}
</div>
))}
</div>
)}
{/* Location */}
{location && (
<div className="flex items-center gap-2 mb-4 px-3 py-2 rounded-xl bg-surface-800/50 border border-white/5">
<i className="fa-solid fa-location-dot text-accent" />
<span className="text-sm text-surface-300 flex-1">{locationName}</span>
<button
className="text-surface-500 hover:text-red-400"
onClick={() => {
setLocation(null);
setLocationName("");
}}
>
<i className="fa-solid fa-times" />
</button>
</div>
)}
{/* Error */}
{error && (
<div className="mb-4 px-4 py-3 rounded-xl bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
<i className="fa-solid fa-exclamation-triangle mr-2" />
{error}
</div>
)}
</div>
{/* Actions bar */}
<div className="flex items-center gap-2 px-4 py-3 border-t border-white/10 bg-surface-900/50">
{/* Photo */}
<motion.button
className="w-11 h-11 rounded-full bg-surface-800 text-green-400 flex items-center justify-center
hover:bg-green-500/20 transition-colors"
whileTap={{ scale: 0.9 }}
onClick={() => fileInputRef.current?.click()}
disabled={media.length >= 4}
>
<i className="fa-solid fa-image" />
</motion.button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => handleFileSelect(e, "image")}
/>
{/* Video */}
<motion.button
className="w-11 h-11 rounded-full bg-surface-800 text-blue-400 flex items-center justify-center
hover:bg-blue-500/20 transition-colors"
whileTap={{ scale: 0.9 }}
onClick={() => videoInputRef.current?.click()}
disabled={media.length >= 4}
>
<i className="fa-solid fa-video" />
</motion.button>
<input
ref={videoInputRef}
type="file"
accept="video/*"
className="hidden"
onChange={(e) => handleFileSelect(e, "video")}
/>
{/* Location */}
<motion.button
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors
${location
? "bg-accent/20 text-accent"
: "bg-surface-800 text-orange-400 hover:bg-orange-500/20"
}`}
whileTap={{ scale: 0.9 }}
onClick={handleGetLocation}
disabled={isGettingLocation}
>
{isGettingLocation ? (
<i className="fa-solid fa-spinner fa-spin" />
) : (
<i className="fa-solid fa-location-dot" />
)}
</motion.button>
{/* Live (future feature) */}
<motion.button
className="w-11 h-11 rounded-full bg-surface-800 text-red-400 flex items-center justify-center
hover:bg-red-500/20 transition-colors"
whileTap={{ scale: 0.9 }}
onClick={() => {
// Dispatch event to open LiveKit broadcast
window.dispatchEvent(new CustomEvent("livekit-broadcast-open"));
onClose?.();
}}
>
<i className="fa-solid fa-broadcast-tower" />
</motion.button>
<div className="flex-1" />
{/* Character count */}
<span className={`text-xs ${content.length > 500 ? "text-red-400" : "text-surface-500"}`}>
{content.length}/500
</span>
</div>
</motion.div>
</motion.div>
);
}
// Floating Action Button for creating posts - triggers the map's create post flow
export function CreatePostFAB() {
const handleClick = () => {
// Dispatch event to open the map's post creation modal
window.dispatchEvent(new CustomEvent("sw:openMapPostCreator"));
};
return (
<motion.button
className="fixed bottom-4 left-4 z-[998] px-4 py-3 rounded-full
text-white text-sm font-semibold
flex items-center gap-2
border border-surface-600/30 backdrop-blur-xl"
style={{
background: "linear-gradient(135deg, rgba(30, 58, 138, 0.9), rgba(59, 130, 246, 0.85))",
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.3)",
}}
whileHover={{ scale: 1.05, y: -2, boxShadow: "0 6px 20px rgba(59, 130, 246, 0.4)" }}
whileTap={{ scale: 0.95 }}
onClick={handleClick}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", delay: 0.5 }}
>
<i className="fa-solid fa-plus text-lg" />
<span className="hidden sm:inline">Post</span>
</motion.button>
);
}

View File

@ -0,0 +1,856 @@
import React, { useCallback, useEffect, useRef, useState, useMemo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useTranslation } from "react-i18next";
import { useAuth } from "../../auth/AuthContext";
import {
recordPostView,
togglePostLike,
recordPostShare,
fetchPostComments,
createPostComment,
fetchPostLikeState,
fetchPostTruth,
votePostTruth,
editPost,
deletePost,
updatePostVisibility,
} from "../../api/client";
const MEDIA_CDN = "https://media.sociowire.com";
function normalizeMediaUrl(url) {
if (!url) return "";
if (url.startsWith("http://") || url.startsWith("https://")) return url;
if (url.startsWith("/")) return `${MEDIA_CDN}${url}`;
return `${MEDIA_CDN}/${url}`;
}
function formatCount(n) {
if (n == null) return "0";
const num = Number(n) || 0;
if (num >= 1_000_000) return (num / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
if (num >= 1_000) return (num / 1_000).toFixed(1).replace(/\.0$/, "") + "K";
return num.toString();
}
function relativeTime(dateStr) {
if (!dateStr) return "";
const d = new Date(dateStr);
const now = new Date();
const diff = Math.floor((now - d) / 1000);
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
return d.toLocaleDateString();
}
function getCategoryIcon(post) {
const cat = (post?.category || "").toString().toLowerCase();
const icons = {
news: "fa-newspaper",
politics: "fa-landmark",
technology: "fa-microchip",
science: "fa-flask",
health: "fa-heart-pulse",
sports: "fa-futbol",
entertainment: "fa-film",
business: "fa-briefcase",
environment: "fa-leaf",
weather: "fa-cloud-sun",
traffic: "fa-car",
live: "fa-broadcast-tower",
camera: "fa-video",
};
return icons[cat] || "fa-newspaper";
}
function getCategoryColor(post) {
const cat = (post?.category || "").toString().toLowerCase();
const colors = {
news: "from-blue-500 to-cyan-500",
politics: "from-purple-500 to-indigo-500",
technology: "from-emerald-500 to-teal-500",
science: "from-violet-500 to-purple-500",
health: "from-rose-500 to-pink-500",
sports: "from-orange-500 to-amber-500",
entertainment: "from-pink-500 to-rose-500",
business: "from-slate-500 to-zinc-500",
environment: "from-green-500 to-emerald-500",
weather: "from-sky-500 to-blue-500",
traffic: "from-yellow-500 to-amber-500",
live: "from-red-500 to-rose-500",
camera: "from-red-500 to-rose-500",
};
return colors[cat] || "from-blue-500 to-cyan-500";
}
function isLivePost(post) {
const embedUrl = post?.embed_url || post?.video_url || "";
return embedUrl.startsWith("livekit://") || post?.category?.toLowerCase() === "live";
}
function isVideoPost(post) {
return !!(post?.video_url && !isLivePost(post));
}
function isCameraPost(post) {
return post?.category?.toLowerCase() === "camera" || post?.template === "camera";
}
function getHeroImage(post) {
if (post?.image_url) return normalizeMediaUrl(post.image_url);
if (post?.thumbnail_url) return normalizeMediaUrl(post.thumbnail_url);
const media = post?.media;
if (Array.isArray(media) && media.length > 0) {
const first = media[0];
return normalizeMediaUrl(first?.url || first?.thumbnail_url || "");
}
return "";
}
export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDeleted }) {
const { t } = useTranslation();
const { username, token } = useAuth();
const modalRef = useRef(null);
// State
const [counts, setCounts] = useState({
views: post?.view_count || 0,
likes: post?.like_count || 0,
shares: post?.share_count || 0,
comments: post?.comment_count || 0,
});
const [liked, setLiked] = useState(false);
const [truth, setTruth] = useState({ ai: 50, user: 50, count: 0 });
const [comments, setComments] = useState([]);
const [commentInput, setCommentInput] = useState("");
const [commentSending, setCommentSending] = useState(false);
const [activeTab, setActiveTab] = useState("info"); // info, comments
const [showEdit, setShowEdit] = useState(false);
const [showVisibility, setShowVisibility] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [editTitle, setEditTitle] = useState(post?.title || "");
const [editSnippet, setEditSnippet] = useState(post?.snippet || "");
const [visibility, setVisibility] = useState(post?.visibility || "public");
const [saving, setSaving] = useState(false);
const [imageIndex, setImageIndex] = useState(0);
const commentsEndRef = useRef(null);
const postId = post?.id || post?._id;
const isAuthor = username && (post?.author === username || post?.username === username);
// Media gallery
const gallery = useMemo(() => {
const images = [];
if (post?.image_url) images.push(normalizeMediaUrl(post.image_url));
if (Array.isArray(post?.media)) {
post.media.forEach((m) => {
const url = normalizeMediaUrl(m?.url || m?.thumbnail_url || "");
if (url && !images.includes(url)) images.push(url);
});
}
return images;
}, [post]);
// Detect post type
const postType = useMemo(() => {
if (isLivePost(post)) return "live";
if (isCameraPost(post)) return "camera";
if (isVideoPost(post)) return "video";
return "article";
}, [post]);
// Initial data fetch
useEffect(() => {
if (!postId) return;
// Record view
recordPostView(postId).then((res) => {
if (res?.counts) {
setCounts((c) => ({
...c,
views: res.counts.view_count || c.views,
likes: res.counts.like_count || c.likes,
shares: res.counts.share_count || c.shares,
comments: res.counts.comment_count || c.comments,
}));
}
});
// Fetch like state
fetchPostLikeState(postId).then((res) => {
if (res?.liked !== undefined) setLiked(res.liked);
});
// Fetch truth scores
fetchPostTruth(postId).then((res) => {
if (res) {
setTruth({
ai: res.ai_score ?? 50,
user: res.user_score ?? 50,
count: res.vote_count ?? 0,
});
}
});
}, [postId]);
// Fetch comments
const refreshComments = useCallback(async () => {
if (!postId) return;
const items = await fetchPostComments(postId, 50);
if (Array.isArray(items)) {
setComments(items);
setCounts((c) => ({ ...c, comments: items.length }));
}
}, [postId]);
useEffect(() => {
refreshComments();
}, [refreshComments]);
// Auto-scroll comments
useEffect(() => {
if (activeTab === "comments") {
commentsEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [comments, activeTab]);
// Handlers
const handleLike = useCallback(async () => {
if (!postId) return;
const newLiked = !liked;
setLiked(newLiked);
setCounts((c) => ({ ...c, likes: c.likes + (newLiked ? 1 : -1) }));
const res = await togglePostLike(postId, newLiked);
if (res?.counts) {
setCounts((c) => ({
...c,
likes: res.counts.like_count ?? c.likes,
}));
}
}, [postId, liked]);
const handleShare = useCallback(async () => {
if (!postId) return;
const shareUrl = `${window.location.origin}/post/${postId}`;
if (navigator.share) {
try {
await navigator.share({
title: post?.title || "Sociowire",
url: shareUrl,
});
} catch {}
} else {
navigator.clipboard?.writeText(shareUrl);
}
const res = await recordPostShare(postId);
if (res?.counts) {
setCounts((c) => ({ ...c, shares: res.counts.share_count ?? c.shares }));
}
}, [postId, post?.title]);
const handleTruthVote = useCallback(async (vote) => {
if (!postId) return;
const res = await votePostTruth(postId, vote);
if (res) {
setTruth({
ai: res.ai_score ?? truth.ai,
user: res.user_score ?? truth.user,
count: res.vote_count ?? truth.count,
});
}
}, [postId, truth]);
const handleComment = useCallback(async () => {
const body = commentInput.trim();
if (!body || !postId || commentSending) return;
setCommentInput("");
setCommentSending(true);
// Optimistic update
const tempId = `temp_${Date.now()}`;
const newComment = {
id: tempId,
body,
author: username || "You",
created_at: new Date().toISOString(),
_pending: true,
};
setComments((prev) => [...prev, newComment]);
setCounts((c) => ({ ...c, comments: c.comments + 1 }));
const res = await createPostComment(postId, body);
setCommentSending(false);
if (res?.ok && res?.comment) {
setComments((prev) =>
prev.map((c) => (c.id === tempId ? { ...res.comment, _pending: false } : c))
);
} else {
setComments((prev) =>
prev.map((c) => (c.id === tempId ? { ...c, _failed: true, _pending: false } : c))
);
}
}, [commentInput, postId, commentSending, username]);
const handleSaveEdit = useCallback(async () => {
if (!postId || saving) return;
setSaving(true);
const res = await editPost(postId, { title: editTitle, snippet: editSnippet }, token);
setSaving(false);
if (res?.ok) {
setShowEdit(false);
onPostUpdated?.({ ...post, title: editTitle, snippet: editSnippet });
}
}, [postId, editTitle, editSnippet, token, saving, post, onPostUpdated]);
const handleSaveVisibility = useCallback(async () => {
if (!postId || saving) return;
setSaving(true);
const res = await updatePostVisibility(postId, { visibility }, token);
setSaving(false);
if (res?.ok) {
setShowVisibility(false);
onPostUpdated?.({ ...post, visibility });
}
}, [postId, visibility, token, saving, post, onPostUpdated]);
const handleDelete = useCallback(async () => {
if (!postId || saving) return;
setSaving(true);
const res = await deletePost(postId, token);
setSaving(false);
if (res?.ok) {
onPostDeleted?.(postId);
onClose?.();
}
}, [postId, token, saving, onPostDeleted, onClose]);
// Close on escape
useEffect(() => {
const handleKey = (e) => {
if (e.key === "Escape") onClose?.();
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [onClose]);
// Close on backdrop click
const handleBackdropClick = (e) => {
if (e.target === e.currentTarget) onClose?.();
};
if (!post) return null;
const heroImage = getHeroImage(post);
const categoryIcon = getCategoryIcon(post);
const categoryColor = getCategoryColor(post);
const author = post?.author || post?.username || post?.source_name || "Unknown";
return (
<motion.div
className="fixed inset-0 z-[1100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={handleBackdropClick}
>
<motion.div
ref={modalRef}
className="relative w-full max-w-4xl max-h-[92vh] overflow-y-auto rounded-3xl
bg-gradient-to-br from-surface-900/95 via-surface-950/98 to-surface-900/95
border border-white/10 shadow-2xl"
initial={{ scale: 0.9, y: 40 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.9, y: 40 }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
onClick={(e) => e.stopPropagation()}
>
{/* Glow effect */}
<div className="absolute -inset-px rounded-3xl bg-gradient-to-r from-blue-500/20 via-cyan-500/10 to-blue-400/20 blur-xl opacity-50 -z-10" />
{/* Header */}
<div className="relative flex items-center justify-between px-5 py-4 border-b border-white/10
bg-gradient-to-r from-surface-900/80 to-surface-800/60">
{/* Category badge */}
<div className="flex items-center gap-3">
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-xl
bg-gradient-to-r ${categoryColor} text-white text-xs font-bold uppercase tracking-wide`}>
<i className={`fa-solid ${categoryIcon}`} />
<span>{post?.category || "News"}</span>
{postType === "live" && (
<span className="w-2 h-2 rounded-full bg-white animate-pulse" />
)}
</div>
<span className="text-surface-400 text-sm">{relativeTime(post?.published_at || post?.created_at)}</span>
</div>
{/* Close button */}
<motion.button
className="w-10 h-10 rounded-full bg-surface-800/80 border border-white/10
flex items-center justify-center text-surface-300 hover:text-white
hover:bg-red-500/20 hover:border-red-500/40 transition-all"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onClose}
>
<i className="fa-solid fa-times" />
</motion.button>
</div>
{/* Main content */}
<div className="flex flex-col lg:flex-row max-h-[calc(92vh-80px)] overflow-y-auto">
{/* Left: Hero media */}
<div className="lg:w-3/5 flex flex-col">
{/* Hero */}
<div className="relative aspect-video bg-surface-950 flex items-center justify-center overflow-hidden">
{postType === "live" || postType === "camera" ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gradient-to-br from-surface-900 to-surface-950">
<div className="relative">
<motion.div
className="absolute inset-0 rounded-full bg-red-500/30 blur-xl"
animate={{ scale: [1, 1.2, 1], opacity: [0.3, 0.5, 0.3] }}
transition={{ duration: 2, repeat: Infinity }}
/>
<div className="relative w-20 h-20 rounded-full bg-gradient-to-br from-red-500 to-rose-600 flex items-center justify-center">
<i className="fa-solid fa-broadcast-tower text-2xl text-white" />
</div>
</div>
<p className="mt-4 text-surface-300 text-sm">
{postType === "live" ? "Live broadcast" : "Camera feed"}
</p>
<motion.button
className="mt-3 px-5 py-2 rounded-full bg-gradient-to-r from-red-500 to-rose-600
text-white text-sm font-bold shadow-lg shadow-red-500/30"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<i className="fa-solid fa-play mr-2" />
Watch Live
</motion.button>
</div>
) : postType === "video" ? (
<video
controls
playsInline
className="w-full h-full object-contain bg-black"
src={normalizeMediaUrl(post.video_url)}
poster={heroImage}
/>
) : heroImage ? (
<>
<img
src={gallery[imageIndex] || heroImage}
alt={post?.title || ""}
className="w-full h-full object-cover"
/>
{/* Gallery navigation */}
{gallery.length > 1 && (
<>
<button
className="absolute left-3 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full
bg-black/50 backdrop-blur-sm text-white flex items-center justify-center
hover:bg-black/70 transition-colors"
onClick={() => setImageIndex((i) => (i - 1 + gallery.length) % gallery.length)}
>
<i className="fa-solid fa-chevron-left" />
</button>
<button
className="absolute right-3 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full
bg-black/50 backdrop-blur-sm text-white flex items-center justify-center
hover:bg-black/70 transition-colors"
onClick={() => setImageIndex((i) => (i + 1) % gallery.length)}
>
<i className="fa-solid fa-chevron-right" />
</button>
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full
bg-black/50 backdrop-blur-sm text-white text-xs">
{imageIndex + 1} / {gallery.length}
</div>
</>
)}
</>
) : (
<div className="flex flex-col items-center justify-center text-surface-500">
<i className={`fa-solid ${categoryIcon} text-5xl opacity-40 mb-3`} />
<span className="text-sm">{post?.category || "Article"}</span>
</div>
)}
</div>
{/* Title & summary */}
<div className="p-5 flex-1 overflow-y-auto">
<h1 className="text-xl font-bold text-surface-100 leading-tight mb-3">
{post?.title || "Untitled"}
</h1>
{/* Author */}
<div className="flex items-center gap-3 mb-4">
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center text-white text-sm font-bold">
{author.charAt(0).toUpperCase()}
</div>
<div>
<p className="text-sm font-medium text-surface-200">{author}</p>
<p className="text-xs text-surface-500">{relativeTime(post?.published_at || post?.created_at)}</p>
</div>
</div>
{/* Summary */}
<p className="text-surface-300 text-sm leading-relaxed mb-4">
{post?.snippet || post?.summary || "No description available."}
</p>
{/* Tags */}
{post?.tags && post.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{post.tags.slice(0, 5).map((tag, i) => (
<span key={i} className="px-2 py-1 rounded-lg bg-surface-800/50 text-surface-400 text-xs">
#{tag}
</span>
))}
</div>
)}
{/* Source link */}
{post?.url && (
<a
href={post.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl
bg-gradient-to-r from-blue-500/20 to-cyan-500/20
border border-accent/30 text-accent text-sm font-medium
hover:border-accent/50 transition-colors"
>
<i className="fa-solid fa-external-link" />
Open source
</a>
)}
</div>
</div>
{/* Right: Stats, actions, comments */}
<div className="lg:w-2/5 flex flex-col border-t lg:border-t-0 lg:border-l border-white/10 bg-surface-900/30">
{/* Stats row */}
<div className="grid grid-cols-4 gap-1 p-3 border-b border-white/5">
{[
{ key: "views", icon: "fa-eye", value: counts.views },
{ key: "likes", icon: "fa-heart", value: counts.likes },
{ key: "shares", icon: "fa-share", value: counts.shares },
{ key: "comments", icon: "fa-comment", value: counts.comments },
].map(({ key, icon, value }) => (
<div key={key} className="flex flex-col items-center py-2 rounded-xl hover:bg-white/5 transition-colors">
<i className={`fa-solid ${icon} text-surface-400 text-sm mb-1`} />
<span className="text-surface-200 font-bold text-sm">{formatCount(value)}</span>
</div>
))}
</div>
{/* Truth score */}
<div className="px-4 py-3 border-b border-white/5">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-surface-400 uppercase tracking-wide">
Truth Score
</span>
<span className="text-xs text-surface-500">{truth.count} votes</span>
</div>
<div className="relative h-2 rounded-full bg-surface-800 overflow-hidden">
<motion.div
className="absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-green-500 to-emerald-400"
initial={{ width: 0 }}
animate={{ width: `${truth.user}%` }}
transition={{ duration: 0.5 }}
/>
</div>
<div className="flex justify-between mt-2">
<motion.button
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-green-500/10 border border-green-500/30
text-green-400 text-xs font-medium hover:bg-green-500/20 transition-colors"
whileTap={{ scale: 0.95 }}
onClick={() => handleTruthVote("up")}
>
<i className="fa-solid fa-check" />
True
</motion.button>
<span className="text-lg font-bold text-surface-200">{Math.round(truth.user)}%</span>
<motion.button
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-red-500/10 border border-red-500/30
text-red-400 text-xs font-medium hover:bg-red-500/20 transition-colors"
whileTap={{ scale: 0.95 }}
onClick={() => handleTruthVote("down")}
>
<i className="fa-solid fa-times" />
False
</motion.button>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-white/5">
<motion.button
className={`flex-1 flex items-center justify-center gap-2 py-2.5 rounded-xl font-medium text-sm transition-all
${liked
? "bg-gradient-to-r from-pink-500/20 to-rose-500/20 border border-pink-500/40 text-pink-400"
: "bg-surface-800/50 border border-white/10 text-surface-300 hover:bg-surface-700/50"
}`}
whileTap={{ scale: 0.95 }}
onClick={handleLike}
>
<i className={liked ? "fa-solid fa-heart" : "fa-regular fa-heart"} />
{liked ? "Liked" : "Like"}
</motion.button>
<motion.button
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-xl font-medium text-sm
bg-surface-800/50 border border-white/10 text-surface-300 hover:bg-surface-700/50 transition-all"
whileTap={{ scale: 0.95 }}
onClick={handleShare}
>
<i className="fa-solid fa-share" />
Share
</motion.button>
</div>
{/* Author actions */}
{isAuthor && (
<div className="flex items-center gap-2 px-4 py-3 border-b border-white/5">
<motion.button
className="flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg
bg-blue-500/10 border border-blue-500/30 text-blue-400 text-xs font-medium
hover:bg-blue-500/20 transition-colors"
whileTap={{ scale: 0.95 }}
onClick={() => setShowEdit(!showEdit)}
>
<i className="fa-solid fa-pen" />
Edit
</motion.button>
<motion.button
className="flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg
bg-purple-500/10 border border-purple-500/30 text-purple-400 text-xs font-medium
hover:bg-purple-500/20 transition-colors"
whileTap={{ scale: 0.95 }}
onClick={() => setShowVisibility(!showVisibility)}
>
<i className="fa-solid fa-lock" />
Visibility
</motion.button>
<motion.button
className="flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg
bg-red-500/10 border border-red-500/30 text-red-400 text-xs font-medium
hover:bg-red-500/20 transition-colors"
whileTap={{ scale: 0.95 }}
onClick={() => setShowDelete(!showDelete)}
>
<i className="fa-solid fa-trash" />
Delete
</motion.button>
</div>
)}
{/* Edit panel */}
<AnimatePresence>
{showEdit && (
<motion.div
className="px-4 py-3 border-b border-white/5 bg-surface-800/30"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
>
<input
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
placeholder="Title"
className="w-full px-3 py-2 mb-2 rounded-lg bg-surface-900/80 border border-white/10
text-surface-100 text-sm placeholder:text-surface-500 focus:outline-none focus:border-accent/50"
/>
<textarea
value={editSnippet}
onChange={(e) => setEditSnippet(e.target.value)}
placeholder="Description"
rows={3}
className="w-full px-3 py-2 mb-2 rounded-lg bg-surface-900/80 border border-white/10
text-surface-100 text-sm placeholder:text-surface-500 focus:outline-none focus:border-accent/50 resize-none"
/>
<div className="flex gap-2">
<button
className="flex-1 py-2 rounded-lg bg-green-500/20 border border-green-500/40 text-green-400 text-xs font-medium
hover:bg-green-500/30 transition-colors disabled:opacity-50"
onClick={handleSaveEdit}
disabled={saving}
>
{saving ? "Saving..." : "Save"}
</button>
<button
className="flex-1 py-2 rounded-lg bg-surface-700/50 border border-white/10 text-surface-300 text-xs
hover:bg-surface-600/50 transition-colors"
onClick={() => setShowEdit(false)}
>
Cancel
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Visibility panel */}
<AnimatePresence>
{showVisibility && (
<motion.div
className="px-4 py-3 border-b border-white/5 bg-surface-800/30"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
>
<select
value={visibility}
onChange={(e) => setVisibility(e.target.value)}
className="w-full px-3 py-2 mb-2 rounded-lg bg-surface-900/80 border border-white/10
text-surface-100 text-sm focus:outline-none focus:border-accent/50"
>
<option value="public">Public</option>
<option value="friends">Friends Only</option>
<option value="private">Private</option>
</select>
<div className="flex gap-2">
<button
className="flex-1 py-2 rounded-lg bg-green-500/20 border border-green-500/40 text-green-400 text-xs font-medium
hover:bg-green-500/30 transition-colors disabled:opacity-50"
onClick={handleSaveVisibility}
disabled={saving}
>
{saving ? "Saving..." : "Save"}
</button>
<button
className="flex-1 py-2 rounded-lg bg-surface-700/50 border border-white/10 text-surface-300 text-xs
hover:bg-surface-600/50 transition-colors"
onClick={() => setShowVisibility(false)}
>
Cancel
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Delete confirm */}
<AnimatePresence>
{showDelete && (
<motion.div
className="px-4 py-3 border-b border-white/5 bg-red-500/10"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
>
<p className="text-red-300 text-sm mb-3 text-center">
Are you sure you want to delete this post?
</p>
<div className="flex gap-2">
<button
className="flex-1 py-2 rounded-lg bg-red-500/30 border border-red-500/50 text-red-300 text-xs font-medium
hover:bg-red-500/40 transition-colors disabled:opacity-50"
onClick={handleDelete}
disabled={saving}
>
{saving ? "Deleting..." : "Yes, Delete"}
</button>
<button
className="flex-1 py-2 rounded-lg bg-surface-700/50 border border-white/10 text-surface-300 text-xs
hover:bg-surface-600/50 transition-colors"
onClick={() => setShowDelete(false)}
>
Cancel
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Comments section */}
<div className="flex-1 flex flex-col min-h-0">
<div className="flex items-center gap-2 px-4 py-3 border-b border-white/5">
<i className="fa-solid fa-comments text-accent" />
<span className="text-sm font-medium text-surface-200">Comments</span>
<span className="ml-auto px-2 py-0.5 rounded-full bg-accent/20 text-accent text-xs font-medium">
{comments.length}
</span>
</div>
{/* Comments list */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
{comments.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-surface-500">
<i className="fa-regular fa-comment-dots text-3xl mb-2 opacity-40" />
<p className="text-sm">No comments yet</p>
</div>
) : (
comments.map((c) => (
<motion.div
key={c.id}
className={`p-3 rounded-xl bg-surface-800/40 border border-white/5
${c._pending ? "opacity-60 border-dashed" : ""}
${c._failed ? "border-red-500/30" : ""}`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: c._pending ? 0.6 : 1, y: 0 }}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-bold text-accent">@{c.author}</span>
<span className="text-xs text-surface-500">{relativeTime(c.created_at)}</span>
{c._failed && (
<span className="text-xs text-red-400">Failed</span>
)}
</div>
<p className="text-sm text-surface-200 leading-relaxed">{c.body}</p>
</motion.div>
))
)}
<div ref={commentsEndRef} />
</div>
{/* Comment input */}
<div className="p-3 border-t border-white/5 bg-surface-900/50">
<div className="flex gap-2">
<input
type="text"
value={commentInput}
onChange={(e) => setCommentInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleComment();
}
}}
placeholder={token ? "Write a comment..." : "Log in to comment"}
disabled={!token || commentSending}
className="flex-1 px-4 py-2.5 rounded-full bg-surface-800/80 border border-white/10
text-surface-100 text-sm placeholder:text-surface-500
focus:outline-none focus:border-accent/50 disabled:opacity-50"
/>
<motion.button
className="w-10 h-10 rounded-full bg-gradient-to-r from-blue-500 to-cyan-500
text-white flex items-center justify-center
disabled:opacity-50 disabled:cursor-not-allowed"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
disabled={!token || !commentInput.trim() || commentSending}
onClick={handleComment}
>
{commentSending ? (
<i className="fa-solid fa-spinner fa-spin" />
) : (
<i className="fa-solid fa-paper-plane" />
)}
</motion.button>
</div>
</div>
</div>
</div>
</div>
</motion.div>
</motion.div>
);
}

View File

@ -1,9 +1,25 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useTranslation } from "react-i18next";
import PostCard from "./PostCardNew";
import { FeedSkeleton } from "../ui/Skeleton";
import { matchesContentFilter } from "../Filters/ContentFilters";
const listVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.08 },
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0, transition: { duration: 0.4, ease: [0.16, 1, 0.3, 1] } },
};
export default function PostList({ posts, loading, error, selectedPost, onSelectPost, onPostDeleted, onPostUpdated, contentFilter = "all" }) {
const { t } = useTranslation();
const [visibleCount, setVisibleCount] = useState(1);
const listRef = useRef(null);
const sentinelRef = useRef(null);
@ -35,29 +51,114 @@ export default function PostList({ posts, loading, error, selectedPost, onSelect
// Show skeleton loading state
if (loading && posts.length === 0) {
return (
<div className="post-list" ref={listRef}>
<div className="space-y-4 p-4" ref={listRef}>
<FeedSkeleton count={3} />
</div>
);
}
return (
<div className="post-list" ref={listRef}>
{error && <p className="status-text status-error">{error}</p>}
<div className="relative" ref={listRef}>
{/* Header */}
<motion.div
className="sticky top-14 sm:top-16 z-50 px-4 py-3
bg-gradient-to-b from-surface-950/95 via-surface-900/90 to-transparent
backdrop-blur-sm"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-1 h-6 rounded-full bg-gradient-to-b from-accent to-purple-500" />
<h2 className="text-lg font-bold text-surface-100">
{t('feed.title', 'News Feed')}
</h2>
<span className="text-xs font-medium text-surface-500 bg-surface-800/50 px-2 py-0.5 rounded-full">
{filtered.length}
</span>
</div>
{loading && (
<motion.div
className="flex items-center gap-2 text-xs text-accent"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<i className="fa-solid fa-circle-notch fa-spin" />
<span>{t('common.loading')}</span>
</motion.div>
)}
</div>
</motion.div>
{/* Error */}
<AnimatePresence>
{error && (
<motion.div
className="mx-4 mb-4 p-4 rounded-2xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
>
<i className="fa-solid fa-exclamation-triangle mr-2" />
{error}
</motion.div>
)}
</AnimatePresence>
{/* Posts */}
<motion.div
className="space-y-4 px-4 pb-4"
variants={listVariants}
initial="hidden"
animate="visible"
>
<AnimatePresence mode="popLayout">
{filtered.slice(0, visibleCount).map((p) => (
<PostCard
<motion.div
key={p.id ?? p._id}
variants={itemVariants}
layout
>
<PostCard
post={p}
selected={selectedPost && (selectedPost.id ?? selectedPost._id) === (p.id ?? p._id)}
onSelect={onSelectPost}
onPostDeleted={onPostDeleted}
onPostUpdated={onPostUpdated}
/>
</motion.div>
))}
</AnimatePresence>
</motion.div>
{!loading && filtered.length === 0 && <p className="status-text">No posts found</p>}
{filtered.length > visibleCount ? <div ref={sentinelRef} className="sw-wall-sentinel" /> : null}
{/* Empty state */}
<AnimatePresence>
{!loading && filtered.length === 0 && (
<motion.div
className="flex flex-col items-center justify-center py-16 text-surface-500"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
>
<i className="fa-regular fa-newspaper text-5xl mb-4 opacity-40" />
<p className="text-sm font-medium">{t('feed.noPosts', 'No posts found')}</p>
</motion.div>
)}
</AnimatePresence>
{/* Load more sentinel */}
{filtered.length > visibleCount && (
<div ref={sentinelRef} className="h-20 flex items-center justify-center">
<motion.div
className="flex items-center gap-2 text-sm text-surface-500"
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{ duration: 1.5, repeat: Infinity }}
>
<i className="fa-solid fa-circle-notch fa-spin" />
<span>{t('feed.loadingMore', 'Loading more...')}</span>
</motion.div>
</div>
)}
</div>
);
}

View File

@ -106,9 +106,9 @@
font-size: 0.9rem;
}
.sociowall-filter-btn.filter-all i { color: #a78bfa; }
.sociowall-filter-btn.filter-all i { color: #60a5fa; }
.sociowall-filter-btn.filter-news i { color: #60a5fa; }
.sociowall-filter-btn.filter-video i { color: #f472b6; }
.sociowall-filter-btn.filter-video i { color: #38bdf8; }
.sociowall-filter-btn.filter-image i { color: #34d399; }
.sociowall-filter-btn.filter-live i { color: #f87171; }
.sociowall-filter-btn.filter-event i { color: #fbbf24; }
@ -125,7 +125,7 @@
margin-bottom: 0.25rem;
/* Gradient text */
background: linear-gradient(135deg, #60a5fa 0%, #a78bfa 50%, #f472b6 100%);
background: linear-gradient(135deg, #60a5fa 0%, #60a5fa 50%, #38bdf8 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@ -133,7 +133,7 @@
/* Animated underline */
border-bottom: 1px solid transparent;
background-image:
linear-gradient(135deg, #60a5fa 0%, #a78bfa 50%, #f472b6 100%),
linear-gradient(135deg, #60a5fa 0%, #60a5fa 50%, #38bdf8 100%),
linear-gradient(90deg, rgba(96, 165, 250, 0.3), rgba(167, 139, 250, 0.3), rgba(244, 114, 182, 0.3));
background-size: 100% 100%, 100% 1px;
background-position: 0 0, 0 100%;
@ -229,7 +229,7 @@
overflow: hidden;
/* Gradient border ring */
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 50%, #ec4899 100%);
background: linear-gradient(135deg, #3b82f6 0%, #3b82f6 50%, #06b6d4 100%);
padding: 2px;
/* Inner container for image */
@ -397,7 +397,7 @@
.sw-wall-image-fallback i {
font-size: 52px;
opacity: 0.6;
background: linear-gradient(135deg, #60a5fa, #a78bfa);
background: linear-gradient(135deg, #60a5fa, #60a5fa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@ -540,7 +540,7 @@
width: 4px;
height: 4px;
border-radius: 50%;
background: linear-gradient(135deg, #60a5fa, #a78bfa);
background: linear-gradient(135deg, #60a5fa, #60a5fa);
}
/*
@ -854,7 +854,7 @@
text-transform: uppercase;
margin-bottom: 0.5rem;
background: linear-gradient(135deg, #60a5fa, #a78bfa);
background: linear-gradient(135deg, #60a5fa, #60a5fa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@ -983,7 +983,7 @@ body[data-theme="light"] .sociowall-panel {
}
body[data-theme="light"] .sociowall-header {
background: linear-gradient(135deg, #3b82f6, #8b5cf6, #ec4899);
background: linear-gradient(135deg, #3b82f6, #3b82f6, #06b6d4);
-webkit-background-clip: text;
background-clip: text;
}

View File

@ -5,13 +5,13 @@
*/
@theme {
/* Primary accent */
/* Primary accent - Clean blue */
--color-accent: #60a5fa;
--color-accent-light: #93c5fd;
--color-accent-dark: #3b82f6;
--color-accent-glow: rgba(96, 165, 250, 0.3);
--color-accent-glow: rgba(96, 165, 250, 0.4);
/* Surface colors */
/* Surface colors - Dark slate */
--color-surface-50: #f8fafc;
--color-surface-100: #f1f5f9;
--color-surface-200: #e2e8f0;
@ -22,33 +22,33 @@
--color-surface-700: #334155;
--color-surface-800: #1e293b;
--color-surface-900: #0f172a;
--color-surface-950: #050816;
--color-surface-950: #020617;
/* Glass backgrounds */
--color-glass-light: rgba(248, 250, 252, 0.98);
--color-glass-dark: rgba(18, 30, 52, 0.97);
--color-glass-blue: rgba(7, 18, 37, 0.98);
--color-glass-card: rgba(40, 48, 70, 0.9);
--color-glass-card-hover: rgba(50, 58, 80, 0.95);
--color-glass-dark: rgba(15, 23, 42, 0.97);
--color-glass-blue: rgba(15, 23, 42, 0.98);
--color-glass-card: rgba(30, 41, 59, 0.9);
--color-glass-card-hover: rgba(51, 65, 85, 0.95);
/* Category colors */
--color-category-news: #60a5fa;
--color-category-breaking: #ef4444;
--color-category-market: #fbbf24;
--color-category-friends: #22c55e;
--color-category-events: #a78bfa;
--color-category-finance: #14b8a6;
--color-category-live: #f87171;
--color-category-video: #f472b6;
--color-category-market: #f59e0b;
--color-category-friends: #10b981;
--color-category-events: #60a5fa;
--color-category-finance: #06b6d4;
--color-category-live: #ef4444;
--color-category-video: #38bdf8;
--color-category-image: #34d399;
/* Shadows */
--shadow-glass: 0 4px 32px rgba(0, 0, 0, 0.4), 0 0 30px rgba(59, 130, 246, 0.05);
--shadow-glass-hover: 0 8px 48px rgba(0, 0, 0, 0.5), 0 0 40px rgba(96, 165, 250, 0.1);
/* Shadows - Blue glow */
--shadow-glass: 0 4px 32px rgba(0, 0, 0, 0.4), 0 0 30px rgba(96, 165, 250, 0.08);
--shadow-glass-hover: 0 8px 48px rgba(0, 0, 0, 0.5), 0 0 40px rgba(96, 165, 250, 0.15);
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.2), 0 8px 24px rgba(0, 0, 0, 0.15);
--shadow-card-hover: 0 8px 32px rgba(0, 0, 0, 0.3), 0 0 32px rgba(96, 165, 250, 0.08);
--shadow-glow: 0 0 20px rgba(96, 165, 250, 0.4);
--shadow-glow-sm: 0 0 10px rgba(96, 165, 250, 0.3);
--shadow-card-hover: 0 8px 32px rgba(0, 0, 0, 0.3), 0 0 32px rgba(96, 165, 250, 0.12);
--shadow-glow: 0 0 20px rgba(96, 165, 250, 0.5);
--shadow-glow-sm: 0 0 10px rgba(96, 165, 250, 0.4);
/* Animations */
--animate-shimmer: shimmer 2s linear infinite;
@ -147,7 +147,7 @@ html {
/* Gradient Text */
.gradient-text {
background: linear-gradient(to right, var(--color-accent), #a78bfa, #f472b6);
background: linear-gradient(135deg, #60a5fa, #38bdf8, #06b6d4);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@ -182,7 +182,7 @@ html {
}
.btn-glass.active {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.25), rgba(139, 92, 246, 0.2));
background: rgba(96, 165, 250, 0.2);
border-color: rgba(96, 165, 250, 0.4);
color: var(--color-accent);
box-shadow: var(--shadow-glow-sm);
@ -193,7 +193,7 @@ html {
position: relative;
border-radius: 9999px;
padding: 2px;
background: linear-gradient(135deg, var(--color-accent-dark), #8b5cf6, #ec4899);
background: linear-gradient(135deg, #60a5fa, #38bdf8, #06b6d4);
box-shadow: var(--shadow-glow-sm);
}
@ -216,15 +216,15 @@ html {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(139, 92, 246, 0.2));
background: rgba(96, 165, 250, 0.15);
color: var(--color-accent-light);
border: 1px solid rgba(96, 165, 250, 0.2);
border: 1px solid rgba(96, 165, 250, 0.25);
box-shadow: var(--shadow-glow-sm);
}
.badge-news {
background: linear-gradient(135deg, rgba(96, 165, 250, 0.2), rgba(59, 130, 246, 0.2));
color: #60a5fa;
background: rgba(96, 165, 250, 0.15);
color: #93c5fd;
border-color: rgba(96, 165, 250, 0.3);
}
@ -290,38 +290,38 @@ html {
/* Theme variables - Blue (default) */
:root,
body[data-theme="blue"] {
--theme-bg-primary: #050816;
--theme-bg-secondary: #0a1628;
--theme-bg-tertiary: #0f1d32;
--theme-bg-elevated: #142238;
--theme-text-primary: #f1f5f9;
--theme-text-secondary: #94a3b8;
--theme-bg-primary: #020617;
--theme-bg-secondary: #0f172a;
--theme-bg-tertiary: #1e293b;
--theme-bg-elevated: #334155;
--theme-text-primary: #f8fafc;
--theme-text-secondary: #cbd5e1;
--theme-text-muted: #64748b;
--theme-border: rgba(59, 130, 246, 0.2);
--theme-border: rgba(96, 165, 250, 0.2);
--theme-border-hover: rgba(96, 165, 250, 0.4);
--theme-accent: #60a5fa;
--theme-accent-soft: rgba(96, 165, 250, 0.15);
--theme-gradient-start: #0a1628;
--theme-gradient-end: #050816;
--theme-glow: rgba(96, 165, 250, 0.3);
--theme-accent-soft: rgba(96, 165, 250, 0.2);
--theme-gradient-start: #0f172a;
--theme-gradient-end: #020617;
--theme-glow: rgba(96, 165, 250, 0.4);
}
/* Theme variables - Dark */
body[data-theme="dark"] {
--theme-bg-primary: #09090b;
--theme-bg-secondary: #18181b;
--theme-bg-tertiary: #27272a;
--theme-bg-elevated: #3f3f46;
--theme-text-primary: #fafafa;
--theme-text-secondary: #a1a1aa;
--theme-text-muted: #71717a;
--theme-bg-primary: #0a0a0f;
--theme-bg-secondary: #141420;
--theme-bg-tertiary: #1e1e2e;
--theme-bg-elevated: #2a2a3d;
--theme-text-primary: #f5f5f7;
--theme-text-secondary: #a0a0b0;
--theme-text-muted: #666680;
--theme-border: rgba(255, 255, 255, 0.1);
--theme-border-hover: rgba(255, 255, 255, 0.2);
--theme-accent: #a78bfa;
--theme-accent-soft: rgba(167, 139, 250, 0.15);
--theme-gradient-start: #18181b;
--theme-gradient-end: #09090b;
--theme-glow: rgba(167, 139, 250, 0.3);
--theme-border-hover: rgba(96, 165, 250, 0.3);
--theme-accent: #60a5fa;
--theme-accent-soft: rgba(96, 165, 250, 0.15);
--theme-gradient-start: #141420;
--theme-gradient-end: #0a0a0f;
--theme-glow: rgba(96, 165, 250, 0.3);
}
/* Theme variables - Light */
@ -333,13 +333,13 @@ body[data-theme="light"] {
--theme-text-primary: #0f172a;
--theme-text-secondary: #475569;
--theme-text-muted: #94a3b8;
--theme-border: rgba(15, 23, 42, 0.1);
--theme-border-hover: rgba(59, 130, 246, 0.3);
--theme-border: rgba(96, 165, 250, 0.15);
--theme-border-hover: rgba(96, 165, 250, 0.3);
--theme-accent: #3b82f6;
--theme-accent-soft: rgba(59, 130, 246, 0.1);
--theme-gradient-start: #ffffff;
--theme-gradient-end: #f1f5f9;
--theme-glow: rgba(59, 130, 246, 0.2);
--theme-gradient-end: #f8fafc;
--theme-glow: rgba(59, 130, 246, 0.25);
}
/*

View File

@ -153,15 +153,15 @@
/* Specific button colors */
.btn-action-install{
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
background: linear-gradient(135deg, #3b82f6, #2563eb);
border-color: rgba(139, 92, 246, 0.5);
color: white;
}
.btn-action-install:hover{
background: linear-gradient(135deg, #a78bfa, #8b5cf6);
background: linear-gradient(135deg, #60a5fa, #3b82f6);
box-shadow: 0 4px 16px rgba(139, 92, 246, 0.6);
border-color: #a78bfa;
border-color: #60a5fa;
}
.btn-action-islands{
@ -892,17 +892,17 @@ body[data-theme="light"] .lang-option.is-active{
}
.btn-action-theme{
background: linear-gradient(135deg, #8b5cf6, #6366f1);
background: linear-gradient(135deg, #3b82f6, #6366f1);
border-color: rgba(139, 92, 246, 0.5);
color: white;
}
.btn-action-theme:hover{
background: linear-gradient(135deg, #a78bfa, #818cf8);
background: linear-gradient(135deg, #60a5fa, #818cf8);
}
.btn-action-theme.is-active{
background: linear-gradient(135deg, #7c3aed, #4f46e5);
background: linear-gradient(135deg, #2563eb, #4f46e5);
box-shadow: 0 0 12px rgba(139, 92, 246, 0.6);
}
@ -938,7 +938,7 @@ body[data-theme="light"] .lang-option.is-active{
.theme-option.is-active{
background: rgba(139, 92, 246, 0.28);
color: #a78bfa;
color: #60a5fa;
}
body[data-theme="light"] .theme-dropdown{