feat: camera pins with thumbnail + LIVE badge (v0.0018)
- Camera markers show thumbnail preview from live stream - Red LIVE badge with pulsing indicator - Fallback to video icon if thumbnail unavailable - Blue glow effect for camera pins Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
7e79688af3
commit
108f8b441a
|
|
@ -251,7 +251,7 @@ export default function App() {
|
|||
<footer className="sw-footer">
|
||||
<div className="sw-footer-inner">
|
||||
<div className="sw-footer-brand">SOCIOWIRE</div>
|
||||
<div className="sw-footer-brand">© Sociowire v0.0017</div>
|
||||
<div className="sw-footer-brand">© Sociowire v0.0018</div>
|
||||
<div id="sw-weather-debug" style={{color: '#0ea5e9', fontSize: '10px'}}></div>
|
||||
<nav className="sw-footer-nav" aria-label="Site">
|
||||
<a href="/">{t('nav.home')}</a>
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ function cloneJson(value) {
|
|||
}
|
||||
}
|
||||
|
||||
async function fetchJsonCached(url, { ttlMs = 0, headers = {}, credentials = "include" } = {}) {
|
||||
async function fetchJsonCached(url, { ttlMs = 0, headers = {}, credentials = "include", signal } = {}) {
|
||||
const key = cacheKeyFor(url, headers);
|
||||
if (ttlMs > 0 && key) {
|
||||
const hit = responseCache.get(key);
|
||||
|
|
@ -81,7 +81,7 @@ async function fetchJsonCached(url, { ttlMs = 0, headers = {}, credentials = "in
|
|||
}
|
||||
}
|
||||
|
||||
const res = await fetch(url, { credentials, headers });
|
||||
const res = await fetch(url, { credentials, headers, signal });
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
const err = new Error(text || `HTTP ${res.status}`);
|
||||
|
|
@ -349,7 +349,7 @@ export async function fetchPostById(postId) {
|
|||
const qs = new URLSearchParams();
|
||||
qs.set("id", String(postId));
|
||||
try {
|
||||
const data = await fetchJsonCached(`${apiBase()}/post?${qs.toString()}`, { ttlMs: 15000 });
|
||||
const data = await fetchJsonCached(`${apiBase()}/post?${qs.toString()}`, { ttlMs: 3000 });
|
||||
if (!data || typeof data !== "object") return null;
|
||||
const resolved = await resolveAssetRefsInPosts([data]);
|
||||
return resolved && resolved[0] ? resolved[0] : data;
|
||||
|
|
@ -455,7 +455,7 @@ export async function registerUser(payload) {
|
|||
* NEW: Fetch intelligent personalized feed with screen-aware prioritization
|
||||
* Uses backend /api/smart-feed endpoint (which proxies to reco-service)
|
||||
*/
|
||||
export async function fetchSmartFeed(params = {}) {
|
||||
export async function fetchSmartFeed(params = {}, { signal } = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
|
||||
if (params.user_id) qs.set("user_id", String(params.user_id));
|
||||
|
|
@ -482,10 +482,11 @@ export async function fetchSmartFeed(params = {}) {
|
|||
const url = `${apiBase()}/smart-feed?${qs.toString()}`;
|
||||
|
||||
try {
|
||||
const data = await fetchJsonCached(url, { ttlMs: 10000 });
|
||||
const data = await fetchJsonCached(url, { ttlMs: 10000, signal });
|
||||
const items = Array.isArray(data) ? data : data.results || data.items || [];
|
||||
return await resolveAssetRefsInPosts(items);
|
||||
} catch (err) {
|
||||
if (err.name === "AbortError") return []; // silently ignore aborted requests
|
||||
console.warn("fetchSmartFeed error:", err);
|
||||
return [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ export default function TemplateMarkerCard({
|
|||
const data = useMemo(() => {
|
||||
const rawImage =
|
||||
mode === "mini"
|
||||
? (post?.image_small || post?.image_medium || "")
|
||||
: (post?.image_medium || post?.image_large || post?.image_small || post?.image || post?.media_url || "");
|
||||
? (post?.image_small || post?.image_medium || post?.thumbnail_url || "")
|
||||
: (post?.image_medium || post?.image_large || post?.image_small || post?.image || post?.thumbnail_url || post?.media_url || "");
|
||||
return {
|
||||
headline: post?.title || "Untitled",
|
||||
source: post?.source || (post?.author ? `by ${post.author}` : "") || (post?.sub_category || ""),
|
||||
|
|
|
|||
|
|
@ -2466,12 +2466,66 @@ 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 || '') : '';
|
||||
|
||||
// Marqueur pin avec image en haut et pointe en bas
|
||||
root.innerHTML = `
|
||||
<div class="sw-simple-marker ${isCamera ? 'sw-camera-marker' : ''} ${isLive ? 'sw-live-marker' : ''} ${isWeather ? 'sw-weather-marker' : ''}" style="
|
||||
root.innerHTML = isCamera ? `
|
||||
<div class="sw-camera-pin" style="
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
background: #1a1a2e;
|
||||
border: 2px solid rgba(56, 189, 248, 0.9);
|
||||
box-shadow: 0 3px 12px rgba(0,0,0,0.4), 0 0 20px rgba(56, 189, 248, 0.3);
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
">
|
||||
${cameraThumbnail ? `
|
||||
<img src="${cameraThumbnail}" alt="" style="width:100%;height:100%;object-fit:cover;" onerror="this.style.display='none';this.nextElementSibling.style.display='flex';" />
|
||||
<div style="display:none;width:100%;height:100%;align-items:center;justify-content:center;background:linear-gradient(135deg, rgba(56, 189, 248, 0.9), rgba(59, 130, 246, 0.9));">
|
||||
<i class="fa-solid fa-video" style="font-size:16px;color:#fff;"></i>
|
||||
</div>
|
||||
` : `
|
||||
<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg, rgba(56, 189, 248, 0.9), rgba(59, 130, 246, 0.9));">
|
||||
<i class="fa-solid fa-video" style="font-size:16px;color:#fff;"></i>
|
||||
</div>
|
||||
`}
|
||||
<div style="
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
background: rgba(239, 68, 68, 0.95);
|
||||
font-size: 7px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
">
|
||||
<span style="width:4px;height:4px;border-radius:50%;background:#fff;animation:pulse 1.5s infinite;"></span>
|
||||
LIVE
|
||||
</div>
|
||||
</div>
|
||||
<div style="
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-top: 10px solid rgba(56, 189, 248, 0.9);
|
||||
margin-top: -1px;
|
||||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
|
||||
"></div>
|
||||
` : `
|
||||
<div class="sw-simple-marker ${isLive ? 'sw-live-marker' : ''} ${isWeather ? 'sw-weather-marker' : ''}" style="
|
||||
${isWeather ? 'width: auto; height: auto; padding: 4px 8px; border-radius: 12px;' : 'width: 32px; height: 32px; border-radius: 50%;'}
|
||||
background: ${isWeather ? 'rgba(14, 165, 233, 0.75)' : color};
|
||||
border: ${isWeather ? '1px solid rgba(255,255,255,0.4)' : isCamera ? '2px solid rgba(255,255,255,0.85)' : isLive ? '2px solid rgba(255,255,255,0.9)' : '2px solid white'};
|
||||
border: ${isWeather ? '1px solid rgba(255,255,255,0.4)' : isLive ? '2px solid rgba(255,255,255,0.9)' : '2px solid white'};
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.25);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -2483,7 +2537,7 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
|
|||
${isWeather ? `
|
||||
<span style="font-size:12px;line-height:1;">${weatherEmoji}</span>
|
||||
<span style="font-size:11px;font-weight:600;color:#fff;">${weatherTemp}</span>
|
||||
` : isLive ? `<i class="fa-solid fa-broadcast-tower" style="font-size:14px;color:#fff;"></i>` : isCamera ? `<i class="fa-solid fa-video" style="font-size:14px;color:#fff;"></i>` : img ? `<img src="${img}" alt="" style="width:100%;height:100%;object-fit:cover;" />` : `
|
||||
` : isLive ? `<i class="fa-solid fa-broadcast-tower" style="font-size:14px;color:#fff;"></i>` : img ? `<img src="${img}" alt="" style="width:100%;height:100%;object-fit:cover;" />` : `
|
||||
<div style="
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
|
|
@ -2503,6 +2557,10 @@ export function createSimpleMarkerForPost(post, mapRef, markersRef, expandedElRe
|
|||
margin-top: -2px;
|
||||
z-index: 1;
|
||||
"></div>` : ''}
|
||||
`;
|
||||
|
||||
// Add hover card for all marker types
|
||||
root.innerHTML += `
|
||||
<div class="sw-simple-hover-card" style="
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
|
|
|
|||
|
|
@ -296,8 +296,8 @@ export function adaptPostToTemplateData(post, opts = {}) {
|
|||
const size = (opts.size || "full").toString();
|
||||
const rawImage =
|
||||
size === "mini"
|
||||
? (post?.image_small || post?.image_medium || post?.image || post?.media_url || "")
|
||||
: (post?.image_medium || post?.image_large || post?.image_small || post?.image || post?.media_url || "");
|
||||
? (post?.image_small || post?.image_medium || post?.image || post?.thumbnail_url || post?.media_url || "")
|
||||
: (post?.image_medium || post?.image_large || post?.image_small || post?.image || post?.thumbnail_url || post?.media_url || "");
|
||||
const image = normalizeImageUrl(rawImage) || `/og/category?cat=${encodeURIComponent((post?.category || "NEWS").toString().toUpperCase())}&variant=mini`;
|
||||
const categoryIcon = getCategoryIcon(post);
|
||||
return { headline, source, summary, url, image, categoryIcon };
|
||||
|
|
|
|||
|
|
@ -407,6 +407,7 @@ export function usePostsEngine({
|
|||
const searchResultsRef = useRef(false);
|
||||
const replacePoolRef = useRef(false);
|
||||
const retryRef = useRef({ key: "", count: 0, ts: 0 });
|
||||
const abortControllerRef = useRef(null);
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
const clusterModeRef = useRef(false);
|
||||
|
||||
|
|
@ -1054,6 +1055,7 @@ export function usePostsEngine({
|
|||
mapObj.__swForceFetchAt = 0;
|
||||
}
|
||||
|
||||
let shouldClearPool = false;
|
||||
if (!forceFetch && last.center && last.filterKey === filterKey && last.timeHours === timeHours) {
|
||||
const [lastLng, lastLat] = last.center;
|
||||
const distKm = haversineKm(lastLat, lastLng, lat, lng);
|
||||
|
|
@ -1077,6 +1079,14 @@ export function usePostsEngine({
|
|||
const minMoveKm = Math.min(10, Math.max(2, radiusKm * 0.03));
|
||||
|
||||
if (!radiusChanged && !boundsChanged && distKm < minMoveKm) return;
|
||||
|
||||
// Clear pool if moved significantly (>50% of radius or >100km)
|
||||
if (distKm > Math.max(50, radiusKm * 0.5)) {
|
||||
shouldClearPool = true;
|
||||
}
|
||||
} else if (forceFetch || last.filterKey !== filterKey) {
|
||||
// Filter changed or force fetch - clear pool
|
||||
shouldClearPool = true;
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -1111,6 +1121,13 @@ export function usePostsEngine({
|
|||
else if (contentFilter === "event") contentTypeParam = "event";
|
||||
}
|
||||
|
||||
// Abort any previous in-flight request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
let newPosts = await fetchSmartFeed({
|
||||
lat,
|
||||
lon: lng,
|
||||
|
|
@ -1123,11 +1140,12 @@ export function usePostsEngine({
|
|||
category: catCode || undefined,
|
||||
sub_category: subCatParam || undefined,
|
||||
content_type: contentTypeParam || undefined,
|
||||
});
|
||||
}, { signal: controller.signal });
|
||||
|
||||
if (cancelled || reqId !== requestSeqRef.current) return;
|
||||
|
||||
const existing = replacePoolRef.current ? [] : allPostsRef.current || [];
|
||||
// Clear pool if moved to new area or filter changed
|
||||
const existing = (replacePoolRef.current || shouldClearPool) ? [] : allPostsRef.current || [];
|
||||
const byId = new Map();
|
||||
for (const p of existing) {
|
||||
const normalized = normalizePostId(p);
|
||||
|
|
@ -1222,6 +1240,11 @@ export function usePostsEngine({
|
|||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
// Abort any in-flight request when deps change or unmount
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
if (moveDebounceRef.current) {
|
||||
try { clearTimeout(moveDebounceRef.current); } catch {}
|
||||
moveDebounceRef.current = null;
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export default function PostCardTemplate({ post, theme = "blue", onOpen }) {
|
|||
source: post.author ? `by ${post.author}` : (post.sub_category || ""),
|
||||
summary: post.snippet || post.body || "",
|
||||
url: post.url || "",
|
||||
image: post.image_medium || post.image_large || post.image_small || post.image || post.media_url || "",
|
||||
image: post.image_medium || post.image_large || post.image_small || post.image || post.thumbnail_url || post.media_url || "",
|
||||
};
|
||||
}, [post]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
/* Camera LIVE indicator pulse animation */
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.post-pin {
|
||||
position: relative;
|
||||
width: 0;
|
||||
|
|
@ -695,11 +701,10 @@ body[data-theme="blue"] .sw-centered-modal .post-card.sw-expanded-shell{
|
|||
}
|
||||
|
||||
.sw-truth-rail{
|
||||
width: 22px;
|
||||
min-width: 22px;
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
height: 180px;
|
||||
min-height: 180px;
|
||||
margin-left: -2px;
|
||||
margin-right: 10px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
|
|
@ -707,6 +712,7 @@ body[data-theme="blue"] .sw-centered-modal .post-card.sw-expanded-shell{
|
|||
flex-direction:column;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
font-weight: 900;
|
||||
letter-spacing: .08em;
|
||||
|
|
@ -748,49 +754,57 @@ body[data-theme="blue"] .sw-centered-modal .post-card.sw-expanded-shell{
|
|||
padding: 4px 6px;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 6px 12px rgba(0,0,0,0.35);
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
left: -6px;
|
||||
transition: opacity 0.2s ease-out;
|
||||
}
|
||||
|
||||
.sw-truth-rail-btn{
|
||||
width: 100%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
background: rgba(56,189,248,0.16);
|
||||
color:#e8f5ff;
|
||||
font-size: 9px;
|
||||
background: rgba(34,197,94,0.25);
|
||||
color:#22c55e;
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
padding: 6px 2px;
|
||||
border-radius: 10px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
line-height: 28px;
|
||||
pointer-events: auto;
|
||||
touch-action: manipulation;
|
||||
position: relative;
|
||||
left: -8px;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-truth-rail-btn:active{
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
.sw-truth-rail-btn-down{
|
||||
background: rgba(239,68,68,0.18);
|
||||
background: rgba(239,68,68,0.25);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.sw-truth-rail-btn.is-active{
|
||||
background: rgba(34,197,94,0.35);
|
||||
color: #eafff3;
|
||||
background: rgba(34,197,94,0.5);
|
||||
color: #fff;
|
||||
box-shadow: 0 0 12px rgba(34,197,94,0.5);
|
||||
}
|
||||
|
||||
.sw-truth-rail-btn-down.is-active{
|
||||
background: rgba(239,68,68,0.35);
|
||||
color: #ffecec;
|
||||
background: rgba(239,68,68,0.5);
|
||||
color: #fff;
|
||||
box-shadow: 0 0 12px rgba(239,68,68,0.5);
|
||||
}
|
||||
|
||||
.sw-truth-vote-disabled{
|
||||
opacity: 0.7;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
.sw-truth-vote-disabled .sw-truth-rail-btn{
|
||||
pointer-events: none !important;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sw-modal-hero-row{
|
||||
display:flex;
|
||||
|
|
|
|||
Loading…
Reference in New Issue