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:
parent
30c962e95e
commit
2a9ec02d1c
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "sociowire-frontend",
|
"name": "sociowire-frontend",
|
||||||
"version": "0.0.95",
|
"version": "0.0.102",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sociowire-frontend",
|
"name": "sociowire-frontend",
|
||||||
"version": "0.0.95",
|
"version": "0.0.102",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "sociowire-frontend",
|
"name": "sociowire-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.95",
|
"version": "0.0.102",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
|
|
||||||
|
|
@ -845,7 +845,7 @@ export async function fetchPostLikeState(postId) {
|
||||||
|
|
||||||
// Edit post
|
// Edit post
|
||||||
export async function editPost(postId, payload, token) {
|
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();
|
const qs = new URLSearchParams();
|
||||||
qs.set("id", String(postId));
|
qs.set("id", String(postId));
|
||||||
try {
|
try {
|
||||||
|
|
@ -857,10 +857,15 @@ export async function editPost(postId, payload, token) {
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify(payload),
|
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(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
return { ok: res.ok, ...data };
|
return { ok: true, ...data };
|
||||||
} catch {
|
} catch (err) {
|
||||||
return { ok: false };
|
return { ok: false, error: err?.message || "Network error" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import { fetchPostById, fetchPostByUrl } from "../../api/client";
|
||||||
import { trackEvent } from "../../utils/analytics";
|
import { trackEvent } from "../../utils/analytics";
|
||||||
import DayNightTerminator from "./DayNightTerminator";
|
import DayNightTerminator from "./DayNightTerminator";
|
||||||
import EventClustersLayer from "./EventClustersLayer";
|
import EventClustersLayer from "./EventClustersLayer";
|
||||||
|
import { AppOverlay, initializeApps, setupDemoApps, getDefaultAppHost, createMapContextBridge } from "../../apps";
|
||||||
|
|
||||||
const CONTENT_CREATOR_MODES = [
|
const CONTENT_CREATOR_MODES = [
|
||||||
{
|
{
|
||||||
|
|
@ -188,9 +189,9 @@ export default function MapView({
|
||||||
try {
|
try {
|
||||||
const v = localStorage.getItem(TIME_FILTER_KEY);
|
const v = localStorage.getItem(TIME_FILTER_KEY);
|
||||||
const n = Number(v);
|
const n = Number(v);
|
||||||
return Number.isFinite(n) && n > 0 ? n : 336;
|
return Number.isFinite(n) && n > 0 ? n : 24; // Default: 24h
|
||||||
} catch {
|
} catch {
|
||||||
return 336;
|
return 24;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -314,6 +315,11 @@ export default function MapView({
|
||||||
const searchFitQueryRef = useRef("");
|
const searchFitQueryRef = useRef("");
|
||||||
const searchUserMovedRef = useRef(false);
|
const searchUserMovedRef = useRef(false);
|
||||||
|
|
||||||
|
// Apps system
|
||||||
|
const mapContextBridgeRef = useRef(null);
|
||||||
|
const appHostRef = useRef(null);
|
||||||
|
const [appsInitialized, setAppsInitialized] = useState(false);
|
||||||
|
|
||||||
const openOverlayWhenReady = useCallback(
|
const openOverlayWhenReady = useCallback(
|
||||||
(post, opts = {}) => {
|
(post, opts = {}) => {
|
||||||
if (!post) return;
|
if (!post) return;
|
||||||
|
|
@ -385,6 +391,51 @@ export default function MapView({
|
||||||
};
|
};
|
||||||
}, [mapRef.current, cancelLongPress]);
|
}, [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
|
// Touch/mouse event handlers for long-press
|
||||||
// Re-run when viewParams changes (indicates map is ready)
|
// Re-run when viewParams changes (indicates map is ready)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1076,6 +1127,11 @@ export default function MapView({
|
||||||
handlePostDelete(msg);
|
handlePostDelete(msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Forward app events to AppHost
|
||||||
|
if (msg.type === "app.event" && appHostRef.current) {
|
||||||
|
appHostRef.current.handleWSMessage(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
} catch {}
|
} 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>}
|
{status && <div className="map-status">{status}</div>}
|
||||||
{debugEnabled && (
|
{debugEnabled && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -2186,50 +2246,7 @@ export default function MapView({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Weather widget - flows naturally below search bar */}
|
{/* Weather widget now provided by apps system - see AppOverlay */}
|
||||||
{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>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dual FAB Menu - Left: Categories, Right: Time/Weather */}
|
{/* Dual FAB Menu - Left: Categories, Right: Time/Weather */}
|
||||||
|
|
|
||||||
|
|
@ -787,20 +787,31 @@ function fullImageForPost(post) {
|
||||||
function collectGalleryImages(post) {
|
function collectGalleryImages(post) {
|
||||||
const result = [];
|
const result = [];
|
||||||
const seen = new Set();
|
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 add = (value) => {
|
||||||
const normalized = normalizeImageUrl(value);
|
const normalized = normalizeImageUrl(value);
|
||||||
if (!normalized) return;
|
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);
|
seen.add(normalized);
|
||||||
|
seenBase.add(base);
|
||||||
result.push(normalized);
|
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)) {
|
if (Array.isArray(post?.media_urls)) {
|
||||||
post.media_urls.forEach(add);
|
post.media_urls.forEach(add);
|
||||||
}
|
}
|
||||||
if (Array.isArray(post?.image_urls)) {
|
if (Array.isArray(post?.image_urls)) {
|
||||||
post.image_urls.forEach(add);
|
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) => {
|
["image_large", "image_medium", "image_small", "image", "media_url"].forEach((key) => {
|
||||||
add(post?.[key]);
|
add(post?.[key]);
|
||||||
});
|
});
|
||||||
|
|
@ -2348,8 +2359,12 @@ export function createMarkerForPost(post, mapRef, markersRef, expandedElRef, the
|
||||||
const hasOffset = false;
|
const hasOffset = false;
|
||||||
const lineHTML = '';
|
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 = `
|
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>
|
<div class="post-pin-pointer-small"></div>
|
||||||
${lineHTML}
|
${lineHTML}
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -11,23 +11,23 @@
|
||||||
export const TEMPLATE_SPECS = {
|
export const TEMPLATE_SPECS = {
|
||||||
polaroid: {
|
polaroid: {
|
||||||
mini_spec: {
|
mini_spec: {
|
||||||
size: { w: 150, h: 190 },
|
size: { w: 100, h: 130 },
|
||||||
background: { type: "solid", value: "#ffffff" },
|
background: { type: "solid", value: "#ffffff" },
|
||||||
radius: 6,
|
radius: 4,
|
||||||
layers: [
|
layers: [
|
||||||
{ id: "image", type: "image", x: 8, y: 8, w: 134, h: 134, bind: "data.image", radius: 3 },
|
{ id: "image", type: "image", x: 5, y: 5, w: 90, h: 90, bind: "data.image", radius: 2 },
|
||||||
{ id: "title", type: "text", x: 8, y: 146, w: 134, h: 24, bind: "data.headline", style: "text.polaroidTitle", maxLines: 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: 8, y: 172, w: 134, h: 14, bind: "data.source", style: "text.polaroidCaption", maxLines: 1 },
|
{ id: "author", type: "text", x: 5, y: 116, w: 90, h: 10, bind: "data.source", style: "text.polaroidCaption", maxLines: 1 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
full_spec: {
|
full_spec: {
|
||||||
size: { w: 280, h: 340 },
|
size: { w: 190, h: 230 },
|
||||||
background: { type: "solid", value: "#ffffff" },
|
background: { type: "solid", value: "#ffffff" },
|
||||||
radius: 12,
|
radius: 8,
|
||||||
layers: [
|
layers: [
|
||||||
{ id: "image", type: "image", x: 15, y: 15, w: 250, h: 250, bind: "data.image", radius: 6 },
|
{ id: "image", type: "image", x: 10, y: 10, w: 170, h: 170, bind: "data.image", radius: 4 },
|
||||||
{ id: "author", type: "text", x: 15, y: 275, w: 250, h: 24, bind: "data.source", style: "text.polaroidCaption", maxLines: 1 },
|
{ 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: 15, y: 300, w: 250, h: 32, bind: "data.headline", style: "text.polaroidText", maxLines: 2 },
|
{ 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: {
|
camera: {
|
||||||
mini_spec: {
|
mini_spec: {
|
||||||
size: { w: 200, h: 112 },
|
size: { w: 140, h: 80 },
|
||||||
background: { type: "solid", value: "#0f172a" },
|
background: { type: "solid", value: "#0f172a" },
|
||||||
radius: 10,
|
radius: 8,
|
||||||
layers: [
|
layers: [
|
||||||
{ id: "image", type: "image", x: 0, y: 0, w: 200, h: 112, bind: "data.image", radius: 10 },
|
{ id: "image", type: "image", x: 0, y: 0, w: 140, h: 80, bind: "data.image", radius: 8 },
|
||||||
{ id: "liveIcon", type: "icon", x: 8, y: 8, w: 24, h: 24, text: "\uf03d", style: "text.liveIcon" },
|
{ id: "liveIcon", type: "icon", x: 6, y: 6, w: 18, h: 18, text: "\uf03d", style: "text.liveIcon" },
|
||||||
{ id: "badge", type: "chip", x: 36, y: 8, text: "LIVE", style: "chip.live" },
|
{ id: "badge", type: "chip", x: 26, y: 6, text: "LIVE", style: "chip.live" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
full_spec: {
|
full_spec: {
|
||||||
size: { w: 320, h: 200 },
|
size: { w: 240, h: 150 },
|
||||||
background: { type: "solid", value: "#0f172a" },
|
background: { type: "solid", value: "#0f172a" },
|
||||||
radius: 14,
|
radius: 10,
|
||||||
layers: [
|
layers: [
|
||||||
{ id: "image", type: "image", x: 0, y: 0, w: 320, h: 180, bind: "data.image", radius: 14 },
|
{ id: "image", type: "image", x: 0, y: 0, w: 240, h: 135, bind: "data.image", radius: 10 },
|
||||||
{ id: "liveIcon", type: "icon", x: 12, y: 12, w: 28, h: 28, text: "\uf03d", style: "text.liveIcon" },
|
{ id: "liveIcon", type: "icon", x: 8, y: 8, w: 22, h: 22, text: "\uf03d", style: "text.liveIcon" },
|
||||||
{ id: "badge", type: "chip", x: 44, y: 12, text: "LIVE", style: "chip.live" },
|
{ id: "badge", type: "chip", x: 34, y: 8, 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 },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -110,12 +110,24 @@ function getLiveKitRoomName(post) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHeroImage(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_url) return normalizeMediaUrl(post.image_url);
|
||||||
|
if (post?.image) return normalizeMediaUrl(post.image);
|
||||||
if (post?.thumbnail_url) return normalizeMediaUrl(post.thumbnail_url);
|
if (post?.thumbnail_url) return normalizeMediaUrl(post.thumbnail_url);
|
||||||
|
if (post?.image_small) return normalizeMediaUrl(post.image_small);
|
||||||
const media = post?.media;
|
const media = post?.media;
|
||||||
if (Array.isArray(media) && media.length > 0) {
|
if (Array.isArray(media) && media.length > 0) {
|
||||||
return normalizeMediaUrl(media[0]?.url || media[0]?.thumbnail_url || "");
|
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 "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,6 +157,19 @@ const CATEGORY_CONFIG = {
|
||||||
default: { icon: "fa-newspaper", gradient: "from-blue-500 to-cyan-500", color: "#60a5fa" },
|
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) {
|
function getCategoryConfig(category) {
|
||||||
return CATEGORY_CONFIG[(category || "").toLowerCase()] || CATEGORY_CONFIG.default;
|
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 heroImage = gallery[imageIndex] || getHeroImage(post);
|
||||||
const author = post?.author || post?.username || "Anonymous";
|
const author = post?.author || post?.username || "Anonymous";
|
||||||
const config = getCategoryConfig(post?.category);
|
const config = getCategoryConfig(post?.category);
|
||||||
|
|
@ -365,8 +390,8 @@ function HeroStory({ post, gallery, imageIndex, setImageIndex }) {
|
||||||
if (heroImage) {
|
if (heroImage) {
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<img src={heroImage} alt="" className="w-full h-full object-cover" />
|
<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" />
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-black/30 pointer-events-none" />
|
||||||
{gallery.length > 1 && (
|
{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"
|
<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>
|
</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>
|
</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 heroImage = getHeroImage(post);
|
||||||
const sourceDomain = getSourceDomain(post?.url);
|
const sourceDomain = getSourceDomain(post?.url);
|
||||||
const config = getCategoryConfig(post?.category);
|
const config = getCategoryConfig(post?.category);
|
||||||
|
|
@ -414,8 +443,8 @@ function HeroLink({ post, gallery, imageIndex, setImageIndex }) {
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<img src={gallery[imageIndex] || heroImage} alt="" className="w-full h-full object-cover" />
|
<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" />
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-black/30 pointer-events-none" />
|
||||||
{sourceDomain && (
|
{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">
|
<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" />
|
<i className="fa-solid fa-link text-accent" />
|
||||||
|
|
@ -427,6 +456,10 @@ function HeroLink({ post, gallery, imageIndex, setImageIndex }) {
|
||||||
{imageIndex + 1} / {gallery.length}
|
{imageIndex + 1} / {gallery.length}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -435,20 +468,42 @@ function HeroLink({ post, gallery, imageIndex, setImageIndex }) {
|
||||||
// FULLSCREEN GALLERY
|
// FULLSCREEN GALLERY
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
function FullscreenGallery({ gallery, imageIndex, setImageIndex, onClose }) {
|
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(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e) => {
|
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 === "ArrowLeft") setImageIndex((i) => (i - 1 + gallery.length) % gallery.length);
|
||||||
if (e.key === "ArrowRight") setImageIndex((i) => (i + 1) % gallery.length);
|
if (e.key === "ArrowRight") setImageIndex((i) => (i + 1) % gallery.length);
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", handleKey);
|
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 (
|
return (
|
||||||
<motion.div className="fixed inset-0 z-[2000] bg-black flex items-center justify-center"
|
<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}>
|
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" onClick={(e) => e.stopPropagation()} />
|
<img src={gallery[imageIndex]} alt="" className="max-w-full max-h-full object-contain cursor-pointer" onClick={handleClose} />
|
||||||
{gallery.length > 1 && (
|
{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"
|
<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"
|
<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" />
|
<i className="fa-solid fa-times text-xl" />
|
||||||
</button>
|
</button>
|
||||||
{gallery.length > 1 && (
|
{gallery.length > 1 && (
|
||||||
|
|
@ -479,8 +534,23 @@ function FullscreenGallery({ gallery, imageIndex, setImageIndex, onClose }) {
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDeleted }) {
|
export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDeleted }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { username, token } = useAuth();
|
const { username, userId, token } = useAuth();
|
||||||
const modalRef = useRef(null);
|
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
|
// State
|
||||||
const [counts, setCounts] = useState({
|
const [counts, setCounts] = useState({
|
||||||
|
|
@ -494,29 +564,50 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
|
||||||
const [comments, setComments] = useState([]);
|
const [comments, setComments] = useState([]);
|
||||||
const [commentInput, setCommentInput] = useState("");
|
const [commentInput, setCommentInput] = useState("");
|
||||||
const [commentSending, setCommentSending] = useState(false);
|
const [commentSending, setCommentSending] = useState(false);
|
||||||
const [showComments, setShowComments] = useState(false);
|
const [showComments, setShowComments] = useState(true);
|
||||||
const [showEdit, setShowEdit] = useState(false);
|
const [showEdit, setShowEdit] = useState(false);
|
||||||
const [showDelete, setShowDelete] = useState(false);
|
const [showDelete, setShowDelete] = useState(false);
|
||||||
const [editTitle, setEditTitle] = useState(post?.title || "");
|
const [editTitle, setEditTitle] = useState(post?.title || "");
|
||||||
const [editSnippet, setEditSnippet] = useState(post?.snippet || "");
|
const [editSnippet, setEditSnippet] = useState(post?.snippet || "");
|
||||||
|
const [editVisibility, setEditVisibility] = useState(post?.visibility || "public");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState("");
|
||||||
const [imageIndex, setImageIndex] = useState(0);
|
const [imageIndex, setImageIndex] = useState(0);
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
|
const fullscreenRef = useRef(false);
|
||||||
|
|
||||||
const commentsEndRef = useRef(null);
|
const commentsEndRef = useRef(null);
|
||||||
const justCommentedRef = useRef(false);
|
const justCommentedRef = useRef(false);
|
||||||
const postId = post?.id || post?._id;
|
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 postType = useMemo(() => getPostType(post), [post]);
|
||||||
const config = getCategoryConfig(post?.category);
|
const config = getCategoryConfig(post?.category);
|
||||||
const author = post?.author || post?.username || post?.source_name || t("common.anon");
|
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 gallery = useMemo(() => {
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
|
const seenBase = new Set();
|
||||||
const images = [];
|
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));
|
[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?.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));
|
post?.media?.forEach(m => add(m?.url || m?.thumbnail_url));
|
||||||
return images;
|
return images;
|
||||||
|
|
@ -589,7 +680,7 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
|
||||||
|
|
||||||
const handleShare = useCallback(async () => {
|
const handleShare = useCallback(async () => {
|
||||||
if (!postId) return;
|
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 {} }
|
if (navigator.share) { try { await navigator.share({ title: post?.title, url: shareUrl }); } catch {} }
|
||||||
else { navigator.clipboard?.writeText(shareUrl); }
|
else { navigator.clipboard?.writeText(shareUrl); }
|
||||||
const res = await recordPostShare(postId);
|
const res = await recordPostShare(postId);
|
||||||
|
|
@ -612,7 +703,15 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
|
||||||
justCommentedRef.current = true;
|
justCommentedRef.current = true;
|
||||||
setComments(prev => [...prev, newComment]);
|
setComments(prev => [...prev, newComment]);
|
||||||
setCounts(c => ({ ...c, comments: c.comments + 1 }));
|
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);
|
setCommentSending(false);
|
||||||
if (res?.ok) {
|
if (res?.ok) {
|
||||||
// Success - update with server response or just mark as complete
|
// 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 () => {
|
const handleSaveEdit = useCallback(async () => {
|
||||||
if (!postId || saving) return;
|
if (!postId || saving) return;
|
||||||
|
if (!token) {
|
||||||
|
setSaveError(t("errors.loginRequired") || "Please log in to edit");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
const res = await editPost(postId, { title: editTitle, snippet: editSnippet }, token);
|
setSaveError("");
|
||||||
setSaving(false);
|
try {
|
||||||
if (res?.ok) { setShowEdit(false); onPostUpdated?.({ ...post, title: editTitle, snippet: editSnippet }); }
|
const payload = {
|
||||||
}, [postId, editTitle, editSnippet, token, saving, post, onPostUpdated]);
|
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 () => {
|
const handleDelete = useCallback(async () => {
|
||||||
if (!postId || saving) return;
|
if (!postId || saving) return;
|
||||||
|
|
@ -646,7 +777,7 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
|
||||||
|
|
||||||
// Close on escape + prevent body scroll
|
// Close on escape + prevent body scroll
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e) => { if (e.key === "Escape" && !fullscreen) onClose?.(); };
|
const handleKey = (e) => { if (e.key === "Escape" && !fullscreen) handleClose(); };
|
||||||
window.addEventListener("keydown", handleKey);
|
window.addEventListener("keydown", handleKey);
|
||||||
|
|
||||||
// Prevent body scroll when modal is open
|
// Prevent body scroll when modal is open
|
||||||
|
|
@ -657,57 +788,103 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
|
||||||
window.removeEventListener("keydown", handleKey);
|
window.removeEventListener("keydown", handleKey);
|
||||||
document.body.style.overflow = originalOverflow;
|
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;
|
if (!post) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<motion.div
|
<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 }}
|
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
|
<motion.div
|
||||||
ref={modalRef}
|
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"
|
bg-themed-secondary border border-themed shadow-2xl"
|
||||||
initial={{ scale: 0.9, y: 50, opacity: 0 }}
|
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 }}
|
exit={{ scale: 0.9, y: 50, opacity: 0 }}
|
||||||
transition={{ type: "spring", damping: 30, stiffness: 400 }}
|
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()}
|
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 */}
|
{/* Hero Section */}
|
||||||
<div className="relative aspect-[16/9] sm:aspect-video bg-surface-950 overflow-hidden">
|
<div className="relative aspect-[16/9] sm:aspect-video bg-surface-950 overflow-hidden">
|
||||||
{postType === "live" ? <HeroLive post={post} authToken={token} username={username} />
|
{postType === "live" ? <HeroLive post={post} authToken={token} username={username} />
|
||||||
: postType === "camera" ? <HeroCamera post={post} />
|
: postType === "camera" ? <HeroCamera post={post} />
|
||||||
: postType === "video" ? <HeroVideo post={post} />
|
: postType === "video" ? <HeroVideo post={post} />
|
||||||
: postType === "picture" ? <HeroPicture gallery={gallery} imageIndex={imageIndex} setImageIndex={setImageIndex} post={post} onFullscreen={() => setFullscreen(true)} />
|
: 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} />
|
: postType === "story" ? <HeroStory post={post} gallery={gallery} imageIndex={imageIndex} setImageIndex={setImageIndex} onFullscreen={() => setFullscreen(true)} />
|
||||||
: <HeroLink post={post} gallery={gallery} imageIndex={imageIndex} setImageIndex={setImageIndex} />}
|
: <HeroLink post={post} gallery={gallery} imageIndex={imageIndex} setImageIndex={setImageIndex} onFullscreen={() => setFullscreen(true)} />}
|
||||||
|
|
||||||
{/* Close Button */}
|
{/* Close Button */}
|
||||||
<motion.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
|
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"
|
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" />
|
<i className="fa-solid fa-times text-lg" />
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|
||||||
{/* Category Badge */}
|
{/* Category Badge + Visibility */}
|
||||||
<div className="absolute top-3 left-3 sm:top-4 sm:left-4 z-20">
|
<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}
|
<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`}>
|
text-white text-xs sm:text-sm font-bold uppercase tracking-wide shadow-lg`}>
|
||||||
<i className={`fa-solid ${config.icon}`} />
|
<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" />}
|
{(postType === "live" || postType === "camera") && <span className="w-2 h-2 rounded-full bg-white animate-pulse" />}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Section */}
|
{/* 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">
|
<div className="p-4 sm:p-6">
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
{post?.title && (
|
{post?.title && (
|
||||||
|
|
@ -732,7 +909,7 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
|
||||||
</div>
|
</div>
|
||||||
{isAuthor && (
|
{isAuthor && (
|
||||||
<div className="flex gap-2">
|
<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">
|
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" />
|
<i className="fa-solid fa-pen text-sm" />
|
||||||
</motion.button>
|
</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" />
|
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}
|
<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" />
|
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">
|
<div className="flex gap-2">
|
||||||
<button onClick={handleSaveEdit} disabled={saving}
|
<button onClick={handleSaveEdit} disabled={saving}
|
||||||
className="flex-1 py-3 rounded-xl bg-accent text-white font-semibold hover:opacity-90 disabled:opacity-50">
|
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")}
|
{saving ? t("post.saving") : t("common.save")}
|
||||||
</button>
|
</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")}
|
{t("common.cancel")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -814,69 +1006,54 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stats & Actions */}
|
{/* Compact Engagement Bar */}
|
||||||
<div className="mt-6 pt-4 border-t border-themed">
|
<div className="mt-4 pt-3 border-t border-themed">
|
||||||
{/* Stats Row */}
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center justify-around mb-4">
|
{/* Stats - compact inline */}
|
||||||
{[
|
<div className="flex items-center gap-3 text-xs text-themed-muted">
|
||||||
{ icon: "fa-eye", value: counts.views, label: "views" },
|
<span className="flex items-center gap-1"><i className="fa-solid fa-eye" />{formatCount(counts.views)}</span>
|
||||||
{ icon: "fa-heart", value: counts.likes, label: "likes" },
|
<span className="flex items-center gap-1"><i className="fa-solid fa-heart" />{formatCount(counts.likes)}</span>
|
||||||
{ icon: "fa-share", value: counts.shares, label: "shares" },
|
<span className="flex items-center gap-1"><i className="fa-solid fa-share" />{formatCount(counts.shares)}</span>
|
||||||
{ icon: "fa-comment", value: counts.comments, label: "comments" },
|
</div>
|
||||||
].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 */}
|
{/* Mini Truth Score */}
|
||||||
<div className="mb-3 p-2.5 rounded-xl bg-themed-elevated">
|
<div className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-themed-elevated">
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<motion.button whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }} onClick={() => handleTruthVote("false")}
|
<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">
|
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 text-sm" />
|
<i className="fa-solid fa-thumbs-down" />
|
||||||
</motion.button>
|
</motion.button>
|
||||||
<div className="flex-1">
|
<div className="flex items-center gap-1 min-w-[50px]">
|
||||||
<div className="flex items-center justify-center gap-1.5 mb-1">
|
<div className="w-8 h-1.5 rounded-full bg-surface-800 overflow-hidden">
|
||||||
<span className={`text-xl font-black ${truth.score >= 70 ? 'text-emerald-400' : truth.score >= 50 ? 'text-amber-400' : 'text-red-400'}`}>
|
<motion.div className={`h-full rounded-full ${truth.score >= 70 ? 'bg-emerald-400' : truth.score >= 50 ? 'bg-amber-400' : 'bg-red-400'}`}
|
||||||
{Math.round(truth.score)}
|
initial={{ width: 0 }} animate={{ width: `${truth.score}%` }} transition={{ duration: 0.4 }} />
|
||||||
</span>
|
|
||||||
<span className="text-themed-muted text-xs">/ 100</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1.5 rounded-full bg-surface-800 overflow-hidden">
|
<span className={`text-xs font-bold ${truth.score >= 70 ? 'text-emerald-400' : truth.score >= 50 ? 'text-amber-400' : 'text-red-400'}`}>
|
||||||
<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'}`}
|
{Math.round(truth.score)}
|
||||||
initial={{ width: 0 }} animate={{ width: `${truth.score}%` }} transition={{ duration: 0.6 }} />
|
</span>
|
||||||
</div>
|
|
||||||
<p className="text-center text-[10px] text-themed-muted mt-0.5">{truth.count} votes</p>
|
|
||||||
</div>
|
</div>
|
||||||
<motion.button whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }} onClick={() => handleTruthVote("true")}
|
<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">
|
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 text-sm" />
|
<i className="fa-solid fa-thumbs-up" />
|
||||||
</motion.button>
|
</motion.button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Compact Action Buttons */}
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="flex items-center gap-1">
|
||||||
<motion.button whileTap={{ scale: 0.95 }} onClick={handleLike}
|
<motion.button whileTap={{ scale: 0.9 }} onClick={handleLike}
|
||||||
className={`flex items-center justify-center gap-2 py-3 rounded-xl font-semibold transition-all
|
className={`w-8 h-8 rounded-full flex items-center justify-center 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'}`}>
|
${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"} />
|
<i className={liked ? "fa-solid fa-heart" : "fa-regular fa-heart"} />
|
||||||
<span className="hidden sm:inline">{t("post.like")}</span>
|
</motion.button>
|
||||||
</motion.button>
|
<motion.button whileTap={{ scale: 0.9 }} onClick={() => setShowComments(!showComments)}
|
||||||
<motion.button whileTap={{ scale: 0.95 }} onClick={() => setShowComments(!showComments)}
|
className={`w-8 h-8 rounded-full flex items-center justify-center transition-all
|
||||||
className={`flex items-center justify-center gap-2 py-3 rounded-xl font-semibold transition-all
|
${showComments ? 'bg-accent/20 text-accent' : 'bg-themed-elevated text-themed-secondary hover:text-accent'}`}>
|
||||||
${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" />
|
||||||
<i className="fa-solid fa-comment" />
|
</motion.button>
|
||||||
<span className="hidden sm:inline">{t("post.comment")}</span>
|
<motion.button whileTap={{ scale: 0.9 }} onClick={handleShare}
|
||||||
</motion.button>
|
className="w-8 h-8 rounded-full bg-themed-elevated text-themed-secondary flex items-center justify-center hover:text-accent transition-all">
|
||||||
<motion.button whileTap={{ scale: 0.95 }} onClick={handleShare}
|
<i className="fa-solid fa-share" />
|
||||||
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>
|
||||||
<i className="fa-solid fa-share" />
|
</div>
|
||||||
<span className="hidden sm:inline">{t("post.share")}</span>
|
|
||||||
</motion.button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -892,11 +1069,10 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Comments List */}
|
{/* 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 ? (
|
{comments.length === 0 ? (
|
||||||
<div className="text-center py-8 text-themed-muted">
|
<div className="text-center py-3 text-themed-muted">
|
||||||
<i className="fa-regular fa-comment-dots text-4xl mb-2 opacity-40" />
|
<p className="text-sm opacity-60">{t("postModal.noComments")}</p>
|
||||||
<p>{t("postModal.noComments")}</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
comments.map((c) => (
|
comments.map((c) => (
|
||||||
|
|
|
||||||
|
|
@ -43,19 +43,20 @@
|
||||||
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
|
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{
|
.sw-template-mini-wrap{
|
||||||
|
--card-tilt: 0deg;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 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;
|
transform-origin: bottom center;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px){
|
@media (max-width: 640px){
|
||||||
.sw-template-mini-wrap{
|
.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{
|
.sw-template-mini-wrap{
|
||||||
backface-visibility: hidden;
|
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;
|
transform-origin: bottom center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px){
|
@media (max-width: 640px){
|
||||||
.sw-template-mini-wrap{
|
.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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue