feat(frontend): implement cook mode UI with wake lock and progress tracking

This commit is contained in:
Paul Huliganga 2026-03-24 03:41:58 -04:00
parent 67a9a8ce16
commit 36489a3f85
2 changed files with 297 additions and 15 deletions

12
TODO.md
View File

@ -21,12 +21,11 @@
- [x] Set up React Router
- [x] Create recipe list page
- [x] Create recipe detail/edit page
- [ ] Implement cook mode UI
- [x] Implement cook mode UI
### Features
- [ ] Tag management (create, assign, filter)
- [ ] Text search (title + ingredients)
- [ ] Screen wake lock for cook mode
- [ ] Basic error handling + loading states
### DevOps
@ -46,6 +45,15 @@
## ✅ Completed Tasks
### 2026-03-24
- **Cook mode UI implementation**
- Implemented full cooking interface with ingredient and step checklists
- Added progress tracking with visual progress bars
- Integrated Screen Wake Lock API to prevent screen sleep during cooking
- Created touch-friendly UI with large text and clear spacing
- Added completion celebration when all steps are done
- Included loading and error states with navigation back to recipe detail
- Verified TypeScript compilation and Vite build succeed
- **Recipe detail/edit page implementation**
- Created useRecipe hook for fetching single recipe with loading/error states
- Created RecipeForm component with full validation (title, ingredients, instructions required)

View File

