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 bdfe8ec..f35ee86 100644 --- a/app/(tabs)/spots/[spotId].tsx +++ b/app/(tabs)/spots/[spotId].tsx @@ -4,39 +4,96 @@ */ 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, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, - Dimensions, - Image, + Alert, Linking, + Modal, Platform, Pressable, ScrollView, StyleSheet, Text, + TextInput, View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { ShareToHomieModal } from '@/components/share'; -import { SpotMap, SpotReviewsList } from '@/components/spots'; -import { getSpotById, type Spot } from '@/lib/api/spots'; +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'; + +const SPOT_CATEGORIES: { id: Spot['category']; label: string }[] = [ + { id: 'park', label: 'Park' }, + { id: 'street', label: 'Street' }, + { id: 'indoor', label: 'Indoor' }, + { id: 'diy', label: 'DIY' }, + { id: 'resort', label: 'Resort' }, + { id: 'other', label: 'Other' }, +]; -const { width: SCREEN_WIDTH } = Dimensions.get('window'); const YELLOW = '#FCF150'; const DARK = '#1a1a1a'; export default function SpotDetailScreen() { const { spotId } = useLocalSearchParams<{ spotId: string }>(); - const { theme, colors, isDark } = useThemeContext(); + const { theme, isDark } = useThemeContext(); + const { user } = useAuthStore(); const [isFavorite, setIsFavorite] = useState(false); const [spot, setSpot] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [shareModalVisible, setShareModalVisible] = useState(false); + // Save state ("My Spots" saved bucket) + const [isSaved, setIsSaved] = useState(false); + const [savePending, setSavePending] = useState(false); + const [listModalVisible, setListModalVisible] = useState(false); + + // Owner edit/delete state + const [editModalVisible, setEditModalVisible] = useState(false); + const [editName, setEditName] = useState(''); + const [editDescription, setEditDescription] = useState(''); + const [editCategory, setEditCategory] = useState('other'); + 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(() => { + if (!spot) return []; + const photos: SpotPhoto[] = [...(spot.userPhotos ?? []), ...(spot.googlePhotos ?? [])]; + if (photos.length === 0 && spot.imageURL) { + photos.push({ url: spot.imageURL }); + } + return photos; + }, [spot]); + // Fetch spot data from backend const loadSpot = useCallback(async () => { if (!spotId) return; @@ -61,6 +118,256 @@ export default function SpotDetailScreen() { loadSpot(); }, [loadSpot]); + // Load the current saved state for this spot + useEffect(() => { + if (!spotId) return; + let cancelled = false; + isSpotSaved(spotId).then((saved) => { + if (!cancelled) setIsSaved(saved); + }); + return () => { + cancelled = true; + }; + }, [spotId]); + + // One-tap save toggle + const handleToggleSave = useCallback(async () => { + if (!spotId || savePending) return; + setSavePending(true); + const next = !isSaved; + // Optimistic update + setIsSaved(next); + const ok = next ? await saveSpot(spotId) : await unsaveSpot(spotId); + if (ok) { + if (next) Alert.alert('Saved', 'Saved to My Spots'); + } else { + // Revert on failure + setIsSaved(!next); + Alert.alert('Error', next ? 'Failed to save spot' : 'Failed to remove spot'); + } + setSavePending(false); + }, [spotId, isSaved, savePending]); + + // Long-press opens the named-list picker + const handleOpenListPicker = useCallback(() => { + setListModalVisible(true); + }, []); + + // Open the inline edit modal, prefilled from the loaded spot + const handleOpenEdit = useCallback(() => { + if (!spot) return; + setEditName(spot.name ?? ''); + setEditDescription(spot.description ?? ''); + setEditCategory(spot.category ?? 'other'); + setEditModalVisible(true); + }, [spot]); + + // Save inline edits + const handleSaveEdit = useCallback(async () => { + if (!spotId || !editName.trim() || savingEdit) return; + setSavingEdit(true); + const updated = await updateSpot(spotId, { + name: editName.trim(), + description: editDescription.trim(), + category: editCategory, + }); + setSavingEdit(false); + if (updated) { + setSpot(updated); + setEditModalVisible(false); + } else { + Alert.alert('Error', 'Failed to update spot'); + } + }, [spotId, editName, editDescription, editCategory, savingEdit]); + + // Delete this spot (owner only), with confirmation + const handleDelete = useCallback(() => { + if (!spotId) return; + Alert.alert( + 'Delete Spot', + 'Are you sure you want to delete this spot? This cannot be undone.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { + setDeleting(true); + const ok = await deleteSpot(spotId); + setDeleting(false); + if (ok) { + router.back(); + } else { + Alert.alert('Error', 'Failed to delete spot'); + } + }, + }, + ], + ); + }, [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 []; @@ -152,8 +459,13 @@ export default function SpotDetailScreen() { > {/* Header Image */} - {spot.imageURL ? ( - + {galleryPhotos.length > 0 ? ( + ) : ( @@ -170,6 +482,17 @@ export default function SpotDetailScreen() { {/* Action buttons */} + + {uploadingPhoto ? ( + + ) : ( + + )} + setIsFavorite(!isFavorite)}> + {/* Photo Gallery (user + Google photos) + "Add Photo" tile */} + + {/* 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 ( + handlePhotoActions(photo) : undefined} + delayLongPress={300} + > + + {isUserPhoto && ( + handlePhotoActions(photo)} + hitSlop={8} + > + + + )} + + ); + })} + + {/* Add Photo tile — available to any logged-in user */} + + {uploadingPhoto ? ( + + ) : ( + <> + + + Add Photo + + + )} + + + {/* Content */} + {/* Owner Actions */} + {isOwner && ( + + + + Edit + + + {deleting ? ( + + ) : ( + <> + + Delete + + )} + + + )} + {/* Title & Rating Row */} @@ -404,12 +812,21 @@ export default function SpotDetailScreen() { { - // TODO: Add to spotlist - }} + style={[ + styles.addButton, + { borderColor: isSaved ? YELLOW : theme.border }, + isSaved && { backgroundColor: `${YELLOW}20` }, + ]} + onPress={handleToggleSave} + onLongPress={handleOpenListPicker} + delayLongPress={300} + disabled={savePending} > - + @@ -430,6 +847,108 @@ export default function SpotDetailScreen() { }} /> )} + + {/* Add to Spot List Modal (long-press on save) */} + {spot && ( + setListModalVisible(false)} + onSuccess={() => { + // A saved-to-a-list spot also lives in "My Spots"; reflect saved state. + setIsSaved(true); + }} + /> + )} + + {/* Inline Edit Modal (owner only) */} + setEditModalVisible(false)} + > + setEditModalVisible(false)}> + e.stopPropagation()} + > + + Edit Spot + setEditModalVisible(false)}> + + + + + Name + + + Description + + + Category + + {SPOT_CATEGORIES.map((cat) => { + const selected = editCategory === cat.id; + return ( + setEditCategory(cat.id)} + > + + {cat.label} + + + ); + })} + + + + {savingEdit ? ( + + ) : ( + Save Changes + )} + + + + ); } @@ -533,12 +1052,142 @@ const styles = StyleSheet.create({ color: DARK, }, + // Photo Gallery + gallery: { + marginTop: 12, + }, + galleryContent: { + paddingHorizontal: 20, + gap: 10, + }, + galleryThumb: { + width: 110, + 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: { paddingHorizontal: 20, paddingTop: 20, }, + // Owner Actions + ownerActions: { + flexDirection: 'row', + gap: 12, + marginBottom: 20, + }, + ownerButton: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + height: 44, + borderRadius: 12, + }, + ownerButtonText: { + fontSize: 15, + fontWeight: '600', + }, + + // Edit Modal + editOverlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.6)', + justifyContent: 'flex-end', + }, + editSheet: { + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + paddingHorizontal: 20, + paddingTop: 8, + paddingBottom: 40, + }, + editHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 16, + }, + editTitle: { + fontSize: 18, + fontWeight: '700', + }, + editLabel: { + fontSize: 13, + fontWeight: '600', + marginTop: 12, + marginBottom: 6, + }, + editInput: { + borderWidth: 1, + borderRadius: 12, + paddingHorizontal: 14, + paddingVertical: 12, + fontSize: 15, + }, + editTextarea: { + minHeight: 90, + textAlignVertical: 'top', + }, + editCategoryRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + editCategoryChip: { + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 20, + borderWidth: 1, + }, + editCategoryText: { + fontSize: 14, + fontWeight: '500', + }, + editSaveButton: { + height: 52, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + marginTop: 24, + }, + editSaveButtonDisabled: { + opacity: 0.5, + }, + editSaveButtonText: { + fontSize: 16, + fontWeight: '600', + color: DARK, + }, + // Title Row titleRow: { flexDirection: 'row', diff --git a/app/(tabs)/spots/add.tsx b/app/(tabs)/spots/add.tsx index 0406b30..78db3bc 100644 --- a/app/(tabs)/spots/add.tsx +++ b/app/(tabs)/spots/add.tsx @@ -10,6 +10,7 @@ */ import { Ionicons } from '@expo/vector-icons'; +import * as ImagePicker from 'expo-image-picker'; import * as Location from 'expo-location'; import { router } from 'expo-router'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -18,6 +19,7 @@ import { Alert, Dimensions, FlatList, + Image, KeyboardAvoidingView, Linking, Modal, @@ -39,7 +41,10 @@ import { reverseGeocode, type SportType, type SpotCategory, + saveSpot, searchPlaces, + updateSpot, + uploadSpotPhoto, } from '@/lib/api/spots'; import { useThemeContext } from '@/lib/providers/ThemeProvider'; import { useAuthStore } from '@/lib/stores/authStore'; @@ -100,9 +105,13 @@ export default function AddSpotScreen() { const [sportTypes, setSportTypes] = useState([]); const [spotCategories, setSpotCategories] = useState([]); + // Photo state (local asset URIs selected for upload) + const [photos, setPhotos] = useState<{ uri: string; mimeType: string }[]>([]); + // UI state const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); + const [uploadStatus, setUploadStatus] = useState(null); const [sportModalVisible, setSportModalVisible] = useState(false); // Get user location on mount @@ -269,22 +278,93 @@ export default function AddSpotScreen() { ); }, []); - // Submit spot - const handleSubmit = useCallback(async () => { + // Add photos from the gallery (multiple allowed) + const handleAddFromGallery = 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) { + const picked = result.assets.map((asset) => ({ + uri: asset.uri, + mimeType: asset.mimeType || 'image/jpeg', + })); + setPhotos((prev) => [...prev, ...picked]); + } + }, []); + + // Capture a photo with the camera + const handleTakePhoto = 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]; + setPhotos((prev) => [...prev, { uri: asset.uri, mimeType: asset.mimeType || 'image/jpeg' }]); + } + }, []); + + // Remove a selected photo before upload + const handleRemovePhoto = useCallback((uri: string) => { + setPhotos((prev) => prev.filter((p) => p.uri !== uri)); + }, []); + + // Upload all selected photos to a spot; returns the first successfully-uploaded URL. + const uploadPhotosForSpot = useCallback( + async (spotId: string): Promise<{ firstUrl: string | null; failed: number }> => { + let firstUrl: string | null = null; + let failed = 0; + for (let i = 0; i < photos.length; i++) { + setUploadStatus(`Uploading photo ${i + 1} of ${photos.length}...`); + const uploaded = await uploadSpotPhoto(spotId, photos[i].uri, photos[i].mimeType); + if (uploaded?.url) { + if (!firstUrl) firstUrl = uploaded.url; + } else { + failed++; + } + } + return { firstUrl, failed }; + }, + [photos], + ); + + // Validate required fields before submitting; alerts and returns false if invalid. + const validateForm = useCallback((): boolean => { if (!spotName.trim()) { Alert.alert('Required', 'Please enter a spot name.'); - return; + return false; } if (!selectedLocation) { Alert.alert('Required', 'Location is required.'); - return; + return false; } if (selectedSports.length === 0) { Alert.alert('Required', 'Please select at least one sport type.'); + return false; + } + return true; + }, [spotName, selectedLocation, selectedSports]); + + // Submit spot + const handleSubmit = useCallback(async () => { + if (!validateForm() || !selectedLocation) { return; } setSaving(true); + setUploadStatus(null); try { const spotData = { name: spotName.trim(), @@ -293,36 +373,58 @@ export default function AddSpotScreen() { description: description.trim() || undefined, city: city || undefined, state: state || undefined, - category: selectedCategory as 'park' | 'street' | 'indoor' | 'diy' | 'other', + category: selectedCategory as 'park' | 'street' | 'indoor' | 'diy' | 'resort' | 'other', sportTypes: selectedSports, isPublic, googlePlaceId: selectedPlaceId || undefined, }; + // 1. Create the spot first so we have an _id to attach photos to. const result = await createSpot(spotData); - if (result) { - Alert.alert( - 'Spot Added!', - isPublic - ? 'Your spot has been submitted for review. It will appear publicly once approved.' - : 'Your private spot has been saved.', - [ - { - text: 'OK', - onPress: () => router.back(), - }, - ], - ); - } else { + if (!result) { Alert.alert('Error', 'Failed to create spot. Please try again.'); + return; + } + + // If the backend deduped to an existing spot owned by someone else, we + // don't own it: save it to "My Spots" and DON'T attach our photos to it. + const currentUserId = user?._id || user?.id; + const isExistingOtherSpot = + !!result.userId && !!currentUserId && result.userId !== currentUserId; + + let title = 'Spot Added!'; + let message = isPublic + ? 'Your spot has been submitted for review. It will appear publicly once approved.' + : 'Your private spot has been saved.'; + + if (isExistingOtherSpot) { + await saveSpot(result._id); + title = 'Already Exists'; + message = + 'That spot already existed, so we saved it to your My Spots.' + + (photos.length > 0 ? ' Your photos were not added to it.' : ''); + } else if (photos.length > 0) { + // 2. Upload each selected photo; use the first as the spot's main image. + const { firstUrl, failed } = await uploadPhotosForSpot(result._id); + if (firstUrl && !result.imageURL) { + setUploadStatus('Finishing up...'); + await updateSpot(result._id, { imageURL: firstUrl }); + } + if (failed > 0) { + message += ` (${failed} photo${failed > 1 ? 's' : ''} couldn't be uploaded — you can add them later from the spot.)`; + } } + + Alert.alert(title, message, [{ text: 'OK', onPress: () => router.back() }]); } catch (_error) { Alert.alert('Error', 'Failed to create spot. Please try again.'); } finally { setSaving(false); + setUploadStatus(null); } }, [ + validateForm, spotName, selectedLocation, description, @@ -332,6 +434,8 @@ export default function AddSpotScreen() { selectedSports, isPublic, selectedPlaceId, + uploadPhotosForSpot, + user, ]); // Render location selection step @@ -361,6 +465,14 @@ export default function AddSpotScreen() { } } > + {/* + WARNING: react-native-maps can CRASH under the New Architecture. + This existing draggable drop-pin is intentionally left in place to avoid + breaking the working location step. If crashes appear, migrate this to a + fixed center-crosshair overlay (a centered pin icon rendered OUTSIDE the + MapView) that reads the map region center via onRegionChangeComplete, + instead of rendering a child. + */} {selectedLocation && ( @@ -592,6 +704,55 @@ export default function AddSpotScreen() { numberOfLines={3} /> + {/* Photos */} + Photos + + + + Add from Gallery + + + + Take Photo + + + {photos.length > 0 && ( + + {photos.map((photo) => ( + + + handleRemovePhoto(photo.uri)} + disabled={saving} + hitSlop={8} + > + + + + ))} + + )} + {/* Category */} Category * @@ -674,7 +835,10 @@ export default function AddSpotScreen() { disabled={saving} > {saving ? ( - + <> + + {uploadStatus && {uploadStatus}} + ) : ( <> @@ -1042,6 +1206,56 @@ const styles = StyleSheet.create({ borderWidth: 1, marginBottom: 20, }, + + // Photos + photoButtonsRow: { + flexDirection: 'row', + gap: 10, + marginBottom: 12, + }, + photoButton: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + paddingVertical: 14, + borderRadius: 12, + borderWidth: 1, + }, + photoButtonText: { + fontSize: 14, + fontWeight: '600', + }, + thumbnailRow: { + marginBottom: 20, + }, + thumbnailRowContent: { + gap: 10, + paddingRight: 4, + }, + thumbnailWrapper: { + width: 88, + height: 88, + borderRadius: 12, + }, + thumbnail: { + width: 88, + height: 88, + borderRadius: 12, + backgroundColor: '#000', + }, + thumbnailRemove: { + position: 'absolute', + top: -6, + right: -6, + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: 'rgba(0,0,0,0.75)', + alignItems: 'center', + justifyContent: 'center', + }, toggleContainer: { flexDirection: 'row', alignItems: 'center', diff --git a/app/(tabs)/spots/index.tsx b/app/(tabs)/spots/index.tsx index 31d1c20..04c0f6f 100644 --- a/app/(tabs)/spots/index.tsx +++ b/app/(tabs)/spots/index.tsx @@ -6,12 +6,11 @@ import { Ionicons } from '@expo/vector-icons'; import * as Location from 'expo-location'; -import { router } from 'expo-router'; +import { router, useFocusEffect } from 'expo-router'; import { useCallback, useEffect, useRef, useState } from 'react'; import { ActivityIndicator, Alert, - Dimensions, FlatList, Image, KeyboardAvoidingView, @@ -26,28 +25,33 @@ import { View, } from 'react-native'; import MapView, { PROVIDER_GOOGLE, type Region } from 'react-native-maps'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { SpotListCard as SpotListCardComponent } from '@/components/spots'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { AddToSpotListModal, SpotListCard as SpotListCardComponent } from '@/components/spots'; import { useKeyboardVisible } from '@/hooks/useKeyboardVisible'; import { useMapClusters } from '@/hooks/useMapClusters'; import { createSpotList, getSpotLists } from '@/lib/api/spotlists'; import { getMapPins, + getMySpots, + getSavedSpots, getSportTypes, 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'; -const { width: SCREEN_WIDTH } = Dimensions.get('window'); - /** * Theme colors - see /src/constants/colors.ts for full policy * YELLOW: Use for backgrounds with dark text, or icons. Never for text on light backgrounds. @@ -74,6 +78,11 @@ const SPORT_ICONS: Record = { type ViewMode = 'map' | 'list'; type TabType = 'allSpots' | 'mySpots'; +// My Spots sub-view: the flat authored+saved list vs. the named collections. +type MySpotsView = 'spots' | 'collections'; +// A spot in the flat "My Spots" list, tagged with how the user relates to it. +type MySpotBadge = 'Mine' | 'Saved'; +type MySpot = Spot & { mineOrSaved: MySpotBadge }; // Dark mode map styling const darkMapStyle = [ @@ -87,8 +96,9 @@ const darkMapStyle = [ ]; export default function SpotsScreen() { - const { theme, colors, isDark } = useThemeContext(); + const { theme, isDark } = useThemeContext(); const { user, token } = useAuthStore(); + const insets = useSafeAreaInsets(); // Tab state const [activeTab, setActiveTab] = useState('allSpots'); @@ -109,7 +119,11 @@ export default function SpotsScreen() { const [userLocation, setUserLocation] = useState<{ latitude: number; longitude: number } | null>( null, ); - const [_spotToAddToList, _setSpotToAddToList] = useState(null); + // Saved-spot state: track which spot ids the user has saved so the bookmark + // control renders filled/outline. Seeded via isSpotSaved when a spot is selected. + const [savedSpotIds, setSavedSpotIds] = useState>(new Set()); + // Spot whose named-list picker (AddToSpotListModal) is open, if any. + const [spotForListModal, setSpotForListModal] = useState(null); const [mapPins, setMapPins] = useState([]); // Current visible region, used to compute clusters for the viewport. const [region, setRegion] = useState({ @@ -134,7 +148,22 @@ 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. + const [mySpotsView, setMySpotsView] = useState('spots'); + // Flat list = spots the user created (badge "Mine") + saved (badge "Saved"). + const [mySpots, setMySpots] = useState([]); + const [mySpotsLoading, setMySpotsLoading] = useState(false); + const [mySpotsRefreshing, setMySpotsRefreshing] = useState(false); const [myLists, setMyLists] = useState([]); const [listsLoading, setListsLoading] = useState(false); const [listsRefreshing, setListsRefreshing] = useState(false); @@ -158,10 +187,39 @@ export default function SpotsScreen() { } }; - const handleAddToList = (spot: Spot) => { - // Navigate to add-to-list screen or show modal - // For now, navigate to spot detail where they can add to list - router.push(`/(tabs)/spots/${spot._id}`); + // One-tap save/unsave toggle for the selected-spot map card. Optimistically + // flips the saved state, then reconciles on the server response. + const handleToggleSave = async (spot: Spot) => { + const currentlySaved = savedSpotIds.has(spot._id); + // Optimistic update + setSavedSpotIds((prev) => { + const next = new Set(prev); + if (currentlySaved) { + next.delete(spot._id); + } else { + next.add(spot._id); + } + return next; + }); + const ok = currentlySaved ? await unsaveSpot(spot._id) : await saveSpot(spot._id); + if (!ok) { + // Revert on failure + setSavedSpotIds((prev) => { + const next = new Set(prev); + if (currentlySaved) { + next.add(spot._id); + } else { + next.delete(spot._id); + } + return next; + }); + Alert.alert('Error', currentlySaved ? 'Failed to remove spot' : 'Failed to save spot'); + } + }; + + // Long-press on the save control opens the named-list picker for that spot. + const handleOpenListPicker = (spot: Spot) => { + setSpotForListModal(spot); }; // Get user location on mount and center map @@ -206,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); @@ -275,6 +408,63 @@ export default function SpotsScreen() { setRefreshing(false); }, [fetchSpots]); + // Load the full set of saved spot ids up front so every bookmark control + // (map preview cards, the selected-spot card, My Spots) renders filled/outline + // correctly. This replaces per-spot isSpotSaved checks — those only seeded the + // selected spot and, because they depended on savedSpotIds, re-added an id + // right after the user optimistically unsaved it (the bookmark bounced back). + const loadSavedSpotIds = useCallback(async () => { + if (!token) return; + try { + const res = await getSavedSpots({ limit: 500 }); + setSavedSpotIds(new Set(res.spots.map((s) => s._id))); + } catch (_error) { + // Keep whatever we already know on failure. + } + }, [token]); + + useEffect(() => { + loadSavedSpotIds(); + }, [loadSavedSpotIds]); + + // Fetch the flat "My Spots" list: spots the user created (badge "Mine") plus + // spots they saved (badge "Saved"), merged and de-duped by _id. If a spot is + // both authored and saved, it shows "Mine". + const fetchMySpots = useCallback(async () => { + if (!user?.id && !user?._id) return; + setMySpotsLoading(true); + try { + const [mine, saved] = await Promise.all([ + getMySpots({ limit: 100 }), + getSavedSpots({ limit: 100 }), + ]); + const byId = new Map(); + // Saved first so authored ("Mine") overwrites on conflict. + for (const spot of saved.spots) { + byId.set(spot._id, { ...spot, mineOrSaved: 'Saved' }); + } + for (const spot of mine.spots) { + byId.set(spot._id, { ...spot, mineOrSaved: 'Mine' }); + } + setMySpots(Array.from(byId.values())); + // Keep the bookmark state in sync with what the server considers saved. + setSavedSpotIds((prev) => { + const next = new Set(prev); + for (const spot of saved.spots) next.add(spot._id); + return next; + }); + } catch (_error) { + } finally { + setMySpotsLoading(false); + } + }, [user?.id, user?._id]); + + const onMySpotsRefresh = useCallback(async () => { + setMySpotsRefreshing(true); + await fetchMySpots(); + setMySpotsRefreshing(false); + }, [fetchMySpots]); + // Fetch user's spot lists const fetchMyLists = useCallback(async () => { if (!user?.id && !user?._id) return; @@ -294,12 +484,30 @@ export default function SpotsScreen() { setListsRefreshing(false); }, [fetchMyLists]); - // Fetch lists when switching to My Spots tab + // Fetch My Spots content when switching to the tab / sub-view. useEffect(() => { - if (activeTab === 'mySpots') { + if (activeTab !== 'mySpots') return; + if (mySpotsView === 'spots') { + fetchMySpots(); + } else { fetchMyLists(); } - }, [activeTab, fetchMyLists]); + }, [activeTab, mySpotsView, fetchMySpots, fetchMyLists]); + + // Refresh My Spots content whenever the screen regains focus (e.g. after + // creating/saving a spot elsewhere), matching the previous refresh-on-focus. + useFocusEffect( + useCallback(() => { + // Reconcile saved-bookmark state with the server on every focus. + loadSavedSpotIds(); + if (activeTab !== 'mySpots') return; + if (mySpotsView === 'spots') { + fetchMySpots(); + } else { + fetchMyLists(); + } + }, [activeTab, mySpotsView, fetchMySpots, fetchMyLists, loadSavedSpotIds]), + ); // Handle create spot list const handleCreateList = async () => { @@ -328,146 +536,155 @@ export default function SpotsScreen() { return ( - {/* Header */} - - Spots - - {activeTab === 'allSpots' && ( - setFilterModalVisible(true)}> - - Filter + {/* Header — hidden in fullscreen map for an immersive view. */} + {!isMapFullscreen && ( + + Spots + + {activeTab === 'allSpots' && ( + setFilterModalVisible(true)}> + + Filter + + )} + + activeTab === 'allSpots' + ? router.push('/(tabs)/spots/add') + : setCreateModalVisible(true) + } + > + - )} - - activeTab === 'allSpots' - ? router.push('/(tabs)/spots/add') - : setCreateModalVisible(true) - } - > - - + - + )} - {/* Tab Toggle */} - - setActiveTab('allSpots')} - > - - - setActiveTab('mySpots')} - > - + All Spots + + + setActiveTab('mySpots')} > - My Spots - - - + + My Spots + + + + )} {activeTab === 'allSpots' ? ( <> - {/* Search Bar */} - - - - - {searchQuery.length > 0 && ( - setSearchQuery('')}> - - - )} - - - - {/* View Toggle & Categories */} - - {/* View Toggle */} - - setViewMode('map')} - > - + + + - - setViewMode('list')} - > - - + {searchQuery.length > 0 && ( + setSearchQuery('')}> + + + )} + + )} - {/* Category Pills */} - - {spotCategories.map((cat) => ( + {/* View Toggle & Categories — hidden in fullscreen map. */} + {!isMapFullscreen && ( + + {/* View Toggle */} + setSelectedCategory(cat.id)} + onPress={() => setViewMode('map')} > - + setViewMode('list')} + > + + + + + {/* Category Pills */} + + {spotCategories.map((cat) => ( + setSelectedCategory(cat.id)} > - {cat.name} - - - ))} - - + + + {cat.name} + + + ))} + + + )} {/* Content */} {loading && !refreshing ? ( @@ -515,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 ( ) : ( // List View @@ -871,7 +1066,102 @@ export default function SpotsScreen() { ) : ( // My Spots Tab <> - {listsLoading && !listsRefreshing ? ( + {/* Sub-view toggle: flat Spots (authored + saved) vs. Collections. */} + + setMySpotsView('spots')} + > + + + Spots + + + setMySpotsView('collections')} + > + + + Collections + + + + + {mySpotsView === 'spots' ? ( + mySpotsLoading && !mySpotsRefreshing ? ( + + + + ) : ( + item._id} + contentContainerStyle={styles.listContent} + showsVerticalScrollIndicator={false} + refreshControl={ + + } + ItemSeparatorComponent={() => } + ListEmptyComponent={ + + + No spots yet + + You haven't added or saved any spots yet + + + } + renderItem={({ item }) => ( + router.push(`/(tabs)/spots/${item._id}`)} + /> + )} + /> + ) + ) : listsLoading && !listsRefreshing ? ( @@ -1006,6 +1296,139 @@ export default function SpotsScreen() { )} + + {/* Named-list picker (opened via long-press on a save/bookmark control). + Mounted once at screen level and controlled by spotForListModal. */} + setSpotForListModal(null)} + onSuccess={() => { + // The picker may have added the spot to the default Saved bucket. + if (spotForListModal) { + const id = spotForListModal._id; + setSavedSpotIds((prev) => { + if (prev.has(id)) return prev; + const next = new Set(prev); + next.add(id); + return next; + }); + } + }} + /> + + {/* 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()}” + + )} + + + ); } @@ -1014,11 +1437,22 @@ export default function SpotsScreen() { interface SpotMapCardProps { spot: Spot; theme: any; + saved?: boolean; onPress: () => void; - onAddToList?: () => void; + /** Tap the bookmark: one-tap save/unsave. */ + onToggleSave?: () => void; + /** Long-press the bookmark: open the named-list picker. */ + onOpenListPicker?: () => void; } -function SpotMapCard({ spot, theme, onPress, onAddToList }: SpotMapCardProps) { +function SpotMapCard({ + spot, + theme, + saved, + onPress, + onToggleSave, + onOpenListPicker, +}: SpotMapCardProps) { const address = [spot.city, spot.state].filter(Boolean).join(', '); return ( @@ -1053,13 +1487,27 @@ function SpotMapCard({ spot, theme, onPress, onAddToList }: SpotMapCardProps) { { e.stopPropagation(); - onAddToList?.(); + onToggleSave?.(); + }} + onLongPress={(e) => { + e.stopPropagation(); + onOpenListPicker?.(); }} + delayLongPress={300} + hitSlop={6} > - + View @@ -1076,27 +1524,37 @@ function SpotMapCard({ spot, theme, onPress, onAddToList }: SpotMapCardProps) { interface SpotListCardProps { spot: Spot; theme: any; + /** Optional "Mine"/"Saved" badge shown on the image (My Spots flat list). */ + badge?: MySpotBadge; onPress: () => void; } -function SpotListCard({ spot, theme, onPress }: SpotListCardProps) { +function SpotListCard({ spot, theme, badge, onPress }: SpotListCardProps) { const address = [spot.city, spot.state].filter(Boolean).join(', ') || 'Unknown location'; return ( {/* Image */} - {spot.imageURL ? ( - - ) : ( - - - - )} + + {spot.imageURL ? ( + + ) : ( + + + + )} + {badge && ( + + + {badge} + + )} + {/* Content */} @@ -1333,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', @@ -1645,6 +2173,45 @@ const styles = StyleSheet.create({ color: DARK, }, + // My Spots sub-view switch (Spots | Collections) + mySpotsSwitch: { + flexDirection: 'row', + marginHorizontal: 20, + marginBottom: 16, + gap: 8, + }, + mySpotsSwitchButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 20, + }, + mySpotsSwitchText: { + fontSize: 14, + fontWeight: '600', + }, + + // Mine / Saved badge on spot card image + cardBadge: { + position: 'absolute', + top: 6, + left: 6, + flexDirection: 'row', + alignItems: 'center', + gap: 3, + paddingHorizontal: 6, + paddingVertical: 3, + borderRadius: 8, + backgroundColor: YELLOW, + }, + cardBadgeText: { + fontSize: 10, + fontWeight: '700', + color: DARK, + }, + // My Spots List myListsContent: { paddingHorizontal: 20, 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 888e11a..5f848c2 100644 --- a/src/lib/api/spots.ts +++ b/src/lib/api/spots.ts @@ -3,7 +3,8 @@ * Functions for fetching and managing spots */ -import { ENDPOINTS } from '@/constants/api'; +import { FileSystemUploadType, uploadAsync } from 'expo-file-system/legacy'; +import { API_CONFIG, ENDPOINTS } from '@/constants/api'; import { apiClient } from './client'; // Types @@ -28,7 +29,7 @@ export interface Spot { state?: string; isPublic?: boolean; sportTypes?: string[]; - category?: 'park' | 'street' | 'indoor' | 'diy' | 'other'; + category?: 'park' | 'street' | 'indoor' | 'diy' | 'resort' | 'other'; approvalStatus?: 'pending' | 'approved' | 'rejected' | 'private'; userId?: string; createdAt?: string; @@ -280,6 +281,18 @@ export async function updateSpot(id: string, updates: Partial): Promise { + try { + await apiClient.delete(ENDPOINTS.spots.detail(id)); + return true; + } catch (_error) { + return false; + } +} + /** * Get user's own spots */ @@ -312,6 +325,129 @@ export async function getMySpots( } } +/** + * Get the current user's saved spots (the "Saved" half of My Spots). + */ +export async function getSavedSpots( + params: { page?: number; limit?: number } = {}, +): Promise { + try { + const queryParams = new URLSearchParams(); + if (params.page) queryParams.append('page', params.page.toString()); + if (params.limit) queryParams.append('limit', params.limit.toString()); + const queryString = queryParams.toString(); + const endpoint = queryString + ? `${ENDPOINTS.spots.list}/saved?${queryString}` + : `${ENDPOINTS.spots.list}/saved`; + return await apiClient.get(endpoint); + } catch (_error) { + return { + spots: [], + pagination: { page: 1, limit: 50, totalCount: 0, totalPages: 0, hasMore: false }, + }; + } +} + +/** + * One-tap save: add a spot to the user's "Saved Spots". Returns true on success. + */ +export async function saveSpot(id: string): Promise { + try { + await apiClient.post(`${ENDPOINTS.spots.detail(id)}/save`); + return true; + } catch (_error) { + return false; + } +} + +/** + * Remove a spot from the user's "Saved Spots". Returns true on success. + */ +export async function unsaveSpot(id: string): Promise { + try { + await apiClient.delete(`${ENDPOINTS.spots.detail(id)}/save`); + return true; + } catch (_error) { + return false; + } +} + +/** + * Whether the current user has saved this spot (checks the default Saved bucket). + */ +export async function isSpotSaved(id: string): Promise { + try { + const lists = await apiClient.get<{ isDefaultSaved?: boolean }[]>( + `${ENDPOINTS.spots.detail(id)}/lists`, + ); + return Array.isArray(lists) && lists.some((l) => l.isDefaultSaved === true); + } catch (_error) { + return false; + } +} + +/** + * Upload one user photo to a spot (multipart). Uses expo-file-system for + * reliable uploads on physical iOS devices. Returns the created photo or null. + */ +export async function uploadSpotPhoto( + spotId: string, + fileUri: string, + mimeType: string = 'image/jpeg', +): Promise { + try { + const token = await apiClient.getToken(); + const result = await uploadAsync( + `${API_CONFIG.baseUrl}${ENDPOINTS.spots.detail(spotId)}/photos`, + fileUri, + { + httpMethod: 'POST', + uploadType: FileSystemUploadType.MULTIPART, + fieldName: 'photo', + mimeType, + headers: token ? { 'x-auth-token': token } : {}, + }, + ); + if (result.status !== 200 && result.status !== 201) { + return null; + } + return JSON.parse(result.body) as SpotPhoto; + } catch (_error) { + return null; + } +} + +/** + * 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 }; +}