diff --git a/package.json b/package.json index 45b30a2..83bb999 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sociowire-frontend", "private": true, - "version": "0.0.66", + "version": "0.0.75", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/src/components/Map/markerManager.js b/src/components/Map/markerManager.js index 3d58139..63bec39 100644 --- a/src/components/Map/markerManager.js +++ b/src/components/Map/markerManager.js @@ -1189,13 +1189,6 @@ function openCameraOverlay({ map, post, theme, fullScreen, interactionState }) { export function openCenteredOverlay({ map, post, theme, fullScreen = false }) { if (!map) return; - if (post?.content_type === "live") { - try { - window.dispatchEvent(new CustomEvent("livekit-open", { detail: post })); - } catch {} - return; - } - // Weather posts open in a dedicated WeatherModal if (post?.content_type === "weather") { try { @@ -1204,6 +1197,13 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) { return; } + // Use the React PostDetailModal for ALL post types (including live, camera, video, etc.) + try { + window.dispatchEvent(new CustomEvent("sw:openPostDetail", { detail: { post } })); + } catch {} + return; + + // LEGACY CODE BELOW - kept for reference but not used anymore closeCenteredOverlay(map, { force: true, reason: "switch", skipHistory: true }); const interactionState = { diff --git a/src/components/Posts/PostDetailModal.jsx b/src/components/Posts/PostDetailModal.jsx index 75a7aa5..833f8f0 100644 --- a/src/components/Posts/PostDetailModal.jsx +++ b/src/components/Posts/PostDetailModal.jsx @@ -1,6 +1,8 @@ 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, @@ -16,8 +18,17 @@ import { updatePostVisibility, } from "../../api/client"; -const MEDIA_CDN = "https://media.sociowire.com"; +// ============================================================================ +// 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; @@ -33,69 +44,49 @@ function formatCount(n) { return num.toString(); } -function relativeTime(dateStr) { +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 "just now"; - if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; - if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; - if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`; + 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 getCategoryIcon(post) { - const cat = (post?.category || "").toString().toLowerCase(); - const icons = { - news: "fa-newspaper", - politics: "fa-landmark", - technology: "fa-microchip", - science: "fa-flask", - health: "fa-heart-pulse", - sports: "fa-futbol", - entertainment: "fa-film", - business: "fa-briefcase", - environment: "fa-leaf", - weather: "fa-cloud-sun", - traffic: "fa-car", - live: "fa-broadcast-tower", - camera: "fa-video", - }; - return icons[cat] || "fa-newspaper"; +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 getCategoryColor(post) { - const cat = (post?.category || "").toString().toLowerCase(); - const colors = { - news: "from-blue-500 to-cyan-500", - politics: "from-purple-500 to-indigo-500", - technology: "from-emerald-500 to-teal-500", - science: "from-violet-500 to-purple-500", - health: "from-rose-500 to-pink-500", - sports: "from-orange-500 to-amber-500", - entertainment: "from-pink-500 to-rose-500", - business: "from-slate-500 to-zinc-500", - environment: "from-green-500 to-emerald-500", - weather: "from-sky-500 to-blue-500", - traffic: "from-yellow-500 to-amber-500", - live: "from-red-500 to-rose-500", - camera: "from-red-500 to-rose-500", - }; - return colors[cat] || "from-blue-500 to-cyan-500"; +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 isLivePost(post) { - const embedUrl = post?.embed_url || post?.video_url || ""; - return embedUrl.startsWith("livekit://") || post?.category?.toLowerCase() === "live"; -} - -function isVideoPost(post) { - return !!(post?.video_url && !isLivePost(post)); -} - -function isCameraPost(post) { - return post?.category?.toLowerCase() === "camera" || post?.template === "camera"; +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) { @@ -103,12 +94,364 @@ function getHeroImage(post) { if (post?.thumbnail_url) return normalizeMediaUrl(post.thumbnail_url); const media = post?.media; if (Array.isArray(media) && media.length > 0) { - const first = media[0]; - return normalizeMediaUrl(first?.url || first?.thumbnail_url || ""); + 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 +