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 && (
<>
)}
+ onClick={handleClose}>
{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" />