@ -1,27 +1,301 @@
import { useParams } from 'react-router-dom';
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useRecipe } from '../hooks/useRecipe';
/**
* CookModePage - Hands-free cooking interface with wake lock
*/
export function CookModePage() {
const { id } = useParams<{ id: string }>();
const recipeId = id ? parseInt(id, 10) : null;
const { recipe, loading, error } = useRecipe(recipeId);
// Track checked ingredients and steps
const [checkedIngredients, setCheckedIngredients] = useState<Set<number>>(new Set());
const [checkedSteps, setCheckedSteps] = useState<Set<number>>(new Set());
// Wake lock state
const [wakeLock, setWakeLock] = useState<WakeLockSentinel | null>(null);
const [wakeLockSupported, setWakeLockSupported] = useState(false);
// Check if Wake Lock API is supported
useEffect(() => {
setWakeLockSupported('wakeLock' in navigator);
}, []);
// Request wake lock
const requestWakeLock = async () => {
if (!wakeLockSupported) return;
try {
const lock = await navigator.wakeLock.request('screen');
setWakeLock(lock);
// Handle wake lock release
lock.addEventListener('release', () => {
setWakeLock(null);
});
} catch (err) {
console.error('Failed to request wake lock:', err);
}
};
// Release wake lock
const releaseWakeLock = async () => {
if (wakeLock) {
await wakeLock.release();
setWakeLock(null);
}
};
// Toggle wake lock
const toggleWakeLock = () => {
if (wakeLock) {
releaseWakeLock();
} else {
requestWakeLock();
}
};
// Release wake lock when leaving page
useEffect(() => {
return () => {
if (wakeLock) {
wakeLock.release();
}
};
}, [wakeLock]);
// Toggle ingredient checkbox
const toggleIngredient = (index: number) => {
setCheckedIngredients(prev => {
const next = new Set(prev);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
return next;
});
};
// Toggle step checkbox
const toggleStep = (index: number) => {
setCheckedSteps(prev => {
const next = new Set(prev);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
return next;
});
};
// Loading state
if (loading) {
return (
<div className="flex justify-center items-center min-h-[50vh]">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
<p className="mt-4 text-gray-600">Loading recipe...</p>
</div>
</div>
);
}
// Error state
if (error || !recipe) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-6">
<h2 className="text-xl font-bold text-red-800 mb-2">Error Loading Recipe</h2>
<p className="text-red-600 mb-4">{error || 'Recipe not found'}</p>
<Link
to="/"
className="inline-block px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Back to Recipes
</Link>
</div>
);
}
// Calculate progress
const ingredientsTotal = recipe.ingredients.length;
const ingredientsChecked = checkedIngredients.size;
const stepsTotal = recipe.instructions.length;
const stepsChecked = checkedSteps.size;
const ingredientsProgress = ingredientsTotal > 0 ? (ingredientsChecked / ingredientsTotal) * 100 : 0;
const stepsProgress = stepsTotal > 0 ? (stepsChecked / stepsTotal) * 100 : 0;
return (
<div>
<div className="mb-6">
<h2 className="text-2xl font-bold text-gray-900">
Cook Mode {id && `- Recipe #${id}`}
</h2>
<p className="mt-1 text-sm text-gray-500">
Step-by-step cooking interface
</p>
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<h1 className="text-3xl font-bold text-gray-900 mb-2">{recipe.title}</h1>
{recipe.description && (
<p className="text-gray-600 text-lg">{recipe.description}</p>
)}
</div>
<Link
to={`/recipe/${recipe.id}`}
className="ml-4 px-4 py-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors text-sm font-medium"
>
Exit Cook Mode
</Link>
</div>
{/* Recipe metadata */}
<div className="flex flex-wrap gap-4 text-sm text-gray-600 mb-4">
{recipe.servings && (
<div className="flex items-center">
<span className="font-medium">Servings:</span>
<span className="ml-1">{recipe.servings}</span>
</div>
)}
{recipe.prep_time_minutes && (
<div className="flex items-center">
<span className="font-medium">Prep:</span>
<span className="ml-1">{recipe.prep_time_minutes} min</span>
</div>
)}
{recipe.cook_time_minutes && (
<div className="flex items-center">
<span className="font-medium">Cook:</span>
<span className="ml-1">{recipe.cook_time_minutes} min</span>
</div>
)}
</div>
{/* Wake lock toggle */}
{wakeLockSupported && (
<div className="border-t pt-4">
<button
onClick={toggleWakeLock}
className={`w-full sm:w-auto px-6 py-3 rounded-lg font-medium transition-colors ${
wakeLock
? 'bg-green-600 text-white hover:bg-green-700'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
{wakeLock ? '🔒 Screen Locked (Stay Awake)' : '🔓 Screen Will Sleep (Tap to Lock)'}
</button>
<p className="mt-2 text-sm text-gray-500">
{wakeLock
? 'Your screen will stay on while cooking'
: 'Enable to prevent your screen from turning off'}
</p>
</div>
)}
</div>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-gray-600">
Cook mode interface will be implemented later
</p>
{/* Ingredients Section */}
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold text-gray-900">Ingredients</h2>
<div className="text-sm font-medium text-gray-600">
{ingredientsChecked} of {ingredientsTotal}
</div>
</div>
{/* Progress bar */}
<div className="mb-6 bg-gray-200 rounded-full h-2 overflow-hidden">
<div
className="bg-green-600 h-full transition-all duration-300"
style={{ width: `${ingredientsProgress}%` }}
/>
</div>
{/* Ingredient checklist */}
<div className="space-y-3">
{recipe.ingredients.map((ingredient, index) => (
<label
key={index}
className="flex items-start gap-3 p-3 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors"
>
<input
type="checkbox"
checked={checkedIngredients.has(index)}
onChange={() => toggleIngredient(index)}
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
/>
<span className={`text-lg flex-1 ${
checkedIngredients.has(index) ? 'line-through text-gray-500' : 'text-gray-900'
}`}>
{ingredient}
</span>
</label>
))}
</div>
</div>
{/* Instructions Section */}
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold text-gray-900">Instructions</h2>
<div className="text-sm font-medium text-gray-600">
{stepsChecked} of {stepsTotal}
</div>
</div>
{/* Progress bar */}
<div className="mb-6 bg-gray-200 rounded-full h-2 overflow-hidden">
<div
className="bg-blue-600 h-full transition-all duration-300"
style={{ width: `${stepsProgress}%` }}
/>
</div>
{/* Instruction steps */}
<div className="space-y-4">
{recipe.instructions.map((instruction, index) => (
<label
key={index}
className="flex items-start gap-4 p-4 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors border-l-4 border-transparent hover:border-blue-600"
>
<div className="flex items-center gap-3">
<div className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold ${
checkedSteps.has(index)
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-600'
}`}>
{index + 1}
</div>
<input
type="checkbox"
checked={checkedSteps.has(index)}
onChange={() => toggleStep(index)}
className="h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
/>
</div>
<span className={`text-lg flex-1 ${
checkedSteps.has(index) ? 'line-through text-gray-500' : 'text-gray-900'
}`}>
{instruction}
</span>
</label>
))}
</div>
</div>
{/* Completion message */}
{ingredientsChecked === ingredientsTotal && stepsChecked === stepsTotal && (
<div className="bg-green-50 border-2 border-green-500 rounded-lg p-6 mb-6 text-center">
<div className="text-4xl mb-3">🎉</div>
<h3 className="text-2xl font-bold text-green-800 mb-2">All Done!</h3>
<p className="text-green-700 text-lg mb-4">
You've completed all steps. Enjoy your meal!
</p>
<Link
to={`/recipe/${recipe.id}`}
className="inline-block px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium"
>
Back to Recipe
</Link>
</div>
)}
</div>
);
}