managarten/nutriphi/apps/mobile/components/meals/FoodItemCard.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

161 lines
5.6 KiB
TypeScript

import React from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FoodItem } from '@/types/Database';
interface FoodItemCardProps {
foodItem: FoodItem;
categoryColor?: string;
onPress?: () => void;
showDetails?: boolean;
}
export const FoodItemCard: React.FC<FoodItemCardProps> = (props) => {
const {
foodItem,
categoryColor = 'border-gray-200 bg-gray-50',
onPress,
showDetails = true,
} = props;
const formatValue = (value?: number, unit: string = 'g') => {
if (value === undefined || value === null) return '--';
return `${Math.round(value)}${unit}`;
};
const getConfidenceColor = (confidence?: number) => {
if (!confidence) return 'text-gray-400';
if (confidence >= 0.8) return 'text-green-600';
if (confidence >= 0.6) return 'text-yellow-600';
return 'text-red-600';
};
const getConfidenceIcon = (confidence?: number) => {
if (!confidence) return 'help-outline';
if (confidence >= 0.8) return 'checkmark-circle-outline';
if (confidence >= 0.6) return 'warning-outline';
return 'alert-circle-outline';
};
const renderNutritionValue = (
label: string,
value?: number,
unit: string = 'g',
color: string = 'text-gray-700'
) => {
if (value === undefined || value === null) return null;
return (
<View className="items-center">
<Text className={`text-sm font-medium ${color}`}>{formatValue(value, unit)}</Text>
<Text className="text-xs uppercase tracking-wide text-gray-500">{label}</Text>
</View>
);
};
const CardComponent = onPress ? TouchableOpacity : View;
return (
<CardComponent
onPress={onPress}
activeOpacity={onPress ? 0.7 : 1}
className={`rounded-lg border p-4 ${categoryColor}`}>
{/* Header */}
<View className="mb-3 flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-semibold text-gray-900">{foodItem.name}</Text>
{foodItem.portion_size && (
<Text className="text-sm text-gray-600">{foodItem.portion_size}</Text>
)}
</View>
{/* Confidence Indicator */}
{foodItem.confidence && (
<View className="flex-row items-center">
<Ionicons
name={getConfidenceIcon(foodItem.confidence)}
size={16}
color={
getConfidenceColor(foodItem.confidence) === 'text-green-600'
? '#16a34a'
: getConfidenceColor(foodItem.confidence) === 'text-yellow-600'
? '#ca8a04'
: '#dc2626'
}
/>
<Text className={`ml-1 text-xs ${getConfidenceColor(foodItem.confidence)}`}>
{Math.round(foodItem.confidence * 100)}%
</Text>
</View>
)}
</View>
{/* Nutrition Information */}
{showDetails && (
<View className="space-y-3">
{/* Main Calories */}
{foodItem.calories && (
<View className="rounded-lg border border-gray-100 bg-white p-3">
<Text className="text-center text-lg font-bold text-gray-900">
{formatValue(foodItem.calories, ' kcal')}
</Text>
<Text className="text-center text-xs uppercase tracking-wide text-gray-500">
Kalorien
</Text>
</View>
)}
{/* Macronutrients */}
{(foodItem.protein || foodItem.carbs || foodItem.fat) && (
<View className="flex-row justify-between">
{renderNutritionValue('Protein', foodItem.protein, 'g', 'text-blue-600')}
{renderNutritionValue('Kohlenhydrate', foodItem.carbs, 'g', 'text-green-600')}
{renderNutritionValue('Fett', foodItem.fat, 'g', 'text-orange-600')}
</View>
)}
{/* Additional nutrients */}
{(foodItem.fiber || foodItem.sugar) && (
<View className="flex-row justify-between">
{renderNutritionValue('Ballaststoffe', foodItem.fiber, 'g', 'text-purple-600')}
{renderNutritionValue('Zucker', foodItem.sugar, 'g', 'text-pink-600')}
<View /> {/* Spacer for alignment */}
</View>
)}
{/* Food Properties */}
<View className="flex-row flex-wrap gap-2">
{Boolean(foodItem.is_organic) && (
<View className="rounded-full bg-green-100 px-2 py-1">
<Text className="text-xs font-medium text-green-800">🌱 Bio</Text>
</View>
)}
{Boolean(foodItem.is_processed) && (
<View className="rounded-full bg-orange-100 px-2 py-1">
<Text className="text-xs font-medium text-orange-800">📦 Verarbeitet</Text>
</View>
)}
</View>
{/* Allergens */}
{foodItem.allergens && (
<View>
<Text className="mb-1 text-xs text-gray-600">Allergene:</Text>
<Text className="text-xs text-red-600">
{(() => {
try {
const allergens = JSON.parse(foodItem.allergens);
return Array.isArray(allergens) && allergens.length > 0
? allergens.join(', ')
: 'Keine';
} catch {
return 'Keine';
}
})()}
</Text>
</View>
)}
</View>
)}
</CardComponent>
);
};