POWR/components/library/ExerciseSheet.tsx

480 lines
20 KiB
TypeScript
Raw Permalink Normal View History

// 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';
2025-03-12 19:23:28 -04:00
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 (
<Modal
visible={isOpen}
transparent={true}
animationType="slide"
onRequestClose={onClose}
>
<View className="flex-1 justify-center items-center bg-black/70">
<View
className={`bg-background ${isDarkColorScheme ? 'bg-card border border-border' : ''} rounded-lg w-[95%] h-[85%] max-w-xl shadow-xl overflow-hidden`}
style={{ maxHeight: 700 }}
>
{/* Header */}
<View className="flex-row justify-between items-center p-4 border-b border-border">
<Text className="text-xl font-bold text-foreground">{getTitle()}</Text>
<TouchableOpacity onPress={onClose} className="p-1">
<X size={24} />
</TouchableOpacity>
</View>
{/* Content */}
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={{ flex: 1 }}
>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<View style={{ flex: 1 }}>
<ScrollView className="flex-1" showsVerticalScrollIndicator={false}>
<View className="gap-5 py-5 px-4">
{/* Source badge for edit/fork mode */}
{(isEditMode || isForkMode) && (
<View className="flex-row mb-2 items-center gap-2">
<View className={`px-2 py-1 rounded-md ${exerciseToEdit?.source === 'nostr' ? 'bg-purple-100 dark:bg-purple-900' : 'bg-blue-100 dark:bg-blue-900'}`}>
<Text className={`text-xs ${exerciseToEdit?.source === 'nostr' ? 'text-purple-800 dark:text-purple-200' : 'text-blue-800 dark:text-blue-200'}`}>
{exerciseToEdit?.source === 'nostr' ? 'Nostr' : exerciseToEdit?.source}
</Text>
</View>
{/* Show forked badge when in fork mode */}
{isForkMode && (
<View className="px-2 py-1 rounded-md bg-amber-100 dark:bg-amber-900">
<Text className="text-xs text-amber-800 dark:text-amber-200">
Creating Local Copy
</Text>
</View>
)}
</View>
)}
<View>
<Text className="text-base font-medium mb-2">Exercise Name</Text>
<Input
value={formData.title}
onChangeText={(text) => setFormData(prev => ({ ...prev, title: text }))}
placeholder="e.g., Barbell Back Squat"
className="text-foreground"
/>
{!formData.title && (
<Text className="text-xs text-muted-foreground mt-1 ml-1">
* Required field
</Text>
)}
</View>
<View>
<Text className="text-base font-medium mb-2">Type</Text>
<View className="flex-row flex-wrap gap-2">
{EXERCISE_TYPES.map((type) => (
<Button
key={type}
variant={formData.type === type ? 'default' : 'outline'}
onPress={() => setFormData(prev => ({ ...prev, type }))}
style={formData.type === type ? { backgroundColor: purpleColor } : {}}
>
<Text className={formData.type === type ? 'text-white' : ''}>
{type.charAt(0).toUpperCase() + type.slice(1)}
</Text>
</Button>
))}
</View>
</View>
<View>
<Text className="text-base font-medium mb-2">Category</Text>
<View className="flex-row flex-wrap gap-2">
{CATEGORIES.map((category) => (
<Button
key={category}
variant={formData.category === category ? 'default' : 'outline'}
onPress={() => setFormData(prev => ({ ...prev, category }))}
style={formData.category === category ? { backgroundColor: purpleColor } : {}}
>
<Text className={formData.category === category ? 'text-white' : ''}>
{category}
</Text>
</Button>
))}
</View>
</View>
<View>
<Text className="text-base font-medium mb-2">Equipment</Text>
<View className="flex-row flex-wrap gap-2">
{EQUIPMENT_OPTIONS.map((eq) => (
<Button
key={eq}
variant={formData.equipment === eq ? 'default' : 'outline'}
onPress={() => setFormData(prev => ({ ...prev, equipment: eq }))}
style={formData.equipment === eq ? { backgroundColor: purpleColor } : {}}
>
<Text className={formData.equipment === eq ? 'text-white' : ''}>
{eq.charAt(0).toUpperCase() + eq.slice(1)}
</Text>
</Button>
))}
</View>
{!formData.equipment && (
<Text className="text-xs text-muted-foreground mt-1 ml-1">
* Required field
</Text>
)}
</View>
<View>
<Text className="text-base font-medium mb-2">Description</Text>
<Input
value={formData.description}
onChangeText={(text) => setFormData(prev => ({ ...prev, description: text }))}
placeholder="Exercise description..."
multiline
numberOfLines={4}
textAlignVertical="top"
className="min-h-24 py-2"
/>
</View>
<View>
<Text className="text-base font-medium mb-2">Tags</Text>
<Input
value={formData.tags.join(', ')}
onChangeText={(text) => {
const tags = text.split(',')
.map(tag => tag.trim())
.filter(tag => tag.length > 0);
setFormData(prev => ({ ...prev, tags }));
}}
placeholder="strength, compound, legs..."
className="text-foreground"
/>
<Text className="text-xs text-muted-foreground mt-1 ml-1">
Separate tags with commas
</Text>
</View>
{/* Additional Nostr information */}
{exerciseToEdit?.source === 'nostr' && exerciseToEdit?.availability?.lastSynced?.nostr && (
<View className="mt-2 p-3 bg-muted rounded-md">
<Text className="text-sm text-muted-foreground">
Last synced with Nostr: {new Date(exerciseToEdit.availability.lastSynced.nostr.timestamp).toLocaleString()}
</Text>
{isEditMode && !isCurrentUserAuthor && (
<Text className="text-xs text-amber-500 mt-1">
You're not the original author. Use the "Fork" option to create your own copy.
</Text>
)}
{isEditMode && isCurrentUserAuthor && !ndkStore.isAuthenticated && (
<Text className="text-xs">
Changes will be saved locally and synced to Nostr when you're online and logged in.
</Text>
)}
{isForkMode && (
<Text className="text-xs text-green-500 mt-1">
Creating a local copy of this exercise that you can customize
</Text>
)}
{isNostrExercise && exerciseToEdit.availability.lastSynced.nostr.metadata.pubkey && (
<Text className="text-xs text-muted-foreground mt-1">
Author: {exerciseToEdit.availability.lastSynced.nostr.metadata.pubkey.substring(0, 8)}...
</Text>
)}
</View>
)}
</View>
</ScrollView>
{/* Create/Update button at bottom */}
<View className="p-4 border-t border-border">
{/* Show fork button when editing Nostr content we don't own */}
{isEditMode && isNostrExercise && !isCurrentUserAuthor ? (
<View className="flex-row gap-2">
<Button
className="flex-1 py-5"
variant='outline'
onPress={onClose}
>
<Text>Cancel</Text>
</Button>
<Button
className="flex-1 py-5"
variant='default'
onPress={() => {
// Close this modal and reopen in fork mode
onClose();
// This would be implemented in the parent component
// by reopening the modal with mode="fork"
}}
style={{ backgroundColor: purpleColor }}
>
<Text className="text-white font-semibold">
Fork Exercise
</Text>
</Button>
</View>
) : (
// Regular submit button for create/edit/fork
<Button
className="w-full py-5"
variant='default'
onPress={handleSubmit}
disabled={!formData.title || !formData.equipment}
style={(!formData.title || !formData.equipment)
? {}
: { backgroundColor: purpleColor }}
>
<Text className={(!formData.title || !formData.equipment)
? 'text-white opacity-50'
: 'text-white font-semibold'}>
{getButtonText()}
</Text>
</Button>
)}
</View>
</View>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
</View>
</View>
</Modal>
);
}