import { useAI, useAuth, useProxy, share } from '@deplixo/sdk';
import { useEffect, useRef, useState } from 'react';
import { ObservationLog } from './components/ObservationLog.jsx';
import { AddObservation } from './components/AddObservation.jsx';
import { Stats } from './components/Stats.jsx';
/* @component-map
* App — Main container, tab navigation [app.jsx]
* ObservationLog — Timeline of stargazing observations [components/ObservationLog.jsx]
* AddObservation — Form to log a new observation [components/AddObservation.jsx]
* ObservationDetail — Detail view for a single observation [components/ObservationDetail.jsx]
* Stats — Overview statistics and charts [components/Stats.jsx]
* LocationCaptureFields — Adds a browser geolocation helper for the AddObservation form, letting users auto-fill latitude, longitude, and optional accuracy from their device.
* ObservationLocationMap — Google Maps view centered on a single observation coordinate pair, with a fallback when coordinates are missing.
* ObservationLogItemMap — Compact, non-interactive Google Maps preview for observation cards in the timeline.
* null — Single-observation detail panel with a full Google Map centered on the saved coordinates. Note: if your existing ObservationDetail has different props/markup, merge the map section into that component.
* TonightVisibleObjects — Shows tonight’s visible sky objects from the new proxy feed with refresh and fallback states.
* AIObservationAssist — Lets the user describe what they saw and uses AI to suggest a better observation description and likely object matches.
* @end-component-map */
// DEPLOY_CONFIG: {"server": {"proxy_endpoints": [{"name": "tonight_visible_objects", "method": "GET", "path": "/api/astronomy/tonight-visible-objects", "description": "Server-side proxy endpoint that fetches tonight's visible astronomical objects from an external astronomy API, using a server-stored API key and returning a normalized list for the client.", "env": {"ASTRONOMY_API_KEY": {"type": "secret", "required": true}, "ASTRONOMY_API_BASE_URL": {"type": "env", "required": false, "default": "https://api.astronomy.example.com"}}}]}}
function App() {
const { user, loading, login, logout } = useAuth();
const [activeTab, setActiveTab] = useState('log');
const [showAddModal, setShowAddModal] = useState(false);
const [editItem, setEditItem] = useState(null);
const [sharedObservationId, setSharedObservationId] = useState(null);
const [sharedObservation, setSharedObservation] = useState(null);
const [sharedLoading, setSharedLoading] = useState(false);
const handleEdit = (item) => {
setEditItem(item);
setShowAddModal(true);
};
const handleCloseModal = () => {
setShowAddModal(false);
setEditItem(null);
};
const handleAddClick = () => {
if (!user) {
login();
return;
}
setShowAddModal(true);
};
const handleOpenSharedObservation = (observation) => {
if (!observation) return;
setActiveTab('log');
setSharedObservationId(observation.id || null);
setSharedObservation(observation);
};
const clearSharedObservation = () => {
setSharedObservationId(null);
setSharedObservation(null);
};
const syncShareRoute = () => {
if (typeof window === 'undefined') return;
const params = new URLSearchParams(window.location.search);
const view = params.get('view');
const id = params.get('id');
if (view === 'observation' && id) {
setSharedObservationId(id);
}
};
useEffect(() => {
syncShareRoute();
if (typeof window === 'undefined') return undefined;
const onPopState = () => syncShareRoute();
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, []);
useEffect(() => {
if (!sharedObservationId || !user) return;
let cancelled = false;
const loadSharedObservation = async () => {
setSharedLoading(true);
try {
const response = await fetch(window.location.href, { method: 'GET' });
const text = await response.text();
const match = text && text.includes(sharedObservationId);
if (!cancelled) {
setSharedObservation(match ? sharedObservation : sharedObservation);
}
} catch (error) {
if (!cancelled) setSharedObservation(sharedObservation);
} finally {
if (!cancelled) setSharedLoading(false);
}
};
loadSharedObservation();
return () => { cancelled = true; };
}, [sharedObservationId, user]);
if (loading) return
Signing in...
;
if (!user) {
return (
Sign in to view your observations
Use Google authentication to keep your stargazing notes private and tied to your account.
);
}
return (
{activeTab === 'log' && }
{activeTab === 'stats' && }
{sharedObservationId && activeTab === 'log' && (
{sharedLoading ? (
Loading shared report...
) : sharedObservation ? (
) : (
Shared observation not found.
)}
)}
{showAddModal && (
)}
);
}
function LocationCaptureFields({ value, onChange }) {
const [capturing, setCapturing] = useState(false);
const [error, setError] = useState('');
const requestLocation = () => {
setError('');
if (!navigator.geolocation) {
setError('Geolocation is not supported by this browser.');
return;
}
setCapturing(true);
navigator.geolocation.getCurrentPosition(
(position) => {
const latitude = position?.coords?.latitude;
const longitude = position?.coords?.longitude;
const accuracy = position?.coords?.accuracy;
if (latitude === undefined || longitude === undefined || latitude === null || longitude === null) {
setError('Could not determine your location.');
setCapturing(false);
return;
}
onChange({
...value,
latitude,
longitude,
accuracy,
});
setCapturing(false);
},
() => {
setError('Unable to access your location. Please allow location access and try again.');
setCapturing(false);
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }
);
};
return (
onChange({ ...value, accuracy: e.target.value })}
placeholder="Accuracy in meters"
/>
{error &&
{error}
}
);
}
function ObservationLocationMap({ latitude, longitude, title, description, zoom = 14, className = '' }) {
const containerRef = useRef(null);
useEffect(() => {
const lat = Number(latitude);
const lng = Number(longitude);
if (!containerRef.current) return;
if (Number.isNaN(lat) || Number.isNaN(lng)) return;
if (typeof window === 'undefined' || !window.google || !window.google.maps) return;
const map = new window.google.maps.Map(containerRef.current, {
center: { lat, lng },
zoom,
mapTypeControl: false,
streetViewControl: false,
fullscreenControl: false,
zoomControl: true,
});
new window.google.maps.Marker({
position: { lat, lng },
map,
title: title || 'Observation site',
});
if (description) {
const infoWindow = new window.google.maps.InfoWindow({
content: ``,
});
map.addListener('click', () => infoWindow.close());
}
}, [latitude, longitude, title, description, zoom]);
const latNum = Number(latitude);
const lngNum = Number(longitude);
const hasCoords = !Number.isNaN(latNum) && !Number.isNaN(lngNum);
return (
{hasCoords ? (
) : (
⌖
No coordinates available
)}
);
}
function ObservationLogItemMap({ latitude, longitude, title }) {
const containerRef = useRef(null);
useEffect(() => {
const lat = Number(latitude);
const lng = Number(longitude);
if (!containerRef.current) return;
if (Number.isNaN(lat) || Number.isNaN(lng)) return;
if (typeof window === 'undefined' || !window.google || !window.google.maps) return;
const map = new window.google.maps.Map(containerRef.current, {
center: { lat, lng },
zoom: 11,
mapTypeControl: false,
streetViewControl: false,
fullscreenControl: false,
gestureHandling: 'none',
draggable: false,
zoomControl: false,
});
new window.google.maps.Marker({
position: { lat, lng },
map,
title: title || 'Observation site',
});
}, [latitude, longitude, title]);
const latNum = Number(latitude);
const lngNum = Number(longitude);
const hasCoords = !Number.isNaN(latNum) && !Number.isNaN(lngNum);
return (
);
}
function ObservationDetail({ observation, onEdit, onClose }) {
const lat = observation?.latitude;
const lng = observation?.longitude;
const hasCoords = lat !== undefined && lat !== null && lng !== undefined && lng !== null && lat !== '' && lng !== '';
const images = Array.isArray(observation?.imageUrls) ? observation.imageUrls : (Array.isArray(observation?.images) ? observation.images : []);
const buildShareSummary = () => {
if (!observation) return 'Stargazer Journal observation report';
const parts = [];
parts.push(`Stargazer Journal report: ${observation.title || 'Observation Details'}`);
if (observation.date) parts.push(`Date: ${observation.date}`);
if (observation.notes) parts.push(`Notes: ${observation.notes}`);
if (hasCoords) {
parts.push(`Location: ${observation.latitude}, ${observation.longitude}`);
if (observation.accuracy !== undefined && observation.accuracy !== null && observation.accuracy !== '') {
parts.push(`Accuracy: ${observation.accuracy} m`);
}
}
if (images.length > 0) parts.push(`Photos: ${images.length}`);
return parts.join('\n');
};
const getShareUrl = () => {
if (typeof window === 'undefined' || !observation?.id) return '';
const base = window.location.origin + window.location.pathname;
const url = new URL(base, window.location.origin);
url.searchParams.set('view', 'observation');
url.searchParams.set('id', String(observation.id));
return url.toString();
};
const handleCopyShareSummary = async () => {
const shareText = `${buildShareSummary()}\n${getShareUrl() ? `\nView shared report: ${getShareUrl()}` : ''}`.trim();
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(shareText);
}
} catch (error) {
const textarea = document.createElement('textarea');
textarea.value = shareText;
textarea.setAttribute('readonly', 'true');
textarea.className = 'share-copy-fallback';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
};
const handleNativeShare = async () => {
const shareUrl = getShareUrl();
const shareText = buildShareSummary();
if (navigator?.share && shareUrl) {
try {
await navigator.share({
title: observation?.title || 'Observation Details',
text: shareText,
url: shareUrl,
});
return;
} catch (error) {
/* user canceled or share failed */
}
}
await handleCopyShareSummary();
};
return (
{observation?.title || 'Observation Details'}
{observation?.date &&
{observation.date}
}
{onEdit && (
)}
{onClose && (
)}
{observation?.notes &&
{observation.notes}
}
{images.length > 0 && (
Astrophotography
{images.map((src, index) => (
))}
{images[0] && (
)}
)}
Observation site
{hasCoords ? (
<>
Latitude: {observation.latitude}
Longitude: {observation.longitude}
{observation.accuracy !== undefined && observation.accuracy !== null && observation.accuracy !== '' && (
Accuracy: {observation.accuracy} m
)}
>
) : (
⌖
No coordinates recorded for this observation.
)}
);
}
function TonightVisibleObjects() {
const { fetch: proxyFetch, loading, error } = useProxy();
const [objects, setObjects] = useState([]);
const [status, setStatus] = useState('');
const loadObjects = async () => {
setStatus('Loading tonight’s visible objects...');
try {
const data = await proxyFetch('https://api.deplixo.ai/astronomy/tonight-visible-objects', {
method: 'GET'
});
const list = Array.isArray(data?.objects) ? data.objects : (Array.isArray(data) ? data : []);
setObjects(list);
setStatus(list.length ? '' : 'No visible objects were returned for tonight.');
} catch (err) {
setObjects([]);
setStatus('Unable to load tonight’s visible objects.');
}
};
return (
Tonight’s Visible Objects
Powered by the live proxy feed.
{error &&
{error}
}
{status &&
{status}
}
{objects.map((item, index) => (
{item?.name || item?.title || 'Unknown object'}
{item?.type && {item.type}}
{item?.description &&
{item.description}
}
{item?.bestTime && Best time: {item.bestTime}}
{item?.altitude && Altitude: {item.altitude}}
{item?.visibility && Visibility: {item.visibility}}
))}
{!objects.length && !loading && !status &&
Tap refresh to load tonight’s list.
}
);
}
function AIObservationAssist({ initialWhatSeen = '', initialNotes = '', onApply }) {
const { generate, loading, error } = useAI();
const [whatSeen, setWhatSeen] = useState(initialWhatSeen);
const [notes, setNotes] = useState(initialNotes);
const [result, setResult] = useState(null);
const handleAnalyze = async () => {
const response = await generate({
system: 'You are an astronomy assistant. Given a user observation, suggest a concise improved description and likely object IDs/names. Return JSON with keys: suggestedDescription, likelyObjects (array of objects with name, id, confidence, reason), followUpQuestions (array of strings).',
user: JSON.stringify({ whatSeen, notes }),
json: true
});
setResult(response || null);
if (onApply && response?.suggestedDescription) {
onApply(response);
}
};
return (
AI-assisted identification
Describe what you saw and get a suggested observation summary plus likely object matches.
{error &&
{error}
}
{result && (
{result.suggestedDescription && (
Suggested description
{result.suggestedDescription}
)}
{Array.isArray(result.likelyObjects) && result.likelyObjects.length > 0 && (
Likely object IDs
{result.likelyObjects.map((obj, index) => (
-
{obj?.name || obj?.id || 'Unknown'}
{obj?.confidence !== undefined && obj?.confidence !== null && — {obj.confidence}}
{obj?.reason &&
{obj.reason}
}
))}
)}
{Array.isArray(result.followUpQuestions) && result.followUpQuestions.length > 0 && (
Follow-up questions
{result.followUpQuestions.map((q, index) => (
- {q}
))}
)}
)}
);
}
ReactDOM.createRoot(document.getElementById("root")).render();