959 lines
48 KiB
JavaScript
959 lines
48 KiB
JavaScript
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,
|
|
fetchProfile,
|
|
} 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 categoryIconClass(post) {
|
|
const c = (post?.category || "NEWS").toString().toUpperCase();
|
|
switch (c) {
|
|
case "EVENT":
|
|
case "EVENTS":
|
|
return "fa-regular fa-calendar";
|
|
case "FRIENDS":
|
|
return "fa-solid fa-user-group";
|
|
case "MARKET":
|
|
return "fa-solid fa-store";
|
|
case "CAMERA":
|
|
return "fa-solid fa-video";
|
|
case "LIVE":
|
|
return "fa-solid fa-tower-broadcast";
|
|
default:
|
|
return "fa-regular fa-newspaper";
|
|
}
|
|
}
|
|
|
|
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" },
|
|
events: { icon: "fa-calendar", gradient: "from-yellow-500 to-orange-500", color: "#eab308" },
|
|
event: { icon: "fa-calendar", gradient: "from-yellow-500 to-orange-500", color: "#eab308" },
|
|
friends: { icon: "fa-user-group", gradient: "from-green-500 to-emerald-500", color: "#22c55e" },
|
|
market: { icon: "fa-store", gradient: "from-amber-500 to-orange-500", color: "#f59e0b" },
|
|
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 (
|
|
<div className="absolute inset-0 bg-black">
|
|
<div className="absolute top-4 left-4 z-20 flex items-center gap-2">
|
|
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-red-600 text-white text-xs font-bold shadow-lg shadow-red-600/50">
|
|
<span className="w-2 h-2 rounded-full bg-white animate-pulse" />
|
|
LIVE
|
|
</div>
|
|
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-black/60 backdrop-blur-sm text-white text-xs">
|
|
<i className="fa-solid fa-eye" />
|
|
<span>{viewerCount}</span>
|
|
</div>
|
|
</div>
|
|
{isConnecting ? (
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
|
<motion.div className="w-20 h-20 rounded-full bg-red-500/20 flex items-center justify-center"
|
|
animate={{ scale: [1, 1.1, 1] }} transition={{ duration: 1.5, repeat: Infinity }}>
|
|
<i className="fa-solid fa-spinner fa-spin text-3xl text-red-400" />
|
|
</motion.div>
|
|
</div>
|
|
) : error ? (
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center text-red-400">
|
|
<i className="fa-solid fa-exclamation-triangle text-4xl mb-4" />
|
|
<p className="text-sm">{error}</p>
|
|
</div>
|
|
) : (
|
|
<video ref={videoRef} autoPlay playsInline controls className="w-full h-full object-contain" />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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 = `<i class="fa-solid fa-video text-xs text-white"></i>`;
|
|
new maplibregl.Marker({ element: el }).setLngLat([lng, lat]).addTo(map);
|
|
mapRef.current = map;
|
|
return () => { map.remove(); mapRef.current = null; };
|
|
}, [hasLocation, lat, lng]);
|
|
|
|
return (
|
|
<div className="absolute inset-0 bg-black">
|
|
<div className="absolute top-4 left-4 z-20 flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-red-600 text-white text-xs font-bold shadow-lg shadow-red-600/50">
|
|
<span className="w-2 h-2 rounded-full bg-white animate-pulse" />
|
|
LIVE
|
|
</div>
|
|
{isQuebec511 && streamUrl ? (
|
|
// Quebec511 cameras use proxy video stream
|
|
<video src={streamUrl} autoPlay muted playsInline controls loop className="absolute inset-0 w-full h-full object-contain" />
|
|
) : embedUrl ? (
|
|
// Other cameras use iframe
|
|
<iframe src={embedUrl} title={post?.title || "Camera"} allow="autoplay; encrypted-media" allowFullScreen className="absolute inset-0 w-full h-full border-0" />
|
|
) : videoUrl ? (
|
|
<video src={videoUrl} autoPlay muted playsInline controls className="absolute inset-0 w-full h-full object-contain" />
|
|
) : (
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center text-surface-500">
|
|
<i className="fa-solid fa-video-slash text-5xl mb-4 opacity-50" />
|
|
<span>Stream unavailable</span>
|
|
{(embedUrl || videoUrl) && (
|
|
<a href={embedUrl || videoUrl} target="_blank" rel="noopener noreferrer"
|
|
className="mt-4 px-4 py-2 rounded-lg bg-accent text-white text-sm">
|
|
Open external link
|
|
</a>
|
|
)}
|
|
</div>
|
|
)}
|
|
{hasLocation && (
|
|
<div ref={mapContainerRef} className="absolute bottom-4 right-4 w-36 h-28 rounded-xl overflow-hidden border-2 border-white/20 shadow-2xl z-10" />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function HeroVideo({ post }) {
|
|
return (
|
|
<div className="absolute inset-0 bg-black flex items-center justify-center">
|
|
<video controls playsInline className="max-w-full max-h-full object-contain"
|
|
src={normalizeMediaUrl(post?.video_url)} poster={getHeroImage(post)} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function HeroPicture({ gallery, imageIndex, setImageIndex, post, onFullscreen }) {
|
|
const currentImage = gallery[imageIndex] || getHeroImage(post);
|
|
if (!currentImage) return (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-surface-900 text-surface-500">
|
|
<i className="fa-solid fa-image text-6xl opacity-30" />
|
|
</div>
|
|
);
|
|
return (
|
|
<div className="absolute inset-0 bg-black">
|
|
<img src={currentImage} alt="" className="w-full h-full object-contain cursor-zoom-in" onClick={onFullscreen} />
|
|
{gallery.length > 1 && (
|
|
<>
|
|
<button className="absolute left-3 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full bg-black/60 backdrop-blur-sm text-white flex items-center justify-center hover:bg-black/80 transition-colors"
|
|
onClick={() => setImageIndex((i) => (i - 1 + gallery.length) % gallery.length)}>
|
|
<i className="fa-solid fa-chevron-left text-lg" />
|
|
</button>
|
|
<button className="absolute right-3 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full bg-black/60 backdrop-blur-sm text-white flex items-center justify-center hover:bg-black/80 transition-colors"
|
|
onClick={() => setImageIndex((i) => (i + 1) % gallery.length)}>
|
|
<i className="fa-solid fa-chevron-right text-lg" />
|
|
</button>
|
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2">
|
|
{gallery.map((_, i) => (
|
|
<button key={i} onClick={() => setImageIndex(i)}
|
|
className={`w-2.5 h-2.5 rounded-full transition-all ${i === imageIndex ? "bg-white w-6" : "bg-white/40 hover:bg-white/60"}`} />
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
<button className="absolute top-4 right-4 w-10 h-10 rounded-full bg-black/60 backdrop-blur-sm text-white flex items-center justify-center hover:bg-black/80"
|
|
onClick={onFullscreen}>
|
|
<i className="fa-solid fa-expand" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="absolute inset-0">
|
|
<img src={heroImage} alt="" className="w-full h-full object-cover" />
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-black/30" />
|
|
{gallery.length > 1 && (
|
|
<>
|
|
<button className="absolute left-3 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-black/60 backdrop-blur-sm text-white flex items-center justify-center hover:bg-black/80"
|
|
onClick={() => setImageIndex((i) => (i - 1 + gallery.length) % gallery.length)}>
|
|
<i className="fa-solid fa-chevron-left" />
|
|
</button>
|
|
<button className="absolute right-3 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-black/60 backdrop-blur-sm text-white flex items-center justify-center hover:bg-black/80"
|
|
onClick={() => setImageIndex((i) => (i + 1) % gallery.length)}>
|
|
<i className="fa-solid fa-chevron-right" />
|
|
</button>
|
|
<div className="absolute bottom-4 right-4 px-3 py-1.5 rounded-full bg-black/60 backdrop-blur-sm text-white text-xs">
|
|
{imageIndex + 1} / {gallery.length}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// No image - show gradient with category icon
|
|
return (
|
|
<div className={`absolute inset-0 bg-gradient-to-br ${config.gradient}`}>
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3">
|
|
<div className="w-20 h-20 rounded-2xl bg-white/20 backdrop-blur-sm flex items-center justify-center text-white shadow-lg">
|
|
<i className={`${categoryIconClass(post)} text-4xl`} />
|
|
</div>
|
|
<span className="text-white/80 text-sm font-medium uppercase tracking-wider">{post?.category || "News"}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function HeroLink({ post, gallery, imageIndex, setImageIndex }) {
|
|
const heroImage = getHeroImage(post);
|
|
const sourceDomain = getSourceDomain(post?.url);
|
|
const config = getCategoryConfig(post?.category);
|
|
|
|
if (!heroImage) {
|
|
return (
|
|
<div className={`absolute inset-0 flex flex-col items-center justify-center bg-gradient-to-br ${config.gradient}`}>
|
|
<i className={`fa-solid ${config.icon} text-7xl text-white/80 mb-4`} />
|
|
<span className="text-white/60 text-lg font-medium">{post?.category || "Article"}</span>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className="absolute inset-0">
|
|
<img src={gallery[imageIndex] || heroImage} alt="" className="w-full h-full object-cover" />
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-black/30" />
|
|
{sourceDomain && (
|
|
<div className="absolute bottom-4 left-4 flex items-center gap-2 px-4 py-2 rounded-full bg-black/60 backdrop-blur-md text-white text-sm font-medium">
|
|
<i className="fa-solid fa-link text-accent" />
|
|
<span>{sourceDomain}</span>
|
|
</div>
|
|
)}
|
|
{gallery.length > 1 && (
|
|
<div className="absolute bottom-4 right-4 px-3 py-1.5 rounded-full bg-black/60 backdrop-blur-sm text-white text-xs">
|
|
{imageIndex + 1} / {gallery.length}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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 (
|
|
<motion.div className="fixed inset-0 z-[2000] bg-black flex items-center justify-center"
|
|
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={onClose}>
|
|
<img src={gallery[imageIndex]} alt="" className="max-w-full max-h-full object-contain" onClick={(e) => e.stopPropagation()} />
|
|
{gallery.length > 1 && (
|
|
<>
|
|
<button className="absolute left-4 top-1/2 -translate-y-1/2 w-14 h-14 rounded-full bg-white/10 backdrop-blur-sm text-white flex items-center justify-center hover:bg-white/20"
|
|
onClick={(e) => { e.stopPropagation(); setImageIndex((i) => (i - 1 + gallery.length) % gallery.length); }}>
|
|
<i className="fa-solid fa-chevron-left text-2xl" />
|
|
</button>
|
|
<button className="absolute right-4 top-1/2 -translate-y-1/2 w-14 h-14 rounded-full bg-white/10 backdrop-blur-sm text-white flex items-center justify-center hover:bg-white/20"
|
|
onClick={(e) => { e.stopPropagation(); setImageIndex((i) => (i + 1) % gallery.length); }}>
|
|
<i className="fa-solid fa-chevron-right text-2xl" />
|
|
</button>
|
|
</>
|
|
)}
|
|
<button className="absolute top-4 right-4 w-12 h-12 rounded-full bg-white/10 backdrop-blur-sm text-white flex items-center justify-center hover:bg-white/20"
|
|
onClick={onClose}>
|
|
<i className="fa-solid fa-times text-xl" />
|
|
</button>
|
|
{gallery.length > 1 && (
|
|
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 px-4 py-2 rounded-full bg-white/10 backdrop-blur-sm text-white">
|
|
{imageIndex + 1} / {gallery.length}
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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 [authorAvatar, setAuthorAvatar] = useState("");
|
|
const [commenterAvatars, setCommenterAvatars] = useState({});
|
|
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 justCommentedRef = useRef(false);
|
|
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 author avatar
|
|
useEffect(() => {
|
|
const authorName = post?.author || post?.username;
|
|
if (!authorName) return;
|
|
fetchProfile(authorName).then(res => {
|
|
if (res?.avatar_url) setAuthorAvatar(res.avatar_url);
|
|
});
|
|
}, [post?.author, post?.username]);
|
|
|
|
// Fetch commenter avatars
|
|
useEffect(() => {
|
|
if (!comments.length) return;
|
|
const usernames = [...new Set(comments.map(c => c.username).filter(Boolean))];
|
|
// Only fetch for usernames we don't already have
|
|
const toFetch = usernames.filter(u => !commenterAvatars[u]);
|
|
if (!toFetch.length) return;
|
|
toFetch.forEach(username => {
|
|
fetchProfile(username).then(res => {
|
|
if (res?.avatar_url) {
|
|
setCommenterAvatars(prev => ({ ...prev, [username]: res.avatar_url }));
|
|
}
|
|
});
|
|
});
|
|
}, [comments]);
|
|
|
|
// Fetch comments
|
|
const refreshComments = useCallback(async (autoOpen = false) => {
|
|
if (!postId) return;
|
|
const items = await fetchPostComments(postId, 50);
|
|
if (Array.isArray(items)) {
|
|
setComments(items);
|
|
setCounts(c => ({ ...c, comments: items.length }));
|
|
if (autoOpen && items.length > 0) setShowComments(true);
|
|
}
|
|
}, [postId]);
|
|
|
|
useEffect(() => { refreshComments(true); }, [refreshComments]);
|
|
useEffect(() => {
|
|
// Only scroll to bottom when user just added a comment, not on initial load
|
|
if (showComments && justCommentedRef.current) {
|
|
commentsEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
justCommentedRef.current = false;
|
|
}
|
|
}, [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, username: username || "You", created_at: new Date().toISOString(), _pending: true };
|
|
justCommentedRef.current = 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));
|
|
}
|
|
// Delay refresh to let backend commit
|
|
setTimeout(() => refreshComments(), 500);
|
|
} 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 (
|
|
<>
|
|
<motion.div
|
|
className="fixed inset-0 z-[1100] flex items-center justify-center p-2 sm:p-4 bg-black/90 backdrop-blur-md"
|
|
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
|
onClick={(e) => { if (e.target === e.currentTarget) onClose?.(); }}
|
|
>
|
|
<motion.div
|
|
ref={modalRef}
|
|
className="relative w-full max-w-5xl max-h-[95vh] overflow-hidden rounded-2xl sm:rounded-3xl
|
|
bg-themed-secondary border border-themed shadow-2xl"
|
|
initial={{ scale: 0.9, y: 50, opacity: 0 }}
|
|
animate={{ scale: 1, y: 0, opacity: 1 }}
|
|
exit={{ scale: 0.9, y: 50, opacity: 0 }}
|
|
transition={{ type: "spring", damping: 30, stiffness: 400 }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Hero Section */}
|
|
<div className="relative aspect-[16/9] sm:aspect-video bg-surface-950 overflow-hidden">
|
|
{postType === "live" ? <HeroLive post={post} authToken={token} username={username} />
|
|
: postType === "camera" ? <HeroCamera post={post} />
|
|
: postType === "video" ? <HeroVideo post={post} />
|
|
: postType === "picture" ? <HeroPicture gallery={gallery} imageIndex={imageIndex} setImageIndex={setImageIndex} post={post} onFullscreen={() => setFullscreen(true)} />
|
|
: postType === "story" ? <HeroStory post={post} gallery={gallery} imageIndex={imageIndex} setImageIndex={setImageIndex} />
|
|
: <HeroLink post={post} gallery={gallery} imageIndex={imageIndex} setImageIndex={setImageIndex} />}
|
|
|
|
{/* Close Button */}
|
|
<motion.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}>
|
|
<i className="fa-solid fa-times text-lg" />
|
|
</motion.button>
|
|
|
|
{/* Category Badge */}
|
|
<div className="absolute top-3 left-3 sm:top-4 sm:left-4 z-20">
|
|
<div className={`flex items-center gap-2 px-3 sm:px-4 py-1.5 sm:py-2 rounded-full bg-gradient-to-r ${config.gradient}
|
|
text-white text-xs sm:text-sm font-bold uppercase tracking-wide shadow-lg`}>
|
|
<i className={`fa-solid ${config.icon}`} />
|
|
<span>{post?.category || "News"}</span>
|
|
{(postType === "live" || postType === "camera") && <span className="w-2 h-2 rounded-full bg-white animate-pulse" />}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content Section */}
|
|
<div className="max-h-[50vh] overflow-y-auto">
|
|
<div className="p-4 sm:p-6">
|
|
{/* Title */}
|
|
{post?.title && (
|
|
<h1 className="text-xl sm:text-2xl font-bold text-themed-primary leading-tight mb-4">
|
|
{post.title}
|
|
</h1>
|
|
)}
|
|
|
|
{/* Author Row */}
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className={`w-11 h-11 sm:w-12 sm:h-12 rounded-full bg-gradient-to-br ${config.gradient} flex items-center justify-center
|
|
text-white font-bold text-base sm:text-lg shadow-lg overflow-hidden`}>
|
|
{authorAvatar ? (
|
|
<img src={authorAvatar} alt={author} className="w-full h-full object-cover" />
|
|
) : (
|
|
author.charAt(0).toUpperCase()
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-semibold text-themed-primary truncate">@{author}</p>
|
|
<p className="text-sm text-themed-muted">{relativeTime(post?.published_at || post?.created_at, t)}</p>
|
|
</div>
|
|
{isAuthor && (
|
|
<div className="flex gap-2">
|
|
<motion.button whileTap={{ scale: 0.9 }} onClick={() => setShowEdit(!showEdit)}
|
|
className="w-9 h-9 rounded-full bg-themed-accent flex items-center justify-center text-accent">
|
|
<i className="fa-solid fa-pen text-sm" />
|
|
</motion.button>
|
|
<motion.button whileTap={{ scale: 0.9 }} onClick={() => setShowDelete(!showDelete)}
|
|
className="w-9 h-9 rounded-full bg-red-500/20 flex items-center justify-center text-red-400">
|
|
<i className="fa-solid fa-trash text-sm" />
|
|
</motion.button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Edit Panel */}
|
|
<AnimatePresence>
|
|
{showEdit && (
|
|
<motion.div className="mb-4 p-4 rounded-2xl bg-themed-elevated border border-themed"
|
|
initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }}>
|
|
<input type="text" value={editTitle} onChange={(e) => 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" />
|
|
<textarea value={editSnippet} onChange={(e) => setEditSnippet(e.target.value)} placeholder={t("post.description")} rows={3}
|
|
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 resize-none" />
|
|
<div className="flex gap-2">
|
|
<button onClick={handleSaveEdit} disabled={saving}
|
|
className="flex-1 py-3 rounded-xl bg-accent text-white font-semibold hover:opacity-90 disabled:opacity-50">
|
|
{saving ? t("post.saving") : t("common.save")}
|
|
</button>
|
|
<button onClick={() => setShowEdit(false)} className="flex-1 py-3 rounded-xl bg-themed-elevated border border-themed text-themed-secondary">
|
|
{t("common.cancel")}
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Delete Confirm */}
|
|
<AnimatePresence>
|
|
{showDelete && (
|
|
<motion.div className="mb-4 p-4 rounded-2xl bg-red-500/10 border border-red-500/30"
|
|
initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }}>
|
|
<p className="text-red-400 text-center mb-4">{t("post.deletePost")}</p>
|
|
<div className="flex gap-2">
|
|
<button onClick={handleDelete} disabled={saving}
|
|
className="flex-1 py-3 rounded-xl bg-red-500 text-white font-semibold hover:bg-red-600 disabled:opacity-50">
|
|
{saving ? t("post.deleting") : t("common.delete")}
|
|
</button>
|
|
<button onClick={() => setShowDelete(false)} className="flex-1 py-3 rounded-xl bg-themed-elevated border border-themed text-themed-secondary">
|
|
{t("common.cancel")}
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Description / Story Text */}
|
|
{(post?.snippet || post?.summary) && (
|
|
<div className={`text-themed-secondary text-base leading-relaxed mb-4 ${postType === "story" ? "whitespace-pre-wrap" : ""}`}>
|
|
{post?.snippet || post?.summary}
|
|
</div>
|
|
)}
|
|
|
|
{/* Tags */}
|
|
{post?.tags?.length > 0 && (
|
|
<div className="flex flex-wrap gap-2 mb-4">
|
|
{post.tags.slice(0, 6).map((tag, i) => (
|
|
<span key={i} className="px-3 py-1.5 rounded-full bg-themed-accent text-accent text-xs font-medium">
|
|
#{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Source Link */}
|
|
{post?.url && (
|
|
<a href={post.url} target="_blank" rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-3 px-5 py-3 rounded-xl bg-themed-elevated border border-themed
|
|
text-themed-primary hover:border-accent hover:text-accent transition-all group">
|
|
<i className="fa-solid fa-external-link-alt text-accent" />
|
|
<span className="truncate max-w-xs">{getSourceDomain(post.url) || post.url}</span>
|
|
<i className="fa-solid fa-arrow-right text-themed-muted group-hover:text-accent group-hover:translate-x-1 transition-all" />
|
|
</a>
|
|
)}
|
|
|
|
{/* Stats & Actions */}
|
|
<div className="mt-6 pt-4 border-t border-themed">
|
|
{/* Stats Row */}
|
|
<div className="flex items-center justify-around mb-4">
|
|
{[
|
|
{ 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 }) => (
|
|
<div key={label} className="flex flex-col items-center">
|
|
<i className={`fa-solid ${icon} text-themed-muted mb-1`} />
|
|
<span className="text-themed-primary font-bold">{formatCount(value)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Truth Score */}
|
|
<div className="mb-3 p-2.5 rounded-xl bg-themed-elevated">
|
|
<div className="flex items-center gap-3">
|
|
<motion.button whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }} onClick={() => handleTruthVote("false")}
|
|
className="w-9 h-9 rounded-full bg-red-500/20 border border-red-500/30 text-red-400 flex items-center justify-center hover:bg-red-500/30">
|
|
<i className="fa-solid fa-thumbs-down text-sm" />
|
|
</motion.button>
|
|
<div className="flex-1">
|
|
<div className="flex items-center justify-center gap-1.5 mb-1">
|
|
<span className={`text-xl font-black ${truth.score >= 70 ? 'text-emerald-400' : truth.score >= 50 ? 'text-amber-400' : 'text-red-400'}`}>
|
|
{Math.round(truth.score)}
|
|
</span>
|
|
<span className="text-themed-muted text-xs">/ 100</span>
|
|
</div>
|
|
<div className="h-1.5 rounded-full bg-surface-800 overflow-hidden">
|
|
<motion.div className={`h-full rounded-full ${truth.score >= 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 }} />
|
|
</div>
|
|
<p className="text-center text-[10px] text-themed-muted mt-0.5">{truth.count} votes</p>
|
|
</div>
|
|
<motion.button whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }} onClick={() => handleTruthVote("true")}
|
|
className="w-9 h-9 rounded-full bg-emerald-500/20 border border-emerald-500/30 text-emerald-400 flex items-center justify-center hover:bg-emerald-500/30">
|
|
<i className="fa-solid fa-thumbs-up text-sm" />
|
|
</motion.button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action Buttons */}
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<motion.button whileTap={{ scale: 0.95 }} onClick={handleLike}
|
|
className={`flex items-center justify-center gap-2 py-3 rounded-xl font-semibold transition-all
|
|
${liked ? 'bg-pink-500/20 text-pink-400 border border-pink-500/30' : 'bg-themed-elevated text-themed-secondary border border-themed hover:border-pink-500/30 hover:text-pink-400'}`}>
|
|
<i className={liked ? "fa-solid fa-heart" : "fa-regular fa-heart"} />
|
|
<span className="hidden sm:inline">{t("post.like")}</span>
|
|
</motion.button>
|
|
<motion.button whileTap={{ scale: 0.95 }} onClick={() => 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'}`}>
|
|
<i className="fa-solid fa-comment" />
|
|
<span className="hidden sm:inline">{t("post.comment")}</span>
|
|
</motion.button>
|
|
<motion.button whileTap={{ scale: 0.95 }} onClick={handleShare}
|
|
className="flex items-center justify-center gap-2 py-3 rounded-xl bg-themed-elevated text-themed-secondary border border-themed font-semibold hover:border-accent/30 hover:text-accent transition-all">
|
|
<i className="fa-solid fa-share" />
|
|
<span className="hidden sm:inline">{t("post.share")}</span>
|
|
</motion.button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Comments Section */}
|
|
<AnimatePresence>
|
|
{showComments && (
|
|
<motion.div className="mt-4" initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }}>
|
|
<div className="p-4 rounded-2xl bg-themed-elevated border border-themed">
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<i className="fa-solid fa-comments text-accent" />
|
|
<span className="font-semibold text-themed-primary">{t("post.comment")}</span>
|
|
<span className="ml-auto px-2 py-0.5 rounded-full bg-accent/20 text-accent text-xs font-bold">{comments.length}</span>
|
|
</div>
|
|
|
|
{/* Comments List */}
|
|
<div className="max-h-64 overflow-y-auto space-y-3 mb-4 scrollbar-thin">
|
|
{comments.length === 0 ? (
|
|
<div className="text-center py-8 text-themed-muted">
|
|
<i className="fa-regular fa-comment-dots text-4xl mb-2 opacity-40" />
|
|
<p>{t("postModal.noComments")}</p>
|
|
</div>
|
|
) : (
|
|
comments.map((c) => (
|
|
<motion.div key={c.id} className={`p-3 rounded-xl bg-themed-primary border border-themed ${c._pending ? "opacity-50" : ""} ${c._failed ? "border-red-500/30" : ""}`}
|
|
initial={{ opacity: 0, y: 10 }} animate={{ opacity: c._pending ? 0.5 : 1, y: 0 }}>
|
|
<div className="flex items-start gap-2">
|
|
<div className={`w-8 h-8 rounded-full bg-gradient-to-br ${config.gradient} flex items-center justify-center text-white text-xs font-bold flex-shrink-0 overflow-hidden`}>
|
|
{commenterAvatars[c.username] ? (
|
|
<img src={commenterAvatars[c.username]} alt={c.username} className="w-full h-full object-cover" />
|
|
) : (
|
|
(c.username || "?")[0]?.toUpperCase()
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-0.5">
|
|
<span className="text-xs font-bold text-accent">@{c.username || "user"}</span>
|
|
<span className="text-xs text-themed-muted">{relativeTime(c.created_at, t)}</span>
|
|
{c._failed && <span className="text-xs text-red-400">Failed</span>}
|
|
</div>
|
|
<p className="text-sm text-themed-secondary">{c.body}</p>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
))
|
|
)}
|
|
<div ref={commentsEndRef} />
|
|
</div>
|
|
|
|
{/* Comment Input */}
|
|
<div className="flex gap-2">
|
|
<input type="text" value={commentInput} onChange={(e) => 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" />
|
|
<motion.button whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}
|
|
disabled={!token || !commentInput.trim() || commentSending} onClick={handleComment}
|
|
className="w-12 h-12 rounded-xl bg-accent text-white flex items-center justify-center disabled:opacity-50">
|
|
{commentSending ? <i className="fa-solid fa-spinner fa-spin" /> : <i className="fa-solid fa-paper-plane" />}
|
|
</motion.button>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
|
|
{/* Fullscreen Gallery */}
|
|
<AnimatePresence>
|
|
{fullscreen && gallery.length > 0 && (
|
|
<FullscreenGallery gallery={gallery} imageIndex={imageIndex} setImageIndex={setImageIndex} onClose={() => setFullscreen(false)} />
|
|
)}
|
|
</AnimatePresence>
|
|
</>
|
|
);
|
|
}
|