feat(frontend): implement cook mode UI with wake lock and progress tracking
This commit is contained in:
parent
67a9a8ce16
commit
36489a3f85
12
TODO.md
12
TODO.md
|
|
@ -21,12 +21,11 @@
|
||||||
- [x] Set up React Router
|
- [x] Set up React Router
|
||||||
- [x] Create recipe list page
|
- [x] Create recipe list page
|
||||||
- [x] Create recipe detail/edit page
|
- [x] Create recipe detail/edit page
|
||||||
- [ ] Implement cook mode UI
|
- [x] Implement cook mode UI
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
- [ ] Tag management (create, assign, filter)
|
- [ ] Tag management (create, assign, filter)
|
||||||
- [ ] Text search (title + ingredients)
|
- [ ] Text search (title + ingredients)
|
||||||
- [ ] Screen wake lock for cook mode
|
|
||||||
- [ ] Basic error handling + loading states
|
- [ ] Basic error handling + loading states
|
||||||
|
|
||||||
### DevOps
|
### DevOps
|
||||||
|
|
@ -46,6 +45,15 @@
|
||||||
## ✅ Completed Tasks
|
## ✅ Completed Tasks
|
||||||
|
|
||||||
### 2026-03-24
|
### 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**
|
- **Recipe detail/edit page implementation**
|
||||||
- Created useRecipe hook for fetching single recipe with loading/error states
|
- Created useRecipe hook for fetching single recipe with loading/error states
|
||||||
- Created RecipeForm component with full validation (title, ingredients, instructions required)
|
- Created RecipeForm component with full validation (title, ingredients, instructions required)
|
||||||
|
|
|
||||||
|
|
@ -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
|
* CookModePage - Hands-free cooking interface with wake lock
|
||||||
*/
|
*/
|
||||||
export function CookModePage() {
|
export function CookModePage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
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 (
|
return (
|
||||||
<div>
|
<div className="flex justify-center items-center min-h-[50vh]">
|
||||||
<div className="mb-6">
|
<div className="text-center">
|
||||||
<h2 className="text-2xl font-bold text-gray-900">
|
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||||
Cook Mode {id && `- Recipe #${id}`}
|
<p className="mt-4 text-gray-600">Loading recipe...</p>
|
||||||
</h2>
|
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
|
||||||
Step-by-step cooking interface
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
|
||||||
<p className="text-gray-600">
|
|
||||||
Cook mode interface will be implemented later
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</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 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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue