import React, { useCallback, useEffect, useRef, useState, useMemo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useTranslation } from "react-i18next";
import { Room, RoomEvent } from "livekit-client";
import maplibregl from "maplibre-gl";
import { useAuth } from "../../auth/AuthContext";
import {
recordPostView,
togglePostLike,
recordPostShare,
fetchPostComments,
createPostComment,
fetchPostLikeState,
fetchPostTruth,
votePostTruth,
editPost,
deletePost,
updatePostVisibility,
} from "../../api/client";
// ============================================================================
// CONSTANTS
// ============================================================================
const LIVEKIT_URL = "wss://stream.sociowire.com";
const LIVEKIT_API = "https://stream.sociowire.com";
const MAPTILER_KEY = "DvwhzlPmC55ZK28nLtXL";
const MEDIA_CDN = ""; // Use relative paths - served from same origin
// ============================================================================
// UTILITIES
// ============================================================================
function normalizeMediaUrl(url) {
if (!url) return "";
if (url.startsWith("http://") || url.startsWith("https://")) return url;
if (url.startsWith("/")) return `${MEDIA_CDN}${url}`;
return `${MEDIA_CDN}/${url}`;
}
function formatCount(n) {
if (n == null) return "0";
const num = Number(n) || 0;
if (num >= 1_000_000) return (num / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
if (num >= 1_000) return (num / 1_000).toFixed(1).replace(/\.0$/, "") + "K";
return num.toString();
}
function relativeTime(dateStr, t) {
if (!dateStr) return "";
const d = new Date(dateStr);
const now = new Date();
const diff = Math.floor((now - d) / 1000);
if (diff < 60) return t("time.now");
if (diff < 3600) return t("time.minutes", { count: Math.floor(diff / 60) });
if (diff < 86400) return t("time.hours", { count: Math.floor(diff / 3600) });
if (diff < 604800) return t("time.days", { count: Math.floor(diff / 86400) });
return d.toLocaleDateString();
}
function getPostType(post) {
if (!post) return "link";
const embedUrl = (post.embed_url || "").toString().toLowerCase();
const videoUrl = (post.video_url || "").toString().toLowerCase();
const category = (post.category || "").toString().toLowerCase();
const contentType = (post.content_type || "").toString().toLowerCase();
const url = (post.url || "").toString();
const snippet = (post.snippet || post.summary || "").toString();
if (category === "camera" || contentType === "camera" || post.template === "camera" ||
embedUrl.includes("quebec511") || embedUrl.includes("511")) return "camera";
if (embedUrl.startsWith("livekit://") || videoUrl.startsWith("livekit://") ||
category === "live" || contentType === "live") return "live";
if (post.video_url) return "video";
if (["photo", "picture", "image", "gallery"].includes(contentType) ||
(hasImages(post) && snippet.length < 100 && !url)) return "picture";
if (["text", "post", "story", "status"].includes(contentType) ||
(snippet.length > 100 && !url && !hasImages(post))) return "story";
return "link";
}
function hasImages(post) {
return !!(post?.image_url || post?.thumbnail_url ||
(Array.isArray(post?.media_urls) && post.media_urls.length > 0) ||
(Array.isArray(post?.image_urls) && post.image_urls.length > 0) ||
(Array.isArray(post?.media) && post.media.length > 0));
}
function getLiveKitRoomName(post) {
const raw = (post?.embed_url || post?.video_url || "").toString().trim();
return raw.startsWith("livekit://") ? raw.slice("livekit://".length) : raw;
}
function getHeroImage(post) {
if (post?.image_url) return normalizeMediaUrl(post.image_url);
if (post?.thumbnail_url) return normalizeMediaUrl(post.thumbnail_url);
const media = post?.media;
if (Array.isArray(media) && media.length > 0) {
return normalizeMediaUrl(media[0]?.url || media[0]?.thumbnail_url || "");
}
return "";
}
function getSourceDomain(url) {
if (!url) return null;
try { return new URL(url).hostname.replace(/^www\./, ""); } catch { return null; }
}
const CATEGORY_CONFIG = {
news: { icon: "fa-newspaper", gradient: "from-blue-500 to-cyan-500", color: "#60a5fa" },
politics: { icon: "fa-landmark", gradient: "from-purple-500 to-indigo-500", color: "#a78bfa" },
technology: { icon: "fa-microchip", gradient: "from-emerald-500 to-teal-500", color: "#34d399" },
science: { icon: "fa-flask", gradient: "from-violet-500 to-purple-500", color: "#8b5cf6" },
health: { icon: "fa-heart-pulse", gradient: "from-rose-500 to-pink-500", color: "#fb7185" },
sports: { icon: "fa-futbol", gradient: "from-orange-500 to-amber-500", color: "#fb923c" },
entertainment: { icon: "fa-film", gradient: "from-pink-500 to-rose-500", color: "#f472b6" },
business: { icon: "fa-briefcase", gradient: "from-slate-500 to-zinc-500", color: "#94a3b8" },
environment: { icon: "fa-leaf", gradient: "from-green-500 to-emerald-500", color: "#22c55e" },
weather: { icon: "fa-cloud-sun", gradient: "from-sky-500 to-blue-500", color: "#38bdf8" },
traffic: { icon: "fa-car", gradient: "from-yellow-500 to-amber-500", color: "#fbbf24" },
live: { icon: "fa-broadcast-tower", gradient: "from-red-500 to-rose-500", color: "#ef4444" },
camera: { icon: "fa-video", gradient: "from-red-500 to-rose-500", color: "#ef4444" },
default: { icon: "fa-newspaper", gradient: "from-blue-500 to-cyan-500", color: "#60a5fa" },
};
function getCategoryConfig(category) {
return CATEGORY_CONFIG[(category || "").toLowerCase()] || CATEGORY_CONFIG.default;
}
// ============================================================================
// HERO COMPONENTS
// ============================================================================
function HeroLive({ post, authToken, username }) {
const roomName = getLiveKitRoomName(post);
const userID = username || `viewer_${Date.now()}`;
const [isConnecting, setIsConnecting] = useState(true);
const [error, setError] = useState(null);
const [viewerCount, setViewerCount] = useState(0);
const [videoTrack, setVideoTrack] = useState(null);
const roomRef = useRef(null);
const videoRef = useRef(null);
const getToken = useCallback(async () => {
const res = await fetch(`${LIVEKIT_API}/api/livekit/token`, {
method: "POST",
headers: { "Content-Type": "application/json", ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}) },
body: JSON.stringify({ room: roomName, identity: userID, name: username || userID, canPublish: false, canSubscribe: true, canPublishData: true }),
});
if (!res.ok) throw new Error("Failed to get LiveKit token");
return (await res.json()).token;
}, [authToken, roomName, userID, username]);
useEffect(() => {
if (!roomName) { setError("Missing room"); setIsConnecting(false); return; }
let mounted = true;
const connect = async () => {
setIsConnecting(true);
try {
const token = await getToken();
if (!mounted) return;
const room = new Room({ adaptiveStream: true, dynacast: true });
roomRef.current = room;
room.on(RoomEvent.TrackSubscribed, (track) => { if (track.kind === "video") setVideoTrack(track); });
room.on(RoomEvent.ParticipantConnected, () => setViewerCount(p => p + 1));
room.on(RoomEvent.ParticipantDisconnected, () => setViewerCount(p => Math.max(0, p - 1)));
room.on(RoomEvent.Disconnected, () => setViewerCount(0));
await room.connect(LIVEKIT_URL, token);
setViewerCount(room.participants?.size || 0);
} catch (err) { if (mounted) setError(err.message); }
finally { if (mounted) setIsConnecting(false); }
};
connect();
return () => { mounted = false; roomRef.current?.disconnect(); roomRef.current = null; };
}, [roomName, getToken]);
useEffect(() => {
if (!videoRef.current || !videoTrack) return;
videoTrack.attach(videoRef.current);
return () => { try { videoTrack.detach(videoRef.current); } catch {} };
}, [videoTrack]);
return (
{isConnecting ? (
) : error ? (
) : (
)}
);
}
// Extract camera ID from Quebec 511 URL like https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3978
function extractCameraId(url) {
if (!url) return null;
const match = url.match(/[?&]id=(\d+)/);
return match ? match[1] : null;
}
// Build stream proxy URL for Quebec511 cameras
function getCameraStreamUrl(embedUrl, videoUrl) {
const url = embedUrl || videoUrl || '';
const cameraId = extractCameraId(url);
if (cameraId) {
return `/live/api/stream/${cameraId}`;
}
return url;
}
function HeroCamera({ post }) {
const embedUrl = (post?.embed_url || "").toString().trim();
const videoUrl = (post?.video_url || "").toString().trim();
const mapRef = useRef(null);
const mapContainerRef = useRef(null);
const lat = Number(post?.lat || post?.latitude);
const lng = Number(post?.lng || post?.longitude || post?.lon);
const hasLocation = Number.isFinite(lat) && Number.isFinite(lng);
// Check if this is a Quebec511 camera
const isQuebec511 = embedUrl.includes('quebec511') || embedUrl.includes('511');
const streamUrl = isQuebec511 ? getCameraStreamUrl(embedUrl, videoUrl) : null;
useEffect(() => {
if (!mapContainerRef.current || !hasLocation || mapRef.current) return;
const map = new maplibregl.Map({
container: mapContainerRef.current,
style: `https://api.maptiler.com/maps/streets-v2-dark/style.json?key=${MAPTILER_KEY}`,
center: [lng, lat], zoom: 13, attributionControl: false, interactive: false,
});
const el = document.createElement("div");
el.className = "w-6 h-6 rounded-full bg-red-500 border-2 border-white flex items-center justify-center shadow-lg";
el.innerHTML = ``;
new maplibregl.Marker({ element: el }).setLngLat([lng, lat]).addTo(map);
mapRef.current = map;
return () => { map.remove(); mapRef.current = null; };
}, [hasLocation, lat, lng]);
return (
LIVE
{isQuebec511 && streamUrl ? (
// Quebec511 cameras use proxy video stream
) : embedUrl ? (
// Other cameras use iframe
) : videoUrl ? (
) : (
)}
{hasLocation && (
)}
);
}
function HeroVideo({ post }) {
return (
);
}
function HeroPicture({ gallery, imageIndex, setImageIndex, post, onFullscreen }) {
const currentImage = gallery[imageIndex] || getHeroImage(post);
if (!currentImage) return (
);
return (

{gallery.length > 1 && (
<>
{gallery.map((_, i) => (
>
)}
);
}
function HeroStory({ post, gallery, imageIndex, setImageIndex }) {
const heroImage = gallery[imageIndex] || getHeroImage(post);
const author = post?.author || post?.username || "Anonymous";
const config = getCategoryConfig(post?.category);
// If has image, show it like HeroLink
if (heroImage) {
return (
{gallery.length > 1 && (
<>
{imageIndex + 1} / {gallery.length}
>
)}
);
}
// No image - show gradient with author
return (
{author.charAt(0).toUpperCase()}
);
}
function HeroLink({ post, gallery, imageIndex, setImageIndex }) {
const heroImage = getHeroImage(post);
const sourceDomain = getSourceDomain(post?.url);
const config = getCategoryConfig(post?.category);
if (!heroImage) {
return (
{post?.category || "Article"}
);
}
return (
{sourceDomain && (
{sourceDomain}
)}
{gallery.length > 1 && (
{imageIndex + 1} / {gallery.length}
)}
);
}
// ============================================================================
// FULLSCREEN GALLERY
// ============================================================================
function FullscreenGallery({ gallery, imageIndex, setImageIndex, onClose }) {
useEffect(() => {
const handleKey = (e) => {
if (e.key === "Escape") onClose();
if (e.key === "ArrowLeft") setImageIndex((i) => (i - 1 + gallery.length) % gallery.length);
if (e.key === "ArrowRight") setImageIndex((i) => (i + 1) % gallery.length);
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [gallery.length, onClose, setImageIndex]);
return (
e.stopPropagation()} />
{gallery.length > 1 && (
<>
>
)}
{gallery.length > 1 && (
{imageIndex + 1} / {gallery.length}
)}
);
}
// ============================================================================
// MAIN COMPONENT
// ============================================================================
export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDeleted }) {
const { t } = useTranslation();
const { username, token } = useAuth();
const modalRef = useRef(null);
// State
const [counts, setCounts] = useState({
views: post?.view_count || 0, likes: post?.like_count || 0,
shares: post?.share_count || 0, comments: post?.comment_count || 0,
});
const [liked, setLiked] = useState(false);
const [truth, setTruth] = useState({ score: 50, count: 0 });
const [comments, setComments] = useState([]);
const [commentInput, setCommentInput] = useState("");
const [commentSending, setCommentSending] = useState(false);
const [showComments, setShowComments] = useState(false);
const [showEdit, setShowEdit] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [editTitle, setEditTitle] = useState(post?.title || "");
const [editSnippet, setEditSnippet] = useState(post?.snippet || "");
const [saving, setSaving] = useState(false);
const [imageIndex, setImageIndex] = useState(0);
const [fullscreen, setFullscreen] = useState(false);
const commentsEndRef = useRef(null);
const postId = post?.id || post?._id;
const isAuthor = username && (post?.author === username || post?.username === username);
const postType = useMemo(() => getPostType(post), [post]);
const config = getCategoryConfig(post?.category);
const author = post?.author || post?.username || post?.source_name || t("common.anon");
// Media gallery
const gallery = useMemo(() => {
const seen = new Set();
const images = [];
const add = (url) => { const n = normalizeMediaUrl(url); if (n && !seen.has(n)) { seen.add(n); images.push(n); } };
[post?.media_urls, post?.image_urls].forEach(arr => arr?.forEach(add));
[post?.image_large, post?.image_medium, post?.image_small, post?.image_url, post?.image].forEach(add);
post?.media?.forEach(m => add(m?.url || m?.thumbnail_url));
return images;
}, [post]);
// Initial data fetch
useEffect(() => {
if (!postId) return;
recordPostView(postId).then(res => {
if (res?.counts) setCounts(c => ({ ...c, views: res.counts.view_count || c.views, likes: res.counts.like_count || c.likes }));
});
fetchPostLikeState(postId).then(res => { if (res?.liked !== undefined) setLiked(res.liked); });
fetchPostTruth(postId).then(res => { if (res) setTruth({ score: res.truth_score ?? res.user_score ?? 50, count: res.vote_count ?? 0 }); });
}, [postId]);
// Fetch comments
const refreshComments = useCallback(async () => {
if (!postId) return;
const items = await fetchPostComments(postId, 50);
if (Array.isArray(items)) { setComments(items); setCounts(c => ({ ...c, comments: items.length })); }
}, [postId]);
useEffect(() => { refreshComments(); }, [refreshComments]);
useEffect(() => { if (showComments) commentsEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [comments, showComments]);
// Handlers
const handleLike = useCallback(async () => {
if (!postId) return;
const newLiked = !liked;
setLiked(newLiked);
setCounts(c => ({ ...c, likes: c.likes + (newLiked ? 1 : -1) }));
const res = await togglePostLike(postId, newLiked);
if (res?.counts) setCounts(c => ({ ...c, likes: res.counts.like_count ?? c.likes }));
}, [postId, liked]);
const handleShare = useCallback(async () => {
if (!postId) return;
const shareUrl = `${window.location.origin}/post/${postId}`;
if (navigator.share) { try { await navigator.share({ title: post?.title, url: shareUrl }); } catch {} }
else { navigator.clipboard?.writeText(shareUrl); }
const res = await recordPostShare(postId);
if (res?.counts) setCounts(c => ({ ...c, shares: res.counts.share_count ?? c.shares }));
}, [postId, post?.title]);
const handleTruthVote = useCallback(async (vote) => {
if (!postId) return;
const res = await votePostTruth(postId, vote);
if (res) setTruth({ score: res.truth_score ?? res.user_score ?? truth.score, count: res.vote_count ?? truth.count });
}, [postId, truth]);
const handleComment = useCallback(async () => {
const body = commentInput.trim();
if (!body || !postId || commentSending) return;
setCommentInput("");
setCommentSending(true);
const tempId = `temp_${Date.now()}`;
const newComment = { id: tempId, body, author: username || "You", created_at: new Date().toISOString(), _pending: true };
setComments(prev => [...prev, newComment]);
setCounts(c => ({ ...c, comments: c.comments + 1 }));
const res = await createPostComment(postId, body);
setCommentSending(false);
if (res?.ok) {
// Success - update with server response or just mark as complete
if (res?.comment) {
setComments(prev => prev.map(c => c.id === tempId ? { ...res.comment, _pending: false } : c));
} else {
setComments(prev => prev.map(c => c.id === tempId ? { ...c, _pending: false } : c));
}
// Refresh comments to get the real list
refreshComments();
} else {
setComments(prev => prev.map(c => c.id === tempId ? { ...c, _failed: true, _pending: false } : c));
}
}, [commentInput, postId, commentSending, username]);
const handleSaveEdit = useCallback(async () => {
if (!postId || saving) return;
setSaving(true);
const res = await editPost(postId, { title: editTitle, snippet: editSnippet }, token);
setSaving(false);
if (res?.ok) { setShowEdit(false); onPostUpdated?.({ ...post, title: editTitle, snippet: editSnippet }); }
}, [postId, editTitle, editSnippet, token, saving, post, onPostUpdated]);
const handleDelete = useCallback(async () => {
if (!postId || saving) return;
setSaving(true);
const res = await deletePost(postId, token);
setSaving(false);
if (res?.ok) { onPostDeleted?.(postId); onClose?.(); }
}, [postId, token, saving, onPostDeleted, onClose]);
// Close on escape + prevent body scroll
useEffect(() => {
const handleKey = (e) => { if (e.key === "Escape" && !fullscreen) onClose?.(); };
window.addEventListener("keydown", handleKey);
// Prevent body scroll when modal is open
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
window.removeEventListener("keydown", handleKey);
document.body.style.overflow = originalOverflow;
};
}, [onClose, fullscreen]);
if (!post) return null;
return (
<>
{ if (e.target === e.currentTarget) onClose?.(); }}
>
e.stopPropagation()}
>
{/* Hero Section */}
{postType === "live" ?
: postType === "camera" ?
: postType === "video" ?
: postType === "picture" ?
setFullscreen(true)} />
: postType === "story" ?
: }
{/* Close Button */}
{/* Category Badge */}
{post?.category || "News"}
{(postType === "live" || postType === "camera") && }
{/* Content Section */}
{/* Title */}
{post?.title && (
{post.title}
)}
{/* Author Row */}
{author.charAt(0).toUpperCase()}
@{author}
{relativeTime(post?.published_at || post?.created_at, t)}
{isAuthor && (
setShowEdit(!showEdit)}
className="w-9 h-9 rounded-full bg-themed-accent flex items-center justify-center text-accent">
setShowDelete(!showDelete)}
className="w-9 h-9 rounded-full bg-red-500/20 flex items-center justify-center text-red-400">
)}
{/* Edit Panel */}
{showEdit && (
setEditTitle(e.target.value)} placeholder={t("post.title")}
className="w-full px-4 py-3 mb-3 rounded-xl bg-themed-primary border border-themed text-themed-primary placeholder:text-themed-muted focus:outline-none focus:border-accent" />
)}
{/* Delete Confirm */}
{showDelete && (
{t("post.deletePost")}
)}
{/* Description / Story Text */}
{(post?.snippet || post?.summary) && (
{post?.snippet || post?.summary}
)}
{/* Tags */}
{post?.tags?.length > 0 && (
{post.tags.slice(0, 6).map((tag, i) => (
#{tag}
))}
)}
{/* Source Link */}
{post?.url && (
{getSourceDomain(post.url) || post.url}
)}
{/* Stats & Actions */}
{/* Stats Row */}
{[
{ icon: "fa-eye", value: counts.views, label: "views" },
{ icon: "fa-heart", value: counts.likes, label: "likes" },
{ icon: "fa-share", value: counts.shares, label: "shares" },
{ icon: "fa-comment", value: counts.comments, label: "comments" },
].map(({ icon, value, label }) => (
{formatCount(value)}
))}
{/* Truth Score */}
handleTruthVote("false")}
className="w-12 h-12 rounded-full bg-red-500/20 border border-red-500/30 text-red-400 flex items-center justify-center hover:bg-red-500/30">
= 70 ? 'text-emerald-400' : truth.score >= 50 ? 'text-amber-400' : 'text-red-400'}`}>
{Math.round(truth.score)}
/ 100
= 70 ? 'bg-gradient-to-r from-emerald-500 to-teal-400' : truth.score >= 50 ? 'bg-gradient-to-r from-amber-500 to-orange-400' : 'bg-gradient-to-r from-red-500 to-pink-400'}`}
initial={{ width: 0 }} animate={{ width: `${truth.score}%` }} transition={{ duration: 0.6 }} />
{truth.count} votes
handleTruthVote("true")}
className="w-12 h-12 rounded-full bg-emerald-500/20 border border-emerald-500/30 text-emerald-400 flex items-center justify-center hover:bg-emerald-500/30">
{/* Action Buttons */}
{t("post.like")}
setShowComments(!showComments)}
className={`flex items-center justify-center gap-2 py-3 rounded-xl font-semibold transition-all
${showComments ? 'bg-accent/20 text-accent border border-accent/30' : 'bg-themed-elevated text-themed-secondary border border-themed hover:border-accent/30 hover:text-accent'}`}>
{t("post.comment")}
{t("post.share")}
{/* Comments Section */}
{showComments && (
{t("post.comment")}
{comments.length}
{/* Comments List */}
{comments.length === 0 ? (
{t("postModal.noComments")}
) : (
comments.map((c) => (
@{c.author}
{relativeTime(c.created_at, t)}
{c._failed && Failed}
{c.body}
))
)}
{/* Comment Input */}
setCommentInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleComment(); } }}
placeholder={token ? t("postModal.writeComment") : t("post.signInToComment")}
disabled={!token || commentSending}
className="flex-1 px-4 py-3 rounded-xl bg-themed-primary border border-themed text-themed-primary placeholder:text-themed-muted focus:outline-none focus:border-accent disabled:opacity-50" />
{commentSending ? : }
)}
{/* Fullscreen Gallery */}
{fullscreen && gallery.length > 0 && (
setFullscreen(false)} />
)}
>
);
}