feat: Share button with options, video pins fixed, polaroid smaller

- Add share menu with 3 options: Copy URL, Copy with coords, Share via
- Share URL includes lat/lon/zoom/category/time filters
- Restore shared view from URL params on page load
- Fix videos ALWAYS using simple markers (blue pin + play button)
- Camera pins: blue 64x48 with red LED pulse
- Video pins: blue 64x48 with play button overlay (no LED)
- Polaroid cards: reduced from 180x230 to 150x190
- Force camera/video to simple markers in fallback scoring

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Sociowire Dev 2026-01-14 22:56:38 +00:00
parent 0e741329cb
commit 1902515d4a
6 changed files with 356 additions and 64 deletions

View File

@ -282,6 +282,8 @@ export default function MapView({
// Live broadcast state
const [showLiveBroadcast, setShowLiveBroadcast] = useState(false);
const [showShareMenu, setShowShareMenu] = useState(false);
const [shareData, setShareData] = useState({ url: '', text: '' });
const [activeLivePost, setActiveLivePost] = useState(null);
const [liveCoords, setLiveCoords] = useState(null);
// Weather modal state
@ -740,6 +742,55 @@ export default function MapView({
});
}, [onSelectPost, openOverlayWhenReady]);
// Restore shared view from URL params (?lat=X&lon=Y&z=Z&cat=CAT&sub=SUB&time=H)
const sharedViewRestoredRef = useRef(false);
useEffect(() => {
if (sharedViewRestoredRef.current) return;
const qs = new URLSearchParams(window.location.search || "");
const lat = parseFloat(qs.get("lat"));
const lon = parseFloat(qs.get("lon"));
const z = parseInt(qs.get("z"), 10);
const cat = qs.get("cat");
const sub = qs.get("sub");
const time = parseInt(qs.get("time"), 10);
// Only restore if lat/lon are present
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
sharedViewRestoredRef.current = true;
skipAutoZoomRef.current = true;
// Restore filters
if (cat) {
const cats = cat.split(",").filter(Boolean);
if (cats.length > 0) setMainFilters(cats);
}
if (sub) {
const subs = sub.split(",").filter(Boolean);
if (subs.length > 0) setSubFilters(subs);
}
if (Number.isFinite(time) && time > 0) {
setTimeHours(time);
}
// Fly to location when map is ready
const flyWhenReady = () => {
const map = mapRef.current;
if (!map) {
setTimeout(flyWhenReady, 200);
return;
}
const zoom = Number.isFinite(z) ? z : 10;
map.flyTo({ center: [lon, lat], zoom, speed: 1.5 });
};
flyWhenReady();
// Clean URL
try {
window.history.replaceState(null, "", "/");
} catch {}
}, []);
useEffect(() => {
if (queryPostRef.current) return;
if (!CLUSTERS_ENABLED) return;
@ -1984,6 +2035,37 @@ export default function MapView({
>
<i className="fa-solid fa-plus"></i>
</button>
<button
type="button"
className="sw-search__iconBtn"
title="Share this view"
onClick={() => {
const map = mapRef.current;
if (!map) return;
const center = map.getCenter();
const zoom = Math.round(map.getZoom());
const params = new URLSearchParams();
params.set('lat', center.lat.toFixed(5));
params.set('lon', center.lng.toFixed(5));
params.set('z', String(zoom));
if (mainFilters.length > 0) {
params.set('cat', mainFilters.join(','));
}
if (subFilters.length > 0) {
params.set('sub', subFilters.join(','));
}
if (timeHours && timeHours !== 336) {
params.set('time', String(timeHours));
}
const shareUrl = `${window.location.origin}/?${params.toString()}`;
const filterDesc = mainFilters.length > 0 ? mainFilters.join(', ') : 'All';
const shareText = `📍 ${center.lat.toFixed(4)}, ${center.lng.toFixed(4)} | Zoom ${zoom} | ${filterDesc}\n${shareUrl}`;
setShareData({ url: shareUrl, text: shareText });
setShowShareMenu(true);
}}
>
<i className="fa-solid fa-share-nodes"></i>
</button>
<button
type="button"
className="sw-search__iconBtn"
@ -2320,6 +2402,116 @@ export default function MapView({
onClose={() => setActiveWeatherPost(null)}
/>
)}
{/* Share Menu */}
{showShareMenu && (
<>
<div
className="share-menu-backdrop"
onClick={() => setShowShareMenu(false)}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
zIndex: 9998,
}}
/>
<div
className="share-menu"
style={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
background: '#1a1f2e',
borderRadius: 16,
padding: 20,
zIndex: 9999,
minWidth: 280,
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h3 style={{ margin: 0, color: '#fff', fontSize: 16, fontWeight: 700 }}>Share this view</h3>
<button
onClick={() => setShowShareMenu(false)}
style={{ background: 'none', border: 'none', color: '#888', cursor: 'pointer', fontSize: 18 }}
>
<i className="fa-solid fa-xmark" />
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{/* Copy URL */}
<button
onClick={() => {
navigator.clipboard.writeText(shareData.url).then(() => {
setShowShareMenu(false);
}).catch(() => {
window.prompt('Copy this link:', shareData.url);
});
}}
style={{
display: 'flex', alignItems: 'center', gap: 12,
background: '#2a3142', border: 'none', borderRadius: 10,
padding: '12px 16px', cursor: 'pointer', color: '#fff',
fontSize: 14, fontWeight: 600, textAlign: 'left',
}}
>
<i className="fa-solid fa-link" style={{ color: '#4ea1ff', width: 20 }} />
Copy URL
</button>
{/* Copy as text */}
<button
onClick={() => {
navigator.clipboard.writeText(shareData.text).then(() => {
setShowShareMenu(false);
}).catch(() => {
window.prompt('Copy this text:', shareData.text);
});
}}
style={{
display: 'flex', alignItems: 'center', gap: 12,
background: '#2a3142', border: 'none', borderRadius: 10,
padding: '12px 16px', cursor: 'pointer', color: '#fff',
fontSize: 14, fontWeight: 600, textAlign: 'left',
}}
>
<i className="fa-solid fa-file-lines" style={{ color: '#22c55e', width: 20 }} />
Copy with coordinates
</button>
{/* Native share (mobile) */}
{typeof navigator !== 'undefined' && navigator.share && (
<button
onClick={() => {
navigator.share({
title: 'SocioWire Map View',
text: 'Check out this location on SocioWire',
url: shareData.url
}).then(() => setShowShareMenu(false)).catch(() => {});
}}
style={{
display: 'flex', alignItems: 'center', gap: 12,
background: '#2a3142', border: 'none', borderRadius: 10,
padding: '12px 16px', cursor: 'pointer', color: '#fff',
fontSize: 14, fontWeight: 600, textAlign: 'left',
}}
>
<i className="fa-solid fa-share-from-square" style={{ color: '#a855f7', width: 20 }} />
Share via...
</button>
)}
</div>
{/* Preview */}
<div style={{ marginTop: 16, padding: 12, background: '#0d1117', borderRadius: 8, fontSize: 11, color: '#888', wordBreak: 'break-all' }}>
{shareData.url}
</div>
</div>
</>
)}
</div>
);
}

View File

@ -388,15 +388,13 @@ function mountCard(container, spec, data, theme, post) {
}
const tokens = cardTokens?.[theme] || cardTokens.blue;
const score = truthScore(post || data || {});
const templateKey = getTemplateKeyForPost(post || {});
const isPolaroid = templateKey === 'polaroid';
root.render(
React.createElement(
"div",
{
className: "sw-card-wrap",
style: { width: spec?.size?.w, height: spec?.size?.h },
},
// Build children array
const children = [
React.createElement(CardRenderer, {
key: "card",
spec,
data,
themeTokens: tokens,
@ -407,9 +405,42 @@ function mountCard(container, spec, data, theme, post) {
},
className: "",
}),
];
// For polaroid: just a small round indicator at bottom right
// For others: full truth bar on the side
if (isPolaroid) {
children.push(
React.createElement(
"div",
{ className: "sw-truth-mini-bar" },
{
key: "truth-dot",
className: "sw-truth-dot",
style: {
position: 'absolute',
bottom: 6,
right: 6,
width: 18,
height: 18,
borderRadius: '50%',
background: truthColor(score),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 9,
fontWeight: 700,
color: '#fff',
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
}
},
`${score}`
)
);
} else {
children.push(
React.createElement(
"div",
{ key: "truth-bar", className: "sw-truth-mini-bar" },
React.createElement("div", {
className: "sw-truth-mini-marker",
style: { top: `${100 - score}%` },
@ -417,9 +448,20 @@ function mountCard(container, spec, data, theme, post) {
),
React.createElement(
"div",
{ className: "sw-truth-mini-score", style: { background: truthColor(score) } },
{ key: "truth-score", className: "sw-truth-mini-score", style: { background: truthColor(score) } },
`${score}`
),
)
);
}
root.render(
React.createElement(
"div",
{
className: "sw-card-wrap",
style: { width: spec?.size?.w, height: spec?.size?.h },
},
...children
)
);
@ -2434,6 +2476,7 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
function renderSimple() {
const activePost = root.__post || post;
const isCamera = activePost?.content_type === 'camera';
const isVideo = activePost?.content_type === 'video';
const isLive = activePost?.content_type === 'live';
const isWeather = activePost?.content_type === 'weather';
const liveColor = 'linear-gradient(135deg, rgba(220,38,38,0.95), rgba(239,68,68,0.95))';
@ -2442,14 +2485,14 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
const weatherPin = '#3b82f6';
const color = isLive
? liveColor
: isCamera
: (isCamera || isVideo)
? 'linear-gradient(135deg, rgba(56, 189, 248, 0.9), rgba(59, 130, 246, 0.9))'
: isWeather
? weatherColor
: getMarkerColorForCategory(activePost?.category);
const pinColor = isLive
? livePin
: isCamera
: (isCamera || isVideo)
? 'rgba(56, 189, 248, 0.9)'
: isWeather
? weatherPin
@ -2489,41 +2532,81 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
// Weather city name for label
const weatherCity = isWeather ? (activePost?.city || (activePost?.title || '').split(':')[0].replace(/^[^\w\s]+/, '').trim()) : '';
// Camera thumbnail URL
const cameraThumbnail = isCamera ? (activePost?.thumbnail_url || activePost?.image_small || '') : '';
// Camera/video thumbnail URL
const mediaThumbnail = (isCamera || isVideo) ? (activePost?.thumbnail_url || activePost?.image_small || '') : '';
// Camera pin - square thumbnail with red live dot
// Camera pin - square thumbnail with red LED that pulses (bigger)
// Video pin - square thumbnail with play button overlay (bigger, no red LED)
root.innerHTML = isCamera ? `
<div style="position:relative;">
<div style="
width: 44px;
height: 32px;
border-radius: 4px;
width: 64px;
height: 48px;
border-radius: 6px;
background: #0ea5e9;
border: 2px solid #0ea5e9;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
box-shadow: 0 3px 10px rgba(0,0,0,0.4);
overflow: hidden;
">
${cameraThumbnail ? `<img src="${cameraThumbnail}" style="width:100%;height:100%;object-fit:cover;" onerror="this.style.display='none';" />` : ``}
${mediaThumbnail ? `<img src="${mediaThumbnail}" style="width:100%;height:100%;object-fit:cover;" onerror="this.style.display='none';" />` : ``}
</div>
<div style="
position: absolute;
top: -4px;
right: -4px;
width: 10px;
height: 10px;
top: -6px;
right: -6px;
width: 14px;
height: 14px;
border-radius: 50%;
background: #ef4444;
border: 2px solid #fff;
box-shadow: 0 0 6px #ef4444;
box-shadow: 0 0 10px #ef4444;
animation: pulse 1.5s infinite;
"></div>
<div style="
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 8px solid #0ea5e9;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-top: 12px solid #0ea5e9;
margin: -2px auto 0;
"></div>
</div>
` : isVideo ? `
<div style="position:relative;">
<div style="
width: 64px;
height: 48px;
border-radius: 6px;
background: #0ea5e9;
border: 2px solid #0ea5e9;
box-shadow: 0 3px 10px rgba(0,0,0,0.4);
overflow: hidden;
position: relative;
">
${mediaThumbnail ? `<img src="${mediaThumbnail}" style="width:100%;height:100%;object-fit:cover;" onerror="this.style.display='none';" />` : ``}
<div style="
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 26px;
height: 26px;
border-radius: 50%;
background: rgba(14, 165, 233, 0.9);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.35);
">
<i class="fa-solid fa-play" style="font-size:10px;color:#fff;margin-left:2px;"></i>
</div>
</div>
<div style="
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-top: 12px solid #0ea5e9;
margin: -2px auto 0;
"></div>
</div>

View File

@ -138,8 +138,8 @@ export function prioritizePosts(posts, viewCenter, options = {}) {
console.log('[prioritizePosts] post:', post.id, 'tier:', post.tier, 'content_type:', post.content_type);
}
// Camera posts ALWAYS use simple markers (small blue pin with red dot)
if (contentType === 'camera') {
// Camera and video posts ALWAYS use simple markers (small blue pin with red dot)
if (contentType === 'camera' || contentType === 'video') {
simpleMarkerPosts.push(post);
continue;
}
@ -166,18 +166,28 @@ export function prioritizePosts(posts, viewCenter, options = {}) {
}
// Fallback: old scoring system if no backend tiers
simpleMarkerPosts.length = 0; // Clear for fallback processing
// First, separate camera/video posts - they ALWAYS use simple markers
const forcedSimple = [];
const otherPosts = [];
for (const post of posts) {
const ct = (post?.content_type || '').toLowerCase();
if (ct === 'camera' || ct === 'video') {
forcedSimple.push(post);
} else {
otherPosts.push(post);
}
}
const postsWithScores = posts.map(post => ({
const postsWithScores = otherPosts.map(post => ({
post,
priority: calculatePostPriority(post, viewCenter, posts),
priority: calculatePostPriority(post, viewCenter, otherPosts),
}));
postsWithScores.sort((a, b) => b.priority - a.priority);
const clusters = clusterPosts(posts, clusterRadius);
const clusters = clusterPosts(otherPosts, clusterRadius);
const finalFull = [];
const finalSimple = [];
const finalSimple = [...forcedSimple]; // Start with camera/video posts
const clusterRepresentatives = new Set();
for (const [clusterId, clusterPostsList] of clusters.entries()) {

View File

@ -11,13 +11,13 @@
export const TEMPLATE_SPECS = {
polaroid: {
mini_spec: {
size: { w: 180, h: 230 },
size: { w: 150, h: 190 },
background: { type: "solid", value: "#ffffff" },
radius: 8,
radius: 6,
layers: [
{ id: "image", type: "image", x: 10, y: 10, w: 160, h: 160, bind: "data.image", radius: 4 },
{ id: "title", type: "text", x: 10, y: 176, w: 160, h: 28, bind: "data.headline", style: "text.polaroidTitle", maxLines: 2 },
{ id: "author", type: "text", x: 10, y: 206, w: 160, h: 18, bind: "data.source", style: "text.polaroidCaption", maxLines: 1 },
{ id: "image", type: "image", x: 8, y: 8, w: 134, h: 134, bind: "data.image", radius: 3 },
{ id: "title", type: "text", x: 8, y: 146, w: 134, h: 24, bind: "data.headline", style: "text.polaroidTitle", maxLines: 2 },
{ id: "author", type: "text", x: 8, y: 172, w: 134, h: 14, bind: "data.source", style: "text.polaroidCaption", maxLines: 1 },
],
},
full_spec: {

View File

@ -717,7 +717,9 @@ export function usePostsEngine({
const have = new Set(next.map((x) => x.id));
for (const [id, entry] of wanted.entries()) {
if (have.has(id)) continue;
if (entry.type === "simple") {
// Camera/video ALWAYS use simple markers regardless of priority
const ct = (entry.post?.content_type || '').toLowerCase();
if (entry.type === "simple" || ct === 'camera' || ct === 'video') {
createSimpleMarkerForPost(entry.post, mapRef, markersRef, expandedElRef, theme);
} else {
// Full cards use createMarkerForPost (template is decided by content_type)
@ -827,10 +829,12 @@ export function usePostsEngine({
const have = new Set(next.map((x) => x.id));
// add missing markers - use appropriate type based on priority
// Camera/video ALWAYS use simple markers regardless of priority
for (const [id, { post, type }] of wanted.entries()) {
if (have.has(id)) continue;
if (type === 'full') {
const ct = (post?.content_type || '').toLowerCase();
if (type === 'full' && ct !== 'camera' && ct !== 'video') {
createMarkerForPost(post, mapRef, markersRef, expandedElRef, theme);
} else {
createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRef, theme);
@ -1416,9 +1420,9 @@ export function usePostsEngine({
const id = getId(normalized);
const have = new Set((markersRef.current || []).map((x) => x.id));
if (id != null && !have.has(id)) {
// Camera posts always use simple markers (small blue pin with red dot)
// Camera and video posts always use simple markers (small blue pin with red dot)
const ct = (normalized?.content_type || '').toLowerCase();
if (ct === 'camera') {
if (ct === 'camera' || ct === 'video') {
createSimpleMarkerForPost(normalized, mapRef, markersRef, expandedElRef, theme);
} else {
createMarkerForPost(normalized, mapRef, markersRef, expandedElRef, theme);

View File

@ -23,8 +23,9 @@ export const cardTokens = {
h1: { color: "#E9EEF8", fontSize: 22, fontWeight: 900, lineHeight: "26px" },
body: { color: "#D7DEED", fontSize: 14, fontWeight: 600, lineHeight: "18px" },
icon: { color: "rgba(255,255,255,0.3)", fontSize: 48, fontFamily: "Font Awesome 6 Free", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 900 },
polaroidTitle: { color: "#1a1a1a", fontSize: 11, fontWeight: 700, lineHeight: "13px" },
polaroidCaption: { color: "#666666", fontSize: 10, fontWeight: 500, lineHeight: "12px" },
polaroidTitle: { color: "#1a1a1a", fontSize: 12, fontWeight: 800, lineHeight: "14px", letterSpacing: "-0.2px" },
polaroidCaption: { color: "#888888", fontSize: 9, fontWeight: 600, lineHeight: "11px", fontStyle: "italic" },
polaroidText: { color: "#333333", fontSize: 10, fontWeight: 500, lineHeight: "12px" },
},
chip: {
news: { background: "rgba(78,161,255,0.18)", color: "#CFE6FF", fontWeight: 800, fontSize: 12, borderRadius: 999 },
@ -59,8 +60,9 @@ export const cardTokens = {
h1: { color: "#F0F7FF", fontSize: 22, fontWeight: 900, lineHeight: "26px" },
body: { color: "#D7E9FF", fontSize: 14, fontWeight: 700, lineHeight: "18px" },
icon: { color: "rgba(255,255,255,0.3)", fontSize: 48, fontFamily: "Font Awesome 6 Free", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 900 },
polaroidTitle: { color: "#1a1a1a", fontSize: 11, fontWeight: 700, lineHeight: "13px" },
polaroidCaption: { color: "#666666", fontSize: 10, fontWeight: 500, lineHeight: "12px" },
polaroidTitle: { color: "#1a1a1a", fontSize: 12, fontWeight: 800, lineHeight: "14px", letterSpacing: "-0.2px" },
polaroidCaption: { color: "#888888", fontSize: 9, fontWeight: 600, lineHeight: "11px", fontStyle: "italic" },
polaroidText: { color: "#333333", fontSize: 10, fontWeight: 500, lineHeight: "12px" },
},
chip: {
news: { background: "rgba(56,189,248,0.18)", color: "#D7F3FF", fontWeight: 900, fontSize: 12, borderRadius: 999 },
@ -95,8 +97,9 @@ export const cardTokens = {
h1: { color: "#0B1220", fontSize: 22, fontWeight: 900, lineHeight: "26px" },
body: { color: "#1A2740", fontSize: 14, fontWeight: 600, lineHeight: "18px" },
icon: { color: "rgba(0,0,0,0.15)", fontSize: 48, fontFamily: "Font Awesome 6 Free", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 900 },
polaroidTitle: { color: "#1a1a1a", fontSize: 11, fontWeight: 700, lineHeight: "13px" },
polaroidCaption: { color: "#666666", fontSize: 10, fontWeight: 500, lineHeight: "12px" },
polaroidTitle: { color: "#1a1a1a", fontSize: 12, fontWeight: 800, lineHeight: "14px", letterSpacing: "-0.2px" },
polaroidCaption: { color: "#888888", fontSize: 9, fontWeight: 600, lineHeight: "11px", fontStyle: "italic" },
polaroidText: { color: "#333333", fontSize: 10, fontWeight: 500, lineHeight: "12px" },
},
chip: {
news: { background: "rgba(30,107,255,0.12)", color: "#0B1220", fontWeight: 900, fontSize: 12, borderRadius: 999 },