From 713a35ab75c1981e157beda6ab966d10308575ba Mon Sep 17 00:00:00 2001 From: SocioWire Date: Thu, 8 Jan 2026 05:26:11 +0000 Subject: [PATCH] Update live flow and search dedupe --- public/service-worker.js | 2 +- src/api/client.js | 144 ++++++++++++++ src/components/Friends/Friends.css | 227 +++++++++++++++++++++++ src/components/Friends/FriendsList.jsx | 163 ++++++++++++++++ src/components/Live/LiveKitBroadcast.jsx | 154 ++++++++++++--- src/components/Live/VideoModal.jsx | 4 +- src/components/Live/useLiveKit.js | 3 + src/components/Map/markerManager.js | 20 +- src/components/Map/usePostsEngine.js | 146 +++++++++++---- src/components/Posts/PostCard.jsx | 155 ++++++++++++++-- src/components/Profile/UserProfile.jsx | 113 ++++++++++- src/services/recoSearch.js | 21 ++- src/styles/posts.css | 54 ++++++ src/styles/profile.css | 33 ++++ 14 files changed, 1156 insertions(+), 83 deletions(-) create mode 100644 src/components/Friends/Friends.css create mode 100644 src/components/Friends/FriendsList.jsx diff --git a/public/service-worker.js b/public/service-worker.js index f3a34bf..548c1c7 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,5 +1,5 @@ // public/service-worker.js -const CACHE_NAME = 'sociowire-v6'; +const CACHE_NAME = 'sociowire-v9'; const PRECACHE = [ '/icons/icon-192x192.png', '/icons/icon-512x512.png', diff --git a/src/api/client.js b/src/api/client.js index 1d8a883..5e91c0e 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -783,3 +783,147 @@ export async function fetchPostLikeState(postId) { return { ok: false }; } } + +// Edit post +export async function editPost(postId, payload, token) { + if (!postId) return { ok: false }; + const qs = new URLSearchParams(); + qs.set("id", String(postId)); + try { + const headers = { "Content-Type": "application/json", ...authHeaders() }; + if (token) headers["Authorization"] = `Bearer ${token}`; + const res = await fetch(`${apiBase()}/post?${qs.toString()}`, { + method: "PATCH", + credentials: "include", + headers, + body: JSON.stringify(payload), + }); + const data = await res.json().catch(() => ({})); + return { ok: res.ok, ...data }; + } catch { + return { ok: false }; + } +} + +// Delete post +export async function deletePost(postId, token) { + if (!postId) return { ok: false }; + const qs = new URLSearchParams(); + qs.set("id", String(postId)); + try { + const headers = { ...authHeaders() }; + if (token) headers["Authorization"] = `Bearer ${token}`; + const res = await fetch(`${apiBase()}/post?${qs.toString()}`, { + method: "DELETE", + credentials: "include", + headers, + }); + const data = await res.json().catch(() => ({})); + return { ok: res.ok, ...data }; + } catch { + return { ok: false }; + } +} + +// Friends API +export async function fetchFriends() { + try { + const res = await fetch(`${apiBase()}/friends`, { + credentials: "include", + headers: { ...authHeaders() }, + }); + const data = await res.json().catch(() => ({})); + return { ok: res.ok, ...data }; + } catch { + return { ok: false, friends: [] }; + } +} + +export async function fetchFriendRequests() { + try { + const res = await fetch(`${apiBase()}/friends/requests`, { + credentials: "include", + headers: { ...authHeaders() }, + }); + const data = await res.json().catch(() => ({})); + return { ok: res.ok, ...data }; + } catch { + return { ok: false, requests: [] }; + } +} + +export async function sendFriendRequest(username) { + try { + const res = await fetch(`${apiBase()}/friends/request`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json", ...authHeaders() }, + body: JSON.stringify({ username }), + }); + const data = await res.json().catch(() => ({})); + return { ok: res.ok, ...data }; + } catch { + return { ok: false }; + } +} + +export async function acceptFriendRequest(username) { + try { + const res = await fetch(`${apiBase()}/friends/accept`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json", ...authHeaders() }, + body: JSON.stringify({ username }), + }); + const data = await res.json().catch(() => ({})); + return { ok: res.ok, ...data }; + } catch { + return { ok: false }; + } +} + +export async function rejectFriendRequest(username) { + try { + const res = await fetch(`${apiBase()}/friends/reject`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json", ...authHeaders() }, + body: JSON.stringify({ username }), + }); + const data = await res.json().catch(() => ({})); + return { ok: res.ok, ...data }; + } catch { + return { ok: false }; + } +} + +export async function removeFriend(username) { + try { + const res = await fetch(`${apiBase()}/friends/remove`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json", ...authHeaders() }, + body: JSON.stringify({ username }), + }); + const data = await res.json().catch(() => ({})); + return { ok: res.ok, ...data }; + } catch { + return { ok: false }; + } +} + +export async function fetchFriendStatus(username) { + if (!username) return { ok: false, status: "none" }; + const qs = new URLSearchParams(); + qs.set("username", username); + try { + const res = await fetch(`${apiBase()}/friends/status?${qs.toString()}`, { + credentials: "include", + headers: { ...authHeaders() }, + }); + const data = await res.json().catch(() => ({})); + return { ok: res.ok, ...data }; + } catch { + return { ok: false, status: "none" }; + } +} diff --git a/src/components/Friends/Friends.css b/src/components/Friends/Friends.css new file mode 100644 index 0000000..7f39724 --- /dev/null +++ b/src/components/Friends/Friends.css @@ -0,0 +1,227 @@ +/* Friends Panel */ +.friends-panel { + background: rgba(15, 23, 42, 0.96); + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.9); + box-shadow: 0 10px 28px rgba(0,0,0,.9), 0 0 18px rgba(56,189,248,.45); + padding: .5rem; + min-width: 200px; + max-width: 300px; +} + +.friends-header { + display: flex; + justify-content: space-between; + align-items: center; + font-size: .85rem; + font-weight: 700; + letter-spacing: .06em; + text-transform: uppercase; + color: #bfdbfe; + margin-bottom: .5rem; + padding: 0 .25rem; +} + +.friends-close { + background: none; + border: none; + color: #94a3b8; + font-size: 1.2rem; + cursor: pointer; + line-height: 1; +} +.friends-close:hover { + color: #e2e8f0; +} + +.friends-tabs { + display: flex; + gap: .25rem; + margin-bottom: .5rem; +} + +.friends-tab { + flex: 1; + padding: .35rem .5rem; + border: 1px solid rgba(56,189,248,0.35); + background: rgba(15,23,42,0.6); + color: #94a3b8; + font-size: .72rem; + font-weight: 600; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: .35rem; +} +.friends-tab.active { + background: rgba(56,189,248,0.2); + border-color: rgba(56,189,248,0.6); + color: #e2f2ff; +} +.friends-tab:hover { + border-color: rgba(56,189,248,0.5); +} + +.friends-badge { + background: #ef4444; + color: white; + font-size: .65rem; + padding: .1rem .35rem; + border-radius: 999px; + min-width: 16px; + text-align: center; +} + +.friends-content { + max-height: 300px; + overflow-y: auto; +} + +.friends-loading, +.friends-empty { + text-align: center; + padding: 1rem; + color: #64748b; + font-size: .8rem; +} + +.friends-list { + display: flex; + flex-direction: column; + gap: .35rem; +} + +.friend-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: .4rem .5rem; + border-radius: 10px; + background: rgba(30, 41, 59, 0.5); + border: 1px solid rgba(71, 85, 105, 0.4); +} +.friend-item:hover { + background: rgba(30, 41, 59, 0.8); +} + +.friend-info { + display: flex; + align-items: center; + gap: .5rem; + cursor: pointer; + flex: 1; + min-width: 0; +} + +.friend-avatar { + width: 28px; + height: 28px; + border-radius: 8px; + background: rgba(56,189,248,0.22); + border: 1px solid rgba(56,189,248,0.55); + color: #e2f2ff; + font-weight: 800; + font-size: .75rem; + display: flex; + align-items: center; + justify-content: center; +} + +.friend-name { + font-size: .8rem; + color: #e5e7eb; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.friend-actions { + display: flex; + gap: .25rem; +} + +.friend-action { + width: 26px; + height: 26px; + border-radius: 6px; + border: 1px solid rgba(148,163,184,0.3); + background: rgba(15,23,42,0.6); + color: #94a3b8; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: .7rem; +} +.friend-action:hover { + border-color: rgba(148,163,184,0.5); +} + +.friend-action-accept { + color: #4ade80; + border-color: rgba(74,222,128,0.3); +} +.friend-action-accept:hover { + background: rgba(74,222,128,0.2); + border-color: rgba(74,222,128,0.6); +} + +.friend-action-reject, +.friend-action-remove { + color: #f87171; + border-color: rgba(248,113,113,0.3); +} +.friend-action-reject:hover, +.friend-action-remove:hover { + background: rgba(248,113,113,0.2); + border-color: rgba(248,113,113,0.6); +} + +/* Friend button in profile */ +.friend-btn { + display: flex; + align-items: center; + gap: .4rem; + padding: .45rem .8rem; + border-radius: 999px; + font-size: .8rem; + font-weight: 600; + cursor: pointer; + border: 1px solid; + transition: all 0.2s; +} + +.friend-btn-add { + background: rgba(56,189,248,0.15); + border-color: rgba(56,189,248,0.5); + color: #7dd3fc; +} +.friend-btn-add:hover { + background: rgba(56,189,248,0.25); + border-color: rgba(56,189,248,0.7); +} + +.friend-btn-pending { + background: rgba(251,191,36,0.15); + border-color: rgba(251,191,36,0.5); + color: #fbbf24; +} + +.friend-btn-friends { + background: rgba(74,222,128,0.15); + border-color: rgba(74,222,128,0.5); + color: #4ade80; +} + +.friend-btn-remove { + background: rgba(248,113,113,0.15); + border-color: rgba(248,113,113,0.5); + color: #f87171; +} +.friend-btn-remove:hover { + background: rgba(248,113,113,0.25); + border-color: rgba(248,113,113,0.7); +} diff --git a/src/components/Friends/FriendsList.jsx b/src/components/Friends/FriendsList.jsx new file mode 100644 index 0000000..250c137 --- /dev/null +++ b/src/components/Friends/FriendsList.jsx @@ -0,0 +1,163 @@ +import React, { useEffect, useState } from 'react'; +import { + fetchFriends, + fetchFriendRequests, + acceptFriendRequest, + rejectFriendRequest, + removeFriend, +} from '../../api/client'; +import { useAuth } from '../../auth/AuthContext'; +import './Friends.css'; + +export default function FriendsList({ onSelectUser, onClose }) { + const { authenticated } = useAuth(); + const [activeTab, setActiveTab] = useState('friends'); + const [friends, setFriends] = useState([]); + const [requests, setRequests] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!authenticated) return; + + const loadData = async () => { + setLoading(true); + const [friendsRes, requestsRes] = await Promise.all([ + fetchFriends(), + fetchFriendRequests(), + ]); + setFriends(friendsRes?.friends || []); + setRequests(requestsRes?.requests || []); + setLoading(false); + }; + + loadData(); + }, [authenticated]); + + const handleAccept = async (username) => { + const res = await acceptFriendRequest(username); + if (res?.ok) { + setRequests((prev) => prev.filter((r) => r.username !== username)); + // Reload friends list + const friendsRes = await fetchFriends(); + setFriends(friendsRes?.friends || []); + } + }; + + const handleReject = async (username) => { + const res = await rejectFriendRequest(username); + if (res?.ok) { + setRequests((prev) => prev.filter((r) => r.username !== username)); + } + }; + + const handleRemove = async (username) => { + if (!window.confirm(`Remove ${username} from friends?`)) return; + const res = await removeFriend(username); + if (res?.ok) { + setFriends((prev) => prev.filter((f) => f.username !== username)); + } + }; + + if (!authenticated) { + return ( +
+
+ Friends + {onClose && } +
+
Sign in to see your friends
+
+ ); + } + + return ( +
+
+ Friends + {onClose && } +
+ +
+ + +
+ +
+ {loading ? ( +
Loading...
+ ) : activeTab === 'friends' ? ( + friends.length === 0 ? ( +
No friends yet
+ ) : ( +
+ {friends.map((friend) => ( +
+
onSelectUser && onSelectUser(friend.username)} + > +
+ {friend.username[0].toUpperCase()} +
+ {friend.username} +
+ +
+ ))} +
+ ) + ) : requests.length === 0 ? ( +
No pending requests
+ ) : ( +
+ {requests.map((request) => ( +
+
onSelectUser && onSelectUser(request.username)} + > +
+ {request.username[0].toUpperCase()} +
+ {request.username} +
+
+ + +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/components/Live/LiveKitBroadcast.jsx b/src/components/Live/LiveKitBroadcast.jsx index cc69e39..5df05e4 100644 --- a/src/components/Live/LiveKitBroadcast.jsx +++ b/src/components/Live/LiveKitBroadcast.jsx @@ -2,6 +2,8 @@ import { useState, useRef, useEffect, useCallback } from "react"; import { useLiveKit } from "./useLiveKit"; import { useDualCamera } from "./useDualCamera"; import { useAuth } from "../../auth/AuthContext"; +import { fetchPostComments, createPostComment } from "../../api/client"; +import { CREATE_CATEGORY_MAP, FILTER_MAIN_CATEGORIES } from "../Map/mapConfig"; import "./Live.css"; const API_BASE = (import.meta?.env?.VITE_API_BASE_URL || "").replace(/\/+$/, ""); @@ -19,12 +21,15 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) { const [title, setTitle] = useState(""); const [showConfirm, setShowConfirm] = useState(false); const [livePostId, setLivePostId] = useState(null); + const liveCategoryOptions = FILTER_MAIN_CATEGORIES.filter((c) => c !== "All"); + const [liveCategory, setLiveCategory] = useState("Events"); + const [liveSubCategory, setLiveSubCategory] = useState("Live"); // Timer state const [liveStartTime, setLiveStartTime] = useState(null); const [elapsedTime, setElapsedTime] = useState(0); - // Comments state + // Comments state (stored in the post so they persist after live) const [comments, setComments] = useState([]); const [commentInput, setCommentInput] = useState(""); const commentsEndRef = useRef(null); @@ -75,6 +80,29 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) { const error = cameraError || liveKitError; + const subCategoryOptions = (() => { + const options = Array.isArray(CREATE_CATEGORY_MAP[liveCategory]) + ? CREATE_CATEGORY_MAP[liveCategory] + : []; + const withLive = ["Live", ...options]; + const deduped = []; + const seen = new Set(); + for (const item of withLive) { + const label = (item || "").toString().trim(); + if (!label || seen.has(label)) continue; + seen.add(label); + deduped.push(label); + } + return deduped; + })(); + + useEffect(() => { + if (!subCategoryOptions.length) return; + if (!subCategoryOptions.includes(liveSubCategory)) { + setLiveSubCategory(subCategoryOptions[0]); + } + }, [liveCategory]); + // Start camera on mount useEffect(() => { startCameras(); @@ -85,9 +113,18 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) { // Update video element when stream changes useEffect(() => { - if (videoRef.current && mainStream) { - videoRef.current.srcObject = mainStream; - } + const el = videoRef.current; + if (!el || !mainStream) return; + el.srcObject = mainStream; + el.muted = true; + const tryPlay = () => { + const p = el.play(); + if (p && typeof p.catch === "function") { + p.catch(() => {}); + } + }; + el.onloadedmetadata = tryPlay; + tryPlay(); }, [mainStream]); // Timer effect - update every second when live @@ -133,20 +170,39 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) { } }, [mainStream, isLive, replaceVideoTrack]); - // Add a comment - const handleAddComment = useCallback(() => { - if (!commentInput.trim()) return; + const refreshComments = useCallback(async () => { + if (!livePostId) return; + const items = await fetchPostComments(livePostId, 50); + if (Array.isArray(items)) { + setComments(items); + } + }, [livePostId]); - const newComment = { - id: Date.now(), - author: username || "Host", - text: commentInput.trim(), - timestamp: Date.now(), + useEffect(() => { + if (!isLive || !livePostId) return; + let active = true; + const poll = async () => { + if (!active) return; + await refreshComments(); }; + poll(); + const timer = setInterval(poll, 4000); + return () => { + active = false; + clearInterval(timer); + }; + }, [isLive, livePostId, refreshComments]); - setComments((prev) => [...prev, newComment]); + // Add a comment (persist to post immediately) + const handleAddComment = useCallback(async () => { + const body = commentInput.trim(); + if (!body || !livePostId) return; setCommentInput(""); - }, [commentInput, username]); + const res = await createPostComment(livePostId, body); + if (res?.ok) { + await refreshComments(); + } + }, [commentInput, livePostId, refreshComments]); const handleGoLive = useCallback(async () => { if (!authToken) { @@ -187,9 +243,9 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) { }, body: JSON.stringify({ title: title.trim(), - snippet: "Live now", - category: "EVENT", - sub_category: "Live", + snippet: `Live now${liveSubCategory ? ` · ${liveSubCategory}` : ""}`, + category: liveCategory === "Events" ? "EVENT" : liveCategory.toUpperCase(), + sub_category: liveSubCategory || "Live", lat: liveCoords?.lat, lon: liveCoords?.lon, author: username, @@ -212,7 +268,18 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) { console.error("[LiveKit] Failed to create live post:", err); alert("Live started, but we couldn't create the live post."); } - }, [title, mainStream, startBroadcast, roomName, liveCoords, onStreamCreated, authToken, username]); + }, [ + title, + mainStream, + startBroadcast, + roomName, + liveCoords, + onStreamCreated, + authToken, + username, + liveCategory, + liveSubCategory, + ]); const handleEndStream = useCallback(async (save) => { await stopBroadcast(); @@ -220,6 +287,7 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) { // Stop egress recording and get video key let videoKey = ""; + let thumbnailKey = ""; try { const egressRes = await fetch(`${LIVEKIT_API}/api/livekit/egress/stop`, { method: "POST", @@ -229,6 +297,7 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) { if (egressRes.ok) { const egressData = await egressRes.json(); videoKey = egressData.key || ""; + thumbnailKey = egressData.thumbnail_key || ""; console.log("[LiveKit] Egress stopped, video key:", videoKey); } } catch (err) { @@ -248,11 +317,8 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) { post_id: livePostId, save: !!save, video_key: videoKey, - comments: save ? comments.map((c) => ({ - author: c.author, - text: c.text, - timestamp: c.timestamp, - })) : [], + thumbnail_key: thumbnailKey, + comments: [], }), }); if (res.ok && save) { @@ -389,6 +455,30 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) { onChange={(e) => setTitle(e.target.value)} maxLength={100} /> + + + +
Using LiveKit - works on 5G/4G/WiFi
@@ -418,13 +508,16 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
{comments.length === 0 ? (
- No comments yet + {livePostId ? "No comments yet" : "Connecting chat..."}
) : ( - comments.map((c) => ( -
- {c.author} - {c.text} + comments.map((c, idx) => ( +
+ {c.username || c.author || "User"} + {c.body || c.text || ""}
)) )} @@ -434,17 +527,18 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) { setCommentInput(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleAddComment()} maxLength={200} + disabled={!livePostId} /> diff --git a/src/components/Live/VideoModal.jsx b/src/components/Live/VideoModal.jsx index 95910ba..a28bed9 100644 --- a/src/components/Live/VideoModal.jsx +++ b/src/components/Live/VideoModal.jsx @@ -64,8 +64,8 @@ export default function VideoModal({ camera, onClose }) { const name = camera.name_fr || camera.name_en || "Camera"; const region = formatRegionLabel(camera.region); const route = camera.route || ""; - const embedUrl = camera.embed_url || ""; - const videoUrl = camera.video_url || ""; + const embedUrl = (camera.embed_url || "").toString().trim(); + const videoUrl = (camera.video_url || "").toString().trim(); const lat = camera.lat; const lng = camera.lng; diff --git a/src/components/Live/useLiveKit.js b/src/components/Live/useLiveKit.js index 5521a73..1feda13 100644 --- a/src/components/Live/useLiveKit.js +++ b/src/components/Live/useLiveKit.js @@ -211,6 +211,9 @@ export function useLiveKit({ roomName, userID, username, token: authToken }) { // Replace video track (for camera switch) const replaceVideoTrack = useCallback(async (newTrack) => { if (!roomRef.current || !newTrack) return false; + if (localVideoTrackRef.current === newTrack) { + return true; + } try { // Unpublish old track diff --git a/src/components/Map/markerManager.js b/src/components/Map/markerManager.js index 557d2ec..e829e1e 100644 --- a/src/components/Map/markerManager.js +++ b/src/components/Map/markerManager.js @@ -425,7 +425,7 @@ function extractCameraId(url) { // Build stream proxy URL for camera function getCameraStreamUrl(postData) { - const embedUrl = postData?.embed_url || postData?.video_url || ''; + const embedUrl = normalizeMediaUrl(postData?.embed_url || postData?.video_url || ''); const cameraId = extractCameraId(embedUrl); if (cameraId) { return `/live/api/stream/${cameraId}`; @@ -786,6 +786,14 @@ function normalizeImageUrl(raw) { return value; } +function normalizeMediaUrl(raw) { + const value = (raw || "").toString().trim(); + if (!value) return ""; + if (value.startsWith("//")) return `https:${value}`; + if (value.startsWith("http://")) return `https://${value.slice(7)}`; + return value; +} + function closeCenteredOverlay(map, opts = {}) { try { const ov = map?.__swCenteredOverlay; @@ -844,8 +852,8 @@ function openCameraOverlay({ map, post, theme, fullScreen, interactionState }) { const postData = post || {}; const title = postData.title || "Live Camera"; const region = postData.region || ""; - const embedUrl = postData.embed_url || ""; - const videoUrl = postData.video_url || ""; + const embedUrl = normalizeMediaUrl(postData.embed_url || ""); + const videoUrl = normalizeMediaUrl(postData.video_url || ""); const lat = postData.lat; const lon = postData.lon; const postID = postData.id || 0; @@ -1221,9 +1229,9 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) { controls playsinline style="position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:#000;" - src="${escapeHtml(postData.video_url || '')}" + src="${escapeHtml(normalizeMediaUrl(postData.video_url || ''))}" > - +
@@ -1253,7 +1261,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
${isCamera ? ` - + Watch Live Stream ` : ` diff --git a/src/components/Map/usePostsEngine.js b/src/components/Map/usePostsEngine.js index fdfe086..f8cd934 100644 --- a/src/components/Map/usePostsEngine.js +++ b/src/components/Map/usePostsEngine.js @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { fetchPosts, fetchSmartFeed, fetchUnifiedSearch } from "../../api/client"; +import { fetchPosts, fetchSmartFeed, fetchUnifiedSearch, resolveAssetRef } from "../../api/client"; import { categoryCode, matchesSubFilter } from "./mapFilter"; import { getCoords, getViewFromMap, haversineKm } from "./mapGeo"; import { createMarkerForPost, createSimpleMarkerForPost } from "./markerManager"; @@ -52,16 +52,29 @@ function getTemplateKey(post) { return getTemplateKeyForPost(post); } +function normalizeMediaCandidate(value) { + const raw = (value || "").toString().trim(); + if (!raw) return ""; + if (raw.startsWith("asset://")) return ""; + if (raw.startsWith("//")) return `https:${raw}`; + if (raw.startsWith("http://")) return `https://${raw.slice(7)}`; + return raw; +} + function resolvePostImage(post) { - return ( - post?.image_small || - post?.image_medium || - post?.image_large || - post?.image || - post?.media_url || - post?.image_url || - "" - ).toString(); + const candidates = [ + post?.image_small, + post?.image_medium, + post?.image_large, + post?.image, + post?.media_url, + post?.image_url, + ]; + for (const candidate of candidates) { + const normalized = normalizeMediaCandidate(candidate); + if (normalized) return normalized; + } + return ""; } function postHaystack(p) { @@ -1268,10 +1281,48 @@ export function usePostsEngine({ const handleIncomingPost = useCallback( (p) => { + const incomingId = Number(p?.id ?? p?.post_id ?? p?.postId ?? 0) || null; const key = getPostKey(p); if (key) { - const exists = (allPostsRef.current || []).some((x) => getPostKey(x) === key); - if (exists) return; + const existingIdx = (allPostsRef.current || []).findIndex((x) => getPostKey(x) === key); + if (existingIdx !== -1) { + const existing = allPostsRef.current[existingIdx]; + const existingId = Number(existing?.id ?? existing?.post_id ?? existing?.postId ?? 0) || null; + if (incomingId && existingId && incomingId === existingId) { + const existingType = String(existing?.content_type || "").toLowerCase(); + const incomingType = String(p?.content_type || "").toLowerCase(); + if (existingType === "video" && incomingType === "live") { + return; + } + const merged = normalizePostId({ ...existing, ...p }); + allPostsRef.current = allPostsRef.current.map((item, idx) => + idx === existingIdx ? merged : item + ); + refreshVisibleAndMarkers(timeHours); + return; + } + return; + } + } + if (incomingId) { + const existingIdx = (allPostsRef.current || []).findIndex((x) => { + const id = Number(x?.id ?? x?.post_id ?? x?.postId ?? 0) || null; + return id && id === incomingId; + }); + if (existingIdx !== -1) { + const existing = allPostsRef.current[existingIdx]; + const existingType = String(existing?.content_type || "").toLowerCase(); + const incomingType = String(p?.content_type || "").toLowerCase(); + if (existingType === "video" && incomingType === "live") { + return; + } + const merged = normalizePostId({ ...existing, ...p }); + allPostsRef.current = allPostsRef.current.map((item, idx) => + idx === existingIdx ? merged : item + ); + refreshVisibleAndMarkers(timeHours); + return; + } } const map = mapRef.current; const center = map?.getCenter?.(); @@ -1332,29 +1383,62 @@ export function usePostsEngine({ payload?.truthScore ); - const updated = []; - let changed = false; - for (const p of allPostsRef.current || []) { - if ((p?.id || p?.ID) !== postId) { - updated.push(p); - continue; + const applyPatch = (patch) => { + const updated = []; + let changed = false; + for (const p of allPostsRef.current || []) { + if ((p?.id || p?.ID) !== postId) { + updated.push(p); + continue; + } + const next = normalizePostId({ ...p, ...(patch || {}) }); + if (counts) { + if (Number.isFinite(counts.view_count)) next.view_count = counts.view_count; + if (Number.isFinite(counts.like_count)) next.like_count = counts.like_count; + 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; + updated.push(next); + changed = true; } - const next = normalizePostId({ ...p, ...(postPatch || {}) }); - if (counts) { - if (Number.isFinite(counts.view_count)) next.view_count = counts.view_count; - if (Number.isFinite(counts.like_count)) next.like_count = counts.like_count; - 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 (changed) { + allPostsRef.current = updated; + refreshVisibleAndMarkers(timeHours, true); + } + }; + + const needsResolve = (value) => + typeof value === "string" && value.trim().startsWith("asset://"); + + if (postPatch && typeof postPatch === "object") { + const assetKeys = ["image_small", "image_medium", "image_large", "image", "media_url", "video_url"]; + const hasAssetRefs = + assetKeys.some((k) => needsResolve(postPatch[k])) || + (Array.isArray(postPatch.media_urls) && postPatch.media_urls.some(needsResolve)); + + if (hasAssetRefs) { + (async () => { + const resolvedPatch = { ...postPatch }; + await Promise.all( + assetKeys.map(async (k) => { + resolvedPatch[k] = await resolveAssetRef(resolvedPatch[k]); + }) + ); + if (Array.isArray(resolvedPatch.media_urls)) { + const resolvedMedia = await Promise.all( + resolvedPatch.media_urls.map((item) => resolveAssetRef(item)) + ); + resolvedPatch.media_urls = resolvedMedia.filter(Boolean); + } + applyPatch(resolvedPatch); + })(); + return; } - if (Number.isFinite(truthScore)) next.truth_score = truthScore; - updated.push(next); - changed = true; - } - if (changed) { - allPostsRef.current = updated; - refreshVisibleAndMarkers(timeHours, true); } + applyPatch(postPatch); + try { if (Number.isFinite(truthScore)) { const markers = markersRef.current || []; diff --git a/src/components/Posts/PostCard.jsx b/src/components/Posts/PostCard.jsx index 0f3c552..a4334b2 100644 --- a/src/components/Posts/PostCard.jsx +++ b/src/components/Posts/PostCard.jsx @@ -6,6 +6,8 @@ import { togglePostLike, updatePostVisibility, fetchProfile, + editPost, + deletePost, } from "../../api/client"; import { useAuth } from "../../auth/AuthContext"; import { trackEvent } from "../../utils/analytics"; @@ -129,7 +131,7 @@ async function getAvatarForUser(name) { return p; } -export default function PostCard({ post, selected, onSelect }) { +export default function PostCard({ post, selected, onSelect, onPostDeleted, onPostUpdated }) { const [liked, setLiked] = useState(false); const [authed, setAuthed] = useState(isAuthed()); const [activeImageIndex, setActiveImageIndex] = useState(0); @@ -153,6 +155,15 @@ export default function PostCard({ post, selected, onSelect }) { })); const { username, token } = useAuth(); + // Edit/Delete state + const [showEditDelete, setShowEditDelete] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [editTitle, setEditTitle] = useState(""); + const [editSnippet, setEditSnippet] = useState(""); + const [editSaving, setEditSaving] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [deleting, setDeleting] = useState(false); + const postId = post?.id ?? post?._id ?? 0; useEffect(() => { @@ -462,16 +473,34 @@ export default function PostCard({ post, selected, onSelect }) { {isAuthor ? ( - + <> + + + ) : null}
@@ -489,6 +518,7 @@ export default function PostCard({ post, selected, onSelect }) { }} > + @@ -552,6 +582,109 @@ export default function PostCard({ post, selected, onSelect }) {
) : null} + + {isAuthor && showEditDelete ? ( +
e.stopPropagation()}> + {!isEditing && !showDeleteConfirm ? ( + <> + + + + ) : null} + + {isEditing ? ( +
+ setEditTitle(e.target.value)} + /> +