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 ? (
+
+ ) : null}
+
+ {showDeleteConfirm ? (
+
+
Delete this post?
+
+
+
+
+
+ ) : null}
+
+ ) : null}
);
}
diff --git a/src/components/Profile/UserProfile.jsx b/src/components/Profile/UserProfile.jsx
index 7b5551a..c4d6039 100644
--- a/src/components/Profile/UserProfile.jsx
+++ b/src/components/Profile/UserProfile.jsx
@@ -1,7 +1,15 @@
import React, { useEffect, useRef, useState } from 'react';
-import { fetchIslandByUsername, uploadProfileAvatar } from '../../api/client';
+import {
+ fetchIslandByUsername,
+ uploadProfileAvatar,
+ fetchFriendStatus,
+ sendFriendRequest,
+ removeFriend,
+ fetchFriends,
+} from '../../api/client';
import { useAuth } from '../../auth/AuthContext';
import '../../styles/profile.css';
+import '../Friends/Friends.css';
/**
* UserProfile: Modal showing user info with "Visit Island" button
@@ -23,6 +31,24 @@ export default function UserProfile({ username, onClose, onVisitIsland }) {
const isSelf = authenticated && authedUser && authedUser.toLowerCase() === username.toLowerCase();
+ // Friend status
+ const [friendStatus, setFriendStatus] = useState('none'); // none, pending, accepted
+ const [friendDirection, setFriendDirection] = useState(''); // sent, received (for pending)
+ const [friendLoading, setFriendLoading] = useState(false);
+ const [showFriends, setShowFriends] = useState(false);
+ const [friends, setFriends] = useState([]);
+
+ useEffect(() => {
+ if (!authenticated || isSelf || !username) return;
+
+ fetchFriendStatus(username).then((res) => {
+ if (res?.ok) {
+ setFriendStatus(res.status || 'none');
+ setFriendDirection(res.direction || '');
+ }
+ });
+ }, [authenticated, isSelf, username]);
+
useEffect(() => {
if (!username) return;
@@ -47,6 +73,33 @@ export default function UserProfile({ username, onClose, onVisitIsland }) {
if (fileRef.current) fileRef.current.click();
};
+ const handleAddFriend = async () => {
+ if (!authenticated || friendLoading) return;
+ setFriendLoading(true);
+ const res = await sendFriendRequest(username);
+ setFriendLoading(false);
+ if (res?.ok) {
+ setFriendStatus('pending');
+ setFriendDirection('sent');
+ }
+ };
+
+ const handleRemoveFriend = async () => {
+ if (!authenticated || friendLoading) return;
+ setFriendLoading(true);
+ const res = await removeFriend(username);
+ setFriendLoading(false);
+ if (res?.ok) {
+ setFriendStatus('none');
+ setFriendDirection('');
+ }
+ };
+
+ const loadFriends = async () => {
+ const res = await fetchFriends();
+ setFriends(res?.friends || []);
+ };
+
const handleAvatarChange = async (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
@@ -165,8 +218,66 @@ export default function UserProfile({ username, onClose, onVisitIsland }) {
>
🏝️ Visit Island
+
+ {authenticated && !isSelf && (
+ friendStatus === 'none' ? (
+
+ ) : friendStatus === 'pending' ? (
+
+ ) : friendStatus === 'accepted' ? (
+
+ ) : null
+ )}
+
+ {isSelf && (
+
+ )}
+ {isSelf && showFriends && (
+
+ {friends.length === 0 ? (
+
No friends yet
+ ) : (
+
+ {friends.map((friend) => (
+
+ {friend.username}
+
+ ))}
+
+ )}
+
+ )}
+
diff --git a/src/services/recoSearch.js b/src/services/recoSearch.js
index 64c665b..cde54b3 100644
--- a/src/services/recoSearch.js
+++ b/src/services/recoSearch.js
@@ -100,8 +100,15 @@ function normalizeOne(r) {
? r.tags
: [];
+ const postID =
+ typeof r.post_id === "number" && r.post_id > 0
+ ? r.post_id
+ : typeof r.postId === "number" && r.postId > 0
+ ? r.postId
+ : null;
+
return {
- id: (r.id ?? r._id ?? r.key ?? title).toString(),
+ id: (postID ?? r.id ?? r._id ?? r.key ?? title).toString(),
type: kind,
kind: kind, // Keep both for compatibility
title,
@@ -110,6 +117,7 @@ function normalizeOne(r) {
lat,
lon,
tags,
+ post_id: postID,
raw: r,
};
}
@@ -139,6 +147,7 @@ async function tryFetchAnyPath(q, { signal, limit } = {}) {
const locations = [];
const profiles = [];
const seen = new Set();
+ const seenPostIDs = new Set();
const minLocationResults = Math.min(2, maxResults);
const locationKinds = new Set([
"place", "city", "region", "province", "state",
@@ -176,8 +185,18 @@ async function tryFetchAnyPath(q, { signal, limit } = {}) {
}
for (const it of normalized) {
+ const rawPostID =
+ typeof it.post_id === "number" && it.post_id > 0
+ ? it.post_id
+ : typeof it.raw?.post_id === "number" && it.raw.post_id > 0
+ ? it.raw.post_id
+ : typeof it.raw?.postId === "number" && it.raw.postId > 0
+ ? it.raw.postId
+ : null;
+ if (rawPostID && seenPostIDs.has(rawPostID)) continue;
if (seen.has(it.id)) continue;
seen.add(it.id);
+ if (rawPostID) seenPostIDs.add(rawPostID);
const kind = (it.kind || "").toLowerCase();
if (locationKinds.has(kind)) {
locations.push(it);
diff --git a/src/styles/posts.css b/src/styles/posts.css
index cd5c692..3bbf59e 100644
--- a/src/styles/posts.css
+++ b/src/styles/posts.css
@@ -314,6 +314,60 @@ body[data-theme="light"] .sw-wall-image-fallback{
background: rgba(56,189,248,0.35);
border-color: rgba(56,189,248,0.8);
}
+.sw-wall-btn-danger{
+ border-color: rgba(248,56,56,0.35);
+ color:#fca5a5;
+}
+.sw-wall-btn-danger:hover{
+ background: rgba(248,56,56,0.35);
+ border-color: rgba(248,56,56,0.8);
+}
+
+/* Edit/Delete menu */
+.sw-wall-edit-menu{
+ margin-top:.25rem;
+ padding:.5rem .6rem;
+ border-radius:12px;
+ border:1px solid rgba(148,163,184,0.35);
+ background: rgba(15,23,42,0.75);
+ display:flex;
+ flex-direction:column;
+ gap:.4rem;
+}
+.sw-wall-edit-form{
+ display:flex;
+ flex-direction:column;
+ gap:.4rem;
+}
+.sw-wall-edit-input,
+.sw-wall-edit-textarea{
+ width:100%;
+ border-radius:8px;
+ border:1px solid #4b5563;
+ padding:.4rem .6rem;
+ background:#020617;
+ color:#e5e7eb;
+ font-size:.8rem;
+ font-family: inherit;
+}
+.sw-wall-edit-textarea{
+ resize: vertical;
+ min-height:60px;
+}
+.sw-wall-edit-actions{
+ display:flex;
+ gap:.4rem;
+}
+.sw-wall-delete-confirm{
+ display:flex;
+ flex-direction:column;
+ gap:.4rem;
+}
+.sw-wall-delete-confirm p{
+ font-size:.8rem;
+ color:#fca5a5;
+ margin:0;
+}
.post-list-header{ display:flex; align-items:center; gap:.5rem; margin-bottom:.25rem; font-size:.7rem; color:#cbd5f5; }
.km-filter input[type="range"]{ width:160px; }
diff --git a/src/styles/profile.css b/src/styles/profile.css
index 0e4966f..bef103e 100644
--- a/src/styles/profile.css
+++ b/src/styles/profile.css
@@ -240,6 +240,39 @@ body[data-theme="blue"] .user-profile-container{
margin: 0;
}
+/* Friends list in profile */
+.user-profile-friends {
+ margin-top: 0.75rem;
+ padding: 0.75rem;
+ background: rgba(0, 0, 0, 0.2);
+ border-radius: 12px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.user-profile-no-friends {
+ text-align: center;
+ color: rgba(255, 255, 255, 0.5);
+ font-size: 0.85rem;
+ margin: 0;
+}
+
+.user-profile-friends-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.user-profile-friend {
+ display: flex;
+ align-items: center;
+ padding: 0.35rem 0.6rem;
+ background: rgba(56, 189, 248, 0.15);
+ border: 1px solid rgba(56, 189, 248, 0.3);
+ border-radius: 999px;
+ font-size: 0.8rem;
+ color: #7dd3fc;
+}
+
/* Loading & No Island */
.user-profile-loading,