From a3903906df23e605b54a1cd6f3fea384b5794533 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 23 Dec 2025 11:32:59 -0500 Subject: [PATCH] Integrate smart feed and unified search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update fetchSmartFeed() to call backend /api/smart-feed - Update fetchUnifiedSearch() to call backend /api/unified-search - Replace fetchPosts() with fetchSmartFeed() in usePostsEngine - Add /api/unified-search as primary search endpoint - Search now queries posts, profiles, and places together - Map now shows personalized and trending posts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- src/api/client.js | 98 +++++++++++++++++++++++++ src/components/Map/usePostsEngine.js | 13 ++-- src/components/Map/useRealtimeStream.js | 98 +++++++++++++++++++++++++ src/services/recoSearch.js | 3 +- 4 files changed, 205 insertions(+), 7 deletions(-) create mode 100644 src/components/Map/useRealtimeStream.js diff --git a/src/api/client.js b/src/api/client.js index 7accfc8..0257ca0 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -154,3 +154,101 @@ export async function registerUser(payload) { return res.json().catch(() => ({})); } + +/** + * 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 = {}) { + const qs = new URLSearchParams(); + + if (params.user_id) qs.set("user_id", String(params.user_id)); + if (params.username) qs.set("username", params.username); + if (typeof params.lat === "number") qs.set("lat", String(params.lat)); + if (typeof params.lon === "number") qs.set("lon", String(params.lon)); + if (typeof params.radius_km === "number") qs.set("radius_km", String(params.radius_km)); + if (typeof params.limit === "number") qs.set("limit", String(params.limit)); + + // NEW: Screen bounds for on-screen prioritization + if (params.bounds) { + const { minLat, minLon, maxLat, maxLon } = params.bounds; + if (minLat != null) qs.set("minLat", String(minLat)); + if (minLon != null) qs.set("minLon", String(minLon)); + if (maxLat != null) qs.set("maxLat", String(maxLat)); + if (maxLon != null) qs.set("maxLon", String(maxLon)); + } + + const url = `${API_BASE}/smart-feed?${qs.toString()}`; + + try { + const res = await fetch(url, { credentials: "include" }); + if (!res.ok) { + console.warn("fetchSmartFeed failed", res.status); + return []; + } + const data = await res.json(); + return Array.isArray(data) ? data : data.results || data.items || []; + } catch (err) { + console.warn("fetchSmartFeed error:", err); + return []; + } +} + +/** + * NEW: Unified search across posts, profiles, and places + * Uses backend /api/unified-search endpoint (which proxies to reco-service) + */ +export async function fetchUnifiedSearch(query, params = {}) { + const qs = new URLSearchParams(); + + qs.set("q", query); + if (params.type) qs.set("type", params.type); // all|posts|profiles|places + if (typeof params.lat === "number") qs.set("lat", String(params.lat)); + if (typeof params.lon === "number") qs.set("lon", String(params.lon)); + if (typeof params.radius_km === "number") qs.set("radius_km", String(params.radius_km)); + if (typeof params.limit === "number") qs.set("limit", String(params.limit)); + + const url = `${API_BASE}/unified-search?${qs.toString()}`; + + try { + const res = await fetch(url, { credentials: "include" }); + if (!res.ok) { + console.warn("fetchUnifiedSearch failed", res.status); + return []; + } + const data = await res.json(); + return Array.isArray(data) ? data : []; + } catch (err) { + console.warn("fetchUnifiedSearch error:", err); + return []; + } +} + +/** + * NEW: Poll for new posts (socket.io compatible) + * Uses reco-service /api/poll endpoint + */ +export async function pollNewPosts(params = {}) { + const qs = new URLSearchParams(); + + if (typeof params.since_id === "number") qs.set("since_id", String(params.since_id)); + if (typeof params.lat === "number") qs.set("lat", String(params.lat)); + if (typeof params.lon === "number") qs.set("lon", String(params.lon)); + if (typeof params.radius_km === "number") qs.set("radius_km", String(params.radius_km)); + if (params.category) qs.set("category", params.category); + if (typeof params.limit === "number") qs.set("limit", String(params.limit)); + + const url = `http://localhost:8091/api/poll?${qs.toString()}`; + + try { + const res = await fetch(url, { credentials: "include" }); + if (!res.ok) { + console.warn("pollNewPosts failed", res.status); + return { posts: [], count: 0, max_id: 0 }; + } + return await res.json(); + } catch (err) { + console.warn("pollNewPosts error:", err); + return { posts: [], count: 0, max_id: 0 }; + } +} diff --git a/src/components/Map/usePostsEngine.js b/src/components/Map/usePostsEngine.js index 3a3744a..20dbc3b 100644 --- a/src/components/Map/usePostsEngine.js +++ b/src/components/Map/usePostsEngine.js @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { fetchPosts } from "../../api/client"; +import { fetchSmartFeed } from "../../api/client"; import { categoryCode, matchesSubFilter, matchesTimeFilter } from "./mapFilter"; import { haversineKm } from "./mapGeo"; import { createMarkerForPost } from "./markerManager"; @@ -220,13 +220,14 @@ export function usePostsEngine({ const subCatParam = subFilter && subFilter.toUpperCase() !== "ALL" && subFilter !== "All" ? subFilter : ""; - const newPosts = await fetchPosts({ - category: catCode, - subCategory: subCatParam, - time: "", + // Use smart feed for personalized, trending posts + const newPosts = await fetchSmartFeed({ lat, lon: lng, - radiusKm, + radius_km: radiusKm, + limit: 80, + // TODO: Add user_id from auth context when available + // TODO: Add bounds parameter for screen-aware prioritization }); if (cancelled) return; diff --git a/src/components/Map/useRealtimeStream.js b/src/components/Map/useRealtimeStream.js new file mode 100644 index 0000000..9eec670 --- /dev/null +++ b/src/components/Map/useRealtimeStream.js @@ -0,0 +1,98 @@ +import { useEffect, useRef, useState, useCallback } from "react"; +import { pollNewPosts } from "../../api/client"; + +/** + * Hook for real-time post streaming using polling (socket.io compatible) + * + * Usage: + * const { newPosts, clearNewPosts } = useRealtimeStream({ + * enabled: true, + * lat: 40.7128, + * lon: -74.0060, + * radius_km: 500, + * category: "NEWS", + * pollInterval: 3000 // poll every 3 seconds + * }); + */ +export function useRealtimeStream({ + enabled = true, + lat, + lon, + radius_km = 500, + category = null, + pollInterval = 3000, // 3 seconds default + onNewPost = null, // callback when new posts arrive +}) { + const [newPosts, setNewPosts] = useState([]); + const [isStreaming, setIsStreaming] = useState(false); + const maxSeenIdRef = useRef(0); + const intervalRef = useRef(null); + + const poll = useCallback(async () => { + if (!lat || !lon) return; + + try { + const result = await pollNewPosts({ + since_id: maxSeenIdRef.current, + lat, + lon, + radius_km, + category, + limit: 20, + }); + + if (result.posts && result.posts.length > 0) { + setNewPosts((prev) => [...prev, ...result.posts]); + + // Update max seen ID + if (result.max_id > maxSeenIdRef.current) { + maxSeenIdRef.current = result.max_id; + } + + // Call callback if provided + if (onNewPost) { + result.posts.forEach(onNewPost); + } + } + } catch (err) { + console.warn("Realtime stream poll error:", err); + } + }, [lat, lon, radius_km, category, onNewPost]); + + useEffect(() => { + if (!enabled || !lat || !lon) { + setIsStreaming(false); + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + return; + } + + setIsStreaming(true); + + // Start polling + intervalRef.current = setInterval(poll, pollInterval); + + // Initial poll + poll(); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + setIsStreaming(false); + }; + }, [enabled, lat, lon, radius_km, category, pollInterval, poll]); + + const clearNewPosts = useCallback(() => { + setNewPosts([]); + }, []); + + return { + newPosts, + clearNewPosts, + isStreaming, + }; +} diff --git a/src/services/recoSearch.js b/src/services/recoSearch.js index a14a603..168a4c4 100644 --- a/src/services/recoSearch.js +++ b/src/services/recoSearch.js @@ -11,9 +11,10 @@ */ const DEFAULT_PATHS = [ + "/api/unified-search", // NEW: Unified search (posts + profiles + places) + "/api/search", // Fallback: Posts only "/api/reco/search", "/api/reco/suggest", - "/api/search", "/api/recommendations/search", ];