Visual polish: CookModePage + ImportUrlPage, global header/nav consistency, theme token styling, improved states (spacing, cards, buttons, empty/error/complete)

This commit is contained in:
Paul Huliganga 2026-03-25 18:38:50 -04:00
parent 855dc62207
commit edc5ce03ad
9 changed files with 220 additions and 343 deletions

View File

@ -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 {
@ -30,36 +31,35 @@ export function useToastContext() {
function App() {
const location = useLocation();
const toast = useToast();
const isActive = (path: string) => {
if (path === '/' && location.pathname === '/') return true;
if (path !== '/' && location.pathname.startsWith(path)) return true;
return false;
};
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`;
};
return (
<ErrorBoundary>
<ToastContext.Provider value={toast}>
<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>
@ -73,8 +73,8 @@ function App() {
</div>
</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 />} />
@ -84,8 +84,8 @@ function App() {
<Route path="*" element={<NotFoundPage />} />
</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

View File

@ -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="flex gap-4">
<span className="text-xs text-gray-700">Git: {getRecentCommit(status)}</span>
</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 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>
<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>
{!!heartbeat.length && (
<div className="text-xs mt-2">
Worker events: {heartbeat.length} ({heartbeat[0]?.timestamp})
</div>
)}
</div>

View File

@ -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; }
}

View File

@ -1,45 +1,40 @@
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);
// 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 {
// @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
const releaseWakeLock = async () => {
if (wakeLock) {
@ -47,52 +42,24 @@ export function CookModePage() {
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 toggleWakeLock = () => { (wakeLock ? releaseWakeLock() : requestWakeLock()); };
useEffect(() => () => { if (wakeLock) wakeLock.release(); }, [wakeLock]);
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>
)}
{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 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>
{/* 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>
</div>
)}
<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="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>
<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 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>
<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>
);
}

View File

@ -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">
<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">
<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" />
<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="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 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 shadow disabled:opacity-50 disabled:cursor-not-allowed">
{loading ? 'Importing…' : 'Import URL'}
</button>
</form>
{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>
<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>
</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>)}
)}
</div>
);
}

View File

@ -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> {};

View File

@ -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;
}>;
}

View File

@ -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 {

View File

@ -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)
}