Integrate smart feed and unified search

- 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 <noreply@anthropic.com>
This commit is contained in:
Your Name 2025-12-23 11:32:59 -05:00
parent 2c3722146a
commit a3903906df
4 changed files with 205 additions and 7 deletions

View File

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

View File

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

View File

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

View File

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