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

View File

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

View File

@ -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);

View 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");

View File

@ -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"]');

View File

@ -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("|");

View File

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

View File

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

View File

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