feat: Add truth score voting UI with thumbs up/down to map overlays
- Add truth vote section with thumbs up/down buttons and progress rail - Fix image gallery deduplication in map overlay (add image_urls support) - Both regular and cluster modals now have the truth voting UI - Score display with color-coded rail (green/amber/red) - Interactive button feedback on vote Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
1bc7163bf9
commit
bd6d53f1e8
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "sociowire-frontend",
|
||||
"version": "0.0.28",
|
||||
"version": "0.0.64",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sociowire-frontend",
|
||||
"version": "0.0.28",
|
||||
"version": "0.0.64",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||
"axios": "^1.13.2",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "sociowire-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.52",
|
||||
"version": "0.0.64",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
|
|
|
|||
|
|
@ -386,34 +386,24 @@ export async function fetchPostTruth(postId) {
|
|||
if (!res.ok) return null;
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!data) return null;
|
||||
// Normalize response - API may return { truth: {...} } or flat object
|
||||
// Normalize response - API returns { truth: {...} }
|
||||
const truth = data?.truth || data || {};
|
||||
const voteCount = truth.vote_count ?? truth.total_votes ?? truth.count ?? 0;
|
||||
const aiScore = truth.ai_score ?? truth.ai ?? null;
|
||||
const userScore = truth.user_score ?? truth.user ?? null;
|
||||
// Separate truth_score field (only if not already captured by ai/user)
|
||||
const explicitTruth = aiScore === null && userScore === null ? (truth.truth_score ?? null) : null;
|
||||
|
||||
// Only return if we have actual meaningful data:
|
||||
// - Must have votes > 0, OR
|
||||
// - Must have an AI score that's explicitly set (not 0 with no votes)
|
||||
const hasAI = aiScore !== null && Number.isFinite(aiScore);
|
||||
const hasUser = userScore !== null && Number.isFinite(userScore);
|
||||
const hasExplicit = explicitTruth !== null && Number.isFinite(explicitTruth);
|
||||
// truth_score is the main combined score (0-100)
|
||||
const truthScore = truth.truth_score ?? null;
|
||||
const aiScore = truth.ai_score ?? null;
|
||||
const voteCount = truth.total_votes ?? truth.vote_count ?? 0;
|
||||
|
||||
// If no real score data, don't override the initial calculated score
|
||||
if (!hasAI && !hasUser && !hasExplicit) return null;
|
||||
// If score is 0 with no votes, it's likely uninitialized - don't update
|
||||
const mainScore = hasUser ? userScore : (hasAI ? aiScore : explicitTruth);
|
||||
if (mainScore === 0 && voteCount === 0 && !hasAI) return null;
|
||||
// Return null only if we have no data at all
|
||||
if (truthScore === null && aiScore === null) return null;
|
||||
|
||||
return {
|
||||
ai_score: hasAI ? aiScore : null,
|
||||
user_score: hasUser ? userScore : null,
|
||||
truth_score: mainScore, // Main display score - no fallback to 50
|
||||
truth_score: truthScore ?? 50, // Main display score
|
||||
ai_score: aiScore,
|
||||
user_score: truthScore, // For backward compatibility
|
||||
total_votes: voteCount,
|
||||
vote_count: voteCount,
|
||||
user_vote: truth.user_vote ?? truth.UserVote ?? null,
|
||||
user_vote: truth.user_vote ?? null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
|
|
@ -1160,8 +1150,8 @@ export async function changePassword(oldPassword, newPassword) {
|
|||
export async function uploadPostMedia(file, opts = {}) {
|
||||
if (!file) return { ok: false, error: "No file provided" };
|
||||
|
||||
const mediaBase = "https://media.sociowire.com";
|
||||
const url = `${mediaBase}/api/upload`;
|
||||
// Use main API for uploads (asset-service proxy)
|
||||
const url = `${apiBase()}/assets/upload`;
|
||||
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
|
|
|
|||
|
|
@ -405,7 +405,8 @@ export default function MapView({
|
|||
|
||||
longPressTimerRef.current = setTimeout(() => {
|
||||
if (longPressStartPos.current && longPressCallbackRef.current) {
|
||||
longPressCallbackRef.current();
|
||||
// Pass the finger position to the callback
|
||||
longPressCallbackRef.current(longPressStartPos.current);
|
||||
}
|
||||
cancelLongPress();
|
||||
}, LONG_PRESS_DURATION);
|
||||
|
|
@ -438,7 +439,8 @@ export default function MapView({
|
|||
longPressStartPos.current = { x: e.clientX, y: e.clientY };
|
||||
longPressTimerRef.current = setTimeout(() => {
|
||||
if (longPressStartPos.current && longPressCallbackRef.current) {
|
||||
longPressCallbackRef.current();
|
||||
// Pass the finger/mouse position to the callback
|
||||
longPressCallbackRef.current(longPressStartPos.current);
|
||||
}
|
||||
cancelLongPress();
|
||||
}, LONG_PRESS_DURATION);
|
||||
|
|
@ -1504,7 +1506,7 @@ export default function MapView({
|
|||
setCreateStep((prev) => Math.max(0, prev - 1));
|
||||
};
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
const handleOpenCreate = (fingerPos = null) => {
|
||||
if (!authenticated) {
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
|
|
@ -1534,6 +1536,32 @@ export default function MapView({
|
|||
return;
|
||||
}
|
||||
|
||||
// Pan map so crosshair aligns with finger position
|
||||
const map = mapRef.current;
|
||||
if (map && fingerPos) {
|
||||
const container = map.getContainer();
|
||||
if (container) {
|
||||
const rect = container.getBoundingClientRect();
|
||||
// Convert finger screen position to map container position
|
||||
const fingerX = fingerPos.x - rect.left;
|
||||
const fingerY = fingerPos.y - rect.top;
|
||||
|
||||
// Get geo coords at finger position
|
||||
const fingerCoords = map.unproject([fingerX, fingerY]);
|
||||
|
||||
// Crosshair is at center X, 45% from top Y
|
||||
const crosshairX = rect.width / 2;
|
||||
const crosshairY = rect.height * 0.45;
|
||||
|
||||
// Calculate pixel offset from crosshair to finger
|
||||
const offsetX = fingerX - crosshairX;
|
||||
const offsetY = fingerY - crosshairY;
|
||||
|
||||
// Pan the map by this offset so the finger location ends up at crosshair
|
||||
map.panBy([offsetX, offsetY], { duration: 300 });
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Show crosshair to pick location
|
||||
setSaveError("");
|
||||
setIsSaving(false);
|
||||
|
|
@ -1542,24 +1570,41 @@ export default function MapView({
|
|||
};
|
||||
|
||||
// Confirm location and open modal
|
||||
const handleConfirmLocation = () => {
|
||||
// Get coordinates from crosshair position (reusable)
|
||||
const getCrosshairCoords = useCallback(() => {
|
||||
const map = mapRef.current;
|
||||
if (map) {
|
||||
const stage = stageRef.current;
|
||||
const crosshairEl = stage?.querySelector(".crosshair-aim");
|
||||
const containerEl = map.getContainer();
|
||||
let coords;
|
||||
if (crosshairEl && containerEl) {
|
||||
const cr = crosshairEl.getBoundingClientRect();
|
||||
const co = containerEl.getBoundingClientRect();
|
||||
const ll = map.unproject([cr.left + cr.width/2 - co.left, cr.top + cr.height/2 - co.top]);
|
||||
coords = [ll.lng, ll.lat];
|
||||
} else {
|
||||
if (!map) return null;
|
||||
|
||||
const container = map.getContainer();
|
||||
if (!container) return null;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
// Crosshair position: center X, 45% from top Y (thumb-friendly position)
|
||||
const x = rect.width / 2;
|
||||
const y = rect.height * 0.45;
|
||||
const ll = map.unproject([x, y]);
|
||||
|
||||
if (ll && Number.isFinite(ll.lng) && Number.isFinite(ll.lat)) {
|
||||
return [ll.lng, ll.lat];
|
||||
}
|
||||
|
||||
// Fallback to map center
|
||||
const c = map.getCenter();
|
||||
coords = [c.lng, c.lat];
|
||||
return [c.lng, c.lat];
|
||||
}, [mapRef]);
|
||||
|
||||
const handleConfirmLocation = () => {
|
||||
let coords = getCrosshairCoords();
|
||||
|
||||
// Validate coords - never allow 0,0 or invalid values
|
||||
if (!coords || !Array.isArray(coords) || coords.length !== 2 ||
|
||||
!Number.isFinite(coords[0]) || !Number.isFinite(coords[1]) ||
|
||||
(coords[0] === 0 && coords[1] === 0)) {
|
||||
// Use a safe default (will be overwritten by map center in onSubmit)
|
||||
coords = null;
|
||||
}
|
||||
|
||||
setDraftCoords(coords);
|
||||
}
|
||||
setIsPickingLocation(false);
|
||||
setIsCreating(true);
|
||||
};
|
||||
|
|
@ -1569,6 +1614,26 @@ export default function MapView({
|
|||
setDraftCoords(null);
|
||||
};
|
||||
|
||||
// Update coordinates when map moves during post creation (step 2)
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !isCreating) return;
|
||||
|
||||
const updateCoords = () => {
|
||||
const coords = getCrosshairCoords();
|
||||
if (coords && coords[0] !== 0 && coords[1] !== 0) {
|
||||
setDraftCoords(coords);
|
||||
}
|
||||
};
|
||||
|
||||
// Update coords when map moves
|
||||
map.on('moveend', updateCoords);
|
||||
|
||||
return () => {
|
||||
map.off('moveend', updateCoords);
|
||||
};
|
||||
}, [isCreating, getCrosshairCoords]);
|
||||
|
||||
// Update ref for long-press callback
|
||||
useEffect(() => {
|
||||
longPressCallbackRef.current = handleOpenCreate;
|
||||
|
|
@ -2160,12 +2225,15 @@ export default function MapView({
|
|||
weatherEnabled={weatherViewEnabled}
|
||||
/>
|
||||
|
||||
{/* Step 1: Location Picker with Crosshair */}
|
||||
{isPickingLocation && (
|
||||
<>
|
||||
{/* Crosshair - shown during location picking AND while creating post */}
|
||||
{(isPickingLocation || isCreating) && (
|
||||
<div className="map-overlay map-crosshair">
|
||||
<div className="crosshair-aim" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 1: Location Picker UI (buttons/hint) */}
|
||||
{isPickingLocation && (
|
||||
<div className="location-picker-ui">
|
||||
<div className="location-picker-hint">
|
||||
<i className="fa-solid fa-crosshairs" />
|
||||
|
|
@ -2180,7 +2248,6 @@ export default function MapView({
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 2: Post Creation Modal */}
|
||||
|
|
@ -2193,30 +2260,49 @@ export default function MapView({
|
|||
try {
|
||||
const map = mapRef.current;
|
||||
let coords = data.coords;
|
||||
if (!coords && map) {
|
||||
|
||||
// Ensure we have valid coordinates - never allow 0,0
|
||||
if (!coords || !Array.isArray(coords) || coords.length !== 2 ||
|
||||
!Number.isFinite(coords[0]) || !Number.isFinite(coords[1])) {
|
||||
if (map) {
|
||||
const c = map.getCenter();
|
||||
coords = [c.lng, c.lat];
|
||||
}
|
||||
}
|
||||
|
||||
// Final validation - reject 0,0 (null island)
|
||||
const lng = coords?.[0];
|
||||
const lat = coords?.[1];
|
||||
if (!Number.isFinite(lng) || !Number.isFinite(lat) || (lng === 0 && lat === 0)) {
|
||||
setSaveError("Invalid location. Please try again.");
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get uploaded images
|
||||
const uploadedImages = Array.isArray(data.images) ? data.images.filter(Boolean) : [];
|
||||
|
||||
const payload = {
|
||||
title: data.title,
|
||||
body: data.body,
|
||||
snippet: data.body || data.title, // Backend uses 'snippet' not 'body'
|
||||
category: data.category === "Events" ? "EVENT" : data.category.toUpperCase(),
|
||||
sub_category: data.subCategory,
|
||||
content_type: data.mode === "photo" ? "photo" : (data.mode === "link" ? "link" : "text"),
|
||||
longitude: coords?.[0] ?? 0,
|
||||
latitude: coords?.[1] ?? 0,
|
||||
sub_category: data.subCategory || "ALL",
|
||||
content_type: data.mode === "photo" && uploadedImages.length > 0 ? "picture" : (data.mode === "link" ? "link" : "text"),
|
||||
lon: lng, // Backend uses 'lon' not 'longitude'
|
||||
lat: lat, // Backend uses 'lat' not 'latitude'
|
||||
url: data.linkUrl || "",
|
||||
image_small: data.images?.[0] || data.linkPreview?.image || "",
|
||||
image_medium: data.images?.[1] || data.images?.[0] || data.linkPreview?.image || "",
|
||||
image_large: data.images?.[2] || data.images?.[1] || data.images?.[0] || data.linkPreview?.image || "",
|
||||
media_urls: data.images || [],
|
||||
source: data.linkPreview?.site_name || "",
|
||||
image_small: uploadedImages[0] || data.linkPreview?.image || "",
|
||||
image_medium: uploadedImages[1] || uploadedImages[0] || data.linkPreview?.image || "",
|
||||
image_large: uploadedImages[2] || uploadedImages[1] || uploadedImages[0] || data.linkPreview?.image || "",
|
||||
media_urls: uploadedImages,
|
||||
source: data.linkPreview?.source || "sociowire",
|
||||
};
|
||||
|
||||
const result = await createPost(payload);
|
||||
if (result?.id) {
|
||||
// Add the new post to the display immediately
|
||||
handleIncomingPost(result);
|
||||
setIsCreating(false);
|
||||
// Refresh posts
|
||||
if (typeof refreshPosts === "function") refreshPosts();
|
||||
}
|
||||
} catch (err) {
|
||||
setSaveError(err.message || "Failed to create post");
|
||||
|
|
|
|||
|
|
@ -794,9 +794,13 @@ function collectGalleryImages(post) {
|
|||
seen.add(normalized);
|
||||
result.push(normalized);
|
||||
};
|
||||
// Check both media_urls (frontend) and image_urls (backend API)
|
||||
if (Array.isArray(post?.media_urls)) {
|
||||
post.media_urls.forEach(add);
|
||||
}
|
||||
if (Array.isArray(post?.image_urls)) {
|
||||
post.image_urls.forEach(add);
|
||||
}
|
||||
["image_large", "image_medium", "image_small", "image", "media_url"].forEach((key) => {
|
||||
add(post?.[key]);
|
||||
});
|
||||
|
|
@ -1381,6 +1385,76 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sw-truth-vote-section" data-post-id="${postID}" style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
border-radius: 12px;
|
||||
margin: 12px 0;
|
||||
">
|
||||
<button class="sw-truth-vote-btn sw-truth-downvote" type="button" data-action="truth-vote" data-vote="false" style="
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(239, 68, 68, 0.4);
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #f87171;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
">
|
||||
<i class="fa-solid fa-thumbs-down" style="font-size: 16px;"></i>
|
||||
</button>
|
||||
|
||||
<div class="sw-truth-rail-container" style="flex: 1; display: flex; flex-direction: column; gap: 6px;">
|
||||
<div style="display: flex; align-items: baseline; gap: 6px;">
|
||||
<span class="sw-truth-score-display" style="
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: ${truth >= 70 ? '#34d399' : truth >= 50 ? '#fbbf24' : '#f87171'};
|
||||
text-shadow: 0 0 20px ${truth >= 70 ? 'rgba(52, 211, 153, 0.5)' : truth >= 50 ? 'rgba(251, 191, 36, 0.5)' : 'rgba(248, 113, 113, 0.5)'};
|
||||
">${truth}</span>
|
||||
<span style="font-size: 11px; color: rgba(255,255,255,0.5); text-transform: uppercase; letter-spacing: 0.5px;">Truth Score</span>
|
||||
</div>
|
||||
<div class="sw-truth-rail" style="
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
">
|
||||
<div class="sw-truth-rail-fill" style="
|
||||
height: 100%;
|
||||
width: ${Math.min(100, Math.max(0, truth))}%;
|
||||
background: ${truth >= 70 ? 'linear-gradient(90deg, #10b981, #34d399)' : truth >= 50 ? 'linear-gradient(90deg, #f59e0b, #fbbf24)' : 'linear-gradient(90deg, #ef4444, #f87171)'};
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease, background 0.3s ease;
|
||||
box-shadow: 0 0 8px ${truth >= 70 ? 'rgba(52, 211, 153, 0.6)' : truth >= 50 ? 'rgba(251, 191, 36, 0.6)' : 'rgba(248, 113, 113, 0.6)'};
|
||||
"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="sw-truth-vote-btn sw-truth-upvote" type="button" data-action="truth-vote" data-vote="true" style="
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(52, 211, 153, 0.4);
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
color: #34d399;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
">
|
||||
<i class="fa-solid fa-thumbs-up" style="font-size: 16px;"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sw-stat-row">
|
||||
<div class="sw-stat-pill" data-stat="views">👁 <span class="sw-stat-num">${formatCount(viewCount)}</span></div>
|
||||
<div class="sw-stat-pill" data-stat="likes">❤️ <span class="sw-stat-num">${formatCount(likeCount)}</span></div>
|
||||
|
|
@ -1501,6 +1575,76 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
|||
${escapeHtml(data?.summary || postData?.snippet || "")}
|
||||
</div>
|
||||
|
||||
<div class="sw-truth-vote-section" data-post-id="${postID}" style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
border-radius: 12px;
|
||||
margin: 12px 0;
|
||||
">
|
||||
<button class="sw-truth-vote-btn sw-truth-downvote" type="button" data-action="truth-vote" data-vote="false" style="
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(239, 68, 68, 0.4);
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #f87171;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
">
|
||||
<i class="fa-solid fa-thumbs-down" style="font-size: 16px;"></i>
|
||||
</button>
|
||||
|
||||
<div class="sw-truth-rail-container" style="flex: 1; display: flex; flex-direction: column; gap: 6px;">
|
||||
<div style="display: flex; align-items: baseline; gap: 6px;">
|
||||
<span class="sw-truth-score-display" style="
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: ${truth >= 70 ? '#34d399' : truth >= 50 ? '#fbbf24' : '#f87171'};
|
||||
text-shadow: 0 0 20px ${truth >= 70 ? 'rgba(52, 211, 153, 0.5)' : truth >= 50 ? 'rgba(251, 191, 36, 0.5)' : 'rgba(248, 113, 113, 0.5)'};
|
||||
">${truth}</span>
|
||||
<span style="font-size: 11px; color: rgba(255,255,255,0.5); text-transform: uppercase; letter-spacing: 0.5px;">Truth Score</span>
|
||||
</div>
|
||||
<div class="sw-truth-rail" style="
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
">
|
||||
<div class="sw-truth-rail-fill" style="
|
||||
height: 100%;
|
||||
width: ${Math.min(100, Math.max(0, truth))}%;
|
||||
background: ${truth >= 70 ? 'linear-gradient(90deg, #10b981, #34d399)' : truth >= 50 ? 'linear-gradient(90deg, #f59e0b, #fbbf24)' : 'linear-gradient(90deg, #ef4444, #f87171)'};
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease, background 0.3s ease;
|
||||
box-shadow: 0 0 8px ${truth >= 70 ? 'rgba(52, 211, 153, 0.6)' : truth >= 50 ? 'rgba(251, 191, 36, 0.6)' : 'rgba(248, 113, 113, 0.6)'};
|
||||
"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="sw-truth-vote-btn sw-truth-upvote" type="button" data-action="truth-vote" data-vote="true" style="
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(52, 211, 153, 0.4);
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
color: #34d399;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
">
|
||||
<i class="fa-solid fa-thumbs-up" style="font-size: 16px;"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sw-stat-row">
|
||||
<div class="sw-stat-pill" data-stat="views">👁 <span class="sw-stat-num">${formatCount(viewCount)}</span></div>
|
||||
<div class="sw-stat-pill" data-stat="likes">❤️ <span class="sw-stat-num">${formatCount(likeCount)}</span></div>
|
||||
|
|
@ -1797,6 +1941,93 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
|
|||
});
|
||||
}
|
||||
|
||||
// Truth vote handlers (thumbs up/down)
|
||||
const truthVoteSection = modalWrap.querySelector('.sw-truth-vote-section');
|
||||
if (truthVoteSection && postID > 0) {
|
||||
const downvoteBtn = truthVoteSection.querySelector('.sw-truth-downvote');
|
||||
const upvoteBtn = truthVoteSection.querySelector('.sw-truth-upvote');
|
||||
const scoreDisplay = truthVoteSection.querySelector('.sw-truth-score-display');
|
||||
const railFill = truthVoteSection.querySelector('.sw-truth-rail-fill');
|
||||
let currentVote = null;
|
||||
let isVoting = false;
|
||||
|
||||
const updateTruthUI = (newScore) => {
|
||||
if (scoreDisplay && Number.isFinite(newScore)) {
|
||||
const score = Math.round(newScore);
|
||||
scoreDisplay.textContent = String(score);
|
||||
// Update color based on score
|
||||
const color = score >= 70 ? '#34d399' : score >= 50 ? '#fbbf24' : '#f87171';
|
||||
const glow = score >= 70 ? 'rgba(52, 211, 153, 0.5)' : score >= 50 ? 'rgba(251, 191, 36, 0.5)' : 'rgba(248, 113, 113, 0.5)';
|
||||
scoreDisplay.style.color = color;
|
||||
scoreDisplay.style.textShadow = `0 0 20px ${glow}`;
|
||||
// Update rail
|
||||
if (railFill) {
|
||||
railFill.style.width = `${Math.min(100, Math.max(0, score))}%`;
|
||||
const gradient = score >= 70 ? 'linear-gradient(90deg, #10b981, #34d399)' : score >= 50 ? 'linear-gradient(90deg, #f59e0b, #fbbf24)' : 'linear-gradient(90deg, #ef4444, #f87171)';
|
||||
railFill.style.background = gradient;
|
||||
railFill.style.boxShadow = `0 0 8px ${glow}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const doTruthVote = async (vote) => {
|
||||
if (!isAuthed()) {
|
||||
requestAuth("Please sign in or create a free account to vote on truth.");
|
||||
return;
|
||||
}
|
||||
if (isVoting) return;
|
||||
isVoting = true;
|
||||
|
||||
// Visual feedback
|
||||
if (vote === 'true' && upvoteBtn) {
|
||||
upvoteBtn.style.transform = 'scale(1.15)';
|
||||
upvoteBtn.style.background = 'rgba(52, 211, 153, 0.4)';
|
||||
} else if (vote === 'false' && downvoteBtn) {
|
||||
downvoteBtn.style.transform = 'scale(1.15)';
|
||||
downvoteBtn.style.background = 'rgba(239, 68, 68, 0.4)';
|
||||
}
|
||||
|
||||
try {
|
||||
trackEvent("truth_vote_click", { post_id: postID, vote, context: "map" });
|
||||
const stats = await votePostTruth(postID, vote);
|
||||
if (stats) {
|
||||
currentVote = vote;
|
||||
if (stats.truth_score !== null && Number.isFinite(stats.truth_score)) {
|
||||
updateTruthUI(stats.truth_score);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Truth vote error:', err);
|
||||
}
|
||||
|
||||
// Reset button visuals
|
||||
setTimeout(() => {
|
||||
if (upvoteBtn) {
|
||||
upvoteBtn.style.transform = '';
|
||||
upvoteBtn.style.background = currentVote === 'true' ? 'rgba(52, 211, 153, 0.35)' : 'rgba(52, 211, 153, 0.15)';
|
||||
}
|
||||
if (downvoteBtn) {
|
||||
downvoteBtn.style.transform = '';
|
||||
downvoteBtn.style.background = currentVote === 'false' ? 'rgba(239, 68, 68, 0.35)' : 'rgba(239, 68, 68, 0.15)';
|
||||
}
|
||||
isVoting = false;
|
||||
}, 200);
|
||||
};
|
||||
|
||||
if (downvoteBtn) {
|
||||
downvoteBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
doTruthVote('false');
|
||||
});
|
||||
}
|
||||
if (upvoteBtn) {
|
||||
upvoteBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
doTruthVote('true');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Author action handlers (edit, visibility, delete)
|
||||
if (isAuthor && postID > 0) {
|
||||
const editBtn = modalWrap.querySelector('[data-action="edit-post"]');
|
||||
|
|
|
|||
|
|
@ -313,7 +313,7 @@ export default function PostCardNew({ post, selected, onSelect, onPostDeleted, o
|
|||
setPostGroupId((post?.group_id || "").toString());
|
||||
}, [post?.visibility, post?.group_id]);
|
||||
|
||||
// Media URLs
|
||||
// Media URLs - check both media_urls (frontend) and image_urls (backend API)
|
||||
const mediaUrls = useMemo(() => {
|
||||
const list = [];
|
||||
const seen = new Set();
|
||||
|
|
@ -323,12 +323,18 @@ export default function PostCardNew({ post, selected, onSelect, onPostDeleted, o
|
|||
seen.add(normalized);
|
||||
list.push(normalized);
|
||||
};
|
||||
// Check media_urls (frontend sends this)
|
||||
if (Array.isArray(post?.media_urls)) {
|
||||
post.media_urls.forEach(addUrl);
|
||||
}
|
||||
// Check image_urls (backend API returns this)
|
||||
if (Array.isArray(post?.image_urls)) {
|
||||
post.image_urls.forEach(addUrl);
|
||||
}
|
||||
// Fallback to individual image fields (already deduped by seen Set)
|
||||
[post?.image_large, post?.image_medium, post?.image_small, post?.image, post?.media_url].forEach(addUrl);
|
||||
return list;
|
||||
}, [post?.media_urls, post?.image_large, post?.image_medium, post?.image_small, post?.image, post?.media_url]);
|
||||
}, [post?.media_urls, post?.image_urls, post?.image_large, post?.image_medium, post?.image_small, post?.image, post?.media_url]);
|
||||
|
||||
const mediaKey = mediaUrls.join("|");
|
||||
|
||||
|
|
|
|||
|
|
@ -51,18 +51,22 @@ export default function PostCreateModal({
|
|||
const textareaRef = useRef(null);
|
||||
const linkTimeoutRef = useRef(null);
|
||||
|
||||
// Reset on open
|
||||
// Reset only when modal opens (not on every initialCategory change)
|
||||
const wasOpenRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (isOpen && !wasOpenRef.current) {
|
||||
// Modal just opened - reset everything
|
||||
setMode("text");
|
||||
setTitle("");
|
||||
setBody("");
|
||||
setLinkUrl("");
|
||||
setLinkPreview(null);
|
||||
setImages([]);
|
||||
setUploadError("");
|
||||
setCategory(initialCategory);
|
||||
setSubCategory(SUB_CATEGORIES[initialCategory]?.[0] || "");
|
||||
}
|
||||
wasOpenRef.current = isOpen;
|
||||
}, [isOpen, initialCategory]);
|
||||
|
||||
// Auto-resize textarea
|
||||
|
|
@ -88,28 +92,37 @@ export default function PostCreateModal({
|
|||
if (preview) {
|
||||
setLinkPreview(preview);
|
||||
if (!title && preview.title) setTitle(preview.title);
|
||||
if (!body && preview.description) setBody(preview.description);
|
||||
}
|
||||
} catch {}
|
||||
setLinkLoading(false);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(linkTimeoutRef.current);
|
||||
}, [linkUrl, mode, title]);
|
||||
}, [linkUrl, mode, title, body]);
|
||||
|
||||
const [uploadError, setUploadError] = useState("");
|
||||
|
||||
const handleFileChange = useCallback(async (e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
setUploadError("");
|
||||
try {
|
||||
for (const file of files) {
|
||||
const result = await uploadPostMedia(file);
|
||||
if (result?.url) {
|
||||
setImages((prev) => [...prev, result.url]);
|
||||
setImages((prev) => {
|
||||
const newImages = [...prev, result.url];
|
||||
return newImages;
|
||||
});
|
||||
} else {
|
||||
setUploadError(result?.error || "Upload failed - no URL returned");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Upload failed:", err);
|
||||
setUploadError(err.message || "Upload failed");
|
||||
}
|
||||
setUploading(false);
|
||||
e.target.value = "";
|
||||
|
|
@ -148,23 +161,28 @@ export default function PostCreateModal({
|
|||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
className="fixed inset-0 z-[1200] flex items-end sm:items-center justify-center"
|
||||
className="fixed inset-0 z-[1200] flex items-end justify-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
{/* Backdrop - only covers bottom half, allows map interaction above */}
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-black/80 backdrop-blur-md"
|
||||
className="absolute bottom-0 left-0 right-0 h-[65vh] bg-gradient-to-b from-transparent to-black/50 pointer-events-none"
|
||||
/>
|
||||
{/* Invisible close trigger at top */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-16 cursor-pointer"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
{/* Modal - positioned at bottom, shorter to show map with crosshair */}
|
||||
<motion.div
|
||||
className="relative w-full max-w-xl mx-2 sm:mx-4
|
||||
className="relative w-full max-w-xl mx-0 sm:mx-4
|
||||
bg-gradient-to-b from-surface-800 to-surface-900
|
||||
rounded-t-3xl sm:rounded-3xl border border-white/10
|
||||
shadow-2xl shadow-black/50 overflow-hidden"
|
||||
rounded-t-3xl border border-white/10 border-b-0
|
||||
shadow-2xl shadow-black/50 overflow-hidden
|
||||
max-h-[65vh]"
|
||||
initial={{ y: "100%", opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: "100%", opacity: 0 }}
|
||||
|
|
@ -232,7 +250,7 @@ export default function PostCreateModal({
|
|||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="p-4 max-h-[60vh] overflow-y-auto space-y-4">
|
||||
<div className="p-4 max-h-[45vh] overflow-y-auto space-y-4">
|
||||
{/* Category row */}
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{CATEGORIES.map((cat) => (
|
||||
|
|
@ -390,7 +408,15 @@ export default function PostCreateModal({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{/* Upload Error */}
|
||||
{uploadError && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-xl bg-orange-500/10 border border-orange-500/30 text-orange-400 text-sm">
|
||||
<i className="fa-solid fa-exclamation-triangle" />
|
||||
{uploadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save Error */}
|
||||
{saveError && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-xl bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
|
||||
<i className="fa-solid fa-exclamation-circle" />
|
||||
|
|
@ -399,10 +425,15 @@ export default function PostCreateModal({
|
|||
)}
|
||||
|
||||
{/* Location */}
|
||||
{coords && (
|
||||
{coords ? (
|
||||
<div className="flex items-center gap-2 text-surface-400 text-sm">
|
||||
<i className="fa-solid fa-location-dot text-emerald-500" />
|
||||
<span>Location pinned on map</span>
|
||||
<span>Location: {coords[1]?.toFixed(4)}, {coords[0]?.toFixed(4)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-orange-400 text-sm">
|
||||
<i className="fa-solid fa-location-dot" />
|
||||
<span>No location - post may fail</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -140,14 +140,35 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
|
|||
const postId = post?.id || post?._id;
|
||||
const isAuthor = username && (post?.author === username || post?.username === username);
|
||||
|
||||
// Media gallery
|
||||
// Media gallery - deduplicated from all possible image fields
|
||||
const gallery = useMemo(() => {
|
||||
const seen = new Set();
|
||||
const images = [];
|
||||
if (post?.image_url) images.push(normalizeMediaUrl(post.image_url));
|
||||
const addUrl = (url) => {
|
||||
const normalized = normalizeMediaUrl(url);
|
||||
if (!normalized || seen.has(normalized)) return;
|
||||
seen.add(normalized);
|
||||
images.push(normalized);
|
||||
};
|
||||
|
||||
// Check media_urls first (frontend sends this)
|
||||
if (Array.isArray(post?.media_urls)) {
|
||||
post.media_urls.forEach(addUrl);
|
||||
}
|
||||
// Check image_urls (backend API returns this)
|
||||
if (Array.isArray(post?.image_urls)) {
|
||||
post.image_urls.forEach(addUrl);
|
||||
}
|
||||
// Check individual image fields (fallback, often same URL)
|
||||
addUrl(post?.image_large);
|
||||
addUrl(post?.image_medium);
|
||||
addUrl(post?.image_small);
|
||||
addUrl(post?.image_url);
|
||||
addUrl(post?.image);
|
||||
// Check media array if present
|
||||
if (Array.isArray(post?.media)) {
|
||||
post.media.forEach((m) => {
|
||||
const url = normalizeMediaUrl(m?.url || m?.thumbnail_url || "");
|
||||
if (url && !images.includes(url)) images.push(url);
|
||||
addUrl(m?.url || m?.thumbnail_url);
|
||||
});
|
||||
}
|
||||
return images;
|
||||
|
|
@ -186,10 +207,12 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
|
|||
// Fetch truth scores
|
||||
fetchPostTruth(postId).then((res) => {
|
||||
if (res) {
|
||||
// Use truth_score as the main display value (0-100 scale)
|
||||
const mainScore = res.truth_score ?? res.user_score ?? 50;
|
||||
setTruth({
|
||||
ai: res.ai_score ?? 50,
|
||||
user: res.user_score ?? 50,
|
||||
count: res.vote_count ?? 0,
|
||||
user: mainScore,
|
||||
count: res.vote_count ?? res.total_votes ?? 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -257,10 +280,12 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
|
|||
if (!postId) return;
|
||||
const res = await votePostTruth(postId, vote);
|
||||
if (res) {
|
||||
// Use truth_score as the main display value (0-100 scale)
|
||||
const mainScore = res.truth_score ?? res.user_score ?? truth.user;
|
||||
setTruth({
|
||||
ai: res.ai_score ?? truth.ai,
|
||||
user: res.user_score ?? truth.user,
|
||||
count: res.vote_count ?? truth.count,
|
||||
user: mainScore,
|
||||
count: res.vote_count ?? res.total_votes ?? truth.count,
|
||||
});
|
||||
}
|
||||
}, [postId, truth]);
|
||||
|
|
@ -551,41 +576,82 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* Truth score */}
|
||||
<div className="px-4 py-3 border-b border-white/5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-surface-400 uppercase tracking-wide">
|
||||
Truth Score
|
||||
{/* Truth Score - Social Network Style */}
|
||||
<div className="px-4 py-4 border-b border-white/5">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Downvote button */}
|
||||
<motion.button
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center
|
||||
bg-gradient-to-br from-red-500/20 to-orange-500/10
|
||||
border border-red-500/30 text-red-400
|
||||
hover:from-red-500/30 hover:to-orange-500/20 hover:border-red-400/50
|
||||
active:scale-95 transition-all shadow-lg shadow-red-500/10"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={() => handleTruthVote("false")}
|
||||
>
|
||||
<i className="fa-solid fa-thumbs-down text-lg" />
|
||||
</motion.button>
|
||||
|
||||
{/* Score display with rail */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<span className={`text-3xl font-black ${
|
||||
truth.user >= 70 ? 'text-emerald-400' :
|
||||
truth.user >= 50 ? 'text-amber-400' :
|
||||
'text-red-400'
|
||||
}`}>
|
||||
{Math.round(truth.user)}
|
||||
</span>
|
||||
<span className="text-xs text-surface-500">{truth.count} votes</span>
|
||||
<span className="text-surface-500 text-sm font-medium">/ 100</span>
|
||||
</div>
|
||||
<div className="relative h-2 rounded-full bg-surface-800 overflow-hidden">
|
||||
{/* Progress rail */}
|
||||
<div className="relative h-3 rounded-full bg-surface-800/80 overflow-hidden shadow-inner">
|
||||
<motion.div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-green-500 to-emerald-400"
|
||||
className={`absolute inset-y-0 left-0 rounded-full ${
|
||||
truth.user >= 70
|
||||
? 'bg-gradient-to-r from-emerald-500 via-green-400 to-teal-400'
|
||||
: truth.user >= 50
|
||||
? 'bg-gradient-to-r from-amber-500 via-yellow-400 to-orange-400'
|
||||
: 'bg-gradient-to-r from-red-500 via-rose-400 to-pink-400'
|
||||
}`}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${truth.user}%` }}
|
||||
transition={{ duration: 0.5 }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
/>
|
||||
{/* Glow effect */}
|
||||
<motion.div
|
||||
className={`absolute inset-y-0 left-0 rounded-full blur-sm opacity-50 ${
|
||||
truth.user >= 70
|
||||
? 'bg-gradient-to-r from-emerald-400 to-teal-400'
|
||||
: truth.user >= 50
|
||||
? 'bg-gradient-to-r from-amber-400 to-orange-400'
|
||||
: 'bg-gradient-to-r from-red-400 to-pink-400'
|
||||
}`}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${truth.user}%` }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-2">
|
||||
<div className="flex justify-between mt-1.5 text-xs text-surface-500">
|
||||
<span>Questionable</span>
|
||||
<span>{truth.count} votes</span>
|
||||
<span>Verified</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upvote button */}
|
||||
<motion.button
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-green-500/10 border border-green-500/30
|
||||
text-green-400 text-xs font-medium hover:bg-green-500/20 transition-colors"
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => handleTruthVote("up")}
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center
|
||||
bg-gradient-to-br from-emerald-500/20 to-teal-500/10
|
||||
border border-emerald-500/30 text-emerald-400
|
||||
hover:from-emerald-500/30 hover:to-teal-500/20 hover:border-emerald-400/50
|
||||
active:scale-95 transition-all shadow-lg shadow-emerald-500/10"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={() => handleTruthVote("true")}
|
||||
>
|
||||
<i className="fa-solid fa-check" />
|
||||
True
|
||||
</motion.button>
|
||||
<span className="text-lg font-bold text-surface-200">{Math.round(truth.user)}%</span>
|
||||
<motion.button
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-red-500/10 border border-red-500/30
|
||||
text-red-400 text-xs font-medium hover:bg-red-500/20 transition-colors"
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => handleTruthVote("down")}
|
||||
>
|
||||
<i className="fa-solid fa-times" />
|
||||
False
|
||||
<i className="fa-solid fa-thumbs-up text-lg" />
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -210,10 +210,10 @@
|
|||
}
|
||||
|
||||
|
||||
/* Crosshair pour création */
|
||||
/* Crosshair pour création - centered for mobile thumb reach */
|
||||
.map-crosshair{
|
||||
left:50%;
|
||||
top:30%;
|
||||
top:45%;
|
||||
transform:translate(-50%,-50%);
|
||||
}
|
||||
.crosshair-aim{
|
||||
|
|
@ -1913,9 +1913,9 @@ body[data-theme="blue"] .mode-selector-v2{
|
|||
/* Location Picker UI */
|
||||
.location-picker-ui {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
top: 58%; /* Position hint below crosshair (which is at 45%) */
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
transform: translate(-50%, 0);
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
Loading…
Reference in New Issue