managarten/nutriphi/apps/mobile/components/meals/MealCard.tsx
Till-JS 6537863696 feat(nutriphi): migrate from Supabase to PostgreSQL + Hetzner S3
- Add nutriphi-database package with Drizzle ORM
  - meals and nutrition_goals schemas
  - PostgreSQL 16 Docker setup
  - Drizzle Kit configuration

- Migrate backend from Supabase to Drizzle
  - Add DatabaseModule with connection pooling
  - Add StorageService for Hetzner Object Storage (S3-compatible)
  - Update MealsService with Drizzle queries
  - Add /api/meals/upload endpoint for image upload + analysis

- Update web app to use backend for uploads
  - Remove Supabase Storage direct upload
  - Update uploadService to send images to backend
  - Remove Supabase dependencies from package.json
  - Simplify hooks.server.ts

- Add Coolify deployment configuration
  - Dockerfile for production build
  - docker-compose.coolify.yml

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 17:52:14 +01:00

190 lines
6.7 KiB
TypeScript

import React, { useState } from 'react';
import { TouchableOpacity, View, Text, Image } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { MealWithItems } from '../../types/Database';
import { AnalysisStatusIndicator } from './AnalysisStatusIndicator';
interface MealCardProps {
meal: MealWithItems;
onPress: () => void;
}
export const MealCard: React.FC<MealCardProps> = ({ meal, onPress }) => {
const [imageError, setImageError] = useState(false);
const generateMealTitle = (meal: MealWithItems): string => {
if (meal.food_items && meal.food_items.length > 0) {
const foodNames = meal.food_items.map((item) => item.name);
if (foodNames.length === 1) {
return foodNames[0];
} else if (foodNames.length === 2) {
return `${foodNames[0]} & ${foodNames[1]}`;
} else if (foodNames.length > 2) {
return `${foodNames[0]} & ${foodNames.length - 1} more`;
}
}
// Fallback to meal type if no food items
const mealTypeLabels = {
breakfast: 'Breakfast',
lunch: 'Lunch',
dinner: 'Dinner',
snack: 'Snack',
};
return mealTypeLabels[meal.meal_type || 'snack'] || 'Meal';
};
const getMealTypeLabel = (mealType?: string): string => {
const labels = {
breakfast: 'Breakfast',
lunch: 'Lunch',
dinner: 'Dinner',
snack: 'Snack',
};
return labels[mealType as keyof typeof labels] || 'Meal';
};
const formatTime = (timestamp: string) => {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) {
return `${diffDays}d ago`;
} else if (diffHours > 0) {
return `${diffHours}h ago`;
} else {
return 'Now';
}
};
const getMealTypeIcon = (mealType?: string) => {
switch (mealType) {
case 'breakfast':
return '🥐';
case 'lunch':
return '🥗';
case 'dinner':
return '🍽️';
case 'snack':
return '🍎';
default:
return '🍽️';
}
};
const getHealthScoreColor = (score?: number) => {
if (!score) return 'text-gray-400';
if (score >= 80) return 'text-green-400';
if (score >= 60) return 'text-yellow-400';
return 'text-red-400';
};
return (
<TouchableOpacity onPress={onPress} activeOpacity={0.8}>
<View className="aspect-square overflow-hidden rounded-2xl bg-gray-200 shadow-lg">
{/* Background Image */}
{meal.photo_path && !imageError ? (
<Image
source={{ uri: meal.photo_path }}
className="h-full w-full"
resizeMode="cover"
onError={(error) => {
console.error('MealCard image loading error:', error);
console.log('MealCard photo_path:', meal.photo_path);
setImageError(true);
}}
onLoad={() => {
console.log('MealCard image loaded successfully:', meal.photo_path);
setImageError(false);
}}
/>
) : (
<View className="h-full w-full items-center justify-center bg-gray-300">
<Text className="text-6xl">{getMealTypeIcon(meal.meal_type)}</Text>
</View>
)}
{/* Blurry Stats Overlay */}
<View className="absolute bottom-0 left-0 right-0">
<View className="bg-black/70 px-4 py-3 backdrop-blur-sm">
<View className="flex-row items-start justify-between">
{/* Left side - Meal info */}
<View className="flex-1">
<Text className="text-lg font-bold text-white" numberOfLines={1}>
{generateMealTitle(meal)}
</Text>
<View className="flex-row items-center space-x-2">
<Text className="text-sm text-gray-300">{getMealTypeLabel(meal.meal_type)}</Text>
<Text className="text-sm text-gray-400"></Text>
<Text className="text-sm text-gray-300">{formatTime(meal.timestamp)}</Text>
</View>
{/* Location if available */}
{meal.location && (
<View className="mt-1 flex-row items-center">
<Ionicons name="location-outline" size={12} color="#d1d5db" />
<Text className="ml-1 text-xs text-gray-300" numberOfLines={1}>
{meal.location}
</Text>
</View>
)}
</View>
{/* Right side - Stats */}
<View className="items-end">
{meal.analysis_status === 'completed' && (
<View className="flex-row items-center space-x-3">
{/* Calories */}
{meal.total_calories && (
<View className="items-center">
<Text className="text-xs text-gray-300">cal</Text>
<Text className="font-bold text-white">
{Math.round(meal.total_calories)}
</Text>
</View>
)}
{/* Health Score */}
{meal.health_score && (
<View className="items-center">
<Text className="text-xs text-gray-300">health</Text>
<Text className={`font-bold ${getHealthScoreColor(meal.health_score)}`}>
{Math.round(meal.health_score)}
</Text>
</View>
)}
{/* Rating */}
{meal.user_rating && (
<View className="items-center">
<Text className="text-xs text-gray-300">rating</Text>
<Text className="font-bold text-yellow-400">{meal.user_rating}/5</Text>
</View>
)}
</View>
)}
{/* Analysis Status for non-completed */}
{meal.analysis_status !== 'completed' && (
<View className="rounded-full bg-black/30 p-1">
<AnalysisStatusIndicator status={meal.analysis_status} mini={true} />
</View>
)}
</View>
</View>
{/* User Notes */}
{meal.user_notes && (
<Text className="mt-2 text-sm italic text-gray-200" numberOfLines={1}>
&ldquo;{meal.user_notes}&rdquo;
</Text>
)}
</View>
</View>
</View>
</TouchableOpacity>
);
};