krishnadhulipalla's picture
pulsemap 1.2
71c1c9d
import React from "react";
import "./style.css";
import type { FC, SelectMeta } from "./lib/types";
import { REPORTS_URL } from "./lib/constants";
import { useFeeds } from "./hooks/useFeeds";
import { useSessionId } from "./hooks/useSessionId";
import { useUpdates } from "./hooks/useUpdates";
import { useChat } from "./hooks/useChat";
import MapCanvas from "./components/map/MapCanvas";
import SelectedLocationCard from "./components/sidebar/SelectedLocationCard";
import UpdatesPanel from "./components/sidebar/UpdatesPanel";
import { useProximityAlerts } from "./hooks/useProximityAlerts";
import ChatPanel from "./components/chat/ChatPanel";
import type { ReactionInfo, UpdateItem } from "./lib/types";
import { REACTIONS_URL, REACT_URL } from "./lib/constants";
import { useNearbyQueue } from "./hooks/useNearbyQueue";
import NearbyAlertModal from "./components/modals/NearbyAlertModal";
export default function App() {
const [selectedLL, setSelectedLL] = React.useState<[number, number] | null>(
null
);
const [selectedMeta, setSelectedMeta] = React.useState<SelectMeta | null>(
null
);
const [reports, setReports] = React.useState<FC>({
type: "FeatureCollection",
features: [],
});
const [reactionsById, setReactionsById] = React.useState<
Record<string, ReactionInfo>
>({});
const { nws, quakes, eonet, firms } = useFeeds();
const [myLL, setMyLL] = React.useState<[number, number] | null>(null);
const sessionId = useSessionId();
const {
activeTab,
setActiveTab,
localUpdates,
globalUpdates,
loadingLocal,
loadingGlobal,
} = useUpdates(myLL);
const {
messages,
draft,
setDraft,
isStreaming,
hasFirstToken,
chatBodyRef,
send,
pendingPhotoUrl,
setPendingPhotoUrl,
isUploading,
onFileChosen,
} = useChat(sessionId, selectedLL);
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
// Try to get user location once at startup (silent fail if denied)
React.useEffect(() => {
if (!("geolocation" in navigator)) return;
navigator.geolocation.getCurrentPosition(
(pos) => setMyLL([pos.coords.latitude, pos.coords.longitude]),
() => {}, // ignore errors; panel won't show without myLL
{ enableHighAccuracy: false, maximumAge: 60_000, timeout: 8_000 }
);
}, []);
// Nearby alerts (2 miles, max 5)
const {
nearby,
loading: loadingNearby,
refetch: refetchNearby,
setNearby,
} = useProximityAlerts(myLL, { radiusMiles: 2, limit: 5, maxAgeHours: 48 });
console.log("myLL:", myLL);
const loadReports = React.useCallback(async () => {
const fc = await fetch(REPORTS_URL)
.then((r) => r.json())
.catch(() => ({ type: "FeatureCollection", features: [] }));
setReports(fc);
}, []);
// helper to hydrate reactions for the current lists
const hydrateReactions = React.useCallback(
async (items: UpdateItem[]) => {
const ids = Array.from(
new Set(items.map((u) => u.rid).filter(Boolean))
) as string[];
if (ids.length === 0) return;
const url = `${REACTIONS_URL}?ids=${ids.join(
","
)}&session_id=${encodeURIComponent(sessionId)}`;
const data = await fetch(url)
.then((r) => r.json())
.catch(() => ({}));
setReactionsById((prev) => ({ ...prev, ...data }));
},
[sessionId]
);
// when updates change, hydrate reactions
React.useEffect(() => {
// hydrate both tabs so Selected card has data no matter the tab
hydrateReactions(localUpdates);
hydrateReactions(globalUpdates);
hydrateReactions(nearby);
}, [localUpdates, globalUpdates, nearby, hydrateReactions]);
React.useEffect(() => {
loadReports();
}, [loadReports]);
const selectPoint = React.useCallback(
(ll: [number, number], meta: SelectMeta) => {
if (meta?.kind === "mylocation") {
setMyLL(ll); // anchor local updates to device location
}
setSelectedLL(ll);
setSelectedMeta(meta);
},
[]
);
const pickPhoto = React.useCallback(() => fileInputRef.current?.click(), []);
const onSend = React.useCallback(async () => {
const res = await send();
if (res?.tool_used === "add_report") await loadReports();
}, [send, loadReports]);
// toggle handler (optimistic)
const reactOnReport = React.useCallback(
async (rid: string, action: "verify" | "clear") => {
setReactionsById((prev) => {
const cur = prev[rid] || {
verify_count: 0,
clear_count: 0,
me: { verified: false, cleared: false },
};
const want = action === "verify" ? !cur.me.verified : !cur.me.cleared;
const next: ReactionInfo = JSON.parse(JSON.stringify(cur));
if (action === "verify") {
if (want) {
next.me.verified = true;
next.verify_count += 1;
if (next.me.cleared) {
next.me.cleared = false;
next.clear_count = Math.max(0, next.clear_count - 1);
}
} else {
next.me.verified = false;
next.verify_count = Math.max(0, next.verify_count - 1);
}
} else {
if (want) {
next.me.cleared = true;
next.clear_count += 1;
if (next.me.verified) {
next.me.verified = false;
next.verify_count = Math.max(0, next.verify_count - 1);
}
} else {
next.me.cleared = false;
next.clear_count = Math.max(0, next.clear_count - 1);
}
}
return { ...prev, [rid]: next };
});
// commit to API; reconcile with truth
try {
const body = { action, value: true, session_id: sessionId };
// Ensure "value" matches our intended state (toggle)
const current = reactionsById[rid];
const want =
action === "verify" ? !current?.me.verified : !current?.me.cleared;
body.value = want;
// commit
const j = await fetch(`${REACT_URL}/${encodeURIComponent(rid)}/react`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, value: want, session_id: sessionId }),
}).then((r) => r.json());
setReactionsById((prev) => ({ ...prev, [rid]: j }));
} catch {
// fallback re-hydrate
const j = await fetch(
`${REACTIONS_URL}?ids=${rid}&session_id=${encodeURIComponent(
sessionId
)}`
)
.then((r) => r.json())
.catch(() => null);
if (j && j[rid])
setReactionsById((prev) => ({ ...prev, [rid]: j[rid] }));
}
},
[sessionId, reactionsById]
);
const queue = useNearbyQueue(
nearby,
reactionsById,
sessionId,
reactOnReport,
{ limit: 5 }
);
React.useEffect(() => {
if (!queue.open && queue.total > 0) queue.openQueue();
}, [queue.open, queue.total]);
const openedOnceRef = React.useRef(false);
React.useEffect(() => {
if (!openedOnceRef.current && queue.total > 0) {
queue.openQueue();
openedOnceRef.current = true;
}
}, [queue.total, queue.openQueue]);
return (
<div className="shell">
<aside className="sidebar">
<div className="brand">
<div className="logo">PM</div>
<div className="title">PulseMap Agent</div>
</div>
<SelectedLocationCard
selectedLL={selectedLL}
selectedMeta={selectedMeta}
reactionsById={reactionsById}
onClear={() => {
setSelectedLL(null);
setSelectedMeta(null);
}}
/>
<UpdatesPanel
activeTab={activeTab}
setActiveTab={setActiveTab}
localUpdates={localUpdates}
globalUpdates={globalUpdates}
loadingLocal={loadingLocal}
loadingGlobal={loadingGlobal}
selectedLL={myLL || selectedLL}
onView={(u) =>
selectPoint([u.lat, u.lon], {
kind: u.kind as any,
title: u.title,
subtitle: (u as any).raw?.text || "",
severity:
typeof u.severity === "undefined" ? "" : String(u.severity),
sourceUrl: u.sourceUrl,
rid: u.rid, // <--- include rid for Selected card
})
}
reactionsById={reactionsById}
onReact={reactOnReport}
/>
</aside>
<NearbyAlertModal
open={queue.open}
leaving={queue.leaving}
current={queue.current}
index={queue.index}
total={queue.total}
myLL={myLL}
onVerify={queue.verify}
onClear={queue.clear}
onSkip={queue.skip}
onClose={queue.closeQueue}
/>
<main className="main">
<section className="mapWrap" style={{ position: "relative" }}>
<MapCanvas
selectedLL={selectedLL}
selectedMeta={selectedMeta}
setSelected={selectPoint}
nws={nws}
quakes={quakes}
eonet={eonet}
firms={firms}
reports={reports}
/>
</section>
<ChatPanel
messages={messages}
draft={draft}
setDraft={setDraft}
isStreaming={isStreaming}
hasFirstToken={hasFirstToken}
chatBodyRef={chatBodyRef}
onSend={onSend}
pendingThumb={pendingPhotoUrl}
onAttachClick={pickPhoto}
onClearAttach={() => setPendingPhotoUrl(null)}
isUploading={isUploading}
/>
{/* hidden file input lives here */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) onFileChosen(f);
}}
/>
</main>
</div>
);
}