Added mapbox integration
This commit is contained in:
+195
-209
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user