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:
parent
d120aa9d17
commit
1ea2b58066
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
45
src/App.jsx
45
src/App.jsx
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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}%`;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
{/* ─────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
Loading…
Reference in New Issue