add client: React + Vite + Capacitor prototype
Web app React 18 + TypeScript con quattro schermate: Setup (URL server + password + test connessione), Ingredients (lista ingredienti e preferenze), Recipes (lista 3 ricette generate), RecipeDetail (dettaglio e procedimento). Persistenza URL/password via Capacitor Preferences. Pronto per build Android. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
import type { CapacitorConfig } from '@capacitor/cli'
|
||||||
|
|
||||||
|
const config: CapacitorConfig = {
|
||||||
|
appId: 'com.appetito.app',
|
||||||
|
appName: 'Appetito',
|
||||||
|
webDir: 'dist',
|
||||||
|
android: {
|
||||||
|
allowMixedContent: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<title>Appetito</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2984
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "appetito-client",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"cap:add:android": "cap add android",
|
||||||
|
"cap:sync": "cap sync",
|
||||||
|
"cap:open:android": "cap open android"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@capacitor/android": "^6.0.0",
|
||||||
|
"@capacitor/core": "^6.0.0",
|
||||||
|
"@capacitor/preferences": "^6.0.0",
|
||||||
|
"react": "^18.3.0",
|
||||||
|
"react-dom": "^18.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@capacitor/cli": "^6.0.0",
|
||||||
|
"@types/react": "^18.3.0",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"typescript": "^5.4.0",
|
||||||
|
"vite": "^5.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import Setup from './components/Setup'
|
||||||
|
import Ingredients from './components/Ingredients'
|
||||||
|
import Recipes from './components/Recipes'
|
||||||
|
import RecipeDetail from './components/RecipeDetail'
|
||||||
|
import { getServerUrl, getPassword } from './storage'
|
||||||
|
import { generateRecipes } from './api'
|
||||||
|
import type { Recipe, RecipeRequest } from './types'
|
||||||
|
|
||||||
|
type Screen = 'setup' | 'ingredients' | 'recipes' | 'detail'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [screen, setScreen] = useState<Screen>('setup')
|
||||||
|
const [serverUrl, setServerUrl] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [recipes, setRecipes] = useState<Recipe[]>([])
|
||||||
|
const [selectedRecipe, setSelectedRecipe] = useState<Recipe | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadSaved() {
|
||||||
|
const url = await getServerUrl()
|
||||||
|
const pwd = await getPassword()
|
||||||
|
setServerUrl(url)
|
||||||
|
setPassword(pwd)
|
||||||
|
if (url && pwd) setScreen('ingredients')
|
||||||
|
}
|
||||||
|
loadSaved()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function handleSetupConfirm(url: string, pwd: string) {
|
||||||
|
setServerUrl(url)
|
||||||
|
setPassword(pwd)
|
||||||
|
setScreen('ingredients')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGenerateRecipes(req: RecipeRequest) {
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await generateRecipes(serverUrl, password, req)
|
||||||
|
setRecipes(res.recipes)
|
||||||
|
setScreen('recipes')
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Errore durante la generazione')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectRecipe(recipe: Recipe) {
|
||||||
|
setSelectedRecipe(recipe)
|
||||||
|
setScreen('detail')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{error && screen === 'ingredients' && (
|
||||||
|
<div style={{
|
||||||
|
background: '#fdecea',
|
||||||
|
color: '#c0392b',
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{screen === 'setup' && (
|
||||||
|
<Setup
|
||||||
|
initialUrl={serverUrl}
|
||||||
|
initialPassword={password}
|
||||||
|
onConfirm={handleSetupConfirm}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{screen === 'ingredients' && (
|
||||||
|
<Ingredients
|
||||||
|
onSubmit={handleGenerateRecipes}
|
||||||
|
onSetup={() => setScreen('setup')}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{screen === 'recipes' && (
|
||||||
|
<Recipes
|
||||||
|
recipes={recipes}
|
||||||
|
onSelect={handleSelectRecipe}
|
||||||
|
onBack={() => setScreen('ingredients')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{screen === 'detail' && selectedRecipe && (
|
||||||
|
<RecipeDetail
|
||||||
|
recipe={selectedRecipe}
|
||||||
|
onBack={() => setScreen('recipes')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { RecipeRequest, RecipeResponse } from './types'
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
serverUrl: string,
|
||||||
|
password: string,
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const base = serverUrl.replace(/\/$/, '')
|
||||||
|
const res = await fetch(`${base}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-App-Password': password,
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(body.detail ?? `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkHealth(serverUrl: string): Promise<void> {
|
||||||
|
const base = serverUrl.replace(/\/$/, '')
|
||||||
|
const res = await fetch(`${base}/health`)
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testAuth(serverUrl: string, password: string): Promise<void> {
|
||||||
|
await request(serverUrl, password, '/auth/test', { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateRecipes(
|
||||||
|
serverUrl: string,
|
||||||
|
password: string,
|
||||||
|
body: RecipeRequest
|
||||||
|
): Promise<RecipeResponse> {
|
||||||
|
return request(serverUrl, password, '/recipes/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import type { Ingredient, Preferences, RecipeRequest } from '../types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSubmit: (req: RecipeRequest) => void
|
||||||
|
onSetup: () => void
|
||||||
|
loading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PREFS: Preferences = {
|
||||||
|
servings: 2,
|
||||||
|
maxTimeMinutes: 30,
|
||||||
|
difficulty: 'facile',
|
||||||
|
diet: 'nessuna',
|
||||||
|
allergies: [],
|
||||||
|
avoid: [],
|
||||||
|
tools: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Ingredients({ onSubmit, onSetup, loading }: Props) {
|
||||||
|
const [ingredients, setIngredients] = useState<Ingredient[]>([])
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [quantity, setQuantity] = useState('')
|
||||||
|
const [unit, setUnit] = useState('g')
|
||||||
|
const [prefs, setPrefs] = useState<Preferences>(DEFAULT_PREFS)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
function addIngredient() {
|
||||||
|
const qty = parseFloat(quantity)
|
||||||
|
if (!name.trim() || isNaN(qty) || qty <= 0) {
|
||||||
|
setError('Inserisci nome e quantità validi.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (ingredients.length >= 30) {
|
||||||
|
setError('Massimo 30 ingredienti.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError('')
|
||||||
|
setIngredients(prev => [...prev, { name: name.trim(), quantity: qty, unit: unit.trim() || 'pz' }])
|
||||||
|
setName('')
|
||||||
|
setQuantity('')
|
||||||
|
setUnit('g')
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeIngredient(index: number) {
|
||||||
|
setIngredients(prev => prev.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (ingredients.length === 0) {
|
||||||
|
setError('Aggiungi almeno un ingrediente.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError('')
|
||||||
|
onSubmit({ ingredients, preferences: prefs })
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePref<K extends keyof Preferences>(key: K, value: Preferences[K]) {
|
||||||
|
setPrefs(prev => ({ ...prev, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="screen">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<h1 className="screen-title">Ingredienti</h1>
|
||||||
|
<button className="btn-ghost" onClick={onSetup}>Server</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
<div className="row">
|
||||||
|
<div className="field" style={{ flex: 2 }}>
|
||||||
|
<label>Ingrediente</label>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
placeholder="es. pasta"
|
||||||
|
onKeyDown={e => e.key === 'Enter' && addIngredient()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field" style={{ flex: 1 }}>
|
||||||
|
<label>Quantità</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={quantity}
|
||||||
|
onChange={e => setQuantity(e.target.value)}
|
||||||
|
placeholder="250"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field" style={{ flex: 1 }}>
|
||||||
|
<label>Unità</label>
|
||||||
|
<input
|
||||||
|
value={unit}
|
||||||
|
onChange={e => setUnit(e.target.value)}
|
||||||
|
placeholder="g"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="btn-secondary" onClick={addIngredient}>+ Aggiungi</button>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ingredients.length > 0 && (
|
||||||
|
<div className="card" style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
{ingredients.map((ing, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span>{ing.name} — {ing.quantity} {ing.unit}</span>
|
||||||
|
<button className="btn-ghost" style={{ color: 'red' }} onClick={() => removeIngredient(i)}>x</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card" style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
<strong>Preferenze</strong>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<div className="field" style={{ flex: 1 }}>
|
||||||
|
<label>Porzioni</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={prefs.servings}
|
||||||
|
onChange={e => updatePref('servings', parseInt(e.target.value) || 1)}
|
||||||
|
min="1" max="20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field" style={{ flex: 1 }}>
|
||||||
|
<label>Tempo max (min)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={prefs.maxTimeMinutes}
|
||||||
|
onChange={e => updatePref('maxTimeMinutes', parseInt(e.target.value) || 5)}
|
||||||
|
min="5" max="300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<div className="field" style={{ flex: 1 }}>
|
||||||
|
<label>Difficoltà</label>
|
||||||
|
<select
|
||||||
|
value={prefs.difficulty}
|
||||||
|
onChange={e => updatePref('difficulty', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="facile">Facile</option>
|
||||||
|
<option value="media">Media</option>
|
||||||
|
<option value="difficile">Difficile</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field" style={{ flex: 1 }}>
|
||||||
|
<label>Dieta</label>
|
||||||
|
<select
|
||||||
|
value={prefs.diet}
|
||||||
|
onChange={e => updatePref('diet', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="nessuna">Nessuna</option>
|
||||||
|
<option value="vegetariana">Vegetariana</option>
|
||||||
|
<option value="vegana">Vegana</option>
|
||||||
|
<option value="senza glutine">Senza glutine</option>
|
||||||
|
<option value="senza lattosio">Senza lattosio</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>Allergie (separate da virgola)</label>
|
||||||
|
<input
|
||||||
|
placeholder="es. noci, arachidi"
|
||||||
|
onChange={e => updatePref('allergies', e.target.value.split(',').map(s => s.trim()).filter(Boolean))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>Alimenti da evitare (separati da virgola)</label>
|
||||||
|
<input
|
||||||
|
placeholder="es. cipolla, aglio"
|
||||||
|
onChange={e => updatePref('avoid', e.target.value.split(',').map(s => s.trim()).filter(Boolean))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>Strumenti disponibili (separati da virgola)</label>
|
||||||
|
<input
|
||||||
|
placeholder="es. padella, pentola, forno"
|
||||||
|
onChange={e => updatePref('tools', e.target.value.split(',').map(s => s.trim()).filter(Boolean))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="btn-primary" onClick={handleSubmit} disabled={loading}>
|
||||||
|
{loading && <span className="spinner" />}
|
||||||
|
Genera ricette
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import type { Recipe } from '../types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
recipe: Recipe
|
||||||
|
onBack: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecipeDetail({ recipe, onBack }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="screen">
|
||||||
|
<button className="btn-ghost" onClick={onBack} style={{ alignSelf: 'flex-start' }}>
|
||||||
|
← Ricette
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h1 className="screen-title">{recipe.title}</h1>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
|
<Tag>{recipe.timeMinutes} min</Tag>
|
||||||
|
<Tag>{recipe.difficulty}</Tag>
|
||||||
|
<Tag>{recipe.servings} porzioni</Tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ color: 'var(--color-muted)', fontSize: '0.9rem' }}>{recipe.whyThisRecipe}</p>
|
||||||
|
|
||||||
|
<Section title="Ingredienti usati">
|
||||||
|
<ul style={{ paddingLeft: '1.2rem', display: 'flex', flexDirection: 'column', gap: '0.2rem' }}>
|
||||||
|
{recipe.usedIngredients.map((ing, i) => <li key={i}>{ing}</li>)}
|
||||||
|
</ul>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{recipe.optionalMissingIngredients.length > 0 && (
|
||||||
|
<Section title="Ingredienti opzionali mancanti">
|
||||||
|
<ul style={{ paddingLeft: '1.2rem', display: 'flex', flexDirection: 'column', gap: '0.2rem', color: 'var(--color-muted)' }}>
|
||||||
|
{recipe.optionalMissingIngredients.map((ing, i) => <li key={i}>{ing}</li>)}
|
||||||
|
</ul>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section title="Procedimento">
|
||||||
|
<ol style={{ paddingLeft: '1.2rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
{recipe.steps.map((step, i) => <li key={i}>{step}</li>)}
|
||||||
|
</ol>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{recipe.notes.length > 0 && (
|
||||||
|
<Section title="Note">
|
||||||
|
<ul style={{ paddingLeft: '1.2rem', display: 'flex', flexDirection: 'column', gap: '0.2rem', color: 'var(--color-muted)' }}>
|
||||||
|
{recipe.notes.map((note, i) => <li key={i}>{note}</li>)}
|
||||||
|
</ul>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
<strong>{title}</strong>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tag({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
background: '#f0e8e0',
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
borderRadius: '999px',
|
||||||
|
padding: '0.2rem 0.7rem',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { Recipe } from '../types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
recipes: Recipe[]
|
||||||
|
onSelect: (recipe: Recipe) => void
|
||||||
|
onBack: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Recipes({ recipes, onSelect, onBack }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="screen">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
|
<button className="btn-ghost" onClick={onBack}>← Indietro</button>
|
||||||
|
<h1 className="screen-title">Ricette</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{recipes.map((recipe, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
className="card"
|
||||||
|
onClick={() => onSelect(recipe)}
|
||||||
|
style={{
|
||||||
|
textAlign: 'left',
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: 'none',
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.4rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong style={{ fontSize: '1.05rem' }}>{recipe.title}</strong>
|
||||||
|
<span style={{ fontSize: '0.85rem', color: 'var(--color-muted)' }}>
|
||||||
|
{recipe.timeMinutes} min · {recipe.difficulty} · {recipe.servings} porz.
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '0.85rem', color: 'var(--color-muted)' }}>
|
||||||
|
{recipe.whyThisRecipe}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { testAuth } from '../api'
|
||||||
|
import { setServerUrl, setPassword } from '../storage'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialUrl: string
|
||||||
|
initialPassword: string
|
||||||
|
onConfirm: (url: string, password: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Setup({ initialUrl, initialPassword, onConfirm }: Props) {
|
||||||
|
const [url, setUrl] = useState(initialUrl)
|
||||||
|
const [password, setPasswordVal] = useState(initialPassword)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [success, setSuccess] = useState(false)
|
||||||
|
|
||||||
|
async function handleTest() {
|
||||||
|
setError('')
|
||||||
|
setSuccess(false)
|
||||||
|
if (!url.trim() || !password.trim()) {
|
||||||
|
setError('Inserisci URL e password.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await testAuth(url.trim(), password.trim())
|
||||||
|
setSuccess(true)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Errore di connessione')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirm() {
|
||||||
|
await setServerUrl(url.trim())
|
||||||
|
await setPassword(password.trim())
|
||||||
|
onConfirm(url.trim(), password.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="screen">
|
||||||
|
<h1 className="screen-title">Configurazione</h1>
|
||||||
|
|
||||||
|
<div className="card" style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
|
<div className="field">
|
||||||
|
<label>URL server</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
placeholder="http://192.168.1.x:8000"
|
||||||
|
value={url}
|
||||||
|
onChange={e => { setUrl(e.target.value); setSuccess(false) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="password condivisa"
|
||||||
|
value={password}
|
||||||
|
onChange={e => { setPasswordVal(e.target.value); setSuccess(false) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
{success && <p style={{ color: 'green', fontSize: '0.85rem' }}>Connessione riuscita</p>}
|
||||||
|
|
||||||
|
<button className="btn-secondary" onClick={handleTest} disabled={loading}>
|
||||||
|
{loading && <span className="spinner" />}
|
||||||
|
Testa connessione
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button className="btn-primary" onClick={handleConfirm} disabled={!success}>
|
||||||
|
Continua
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-bg: #f9f6f1;
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-primary: #d4622a;
|
||||||
|
--color-primary-dark: #b5501f;
|
||||||
|
--color-text: #1a1a1a;
|
||||||
|
--color-muted: #6b6b6b;
|
||||||
|
--color-border: #e0dbd4;
|
||||||
|
--color-error: #c0392b;
|
||||||
|
--radius: 12px;
|
||||||
|
--shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100dvh;
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--color-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-primary);
|
||||||
|
border: 1.5px solid var(--color-primary);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-muted);
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: 1.5px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.65rem 0.9rem;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, select:focus {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-muted);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--color-error);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screen {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 480px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screen-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1.1em;
|
||||||
|
height: 1.1em;
|
||||||
|
border: 2px solid rgba(255,255,255,0.4);
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 0.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Preferences } from '@capacitor/preferences'
|
||||||
|
|
||||||
|
export async function getServerUrl(): Promise<string> {
|
||||||
|
const { value } = await Preferences.get({ key: 'serverUrl' })
|
||||||
|
return value ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setServerUrl(url: string): Promise<void> {
|
||||||
|
await Preferences.set({ key: 'serverUrl', value: url })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPassword(): Promise<string> {
|
||||||
|
const { value } = await Preferences.get({ key: 'password' })
|
||||||
|
return value ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setPassword(password: string): Promise<void> {
|
||||||
|
await Preferences.set({ key: 'password', value: password })
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export interface Ingredient {
|
||||||
|
name: string
|
||||||
|
quantity: number
|
||||||
|
unit: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Preferences {
|
||||||
|
servings: number
|
||||||
|
maxTimeMinutes: number
|
||||||
|
difficulty: string
|
||||||
|
diet: string
|
||||||
|
allergies: string[]
|
||||||
|
avoid: string[]
|
||||||
|
tools: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecipeRequest {
|
||||||
|
ingredients: Ingredient[]
|
||||||
|
preferences: Preferences
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Recipe {
|
||||||
|
title: string
|
||||||
|
servings: number
|
||||||
|
timeMinutes: number
|
||||||
|
difficulty: string
|
||||||
|
whyThisRecipe: string
|
||||||
|
usedIngredients: string[]
|
||||||
|
optionalMissingIngredients: string[]
|
||||||
|
steps: string[]
|
||||||
|
notes: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecipeResponse {
|
||||||
|
recipes: Recipe[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts", "capacitor.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user