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:
2026-06-07 00:08:20 +02:00
parent 3d330655a6
commit f1f9be5ded
17 changed files with 3838 additions and 0 deletions
+12
View File
@@ -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
+12
View File
@@ -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>
+2984
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -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"
}
}
+103
View File
@@ -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')}
/>
)}
</>
)
}
+44
View File
@@ -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),
})
}
+197
View File
@@ -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>
)
}
+78
View File
@@ -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>
)
}
+45
View File
@@ -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>
)
}
+80
View File
@@ -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>
)
}
+153
View File
@@ -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); }
}
+10
View File
@@ -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>
)
+19
View File
@@ -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 })
}
+36
View File
@@ -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[]
}
+21
View File
@@ -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" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts", "capacitor.config.ts"]
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})