From bd6d53f1e8338113c84fd1d8147e89089d4c4c2f Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 19 Jan 2026 20:51:03 +0000 Subject: [PATCH] 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 --- package-lock.json | 4 +- package.json | 2 +- src/api/client.js | 36 ++-- src/components/Map/MapView.jsx | 190 ++++++++++++++----- src/components/Map/markerManager.js | 231 +++++++++++++++++++++++ src/components/Posts/PostCardNew.jsx | 10 +- src/components/Posts/PostCreateModal.jsx | 63 +++++-- src/components/Posts/PostDetailModal.jsx | 142 ++++++++++---- src/styles/map.css | 8 +- 9 files changed, 548 insertions(+), 138 deletions(-) diff --git a/package-lock.json b/package-lock.json index 41eddd0..b30d161 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 9dc0bd3..f200536 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api/client.js b/src/api/client.js index 62a89e4..0114352 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -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); diff --git a/src/components/Map/MapView.jsx b/src/components/Map/MapView.jsx index 37fa249..20dff43 100644 --- a/src/components/Map/MapView.jsx +++ b/src/components/Map/MapView.jsx @@ -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 { - const c = map.getCenter(); - coords = [c.lng, c.lat]; - } - setDraftCoords(coords); + 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(); + 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,27 +2225,29 @@ export default function MapView({ weatherEnabled={weatherViewEnabled} /> - {/* Step 1: Location Picker with Crosshair */} + {/* Crosshair - shown during location picking AND while creating post */} + {(isPickingLocation || isCreating) && ( +
+
+
+ )} + + {/* Step 1: Location Picker UI (buttons/hint) */} {isPickingLocation && ( - <> -
-
+
+
+ + Move the map to position your post
-
-
- - Move the map to position your post -
-
- - -
+
+ +
- +
)} {/* 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) { - const c = map.getCenter(); - coords = [c.lng, c.lat]; + + // 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"); diff --git a/src/components/Map/markerManager.js b/src/components/Map/markerManager.js index c718d42..f4c3d13 100644 --- a/src/components/Map/markerManager.js +++ b/src/components/Map/markerManager.js @@ -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 }) {
+
+ + +
+
+ ${truth} + Truth Score +
+
+
+
+
+ + +
+
👁 ${formatCount(viewCount)}
❤️ ${formatCount(likeCount)}
@@ -1501,6 +1575,76 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) { ${escapeHtml(data?.summary || postData?.snippet || "")}
+
+ + +
+
+ ${truth} + Truth Score +
+
+
+
+
+ + +
+
👁 ${formatCount(viewCount)}
❤️ ${formatCount(likeCount)}
@@ -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"]'); diff --git a/src/components/Posts/PostCardNew.jsx b/src/components/Posts/PostCardNew.jsx index 6d12bcf..9df418d 100644 --- a/src/components/Posts/PostCardNew.jsx +++ b/src/components/Posts/PostCardNew.jsx @@ -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("|"); diff --git a/src/components/Posts/PostCreateModal.jsx b/src/components/Posts/PostCreateModal.jsx index 1de54d0..e7c848a 100644 --- a/src/components/Posts/PostCreateModal.jsx +++ b/src/components/Posts/PostCreateModal.jsx @@ -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 ( - {/* Backdrop */} + {/* Backdrop - only covers bottom half, allows map interaction above */} + {/* Invisible close trigger at top */} +
- {/* Modal */} + {/* Modal - positioned at bottom, shorter to show map with crosshair */} {/* Scrollable content */} -
+
{/* Category row */}
{CATEGORIES.map((cat) => ( @@ -390,7 +408,15 @@ export default function PostCreateModal({
)} - {/* Error */} + {/* Upload Error */} + {uploadError && ( +
+ + {uploadError} +
+ )} + + {/* Save Error */} {saveError && (
@@ -399,10 +425,15 @@ export default function PostCreateModal({ )} {/* Location */} - {coords && ( + {coords ? (
- Location pinned on map + Location: {coords[1]?.toFixed(4)}, {coords[0]?.toFixed(4)} +
+ ) : ( +
+ + No location - post may fail
)}
diff --git a/src/components/Posts/PostDetailModal.jsx b/src/components/Posts/PostDetailModal.jsx index cfa692a..75a7aa5 100644 --- a/src/components/Posts/PostDetailModal.jsx +++ b/src/components/Posts/PostDetailModal.jsx @@ -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 ))}
- {/* Truth score */} -
-
- - Truth Score - - {truth.count} votes -
-
- -
-
+ {/* Truth Score - Social Network Style */} +
+
+ {/* Downvote button */} handleTruthVote("up")} + 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")} > - - True + - {Math.round(truth.user)}% + + {/* Score display with rail */} +
+
+ = 70 ? 'text-emerald-400' : + truth.user >= 50 ? 'text-amber-400' : + 'text-red-400' + }`}> + {Math.round(truth.user)} + + / 100 +
+ {/* Progress rail */} +
+ = 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.6, ease: "easeOut" }} + /> + {/* Glow effect */} + = 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" }} + /> +
+
+ Questionable + {truth.count} votes + Verified +
+
+ + {/* Upvote button */} handleTruthVote("down")} + 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")} > - - False +
diff --git a/src/styles/map.css b/src/styles/map.css index 8b38ce5..ab16f09 100644 --- a/src/styles/map.css +++ b/src/styles/map.css @@ -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;