feat: Initialize GreenLens project with core dependencies and structure
Sets up the project using Vite, React, and TypeScript. Includes initial configuration for Tailwind CSS, Gemini API integration, and local storage management. Defines basic types for plant data and UI elements. The README is updated with local development instructions.
This commit is contained in:
59
components/PlantCard.tsx
Normal file
59
components/PlantCard.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { Plant } from '../types';
|
||||
import { Droplets } from 'lucide-react';
|
||||
|
||||
interface PlantCardProps {
|
||||
plant: Plant;
|
||||
onClick: () => void;
|
||||
t: any; // Using any for simplicity with the dynamic translation object
|
||||
}
|
||||
|
||||
export const PlantCard: React.FC<PlantCardProps> = ({ plant, onClick, t }) => {
|
||||
const daysUntilWatering = plant.careInfo.waterIntervalDays;
|
||||
// Very basic check logic for MVP
|
||||
const isUrgent = daysUntilWatering <= 1;
|
||||
|
||||
const wateringText = isUrgent
|
||||
? t.waterToday
|
||||
: t.inXDays.replace('{0}', daysUntilWatering.toString());
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="relative aspect-[4/5] rounded-2xl overflow-hidden shadow-md group active:scale-[0.98] transition-transform w-full text-left"
|
||||
>
|
||||
<img
|
||||
src={plant.imageUri}
|
||||
alt={plant.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
{/* Gradient Overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent" />
|
||||
|
||||
{/* Badge */}
|
||||
<div className="absolute top-3 left-3">
|
||||
<div className={`flex items-center space-x-1.5 px-2.5 py-1 rounded-full backdrop-blur-md ${
|
||||
isUrgent
|
||||
? 'bg-orange-500 text-white'
|
||||
: 'bg-black/40 text-stone-200 border border-white/10'
|
||||
}`}>
|
||||
<Droplets size={10} className="fill-current" />
|
||||
<span className="text-[10px] font-bold">
|
||||
{wateringText}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="absolute bottom-4 left-3 right-3">
|
||||
<h3 className="text-white font-bold text-lg leading-tight font-serif mb-0.5 shadow-sm">
|
||||
{plant.name}
|
||||
</h3>
|
||||
<p className="text-stone-300 text-xs truncate">
|
||||
{plant.botanicalName}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
302
components/PlantDetail.tsx
Normal file
302
components/PlantDetail.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Plant, Language } from '../types';
|
||||
import { Droplets, Sun, Thermometer, ArrowLeft, Calendar, Trash2, Share2, Edit2, AlertCircle, Check, Clock, Bell, BellOff } from 'lucide-react';
|
||||
|
||||
interface PlantDetailProps {
|
||||
plant: Plant;
|
||||
onClose: () => void;
|
||||
onDelete: (id: string) => void;
|
||||
onUpdate: (plant: Plant) => void;
|
||||
t: any;
|
||||
language: Language;
|
||||
}
|
||||
|
||||
export const PlantDetail: React.FC<PlantDetailProps> = ({ plant, onClose, onDelete, onUpdate, t, language }) => {
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
// Map internal language codes to locale strings for Date
|
||||
const localeMap: Record<string, string> = {
|
||||
de: 'de-DE',
|
||||
en: 'en-US',
|
||||
es: 'es-ES'
|
||||
};
|
||||
|
||||
const formattedAddedDate = new Date(plant.dateAdded).toLocaleDateString(localeMap[language] || 'de-DE');
|
||||
const formattedWateredDate = new Date(plant.lastWatered).toLocaleDateString(localeMap[language] || 'de-DE');
|
||||
|
||||
// Calculate next watering date
|
||||
const lastWateredObj = new Date(plant.lastWatered);
|
||||
const nextWateringDate = new Date(lastWateredObj);
|
||||
nextWateringDate.setDate(lastWateredObj.getDate() + plant.careInfo.waterIntervalDays);
|
||||
|
||||
const formattedNextWatering = nextWateringDate.toLocaleDateString(localeMap[language] || 'de-DE', { weekday: 'long', day: 'numeric', month: 'numeric' });
|
||||
const nextWateringText = t.nextWatering.replace('{0}', formattedNextWatering);
|
||||
const lastWateredText = t.lastWateredDate.replace('{0}', formattedWateredDate);
|
||||
|
||||
// Check if watered today
|
||||
const isWateredToday = new Date(plant.lastWatered).toDateString() === new Date().toDateString();
|
||||
|
||||
const handleWaterPlant = () => {
|
||||
const now = new Date().toISOString();
|
||||
// Update history: add new date to the beginning, keep last 10 entries max
|
||||
const currentHistory = plant.wateringHistory || [];
|
||||
const newHistory = [now, ...currentHistory].slice(0, 10);
|
||||
|
||||
const updatedPlant = {
|
||||
...plant,
|
||||
lastWatered: now,
|
||||
wateringHistory: newHistory
|
||||
};
|
||||
onUpdate(updatedPlant);
|
||||
};
|
||||
|
||||
const toggleReminder = async () => {
|
||||
const newValue = !plant.notificationsEnabled;
|
||||
|
||||
if (newValue) {
|
||||
// Request permission if enabling
|
||||
if (!('Notification' in window)) {
|
||||
alert("Notifications are not supported by this browser.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (Notification.permission === 'granted') {
|
||||
onUpdate({ ...plant, notificationsEnabled: true });
|
||||
} else if (Notification.permission !== 'denied') {
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission === 'granted') {
|
||||
onUpdate({ ...plant, notificationsEnabled: true });
|
||||
}
|
||||
} else {
|
||||
alert(t.reminderPermissionNeeded);
|
||||
}
|
||||
} else {
|
||||
// Disabling
|
||||
onUpdate({ ...plant, notificationsEnabled: false });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
onDelete(plant.id);
|
||||
};
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
setShowDeleteConfirm(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col h-full bg-stone-50 dark:bg-stone-950 overflow-y-auto no-scrollbar animate-in slide-in-from-right duration-300">
|
||||
|
||||
{/* Header */}
|
||||
<div className="absolute top-0 left-0 right-0 z-10 flex justify-between items-center p-6 text-stone-900 dark:text-white">
|
||||
<button onClick={onClose} className="bg-white/80 dark:bg-black/50 backdrop-blur-md p-2 rounded-full shadow-sm">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<div className="flex space-x-2">
|
||||
<button className="bg-white/80 dark:bg-black/50 backdrop-blur-md p-2 rounded-full shadow-sm">
|
||||
<Share2 size={20} />
|
||||
</button>
|
||||
<button className="bg-white/80 dark:bg-black/50 backdrop-blur-md p-2 rounded-full shadow-sm">
|
||||
<Edit2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
{/* Hero Image */}
|
||||
<div className="relative w-full aspect-[4/5] md:aspect-video rounded-b-[2.5rem] overflow-hidden shadow-lg mb-6">
|
||||
<img src={plant.imageUri} alt={plant.name} className="w-full h-full object-cover" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
|
||||
|
||||
<div className="absolute bottom-8 left-6 right-6">
|
||||
<h1 className="text-3xl font-serif font-bold text-white leading-tight mb-1 shadow-sm">
|
||||
{plant.name}
|
||||
</h1>
|
||||
<p className="text-stone-200 italic text-sm">
|
||||
{plant.botanicalName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Container */}
|
||||
<div className="px-6 pb-24">
|
||||
{/* Added Date Info */}
|
||||
<div className="flex justify-between items-center text-xs text-stone-500 dark:text-stone-400 mb-6">
|
||||
<div className="flex items-center space-x-1.5 bg-white dark:bg-stone-900 px-3 py-1.5 rounded-full border border-stone-100 dark:border-stone-800">
|
||||
<Calendar size={12} />
|
||||
<span>{t.addedOn} {formattedAddedDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Action: Water */}
|
||||
<div className={`mb-3 p-4 rounded-2xl border flex justify-between items-center transition-colors ${
|
||||
isWateredToday
|
||||
? 'bg-green-50 dark:bg-green-900/10 border-green-100 dark:border-green-800/30'
|
||||
: 'bg-blue-50 dark:bg-blue-900/10 border-blue-100 dark:border-blue-800/30'
|
||||
}`}>
|
||||
<div>
|
||||
<span className="block text-xs text-stone-500 dark:text-stone-400 font-medium mb-0.5">{lastWateredText}</span>
|
||||
<span className={`block text-sm font-bold ${isWateredToday ? 'text-green-700 dark:text-green-300' : 'text-stone-900 dark:text-stone-200'}`}>
|
||||
{nextWateringText}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleWaterPlant}
|
||||
disabled={isWateredToday}
|
||||
className={`px-4 py-2.5 rounded-xl font-bold text-xs flex items-center shadow-lg transition-all ${
|
||||
isWateredToday
|
||||
? 'bg-green-500 text-white cursor-default shadow-green-500/30'
|
||||
: 'bg-blue-500 hover:bg-blue-600 active:scale-95 text-white shadow-blue-500/30'
|
||||
}`}
|
||||
>
|
||||
{isWateredToday ? (
|
||||
<>
|
||||
<Check size={14} className="mr-2" />
|
||||
{t.watered}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Droplets size={14} className="mr-2 fill-current" />
|
||||
{t.waterNow}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Reminder Toggle */}
|
||||
<div className="mb-8 flex items-center justify-between p-3 rounded-xl bg-white dark:bg-stone-900 border border-stone-100 dark:border-stone-800">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`p-2 rounded-full ${plant.notificationsEnabled ? 'bg-amber-100 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400' : 'bg-stone-100 text-stone-400 dark:bg-stone-800 dark:text-stone-500'}`}>
|
||||
{plant.notificationsEnabled ? <Bell size={18} /> : <BellOff size={18} />}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-bold text-stone-900 dark:text-stone-100">{t.reminder}</span>
|
||||
<span className="text-[10px] text-stone-500">{plant.notificationsEnabled ? t.reminderOn : t.reminderOff}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleReminder}
|
||||
className={`w-11 h-6 rounded-full relative transition-colors duration-300 ${plant.notificationsEnabled ? 'bg-primary-500' : 'bg-stone-300 dark:bg-stone-700'}`}
|
||||
>
|
||||
<div className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-all duration-300 shadow-sm ${plant.notificationsEnabled ? 'left-6' : 'left-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 className="font-bold text-stone-900 dark:text-stone-100 mb-2">{t.aboutPlant}</h3>
|
||||
<p className="text-stone-600 dark:text-stone-300 text-sm leading-relaxed mb-8">
|
||||
{plant.description || t.noDescription}
|
||||
</p>
|
||||
|
||||
{/* Care Info */}
|
||||
<h3 className="font-bold text-stone-900 dark:text-stone-100 mb-4">{t.careTips}</h3>
|
||||
<div className="grid grid-cols-3 gap-3 mb-10">
|
||||
<div className="bg-white dark:bg-stone-900 p-3 rounded-2xl border border-stone-100 dark:border-stone-800 flex flex-col items-center text-center shadow-sm">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-500 flex items-center justify-center mb-2">
|
||||
<Droplets size={16} className="fill-current" />
|
||||
</div>
|
||||
<span className="text-[10px] text-stone-400 font-medium mb-0.5">{t.water}</span>
|
||||
<span className="text-xs font-bold text-stone-800 dark:text-stone-200">
|
||||
{plant.careInfo.waterIntervalDays} {t.days || 'Tage'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-stone-900 p-3 rounded-2xl border border-stone-100 dark:border-stone-800 flex flex-col items-center text-center shadow-sm">
|
||||
<div className="w-8 h-8 rounded-full bg-amber-50 dark:bg-amber-900/20 text-amber-500 flex items-center justify-center mb-2">
|
||||
<Sun size={16} className="fill-current" />
|
||||
</div>
|
||||
<span className="text-[10px] text-stone-400 font-medium mb-0.5">{t.light}</span>
|
||||
<span className="text-xs font-bold text-stone-800 dark:text-stone-200 truncate w-full">
|
||||
{plant.careInfo.light}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-stone-900 p-3 rounded-2xl border border-stone-100 dark:border-stone-800 flex flex-col items-center text-center shadow-sm">
|
||||
<div className="w-8 h-8 rounded-full bg-rose-50 dark:bg-rose-900/20 text-rose-500 flex items-center justify-center mb-2">
|
||||
<Thermometer size={16} className="fill-current" />
|
||||
</div>
|
||||
<span className="text-[10px] text-stone-400 font-medium mb-0.5">{t.temp}</span>
|
||||
<span className="text-xs font-bold text-stone-800 dark:text-stone-200">
|
||||
{plant.careInfo.temp}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Watering History Section */}
|
||||
<h3 className="font-bold text-stone-900 dark:text-stone-100 mb-4">{t.wateringHistory}</h3>
|
||||
<div className="bg-white dark:bg-stone-900 rounded-2xl border border-stone-100 dark:border-stone-800 overflow-hidden mb-10 shadow-sm">
|
||||
{(!plant.wateringHistory || plant.wateringHistory.length === 0) ? (
|
||||
<div className="p-6 text-center text-stone-400 text-sm">
|
||||
<Clock size={24} className="mx-auto mb-2 opacity-50" />
|
||||
{t.noHistory}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-stone-100 dark:divide-stone-800">
|
||||
{plant.wateringHistory.slice(0, 5).map((dateStr, index) => (
|
||||
<li key={index} className="px-5 py-3 flex justify-between items-center group hover:bg-stone-50 dark:hover:bg-stone-800/50 transition-colors">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-50 dark:bg-blue-900/10 text-blue-500 flex items-center justify-center">
|
||||
<Droplets size={14} className="fill-current" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-stone-700 dark:text-stone-300">
|
||||
{new Date(dateStr).toLocaleDateString(localeMap[language] || 'de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-stone-400 bg-stone-100 dark:bg-stone-800 px-2 py-1 rounded-md">
|
||||
{new Date(dateStr).toLocaleTimeString(localeMap[language] || 'de-DE', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="border-t border-stone-200 dark:border-stone-800 pt-6 flex justify-center">
|
||||
<button
|
||||
onClick={handleDeleteClick}
|
||||
className="flex items-center space-x-2 text-red-500 hover:text-red-600 px-4 py-2 rounded-xl hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
<span className="text-sm font-medium">{t.delete}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="absolute inset-0 z-[60] bg-black/40 backdrop-blur-sm flex items-center justify-center p-6 animate-in fade-in duration-200">
|
||||
<div className="bg-white dark:bg-stone-900 w-full max-w-sm rounded-2xl p-6 shadow-2xl scale-100 animate-in zoom-in-95 duration-200">
|
||||
<div className="w-12 h-12 bg-red-100 dark:bg-red-900/30 text-red-500 rounded-full flex items-center justify-center mb-4 mx-auto">
|
||||
<AlertCircle size={24} />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-center text-stone-900 dark:text-white mb-2">{t.deleteConfirmTitle}</h3>
|
||||
<p className="text-stone-500 dark:text-stone-400 text-center text-sm mb-6 leading-relaxed">
|
||||
{t.deleteConfirmMessage}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={handleCancelDelete}
|
||||
className="py-3 px-4 rounded-xl bg-stone-100 dark:bg-stone-800 text-stone-700 dark:text-stone-300 font-bold text-sm"
|
||||
>
|
||||
{t.cancel}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmDelete}
|
||||
className="py-3 px-4 rounded-xl bg-red-500 text-white font-bold text-sm hover:bg-red-600"
|
||||
>
|
||||
{t.confirm}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
21
components/PlantSkeleton.tsx
Normal file
21
components/PlantSkeleton.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export const PlantSkeleton: React.FC = () => {
|
||||
return (
|
||||
<div className="relative aspect-[4/5] rounded-2xl overflow-hidden bg-stone-200 dark:bg-stone-800 animate-pulse border border-stone-300 dark:border-stone-700/50">
|
||||
|
||||
{/* Badge Placeholder */}
|
||||
<div className="absolute top-3 left-3">
|
||||
<div className="h-6 w-20 bg-stone-300 dark:bg-stone-700 rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* Content Placeholder */}
|
||||
<div className="absolute bottom-4 left-3 right-3 space-y-2">
|
||||
{/* Title */}
|
||||
<div className="h-6 w-3/4 bg-stone-300 dark:bg-stone-700 rounded-md" />
|
||||
{/* Subtitle */}
|
||||
<div className="h-3 w-1/2 bg-stone-300 dark:bg-stone-700 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
140
components/ResultCard.tsx
Normal file
140
components/ResultCard.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { IdentificationResult } from '../types';
|
||||
import { Droplets, Sun, Thermometer, CheckCircle2, ArrowLeft, Share2 } from 'lucide-react';
|
||||
|
||||
interface ResultCardProps {
|
||||
result: IdentificationResult;
|
||||
imageUri: string;
|
||||
onSave: () => void;
|
||||
onClose: () => void;
|
||||
t: any;
|
||||
}
|
||||
|
||||
export const ResultCard: React.FC<ResultCardProps> = ({ result, imageUri, onSave, onClose, t }) => {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-stone-50 dark:bg-stone-950 overflow-y-auto no-scrollbar animate-in slide-in-from-right duration-300">
|
||||
|
||||
{/* Header */}
|
||||
<div className="absolute top-0 left-0 right-0 z-10 flex justify-between items-center p-6 text-stone-900 dark:text-white">
|
||||
<button onClick={onClose} className="bg-white/80 dark:bg-black/50 backdrop-blur-md p-2 rounded-full shadow-sm">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<span className="font-bold text-sm bg-white/80 dark:bg-black/50 backdrop-blur-md px-3 py-1 rounded-full">{t.result}</span>
|
||||
<button className="bg-white/80 dark:bg-black/50 backdrop-blur-md p-2 rounded-full shadow-sm">
|
||||
<Share2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 pt-20">
|
||||
{/* Hero Image */}
|
||||
<div className="relative w-full aspect-[4/3] rounded-[2rem] overflow-hidden shadow-lg mb-6">
|
||||
<img src={imageUri} alt="Analyzed Plant" className="w-full h-full object-cover" />
|
||||
<div className="absolute bottom-4 left-4 bg-white/90 backdrop-blur-sm text-primary-700 px-3 py-1.5 rounded-full text-xs font-bold shadow-sm flex items-center">
|
||||
<CheckCircle2 size={14} className="mr-1.5 fill-primary-600 text-white" />
|
||||
{Math.round(result.confidence * 100)}% {t.match}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="px-2">
|
||||
<h1 className="text-3xl font-serif font-bold text-stone-900 dark:text-stone-100 leading-tight mb-1">
|
||||
{result.name}
|
||||
</h1>
|
||||
<p className="text-stone-400 dark:text-stone-500 italic text-sm mb-6">
|
||||
{result.botanicalName}
|
||||
</p>
|
||||
|
||||
<p className="text-stone-600 dark:text-stone-300 text-sm leading-relaxed mb-8">
|
||||
{result.description || t.noDescription}
|
||||
</p>
|
||||
|
||||
{/* Care Check */}
|
||||
<div className="flex justify-between items-end mb-4">
|
||||
<h3 className="font-bold text-stone-900 dark:text-stone-100">{t.careCheck}</h3>
|
||||
<button
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
className="text-[10px] font-bold text-primary-600 uppercase tracking-wide"
|
||||
>
|
||||
{showDetails ? t.hideDetails : t.showDetails}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 mb-8">
|
||||
<div className="bg-white dark:bg-stone-900 p-3 rounded-2xl border border-stone-100 dark:border-stone-800 flex flex-col items-center text-center shadow-sm">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-500 flex items-center justify-center mb-2">
|
||||
<Droplets size={16} className="fill-current" />
|
||||
</div>
|
||||
<span className="text-[10px] text-stone-400 font-medium mb-0.5">{t.water}</span>
|
||||
<span className="text-xs font-bold text-stone-800 dark:text-stone-200">
|
||||
{result.careInfo.waterIntervalDays <= 7 ? t.waterModerate : t.waterLittle}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-stone-900 p-3 rounded-2xl border border-stone-100 dark:border-stone-800 flex flex-col items-center text-center shadow-sm">
|
||||
<div className="w-8 h-8 rounded-full bg-amber-50 dark:bg-amber-900/20 text-amber-500 flex items-center justify-center mb-2">
|
||||
<Sun size={16} className="fill-current" />
|
||||
</div>
|
||||
<span className="text-[10px] text-stone-400 font-medium mb-0.5">{t.light}</span>
|
||||
<span className="text-xs font-bold text-stone-800 dark:text-stone-200 truncate w-full">
|
||||
{result.careInfo.light}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-stone-900 p-3 rounded-2xl border border-stone-100 dark:border-stone-800 flex flex-col items-center text-center shadow-sm">
|
||||
<div className="w-8 h-8 rounded-full bg-rose-50 dark:bg-rose-900/20 text-rose-500 flex items-center justify-center mb-2">
|
||||
<Thermometer size={16} className="fill-current" />
|
||||
</div>
|
||||
<span className="text-[10px] text-stone-400 font-medium mb-0.5">{t.temp}</span>
|
||||
<span className="text-xs font-bold text-stone-800 dark:text-stone-200">
|
||||
{result.careInfo.temp}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Details */}
|
||||
{showDetails && (
|
||||
<div className="mb-8 p-5 bg-stone-100 dark:bg-stone-900/50 rounded-2xl animate-in fade-in slide-in-from-top-2 border border-stone-200 dark:border-stone-800">
|
||||
<h4 className="font-bold text-xs mb-3 text-stone-700 dark:text-stone-300 uppercase tracking-wide">{t.detailedCare}</h4>
|
||||
<ul className="space-y-3 text-sm text-stone-600 dark:text-stone-300">
|
||||
<li className="flex items-start">
|
||||
<span className="mr-3 text-primary-500">•</span>
|
||||
<span>{t.careTextWater.replace('{0}', result.careInfo.waterIntervalDays.toString())}</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="mr-3 text-amber-500">•</span>
|
||||
<span>{t.careTextLight.replace('{0}', result.careInfo.light)}</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="mr-3 text-rose-500">•</span>
|
||||
<span>{t.careTextTemp.replace('{0}', result.careInfo.temp)}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex items-center justify-center space-x-2 text-xs text-stone-400 mb-4">
|
||||
<div className="w-3 h-3 rounded-sm border border-stone-300 flex items-center justify-center">
|
||||
<div className="w-1.5 h-1.5 bg-stone-400 rounded-[1px]"></div>
|
||||
</div>
|
||||
<span>{t.dataSavedLocally}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onSave}
|
||||
className="w-full py-4 bg-primary-500 hover:bg-primary-600 text-black font-bold text-sm rounded-xl shadow-lg shadow-primary-500/30 active:scale-[0.98] transition-all flex items-center justify-center"
|
||||
>
|
||||
<div className="bg-black/20 rounded-full p-0.5 mr-2">
|
||||
<CheckCircle2 size={14} className="text-black" />
|
||||
</div>
|
||||
{t.addToPlants}
|
||||
</button>
|
||||
<div className="h-8"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
51
components/TabBar.tsx
Normal file
51
components/TabBar.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { Tab } from '../types';
|
||||
import { LayoutGrid, Search, User } from 'lucide-react';
|
||||
|
||||
interface TabBarProps {
|
||||
currentTab: Tab;
|
||||
onTabChange: (tab: Tab) => void;
|
||||
labels: {
|
||||
home: string;
|
||||
search: string;
|
||||
settings: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const TabBar: React.FC<TabBarProps> = ({ currentTab, onTabChange, labels }) => {
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-stone-900 border-t border-stone-200 dark:border-stone-800 pb-safe pt-2 px-6 z-40">
|
||||
<div className="flex justify-between items-center h-16 max-w-sm mx-auto">
|
||||
<button
|
||||
onClick={() => onTabChange(Tab.HOME)}
|
||||
className={`flex flex-col items-center justify-center w-16 space-y-1.5 ${
|
||||
currentTab === Tab.HOME ? 'text-stone-900 dark:text-stone-100' : 'text-stone-400 dark:text-stone-600'
|
||||
}`}
|
||||
>
|
||||
<LayoutGrid size={24} strokeWidth={currentTab === Tab.HOME ? 2.5 : 2} />
|
||||
<span className="text-[10px] font-medium">{labels.home}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onTabChange(Tab.SEARCH)}
|
||||
className={`flex flex-col items-center justify-center w-16 space-y-1.5 ${
|
||||
currentTab === Tab.SEARCH ? 'text-stone-900 dark:text-stone-100' : 'text-stone-400 dark:text-stone-600'
|
||||
}`}
|
||||
>
|
||||
<Search size={24} strokeWidth={currentTab === Tab.SEARCH ? 2.5 : 2} />
|
||||
<span className="text-[10px] font-medium">{labels.search}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onTabChange(Tab.SETTINGS)}
|
||||
className={`flex flex-col items-center justify-center w-16 space-y-1.5 ${
|
||||
currentTab === Tab.SETTINGS ? 'text-stone-900 dark:text-stone-100' : 'text-stone-400 dark:text-stone-600'
|
||||
}`}
|
||||
>
|
||||
<User size={24} strokeWidth={currentTab === Tab.SETTINGS ? 2.5 : 2} />
|
||||
<span className="text-[10px] font-medium">{labels.settings}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
37
components/Toast.tsx
Normal file
37
components/Toast.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
|
||||
interface ToastProps {
|
||||
message: string;
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const Toast: React.FC<ToastProps> = ({ message, isVisible, onClose }) => {
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
setShow(true);
|
||||
const timer = setTimeout(() => {
|
||||
setShow(false);
|
||||
setTimeout(onClose, 300); // Wait for animation
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setShow(false);
|
||||
}
|
||||
}, [isVisible, onClose]);
|
||||
|
||||
if (!isVisible && !show) return null;
|
||||
|
||||
return (
|
||||
<div className={`fixed bottom-20 left-0 right-0 z-[70] flex justify-center pointer-events-none transition-all duration-300 transform ${show ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0'}`}>
|
||||
<div className="bg-stone-900 dark:bg-white text-white dark:text-stone-900 px-4 py-3 rounded-full shadow-lg flex items-center space-x-2">
|
||||
<CheckCircle2 size={18} className="text-green-500" />
|
||||
<span className="text-sm font-medium">{message}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user