Fix map cards, share URL, and post modal issues

- Fix share URL format (/p/ instead of /post/)
- Reduce map card size (scale 0.45/0.38)
- Add polaroid tilt effect via CSS variable
- Fix card positioning above pointer
- Improve gallery image deduplication
- Fix save error handling in post edit

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-01-21 20:38:52 +00:00
parent 30c962e95e
commit 2a9ec02d1c
8 changed files with 391 additions and 178 deletions

4
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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" };
}
}

View File

@ -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 && (
<AppOverlay appHost={appHostRef.current} visible={true} />
)}
{status && <div className="map-status">{status}</div>}
{debugEnabled && (
<>
@ -2186,50 +2246,7 @@ export default function MapView({
</div>
</div>
</div>
{/* 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 (
<div
onClick={() => 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)',
}}
>
<span style={{ fontSize: '16px' }}>{w.icon}</span>
<span>{w.city}:</span>
<span style={{ fontWeight: '700' }}>{w.temp}°</span>
</div>
);
})()}
{/* Weather widget now provided by apps system - see AppOverlay */}
</div>
{/* Dual FAB Menu - Left: Categories, Right: Time/Weather */}

View File

@ -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 = `
<div class="sw-template-mini-wrap"></div>
<div class="sw-template-mini-wrap" style="--card-tilt: ${tiltAngle}deg;"></div>
<div class="post-pin-pointer-small"></div>
${lineHTML}
`;

View File

@ -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" },
],
},
},

View File

@ -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 (
<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" />
<img src={heroImage} alt="" className="w-full h-full object-cover cursor-zoom-in" onClick={onFullscreen} />
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-black/30 pointer-events-none" />
{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"
@ -382,6 +407,10 @@ function HeroStory({ post, gallery, imageIndex, setImageIndex }) {
</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>
);
}
@ -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 (
<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" />
<img src={gallery[imageIndex] || heroImage} alt="" className="w-full h-full object-cover cursor-zoom-in" onClick={onFullscreen} />
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-black/30 pointer-events-none" />
{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" />
@ -427,6 +456,10 @@ function HeroLink({ post, gallery, imageIndex, setImageIndex }) {
{imageIndex + 1} / {gallery.length}
</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>
);
}
@ -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 (
<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()} />
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={handleClose}>
<img src={gallery[imageIndex]} alt="" className="max-w-full max-h-full object-contain cursor-pointer" onClick={handleClose} />
{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"
@ -462,7 +517,7 @@ function FullscreenGallery({ gallery, imageIndex, setImageIndex, onClose }) {
</>
)}
<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}>
onClick={handleClose}>
<i className="fa-solid fa-times text-xl" />
</button>
{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);
setSaveError("");
try {
const payload = {
title: editTitle,
snippet: editSnippet,
visibility: editVisibility,
};
const res = await editPost(postId, payload, token);
setSaving(false);
if (res?.ok) { setShowEdit(false); onPostUpdated?.({ ...post, title: editTitle, snippet: editSnippet }); }
}, [postId, editTitle, editSnippet, token, saving, post, onPostUpdated]);
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 (
<>
<motion.div
className="fixed inset-0 z-[1100] flex items-center justify-center p-2 sm:p-4 bg-black/90 backdrop-blur-md"
className="fixed inset-0 z-[1100] flex items-start justify-center pt-4 sm:pt-6 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?.(); }}
onClick={(e) => { if (e.target === e.currentTarget) handleClose(); }}
>
<motion.div
ref={modalRef}
className="relative w-full max-w-5xl max-h-[95vh] overflow-hidden rounded-2xl sm:rounded-3xl
className="relative w-full max-w-5xl max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-3rem)] 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 }}
animate={{ scale: 1, y: dragY, opacity: 1 - dragY / 300 }}
exit={{ scale: 0.9, y: 50, opacity: 0 }}
transition={{ type: "spring", damping: 30, stiffness: 400 }}
drag="y"
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.5 }}
onDrag={(_, info) => 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 */}
<div className="sm:hidden absolute top-2 left-1/2 -translate-x-1/2 z-40">
<div className="w-10 h-1 rounded-full bg-white/40" />
</div>
{/* 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} />}
: postType === "story" ? <HeroStory post={post} gallery={gallery} imageIndex={imageIndex} setImageIndex={setImageIndex} onFullscreen={() => setFullscreen(true)} />
: <HeroLink post={post} gallery={gallery} imageIndex={imageIndex} setImageIndex={setImageIndex} onFullscreen={() => setFullscreen(true)} />}
{/* 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}>
whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }} onClick={handleClose}>
<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">
{/* Category Badge + Visibility */}
<div className="absolute top-3 left-3 sm:top-4 sm:left-4 z-20 flex items-center gap-2">
<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>
<span>{post?.category || "News"}{post?.sub_category ? ` / ${post.sub_category}` : ""}</span>
{(postType === "live" || postType === "camera") && <span className="w-2 h-2 rounded-full bg-white animate-pulse" />}
</div>
{/* Visibility Badge - only show if not public */}
{post?.visibility && post.visibility !== "public" && (
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-full bg-black/60 backdrop-blur-sm text-white text-xs font-medium shadow-lg"
title={getVisibilityConfig(post.visibility).label}>
<i className={`fa-solid ${getVisibilityConfig(post.visibility).icon}`} />
</div>
)}
</div>
</div>
{/* Content Section */}
<div className="max-h-[50vh] overflow-y-auto">
<div className="max-h-[70vh] overflow-y-auto">
<div className="p-4 sm:p-6">
{/* Title */}
{post?.title && (
@ -732,7 +909,7 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
</div>
{isAuthor && (
<div className="flex gap-2">
<motion.button whileTap={{ scale: 0.9 }} onClick={() => setShowEdit(!showEdit)}
<motion.button whileTap={{ scale: 0.9 }} onClick={() => { setShowEdit(!showEdit); setSaveError(""); }}
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>
@ -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" />
<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" />
{/* Visibility/Audience selector */}
<div className="mb-3">
<label className="block text-sm text-themed-muted mb-1">{t("post.audience") || "Audience"}</label>
<select value={editVisibility} onChange={(e) => setEditVisibility(e.target.value)}
className="w-full px-4 py-3 rounded-xl bg-themed-primary border border-themed text-themed-primary focus:outline-none focus:border-accent">
<option value="public">🌐 {t("post.public") || "Public"}</option>
<option value="friends">👥 {t("post.friendsOnly") || "Friends Only"}</option>
<option value="private">🔒 {t("post.private") || "Private"}</option>
</select>
</div>
{saveError && (
<div className="mb-3 p-3 rounded-xl bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
{saveError}
</div>
)}
<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">
<button onClick={() => { setShowEdit(false); setSaveError(""); }} className="flex-1 py-3 rounded-xl bg-themed-elevated border border-themed text-themed-secondary">
{t("common.cancel")}
</button>
</div>
@ -814,71 +1006,56 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
</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>
))}
{/* Compact Engagement Bar */}
<div className="mt-4 pt-3 border-t border-themed">
<div className="flex items-center justify-between gap-2">
{/* Stats - compact inline */}
<div className="flex items-center gap-3 text-xs text-themed-muted">
<span className="flex items-center gap-1"><i className="fa-solid fa-eye" />{formatCount(counts.views)}</span>
<span className="flex items-center gap-1"><i className="fa-solid fa-heart" />{formatCount(counts.likes)}</span>
<span className="flex items-center gap-1"><i className="fa-solid fa-share" />{formatCount(counts.shares)}</span>
</div>
{/* Truth Score */}
<div className="mb-3 p-2.5 rounded-xl bg-themed-elevated">
<div className="flex items-center gap-3">
{/* Mini Truth Score */}
<div className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-themed-elevated">
<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" />
className="w-6 h-6 rounded-full bg-red-500/20 text-red-400 flex items-center justify-center hover:bg-red-500/30 text-xs">
<i className="fa-solid fa-thumbs-down" />
</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'}`}>
<div className="flex items-center gap-1 min-w-[50px]">
<div className="w-8 h-1.5 rounded-full bg-surface-800 overflow-hidden">
<motion.div className={`h-full rounded-full ${truth.score >= 70 ? 'bg-emerald-400' : truth.score >= 50 ? 'bg-amber-400' : 'bg-red-400'}`}
initial={{ width: 0 }} animate={{ width: `${truth.score}%` }} transition={{ duration: 0.4 }} />
</div>
<span className={`text-xs font-bold ${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" />
className="w-6 h-6 rounded-full bg-emerald-500/20 text-emerald-400 flex items-center justify-center hover:bg-emerald-500/30 text-xs">
<i className="fa-solid fa-thumbs-up" />
</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'}`}>
{/* Compact Action Buttons */}
<div className="flex items-center gap-1">
<motion.button whileTap={{ scale: 0.9 }} onClick={handleLike}
className={`w-8 h-8 rounded-full flex items-center justify-center transition-all
${liked ? 'bg-pink-500/20 text-pink-400' : 'bg-themed-elevated text-themed-secondary 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'}`}>
<motion.button whileTap={{ scale: 0.9 }} onClick={() => setShowComments(!showComments)}
className={`w-8 h-8 rounded-full flex items-center justify-center transition-all
${showComments ? 'bg-accent/20 text-accent' : 'bg-themed-elevated text-themed-secondary 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">
<motion.button whileTap={{ scale: 0.9 }} onClick={handleShare}
className="w-8 h-8 rounded-full bg-themed-elevated text-themed-secondary flex items-center justify-center hover:text-accent transition-all">
<i className="fa-solid fa-share" />
<span className="hidden sm:inline">{t("post.share")}</span>
</motion.button>
</div>
</div>
</div>
{/* Comments Section */}
<AnimatePresence>
@ -892,11 +1069,10 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
</div>
{/* Comments List */}
<div className="max-h-64 overflow-y-auto space-y-3 mb-4 scrollbar-thin">
<div className={`${comments.length > 0 ? 'max-h-80' : ''} 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 className="text-center py-3 text-themed-muted">
<p className="text-sm opacity-60">{t("postModal.noComments")}</p>
</div>
) : (
comments.map((c) => (

View File

@ -43,19 +43,20 @@
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
}
/* Template mini wrapper */
/* Template mini wrapper - with polaroid tilt effect */
.sw-template-mini-wrap{
--card-tilt: 0deg;
position: absolute;
left: 0;
top: 0;
transform: translate(-50%, calc(-100% - 12px)) scale(0.62);
transform: translate(-50%, calc(-100% - 12px)) scale(0.45) rotate(var(--card-tilt));
transform-origin: bottom center;
will-change: transform;
}
@media (max-width: 640px){
.sw-template-mini-wrap{
transform: translate(-50%, calc(-100% - 12px)) scale(0.52);
transform: translate(-50%, calc(-100% - 12px)) scale(0.38) rotate(var(--card-tilt));
}
}
@ -1228,13 +1229,13 @@ body[data-theme="light"] .sw-modal-x{
.sw-template-mini-wrap{
backface-visibility: hidden;
transform: translate3d(-50%, calc(-100% - 12px), 0) scale(0.62);
transform: translate3d(-50%, calc(-100% - 12px), 0) scale(0.45) rotate(var(--card-tilt, 0deg));
transform-origin: bottom center;
}
@media (max-width: 640px){
.sw-template-mini-wrap{
transform: translate3d(-50%, calc(-100% - 12px), 0) scale(0.52);
transform: translate3d(-50%, calc(-100% - 12px), 0) scale(0.38) rotate(var(--card-tilt, 0deg));
}
}