// components/library/ExerciseSheet.tsx import React, { useState, useEffect } from 'react'; import { View, ScrollView, KeyboardAvoidingView, Platform, TouchableWithoutFeedback, Keyboard, Modal, TouchableOpacity } from 'react-native'; import { Text } from '@/components/ui/text'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { generateId } from '@/utils/ids'; import { X } from 'lucide-react-native'; import { useColorScheme } from '@/lib/theme/useColorScheme'; import { BaseExercise, ExerciseType, ExerciseCategory, Equipment, ExerciseFormat, ExerciseFormatUnits, ExerciseDisplay } from '@/types/exercise'; import { StorageSource } from '@/types/shared'; import { NDKEvent } from '@nostr-dev-kit/ndk-mobile'; import { useNDKStore } from '@/lib/stores/ndk'; import { useExerciseService, usePublicationQueue } from '@/components/DatabaseProvider'; interface ExerciseSheetProps { isOpen: boolean; onClose: () => void; onSubmit: (exercise: BaseExercise) => void; exerciseToEdit?: ExerciseDisplay; // Optional - if provided, we're in edit mode mode?: 'create' | 'edit' | 'fork'; // Optional - defaults to 'create' or 'edit' based on exerciseToEdit } const EXERCISE_TYPES: ExerciseType[] = ['strength', 'cardio', 'bodyweight']; const CATEGORIES: ExerciseCategory[] = ['Push', 'Pull', 'Legs', 'Core']; const EQUIPMENT_OPTIONS: Equipment[] = [ 'bodyweight', 'barbell', 'dumbbell', 'kettlebell', 'machine', 'cable', 'other' ]; // Default empty form data const DEFAULT_FORM_DATA = { title: '', type: 'strength' as ExerciseType, category: 'Push' as ExerciseCategory, equipment: undefined as Equipment | undefined, description: '', tags: [] as string[], format: { weight: true, reps: true, rpe: true, set_type: true } as ExerciseFormat, format_units: { weight: 'kg', reps: 'count', rpe: '0-10', set_type: 'warmup|normal|drop|failure' } as ExerciseFormatUnits }; export function ExerciseSheet({ isOpen, onClose, onSubmit, exerciseToEdit, mode: explicitMode }: ExerciseSheetProps) { const { isDarkColorScheme } = useColorScheme(); const [formData, setFormData] = useState(DEFAULT_FORM_DATA); const ndkStore = useNDKStore(); const publicationQueue = usePublicationQueue(); // Determine if we're in edit, create, or fork mode const hasExercise = !!exerciseToEdit; const isNostrExercise = exerciseToEdit?.source === 'nostr'; const isCurrentUserAuthor = isNostrExercise && exerciseToEdit?.availability?.lastSynced?.nostr?.metadata?.pubkey === ndkStore.currentUser?.pubkey; // Use explicit mode if provided, otherwise determine based on context const mode = explicitMode || (hasExercise ? (isNostrExercise && !isCurrentUserAuthor ? 'fork' : 'edit') : 'create'); const isEditMode = mode === 'edit'; const isForkMode = mode === 'fork'; // Load data from exerciseToEdit when in edit mode useEffect(() => { if (isOpen && exerciseToEdit) { setFormData({ title: exerciseToEdit.title, type: exerciseToEdit.type, category: exerciseToEdit.category, equipment: exerciseToEdit.equipment, description: exerciseToEdit.description || '', tags: exerciseToEdit.tags || [], format: exerciseToEdit.format || DEFAULT_FORM_DATA.format, format_units: exerciseToEdit.format_units || DEFAULT_FORM_DATA.format_units }); } else if (isOpen && !exerciseToEdit) { // Reset form when opening in create mode setFormData(DEFAULT_FORM_DATA); } }, [isOpen, exerciseToEdit]); // Reset form data when modal closes useEffect(() => { if (!isOpen) { // Add a delay to ensure the closing animation completes first const timer = setTimeout(() => { setFormData(DEFAULT_FORM_DATA); }, 300); return () => clearTimeout(timer); } }, [isOpen]); const handleSubmit = async () => { if (!formData.title || !formData.equipment) return; const timestamp = Date.now(); const isNostrExercise = exerciseToEdit?.source === 'nostr'; const canEditNostr = isNostrExercise && isCurrentUserAuthor; // Create BaseExercise const exercise: BaseExercise = { // Generate new ID when forking, otherwise use existing or generate new id: isForkMode ? generateId() : (exerciseToEdit?.id || generateId()), title: formData.title, type: formData.type, category: formData.category, equipment: formData.equipment, description: formData.description, tags: formData.tags.length ? formData.tags : [formData.category.toLowerCase()], format: formData.format, format_units: formData.format_units, // Use current timestamp for fork, otherwise preserve original or use current created_at: isForkMode ? timestamp : (exerciseToEdit?.created_at || timestamp), // For forked exercises, create new local availability availability: isForkMode ? { source: ['local' as StorageSource], lastSynced: undefined } : (exerciseToEdit?.availability || { source: ['local' as StorageSource], lastSynced: undefined }) }; // If this is a Nostr exercise we can edit OR a new exercise while authenticated, // we should create and possibly publish the Nostr event if ((canEditNostr || (!exerciseToEdit && ndkStore.isAuthenticated)) && !isForkMode) { try { // Create tags for the exercise const nostrTags = [ ['d', exercise.id], // Use the same 'd' tag to make it replaceable ['title', exercise.title], ['type', exercise.type], ['category', exercise.category], ['equipment', exercise.equipment || ''], ...(exercise.tags.map(tag => ['t', tag])), // Format tags - handle possible undefined with null coalescing operator ['format', ...Object.keys(exercise.format || {}).filter(k => exercise.format && exercise.format[k as keyof ExerciseFormat] )] ]; // Add format units if they exist if (exercise.format_units) { const unitEntries = Object.entries(exercise.format_units); if (unitEntries.length > 0) { nostrTags.push(['format_units', ...unitEntries.flat()]); } } // Create and attempt to publish the event const event = new NDKEvent(ndkStore.ndk || undefined); event.kind = 33401; // Or whatever kind you need event.content = exercise.description || ''; event.tags = nostrTags; await event.sign(); if (event) { // Queue for publication (this will publish immediately if online) await publicationQueue.queueEvent(event); // If this is a new exercise, add nostr to sources if (!exerciseToEdit) { exercise.availability.source.push('nostr'); // Add nostr metadata exercise.availability.lastSynced = { ...exercise.availability.lastSynced, nostr: { timestamp: Date.now(), metadata: { id: event.id || exercise.id, pubkey: ndkStore.currentUser?.pubkey || '', relayUrl: 'wss://relay.damus.io', // Default relay created_at: event.created_at || Math.floor(Date.now() / 1000) } } }; } console.log(isEditMode ? 'Exercise updated on Nostr' : 'Exercise published to Nostr'); } } catch (error) { console.error('Error with Nostr event:', error); // Continue with local update even if Nostr fails } } // Close first, then submit with a small delay onClose(); setTimeout(() => { onSubmit(exercise); }, 50); }; // Purple color used throughout the app const purpleColor = 'hsl(261, 90%, 66%)'; // Get title and button text based on mode const getTitle = () => { if (isEditMode) return "Edit Exercise"; if (isForkMode) return "Fork Exercise"; return "Create New Exercise"; }; const getButtonText = () => { if (isEditMode) return "Update Exercise"; if (isForkMode) return "Save as My Exercise"; return "Create Exercise"; }; // Return null if not open if (!isOpen) return null; return ( {/* Header */} {getTitle()} {/* Content */} {/* Source badge for edit/fork mode */} {(isEditMode || isForkMode) && ( {exerciseToEdit?.source === 'nostr' ? 'Nostr' : exerciseToEdit?.source} {/* Show forked badge when in fork mode */} {isForkMode && ( Creating Local Copy )} )} Exercise Name setFormData(prev => ({ ...prev, title: text }))} placeholder="e.g., Barbell Back Squat" className="text-foreground" /> {!formData.title && ( * Required field )} Type {EXERCISE_TYPES.map((type) => ( ))} Category {CATEGORIES.map((category) => ( ))} Equipment {EQUIPMENT_OPTIONS.map((eq) => ( ))} {!formData.equipment && ( * Required field )} Description setFormData(prev => ({ ...prev, description: text }))} placeholder="Exercise description..." multiline numberOfLines={4} textAlignVertical="top" className="min-h-24 py-2" /> Tags { const tags = text.split(',') .map(tag => tag.trim()) .filter(tag => tag.length > 0); setFormData(prev => ({ ...prev, tags })); }} placeholder="strength, compound, legs..." className="text-foreground" /> Separate tags with commas {/* Additional Nostr information */} {exerciseToEdit?.source === 'nostr' && exerciseToEdit?.availability?.lastSynced?.nostr && ( Last synced with Nostr: {new Date(exerciseToEdit.availability.lastSynced.nostr.timestamp).toLocaleString()} {isEditMode && !isCurrentUserAuthor && ( You're not the original author. Use the "Fork" option to create your own copy. )} {isEditMode && isCurrentUserAuthor && !ndkStore.isAuthenticated && ( Changes will be saved locally and synced to Nostr when you're online and logged in. )} {isForkMode && ( Creating a local copy of this exercise that you can customize )} {isNostrExercise && exerciseToEdit.availability.lastSynced.nostr.metadata.pubkey && ( Author: {exerciseToEdit.availability.lastSynced.nostr.metadata.pubkey.substring(0, 8)}... )} )} {/* Create/Update button at bottom */} {/* Show fork button when editing Nostr content we don't own */} {isEditMode && isNostrExercise && !isCurrentUserAuthor ? ( ) : ( // Regular submit button for create/edit/fork )} ); }