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