diff --git a/package-lock.json b/package-lock.json index fc30209..5a24b67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sociowire-frontend", - "version": "0.0.95", + "version": "0.0.102", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sociowire-frontend", - "version": "0.0.95", + "version": "0.0.102", "dependencies": { "@fortawesome/fontawesome-free": "^7.1.0", "axios": "^1.13.2", diff --git a/package.json b/package.json index 320da57..dc1fefa 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sociowire-frontend", "private": true, - "version": "0.0.95", + "version": "0.0.102", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/src/api/client.js b/src/api/client.js index 343d7fb..18e91fa 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -845,7 +845,7 @@ export async function fetchPostLikeState(postId) { // Edit post export async function editPost(postId, payload, token) { - if (!postId) return { ok: false }; + if (!postId) return { ok: false, error: "Missing post ID" }; const qs = new URLSearchParams(); qs.set("id", String(postId)); try { @@ -857,10 +857,15 @@ export async function editPost(postId, payload, token) { headers, body: JSON.stringify(payload), }); + if (!res.ok) { + // Try to get error message from response + const text = await res.text().catch(() => ""); + return { ok: false, error: text || `HTTP ${res.status}`, status: res.status }; + } const data = await res.json().catch(() => ({})); - return { ok: res.ok, ...data }; - } catch { - return { ok: false }; + return { ok: true, ...data }; + } catch (err) { + return { ok: false, error: err?.message || "Network error" }; } } diff --git a/src/components/Map/MapView.jsx b/src/components/Map/MapView.jsx index 6966bad..6e6fd18 100644 --- a/src/components/Map/MapView.jsx +++ b/src/components/Map/MapView.jsx @@ -26,6 +26,7 @@ import { fetchPostById, fetchPostByUrl } from "../../api/client"; import { trackEvent } from "../../utils/analytics"; import DayNightTerminator from "./DayNightTerminator"; import EventClustersLayer from "./EventClustersLayer"; +import { AppOverlay, initializeApps, setupDemoApps, getDefaultAppHost, createMapContextBridge } from "../../apps"; const CONTENT_CREATOR_MODES = [ { @@ -188,9 +189,9 @@ export default function MapView({ try { const v = localStorage.getItem(TIME_FILTER_KEY); const n = Number(v); - return Number.isFinite(n) && n > 0 ? n : 336; + return Number.isFinite(n) && n > 0 ? n : 24; // Default: 24h } catch { - return 336; + return 24; } }); @@ -314,6 +315,11 @@ export default function MapView({ const searchFitQueryRef = useRef(""); const searchUserMovedRef = useRef(false); + // Apps system + const mapContextBridgeRef = useRef(null); + const appHostRef = useRef(null); + const [appsInitialized, setAppsInitialized] = useState(false); + const openOverlayWhenReady = useCallback( (post, opts = {}) => { if (!post) return; @@ -385,6 +391,51 @@ export default function MapView({ }; }, [mapRef.current, cancelLongPress]); + // Initialize Apps system when map is ready + useEffect(() => { + const map = mapRef.current; + if (!map || appsInitialized) return; + + // Wait for map style to load + const initApps = () => { + try { + if (!map.isStyleLoaded()) { + setTimeout(initApps, 100); + return; + } + + // Initialize app registry with built-in apps + initializeApps(); + + // Get or create app host + const appHost = getDefaultAppHost(); + appHostRef.current = appHost; + + // Setup demo apps (weather widget) + setupDemoApps(appHost); + + // Create map context bridge + mapContextBridgeRef.current = createMapContextBridge(map, { + throttleMs: 500, + appHost, + }); + + setAppsInitialized(true); + } catch (err) { + console.warn("Apps system init error:", err); + } + }; + + initApps(); + + return () => { + if (mapContextBridgeRef.current) { + mapContextBridgeRef.current.destroy(); + mapContextBridgeRef.current = null; + } + }; + }, [mapRef.current, appsInitialized]); + // Touch/mouse event handlers for long-press // Re-run when viewParams changes (indicates map is ready) useEffect(() => { @@ -1076,6 +1127,11 @@ export default function MapView({ handlePostDelete(msg); return; } + // Forward app events to AppHost + if (msg.type === "app.event" && appHostRef.current) { + appHostRef.current.handleWSMessage(msg); + return; + } } catch {} }; @@ -2081,6 +2137,10 @@ export default function MapView({ }} /> )} + {/* Apps Widget Overlay */} + {appsInitialized && appHostRef.current && ( + + )} {status &&
{status}
} {debugEnabled && ( <> @@ -2186,50 +2246,7 @@ export default function MapView({ - {/* Weather widget - flows naturally below search bar */} - {nearestWeather && (() => { - const w = nearestWeather; - const isSunny = w.icon === '☀️' || w.icon === '⛅'; - const isNight = w.icon === '🌙'; - const isRainy = w.icon === '🌧️' || w.icon === '🌦️' || w.icon === '⛈️'; - const isSnow = w.icon === '❄️' || w.icon === '🌨️'; - const bg = isNight - ? 'linear-gradient(135deg, rgba(30, 41, 59, 0.9), rgba(15, 23, 42, 0.9))' - : isSunny - ? 'linear-gradient(135deg, rgba(56, 189, 248, 0.85), rgba(14, 165, 233, 0.85))' - : isRainy - ? 'linear-gradient(135deg, rgba(71, 85, 105, 0.9), rgba(51, 65, 85, 0.9))' - : isSnow - ? 'linear-gradient(135deg, rgba(148, 163, 184, 0.9), rgba(203, 213, 225, 0.9))' - : 'linear-gradient(135deg, rgba(100, 116, 139, 0.85), rgba(71, 85, 105, 0.85))'; - - return ( -
setActiveWeatherPost(w)} - style={{ - marginTop: '4px', - marginLeft: '3px', - display: 'inline-flex', - alignItems: 'center', - gap: '5px', - padding: '5px 12px', - borderRadius: '16px', - background: bg, - border: '1px solid rgba(255,255,255,0.25)', - color: isSnow ? '#1e293b' : '#fff', - fontSize: '13px', - fontWeight: '600', - cursor: 'pointer', - backdropFilter: 'blur(8px)', - boxShadow: '0 2px 8px rgba(0,0,0,0.2)', - }} - > - {w.icon} - {w.city}: - {w.temp}° -
- ); - })()} + {/* Weather widget now provided by apps system - see AppOverlay */} {/* Dual FAB Menu - Left: Categories, Right: Time/Weather */} diff --git a/src/components/Map/markerManager.js b/src/components/Map/markerManager.js index 63bec39..264a7b7 100644 --- a/src/components/Map/markerManager.js +++ b/src/components/Map/markerManager.js @@ -787,20 +787,31 @@ function fullImageForPost(post) { function collectGalleryImages(post) { const result = []; const seen = new Set(); + const seenBase = new Set(); + // Get base filename without size suffix to detect same image in different sizes + const getBase = (url) => { + if (!url) return ''; + return url.replace(/[-_](large|medium|small|thumb|thumbnail)(\.[^.]+)?$/i, '$2') + .replace(/\?.*$/, ''); // Remove query params + }; const add = (value) => { const normalized = normalizeImageUrl(value); if (!normalized) return; - if (seen.has(normalized)) return; + const base = getBase(normalized); + // Skip if we already have this exact URL or same base image + if (seen.has(normalized) || seenBase.has(base)) return; seen.add(normalized); + seenBase.add(base); result.push(normalized); }; - // Check both media_urls (frontend) and image_urls (backend API) + // Check both media_urls (frontend) and image_urls (backend API) - these are actual galleries if (Array.isArray(post?.media_urls)) { post.media_urls.forEach(add); } if (Array.isArray(post?.image_urls)) { post.image_urls.forEach(add); } + // Single images - prefer large > medium > small (dedupe by base name) ["image_large", "image_medium", "image_small", "image", "media_url"].forEach((key) => { add(post?.[key]); }); @@ -2348,8 +2359,12 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the const hasOffset = false; const lineHTML = ''; + // Add slight random tilt for polaroid effect (based on post ID for consistency) + const postId = post?.id || post?.ID || 0; + const tiltAngle = ((postId % 11) - 5) * 1.2; // Range: -6 to +6 degrees + root.innerHTML = ` -
+
${lineHTML} `; diff --git a/src/components/Map/templateSpecs.js b/src/components/Map/templateSpecs.js index 3692010..69a5686 100644 --- a/src/components/Map/templateSpecs.js +++ b/src/components/Map/templateSpecs.js @@ -11,23 +11,23 @@ export const TEMPLATE_SPECS = { polaroid: { mini_spec: { - size: { w: 150, h: 190 }, + size: { w: 100, h: 130 }, background: { type: "solid", value: "#ffffff" }, - radius: 6, + radius: 4, layers: [ - { id: "image", type: "image", x: 8, y: 8, w: 134, h: 134, bind: "data.image", radius: 3 }, - { id: "title", type: "text", x: 8, y: 146, w: 134, h: 24, bind: "data.headline", style: "text.polaroidTitle", maxLines: 2 }, - { id: "author", type: "text", x: 8, y: 172, w: 134, h: 14, bind: "data.source", style: "text.polaroidCaption", maxLines: 1 }, + { id: "image", type: "image", x: 5, y: 5, w: 90, h: 90, bind: "data.image", radius: 2 }, + { id: "title", type: "text", x: 5, y: 98, w: 90, h: 18, bind: "data.headline", style: "text.polaroidTitle", maxLines: 2 }, + { id: "author", type: "text", x: 5, y: 116, w: 90, h: 10, bind: "data.source", style: "text.polaroidCaption", maxLines: 1 }, ], }, full_spec: { - size: { w: 280, h: 340 }, + size: { w: 190, h: 230 }, background: { type: "solid", value: "#ffffff" }, - radius: 12, + radius: 8, layers: [ - { id: "image", type: "image", x: 15, y: 15, w: 250, h: 250, bind: "data.image", radius: 6 }, - { id: "author", type: "text", x: 15, y: 275, w: 250, h: 24, bind: "data.source", style: "text.polaroidCaption", maxLines: 1 }, - { id: "caption", type: "text", x: 15, y: 300, w: 250, h: 32, bind: "data.headline", style: "text.polaroidText", maxLines: 2 }, + { id: "image", type: "image", x: 10, y: 10, w: 170, h: 170, bind: "data.image", radius: 4 }, + { id: "author", type: "text", x: 10, y: 185, w: 170, h: 18, bind: "data.source", style: "text.polaroidCaption", maxLines: 1 }, + { id: "caption", type: "text", x: 10, y: 205, w: 170, h: 22, bind: "data.headline", style: "text.polaroidText", maxLines: 2 }, ], }, }, @@ -56,24 +56,23 @@ export const TEMPLATE_SPECS = { }, camera: { mini_spec: { - size: { w: 200, h: 112 }, + size: { w: 140, h: 80 }, background: { type: "solid", value: "#0f172a" }, - radius: 10, + radius: 8, layers: [ - { id: "image", type: "image", x: 0, y: 0, w: 200, h: 112, bind: "data.image", radius: 10 }, - { id: "liveIcon", type: "icon", x: 8, y: 8, w: 24, h: 24, text: "\uf03d", style: "text.liveIcon" }, - { id: "badge", type: "chip", x: 36, y: 8, text: "LIVE", style: "chip.live" }, + { id: "image", type: "image", x: 0, y: 0, w: 140, h: 80, bind: "data.image", radius: 8 }, + { id: "liveIcon", type: "icon", x: 6, y: 6, w: 18, h: 18, text: "\uf03d", style: "text.liveIcon" }, + { id: "badge", type: "chip", x: 26, y: 6, text: "LIVE", style: "chip.live" }, ], }, full_spec: { - size: { w: 320, h: 200 }, + size: { w: 240, h: 150 }, background: { type: "solid", value: "#0f172a" }, - radius: 14, + radius: 10, layers: [ - { id: "image", type: "image", x: 0, y: 0, w: 320, h: 180, bind: "data.image", radius: 14 }, - { id: "liveIcon", type: "icon", x: 12, y: 12, w: 28, h: 28, text: "\uf03d", style: "text.liveIcon" }, - { id: "badge", type: "chip", x: 44, y: 12, text: "LIVE", style: "chip.live" }, - { id: "title", type: "text", x: 12, y: 185, w: 296, h: 20, bind: "data.headline", style: "text.cameraTitle", maxLines: 1 }, + { id: "image", type: "image", x: 0, y: 0, w: 240, h: 135, bind: "data.image", radius: 10 }, + { id: "liveIcon", type: "icon", x: 8, y: 8, w: 22, h: 22, text: "\uf03d", style: "text.liveIcon" }, + { id: "badge", type: "chip", x: 34, y: 8, text: "LIVE", style: "chip.live" }, ], }, }, diff --git a/src/components/Posts/PostDetailModal.jsx b/src/components/Posts/PostDetailModal.jsx index c2bffe9..d2fda3f 100644 --- a/src/components/Posts/PostDetailModal.jsx +++ b/src/components/Posts/PostDetailModal.jsx @@ -110,12 +110,24 @@ function getLiveKitRoomName(post) { } function getHeroImage(post) { + // Check various image fields that link posts might use + if (post?.image_large) return normalizeMediaUrl(post.image_large); + if (post?.image_medium) return normalizeMediaUrl(post.image_medium); if (post?.image_url) return normalizeMediaUrl(post.image_url); + if (post?.image) return normalizeMediaUrl(post.image); if (post?.thumbnail_url) return normalizeMediaUrl(post.thumbnail_url); + if (post?.image_small) return normalizeMediaUrl(post.image_small); const media = post?.media; if (Array.isArray(media) && media.length > 0) { return normalizeMediaUrl(media[0]?.url || media[0]?.thumbnail_url || ""); } + // Check media_urls and image_urls arrays + if (Array.isArray(post?.media_urls) && post.media_urls.length > 0) { + return normalizeMediaUrl(post.media_urls[0]); + } + if (Array.isArray(post?.image_urls) && post.image_urls.length > 0) { + return normalizeMediaUrl(post.image_urls[0]); + } return ""; } @@ -145,6 +157,19 @@ const CATEGORY_CONFIG = { default: { icon: "fa-newspaper", gradient: "from-blue-500 to-cyan-500", color: "#60a5fa" }, }; +// Visibility icons +const VISIBILITY_CONFIG = { + public: { icon: "fa-globe", label: "Public" }, + friends: { icon: "fa-user-group", label: "Friends" }, + group: { icon: "fa-users", label: "Group" }, + custom: { icon: "fa-user-check", label: "Custom" }, + private: { icon: "fa-lock", label: "Private" }, +}; + +function getVisibilityConfig(visibility) { + return VISIBILITY_CONFIG[visibility] || VISIBILITY_CONFIG.public; +} + function getCategoryConfig(category) { return CATEGORY_CONFIG[(category || "").toLowerCase()] || CATEGORY_CONFIG.default; } @@ -356,7 +381,7 @@ function HeroPicture({ gallery, imageIndex, setImageIndex, post, onFullscreen }) ); } -function HeroStory({ post, gallery, imageIndex, setImageIndex }) { +function HeroStory({ post, gallery, imageIndex, setImageIndex, onFullscreen }) { const heroImage = gallery[imageIndex] || getHeroImage(post); const author = post?.author || post?.username || "Anonymous"; const config = getCategoryConfig(post?.category); @@ -365,8 +390,8 @@ function HeroStory({ post, gallery, imageIndex, setImageIndex }) { if (heroImage) { return (
- -
+ +
{gallery.length > 1 && ( <>
); } @@ -399,7 +428,7 @@ function HeroStory({ post, gallery, imageIndex, setImageIndex }) { ); } -function HeroLink({ post, gallery, imageIndex, setImageIndex }) { +function HeroLink({ post, gallery, imageIndex, setImageIndex, onFullscreen }) { const heroImage = getHeroImage(post); const sourceDomain = getSourceDomain(post?.url); const config = getCategoryConfig(post?.category); @@ -414,8 +443,8 @@ function HeroLink({ post, gallery, imageIndex, setImageIndex }) { } return (
- -
+ +
{sourceDomain && (
@@ -427,6 +456,10 @@ function HeroLink({ post, gallery, imageIndex, setImageIndex }) { {imageIndex + 1} / {gallery.length}
)} +
); } @@ -435,20 +468,42 @@ function HeroLink({ post, gallery, imageIndex, setImageIndex }) { // FULLSCREEN GALLERY // ============================================================================ function FullscreenGallery({ gallery, imageIndex, setImageIndex, onClose }) { + const closedViaBackRef = useRef(false); + + const handleClose = useCallback(() => { + if (!closedViaBackRef.current) { + closedViaBackRef.current = true; + window.history.back(); + } + onClose(); + }, [onClose]); + useEffect(() => { const handleKey = (e) => { - if (e.key === "Escape") onClose(); + if (e.key === "Escape") handleClose(); 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]); + + // Push state for back button + window.history.pushState({ modal: 'fullscreen' }, ''); + const handlePopState = () => { + closedViaBackRef.current = true; + onClose(); + }; + window.addEventListener("popstate", handlePopState); + + return () => { + window.removeEventListener("keydown", handleKey); + window.removeEventListener("popstate", handlePopState); + }; + }, [gallery.length, onClose, setImageIndex, handleClose]); return ( - e.stopPropagation()} /> + initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={handleClose}> + {gallery.length > 1 && ( <> {gallery.length > 1 && ( @@ -479,8 +534,23 @@ function FullscreenGallery({ gallery, imageIndex, setImageIndex, onClose }) { // ============================================================================ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDeleted }) { const { t } = useTranslation(); - const { username, token } = useAuth(); + const { username, userId, token } = useAuth(); const modalRef = useRef(null); + const [dragY, setDragY] = useState(0); + const closingRef = useRef(false); + const closedViaBackRef = useRef(false); + + // Handle close - prevent double-close and manage history + const handleClose = useCallback(() => { + if (closingRef.current) return; + closingRef.current = true; + // If not closed via back button, go back to remove our history entry + if (!closedViaBackRef.current) { + closedViaBackRef.current = true; + window.history.back(); + } + onClose?.(); + }, [onClose]); // State const [counts, setCounts] = useState({ @@ -494,29 +564,50 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe const [comments, setComments] = useState([]); const [commentInput, setCommentInput] = useState(""); const [commentSending, setCommentSending] = useState(false); - const [showComments, setShowComments] = useState(false); + const [showComments, setShowComments] = useState(true); const [showEdit, setShowEdit] = useState(false); const [showDelete, setShowDelete] = useState(false); const [editTitle, setEditTitle] = useState(post?.title || ""); const [editSnippet, setEditSnippet] = useState(post?.snippet || ""); + const [editVisibility, setEditVisibility] = useState(post?.visibility || "public"); const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(""); const [imageIndex, setImageIndex] = useState(0); const [fullscreen, setFullscreen] = useState(false); + const fullscreenRef = useRef(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 isAuthor = (username && (post?.author === username || post?.username === username)) || + (userId && (post?.user_id === userId || post?.userId === userId)); 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 + // Media gallery - dedupe by base filename (ignore size variants like _large, _medium, _small) const gallery = useMemo(() => { const seen = new Set(); + const seenBase = new Set(); const images = []; - const add = (url) => { const n = normalizeMediaUrl(url); if (n && !seen.has(n)) { seen.add(n); images.push(n); } }; + const getBase = (url) => { + // Remove size suffixes to detect same image in different sizes + return url.replace(/[-_](large|medium|small|thumb|thumbnail)(\.[^.]+)?$/i, '$2') + .replace(/\?.*$/, ''); // Remove query params + }; + const add = (url) => { + const n = normalizeMediaUrl(url); + if (!n) return; + const base = getBase(n); + // Skip if we already have this exact URL or same base image + if (seen.has(n) || seenBase.has(base)) return; + seen.add(n); + seenBase.add(base); + images.push(n); + }; + // Add from arrays first (user-uploaded galleries) [post?.media_urls, post?.image_urls].forEach(arr => arr?.forEach(add)); + // Then single images (prefer large > medium > small) [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; @@ -589,7 +680,7 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe const handleShare = useCallback(async () => { if (!postId) return; - const shareUrl = `${window.location.origin}/post/${postId}`; + const shareUrl = `${window.location.origin}/p/${postId}`; if (navigator.share) { try { await navigator.share({ title: post?.title, url: shareUrl }); } catch {} } else { navigator.clipboard?.writeText(shareUrl); } const res = await recordPostShare(postId); @@ -612,7 +703,15 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe justCommentedRef.current = true; setComments(prev => [...prev, newComment]); setCounts(c => ({ ...c, comments: c.comments + 1 })); - const res = await createPostComment(postId, body); + + // Retry logic - try up to 3 times with delay + let res = null; + for (let attempt = 0; attempt < 3; attempt++) { + res = await createPostComment(postId, body); + if (res?.ok) break; + if (attempt < 2) await new Promise(r => setTimeout(r, 500)); // wait before retry + } + setCommentSending(false); if (res?.ok) { // Success - update with server response or just mark as complete @@ -630,11 +729,43 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe const handleSaveEdit = useCallback(async () => { if (!postId || saving) return; + if (!token) { + setSaveError(t("errors.loginRequired") || "Please log in to edit"); + 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]); + setSaveError(""); + try { + const payload = { + title: editTitle, + snippet: editSnippet, + visibility: editVisibility, + }; + const res = await editPost(postId, payload, token); + setSaving(false); + if (res?.ok) { + setShowEdit(false); + setSaveError(""); + // Merge backend response with our local edits + const updatedPost = { + ...post, + ...res, + title: editTitle, + snippet: editSnippet, + visibility: editVisibility + }; + onPostUpdated?.(updatedPost); + } else { + // Show error to user + const errMsg = res?.error || res?.message || t("errors.saveFailed") || "Save failed"; + setSaveError(errMsg); + } + } catch (err) { + console.error("Save edit failed:", err); + setSaving(false); + setSaveError(err?.message || t("errors.saveFailed") || "Save failed"); + } + }, [postId, editTitle, editSnippet, editVisibility, token, saving, post, onPostUpdated, t]); const handleDelete = useCallback(async () => { if (!postId || saving) return; @@ -646,7 +777,7 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe // Close on escape + prevent body scroll useEffect(() => { - const handleKey = (e) => { if (e.key === "Escape" && !fullscreen) onClose?.(); }; + const handleKey = (e) => { if (e.key === "Escape" && !fullscreen) handleClose(); }; window.addEventListener("keydown", handleKey); // Prevent body scroll when modal is open @@ -657,57 +788,103 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe window.removeEventListener("keydown", handleKey); document.body.style.overflow = originalOverflow; }; - }, [onClose, fullscreen]); + }, [fullscreen, handleClose]); + + // Keep fullscreenRef in sync + useEffect(() => { + fullscreenRef.current = fullscreen; + }, [fullscreen]); + + // Browser back button support - separate effect that only runs once + useEffect(() => { + window.history.pushState({ modal: 'post-detail' }, ''); + + const handlePopState = () => { + // Only close modal if not in fullscreen (fullscreen has its own handler) + if (!fullscreenRef.current) { + closedViaBackRef.current = true; + handleClose(); + } + }; + window.addEventListener("popstate", handlePopState); + + return () => { + window.removeEventListener("popstate", handlePopState); + }; + }, [handleClose]); if (!post) return null; return ( <> { if (e.target === e.currentTarget) onClose?.(); }} + onClick={(e) => { if (e.target === e.currentTarget) handleClose(); }} > setDragY(Math.max(0, info.offset.y))} + onDragEnd={(_, info) => { + if (info.offset.y > 100 || info.velocity.y > 500) { + handleClose(); + } else { + setDragY(0); + } + }} onClick={(e) => e.stopPropagation()} > + {/* Drag handle for mobile */} +
+
+
+ {/* Hero Section */}
{postType === "live" ? : postType === "camera" ? : postType === "video" ? : postType === "picture" ? setFullscreen(true)} /> - : postType === "story" ? - : } + : postType === "story" ? setFullscreen(true)} /> + : setFullscreen(true)} />} {/* Close Button */} + whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }} onClick={handleClose}> - {/* Category Badge */} -
+ {/* Category Badge + Visibility */} +
- {post?.category || "News"} + {post?.category || "News"}{post?.sub_category ? ` / ${post.sub_category}` : ""} {(postType === "live" || postType === "camera") && }
+ {/* Visibility Badge - only show if not public */} + {post?.visibility && post.visibility !== "public" && ( +
+ +
+ )}
{/* Content Section */} -
+
{/* Title */} {post?.title && ( @@ -732,7 +909,7 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
{isAuthor && (
- setShowEdit(!showEdit)} + { setShowEdit(!showEdit); setSaveError(""); }} className="w-9 h-9 rounded-full bg-themed-accent flex items-center justify-center text-accent"> @@ -753,12 +930,27 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe 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" />