diff --git a/app/(tabs)/media/index.tsx b/app/(tabs)/media/index.tsx
index 4a63fbb..b0b2909 100644
--- a/app/(tabs)/media/index.tsx
+++ b/app/(tabs)/media/index.tsx
@@ -893,6 +893,19 @@ const FeedVideoItem = memo(function FeedVideoItem({
)}
+ {/* Spot chip */}
+ {post.spot && (
+ router.push(`/(tabs)/spots/${post.spot?._id}`)}
+ >
+
+
+ {post.spot.name}
+
+
+ )}
+
{/* Tags */}
{post.sportTypes && post.sportTypes.length > 0 && (
@@ -1379,4 +1392,24 @@ const styles = StyleSheet.create({
color: 'rgba(255,255,255,0.7)',
fontSize: 12,
},
+ spotChip: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ alignSelf: 'flex-start',
+ gap: 4,
+ maxWidth: '100%',
+ backgroundColor: 'rgba(0,0,0,0.4)',
+ borderWidth: 1,
+ borderColor: `${YELLOW}80`,
+ paddingHorizontal: 10,
+ paddingVertical: 5,
+ borderRadius: 16,
+ marginBottom: 8,
+ },
+ spotChipText: {
+ color: '#fff',
+ fontSize: 13,
+ fontWeight: '600',
+ flexShrink: 1,
+ },
});
diff --git a/app/(tabs)/media/post/[postId].tsx b/app/(tabs)/media/post/[postId].tsx
index d1f467d..ac61b16 100644
--- a/app/(tabs)/media/post/[postId].tsx
+++ b/app/(tabs)/media/post/[postId].tsx
@@ -327,6 +327,29 @@ export default function PostDetailScreen() {
)}
+ {/* Spot chip */}
+ {post.spot && (
+
+ router.push(`/(tabs)/spots/${post.spot?._id}`)}
+ >
+
+
+ {post.spot.name}
+
+ {(post.spot.city || post.spot.state) && (
+
+ {[post.spot.city, post.spot.state].filter(Boolean).join(', ')}
+
+ )}
+
+
+ )}
+
{/* Comments Section */}
@@ -565,6 +588,31 @@ const styles = StyleSheet.create({
fontSize: 15,
lineHeight: 22,
},
+ spotSection: {
+ paddingHorizontal: 20,
+ paddingBottom: 16,
+ },
+ spotChip: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ alignSelf: 'flex-start',
+ maxWidth: '100%',
+ gap: 6,
+ paddingHorizontal: 12,
+ paddingVertical: 8,
+ borderRadius: 20,
+ borderWidth: 1,
+ borderColor: `${YELLOW}60`,
+ },
+ spotChipText: {
+ fontSize: 14,
+ fontWeight: '600',
+ flexShrink: 1,
+ },
+ spotChipMeta: {
+ fontSize: 13,
+ flexShrink: 1,
+ },
commentsSection: {
paddingHorizontal: 20,
paddingTop: 16,
diff --git a/app/(tabs)/media/upload.tsx b/app/(tabs)/media/upload.tsx
index 8142e70..6adef61 100644
--- a/app/(tabs)/media/upload.tsx
+++ b/app/(tabs)/media/upload.tsx
@@ -13,8 +13,10 @@ import {
ActivityIndicator,
Alert,
Dimensions,
+ FlatList,
Image,
KeyboardAvoidingView,
+ Modal,
Platform,
Pressable,
ScrollView,
@@ -25,7 +27,7 @@ import {
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { type CreatePostData, createPost } from '@/lib/api/feed';
-import { getSportTypes, type SportType } from '@/lib/api/spots';
+import { getSportTypes, type SportType, type Spot, searchSpots } from '@/lib/api/spots';
import {
createVideoEntry,
uploadImageToS3,
@@ -61,6 +63,13 @@ export default function UploadScreen() {
const [trickInput, setTrickInput] = useState('');
const [visibility, setVisibility] = useState<'public' | 'homies' | 'private'>('public');
+ // Spot tagging state
+ const [selectedSpot, setSelectedSpot] = useState(null);
+ const [spotModalVisible, setSpotModalVisible] = useState(false);
+ const [spotQuery, setSpotQuery] = useState('');
+ const [spotResults, setSpotResults] = useState([]);
+ const [spotSearching, setSpotSearching] = useState(false);
+
// Upload state
const [uploadStep, setUploadStep] = useState('idle');
const [uploadProgress, setUploadProgress] = useState(0);
@@ -145,6 +154,45 @@ export default function UploadScreen() {
setTricks(tricks.filter((t) => t !== trick));
};
+ // Debounced spot search while the picker modal is open
+ useEffect(() => {
+ if (!spotModalVisible) return;
+ const query = spotQuery.trim();
+ if (query.length < 2) {
+ setSpotResults([]);
+ setSpotSearching(false);
+ return;
+ }
+ let cancelled = false;
+ setSpotSearching(true);
+ const handle = setTimeout(async () => {
+ const response = await searchSpots(query);
+ if (!cancelled) {
+ setSpotResults(response.spots);
+ setSpotSearching(false);
+ }
+ }, 350);
+ return () => {
+ cancelled = true;
+ clearTimeout(handle);
+ };
+ }, [spotQuery, spotModalVisible]);
+
+ const openSpotModal = () => {
+ setSpotQuery('');
+ setSpotResults([]);
+ setSpotModalVisible(true);
+ };
+
+ const handleSelectSpot = (spot: Spot) => {
+ setSelectedSpot(spot);
+ setSpotModalVisible(false);
+ };
+
+ const handleRemoveSpot = () => {
+ setSelectedSpot(null);
+ };
+
const handleSubmit = async () => {
if (!selectedFile) {
setError('Please select a video or image to upload');
@@ -297,6 +345,7 @@ export default function UploadScreen() {
visibility,
duration: processedVideo.duration,
aspectRatio,
+ spotId: selectedSpot?._id,
};
const post = await createPost(postData);
@@ -344,6 +393,7 @@ export default function UploadScreen() {
sportTypes: selectedSports,
tricks,
visibility,
+ spotId: selectedSpot?._id,
};
const post = await createPost(postData);
@@ -527,6 +577,46 @@ export default function UploadScreen() {
)}
+ {/* Tag a Spot */}
+
+ Tag a Spot
+ {selectedSpot ? (
+
+
+
+
+ {selectedSpot.name}
+
+ {(selectedSpot.city || selectedSpot.state) && (
+
+ {[selectedSpot.city, selectedSpot.state].filter(Boolean).join(', ')}
+
+ )}
+
+ {!isUploading && (
+
+
+
+ )}
+
+ ) : (
+
+
+
+ Tag a spot (optional)
+
+
+
+ )}
+
+
{/* Visibility */}
Who can see this?
@@ -625,6 +715,90 @@ export default function UploadScreen() {
+
+ {/* Spot Picker Modal */}
+ setSpotModalVisible(false)}
+ >
+
+
+ Tag a Spot
+ setSpotModalVisible(false)}
+ >
+
+
+
+
+
+
+
+ {spotQuery.length > 0 && (
+ setSpotQuery('')} hitSlop={8}>
+
+
+ )}
+
+
+ {spotSearching ? (
+
+
+
+ ) : (
+ item._id}
+ keyboardShouldPersistTaps="handled"
+ contentContainerStyle={styles.modalListContent}
+ renderItem={({ item }) => (
+ handleSelectSpot(item)}
+ >
+
+
+
+ {item.name}
+
+ {(item.city || item.state) && (
+
+ {[item.city, item.state].filter(Boolean).join(', ')}
+
+ )}
+
+
+ )}
+ ListEmptyComponent={
+
+
+ {spotQuery.trim().length < 2
+ ? 'Type at least 2 characters to search.'
+ : 'No spots found.'}
+
+
+ }
+ />
+ )}
+
+
);
}
@@ -885,4 +1059,97 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '600',
},
+ tagSpotButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 10,
+ paddingHorizontal: 16,
+ paddingVertical: 14,
+ borderRadius: 12,
+ },
+ tagSpotButtonText: {
+ flex: 1,
+ fontSize: 15,
+ fontWeight: '500',
+ },
+ spotChip: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 10,
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: `${YELLOW}60`,
+ },
+ spotChipInfo: {
+ flex: 1,
+ },
+ spotChipName: {
+ fontSize: 15,
+ fontWeight: '600',
+ },
+ spotChipMeta: {
+ fontSize: 13,
+ marginTop: 2,
+ },
+ modalContainer: {
+ flex: 1,
+ },
+ modalHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ },
+ modalTitle: {
+ fontSize: 18,
+ fontWeight: '600',
+ },
+ modalSearchRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ marginHorizontal: 16,
+ marginBottom: 12,
+ paddingHorizontal: 14,
+ paddingVertical: 10,
+ borderRadius: 12,
+ backgroundColor: 'rgba(255,255,255,0.08)',
+ },
+ modalSearchInput: {
+ flex: 1,
+ fontSize: 15,
+ },
+ modalListContent: {
+ paddingHorizontal: 16,
+ },
+ spotResultRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 12,
+ paddingVertical: 14,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ },
+ spotResultInfo: {
+ flex: 1,
+ },
+ spotResultName: {
+ fontSize: 15,
+ fontWeight: '600',
+ },
+ spotResultMeta: {
+ fontSize: 13,
+ marginTop: 2,
+ },
+ modalEmpty: {
+ paddingTop: 40,
+ alignItems: 'center',
+ },
+ modalEmptyText: {
+ fontSize: 14,
+ textAlign: 'center',
+ paddingHorizontal: 40,
+ },
});
diff --git a/app/(tabs)/spots/[spotId].tsx b/app/(tabs)/spots/[spotId].tsx
index 262720c..f35ee86 100644
--- a/app/(tabs)/spots/[spotId].tsx
+++ b/app/(tabs)/spots/[spotId].tsx
@@ -5,6 +5,7 @@
import { Ionicons } from '@expo/vector-icons';
import { Image as ExpoImage } from 'expo-image';
+import * as ImagePicker from 'expo-image-picker';
import { router, useLocalSearchParams } from 'expo-router';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
@@ -25,13 +26,16 @@ import { ShareToHomieModal } from '@/components/share';
import { AddToSpotListModal, SpotMap, SpotReviewsList } from '@/components/spots';
import {
deleteSpot,
+ deleteSpotPhoto,
getSpotById,
isSpotSaved,
+ reportSpotPhoto,
type Spot,
type SpotPhoto,
saveSpot,
unsaveSpot,
updateSpot,
+ uploadSpotPhoto,
} from '@/lib/api/spots';
import { useThemeContext } from '@/lib/providers/ThemeProvider';
import { useAuthStore } from '@/lib/stores/authStore';
@@ -71,9 +75,14 @@ export default function SpotDetailScreen() {
const [savingEdit, setSavingEdit] = useState(false);
const [deleting, setDeleting] = useState(false);
+ // Photo upload state (Google-Maps-style "add a photo" for any logged-in user)
+ const [uploadingPhoto, setUploadingPhoto] = useState(false);
+
// Is the current user the author/owner of this spot?
const currentUserId = user?.id || user?._id;
const isOwner = !!currentUserId && !!spot?.userId && spot.userId === currentUserId;
+ const isAdmin = user?.role === 'admin';
+ const isLoggedIn = !!currentUserId;
// Combined photo gallery: user photos first, then Google photos, else header image
const galleryPhotos = useMemo(() => {
@@ -197,6 +206,168 @@ export default function SpotDetailScreen() {
);
}, [spotId]);
+ // Upload one or more picked photos to this spot, then refetch to refresh gallery.
+ const uploadPickedPhotos = useCallback(
+ async (assets: { uri: string; mimeType: string }[]) => {
+ if (!spotId || assets.length === 0) return;
+ setUploadingPhoto(true);
+ let failed = 0;
+ for (const asset of assets) {
+ const uploaded = await uploadSpotPhoto(spotId, asset.uri, asset.mimeType);
+ if (!uploaded) failed++;
+ }
+ // Refetch the spot so the new photos show up in the gallery.
+ const refreshed = await getSpotById(spotId);
+ if (refreshed) setSpot(refreshed);
+ setUploadingPhoto(false);
+ if (failed > 0) {
+ Alert.alert(
+ 'Some photos failed',
+ `${failed} photo${failed > 1 ? 's' : ''} couldn't be uploaded. Please try again.`,
+ );
+ }
+ },
+ [spotId],
+ );
+
+ // Pick photos from the library and upload them.
+ const handleAddPhotoFromGallery = useCallback(async () => {
+ const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
+ if (status !== 'granted') {
+ Alert.alert('Permission Required', 'Please allow access to your photo library.');
+ return;
+ }
+ const result = await ImagePicker.launchImageLibraryAsync({
+ mediaTypes: ['images'],
+ allowsMultipleSelection: true,
+ quality: 0.8,
+ });
+ if (!result.canceled && result.assets.length > 0) {
+ await uploadPickedPhotos(
+ result.assets.map((asset) => ({
+ uri: asset.uri,
+ mimeType: asset.mimeType || 'image/jpeg',
+ })),
+ );
+ }
+ }, [uploadPickedPhotos]);
+
+ // Capture a photo with the camera and upload it.
+ const handleAddPhotoFromCamera = useCallback(async () => {
+ const { status } = await ImagePicker.requestCameraPermissionsAsync();
+ if (status !== 'granted') {
+ Alert.alert('Permission Required', 'Please allow access to your camera.');
+ return;
+ }
+ const result = await ImagePicker.launchCameraAsync({
+ mediaTypes: ['images'],
+ quality: 0.8,
+ });
+ if (!result.canceled && result.assets[0]) {
+ const asset = result.assets[0];
+ await uploadPickedPhotos([{ uri: asset.uri, mimeType: asset.mimeType || 'image/jpeg' }]);
+ }
+ }, [uploadPickedPhotos]);
+
+ // "Add Photo" affordance — any logged-in user. Offers Gallery or Camera.
+ const handleAddPhoto = useCallback(() => {
+ if (!isLoggedIn) {
+ Alert.alert('Sign In Required', 'Please sign in to add a photo to this spot.', [
+ { text: 'Cancel', style: 'cancel' },
+ { text: 'Sign In', onPress: () => router.push('/(auth)/login') },
+ ]);
+ return;
+ }
+ if (uploadingPhoto) return;
+ Alert.alert('Add Photo', 'Add a photo to this spot', [
+ { text: 'Choose from Gallery', onPress: handleAddPhotoFromGallery },
+ { text: 'Take Photo', onPress: handleAddPhotoFromCamera },
+ { text: 'Cancel', style: 'cancel' },
+ ]);
+ }, [isLoggedIn, uploadingPhoto, handleAddPhotoFromGallery, handleAddPhotoFromCamera]);
+
+ // Report a user photo, then thank the reporter.
+ const handleReportPhoto = useCallback(
+ (photoKey: string) => {
+ if (!spotId) return;
+ Alert.alert('Report Photo', 'Report this photo as inappropriate or incorrect?', [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Report',
+ style: 'destructive',
+ onPress: async () => {
+ const ok = await reportSpotPhoto(spotId, photoKey);
+ if (ok) {
+ // A report may have crossed the auto-hide threshold — refresh so a
+ // now-hidden photo disappears.
+ const refreshed = await getSpotById(spotId);
+ if (refreshed) setSpot(refreshed);
+ }
+ Alert.alert(
+ ok ? 'Thanks' : 'Error',
+ ok ? "Thanks — we'll review this." : 'Failed to report photo. Please try again.',
+ );
+ },
+ },
+ ]);
+ },
+ [spotId],
+ );
+
+ // Delete a user photo (uploader/owner or admin), then refetch.
+ const handleDeletePhoto = useCallback(
+ (photoKey: string) => {
+ if (!spotId) return;
+ Alert.alert('Delete Photo', 'Delete this photo? This cannot be undone.', [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Delete',
+ style: 'destructive',
+ onPress: async () => {
+ const ok = await deleteSpotPhoto(spotId, photoKey);
+ if (ok) {
+ const refreshed = await getSpotById(spotId);
+ if (refreshed) setSpot(refreshed);
+ } else {
+ Alert.alert('Error', 'Failed to delete photo. Please try again.');
+ }
+ },
+ },
+ ]);
+ },
+ [spotId],
+ );
+
+ // Long-press / "..." on a user photo opens the report/delete menu.
+ const handlePhotoActions = useCallback(
+ (photo: SpotPhoto) => {
+ // Google photos have no key and are neither reportable nor deletable.
+ if (!photo.key) return;
+ const photoKey = photo.key;
+ const isOwnPhoto = !!currentUserId && photo.userId === currentUserId;
+ const canDelete = isAdmin || isOwnPhoto;
+ const buttons: {
+ text: string;
+ style?: 'default' | 'cancel' | 'destructive';
+ onPress?: () => void;
+ }[] = [];
+ // You can't report your own photo — you delete it instead.
+ if (!isOwnPhoto) {
+ buttons.push({ text: 'Report photo', onPress: () => handleReportPhoto(photoKey) });
+ }
+ if (canDelete) {
+ buttons.push({
+ text: 'Delete',
+ style: 'destructive',
+ onPress: () => handleDeletePhoto(photoKey),
+ });
+ }
+ buttons.push({ text: 'Cancel', style: 'cancel' });
+ Alert.alert('Photo', undefined, buttons);
+ },
+ [isAdmin, currentUserId, handleReportPhoto, handleDeletePhoto],
+ );
+
// Parse tags into features array
const getFeatures = (): string[] => {
if (!spot?.tags) return [];
@@ -311,6 +482,17 @@ export default function SpotDetailScreen() {
{/* Action buttons */}
+
+ {uploadingPhoto ? (
+
+ ) : (
+
+ )}
+
setIsFavorite(!isFavorite)}>
- {/* Photo Gallery (user + Google photos) */}
- {galleryPhotos.length > 1 && (
-
- {galleryPhotos.map((photo, index) => (
-
+ {/* Skip index 0 — it's already shown as the large header image. */}
+ {galleryPhotos.slice(1).map((photo, index) => {
+ // Only user photos (which carry a `key`) can be reported/deleted.
+ const isUserPhoto = !!photo.key;
+ return (
+
- ))}
-
- )}
+ onLongPress={isUserPhoto ? () => handlePhotoActions(photo) : undefined}
+ delayLongPress={300}
+ >
+
+ {isUserPhoto && (
+ handlePhotoActions(photo)}
+ hitSlop={8}
+ >
+
+
+ )}
+
+ );
+ })}
+
+ {/* Add Photo tile — available to any logged-in user */}
+
+ {uploadingPhoto ? (
+
+ ) : (
+ <>
+
+
+ Add Photo
+
+ >
+ )}
+
+
{/* Content */}
@@ -845,6 +1065,31 @@ const styles = StyleSheet.create({
height: 80,
borderRadius: 12,
},
+ galleryMoreButton: {
+ position: 'absolute',
+ top: 6,
+ right: 6,
+ width: 26,
+ height: 26,
+ borderRadius: 13,
+ backgroundColor: 'rgba(0,0,0,0.55)',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ galleryAddTile: {
+ width: 110,
+ height: 80,
+ borderRadius: 12,
+ borderWidth: 1,
+ borderStyle: 'dashed',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 4,
+ },
+ galleryAddText: {
+ fontSize: 12,
+ fontWeight: '600',
+ },
// Content
content: {
diff --git a/app/(tabs)/spots/index.tsx b/app/(tabs)/spots/index.tsx
index 4f23229..04c0f6f 100644
--- a/app/(tabs)/spots/index.tsx
+++ b/app/(tabs)/spots/index.tsx
@@ -38,13 +38,16 @@ import {
getSpotCategories,
getSpots,
type MapPin,
+ type PlaceSearchResult,
type SportType,
type Spot,
type SpotCategory,
saveSpot,
+ searchPlaces,
+ searchSpots,
unsaveSpot,
} from '@/lib/api/spots';
-import { projectToScreen } from '@/lib/mapProjection';
+import { projectToScreenXY } from '@/lib/mapProjection';
import { useThemeContext } from '@/lib/providers/ThemeProvider';
import { useAuthStore } from '@/lib/stores/authStore';
import type { CreateSpotListInput, SpotList } from '@/types/spots';
@@ -145,6 +148,14 @@ export default function SpotsScreen() {
const [isMapFullscreen, setIsMapFullscreen] = useState(false);
const mapPinsDebounceRef = useRef | null>(null);
+ // Google-Maps-style in-map search: type a query, get matching places (Google
+ // Places, biased to the current viewport) + our spots, tap one to fly there.
+ const [mapSearchVisible, setMapSearchVisible] = useState(false);
+ const [mapSearchText, setMapSearchText] = useState('');
+ const [mapSearchPlaces, setMapSearchPlaces] = useState([]);
+ const [mapSearchSpots, setMapSearchSpots] = useState([]);
+ const [mapSearchLoading, setMapSearchLoading] = useState(false);
+
// My Spots state
// Sub-view within the My Spots tab: the flat authored+saved list ('spots')
// is primary; collections (named lists) live behind a toggle.
@@ -253,6 +264,81 @@ export default function SpotsScreen() {
return () => clearTimeout(timer);
}, [searchQuery]);
+ const openMapSearch = useCallback(() => {
+ setMapSearchVisible(true);
+ }, []);
+
+ const closeMapSearch = useCallback(() => {
+ setMapSearchVisible(false);
+ setMapSearchText('');
+ setMapSearchPlaces([]);
+ setMapSearchSpots([]);
+ }, []);
+
+ // In-map search: debounce the query, then fetch Google Places (biased to the
+ // current viewport center) and matching spots in parallel.
+ useEffect(() => {
+ if (!mapSearchVisible) return;
+ const q = mapSearchText.trim();
+ if (q.length < 2) {
+ setMapSearchPlaces([]);
+ setMapSearchSpots([]);
+ setMapSearchLoading(false);
+ return;
+ }
+ let cancelled = false;
+ setMapSearchLoading(true);
+ const timer = setTimeout(async () => {
+ const [places, spotsRes] = await Promise.all([
+ searchPlaces(q, region.latitude, region.longitude),
+ searchSpots(q),
+ ]);
+ if (cancelled) return;
+ setMapSearchPlaces(places.slice(0, 6));
+ setMapSearchSpots(spotsRes.spots.slice(0, 6));
+ setMapSearchLoading(false);
+ }, 350);
+ return () => {
+ cancelled = true;
+ clearTimeout(timer);
+ };
+ }, [mapSearchText, mapSearchVisible, region.latitude, region.longitude]);
+
+ // Fly the map to a searched Google place.
+ const handleSelectSearchPlace = useCallback(
+ (place: PlaceSearchResult) => {
+ closeMapSearch();
+ mapRef.current?.animateToRegion(
+ {
+ latitude: place.latitude,
+ longitude: place.longitude,
+ latitudeDelta: 0.02,
+ longitudeDelta: 0.02,
+ },
+ 500,
+ );
+ },
+ [closeMapSearch],
+ );
+
+ // Fly to a matched spot and select it (shows its card).
+ const handleSelectSearchSpot = useCallback(
+ (spot: Spot) => {
+ closeMapSearch();
+ setSelectedSpot(spot);
+ mapRef.current?.animateToRegion(
+ {
+ latitude: spot.latitude,
+ longitude: spot.longitude,
+ latitudeDelta: 0.02,
+ longitudeDelta: 0.02,
+ },
+ 500,
+ );
+ },
+ [closeMapSearch],
+ );
+
// Fetch spots when filters change
const fetchSpots = useCallback(async () => {
setLoading(true);
@@ -509,8 +595,9 @@ export default function SpotsScreen() {
{activeTab === 'allSpots' ? (
<>
- {/* Search Bar — hidden in fullscreen map. */}
- {!isMapFullscreen && (
+ {/* Search Bar — list view only. Map view has its own in-map search
+ (the magnifying-glass map control), so this top bar is redundant there. */}
+ {viewMode === 'list' && (
@@ -645,36 +732,46 @@ export default function SpotsScreen() {
{mapReady && mapLayout.width > 0 && (
{clusters.map((item) => {
- const pt = projectToScreen(
+ const pt = projectToScreenXY(
item.latitude,
item.longitude,
projectionRegion,
mapLayout,
);
if (!pt) return null;
+ // NEVER unmount an off-screen marker while panning — that
+ // desyncs Fabric's touch registry and hard-crashes on the New
+ // Architecture (RN #53303). Keep it mounted but hidden and
+ // non-interactive instead.
+ const hidden = !pt.onScreen;
if (item.type === 'cluster') {
return (
- mapRef.current?.animateToRegion(
- getClusterExpansionRegion(
- item.clusterId as number,
- item.latitude,
- item.longitude,
- ),
- 300,
- )
- }
+ onPress={() => {
+ // Defer the camera animation past this touch's end so
+ // re-projection/re-clustering can't move or unmount the
+ // pressed marker while UIKit is still finalizing it.
+ const target = getClusterExpansionRegion(
+ item.clusterId as number,
+ item.latitude,
+ item.longitude,
+ );
+ requestAnimationFrame(() =>
+ mapRef.current?.animateToRegion(target, 300),
+ );
+ }}
>
{item.count}
@@ -684,28 +781,35 @@ export default function SpotsScreen() {
}
const selected = selectedSpot?._id === item.pin?._id;
+ const pin = item.pin;
return (
{
- setSelectedSpot(item.pin as unknown as Spot);
- mapRef.current?.animateToRegion(
- {
- latitude: item.latitude,
- longitude: item.longitude,
- latitudeDelta: 0.05,
- longitudeDelta: 0.05,
- },
- 300,
- );
+ const lat = item.latitude;
+ const lng = item.longitude;
+ requestAnimationFrame(() => {
+ setSelectedSpot(pin as unknown as Spot);
+ mapRef.current?.animateToRegion(
+ {
+ latitude: lat,
+ longitude: lng,
+ latitudeDelta: 0.05,
+ longitudeDelta: 0.05,
+ },
+ 300,
+ );
+ });
}}
>
@@ -760,44 +864,13 @@ export default function SpotsScreen() {
- {/* Zoom In */}
- {
- mapRef.current?.getCamera().then((camera) => {
- if (camera) {
- mapRef.current?.animateCamera(
- {
- ...camera,
- zoom: (camera.zoom || 10) + 1,
- },
- { duration: 300 },
- );
- }
- });
- }}
- >
-
-
-
- {/* Zoom Out */}
+ {/* Search places & spots (Google-Maps style). Replaces the +/-
+ zoom buttons — pinch-to-zoom covers zooming. */}
{
- mapRef.current?.getCamera().then((camera) => {
- if (camera) {
- mapRef.current?.animateCamera(
- {
- ...camera,
- zoom: (camera.zoom || 10) - 1,
- },
- { duration: 300 },
- );
- }
- });
- }}
+ onPress={openMapSearch}
>
-
+
{/* Toggle full-screen map */}
@@ -809,15 +882,17 @@ export default function SpotsScreen() {
- {/* Selected spot card or spots preview — lifted above the bottom
+ {/* Selected-spot card — shown ONLY when a pin is tapped. Tapping
+ the map clears the selection (onPress on MapView) and hides it;
+ tapping another pin swaps in that spot. Lifted above the bottom
safe area when fullscreen (no tab bar padding then). */}
-
- {selectedSpot ? (
+ {selectedSpot && (
+
handleToggleSave(selectedSpot)}
onOpenListPicker={() => handleOpenListPicker(selectedSpot)}
/>
- ) : spots.length > 0 ? (
-
- {spots.slice(0, 5).map((spot) => (
- {
- setSelectedSpot(spot);
- mapRef.current?.animateToRegion(
- {
- latitude: spot.latitude,
- longitude: spot.longitude,
- latitudeDelta: 0.05,
- longitudeDelta: 0.05,
- },
- 500,
- );
- }}
- onToggleSave={() => handleToggleSave(spot)}
- onOpenListPicker={() => handleOpenListPicker(spot)}
- />
- ))}
-
- ) : null}
-
+
+ )}
) : (
// List View
@@ -1271,6 +1317,118 @@ export default function SpotsScreen() {
}
}}
/>
+
+ {/* Google-Maps-style in-map search: places (Google) + our spots. */}
+
+
+
+
+
+
+ {mapSearchText.length > 0 && (
+ setMapSearchText('')} hitSlop={8}>
+
+
+ )}
+
+
+ Cancel
+
+
+
+
+ {mapSearchLoading && }
+
+ {mapSearchSpots.length > 0 && (
+ Spots
+ )}
+ {mapSearchSpots.map((s) => (
+ handleSelectSearchSpot(s)}
+ >
+
+
+
+
+
+ {s.name}
+
+
+ {[s.city, s.state].filter(Boolean).join(', ') || 'Spot'}
+
+
+
+ ))}
+
+ {mapSearchPlaces.length > 0 && (
+ Places
+ )}
+ {mapSearchPlaces.map((p) => (
+ handleSelectSearchPlace(p)}
+ >
+
+
+
+ {p.name}
+
+
+ {p.address}
+
+
+
+ ))}
+
+ {!mapSearchLoading &&
+ mapSearchText.trim().length >= 2 &&
+ mapSearchSpots.length === 0 &&
+ mapSearchPlaces.length === 0 && (
+
+ No results for “{mapSearchText.trim()}”
+
+ )}
+
+
+
);
}
@@ -1633,6 +1791,76 @@ const styles = StyleSheet.create({
shadowRadius: 5,
elevation: 6,
},
+ // In-map search (Google-Maps style)
+ mapSearchScreen: {
+ flex: 1,
+ },
+ mapSearchHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 12,
+ paddingVertical: 8,
+ gap: 8,
+ },
+ mapSearchBar: {
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ paddingHorizontal: 12,
+ height: 44,
+ borderRadius: 12,
+ borderWidth: 1,
+ },
+ mapSearchInput: {
+ flex: 1,
+ fontSize: 16,
+ },
+ mapSearchCancel: {
+ paddingHorizontal: 4,
+ paddingVertical: 8,
+ },
+ mapSearchResults: {
+ flex: 1,
+ },
+ mapSearchSection: {
+ fontSize: 12,
+ fontWeight: '700',
+ textTransform: 'uppercase',
+ letterSpacing: 0.4,
+ paddingHorizontal: 16,
+ paddingTop: 16,
+ paddingBottom: 6,
+ },
+ mapSearchRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 12,
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ },
+ mapSearchIcon: {
+ width: 32,
+ height: 32,
+ borderRadius: 16,
+ backgroundColor: YELLOW,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ mapSearchRowTitle: {
+ fontSize: 15,
+ fontWeight: '600',
+ },
+ mapSearchRowSub: {
+ fontSize: 13,
+ marginTop: 2,
+ },
+ mapSearchEmpty: {
+ textAlign: 'center',
+ marginTop: 32,
+ fontSize: 15,
+ },
overlayMarker: {
position: 'absolute',
alignItems: 'center',
diff --git a/patches/react-native+0.81.5.patch b/patches/react-native+0.81.5.patch
new file mode 100644
index 0000000..978e27f
--- /dev/null
+++ b/patches/react-native+0.81.5.patch
@@ -0,0 +1,40 @@
+diff --git a/node_modules/react-native/React/Fabric/RCTSurfaceTouchHandler.mm b/node_modules/react-native/React/Fabric/RCTSurfaceTouchHandler.mm
+index a793251..db132bb 100644
+--- a/node_modules/react-native/React/Fabric/RCTSurfaceTouchHandler.mm
++++ b/node_modules/react-native/React/Fabric/RCTSurfaceTouchHandler.mm
+@@ -203,7 +203,10 @@ - (void)_updateTouches:(NSSet *)touches
+ {
+ for (UITouch *touch in touches) {
+ auto iterator = _activeTouches.find(touch);
+- RCTAssert(iterator != _activeTouches.end(), @"Inconsistency between local and UIKit touch registries");
++ // patched (facebook/react-native#53303): the fatal RCTAssert is removed so a
++ // touch that UIKit delivers after its responder view was unmounted mid-gesture
++ // is skipped by the guard below instead of hard-crashing the app. Common on the
++ // map's projected-overlay markers on iOS 26 + New Architecture.
+ if (iterator == _activeTouches.end()) {
+ continue;
+ }
+@@ -216,7 +219,10 @@ - (void)_unregisterTouches:(NSSet *)touches
+ {
+ for (UITouch *touch in touches) {
+ auto iterator = _activeTouches.find(touch);
+- RCTAssert(iterator != _activeTouches.end(), @"Inconsistency between local and UIKit touch registries");
++ // patched (facebook/react-native#53303): the fatal RCTAssert is removed so a
++ // touch that UIKit delivers after its responder view was unmounted mid-gesture
++ // is skipped by the guard below instead of hard-crashing the app. Common on the
++ // map's projected-overlay markers on iOS 26 + New Architecture.
+ if (iterator == _activeTouches.end()) {
+ continue;
+ }
+@@ -233,7 +239,10 @@ - (void)_unregisterTouches:(NSSet *)touches
+
+ for (UITouch *touch in touches) {
+ auto iterator = _activeTouches.find(touch);
+- RCTAssert(iterator != _activeTouches.end(), @"Inconsistency between local and UIKit touch registries");
++ // patched (facebook/react-native#53303): the fatal RCTAssert is removed so a
++ // touch that UIKit delivers after its responder view was unmounted mid-gesture
++ // is skipped by the guard below instead of hard-crashing the app. Common on the
++ // map's projected-overlay markers on iOS 26 + New Architecture.
+ if (iterator == _activeTouches.end()) {
+ continue;
+ }
diff --git a/src/hooks/useMapClusters.ts b/src/hooks/useMapClusters.ts
index a2ac442..ab791b7 100644
--- a/src/hooks/useMapClusters.ts
+++ b/src/hooks/useMapClusters.ts
@@ -63,7 +63,11 @@ export function useMapClusters(pins: MapPin[], region: Region | null) {
if (props.cluster) {
return {
type: 'cluster' as const,
- id: `cluster-${props.cluster_id}`,
+ // Position-derived key (NOT cluster_id — supercluster reassigns
+ // cluster_id across bbox/zoom queries, so a cluster at the same place
+ // would get a new React key and remount its Pressable, which can
+ // desync the Fabric touch registry mid-gesture).
+ id: `cluster-${Math.round(latitude * 1e4)}-${Math.round(longitude * 1e4)}`,
latitude,
longitude,
count: props.point_count as number,
diff --git a/src/lib/api/feed.ts b/src/lib/api/feed.ts
index 77583cf..ab45d35 100644
--- a/src/lib/api/feed.ts
+++ b/src/lib/api/feed.ts
@@ -52,6 +52,16 @@ export interface FeedPost {
status: 'processing' | 'published' | 'removed';
userReactions?: ('love' | 'respect')[];
saved?: boolean;
+ // Linked spot (backend enriches the post with this when spotId is set)
+ spotId?: string;
+ spot?: {
+ _id: string;
+ name: string;
+ city?: string;
+ state?: string;
+ imageURL?: string | null;
+ category?: string;
+ };
createdAt: string;
updatedAt: string;
}
@@ -240,6 +250,7 @@ export interface CreatePostData {
duration?: number;
aspectRatio?: string;
visibility?: 'public' | 'homies' | 'private';
+ spotId?: string;
}
/**
diff --git a/src/lib/api/spots.ts b/src/lib/api/spots.ts
index bf3ce24..5f848c2 100644
--- a/src/lib/api/spots.ts
+++ b/src/lib/api/spots.ts
@@ -417,6 +417,37 @@ export async function uploadSpotPhoto(
}
}
+/**
+ * Report a user-uploaded spot photo for review (any authenticated user).
+ * Returns true on success.
+ */
+export async function reportSpotPhoto(spotId: string, photoKey: string): Promise {
+ try {
+ // The S3 key contains a "/" (e.g. "spots/.jpg"); encode it so it stays
+ // a single path segment and the :photoKey route matches.
+ await apiClient.post(
+ `${ENDPOINTS.spots.detail(spotId)}/photos/${encodeURIComponent(photoKey)}/report`,
+ );
+ return true;
+ } catch (_error) {
+ return false;
+ }
+}
+
+/**
+ * Delete a user-uploaded spot photo (uploader/owner or admin). Returns true on success.
+ */
+export async function deleteSpotPhoto(spotId: string, photoKey: string): Promise {
+ try {
+ await apiClient.delete(
+ `${ENDPOINTS.spots.detail(spotId)}/photos/${encodeURIComponent(photoKey)}`,
+ );
+ return true;
+ } catch (_error) {
+ return false;
+ }
+}
+
// Google Places integration types
export interface PlaceSearchResult {
placeId: string;
diff --git a/src/lib/mapProjection.ts b/src/lib/mapProjection.ts
index f225f60..746db8b 100644
--- a/src/lib/mapProjection.ts
+++ b/src/lib/mapProjection.ts
@@ -38,3 +38,34 @@ export function projectToScreen(
if (x < -60 || x > layout.width + 60 || y < -80 || y > layout.height + 40) return null;
return { x, y };
}
+
+/**
+ * Like projectToScreen, but NEVER returns null for an off-screen point — instead
+ * it returns the coordinates plus an `onScreen` flag. Callers keep the marker
+ * view MOUNTED and merely hide it (opacity/pointerEvents) when off-screen.
+ *
+ * This is critical on the New Architecture: unmounting a touch-target view while
+ * a UIKit touch is still active desyncs Fabric's touch registry and hard-crashes
+ * ("Inconsistency between local and UIKit touch registries", RN #53303). Keeping
+ * markers mounted during pan/zoom removes that trigger.
+ */
+export function projectToScreenXY(
+ lat: number,
+ lng: number,
+ region: ProjRegion | null,
+ layout: { width: number; height: number },
+): { x: number; y: number; onScreen: boolean } | null {
+ if (!region || !layout.width || !layout.height) return null;
+ const { latitude, longitude, latitudeDelta, longitudeDelta } = region;
+
+ const west = longitude - longitudeDelta / 2;
+ const x = ((lng - west) / longitudeDelta) * layout.width;
+
+ const mercY = (l: number) => Math.log(Math.tan(Math.PI / 4 + (l * Math.PI) / 360));
+ const yN = mercY(latitude + latitudeDelta / 2);
+ const yS = mercY(latitude - latitudeDelta / 2);
+ const y = ((yN - mercY(lat)) / (yN - yS)) * layout.height;
+
+ const onScreen = !(x < -60 || x > layout.width + 60 || y < -80 || y > layout.height + 40);
+ return { x, y, onScreen };
+}