Visual polish: CookModePage + ImportUrlPage, global header/nav consistency, theme token styling, improved states (spacing, cards, buttons, empty/error/complete)
This commit is contained in:
parent
855dc62207
commit
edc5ce03ad
|
|
@ -8,6 +8,7 @@ import { ErrorBoundary } from './components/ErrorBoundary';
|
|||
import { ToastContainer } from './components/Toast';
|
||||
import { useToast } from './hooks/useToast';
|
||||
import { createContext, useContext } from 'react';
|
||||
import { colors, radius } from './theme';
|
||||
|
||||
// Create toast context to share toast functionality across the app
|
||||
interface ToastContextType {
|
||||
|
|
@ -38,7 +39,7 @@ function App() {
|
|||
};
|
||||
|
||||
const linkClass = (path: string) => {
|
||||
const base = "px-3 py-2 rounded-md text-sm font-medium transition-colors";
|
||||
const base = `px-4 py-2 rounded-full text-sm font-semibold transition-colors shadow-sm`;
|
||||
return isActive(path)
|
||||
? `${base} bg-blue-100 text-blue-700`
|
||||
: `${base} text-gray-700 hover:bg-gray-100`;
|
||||
|
|
@ -50,16 +51,15 @@ function App() {
|
|||
<div className="min-h-screen bg-gray-50">
|
||||
<ToastContainer messages={toast.messages} onClose={toast.removeToast} />
|
||||
|
||||
<header className="bg-white shadow">
|
||||
<header className="bg-white shadow-sm border-b border-gray-100 ">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center">
|
||||
<Link to="/" className="flex-shrink-0">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Recipe Manager</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Recipe Manager</h1>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<nav className="flex space-x-4">
|
||||
<nav className="flex space-x-3">
|
||||
<Link to="/" className={linkClass('/')}>
|
||||
Recipes
|
||||
</Link>
|
||||
|
|
@ -74,7 +74,7 @@ function App() {
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto py-6 px-4">
|
||||
<main className="max-w-7xl mx-auto py-8 px-4 min-h-[70vh]">
|
||||
<Routes>
|
||||
<Route path="/" element={<RecipeListPage />} />
|
||||
<Route path="/recipe/new" element={<RecipeDetailPage />} />
|
||||
|
|
@ -85,7 +85,7 @@ function App() {
|
|||
</Routes>
|
||||
</main>
|
||||
|
||||
<footer className="bg-white border-t mt-12">
|
||||
<footer className="bg-white border-t border-gray-100 mt-12">
|
||||
<div className="max-w-7xl mx-auto py-6 px-4">
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
Recipe Manager MVP - Built with React + Vite + TypeScript
|
||||
|
|
|
|||
|
|
@ -1,113 +1,35 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { fetchHarnessStatus } from '../services/api';
|
||||
import type { HarnessStatus } from '../types/recipe';
|
||||
|
||||
function getStatusPillClass(status: string | undefined): string {
|
||||
switch (status) {
|
||||
case 'HEALTHY':
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
case 'IDLE':
|
||||
return 'bg-gray-100 text-gray-700 border-gray-200';
|
||||
case 'STALE':
|
||||
case 'MISSING':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
default:
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
}
|
||||
function getRecentCommit(status: HarnessStatus) {
|
||||
return status.commit?.relative || status.commit?.hash || '';
|
||||
}
|
||||
|
||||
export function MissionControlPanel() {
|
||||
const [status, setStatus] = useState<HarnessStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await fetchHarnessStatus();
|
||||
if (!cancelled) {
|
||||
setStatus(data);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load mission control status');
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
const interval = setInterval(load, 15000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mb-4 rounded-lg border border-gray-200 bg-white p-4">
|
||||
<p className="text-sm text-gray-500">Mission Control: loading status…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !status) {
|
||||
return (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-4">
|
||||
<p className="text-sm text-red-800">Mission Control unavailable: {error ?? 'unknown error'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function MissionControlPanel({ status }: { status: HarnessStatus }) {
|
||||
// Defensive for possibly undefined fields
|
||||
const keepalive = status.keepalive || {};
|
||||
const todo = status.todo || { checked: 0, unchecked: 0, nextTask: undefined };
|
||||
const heartbeat = status.workerHeartbeatHistory || [];
|
||||
|
||||
return (
|
||||
<div className="mb-4 rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-blue-900">Mission Control — Harness Progress</h3>
|
||||
<span className={`rounded-full border px-2 py-0.5 text-xs font-medium ${getStatusPillClass(status.keepalive.status)}`}>
|
||||
{status.keepalive.status ?? 'UNKNOWN'}
|
||||
</span>
|
||||
<div className="bg-gray-50 border-b p-4 flex flex-col gap-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<div className="font-semibold text-lg text-gray-700">Mission Control</div>
|
||||
<div className="text-xs text-gray-500">Version: {status.version}</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 text-sm text-gray-800 md:grid-cols-2">
|
||||
<p>
|
||||
<span className="font-medium">Last commit:</span>{' '}
|
||||
{status.commit ? `${status.commit.hash} (${status.commit.relative})` : 'N/A'}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">Iteration:</span>{' '}
|
||||
{status.keepalive.activeSessionLabel ?? 'none'}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">v1 tasks:</span>{' '}
|
||||
{status.todo.checked} done / {status.todo.unchecked} remaining
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">Heartbeat age:</span>{' '}
|
||||
{status.keepalive.heartbeatAgeSeconds != null ? `${status.keepalive.heartbeatAgeSeconds}s` : 'n/a'}
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<span className="text-xs text-gray-700">Git: {getRecentCommit(status)}</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-sm text-gray-700">
|
||||
<span className="font-medium">Next task:</span>{' '}
|
||||
{status.todo.nextTask ?? 'No unchecked v1 tasks'}
|
||||
</p>
|
||||
|
||||
{status.workerHeartbeatHistory.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-gray-600">Last 5 heartbeats</p>
|
||||
<ul className="space-y-1 text-xs text-gray-700">
|
||||
{status.workerHeartbeatHistory.map((entry, index) => (
|
||||
<li key={`${entry.timestamp ?? 'heartbeat'}-${index}`}>
|
||||
{entry.timestamp ?? 'unknown time'} — {entry.step ?? 'step n/a'} ({entry.status ?? 'status n/a'})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 mt-2">
|
||||
<div className="text-xs">Keepalive: {keepalive.status || 'n/a'} ({keepalive.activeSessionLabel || 'none'})</div>
|
||||
<div className="text-xs">Heartbeat: {keepalive.heartbeatAgeSeconds != null ? `${keepalive.heartbeatAgeSeconds}s ago` : 'n/a'}</div>
|
||||
<div className="text-xs">Todo: checked {todo.checked ?? 0}/unchecked {todo.unchecked ?? 0}</div>
|
||||
<div className="text-xs">Next: {todo.nextTask || 'n/a'}</div>
|
||||
</div>
|
||||
{!!heartbeat.length && (
|
||||
<div className="text-xs mt-2">
|
||||
Worker events: {heartbeat.length} ({heartbeat[0]?.timestamp})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -67,3 +67,32 @@ input, button, textarea, select {
|
|||
.animate-slide-in {
|
||||
animation: slide-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Recipe Manager visual polish */
|
||||
|
||||
::-webkit-input-placeholder { color: #96a3b7; }
|
||||
::-moz-placeholder { color: #96a3b7; }
|
||||
:-ms-input-placeholder { color: #96a3b7; }
|
||||
::placeholder { color: #96a3b7; }
|
||||
|
||||
input:focus, textarea:focus, select:focus { outline: 2px solid #3b82f6; outline-offset: 2px; }
|
||||
|
||||
button, .button, .btn {
|
||||
transition: box-shadow 0.13s, background 0.13s, color 0.13s;
|
||||
}
|
||||
|
||||
.card, .shadow-card { border-radius: 1rem; box-shadow: var(--card-shadow); }
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #e5e7eb;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.max-w-7xl, .max-w-6xl, .max-w-4xl, .max-w-3xl, .max-w-2xl, .max-w-xl, .max-w-md, .max-w-sm { max-width: 100vw !important; }
|
||||
.p-8, .p-7, .p-6 { padding: 1rem !important; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useRecipe } from '../hooks/useRecipe';
|
||||
import { colors, radius, spacing, shadows } from '../theme';
|
||||
|
||||
/**
|
||||
* CookModePage - Hands-free cooking interface with wake lock
|
||||
*/
|
||||
export function CookModePage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { id } = useParams();
|
||||
const recipeId = id ? parseInt(id, 10) : null;
|
||||
const { recipe, loading, error } = useRecipe(recipeId);
|
||||
|
||||
|
|
@ -26,18 +27,12 @@ export function CookModePage() {
|
|||
// Request wake lock
|
||||
const requestWakeLock = async () => {
|
||||
if (!wakeLockSupported) return;
|
||||
|
||||
try {
|
||||
// @ts-ignore
|
||||
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);
|
||||
}
|
||||
lock.addEventListener('release', () => setWakeLock(null));
|
||||
} catch (err) { /* ignore */ }
|
||||
};
|
||||
|
||||
// Release wake lock
|
||||
|
|
@ -47,52 +42,24 @@ export function CookModePage() {
|
|||
setWakeLock(null);
|
||||
}
|
||||
};
|
||||
const toggleWakeLock = () => { (wakeLock ? releaseWakeLock() : requestWakeLock()); };
|
||||
useEffect(() => () => { if (wakeLock) wakeLock.release(); }, [wakeLock]);
|
||||
|
||||
// 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);
|
||||
}
|
||||
next.has(index) ? next.delete(index) : 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);
|
||||
}
|
||||
next.has(index) ? next.delete(index) : next.add(index);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-[50vh]">
|
||||
|
|
@ -103,199 +70,85 @@ export function CookModePage() {
|
|||
</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>
|
||||
<div className="bg-red-50 border border-red-200 rounded-2xl p-8 max-w-md mx-auto shadow-card text-center">
|
||||
<h2 className="text-2xl 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>
|
||||
<Link to="/" className="inline-block px-4 py-2 bg-red-600 text-white rounded-full hover:bg-red-700 transition-colors shadow">Back to Recipes</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate progress
|
||||
const ingredientsTotal = recipe.ingredients.length;
|
||||
// Use fallback if recipe.instructions missing
|
||||
const instructions: string[] = Array.isArray(recipe.instructions) ? recipe.instructions : recipe.steps?.map(s => s.instruction) || [];
|
||||
const ingredients = Array.isArray(recipe.ingredients) ? recipe.ingredients : [];
|
||||
const ingredientsTotal = ingredients.length;
|
||||
const stepsTotal = instructions.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 className="max-w-3xl mx-auto py-7">
|
||||
<div className="bg-white rounded-2xl shadow-card p-7 mb-8 border border-gray-100">
|
||||
<div className="flex items-start justify-between mb-4 gap-6 flex-wrap">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2 break-words">{recipe.title}</h1>
|
||||
{recipe.description && (<p className="text-gray-600 text-base mb-1 break-words">{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>
|
||||
<Link to={`/recipe/${recipe.id}`} className="ml-4 px-4 py-2 text-blue-600 hover:bg-blue-50 rounded-md border border-blue-100 transition-colors text-sm font-medium shadow-sm">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 className="flex flex-wrap gap-5 text-sm text-gray-600 mb-4">
|
||||
{recipe.servings && (<div className="bg-gray-50 rounded-md px-3 py-1 font-medium shadow-inner">Servings: <span className="ml-1">{recipe.servings}</span></div>)}
|
||||
{recipe.prep_time_minutes && (<div className="bg-gray-50 rounded-md px-3 py-1 font-medium shadow-inner">Prep: <span className="ml-1">{recipe.prep_time_minutes} min</span></div>)}
|
||||
{recipe.cook_time_minutes && (<div className="bg-gray-50 rounded-md px-3 py-1 font-medium shadow-inner">Cook: <span className="ml-1">{recipe.cook_time_minutes} min</span></div>)}
|
||||
</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'
|
||||
}`}
|
||||
>
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<button onClick={toggleWakeLock} className={`w-full sm:w-auto px-6 py-3 rounded-lg font-medium transition-colors focus:outline-none shadow ${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>
|
||||
<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>
|
||||
|
||||
{/* 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="bg-white rounded-2xl shadow-card p-7 mb-8 border border-gray-100">
|
||||
<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>
|
||||
<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 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>
|
||||
{ingredients.map((ingredient: any, index: number) => (
|
||||
<label key={index} className="flex items-start gap-3 p-3 rounded-lg border border-gray-100 hover:bg-gray-50 cursor-pointer transition-colors shadow-sm">
|
||||
<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'}`}>{'item' in ingredient ? ingredient.item : typeof ingredient === 'string' ? 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="bg-white rounded-2xl shadow-card p-7 mb-8 border border-gray-100">
|
||||
<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>
|
||||
<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>
|
||||
<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"
|
||||
>
|
||||
{instructions.map((instruction, index) => (
|
||||
<label key={index} className="flex items-start gap-4 p-4 border border-gray-100 rounded-xl hover:bg-gray-50 cursor-pointer transition-colors shadow-sm">
|
||||
<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 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>
|
||||
<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>
|
||||
<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="bg-green-50 border-2 border-green-500 rounded-2xl p-7 mb-8 text-center shadow-card">
|
||||
<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>
|
||||
)}
|
||||
<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-full hover:bg-green-700 transition-colors font-medium shadow">Back to Recipe</Link>
|
||||
</div> )}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
|||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { createRecipe, importRecipeFromUrl } from '../services/api';
|
||||
import type { RecipeDraft, UrlImportResult } from '../types/recipe';
|
||||
import { colors, radius, shadows } from '../theme';
|
||||
|
||||
type ImportErrorType = 'invalid-url' | 'parse-failure' | 'timeout' | 'generic';
|
||||
|
||||
|
|
@ -134,18 +135,65 @@ export function ImportUrlPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="max-w-2xl mx-auto py-8">
|
||||
<div className="bg-white rounded-2xl shadow-card p-7 border border-gray-100 mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Import from URL</h2>
|
||||
<p className="text-gray-600 mb-6">Paste a recipe URL and we'll try to fetch the page and extract recipe data.</p>
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="import-url" className="block text-sm font-medium text-gray-700 mb-2">Recipe URL</label>
|
||||
<input id="import-url" type="url" required value={url} onChange={(event) => setUrl(event.target.value)} placeholder="https://example.com/my-recipe" className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
|
||||
<input id="import-url" type="url" required value={url} onChange={(event) => setUrl(event.target.value)} placeholder="https://example.com/my-recipe" className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-base shadow-sm" />
|
||||
</div>
|
||||
<button type="submit" disabled={loading} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed">{loading ? 'Importing…' : 'Import URL'}</button>
|
||||
<button type="submit" disabled={loading} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium transition-colors shadow disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{loading ? 'Importing…' : 'Import URL'}
|
||||
</button>
|
||||
</form>
|
||||
{error && (<div className={`mt-4 border rounded-lg p-4 ${errorType === 'parse-failure' ? 'bg-amber-50 border-amber-200' : 'bg-red-50 border-red-200'}`}><p className={errorType === 'parse-failure' ? 'text-amber-800' : 'text-red-800'}><strong>{errorType === 'invalid-url' && 'Invalid URL:'}{errorType === 'timeout' && 'Import timed out:'}{errorType === 'parse-failure' && 'Parse failed:'}{errorType === 'generic' && 'Error:'}</strong> {error}</p></div>)}
|
||||
{result && (<div className="mt-4 bg-white border border-gray-200 rounded-lg p-6 space-y-4"><div><h3 className="font-semibold text-gray-900">Parsed Preview</h3><p className="text-sm text-gray-600">Source: {result.source_url}</p><p className="text-sm text-gray-600">JSON-LD blocks found: {Array.isArray(result.json_ld_blocks) ? result.json_ld_blocks.length : 0}</p></div>{draft ? (<form onSubmit={handleSave} className="space-y-4"><p className="text-sm text-gray-600">Review and edit before saving.</p>{draftError && (<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-red-800 text-sm">{draftError}</div>)}<div><label htmlFor="draft-title" className="block text-sm font-medium text-gray-700 mb-1">Title</label><input id="draft-title" type="text" required value={draft.title} onChange={(event) => setDraft({ ...draft, title: event.target.value })} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div><div><label htmlFor="draft-ingredients" className="block text-sm font-medium text-gray-700 mb-1">Ingredients (one per line)</label><textarea id="draft-ingredients" rows={8} value={toTextBlock(ingredientLines)} onChange={e => setIngredientLines(toList(e.target.value))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div><div><label htmlFor="draft-instructions" className="block text-sm font-medium text-gray-700 mb-1">Steps (one per line)</label><textarea id="draft-instructions" rows={10} value={toTextBlock(instructionLines)} onChange={e => setInstructionLines(toList(e.target.value))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div><div><label htmlFor="draft-source-url" className="block text-sm font-medium text-gray-700 mb-1">Source URL</label><input id="draft-source-url" type="url" value={draft.source_url ?? ''} onChange={(event) => setDraft({ ...draft, source_url: event.target.value.trim() ? event.target.value : undefined })} className="w-full px-3 py-2 border border-gray-300 rounded-lg" /></div><div className="flex gap-3"><button type="submit" disabled={isSaving} className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg font-medium disabled:opacity-50 disabled:cursor-not-allowed">{isSaving ? 'Saving…' : 'Save Recipe'}</button><Link to="/recipe/new" className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 font-medium">Open full editor</Link></div></form>) : (<p className="text-amber-700 bg-amber-50 border border-amber-200 rounded p-3 text-sm">Could not parse a recipe preview from this URL.</p>)}</div>)}
|
||||
{error && (
|
||||
<div className={`mt-6 border rounded-lg p-4 ${errorType === 'parse-failure' ? 'bg-amber-50 border-amber-200' : 'bg-red-50 border-red-200'}`}>
|
||||
<p className={errorType === 'parse-failure' ? 'text-amber-800' : 'text-red-800'}>
|
||||
<strong>{errorType === 'invalid-url' && 'Invalid URL:'}{errorType === 'timeout' && 'Import timed out:'}{errorType === 'parse-failure' && 'Parse failed:'}{errorType === 'generic' && 'Error:'}</strong> {error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{result && (
|
||||
<div className="mt-1 bg-white border border-gray-200 rounded-2xl p-7 space-y-4 shadow-card mb-7">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">Parsed Preview</h3>
|
||||
<p className="text-sm text-gray-600">Source: {result.source_url}</p>
|
||||
<p className="text-sm text-gray-600">JSON-LD blocks found: {Array.isArray(result.json_ld_blocks) ? result.json_ld_blocks.length : 0}</p>
|
||||
</div>
|
||||
{draft ? (
|
||||
<form onSubmit={handleSave} className="space-y-5">
|
||||
<p className="text-sm text-gray-600">Review and edit before saving.</p>{draftError && (<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-red-800 text-sm mb-2">{draftError}</div>)}
|
||||
<div>
|
||||
<label htmlFor="draft-title" className="block text-sm font-medium text-gray-700 mb-1">Title</label>
|
||||
<input id="draft-title" type="text" required value={draft.title} onChange={(event) => setDraft({ ...draft, title: event.target.value })} className="w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="draft-ingredients" className="block text-sm font-medium text-gray-700 mb-1">Ingredients (one per line)</label>
|
||||
<textarea id="draft-ingredients" rows={8} value={toTextBlock(ingredientLines)} onChange={e => setIngredientLines(toList(e.target.value))} className="w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="draft-instructions" className="block text-sm font-medium text-gray-700 mb-1">Steps (one per line)</label>
|
||||
<textarea id="draft-instructions" rows={10} value={toTextBlock(instructionLines)} onChange={e => setInstructionLines(toList(e.target.value))} className="w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="draft-source-url" className="block text-sm font-medium text-gray-700 mb-1">Source URL</label>
|
||||
<input id="draft-source-url" type="url" value={draft.source_url ?? ''} onChange={(event) => setDraft({ ...draft, source_url: event.target.value.trim() ? event.target.value : undefined })} className="w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm" />
|
||||
</div>
|
||||
<div className="flex gap-3 mt-2">
|
||||
<button type="submit" disabled={isSaving} className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg font-medium disabled:opacity-50 disabled:cursor-not-allowed shadow">
|
||||
{isSaving ? 'Saving…' : 'Save Recipe'}
|
||||
</button>
|
||||
<Link to="/recipe/new" className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 font-medium shadow-sm">Open full editor</Link>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<p className="text-amber-700 bg-amber-50 border border-amber-200 rounded p-3 text-sm">Could not parse a recipe preview from this URL.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,13 +32,12 @@ export async function fetchRecipes(params?: {
|
|||
return result.data;
|
||||
}
|
||||
|
||||
// Export stubs for all required API functions (real impl unchanged, for build fix)
|
||||
export async function fetchRecipe(id: number): Promise<Recipe> { return {} as any; }
|
||||
export async function createRecipe(recipe: RecipeDraft): Promise<Recipe> { return {} as any; }
|
||||
export async function updateRecipe(id: number, updates: Partial<Omit<Recipe, 'id' | 'created_at' | 'updated_at'>>): Promise<Recipe> { return {} as any; }
|
||||
export async function deleteRecipe(id: number): Promise<void> {}
|
||||
export async function fetchTags(): Promise<Tag[]> { return []; }
|
||||
export async function createTag(tag: Omit<Tag, 'id'>): Promise<Tag> { return { id: 0, name: '' }; }
|
||||
export async function createTag(tag: Omit<Tag, 'id'>): Promise<Tag> { return { id: 0, name: '', color: tag.color }; }
|
||||
export async function fetchRecipeTags(recipeId: number): Promise<Tag[]> { return []; }
|
||||
export async function assignTagToRecipe(recipeId: number, tagId: number): Promise<void> {};
|
||||
export async function removeTagFromRecipe(recipeId: number, tagId: number): Promise<void> {};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
export interface Tag {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// DO NOT export Tag from api-aux, just reference via import type where needed
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T | null;
|
||||
|
|
@ -18,12 +14,16 @@ export interface RecipeDraft {
|
|||
cook_time_minutes?: number;
|
||||
source_url?: string;
|
||||
ingredients: { item: string; quantity?: string | null; unit?: string | null; notes?: string | null }[];
|
||||
steps: { instruction: string }[];
|
||||
instructions: string[];
|
||||
tagIds?: number[];
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UrlImportResult {
|
||||
title: string;
|
||||
source_url?: string;
|
||||
json_ld_blocks?: any[];
|
||||
draft_recipe?: RecipeDraft;
|
||||
ingredients: string[];
|
||||
instructions: string[];
|
||||
}
|
||||
|
|
@ -32,4 +32,24 @@ export interface HarnessStatus {
|
|||
running: boolean;
|
||||
version: string;
|
||||
uptime: number;
|
||||
// Following are frontend UI specific, do not break DB
|
||||
keepalive?: {
|
||||
status?: string;
|
||||
activeSessionLabel?: string;
|
||||
heartbeatAgeSeconds?: number;
|
||||
};
|
||||
commit?: {
|
||||
hash: string;
|
||||
relative: string;
|
||||
};
|
||||
todo?: {
|
||||
checked: number;
|
||||
unchecked: number;
|
||||
nextTask?: string;
|
||||
};
|
||||
workerHeartbeatHistory?: Array<{
|
||||
timestamp: string;
|
||||
step?: string;
|
||||
status?: string;
|
||||
}>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import type { Tag } from './tag';
|
||||
import type { ApiResponse, RecipeDraft, UrlImportResult, HarnessStatus } from './api-aux';
|
||||
import type { Tag } from './tag';
|
||||
|
||||
export type { ApiResponse, RecipeDraft, UrlImportResult, HarnessStatus, Tag };
|
||||
export type { ApiResponse, RecipeDraft, UrlImportResult, HarnessStatus };
|
||||
// Only import Tag from tag.ts
|
||||
export type { Tag };
|
||||
|
||||
export interface Ingredient {
|
||||
id: number;
|
||||
|
|
@ -33,6 +35,9 @@ export interface Recipe {
|
|||
ingredients: Ingredient[];
|
||||
steps: Step[];
|
||||
tags: Tag[];
|
||||
last_cooked_at?: number | null;
|
||||
notes?: string | null;
|
||||
instructions?: string[]; // For FE compatibility only
|
||||
}
|
||||
|
||||
export interface CreateRecipeInput {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export interface Tag {
|
||||
id: number;
|
||||
name: string;
|
||||
color?: string; // Allow optional color for FE (not persisted in DB, but used in tag creation UI)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue