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:
Your Name 2026-01-19 20:51:03 +00:00
parent 1bc7163bf9
commit bd6d53f1e8
9 changed files with 548 additions and 138 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "sociowire-frontend", "name": "sociowire-frontend",
"version": "0.0.28", "version": "0.0.64",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sociowire-frontend", "name": "sociowire-frontend",
"version": "0.0.28", "version": "0.0.64",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^7.1.0", "@fortawesome/fontawesome-free": "^7.1.0",
"axios": "^1.13.2", "axios": "^1.13.2",

View File

@ -1,7 +1,7 @@
{ {
"name": "sociowire-frontend", "name": "sociowire-frontend",
"private": true, "private": true,
"version": "0.0.52", "version": "0.0.64",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",

View File

@ -386,34 +386,24 @@ export async function fetchPostTruth(postId) {
if (!res.ok) return null; if (!res.ok) return null;
const data = await res.json().catch(() => null); const data = await res.json().catch(() => null);
if (!data) return 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 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: // truth_score is the main combined score (0-100)
// - Must have votes > 0, OR const truthScore = truth.truth_score ?? null;
// - Must have an AI score that's explicitly set (not 0 with no votes) const aiScore = truth.ai_score ?? null;
const hasAI = aiScore !== null && Number.isFinite(aiScore); const voteCount = truth.total_votes ?? truth.vote_count ?? 0;
const hasUser = userScore !== null && Number.isFinite(userScore);
const hasExplicit = explicitTruth !== null && Number.isFinite(explicitTruth);
// If no real score data, don't override the initial calculated score // Return null only if we have no data at all
if (!hasAI && !hasUser && !hasExplicit) return null; if (truthScore === null && aiScore === null) 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 { return {
ai_score: hasAI ? aiScore : null, truth_score: truthScore ?? 50, // Main display score
user_score: hasUser ? userScore : null, ai_score: aiScore,
truth_score: mainScore, // Main display score - no fallback to 50 user_score: truthScore, // For backward compatibility
total_votes: voteCount, total_votes: voteCount,
vote_count: voteCount, vote_count: voteCount,
user_vote: truth.user_vote ?? truth.UserVote ?? null, user_vote: truth.user_vote ?? null,
}; };
} catch { } catch {
return null; return null;
@ -1160,8 +1150,8 @@ export async function changePassword(oldPassword, newPassword) {
export async function uploadPostMedia(file, opts = {}) { export async function uploadPostMedia(file, opts = {}) {
if (!file) return { ok: false, error: "No file provided" }; if (!file) return { ok: false, error: "No file provided" };
const mediaBase = "https://media.sociowire.com"; // Use main API for uploads (asset-service proxy)
const url = `${mediaBase}/api/upload`; const url = `${apiBase()}/assets/upload`;
const form = new FormData(); const form = new FormData();
form.append("file", file); form.append("file", file);

View File

@ -405,7 +405,8 @@ export default function MapView({
longPressTimerRef.current = setTimeout(() => { longPressTimerRef.current = setTimeout(() => {
if (longPressStartPos.current && longPressCallbackRef.current) { if (longPressStartPos.current && longPressCallbackRef.current) {
longPressCallbackRef.current(); // Pass the finger position to the callback
longPressCallbackRef.current(longPressStartPos.current);
} }
cancelLongPress(); cancelLongPress();
}, LONG_PRESS_DURATION); }, LONG_PRESS_DURATION);
@ -438,7 +439,8 @@ export default function MapView({
longPressStartPos.current = { x: e.clientX, y: e.clientY }; longPressStartPos.current = { x: e.clientX, y: e.clientY };
longPressTimerRef.current = setTimeout(() => { longPressTimerRef.current = setTimeout(() => {
if (longPressStartPos.current && longPressCallbackRef.current) { if (longPressStartPos.current && longPressCallbackRef.current) {
longPressCallbackRef.current(); // Pass the finger/mouse position to the callback
longPressCallbackRef.current(longPressStartPos.current);
} }
cancelLongPress(); cancelLongPress();
}, LONG_PRESS_DURATION); }, LONG_PRESS_DURATION);
@ -1504,7 +1506,7 @@ export default function MapView({
setCreateStep((prev) => Math.max(0, prev - 1)); setCreateStep((prev) => Math.max(0, prev - 1));
}; };
const handleOpenCreate = () => { const handleOpenCreate = (fingerPos = null) => {
if (!authenticated) { if (!authenticated) {
try { try {
window.dispatchEvent( window.dispatchEvent(
@ -1534,6 +1536,32 @@ export default function MapView({
return; 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 // Step 1: Show crosshair to pick location
setSaveError(""); setSaveError("");
setIsSaving(false); setIsSaving(false);
@ -1542,24 +1570,41 @@ export default function MapView({
}; };
// Confirm location and open modal // Confirm location and open modal
const handleConfirmLocation = () => { // Get coordinates from crosshair position (reusable)
const getCrosshairCoords = useCallback(() => {
const map = mapRef.current; const map = mapRef.current;
if (map) { if (!map) return null;
const stage = stageRef.current;
const crosshairEl = stage?.querySelector(".crosshair-aim"); const container = map.getContainer();
const containerEl = map.getContainer(); if (!container) return null;
let coords;
if (crosshairEl && containerEl) { const rect = container.getBoundingClientRect();
const cr = crosshairEl.getBoundingClientRect(); // Crosshair position: center X, 45% from top Y (thumb-friendly position)
const co = containerEl.getBoundingClientRect(); const x = rect.width / 2;
const ll = map.unproject([cr.left + cr.width/2 - co.left, cr.top + cr.height/2 - co.top]); const y = rect.height * 0.45;
coords = [ll.lng, ll.lat]; const ll = map.unproject([x, y]);
} else {
if (ll && Number.isFinite(ll.lng) && Number.isFinite(ll.lat)) {
return [ll.lng, ll.lat];
}
// Fallback to map center
const c = map.getCenter(); 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); setDraftCoords(coords);
}
setIsPickingLocation(false); setIsPickingLocation(false);
setIsCreating(true); setIsCreating(true);
}; };
@ -1569,6 +1614,26 @@ export default function MapView({
setDraftCoords(null); 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 // Update ref for long-press callback
useEffect(() => { useEffect(() => {
longPressCallbackRef.current = handleOpenCreate; longPressCallbackRef.current = handleOpenCreate;
@ -2160,12 +2225,15 @@ export default function MapView({
weatherEnabled={weatherViewEnabled} weatherEnabled={weatherViewEnabled}
/> />
{/* Step 1: Location Picker with Crosshair */} {/* Crosshair - shown during location picking AND while creating post */}
{isPickingLocation && ( {(isPickingLocation || isCreating) && (
<>
<div className="map-overlay map-crosshair"> <div className="map-overlay map-crosshair">
<div className="crosshair-aim" /> <div className="crosshair-aim" />
</div> </div>
)}
{/* Step 1: Location Picker UI (buttons/hint) */}
{isPickingLocation && (
<div className="location-picker-ui"> <div className="location-picker-ui">
<div className="location-picker-hint"> <div className="location-picker-hint">
<i className="fa-solid fa-crosshairs" /> <i className="fa-solid fa-crosshairs" />
@ -2180,7 +2248,6 @@ export default function MapView({
</button> </button>
</div> </div>
</div> </div>
</>
)} )}
{/* Step 2: Post Creation Modal */} {/* Step 2: Post Creation Modal */}
@ -2193,30 +2260,49 @@ export default function MapView({
try { try {
const map = mapRef.current; const map = mapRef.current;
let coords = data.coords; 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(); const c = map.getCenter();
coords = [c.lng, c.lat]; 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 = { const payload = {
title: data.title, title: data.title,
body: data.body, snippet: data.body || data.title, // Backend uses 'snippet' not 'body'
category: data.category === "Events" ? "EVENT" : data.category.toUpperCase(), category: data.category === "Events" ? "EVENT" : data.category.toUpperCase(),
sub_category: data.subCategory, sub_category: data.subCategory || "ALL",
content_type: data.mode === "photo" ? "photo" : (data.mode === "link" ? "link" : "text"), content_type: data.mode === "photo" && uploadedImages.length > 0 ? "picture" : (data.mode === "link" ? "link" : "text"),
longitude: coords?.[0] ?? 0, lon: lng, // Backend uses 'lon' not 'longitude'
latitude: coords?.[1] ?? 0, lat: lat, // Backend uses 'lat' not 'latitude'
url: data.linkUrl || "", url: data.linkUrl || "",
image_small: data.images?.[0] || data.linkPreview?.image || "", image_small: uploadedImages[0] || data.linkPreview?.image || "",
image_medium: data.images?.[1] || data.images?.[0] || data.linkPreview?.image || "", image_medium: uploadedImages[1] || uploadedImages[0] || data.linkPreview?.image || "",
image_large: data.images?.[2] || data.images?.[1] || data.images?.[0] || data.linkPreview?.image || "", image_large: uploadedImages[2] || uploadedImages[1] || uploadedImages[0] || data.linkPreview?.image || "",
media_urls: data.images || [], media_urls: uploadedImages,
source: data.linkPreview?.site_name || "", source: data.linkPreview?.source || "sociowire",
}; };
const result = await createPost(payload); const result = await createPost(payload);
if (result?.id) { if (result?.id) {
// Add the new post to the display immediately
handleIncomingPost(result);
setIsCreating(false); setIsCreating(false);
// Refresh posts
if (typeof refreshPosts === "function") refreshPosts();
} }
} catch (err) { } catch (err) {
setSaveError(err.message || "Failed to create post"); setSaveError(err.message || "Failed to create post");

View File

@ -794,9 +794,13 @@ function collectGalleryImages(post) {
seen.add(normalized); seen.add(normalized);
result.push(normalized); result.push(normalized);
}; };
// Check both media_urls (frontend) and image_urls (backend API)
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)) {
post.image_urls.forEach(add);
}
["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]);
}); });
@ -1381,6 +1385,76 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
</div> </div>
</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-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="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> <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 || "")} ${escapeHtml(data?.summary || postData?.snippet || "")}
</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-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="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> <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) // Author action handlers (edit, visibility, delete)
if (isAuthor && postID > 0) { if (isAuthor && postID > 0) {
const editBtn = modalWrap.querySelector('[data-action="edit-post"]'); const editBtn = modalWrap.querySelector('[data-action="edit-post"]');

View File

@ -313,7 +313,7 @@ export default function PostCardNew({ post, selected, onSelect, onPostDeleted, o
setPostGroupId((post?.group_id || "").toString()); setPostGroupId((post?.group_id || "").toString());
}, [post?.visibility, post?.group_id]); }, [post?.visibility, post?.group_id]);
// Media URLs // Media URLs - check both media_urls (frontend) and image_urls (backend API)
const mediaUrls = useMemo(() => { const mediaUrls = useMemo(() => {
const list = []; const list = [];
const seen = new Set(); const seen = new Set();
@ -323,12 +323,18 @@ export default function PostCardNew({ post, selected, onSelect, onPostDeleted, o
seen.add(normalized); seen.add(normalized);
list.push(normalized); list.push(normalized);
}; };
// Check media_urls (frontend sends this)
if (Array.isArray(post?.media_urls)) { if (Array.isArray(post?.media_urls)) {
post.media_urls.forEach(addUrl); 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); [post?.image_large, post?.image_medium, post?.image_small, post?.image, post?.media_url].forEach(addUrl);
return list; 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("|"); const mediaKey = mediaUrls.join("|");

View File

@ -51,18 +51,22 @@ export default function PostCreateModal({
const textareaRef = useRef(null); const textareaRef = useRef(null);
const linkTimeoutRef = useRef(null); const linkTimeoutRef = useRef(null);
// Reset on open // Reset only when modal opens (not on every initialCategory change)
const wasOpenRef = useRef(false);
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen && !wasOpenRef.current) {
// Modal just opened - reset everything
setMode("text"); setMode("text");
setTitle(""); setTitle("");
setBody(""); setBody("");
setLinkUrl(""); setLinkUrl("");
setLinkPreview(null); setLinkPreview(null);
setImages([]); setImages([]);
setUploadError("");
setCategory(initialCategory); setCategory(initialCategory);
setSubCategory(SUB_CATEGORIES[initialCategory]?.[0] || ""); setSubCategory(SUB_CATEGORIES[initialCategory]?.[0] || "");
} }
wasOpenRef.current = isOpen;
}, [isOpen, initialCategory]); }, [isOpen, initialCategory]);
// Auto-resize textarea // Auto-resize textarea
@ -88,28 +92,37 @@ export default function PostCreateModal({
if (preview) { if (preview) {
setLinkPreview(preview); setLinkPreview(preview);
if (!title && preview.title) setTitle(preview.title); if (!title && preview.title) setTitle(preview.title);
if (!body && preview.description) setBody(preview.description);
} }
} catch {} } catch {}
setLinkLoading(false); setLinkLoading(false);
}, 500); }, 500);
return () => clearTimeout(linkTimeoutRef.current); return () => clearTimeout(linkTimeoutRef.current);
}, [linkUrl, mode, title]); }, [linkUrl, mode, title, body]);
const [uploadError, setUploadError] = useState("");
const handleFileChange = useCallback(async (e) => { const handleFileChange = useCallback(async (e) => {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
if (files.length === 0) return; if (files.length === 0) return;
setUploading(true); setUploading(true);
setUploadError("");
try { try {
for (const file of files) { for (const file of files) {
const result = await uploadPostMedia(file); const result = await uploadPostMedia(file);
if (result?.url) { 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) { } catch (err) {
console.error("Upload failed:", err); setUploadError(err.message || "Upload failed");
} }
setUploading(false); setUploading(false);
e.target.value = ""; e.target.value = "";
@ -148,23 +161,28 @@ export default function PostCreateModal({
return ( return (
<AnimatePresence> <AnimatePresence>
<motion.div <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 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
> >
{/* Backdrop */} {/* Backdrop - only covers bottom half, allows map interaction above */}
<motion.div <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} onClick={onClose}
/> />
{/* Modal */} {/* Modal - positioned at bottom, shorter to show map with crosshair */}
<motion.div <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 bg-gradient-to-b from-surface-800 to-surface-900
rounded-t-3xl sm:rounded-3xl border border-white/10 rounded-t-3xl border border-white/10 border-b-0
shadow-2xl shadow-black/50 overflow-hidden" shadow-2xl shadow-black/50 overflow-hidden
max-h-[65vh]"
initial={{ y: "100%", opacity: 0 }} initial={{ y: "100%", opacity: 0 }}
animate={{ y: 0, opacity: 1 }} animate={{ y: 0, opacity: 1 }}
exit={{ y: "100%", opacity: 0 }} exit={{ y: "100%", opacity: 0 }}
@ -232,7 +250,7 @@ export default function PostCreateModal({
</div> </div>
{/* Scrollable content */} {/* 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 */} {/* Category row */}
<div className="flex gap-2 overflow-x-auto pb-1"> <div className="flex gap-2 overflow-x-auto pb-1">
{CATEGORIES.map((cat) => ( {CATEGORIES.map((cat) => (
@ -390,7 +408,15 @@ export default function PostCreateModal({
</div> </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 && ( {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"> <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" /> <i className="fa-solid fa-exclamation-circle" />
@ -399,10 +425,15 @@ export default function PostCreateModal({
)} )}
{/* Location */} {/* Location */}
{coords && ( {coords ? (
<div className="flex items-center gap-2 text-surface-400 text-sm"> <div className="flex items-center gap-2 text-surface-400 text-sm">
<i className="fa-solid fa-location-dot text-emerald-500" /> <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>
)} )}
</div> </div>

View File

@ -140,14 +140,35 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
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);
// Media gallery // Media gallery - deduplicated from all possible image fields
const gallery = useMemo(() => { const gallery = useMemo(() => {
const seen = new Set();
const images = []; 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)) { if (Array.isArray(post?.media)) {
post.media.forEach((m) => { post.media.forEach((m) => {
const url = normalizeMediaUrl(m?.url || m?.thumbnail_url || ""); addUrl(m?.url || m?.thumbnail_url);
if (url && !images.includes(url)) images.push(url);
}); });
} }
return images; return images;
@ -186,10 +207,12 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
// Fetch truth scores // Fetch truth scores
fetchPostTruth(postId).then((res) => { fetchPostTruth(postId).then((res) => {
if (res) { if (res) {
// Use truth_score as the main display value (0-100 scale)
const mainScore = res.truth_score ?? res.user_score ?? 50;
setTruth({ setTruth({
ai: res.ai_score ?? 50, ai: res.ai_score ?? 50,
user: res.user_score ?? 50, user: mainScore,
count: res.vote_count ?? 0, count: res.vote_count ?? res.total_votes ?? 0,
}); });
} }
}); });
@ -257,10 +280,12 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
if (!postId) return; if (!postId) return;
const res = await votePostTruth(postId, vote); const res = await votePostTruth(postId, vote);
if (res) { if (res) {
// Use truth_score as the main display value (0-100 scale)
const mainScore = res.truth_score ?? res.user_score ?? truth.user;
setTruth({ setTruth({
ai: res.ai_score ?? truth.ai, ai: res.ai_score ?? truth.ai,
user: res.user_score ?? truth.user, user: mainScore,
count: res.vote_count ?? truth.count, count: res.vote_count ?? res.total_votes ?? truth.count,
}); });
} }
}, [postId, truth]); }, [postId, truth]);
@ -551,41 +576,82 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe
))} ))}
</div> </div>
{/* Truth score */} {/* Truth Score - Social Network Style */}
<div className="px-4 py-3 border-b border-white/5"> <div className="px-4 py-4 border-b border-white/5">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center gap-4">
<span className="text-xs font-medium text-surface-400 uppercase tracking-wide"> {/* Downvote button */}
Truth Score <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>
<span className="text-xs text-surface-500">{truth.count} votes</span> <span className="text-surface-500 text-sm font-medium">/ 100</span>
</div> </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 <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 }} initial={{ width: 0 }}
animate={{ width: `${truth.user}%` }} 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>
<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 <motion.button
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-green-500/10 border border-green-500/30 className="w-12 h-12 rounded-full flex items-center justify-center
text-green-400 text-xs font-medium hover:bg-green-500/20 transition-colors" bg-gradient-to-br from-emerald-500/20 to-teal-500/10
whileTap={{ scale: 0.95 }} border border-emerald-500/30 text-emerald-400
onClick={() => handleTruthVote("up")} 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" /> <i className="fa-solid fa-thumbs-up text-lg" />
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
</motion.button> </motion.button>
</div> </div>
</div> </div>

View File

@ -210,10 +210,10 @@
} }
/* Crosshair pour création */ /* Crosshair pour création - centered for mobile thumb reach */
.map-crosshair{ .map-crosshair{
left:50%; left:50%;
top:30%; top:45%;
transform:translate(-50%,-50%); transform:translate(-50%,-50%);
} }
.crosshair-aim{ .crosshair-aim{
@ -1913,9 +1913,9 @@ body[data-theme="blue"] .mode-selector-v2{
/* Location Picker UI */ /* Location Picker UI */
.location-picker-ui { .location-picker-ui {
position: absolute; position: absolute;
bottom: 24px; top: 58%; /* Position hint below crosshair (which is at 45%) */
left: 50%; left: 50%;
transform: translateX(-50%); transform: translate(-50%, 0);
z-index: 1100; z-index: 1100;
display: flex; display: flex;
flex-direction: column; flex-direction: column;