Update live flow and search dedupe
This commit is contained in:
parent
694a0aaaa2
commit
713a35ab75
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="friends-panel">
|
||||
<div className="friends-header">
|
||||
<span>Friends</span>
|
||||
{onClose && <button className="friends-close" onClick={onClose}>×</button>}
|
||||
</div>
|
||||
<div className="friends-empty">Sign in to see your friends</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="friends-panel">
|
||||
<div className="friends-header">
|
||||
<span>Friends</span>
|
||||
{onClose && <button className="friends-close" onClick={onClose}>×</button>}
|
||||
</div>
|
||||
|
||||
<div className="friends-tabs">
|
||||
<button
|
||||
className={`friends-tab ${activeTab === 'friends' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('friends')}
|
||||
>
|
||||
Friends ({friends.length})
|
||||
</button>
|
||||
<button
|
||||
className={`friends-tab ${activeTab === 'requests' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('requests')}
|
||||
>
|
||||
Requests {requests.length > 0 && <span className="friends-badge">{requests.length}</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="friends-content">
|
||||
{loading ? (
|
||||
<div className="friends-loading">Loading...</div>
|
||||
) : activeTab === 'friends' ? (
|
||||
friends.length === 0 ? (
|
||||
<div className="friends-empty">No friends yet</div>
|
||||
) : (
|
||||
<div className="friends-list">
|
||||
{friends.map((friend) => (
|
||||
<div key={friend.id} className="friend-item">
|
||||
<div
|
||||
className="friend-info"
|
||||
onClick={() => onSelectUser && onSelectUser(friend.username)}
|
||||
>
|
||||
<div className="friend-avatar">
|
||||
{friend.username[0].toUpperCase()}
|
||||
</div>
|
||||
<span className="friend-name">{friend.username}</span>
|
||||
</div>
|
||||
<button
|
||||
className="friend-action friend-action-remove"
|
||||
onClick={() => handleRemove(friend.username)}
|
||||
title="Remove friend"
|
||||
>
|
||||
<i className="fa-solid fa-user-minus" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : requests.length === 0 ? (
|
||||
<div className="friends-empty">No pending requests</div>
|
||||
) : (
|
||||
<div className="friends-list">
|
||||
{requests.map((request) => (
|
||||
<div key={request.id} className="friend-item">
|
||||
<div
|
||||
className="friend-info"
|
||||
onClick={() => onSelectUser && onSelectUser(request.username)}
|
||||
>
|
||||
<div className="friend-avatar">
|
||||
{request.username[0].toUpperCase()}
|
||||
</div>
|
||||
<span className="friend-name">{request.username}</span>
|
||||
</div>
|
||||
<div className="friend-actions">
|
||||
<button
|
||||
className="friend-action friend-action-accept"
|
||||
onClick={() => handleAccept(request.username)}
|
||||
title="Accept"
|
||||
>
|
||||
<i className="fa-solid fa-check" />
|
||||
</button>
|
||||
<button
|
||||
className="friend-action friend-action-reject"
|
||||
onClick={() => handleReject(request.username)}
|
||||
title="Reject"
|
||||
>
|
||||
<i className="fa-solid fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
<label className="sw-broadcast-label">Category</label>
|
||||
<select
|
||||
className="sw-broadcast-input"
|
||||
value={liveCategory}
|
||||
onChange={(e) => setLiveCategory(e.target.value)}
|
||||
>
|
||||
{liveCategoryOptions.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="sw-broadcast-label">Subcategory</label>
|
||||
<select
|
||||
className="sw-broadcast-input"
|
||||
value={liveSubCategory}
|
||||
onChange={(e) => setLiveSubCategory(e.target.value)}
|
||||
>
|
||||
{subCategoryOptions.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="sw-broadcast-hint">
|
||||
Using LiveKit - works on 5G/4G/WiFi
|
||||
</div>
|
||||
|
|
@ -418,13 +508,16 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
|
|||
<div className="sw-broadcast-comments-list">
|
||||
{comments.length === 0 ? (
|
||||
<div className="sw-broadcast-no-comments">
|
||||
No comments yet
|
||||
{livePostId ? "No comments yet" : "Connecting chat..."}
|
||||
</div>
|
||||
) : (
|
||||
comments.map((c) => (
|
||||
<div key={c.id} className="sw-broadcast-comment">
|
||||
<span className="sw-broadcast-comment-author">{c.author}</span>
|
||||
<span className="sw-broadcast-comment-text">{c.text}</span>
|
||||
comments.map((c, idx) => (
|
||||
<div
|
||||
key={c.id || c.ID || c.created_at || c.createdAt || `${c.username || "user"}-${idx}`}
|
||||
className="sw-broadcast-comment"
|
||||
>
|
||||
<span className="sw-broadcast-comment-author">{c.username || c.author || "User"}</span>
|
||||
<span className="sw-broadcast-comment-text">{c.body || c.text || ""}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
|
@ -434,17 +527,18 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
|
|||
<input
|
||||
type="text"
|
||||
className="sw-broadcast-comment-input"
|
||||
placeholder="Add a comment..."
|
||||
placeholder={livePostId ? "Add a comment..." : "Chat starting..."}
|
||||
value={commentInput}
|
||||
onChange={(e) => setCommentInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAddComment()}
|
||||
maxLength={200}
|
||||
disabled={!livePostId}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="sw-broadcast-comment-send"
|
||||
onClick={handleAddComment}
|
||||
disabled={!commentInput.trim()}
|
||||
disabled={!commentInput.trim() || !livePostId}
|
||||
>
|
||||
<i className="fa-solid fa-paper-plane" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 || ''))}"
|
||||
>
|
||||
<source src="${escapeHtml(postData.video_url || '')}" type="video/mp4">
|
||||
<source src="${escapeHtml(normalizeMediaUrl(postData.video_url || ''))}" type="video/mp4">
|
||||
</video>
|
||||
<div class="sw-video-badge" style="position:absolute;top:12px;left:12px;display:flex;align-items:center;gap:6px;padding:4px 10px;background:rgba(59,130,246,0.9);border-radius:4px;font-size:11px;font-weight:700;color:#fff;text-transform:uppercase;">
|
||||
<i class="fa-solid fa-video"></i>
|
||||
|
|
@ -1253,7 +1261,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
|||
|
||||
<div class="sw-modal-cta-row">
|
||||
${isCamera ? `
|
||||
<a class="sw-cta-primary sw-cta-camera" href="${escapeHtml(postData.video_url || postData.embed_url || '')}" target="_blank" rel="noopener" style="display:inline-flex;align-items:center;gap:0.5rem;text-decoration:none;background:linear-gradient(135deg,#dc2626,#ef4444);">
|
||||
<a class="sw-cta-primary sw-cta-camera" href="${escapeHtml(normalizeMediaUrl(postData.video_url || postData.embed_url || ''))}" target="_blank" rel="noopener" style="display:inline-flex;align-items:center;gap:0.5rem;text-decoration:none;background:linear-gradient(135deg,#dc2626,#ef4444);">
|
||||
<i class="fa-solid fa-video"></i> Watch Live Stream
|
||||
</a>
|
||||
` : `
|
||||
|
|
|
|||
|
|
@ -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 || [];
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
</button>
|
||||
|
||||
{isAuthor ? (
|
||||
<button
|
||||
type="button"
|
||||
className={"sw-wall-btn" + (showPrivacy ? " is-active" : "")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPrivacy((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
<i className="fa-solid fa-lock" /> Visibility
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={"sw-wall-btn" + (showPrivacy ? " is-active" : "")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPrivacy((prev) => !prev);
|
||||
setShowEditDelete(false);
|
||||
}}
|
||||
>
|
||||
<i className="fa-solid fa-lock" /> Visibility
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={"sw-wall-btn" + (showEditDelete ? " is-active" : "")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowEditDelete((prev) => !prev);
|
||||
setShowPrivacy(false);
|
||||
if (!showEditDelete) {
|
||||
setEditTitle(post?.title || "");
|
||||
setEditSnippet(post?.snippet || "");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<i className="fa-solid fa-ellipsis" />
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
|
|
@ -489,6 +518,7 @@ export default function PostCard({ post, selected, onSelect }) {
|
|||
}}
|
||||
>
|
||||
<option value="public">Public</option>
|
||||
<option value="friends">Friends Only</option>
|
||||
<option value="private">Private</option>
|
||||
<option value="group">Group</option>
|
||||
<option value="custom">Custom</option>
|
||||
|
|
@ -552,6 +582,109 @@ export default function PostCard({ post, selected, onSelect }) {
|
|||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isAuthor && showEditDelete ? (
|
||||
<div className="sw-wall-edit-menu" onClick={(e) => e.stopPropagation()}>
|
||||
{!isEditing && !showDeleteConfirm ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="sw-wall-btn"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
<i className="fa-solid fa-pen" /> Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="sw-wall-btn sw-wall-btn-danger"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
>
|
||||
<i className="fa-solid fa-trash" /> Delete
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{isEditing ? (
|
||||
<div className="sw-wall-edit-form">
|
||||
<input
|
||||
type="text"
|
||||
className="sw-wall-edit-input"
|
||||
placeholder="Title"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
className="sw-wall-edit-textarea"
|
||||
placeholder="Description"
|
||||
value={editSnippet}
|
||||
onChange={(e) => setEditSnippet(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="sw-wall-edit-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="sw-wall-btn"
|
||||
disabled={editSaving}
|
||||
onClick={async () => {
|
||||
setEditSaving(true);
|
||||
const res = await editPost(postId, {
|
||||
title: editTitle.trim(),
|
||||
snippet: editSnippet.trim(),
|
||||
}, token);
|
||||
setEditSaving(false);
|
||||
if (res?.ok) {
|
||||
setIsEditing(false);
|
||||
setShowEditDelete(false);
|
||||
if (onPostUpdated) onPostUpdated(res);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{editSaving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="sw-wall-btn"
|
||||
onClick={() => setIsEditing(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showDeleteConfirm ? (
|
||||
<div className="sw-wall-delete-confirm">
|
||||
<p>Delete this post?</p>
|
||||
<div className="sw-wall-edit-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="sw-wall-btn sw-wall-btn-danger"
|
||||
disabled={deleting}
|
||||
onClick={async () => {
|
||||
setDeleting(true);
|
||||
const res = await deletePost(postId, token);
|
||||
setDeleting(false);
|
||||
if (res?.ok) {
|
||||
setShowDeleteConfirm(false);
|
||||
setShowEditDelete(false);
|
||||
if (onPostDeleted) onPostDeleted(postId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{deleting ? "Deleting..." : "Delete"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="sw-wall-btn"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</button>
|
||||
|
||||
{authenticated && !isSelf && (
|
||||
friendStatus === 'none' ? (
|
||||
<button
|
||||
className="friend-btn friend-btn-add"
|
||||
onClick={handleAddFriend}
|
||||
disabled={friendLoading}
|
||||
>
|
||||
<i className="fa-solid fa-user-plus" />
|
||||
{friendLoading ? 'Sending...' : 'Add Friend'}
|
||||
</button>
|
||||
) : friendStatus === 'pending' ? (
|
||||
<button
|
||||
className="friend-btn friend-btn-pending"
|
||||
disabled
|
||||
>
|
||||
<i className="fa-solid fa-clock" />
|
||||
{friendDirection === 'sent' ? 'Request Sent' : 'Pending'}
|
||||
</button>
|
||||
) : friendStatus === 'accepted' ? (
|
||||
<button
|
||||
className="friend-btn friend-btn-remove"
|
||||
onClick={handleRemoveFriend}
|
||||
disabled={friendLoading}
|
||||
>
|
||||
<i className="fa-solid fa-user-minus" />
|
||||
{friendLoading ? 'Removing...' : 'Remove Friend'}
|
||||
</button>
|
||||
) : null
|
||||
)}
|
||||
|
||||
{isSelf && (
|
||||
<button
|
||||
className="user-profile-btn user-profile-btn-ghost"
|
||||
onClick={() => {
|
||||
setShowFriends(!showFriends);
|
||||
if (!showFriends) loadFriends();
|
||||
}}
|
||||
>
|
||||
<i className="fa-solid fa-user-group" /> Friends
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSelf && showFriends && (
|
||||
<div className="user-profile-friends">
|
||||
{friends.length === 0 ? (
|
||||
<p className="user-profile-no-friends">No friends yet</p>
|
||||
) : (
|
||||
<div className="user-profile-friends-list">
|
||||
{friends.map((friend) => (
|
||||
<div key={friend.id} className="user-profile-friend">
|
||||
<span>{friend.username}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="user-profile-footer">
|
||||
<p className="user-profile-seed">Seed: {island.seed}</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue