Update live flow and search dedupe
This commit is contained in:
parent
694a0aaaa2
commit
713a35ab75
|
|
@ -1,5 +1,5 @@
|
||||||
// public/service-worker.js
|
// public/service-worker.js
|
||||||
const CACHE_NAME = 'sociowire-v6';
|
const CACHE_NAME = 'sociowire-v9';
|
||||||
const PRECACHE = [
|
const PRECACHE = [
|
||||||
'/icons/icon-192x192.png',
|
'/icons/icon-192x192.png',
|
||||||
'/icons/icon-512x512.png',
|
'/icons/icon-512x512.png',
|
||||||
|
|
|
||||||
|
|
@ -783,3 +783,147 @@ export async function fetchPostLikeState(postId) {
|
||||||
return { ok: false };
|
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 { useLiveKit } from "./useLiveKit";
|
||||||
import { useDualCamera } from "./useDualCamera";
|
import { useDualCamera } from "./useDualCamera";
|
||||||
import { useAuth } from "../../auth/AuthContext";
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
|
import { fetchPostComments, createPostComment } from "../../api/client";
|
||||||
|
import { CREATE_CATEGORY_MAP, FILTER_MAIN_CATEGORIES } from "../Map/mapConfig";
|
||||||
import "./Live.css";
|
import "./Live.css";
|
||||||
|
|
||||||
const API_BASE = (import.meta?.env?.VITE_API_BASE_URL || "").replace(/\/+$/, "");
|
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 [title, setTitle] = useState("");
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
const [livePostId, setLivePostId] = useState(null);
|
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
|
// Timer state
|
||||||
const [liveStartTime, setLiveStartTime] = useState(null);
|
const [liveStartTime, setLiveStartTime] = useState(null);
|
||||||
const [elapsedTime, setElapsedTime] = useState(0);
|
const [elapsedTime, setElapsedTime] = useState(0);
|
||||||
|
|
||||||
// Comments state
|
// Comments state (stored in the post so they persist after live)
|
||||||
const [comments, setComments] = useState([]);
|
const [comments, setComments] = useState([]);
|
||||||
const [commentInput, setCommentInput] = useState("");
|
const [commentInput, setCommentInput] = useState("");
|
||||||
const commentsEndRef = useRef(null);
|
const commentsEndRef = useRef(null);
|
||||||
|
|
@ -75,6 +80,29 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
|
||||||
|
|
||||||
const error = cameraError || liveKitError;
|
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
|
// Start camera on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
startCameras();
|
startCameras();
|
||||||
|
|
@ -85,9 +113,18 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
|
||||||
|
|
||||||
// Update video element when stream changes
|
// Update video element when stream changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (videoRef.current && mainStream) {
|
const el = videoRef.current;
|
||||||
videoRef.current.srcObject = mainStream;
|
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]);
|
}, [mainStream]);
|
||||||
|
|
||||||
// Timer effect - update every second when live
|
// Timer effect - update every second when live
|
||||||
|
|
@ -133,20 +170,39 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
|
||||||
}
|
}
|
||||||
}, [mainStream, isLive, replaceVideoTrack]);
|
}, [mainStream, isLive, replaceVideoTrack]);
|
||||||
|
|
||||||
// Add a comment
|
const refreshComments = useCallback(async () => {
|
||||||
const handleAddComment = useCallback(() => {
|
if (!livePostId) return;
|
||||||
if (!commentInput.trim()) return;
|
const items = await fetchPostComments(livePostId, 50);
|
||||||
|
if (Array.isArray(items)) {
|
||||||
|
setComments(items);
|
||||||
|
}
|
||||||
|
}, [livePostId]);
|
||||||
|
|
||||||
const newComment = {
|
useEffect(() => {
|
||||||
id: Date.now(),
|
if (!isLive || !livePostId) return;
|
||||||
author: username || "Host",
|
let active = true;
|
||||||
text: commentInput.trim(),
|
const poll = async () => {
|
||||||
timestamp: Date.now(),
|
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("");
|
setCommentInput("");
|
||||||
}, [commentInput, username]);
|
const res = await createPostComment(livePostId, body);
|
||||||
|
if (res?.ok) {
|
||||||
|
await refreshComments();
|
||||||
|
}
|
||||||
|
}, [commentInput, livePostId, refreshComments]);
|
||||||
|
|
||||||
const handleGoLive = useCallback(async () => {
|
const handleGoLive = useCallback(async () => {
|
||||||
if (!authToken) {
|
if (!authToken) {
|
||||||
|
|
@ -187,9 +243,9 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
snippet: "Live now",
|
snippet: `Live now${liveSubCategory ? ` · ${liveSubCategory}` : ""}`,
|
||||||
category: "EVENT",
|
category: liveCategory === "Events" ? "EVENT" : liveCategory.toUpperCase(),
|
||||||
sub_category: "Live",
|
sub_category: liveSubCategory || "Live",
|
||||||
lat: liveCoords?.lat,
|
lat: liveCoords?.lat,
|
||||||
lon: liveCoords?.lon,
|
lon: liveCoords?.lon,
|
||||||
author: username,
|
author: username,
|
||||||
|
|
@ -212,7 +268,18 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
|
||||||
console.error("[LiveKit] Failed to create live post:", err);
|
console.error("[LiveKit] Failed to create live post:", err);
|
||||||
alert("Live started, but we couldn't create the live post.");
|
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) => {
|
const handleEndStream = useCallback(async (save) => {
|
||||||
await stopBroadcast();
|
await stopBroadcast();
|
||||||
|
|
@ -220,6 +287,7 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
|
||||||
|
|
||||||
// Stop egress recording and get video key
|
// Stop egress recording and get video key
|
||||||
let videoKey = "";
|
let videoKey = "";
|
||||||
|
let thumbnailKey = "";
|
||||||
try {
|
try {
|
||||||
const egressRes = await fetch(`${LIVEKIT_API}/api/livekit/egress/stop`, {
|
const egressRes = await fetch(`${LIVEKIT_API}/api/livekit/egress/stop`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -229,6 +297,7 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
|
||||||
if (egressRes.ok) {
|
if (egressRes.ok) {
|
||||||
const egressData = await egressRes.json();
|
const egressData = await egressRes.json();
|
||||||
videoKey = egressData.key || "";
|
videoKey = egressData.key || "";
|
||||||
|
thumbnailKey = egressData.thumbnail_key || "";
|
||||||
console.log("[LiveKit] Egress stopped, video key:", videoKey);
|
console.log("[LiveKit] Egress stopped, video key:", videoKey);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -248,11 +317,8 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
|
||||||
post_id: livePostId,
|
post_id: livePostId,
|
||||||
save: !!save,
|
save: !!save,
|
||||||
video_key: videoKey,
|
video_key: videoKey,
|
||||||
comments: save ? comments.map((c) => ({
|
thumbnail_key: thumbnailKey,
|
||||||
author: c.author,
|
comments: [],
|
||||||
text: c.text,
|
|
||||||
timestamp: c.timestamp,
|
|
||||||
})) : [],
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (res.ok && save) {
|
if (res.ok && save) {
|
||||||
|
|
@ -389,6 +455,30 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
maxLength={100}
|
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">
|
<div className="sw-broadcast-hint">
|
||||||
Using LiveKit - works on 5G/4G/WiFi
|
Using LiveKit - works on 5G/4G/WiFi
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -418,13 +508,16 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
|
||||||
<div className="sw-broadcast-comments-list">
|
<div className="sw-broadcast-comments-list">
|
||||||
{comments.length === 0 ? (
|
{comments.length === 0 ? (
|
||||||
<div className="sw-broadcast-no-comments">
|
<div className="sw-broadcast-no-comments">
|
||||||
No comments yet
|
{livePostId ? "No comments yet" : "Connecting chat..."}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
comments.map((c) => (
|
comments.map((c, idx) => (
|
||||||
<div key={c.id} className="sw-broadcast-comment">
|
<div
|
||||||
<span className="sw-broadcast-comment-author">{c.author}</span>
|
key={c.id || c.ID || c.created_at || c.createdAt || `${c.username || "user"}-${idx}`}
|
||||||
<span className="sw-broadcast-comment-text">{c.text}</span>
|
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>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
@ -434,17 +527,18 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="sw-broadcast-comment-input"
|
className="sw-broadcast-comment-input"
|
||||||
placeholder="Add a comment..."
|
placeholder={livePostId ? "Add a comment..." : "Chat starting..."}
|
||||||
value={commentInput}
|
value={commentInput}
|
||||||
onChange={(e) => setCommentInput(e.target.value)}
|
onChange={(e) => setCommentInput(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleAddComment()}
|
onKeyDown={(e) => e.key === "Enter" && handleAddComment()}
|
||||||
maxLength={200}
|
maxLength={200}
|
||||||
|
disabled={!livePostId}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="sw-broadcast-comment-send"
|
className="sw-broadcast-comment-send"
|
||||||
onClick={handleAddComment}
|
onClick={handleAddComment}
|
||||||
disabled={!commentInput.trim()}
|
disabled={!commentInput.trim() || !livePostId}
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-paper-plane" />
|
<i className="fa-solid fa-paper-plane" />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -64,8 +64,8 @@ export default function VideoModal({ camera, onClose }) {
|
||||||
const name = camera.name_fr || camera.name_en || "Camera";
|
const name = camera.name_fr || camera.name_en || "Camera";
|
||||||
const region = formatRegionLabel(camera.region);
|
const region = formatRegionLabel(camera.region);
|
||||||
const route = camera.route || "";
|
const route = camera.route || "";
|
||||||
const embedUrl = camera.embed_url || "";
|
const embedUrl = (camera.embed_url || "").toString().trim();
|
||||||
const videoUrl = camera.video_url || "";
|
const videoUrl = (camera.video_url || "").toString().trim();
|
||||||
const lat = camera.lat;
|
const lat = camera.lat;
|
||||||
const lng = camera.lng;
|
const lng = camera.lng;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -211,6 +211,9 @@ export function useLiveKit({ roomName, userID, username, token: authToken }) {
|
||||||
// Replace video track (for camera switch)
|
// Replace video track (for camera switch)
|
||||||
const replaceVideoTrack = useCallback(async (newTrack) => {
|
const replaceVideoTrack = useCallback(async (newTrack) => {
|
||||||
if (!roomRef.current || !newTrack) return false;
|
if (!roomRef.current || !newTrack) return false;
|
||||||
|
if (localVideoTrackRef.current === newTrack) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Unpublish old track
|
// Unpublish old track
|
||||||
|
|
|
||||||
|
|
@ -425,7 +425,7 @@ function extractCameraId(url) {
|
||||||
|
|
||||||
// Build stream proxy URL for camera
|
// Build stream proxy URL for camera
|
||||||
function getCameraStreamUrl(postData) {
|
function getCameraStreamUrl(postData) {
|
||||||
const embedUrl = postData?.embed_url || postData?.video_url || '';
|
const embedUrl = normalizeMediaUrl(postData?.embed_url || postData?.video_url || '');
|
||||||
const cameraId = extractCameraId(embedUrl);
|
const cameraId = extractCameraId(embedUrl);
|
||||||
if (cameraId) {
|
if (cameraId) {
|
||||||
return `/live/api/stream/${cameraId}`;
|
return `/live/api/stream/${cameraId}`;
|
||||||
|
|
@ -786,6 +786,14 @@ function normalizeImageUrl(raw) {
|
||||||
return value;
|
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 = {}) {
|
function closeCenteredOverlay(map, opts = {}) {
|
||||||
try {
|
try {
|
||||||
const ov = map?.__swCenteredOverlay;
|
const ov = map?.__swCenteredOverlay;
|
||||||
|
|
@ -844,8 +852,8 @@ function openCameraOverlay({ map, post, theme, fullScreen, interactionState }) {
|
||||||
const postData = post || {};
|
const postData = post || {};
|
||||||
const title = postData.title || "Live Camera";
|
const title = postData.title || "Live Camera";
|
||||||
const region = postData.region || "";
|
const region = postData.region || "";
|
||||||
const embedUrl = postData.embed_url || "";
|
const embedUrl = normalizeMediaUrl(postData.embed_url || "");
|
||||||
const videoUrl = postData.video_url || "";
|
const videoUrl = normalizeMediaUrl(postData.video_url || "");
|
||||||
const lat = postData.lat;
|
const lat = postData.lat;
|
||||||
const lon = postData.lon;
|
const lon = postData.lon;
|
||||||
const postID = postData.id || 0;
|
const postID = postData.id || 0;
|
||||||
|
|
@ -1221,9 +1229,9 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
||||||
controls
|
controls
|
||||||
playsinline
|
playsinline
|
||||||
style="position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:#000;"
|
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>
|
</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;">
|
<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>
|
<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">
|
<div class="sw-modal-cta-row">
|
||||||
${isCamera ? `
|
${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
|
<i class="fa-solid fa-video"></i> Watch Live Stream
|
||||||
</a>
|
</a>
|
||||||
` : `
|
` : `
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
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 { categoryCode, matchesSubFilter } from "./mapFilter";
|
||||||
import { getCoords, getViewFromMap, haversineKm } from "./mapGeo";
|
import { getCoords, getViewFromMap, haversineKm } from "./mapGeo";
|
||||||
import { createMarkerForPost, createSimpleMarkerForPost } from "./markerManager";
|
import { createMarkerForPost, createSimpleMarkerForPost } from "./markerManager";
|
||||||
|
|
@ -52,16 +52,29 @@ function getTemplateKey(post) {
|
||||||
return getTemplateKeyForPost(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) {
|
function resolvePostImage(post) {
|
||||||
return (
|
const candidates = [
|
||||||
post?.image_small ||
|
post?.image_small,
|
||||||
post?.image_medium ||
|
post?.image_medium,
|
||||||
post?.image_large ||
|
post?.image_large,
|
||||||
post?.image ||
|
post?.image,
|
||||||
post?.media_url ||
|
post?.media_url,
|
||||||
post?.image_url ||
|
post?.image_url,
|
||||||
""
|
];
|
||||||
).toString();
|
for (const candidate of candidates) {
|
||||||
|
const normalized = normalizeMediaCandidate(candidate);
|
||||||
|
if (normalized) return normalized;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function postHaystack(p) {
|
function postHaystack(p) {
|
||||||
|
|
@ -1268,10 +1281,48 @@ export function usePostsEngine({
|
||||||
|
|
||||||
const handleIncomingPost = useCallback(
|
const handleIncomingPost = useCallback(
|
||||||
(p) => {
|
(p) => {
|
||||||
|
const incomingId = Number(p?.id ?? p?.post_id ?? p?.postId ?? 0) || null;
|
||||||
const key = getPostKey(p);
|
const key = getPostKey(p);
|
||||||
if (key) {
|
if (key) {
|
||||||
const exists = (allPostsRef.current || []).some((x) => getPostKey(x) === key);
|
const existingIdx = (allPostsRef.current || []).findIndex((x) => getPostKey(x) === key);
|
||||||
if (exists) return;
|
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 map = mapRef.current;
|
||||||
const center = map?.getCenter?.();
|
const center = map?.getCenter?.();
|
||||||
|
|
@ -1332,6 +1383,7 @@ export function usePostsEngine({
|
||||||
payload?.truthScore
|
payload?.truthScore
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const applyPatch = (patch) => {
|
||||||
const updated = [];
|
const updated = [];
|
||||||
let changed = false;
|
let changed = false;
|
||||||
for (const p of allPostsRef.current || []) {
|
for (const p of allPostsRef.current || []) {
|
||||||
|
|
@ -1339,7 +1391,7 @@ export function usePostsEngine({
|
||||||
updated.push(p);
|
updated.push(p);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const next = normalizePostId({ ...p, ...(postPatch || {}) });
|
const next = normalizePostId({ ...p, ...(patch || {}) });
|
||||||
if (counts) {
|
if (counts) {
|
||||||
if (Number.isFinite(counts.view_count)) next.view_count = counts.view_count;
|
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.like_count)) next.like_count = counts.like_count;
|
||||||
|
|
@ -1354,6 +1406,38 @@ export function usePostsEngine({
|
||||||
allPostsRef.current = updated;
|
allPostsRef.current = updated;
|
||||||
refreshVisibleAndMarkers(timeHours, true);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyPatch(postPatch);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (Number.isFinite(truthScore)) {
|
if (Number.isFinite(truthScore)) {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import {
|
||||||
togglePostLike,
|
togglePostLike,
|
||||||
updatePostVisibility,
|
updatePostVisibility,
|
||||||
fetchProfile,
|
fetchProfile,
|
||||||
|
editPost,
|
||||||
|
deletePost,
|
||||||
} from "../../api/client";
|
} from "../../api/client";
|
||||||
import { useAuth } from "../../auth/AuthContext";
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
import { trackEvent } from "../../utils/analytics";
|
import { trackEvent } from "../../utils/analytics";
|
||||||
|
|
@ -129,7 +131,7 @@ async function getAvatarForUser(name) {
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PostCard({ post, selected, onSelect }) {
|
export default function PostCard({ post, selected, onSelect, onPostDeleted, onPostUpdated }) {
|
||||||
const [liked, setLiked] = useState(false);
|
const [liked, setLiked] = useState(false);
|
||||||
const [authed, setAuthed] = useState(isAuthed());
|
const [authed, setAuthed] = useState(isAuthed());
|
||||||
const [activeImageIndex, setActiveImageIndex] = useState(0);
|
const [activeImageIndex, setActiveImageIndex] = useState(0);
|
||||||
|
|
@ -153,6 +155,15 @@ export default function PostCard({ post, selected, onSelect }) {
|
||||||
}));
|
}));
|
||||||
const { username, token } = useAuth();
|
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;
|
const postId = post?.id ?? post?._id ?? 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -462,16 +473,34 @@ export default function PostCard({ post, selected, onSelect }) {
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isAuthor ? (
|
{isAuthor ? (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={"sw-wall-btn" + (showPrivacy ? " is-active" : "")}
|
className={"sw-wall-btn" + (showPrivacy ? " is-active" : "")}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setShowPrivacy((prev) => !prev);
|
setShowPrivacy((prev) => !prev);
|
||||||
|
setShowEditDelete(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<i className="fa-solid fa-lock" /> Visibility
|
<i className="fa-solid fa-lock" /> Visibility
|
||||||
</button>
|
</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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -489,6 +518,7 @@ export default function PostCard({ post, selected, onSelect }) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="public">Public</option>
|
<option value="public">Public</option>
|
||||||
|
<option value="friends">Friends Only</option>
|
||||||
<option value="private">Private</option>
|
<option value="private">Private</option>
|
||||||
<option value="group">Group</option>
|
<option value="group">Group</option>
|
||||||
<option value="custom">Custom</option>
|
<option value="custom">Custom</option>
|
||||||
|
|
@ -552,6 +582,109 @@ export default function PostCard({ post, selected, onSelect }) {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,15 @@
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
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 { useAuth } from '../../auth/AuthContext';
|
||||||
import '../../styles/profile.css';
|
import '../../styles/profile.css';
|
||||||
|
import '../Friends/Friends.css';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UserProfile: Modal showing user info with "Visit Island" button
|
* 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();
|
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(() => {
|
useEffect(() => {
|
||||||
if (!username) return;
|
if (!username) return;
|
||||||
|
|
||||||
|
|
@ -47,6 +73,33 @@ export default function UserProfile({ username, onClose, onVisitIsland }) {
|
||||||
if (fileRef.current) fileRef.current.click();
|
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 handleAvatarChange = async (e) => {
|
||||||
const file = e.target.files && e.target.files[0];
|
const file = e.target.files && e.target.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
@ -165,8 +218,66 @@ export default function UserProfile({ username, onClose, onVisitIsland }) {
|
||||||
>
|
>
|
||||||
🏝️ Visit Island
|
🏝️ Visit Island
|
||||||
</button>
|
</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>
|
</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">
|
<div className="user-profile-footer">
|
||||||
<p className="user-profile-seed">Seed: {island.seed}</p>
|
<p className="user-profile-seed">Seed: {island.seed}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -100,8 +100,15 @@ function normalizeOne(r) {
|
||||||
? r.tags
|
? 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 {
|
return {
|
||||||
id: (r.id ?? r._id ?? r.key ?? title).toString(),
|
id: (postID ?? r.id ?? r._id ?? r.key ?? title).toString(),
|
||||||
type: kind,
|
type: kind,
|
||||||
kind: kind, // Keep both for compatibility
|
kind: kind, // Keep both for compatibility
|
||||||
title,
|
title,
|
||||||
|
|
@ -110,6 +117,7 @@ function normalizeOne(r) {
|
||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
tags,
|
tags,
|
||||||
|
post_id: postID,
|
||||||
raw: r,
|
raw: r,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -139,6 +147,7 @@ async function tryFetchAnyPath(q, { signal, limit } = {}) {
|
||||||
const locations = [];
|
const locations = [];
|
||||||
const profiles = [];
|
const profiles = [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
|
const seenPostIDs = new Set();
|
||||||
const minLocationResults = Math.min(2, maxResults);
|
const minLocationResults = Math.min(2, maxResults);
|
||||||
const locationKinds = new Set([
|
const locationKinds = new Set([
|
||||||
"place", "city", "region", "province", "state",
|
"place", "city", "region", "province", "state",
|
||||||
|
|
@ -176,8 +185,18 @@ async function tryFetchAnyPath(q, { signal, limit } = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const it of normalized) {
|
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;
|
if (seen.has(it.id)) continue;
|
||||||
seen.add(it.id);
|
seen.add(it.id);
|
||||||
|
if (rawPostID) seenPostIDs.add(rawPostID);
|
||||||
const kind = (it.kind || "").toLowerCase();
|
const kind = (it.kind || "").toLowerCase();
|
||||||
if (locationKinds.has(kind)) {
|
if (locationKinds.has(kind)) {
|
||||||
locations.push(it);
|
locations.push(it);
|
||||||
|
|
|
||||||
|
|
@ -314,6 +314,60 @@ body[data-theme="light"] .sw-wall-image-fallback{
|
||||||
background: rgba(56,189,248,0.35);
|
background: rgba(56,189,248,0.35);
|
||||||
border-color: rgba(56,189,248,0.8);
|
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; }
|
.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; }
|
.km-filter input[type="range"]{ width:160px; }
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,39 @@ body[data-theme="blue"] .user-profile-container{
|
||||||
margin: 0;
|
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 */
|
/* Loading & No Island */
|
||||||
|
|
||||||
.user-profile-loading,
|
.user-profile-loading,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue