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 (
LIVE
{viewerCount}
{isConnecting ? (
) : error ? (

{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