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 ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+// 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();
@@ -116,210 +459,108 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
// 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,
+ 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({ ai: 50, user: 50, count: 0 });
+ const [truth, setTruth] = useState({ score: 50, count: 0 });
const [comments, setComments] = useState([]);
const [commentInput, setCommentInput] = useState("");
const [commentSending, setCommentSending] = useState(false);
- const [activeTab, setActiveTab] = useState("info"); // info, comments
+ const [showComments, setShowComments] = useState(false);
const [showEdit, setShowEdit] = useState(false);
- const [showVisibility, setShowVisibility] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [editTitle, setEditTitle] = useState(post?.title || "");
const [editSnippet, setEditSnippet] = useState(post?.snippet || "");
- const [visibility, setVisibility] = useState(post?.visibility || "public");
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 - deduplicated from all possible image fields
+ // Media gallery
const gallery = useMemo(() => {
const seen = new Set();
const images = [];
- const addUrl = (url) => {
- const normalized = normalizeMediaUrl(url);
- if (!normalized || seen.has(normalized)) return;
- seen.add(normalized);
- images.push(normalized);
- };
-
- // Check media_urls first (frontend sends this)
- if (Array.isArray(post?.media_urls)) {
- post.media_urls.forEach(addUrl);
- }
- // Check image_urls (backend API returns this)
- if (Array.isArray(post?.image_urls)) {
- post.image_urls.forEach(addUrl);
- }
- // Check individual image fields (fallback, often same URL)
- addUrl(post?.image_large);
- addUrl(post?.image_medium);
- addUrl(post?.image_small);
- addUrl(post?.image_url);
- addUrl(post?.image);
- // Check media array if present
- if (Array.isArray(post?.media)) {
- post.media.forEach((m) => {
- addUrl(m?.url || m?.thumbnail_url);
- });
- }
+ 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]);
- // Detect post type
- const postType = useMemo(() => {
- if (isLivePost(post)) return "live";
- if (isCameraPost(post)) return "camera";
- if (isVideoPost(post)) return "video";
- return "article";
- }, [post]);
-
// Initial data fetch
useEffect(() => {
if (!postId) return;
-
- // Record view
- recordPostView(postId).then((res) => {
- if (res?.counts) {
- setCounts((c) => ({
- ...c,
- views: res.counts.view_count || c.views,
- likes: res.counts.like_count || c.likes,
- shares: res.counts.share_count || c.shares,
- comments: res.counts.comment_count || c.comments,
- }));
- }
- });
-
- // Fetch like state
- fetchPostLikeState(postId).then((res) => {
- if (res?.liked !== undefined) setLiked(res.liked);
- });
-
- // Fetch truth scores
- fetchPostTruth(postId).then((res) => {
- if (res) {
- // Use truth_score as the main display value (0-100 scale)
- const mainScore = res.truth_score ?? res.user_score ?? 50;
- setTruth({
- ai: res.ai_score ?? 50,
- user: mainScore,
- count: res.vote_count ?? res.total_votes ?? 0,
- });
- }
+ 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 }));
- }
+ if (Array.isArray(items)) { setComments(items); setCounts(c => ({ ...c, comments: items.length })); }
}, [postId]);
- useEffect(() => {
- refreshComments();
- }, [refreshComments]);
-
- // Auto-scroll comments
- useEffect(() => {
- if (activeTab === "comments") {
- commentsEndRef.current?.scrollIntoView({ behavior: "smooth" });
- }
- }, [comments, activeTab]);
+ 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) }));
-
+ 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,
- }));
- }
+ 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 || "Sociowire",
- url: shareUrl,
- });
- } catch {}
- } else {
- navigator.clipboard?.writeText(shareUrl);
- }
-
+ 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 }));
- }
+ 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) {
- // Use truth_score as the main display value (0-100 scale)
- const mainScore = res.truth_score ?? res.user_score ?? truth.user;
- setTruth({
- ai: res.ai_score ?? truth.ai,
- user: mainScore,
- count: res.vote_count ?? res.total_votes ?? truth.count,
- });
- }
+ 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);
-
- // Optimistic update
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 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 && res?.comment) {
- setComments((prev) =>
- prev.map((c) => (c.id === tempId ? { ...res.comment, _pending: false } : c))
- );
+ 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))
- );
+ setComments(prev => prev.map(c => c.id === tempId ? { ...c, _failed: true, _pending: false } : c));
}
}, [commentInput, postId, commentSending, username]);
@@ -328,595 +569,311 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
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 });
- }
+ if (res?.ok) { setShowEdit(false); onPostUpdated?.({ ...post, title: editTitle, snippet: editSnippet }); }
}, [postId, editTitle, editSnippet, token, saving, post, onPostUpdated]);
- const handleSaveVisibility = useCallback(async () => {
- if (!postId || saving) return;
- setSaving(true);
- const res = await updatePostVisibility(postId, { visibility }, token);
- setSaving(false);
- if (res?.ok) {
- setShowVisibility(false);
- onPostUpdated?.({ ...post, visibility });
- }
- }, [postId, visibility, 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?.();
- }
+ if (res?.ok) { onPostDeleted?.(postId); onClose?.(); }
}, [postId, token, saving, onPostDeleted, onClose]);
- // Close on escape
+ // Close on escape + prevent body scroll
useEffect(() => {
- const handleKey = (e) => {
- if (e.key === "Escape") onClose?.();
- };
+ const handleKey = (e) => { if (e.key === "Escape" && !fullscreen) onClose?.(); };
window.addEventListener("keydown", handleKey);
- return () => window.removeEventListener("keydown", handleKey);
- }, [onClose]);
- // Close on backdrop click
- const handleBackdropClick = (e) => {
- if (e.target === e.currentTarget) onClose?.();
- };
+ // 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;
- const heroImage = getHeroImage(post);
- const categoryIcon = getCategoryIcon(post);
- const categoryColor = getCategoryColor(post);
- const author = post?.author || post?.username || post?.source_name || "Unknown";
-
return (
-
+ <>
+ { if (e.target === e.currentTarget) onClose?.(); }}
+ >
e.stopPropagation()}
>
- {/* Glow effect */}
-
+ {/* Hero Section */}
+
+ {postType === "live" ?
+ : postType === "camera" ?
+ : postType === "video" ?
+ : postType === "picture" ?
setFullscreen(true)} />
+ : postType === "story" ?
+ : }
- {/* Header */}
-
- {/* Category badge */}
-
-
-
- {post?.category || "News"}
- {postType === "live" && (
-
- )}
-
-
{relativeTime(post?.published_at || post?.created_at)}
-
-
- {/* Close button */}
+ {/* Close Button */}
-
+ className="absolute top-3 right-3 sm:top-4 sm:right-4 w-10 h-10 sm:w-11 sm:h-11 rounded-full bg-black/60 backdrop-blur-md
+ flex items-center justify-center text-white hover:bg-black/80 transition-colors z-30"
+ whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }} onClick={onClose}>
+
+
+ {/* Category Badge */}
+
+
+
+ {post?.category || "News"}
+ {(postType === "live" || postType === "camera") && }
+
+
- {/* Main content */}
-
- {/* Left: Hero media */}
-
- {/* Hero */}
-
- {postType === "live" || postType === "camera" ? (
-
-
-
- {postType === "live" ? "Live broadcast" : "Camera feed"}
-
-
-
- Watch Live
+ {/* 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">
+
- ) : postType === "video" ? (
-
- ) : heroImage ? (
- <>
-

- {/* Gallery navigation */}
- {gallery.length > 1 && (
- <>
-
-
-
- {imageIndex + 1} / {gallery.length}
-
- >
- )}
- >
- ) : (
-
-
- {post?.category || "Article"}
-
)}
- {/* Title & summary */}
-
-
- {post?.title || "Untitled"}
-
-
- {/* Author */}
-
-
- {author.charAt(0).toUpperCase()}
-
-
-
{author}
-
{relativeTime(post?.published_at || post?.created_at)}
-
-
-
- {/* Summary */}
-
- {post?.snippet || post?.summary || "No description available."}
-
-
- {/* Tags */}
- {post?.tags && post.tags.length > 0 && (
-
- {post.tags.slice(0, 5).map((tag, i) => (
-
- #{tag}
-
- ))}
-
+ {/* 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" />
+
)}
+
- {/* Source link */}
- {post?.url && (
-
-
- Open source
-
+ {/* Delete Confirm */}
+
+ {showDelete && (
+
+ {t("post.deletePost")}
+
+
+
+
+
)}
-
-
+
- {/* Right: Stats, actions, comments */}
-
- {/* Stats row */}
-
- {[
- { key: "views", icon: "fa-eye", value: counts.views },
- { key: "likes", icon: "fa-heart", value: counts.likes },
- { key: "shares", icon: "fa-share", value: counts.shares },
- { key: "comments", icon: "fa-comment", value: counts.comments },
- ].map(({ key, icon, value }) => (
-
-
- {formatCount(value)}
-
- ))}
-
-
- {/* Truth Score - Social Network Style */}
-
-
- {/* Downvote button */}
-
handleTruthVote("false")}
- >
-
-
-
- {/* Score display with rail */}
-
-
- = 70 ? 'text-emerald-400' :
- truth.user >= 50 ? 'text-amber-400' :
- 'text-red-400'
- }`}>
- {Math.round(truth.user)}
-
- / 100
-
- {/* Progress rail */}
-
- = 70
- ? 'bg-gradient-to-r from-emerald-500 via-green-400 to-teal-400'
- : truth.user >= 50
- ? 'bg-gradient-to-r from-amber-500 via-yellow-400 to-orange-400'
- : 'bg-gradient-to-r from-red-500 via-rose-400 to-pink-400'
- }`}
- initial={{ width: 0 }}
- animate={{ width: `${truth.user}%` }}
- transition={{ duration: 0.6, ease: "easeOut" }}
- />
- {/* Glow effect */}
- = 70
- ? 'bg-gradient-to-r from-emerald-400 to-teal-400'
- : truth.user >= 50
- ? 'bg-gradient-to-r from-amber-400 to-orange-400'
- : 'bg-gradient-to-r from-red-400 to-pink-400'
- }`}
- initial={{ width: 0 }}
- animate={{ width: `${truth.user}%` }}
- transition={{ duration: 0.6, ease: "easeOut" }}
- />
-
-
- Questionable
- {truth.count} votes
- Verified
-
-
-
- {/* Upvote button */}
-
handleTruthVote("true")}
- >
-
-
-
-
-
- {/* Actions */}
-
-
-
- {liked ? "Liked" : "Like"}
-
-
-
- Share
-
-
-
- {/* Author actions */}
- {isAuthor && (
-
-
setShowEdit(!showEdit)}
- >
-
- Edit
-
-
setShowVisibility(!showVisibility)}
- >
-
- Visibility
-
-
setShowDelete(!showDelete)}
- >
-
- Delete
-
+ {/* Description / Story Text */}
+ {(post?.snippet || post?.summary) && (
+
+ {post?.snippet || post?.summary}
)}
- {/* Edit panel */}
-
- {showEdit && (
-
- setEditTitle(e.target.value)}
- placeholder="Title"
- className="w-full px-3 py-2 mb-2 rounded-lg bg-surface-900/80 border border-white/10
- text-surface-100 text-sm placeholder:text-surface-500 focus:outline-none focus:border-accent/50"
- />
-
- )}
-
+ {/* Tags */}
+ {post?.tags?.length > 0 && (
+
+ {post.tags.slice(0, 6).map((tag, i) => (
+
+ #{tag}
+
+ ))}
+
+ )}
- {/* Visibility panel */}
-
- {showVisibility && (
-
-
-
-
-
-
-
- )}
-
+ {/* Source Link */}
+ {post?.url && (
+
+
+ {getSourceDomain(post.url) || post.url}
+
+
+ )}
- {/* Delete confirm */}
-
- {showDelete && (
-
-
- Are you sure you want to delete this post?
-
-
-
-
+ {/* 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)}
-
- )}
-
-
- {/* Comments section */}
-
-
-
- Comments
-
- {comments.length}
-
+ ))}
- {/* Comments list */}
-
- {comments.length === 0 ? (
-
-
-
No comments yet
+ {/* 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
- ) : (
- comments.map((c) => (
-
-
- @{c.author}
- {relativeTime(c.created_at)}
- {c._failed && (
- Failed
- )}
-
- {c.body}
-
- ))
- )}
-
-
-
- {/* Comment input */}
-
+
+ {/* 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)} />
+ )}
+
+ >
);
}
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json
index ce25fef..582d41b 100644
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -283,5 +283,15 @@
"online": "Online",
"offline": "Offline",
"typing": "typing..."
+ },
+ "postModal": {
+ "readArticle": "Read full article",
+ "noComments": "No comments yet",
+ "writeComment": "Write a comment...",
+ "truthScore": "Truth Score",
+ "votes": "votes",
+ "viewers": "viewers",
+ "streamUnavailable": "Stream unavailable",
+ "connecting": "Connecting..."
}
}
diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json
index c3923dc..24cc5a7 100644
--- a/src/locales/es/translation.json
+++ b/src/locales/es/translation.json
@@ -283,5 +283,15 @@
"online": "En línea",
"offline": "Desconectado",
"typing": "escribiendo..."
+ },
+ "postModal": {
+ "readArticle": "Leer artículo completo",
+ "noComments": "Sin comentarios aún",
+ "writeComment": "Escribe un comentario...",
+ "truthScore": "Puntuación de verdad",
+ "votes": "votos",
+ "viewers": "espectadores",
+ "streamUnavailable": "Stream no disponible",
+ "connecting": "Conectando..."
}
}
diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json
index 3b648dd..a729675 100644
--- a/src/locales/fr/translation.json
+++ b/src/locales/fr/translation.json
@@ -286,5 +286,15 @@
"online": "En ligne",
"offline": "Hors ligne",
"typing": "en train d'écrire..."
+ },
+ "postModal": {
+ "readArticle": "Lire l'article complet",
+ "noComments": "Aucun commentaire",
+ "writeComment": "Écrire un commentaire...",
+ "truthScore": "Score de vérité",
+ "votes": "votes",
+ "viewers": "spectateurs",
+ "streamUnavailable": "Stream indisponible",
+ "connecting": "Connexion..."
}
}