Files
diet-coach/src/App.jsx

162 lines
5.4 KiB
JavaScript

import { useState, useRef, useEffect } from 'react'
import { marked } from 'marked'
import Dashboard from './Dashboard'
import './App.css'
import './Dashboard.css'
marked.setOptions({ breaks: true, gfm: true })
const API_URL = window.location.hostname === 'localhost'
? 'http://localhost:4003'
: 'https://diet-api.psbailey.uk'
export default function App() {
const [page, setPage] = useState('chat')
const [messages, setMessages] = useState([])
const [input, setInput] = useState('')
const [images, setImages] = useState([])
const [loading, setLoading] = useState(false)
const [restarting, setRestarting] = useState(false)
const messagesEnd = useRef(null)
const inputRef = useRef(null)
const fileRef = useRef(null)
useEffect(() => {
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
function handleImage(e) {
const files = Array.from(e.target.files || [])
for (const file of files) {
const reader = new FileReader()
reader.onload = () => {
const base64 = reader.result.split(',')[1]
setImages(prev => [...prev, { base64, preview: reader.result, mime: file.type }])
}
reader.readAsDataURL(file)
}
}
function removeImage(idx) {
setImages(prev => prev.filter((_, i) => i !== idx))
if (fileRef.current) fileRef.current.value = ''
}
async function send(e) {
e.preventDefault()
const text = input.trim()
if ((!text && !images.length) || loading) return
setInput('')
const userMsg = { role: 'user', content: text, images: images.map(i => i.preview) }
setMessages(prev => [...prev, userMsg])
setLoading(true)
const payload = { message: text || 'Analyse these images' }
if (images.length) payload.images = images.map(i => ({ base64: i.base64, mime: i.mime }))
setImages([])
if (fileRef.current) fileRef.current.value = ''
try {
const res = await fetch(`${API_URL}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
if (!res.ok) throw new Error('Failed')
const reader = res.body.getReader()
const decoder = new TextDecoder()
let assistantMsg = ''
setMessages(prev => [...prev, { role: 'assistant', content: '' }])
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
for (const line of chunk.split('\n')) {
if (line.startsWith('data: ')) {
const data = line.slice(6)
if (data === '[DONE]') break
assistantMsg += data.replace(/\\n/g, '\n')
setMessages(prev => {
const updated = [...prev]
updated[updated.length - 1] = { role: 'assistant', content: assistantMsg }
return updated
})
}
}
}
} catch (err) {
setMessages(prev => [...prev, { role: 'assistant', content: '⚠️ Connection error. Try again.' }])
} finally {
setLoading(false)
inputRef.current?.focus()
}
}
if (page === 'dashboard') return <Dashboard onBack={() => setPage('chat')} />
async function restartApi() {
setRestarting(true)
try {
await fetch(`${API_URL}/restart`, { method: 'POST', headers: { Authorization: 'my-health-data' } })
} catch {}
setTimeout(() => setRestarting(false), 5000)
}
return (
<div className="app">
<header>
<h1>🥗 Diet Coach</h1>
<div className="header-btns">
<button onClick={restartApi} className="nav-btn" disabled={restarting}>{restarting ? '⏳' : '🔄'} API</button>
<button onClick={() => setPage('dashboard')} className="nav-btn">📊 Dashboard</button>
</div>
</header>
<div className="messages">
{messages.length === 0 && (
<div className="empty">Ask me about your diet, log food, or send a screenshot of your Lose It report.</div>
)}
{messages.map((msg, i) => (
<div key={i} className={`msg ${msg.role}`}>
{msg.role === 'assistant'
? <div className="bubble" dangerouslySetInnerHTML={{ __html: marked.parse(msg.content || '...') }} />
: <div className="bubble">
{msg.images?.map((src, j) => <img key={j} src={src} className="msg-img" alt="" />)}
{msg.content}
</div>
}
</div>
))}
<div ref={messagesEnd} />
</div>
{images.length > 0 && (
<div className="image-preview">
{images.map((img, i) => (
<span key={i} className="preview-item">
<img src={img.preview} alt="" />
<button onClick={() => removeImage(i)} className="remove-img">✕</button>
</span>
))}
</div>
)}
<form onSubmit={send} className="input-bar">
<button type="button" className="attach-btn" onClick={() => fileRef.current?.click()} disabled={loading}>📷</button>
<input type="file" ref={fileRef} accept="image/*" multiple onChange={handleImage} hidden />
<input
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
placeholder="What did you eat today?"
disabled={loading}
autoFocus
/>
<button type="submit" disabled={loading || (!input.trim() && !images.length)}>Send</button>
</form>
</div>
)
}