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 ( - {/* 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 }; +}