diff --git a/package-lock.json b/package-lock.json index bc68514..41eddd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 845452a..81e76a2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.jsx b/src/App.jsx index f4c3b84..fc2a546 100644 --- a/src/App.jsx +++ b/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) */} + + {detailPost && ( + setDetailPost(null)} + onPostUpdated={(updated) => { + handlePostUpdated(updated); + setDetailPost((prev) => (prev ? { ...prev, ...updated } : null)); + }} + onPostDeleted={(postId) => { + handlePostDeleted(postId); + setDetailPost(null); + }} + /> + )} + + + {/* Floating Action Button for creating posts */} + ); } diff --git a/src/api/client.js b/src/api/client.js index b9ae33f..71742f3 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -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; } @@ -393,7 +422,7 @@ export async function fetchPostTruth(postId) { export async function votePostTruth(postId, vote) { if (!postId) throw new Error("post_id required"); const body = JSON.stringify({ post_id: postId, vote }); - const res = await fetch(`${apiBase()}/post/truth-vote`, { + const res = await fetch(`${apiBase()}/post/truth-vote`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders() }, credentials: "include", @@ -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 } + ); + }); +} diff --git a/src/components/Chat/ChatContainer.css b/src/components/Chat/ChatContainer.css index 35ef6df..aadf6a3 100644 --- a/src/components/Chat/ChatContainer.css +++ b/src/components/Chat/ChatContainer.css @@ -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; diff --git a/src/components/Chat/ChatContainer.jsx b/src/components/Chat/ChatContainer.jsx index df2f91f..2098d30 100644 --- a/src/components/Chat/ChatContainer.jsx +++ b/src/components/Chat/ChatContainer.jsx @@ -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"; diff --git a/src/components/Chat/ChatWindow.css b/src/components/Chat/ChatWindow.css index 968783c..8703edf 100644 --- a/src/components/Chat/ChatWindow.css +++ b/src/components/Chat/ChatWindow.css @@ -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; diff --git a/src/components/Chat/ChatWindowNew.jsx b/src/components/Chat/ChatWindowNew.jsx new file mode 100644 index 0000000..2b0fae8 --- /dev/null +++ b/src/components/Chat/ChatWindowNew.jsx @@ -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 ( +
+
+ {src ? ( + {name} + ) : ( +
+ {(name || "?")[0].toUpperCase()} +
+ )} +
+ {online && ( + + )} +
+ ); +} + +function TypingIndicator() { + return ( +
+
+ {[0, 1, 2].map((i) => ( + + ))} +
+
+ ); +} + +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 ( + +
+ + {recipientName} + { e.stopPropagation(); onClose?.(); }} + whileHover={{ scale: 1.1 }} + whileTap={{ scale: 0.9 }} + > + + +
+
+ ); + } + + return ( + + {/* Sidebar */} + + {showSidebar && ( + +
+ {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 ( + onSelectFriend?.(friend)} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + + {unread > 0 && ( + + {unread > 9 ? "9+" : unread} + + )} + + ); + })} +
+
+ )} +
+ + {/* Main Window */} +
+ {/* Header */} +
+ +
+
{recipientName}
+
+ {online ? t("common.online") : t("common.offline")} +
+
+
+ {onToggleSidebar && ( + + + + )} + + + + + + +
+
+ + {/* Messages */} +
+ {messages.length === 0 ? ( +
+ +

{t("chat.startConversation")}

+
+ ) : ( + 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 ( + +
+

{msg.content}

+
+ + {formatChatTime(msg.created_at)} + + {isMine && ( + + {isSending ? ( + + ) : isRead ? ( + + ) : isSent ? ( + + ) : null} + + )} +
+
+
+ ); + }) + )} + {typing && } +
+
+ + {/* Input */} +
+
+ + + + +
+
+
+ + ); +} diff --git a/src/components/Filters/ContentFilters.css b/src/components/Filters/ContentFilters.css index a305815..37a0563 100644 --- a/src/components/Filters/ContentFilters.css +++ b/src/components/Filters/ContentFilters.css @@ -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); } diff --git a/src/components/Layout/TopBarNew.jsx b/src/components/Layout/TopBarNew.jsx index f8934b1..8a0f20f 100644 --- a/src/components/Layout/TopBarNew.jsx +++ b/src/components/Layout/TopBarNew.jsx @@ -208,7 +208,7 @@ function UserChip({ authenticated, loading, username, avatarUrl, onClick }) { > {/* Avatar */}
+ bg-gradient-to-br from-blue-500 via-cyan-500 to-blue-400 p-0.5"> {avatarUrl ? ( {username} ) : ( @@ -473,131 +473,216 @@ export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClic
)} - {/* Header */} + {/* Header - Premium Social Network Style */} - {/* Left: Logo & Brand */} -
+ {/* Animated gradient background */} +
+ + {/* Glass overlay */} +
+ {/* Bottom glow line */} +
+ +
+ {/* Left: Logo & Brand with glow */} - SocioWire + {/* Logo with glow effect */} +
+ +
+
+ SocioWire +
+
+
+ + {/* Brand text */} +
+ + SOCIOWIRE + + + + {t('topbar.wiredToLife')} + +
-
- - SOCIOWIRE - - - {t('topbar.wiredToLife')} - -
- - {/* Theme dots - desktop only */} -
- {THEMES.map((themeKey) => ( + {/* Center: Theme selector - desktop */} +
+ {THEMES.map((themeKey, idx) => ( onChangeTheme?.(themeKey)} - whileHover={{ scale: 1.15 }} - whileTap={{ scale: 0.9 }} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} > - {t(`theme.${themeKey}`)[0]} + {theme === themeKey && ( + + )} + {t(`theme.${themeKey}`)} ))}
-
- {/* Right: Actions */} -
+ {/* Right: Actions */} +
+ {/* PWA Install */} + {showInstallBtn && ( + promptInstall()} + title={t('topbar.installApp')} + /> + )} - {/* User chip */} - authenticated && onUserIsland?.(username)} - /> + {/* Theme - mobile */} +
+ + + +
- {/* PWA Install */} - {showInstallBtn && ( - promptInstall()} - title={t('topbar.installApp')} - /> - )} + {/* Language */} +
+ + + +
- {/* Theme - mobile only */} -
- + {/* Friends */} + {authenticated && ( + setShowFriends(!showFriends)} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + + {pendingRequestsCount > 0 && ( + + {pendingRequestsCount} + + )} + + )} + + {/* Divider */} +
+ + {/* User/Auth */} + {authenticated ? ( +
+ onUserIsland?.(username)} + /> + { trackEvent("logout_click"); logout(); }} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + title={t('topbar.logout')} + > + + +
+ ) : ( + openModal("login")} + disabled={loading} + whileHover={{ scale: 1.03 }} + whileTap={{ scale: 0.97 }} + > +
+ + + {t('topbar.joinNow')} + + )}
- - {/* Language - icon only on mobile */} -
- -
- - {/* Friends */} - {authenticated && ( - setShowFriends(!showFriends)} - title={t('topbar.friends')} - /> - )} - - {/* Auth - icon only on mobile */} - { - if (authenticated) { trackEvent("logout_click"); logout(); } - else openModal("login"); - }} - disabled={loading} - title={authenticated ? t('topbar.logout') : t('topbar.login')} - />
@@ -848,7 +933,7 @@ export default function TopBarNew({ theme = "dark", onChangeTheme, onIslandsClic /> { + 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 - 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)); + // Show locally immediately + setComments(prev => [...prev, newComment]); + + // Broadcast via WebSocket to all viewers (real-time) + if (sendData) { + sendData({ type: "comment", comment: newComment }); } - }, [commentInput, livePostId, refreshComments, username]); + + // 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) { + saved = true; + // Update comment to mark as saved + setComments(prev => prev.map(c => + c.id === commentId ? { ...c, _pending: false } : c + )); + } + } + 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 diff --git a/src/components/Live/LiveKitViewerModal.jsx b/src/components/Live/LiveKitViewerModal.jsx index ca642eb..ee02f44 100644 --- a/src/components/Live/LiveKitViewerModal.jsx +++ b/src/components/Live/LiveKitViewerModal.jsx @@ -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) { diff --git a/src/components/Live/useLiveKit.js b/src/components/Live/useLiveKit.js index 0393f23..aaa8bdb 100644 --- a/src/components/Live/useLiveKit.js +++ b/src/components/Live/useLiveKit.js @@ -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, }; } diff --git a/src/components/Map/MapView.jsx b/src/components/Map/MapView.jsx index 1b66013..42bf9e7 100644 --- a/src/components/Map/MapView.jsx +++ b/src/components/Map/MapView.jsx @@ -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]); diff --git a/src/components/Map/markerManager.js b/src/components/Map/markerManager.js index df5f4f1..c718d42 100644 --- a/src/components/Map/markerManager.js +++ b/src/components/Map/markerManager.js @@ -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 }) {
-
- -
-
-
-
${truth}
- +
+
${truth}
+ Truth
${ @@ -1407,13 +1428,30 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
${!isCamera ? ` -
- -
-
-
-
${truth}
- +
+
${truth}
+ Truth
` : ''}
@@ -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 { - isVoting = false; - rail.style.opacity = "1"; - rail.style.pointerEvents = "auto"; - } + } catch {} + isVoting = false; + 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) => { diff --git a/src/components/Map/usePostsEngine.js b/src/components/Map/usePostsEngine.js index 0ee54bc..94997ec 100644 --- a/src/components/Map/usePostsEngine.js +++ b/src/components/Map/usePostsEngine.js @@ -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}%`; diff --git a/src/components/Posts/PostCardNew.jsx b/src/components/Posts/PostCardNew.jsx index 72aaace..6d12bcf 100644 --- a/src/components/Posts/PostCardNew.jsx +++ b/src/components/Posts/PostCardNew.jsx @@ -766,62 +766,88 @@ export default function PostCardNew({ post, selected, onSelect, onPostDeleted, o )} {/* ───────────────────────────────────────────────────────────────────────── - Footer + Footer - Compact action bar with integrated stats ───────────────────────────────────────────────────────────────────────── */} -
- {/* Stats */} -
- - - -
- - {/* Actions */} -
- + {/* Main actions with counts */} +
+ - - + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.95 }} + > + + {formatCount(counts.like)} + - {isAuthor && ( - <> - { - e.stopPropagation(); - setShowPrivacy((prev) => !prev); - setShowEditDelete(false); - }} - /> - { - e.stopPropagation(); - setShowEditDelete((prev) => !prev); - setShowPrivacy(false); - if (!showEditDelete) { - setEditTitle(post?.title || ""); - setEditSnippet(post?.snippet || ""); - } - }} - /> - - )} + + + {formatCount(counts.comment)} + + + + + {formatCount(counts.share)} +
+ + {/* Author actions */} + {isAuthor && ( +
+ { + e.stopPropagation(); + setShowPrivacy((prev) => !prev); + setShowEditDelete(false); + }} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + + + { + e.stopPropagation(); + setShowEditDelete((prev) => !prev); + setShowPrivacy(false); + if (!showEditDelete) { + setEditTitle(post?.title || ""); + setEditSnippet(post?.snippet || ""); + } + }} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + + +
+ )}
{/* ───────────────────────────────────────────────────────────────────────── diff --git a/src/components/Posts/PostComposer.jsx b/src/components/Posts/PostComposer.jsx new file mode 100644 index 0000000..26684bd --- /dev/null +++ b/src/components/Posts/PostComposer.jsx @@ -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 ( + + e.stopPropagation()} + > + +

Login Required

+

You need to be logged in to create posts.

+ +
+
+ ); + } + + return ( + + e.stopPropagation()} + > + {/* Header */} +
+ +

Create Post

+ 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 ? ( + + ) : ( + "Post" + )} + +
+ + {/* Content */} +
+ {/* User info */} +
+
+ {username?.charAt(0)?.toUpperCase() || "U"} +
+
+

{username || "You"}

+ +
+
+ + {/* Category selector */} + + {step === "category" && ( + +

Select Category

+
+ {CATEGORIES.map((cat) => ( + { + setCategory(cat.id); + setStep("compose"); + }} + > + + {cat.label} + + ))} +
+
+ )} +
+ + {/* Text input */} +