Added mapbox integration
Deploy / deploy-dev (push) Successful in 2m33s
Deploy / deploy-prod (push) Has been skipped

This commit is contained in:
blaisadmin
2026-06-29 19:50:08 -04:00
parent 9ddd85b5c4
commit 7ef20ab0fb
7 changed files with 434 additions and 213 deletions
+1
View File
@@ -14,6 +14,7 @@ PHOTO_DELIVERY_MODE=proxy
FRONTEND_URL=http://localhost:3000
BACKEND_URL=http://localhost:5000
VITE_API_BASE_URL=http://localhost:5000/api
MAPBOX_ACCESS_TOKEN=
NODE_ENV=development
TRUST_PROXY=
ADMIN_EMAILS=corey@blaishome.online
+156 -2
View File
@@ -288,6 +288,7 @@ const birdProfileListSchema = z
const verifiedLocationDetailsSchema = z
.object({
label: z.string().trim().max(220).optional().or(z.literal('')),
city: z.string().trim().max(120).optional().or(z.literal('')),
region: z.string().trim().max(120).optional().or(z.literal('')),
country: z.string().trim().max(120).optional().or(z.literal('')),
@@ -295,10 +296,16 @@ const verifiedLocationDetailsSchema = z
latitude: z.coerce.number().min(-90).max(90).optional().nullable().or(z.literal('')),
longitude: z.coerce.number().min(-180).max(180).optional().nullable().or(z.literal('')),
precision: z.enum(['city', 'region', 'country']).optional(),
provider: z.literal('mapbox').optional().nullable(),
providerPlaceId: z.string().trim().max(220).optional().or(z.literal('')),
})
.optional()
.nullable();
const locationSearchSchema = z.object({
q: z.string().trim().min(3).max(120),
});
const birdSchema = z.object({
name: z.string().trim().min(1).max(120),
tagId: z.string().trim().max(80).optional().or(z.literal('')),
@@ -367,12 +374,16 @@ const normalizeVerifiedLocationDetails = (value: VerifiedLocationDetailsInput) =
const latitude = typeof value.latitude === 'number' ? Number(value.latitude.toFixed(4)) : null;
const longitude = typeof value.longitude === 'number' ? Number(value.longitude.toFixed(4)) : null;
const precision = value.precision ?? (city ? 'city' : region ? 'region' : country ? 'country' : null);
const label = value.label?.trim() || [city, region, country].filter(Boolean).join(', ') || null;
const provider = value.provider ?? null;
const providerPlaceId = value.providerPlaceId?.trim() || null;
if (!city && !region && !country && !countryCode && latitude === null && longitude === null) {
if (!label && !city && !region && !country && !countryCode && latitude === null && longitude === null) {
return null;
}
return {
label,
city,
region,
country,
@@ -380,12 +391,93 @@ const normalizeVerifiedLocationDetails = (value: VerifiedLocationDetailsInput) =
latitude,
longitude,
precision,
provider,
providerPlaceId,
verifiedAt: new Date().toISOString(),
};
};
const formatVerifiedLocationLabel = (details: ReturnType<typeof normalizeVerifiedLocationDetails>) =>
details ? [details.city, details.region, details.country].filter(Boolean).join(', ') || null : null;
details ? details.label || [details.city, details.region, details.country].filter(Boolean).join(', ') || null : null;
type VerifiedLocationSearchResult = NonNullable<ReturnType<typeof normalizeVerifiedLocationDetails>>;
type MapboxGeocodeFeature = {
id?: string;
geometry?: {
coordinates?: [number, number];
};
properties?: {
mapbox_id?: string;
feature_type?: string;
name?: string;
full_address?: string;
place_formatted?: string;
coordinates?: {
longitude?: number;
latitude?: number;
};
context?: {
place?: { name?: string };
locality?: { name?: string };
region?: { name?: string; region_code?: string; region_code_full?: string };
country?: { name?: string; country_code?: string };
};
};
};
type MapboxGeocodeResponse = {
features?: MapboxGeocodeFeature[];
message?: string;
};
const mapboxAccessToken = process.env.MAPBOX_ACCESS_TOKEN?.trim() ?? '';
const allowedMapboxLocationTypes = new Set(['place', 'locality', 'region', 'country']);
const mapboxLocationCache = new Map<string, { expiresAt: number; results: VerifiedLocationSearchResult[] }>();
const mapboxLocationCacheTtlMs = 24 * 60 * 60 * 1000;
const getMapboxContextName = (feature: MapboxGeocodeFeature, key: 'place' | 'locality' | 'region' | 'country') =>
feature.properties?.context?.[key]?.name?.trim() || null;
const normalizeMapboxLocationFeature = (feature: MapboxGeocodeFeature): VerifiedLocationSearchResult | null => {
const properties = feature.properties;
const featureType = properties?.feature_type;
if (!featureType || !allowedMapboxLocationTypes.has(featureType)) {
return null;
}
const name = properties.name?.trim() || null;
const contextPlace = getMapboxContextName(feature, 'place');
const contextLocality = getMapboxContextName(feature, 'locality');
const contextRegion = getMapboxContextName(feature, 'region');
const contextCountry = getMapboxContextName(feature, 'country');
const city = featureType === 'place' || featureType === 'locality' ? name : contextPlace || contextLocality;
const region = featureType === 'region' ? name : contextRegion;
const country = featureType === 'country' ? name : contextCountry;
const countryCode = properties.context?.country?.country_code?.trim().toUpperCase() || null;
const longitude = properties.coordinates?.longitude ?? feature.geometry?.coordinates?.[0] ?? null;
const latitude = properties.coordinates?.latitude ?? feature.geometry?.coordinates?.[1] ?? null;
const label = [city, region, country].filter(Boolean).join(', ') || properties.full_address || properties.place_formatted || name || null;
const precision = featureType === 'country' ? 'country' : featureType === 'region' ? 'region' : 'city';
if (!label || typeof latitude !== 'number' || typeof longitude !== 'number') {
return null;
}
return normalizeVerifiedLocationDetails({
label,
city: city ?? '',
region: region ?? '',
country: country ?? '',
countryCode: countryCode ?? '',
latitude,
longitude,
precision,
provider: 'mapbox',
providerPlaceId: properties.mapbox_id || feature.id || '',
});
};
const weightSchema = z.object({
weightGrams: z.coerce.number().positive().max(10000),
@@ -966,6 +1058,13 @@ const lostBirdReportLimiter = rateLimit({
legacyHeaders: false,
message: { error: 'Too many found bird reports. Please try again later.' },
});
const locationSearchLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 40,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many location searches. Please try again later.' },
});
app.post('/api/billing/stripe/webhook', express.raw({ type: 'application/json' }), async (req: Request, res: Response) => {
if (!stripeWebhookSecret) {
res.status(503).json({ error: 'Stripe webhook is not configured.' });
@@ -3652,6 +3751,61 @@ app.get('/api/audit-log', requireAuth, requireSessionAuth, requireWorkspaceRole(
}
});
app.get('/api/locations/search', requireAuth, locationSearchLimiter, async (req: Request, res: Response, next: NextFunction) => {
const parsed = locationSearchSchema.safeParse(req.query);
if (!parsed.success) {
res.status(400).json({ error: 'Enter at least 3 characters to search for a location.' });
return;
}
if (!mapboxAccessToken) {
res.status(503).json({ error: 'Location search is not configured.' });
return;
}
const query = parsed.data.q.replace(/\s+/g, ' ').trim();
const cacheKey = query.toLocaleLowerCase();
const cached = mapboxLocationCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
res.json({ results: cached.results });
return;
}
try {
const searchUrl = new URL('https://api.mapbox.com/search/geocode/v6/forward');
searchUrl.searchParams.set('q', query);
searchUrl.searchParams.set('access_token', mapboxAccessToken);
searchUrl.searchParams.set('autocomplete', 'false');
searchUrl.searchParams.set('types', 'place,locality,region,country');
searchUrl.searchParams.set('limit', '5');
searchUrl.searchParams.set('permanent', 'true');
const mapboxResponse = await fetch(searchUrl);
const data = (await mapboxResponse.json().catch(() => null)) as MapboxGeocodeResponse | null;
if (!mapboxResponse.ok) {
res.status(502).json({ error: data?.message || 'Location search failed.' });
return;
}
const results = (data?.features ?? [])
.map(normalizeMapboxLocationFeature)
.filter((result): result is VerifiedLocationSearchResult => result !== null)
.filter((result, index, allResults) => allResults.findIndex((entry) => entry.providerPlaceId === result.providerPlaceId) === index);
mapboxLocationCache.set(cacheKey, {
expiresAt: Date.now() + mapboxLocationCacheTtlMs,
results,
});
res.json({ results });
} catch (error) {
next(error);
}
});
app.get('/api/birds', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const [birds, memorializedBirds] = await Promise.all([
+15 -1
View File
@@ -208,7 +208,21 @@ export const createBirdTimelineEvent = async ({
created_by_user_id,
location_details
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, COALESCE($9::text, $6::text, $5::text), $10, COALESCE($11::date, CURRENT_DATE), $12, $13)
VALUES (
$1,
$2,
$3,
$4,
$5::varchar(160),
$6::varchar(160),
$7::varchar(320),
$8::varchar(320),
COALESCE($9::varchar(160), $6::varchar(160), $5::varchar(160)),
$10,
COALESCE($11::date, CURRENT_DATE),
$12,
$13
)
RETURNING id, bird_id, event_type, from_workspace_id, to_workspace_id, from_workspace_name, to_workspace_name, from_owner_email, to_owner_email, location_label, location_details, note, event_date::text, created_by_user_id, created_at`,
[
birdId,
+2
View File
@@ -55,6 +55,7 @@ services:
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
@@ -136,6 +137,7 @@ services:
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
+2
View File
@@ -53,6 +53,7 @@ services:
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
@@ -129,6 +130,7 @@ services:
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
MAPBOX_ACCESS_TOKEN: ${MAPBOX_ACCESS_TOKEN:-}
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
+195 -209
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
import birdSilhouette from './assets/bird-silhouette.jpg';
import flockPalLandingArt from './assets/flockpal-landing-art.png';
import flockPalTextArt from './assets/flockpal-text.png';
@@ -17,6 +17,7 @@ type IntegrationTokenScope = 'read_only' | 'read_write';
type BirdGender = 'unknown' | 'male' | 'female' | 'male_dna' | 'female_dna';
type VerifiedLocationDetails = {
label?: string | null;
city: string;
region: string;
country: string;
@@ -24,9 +25,18 @@ type VerifiedLocationDetails = {
latitude: number | null;
longitude: number | null;
precision: 'city' | 'region' | 'country';
provider?: 'mapbox' | null;
providerPlaceId?: string | null;
verifiedAt?: string;
};
type LocationSearchState = {
query: string;
results: VerifiedLocationDetails[];
searching: boolean;
error: string;
};
type Bird = {
id: string;
workspaceId?: number;
@@ -713,6 +723,7 @@ const parseBirdImportRows = (rows: Record<string, unknown>[]): BirdImportPreview
};
const emptyVerifiedLocationDetails: VerifiedLocationDetails = {
label: '',
city: '',
region: '',
country: '',
@@ -720,11 +731,21 @@ const emptyVerifiedLocationDetails: VerifiedLocationDetails = {
latitude: null,
longitude: null,
precision: 'city',
provider: null,
providerPlaceId: '',
};
const emptyLocationSearchState: LocationSearchState = {
query: '',
results: [],
searching: false,
error: '',
};
const normalizeVerifiedLocationDetails = (details: Partial<VerifiedLocationDetails> | null | undefined): VerifiedLocationDetails => ({
...emptyVerifiedLocationDetails,
...details,
label: details?.label ?? '',
city: details?.city ?? '',
region: details?.region ?? '',
country: details?.country ?? '',
@@ -732,10 +753,95 @@ const normalizeVerifiedLocationDetails = (details: Partial<VerifiedLocationDetai
latitude: typeof details?.latitude === 'number' ? details.latitude : null,
longitude: typeof details?.longitude === 'number' ? details.longitude : null,
precision: details?.precision ?? 'city',
provider: details?.provider ?? null,
providerPlaceId: details?.providerPlaceId ?? '',
});
const formatVerifiedLocationLabel = (details: VerifiedLocationDetails) =>
[details.city.trim(), details.region.trim(), details.country.trim()].filter(Boolean).join(', ');
details.label?.trim() || [details.city.trim(), details.region.trim(), details.country.trim()].filter(Boolean).join(', ');
type VerifiedLocationSearchFieldProps = {
label: string;
location: VerifiedLocationDetails;
fallbackLabel?: string | null;
searchState: LocationSearchState;
onSearchStateChange: (state: LocationSearchState) => void;
onSearch: () => void;
onSelect: (location: VerifiedLocationDetails) => void;
onClear: () => void;
};
const VerifiedLocationSearchField = ({
label,
location,
fallbackLabel,
searchState,
onSearchStateChange,
onSearch,
onSelect,
onClear,
}: VerifiedLocationSearchFieldProps) => {
const selectedLabel = formatVerifiedLocationLabel(location) || fallbackLabel || '';
return (
<div className="verified-location-field wide-field">
<div className="verified-location-label">
<span>{label}</span>
<div className="verified-location-search-row">
<input
aria-label={label}
value={searchState.query}
onChange={(event) => onSearchStateChange({ ...searchState, query: event.target.value, error: '' })}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
onSearch();
}
}}
placeholder="Search city, region, or country"
/>
<button className="secondary-button" type="button" onClick={onSearch} disabled={searchState.searching}>
{searchState.searching ? 'Searching...' : 'Search'}
</button>
</div>
</div>
{selectedLabel ? (
<div className="verified-location-selected">
<div>
<strong>{selectedLabel}</strong>
<small>{location.provider === 'mapbox' ? 'Verified by Mapbox' : 'Saved location'}</small>
</div>
<button className="secondary-button" type="button" onClick={onClear}>
Clear
</button>
</div>
) : null}
{searchState.error ? (
<p className="error-banner" role="alert">
{searchState.error}
</p>
) : null}
{searchState.results.length ? (
<div className="verified-location-results">
{searchState.results.map((result) => {
const resultLabel = formatVerifiedLocationLabel(result);
return (
<button
key={result.providerPlaceId || resultLabel}
className="verified-location-result"
type="button"
onClick={() => onSelect(result)}
>
<strong>{resultLabel}</strong>
<small>{result.precision === 'city' ? 'City-level' : result.precision === 'region' ? 'Region-level' : 'Country-level'}</small>
</button>
);
})}
</div>
) : null}
</div>
);
};
const emptyBirdForm: BirdFormState = {
name: '',
@@ -1846,6 +1952,8 @@ function App() {
const [birdTimelineLoading, setBirdTimelineLoading] = useState(false);
const [savingBirdTimelineEvent, setSavingBirdTimelineEvent] = useState(false);
const [birdTimelineNotice, setBirdTimelineNotice] = useState<{ message: string; kind: 'success' | 'error' } | null>(null);
const [birdLocationSearch, setBirdLocationSearch] = useState<LocationSearchState>(emptyLocationSearchState);
const [timelineLocationSearch, setTimelineLocationSearch] = useState<LocationSearchState>(emptyLocationSearchState);
const [revokingIntegrationTokenId, setRevokingIntegrationTokenId] = useState('');
const [newIntegrationTokenSecret, setNewIntegrationTokenSecret] = useState('');
const [updatingRescueWorkspaceId, setUpdatingRescueWorkspaceId] = useState<number | null>(null);
@@ -1954,6 +2062,7 @@ function App() {
setBirdTimelineEvents([]);
setBirdTimelineEventForm(emptyBirdTimelineEventForm);
setBirdTimelineNotice(null);
setTimelineLocationSearch(emptyLocationSearchState);
}, [selectedBirdId]);
useEffect(() => {
@@ -2893,6 +3002,7 @@ function App() {
if (!editingBird) {
setEditingBirdId('');
setBirdForm(emptyBirdForm);
setBirdLocationSearch(emptyLocationSearchState);
setBirdPhotoName('');
setPhotoCrop(null);
setPhotoDrag(null);
@@ -2900,6 +3010,7 @@ function App() {
}
setBirdForm(toBirdForm(editingBird));
setBirdLocationSearch(emptyLocationSearchState);
setBirdPhotoName('');
setPhotoCrop(null);
setPhotoDrag(null);
@@ -2928,6 +3039,7 @@ function App() {
setEditingBirdId('');
setBirdEditorOpen(true);
setBirdForm(emptyBirdForm);
setBirdLocationSearch(emptyLocationSearchState);
setBirdPhotoName('');
setPhotoCrop(null);
setPhotoDrag(null);
@@ -2935,6 +3047,42 @@ function App() {
setActivePage('flock');
};
const searchVerifiedLocations = async (
searchState: LocationSearchState,
setSearchState: Dispatch<SetStateAction<LocationSearchState>>,
) => {
const query = searchState.query.replace(/\s+/g, ' ').trim();
if (query.length < 3) {
setSearchState((current) => ({ ...current, error: 'Enter at least 3 characters to search.', results: [] }));
return;
}
setSearchState((current) => ({ ...current, searching: true, error: '', results: [] }));
try {
const response = await apiFetch(`/locations/search?q=${encodeURIComponent(query)}`, authToken);
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to search locations.'));
}
const data = (await readJsonSafely<{ results?: VerifiedLocationDetails[] }>(response)) ?? {};
setSearchState((current) => ({
...current,
searching: false,
results: (data.results ?? []).map(normalizeVerifiedLocationDetails),
error: data.results?.length ? '' : 'No matching city, region, or country found.',
}));
} catch (searchError) {
setSearchState((current) => ({
...current,
searching: false,
error: searchError instanceof Error ? searchError.message : 'Unable to search locations.',
}));
}
};
const handleAuthSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError('');
@@ -6580,105 +6728,23 @@ function App() {
<p className="eyebrow">Location</p>
<h3>Mappable location</h3>
</div>
<label>
City
<input
value={birdForm.locationDetails.city}
onChange={(event) =>
setBirdForm({
...birdForm,
locationDetails: { ...birdForm.locationDetails, city: event.target.value, precision: 'city' },
})
}
placeholder="City"
/>
</label>
<label>
Region
<input
value={birdForm.locationDetails.region}
onChange={(event) =>
setBirdForm({
...birdForm,
locationDetails: { ...birdForm.locationDetails, region: event.target.value },
})
}
placeholder="State, province, or region"
/>
</label>
<label>
Country
<input
value={birdForm.locationDetails.country}
onChange={(event) =>
setBirdForm({
...birdForm,
locationDetails: { ...birdForm.locationDetails, country: event.target.value },
})
}
placeholder="Country"
/>
</label>
<label>
Country code
<input
value={birdForm.locationDetails.countryCode}
onChange={(event) =>
setBirdForm({
...birdForm,
locationDetails: { ...birdForm.locationDetails, countryCode: event.target.value.toUpperCase().slice(0, 2) },
})
}
placeholder="US"
maxLength={2}
/>
</label>
<label>
Latitude
<input
type="number"
step="0.0001"
min="-90"
max="90"
value={birdForm.locationDetails.latitude ?? ''}
onChange={(event) =>
setBirdForm({
...birdForm,
locationDetails: {
...birdForm.locationDetails,
latitude: event.target.value ? Number(event.target.value) : null,
},
})
}
/>
</label>
<label>
Longitude
<input
type="number"
step="0.0001"
min="-180"
max="180"
value={birdForm.locationDetails.longitude ?? ''}
onChange={(event) =>
setBirdForm({
...birdForm,
locationDetails: {
...birdForm.locationDetails,
longitude: event.target.value ? Number(event.target.value) : null,
},
})
}
/>
</label>
<label className="wide-field">
Display label
<input
value={birdForm.locationLabel}
onChange={(event) => setBirdForm({ ...birdForm, locationLabel: event.target.value })}
placeholder="City, region, or country"
/>
</label>
<VerifiedLocationSearchField
label="Location"
location={birdForm.locationDetails}
fallbackLabel={birdForm.locationLabel}
searchState={birdLocationSearch}
onSearchStateChange={setBirdLocationSearch}
onSearch={() => searchVerifiedLocations(birdLocationSearch, setBirdLocationSearch)}
onSelect={(location) => {
const locationLabel = formatVerifiedLocationLabel(location);
setBirdForm({ ...birdForm, locationDetails: location, locationLabel });
setBirdLocationSearch({ ...emptyLocationSearchState, query: locationLabel });
}}
onClear={() => {
setBirdForm({ ...birdForm, locationDetails: emptyVerifiedLocationDetails, locationLabel: '' });
setBirdLocationSearch(emptyLocationSearchState);
}}
/>
<div className="segmented-field wide-field">
<span>Gender</span>
<div className="segmented-control" role="radiogroup" aria-label="Bird gender">
@@ -7899,6 +7965,9 @@ function App() {
value={birdTimelineEventForm.eventType}
onChange={(event) => {
const eventType = event.target.value as BirdTimelineEventFormState['eventType'];
if (eventType === 'owner_changed') {
setTimelineLocationSearch(emptyLocationSearchState);
}
setBirdTimelineEventForm({
...birdTimelineEventForm,
eventType,
@@ -7924,114 +7993,31 @@ function App() {
/>
</label>
{birdTimelineEventForm.eventType !== 'owner_changed' ? (
<>
<label>
City
<input
value={birdTimelineEventForm.locationDetails.city}
onChange={(event) =>
setBirdTimelineEventForm({
...birdTimelineEventForm,
locationDetails: {
...birdTimelineEventForm.locationDetails,
city: event.target.value,
precision: 'city',
},
})
}
placeholder={selectedBird.locationDetails?.city || 'City'}
/>
</label>
<label>
Region
<input
value={birdTimelineEventForm.locationDetails.region}
onChange={(event) =>
setBirdTimelineEventForm({
...birdTimelineEventForm,
locationDetails: { ...birdTimelineEventForm.locationDetails, region: event.target.value },
})
}
placeholder={selectedBird.locationDetails?.region || 'State or region'}
/>
</label>
<label>
Country
<input
value={birdTimelineEventForm.locationDetails.country}
onChange={(event) =>
setBirdTimelineEventForm({
...birdTimelineEventForm,
locationDetails: { ...birdTimelineEventForm.locationDetails, country: event.target.value },
})
}
placeholder={selectedBird.locationDetails?.country || 'Country'}
/>
</label>
<label>
Country code
<input
value={birdTimelineEventForm.locationDetails.countryCode}
onChange={(event) =>
setBirdTimelineEventForm({
...birdTimelineEventForm,
locationDetails: {
...birdTimelineEventForm.locationDetails,
countryCode: event.target.value.toUpperCase().slice(0, 2),
},
})
}
placeholder={selectedBird.locationDetails?.countryCode || 'US'}
maxLength={2}
/>
</label>
<label>
Latitude
<input
type="number"
step="0.0001"
min="-90"
max="90"
value={birdTimelineEventForm.locationDetails.latitude ?? ''}
onChange={(event) =>
setBirdTimelineEventForm({
...birdTimelineEventForm,
locationDetails: {
...birdTimelineEventForm.locationDetails,
latitude: event.target.value ? Number(event.target.value) : null,
},
})
}
/>
</label>
<label>
Longitude
<input
type="number"
step="0.0001"
min="-180"
max="180"
value={birdTimelineEventForm.locationDetails.longitude ?? ''}
onChange={(event) =>
setBirdTimelineEventForm({
...birdTimelineEventForm,
locationDetails: {
...birdTimelineEventForm.locationDetails,
longitude: event.target.value ? Number(event.target.value) : null,
},
})
}
/>
</label>
<label className="wide-field">
Display label
<input
value={birdTimelineEventForm.locationLabel}
onChange={(event) => setBirdTimelineEventForm({ ...birdTimelineEventForm, locationLabel: event.target.value })}
placeholder={selectedBird.locationLabel || 'City, region, or country'}
/>
</label>
</>
<VerifiedLocationSearchField
label="Location"
location={birdTimelineEventForm.locationDetails}
fallbackLabel={birdTimelineEventForm.locationLabel || selectedBird.locationLabel}
searchState={timelineLocationSearch}
onSearchStateChange={setTimelineLocationSearch}
onSearch={() => searchVerifiedLocations(timelineLocationSearch, setTimelineLocationSearch)}
onSelect={(location) => {
const locationLabel = formatVerifiedLocationLabel(location);
setBirdTimelineEventForm({
...birdTimelineEventForm,
locationDetails: location,
locationLabel,
});
setTimelineLocationSearch({ ...emptyLocationSearchState, query: locationLabel });
}}
onClear={() => {
setBirdTimelineEventForm({
...birdTimelineEventForm,
locationDetails: emptyVerifiedLocationDetails,
locationLabel: '',
});
setTimelineLocationSearch(emptyLocationSearchState);
}}
/>
) : null}
{birdTimelineEventForm.eventType === 'location_updated' ? (
<label className="checkbox-row">
+63 -1
View File
@@ -2040,6 +2040,67 @@ label {
font-size: 0.95rem;
}
.verified-location-field {
display: grid;
gap: 0.75rem;
}
.verified-location-label {
display: grid;
gap: 0.35rem;
font-weight: 600;
}
.verified-location-search-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.65rem;
align-items: end;
}
.verified-location-selected {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.85rem 0.95rem;
border: 1px solid rgba(35, 138, 90, 0.2);
border-radius: 8px;
background: rgba(35, 138, 90, 0.08);
}
.verified-location-selected div {
display: grid;
gap: 0.15rem;
}
.verified-location-selected small,
.verified-location-result small {
color: var(--muted);
}
.verified-location-results {
display: grid;
gap: 0.45rem;
}
.verified-location-result {
display: grid;
gap: 0.15rem;
width: 100%;
padding: 0.8rem 0.9rem;
text-align: left;
color: var(--ink);
border: 1px solid rgba(53, 129, 98, 0.2);
border-radius: 8px;
background: rgba(255, 255, 255, 0.55);
}
.verified-location-result:hover {
border-color: rgba(39, 105, 179, 0.28);
background: rgba(255, 255, 255, 0.78);
}
.toggle-card input[type="checkbox"] {
width: 20px;
height: 20px;
@@ -2374,7 +2435,8 @@ label {
.inline-form,
.profile-hero,
.photo-editor,
.settings-nested-grid {
.settings-nested-grid,
.verified-location-search-row {
grid-template-columns: 1fr;
}