Update live flow and search dedupe

This commit is contained in:
SocioWire 2026-01-08 05:26:11 +00:00
parent 694a0aaaa2
commit 713a35ab75
14 changed files with 1156 additions and 83 deletions

View File

@ -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',

View File

@ -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" };
}
}

View File

@ -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);
}

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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;

View File

@ -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

View File

@ -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>
` : ` ` : `

View File

@ -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)) {

View File

@ -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>
); );
} }

View File

@ -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>

View File

@ -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);

View File

@ -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; }

View File

@ -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,