From e60aaa111a4fbaa7d6bcf616eaddc874bd9fd0b5 Mon Sep 17 00:00:00 2001 From: Otto Date: Mon, 30 Mar 2026 21:44:46 -0400 Subject: [PATCH] Meal Tracker - full feature set with auth, favorites, admin panel --- client/src/App.jsx | 50 ++++++- client/src/api.js | 113 ++++++++++++++++ client/src/components/Layout.jsx | 113 +++++----------- client/src/pages/AddEntry.jsx | 221 +++++-------------------------- client/src/pages/Admin.jsx | 210 +++++++++++++++++++++++++++++ client/src/pages/Daily.jsx | 135 ++++++++----------- client/src/pages/EntryDetail.jsx | 97 ++++---------- client/src/pages/Goals.jsx | 126 ++++++++++++++++++ client/src/pages/Leaderboard.jsx | 123 +++++++++++++++++ client/src/pages/Login.jsx | 106 +++++++++++++++ client/src/pages/Photos.jsx | 68 ++++++++++ client/src/pages/Weekly.jsx | 142 ++++++++++++++++++++ server/index.js | 72 +++++----- server/routes/admin.js | 97 ++++++++++++++ server/routes/auth.js | 65 +++++++++ server/routes/entries.js | 163 +++++++++-------------- server/routes/foods.js | 78 +++++------ server/routes/stats.js | 18 +++ server/routes/summary.js | 25 +--- server/scripts/update-db.cjs | 22 +++ 20 files changed, 1422 insertions(+), 622 deletions(-) create mode 100644 client/src/api.js create mode 100644 client/src/pages/Admin.jsx create mode 100644 client/src/pages/Goals.jsx create mode 100644 client/src/pages/Leaderboard.jsx create mode 100644 client/src/pages/Login.jsx create mode 100644 client/src/pages/Photos.jsx create mode 100644 client/src/pages/Weekly.jsx create mode 100644 server/routes/admin.js create mode 100644 server/routes/auth.js create mode 100644 server/routes/stats.js create mode 100644 server/scripts/update-db.cjs diff --git a/client/src/App.jsx b/client/src/App.jsx index 3d21f5a..6aaa13a 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,15 +1,57 @@ -import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { useState, useEffect } from 'react'; import Daily from './pages/Daily'; import AddEntry from './pages/AddEntry'; import EntryDetail from './pages/EntryDetail'; +import Login from './pages/Login'; +import Admin from './pages/Admin'; +import Weekly from './pages/Weekly'; +import Leaderboard from './pages/Leaderboard'; +import Photos from './pages/Photos'; +import Goals from './pages/Goals'; + +function PrivateRoute({ children }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const storedUser = localStorage.getItem('user'); + if (storedUser) setUser(JSON.parse(storedUser)); + setLoading(false); + }, []); + + if (loading) return
Loading...
; + return user ? children : ; +} + +function AdminRoute() { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const storedUser = localStorage.getItem('user'); + if (storedUser) setUser(JSON.parse(storedUser)); + setLoading(false); + }, []); + + if (loading) return
Loading...
; + if (!user || user.username !== 'admin') return ; + return ; +} export default function App() { return ( - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> ); diff --git a/client/src/api.js b/client/src/api.js new file mode 100644 index 0000000..9bead54 --- /dev/null +++ b/client/src/api.js @@ -0,0 +1,113 @@ +const API_BASE = 'http://10.10.10.143:3000/api'; + +function getUser() { + const user = localStorage.getItem('user'); + return user ? JSON.parse(user) : null; +} + +export function getHeaders() { + const headers = { 'Content-Type': 'application/json' }; + const user = getUser(); + if (user && user.id) headers['x-user-id'] = user.id; + return headers; +} + +export async function getEntries(date) { + const user = getUser(); + const userId = user ? user.id : 1; + const res = await fetch(`${API_BASE}/entries?date=${date}&user_id=${userId}`, { headers: getHeaders() }); + return res.json(); +} + +export async function getRecentEntries(limit = 10) { + const res = await fetch(`${API_BASE}/entries/recent?limit=${limit}`, { headers: getHeaders() }); + return res.json(); +} + +export async function createEntry(data) { + const user = getUser(); + const userId = user ? user.id : 1; + const payload = { ...data, user_id: userId }; + const res = await fetch(`${API_BASE}/entries`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + return res.json(); +} + +export async function deleteEntry(id) { + const user = getUser(); + const userId = user ? user.id : 1; + const res = await fetch(`${API_BASE}/entries/${id}?user_id=${userId}`, { method: 'DELETE' }); + return res.json(); +} + +export async function toggleFavorite(id) { + const res = await fetch(`${API_BASE}/entries/${id}/favorite`, { method: 'POST', headers: getHeaders() }); + return res.json(); +} + +export async function getFavorites() { + const res = await fetch(`${API_BASE}/favorites`, { headers: getHeaders() }); + return res.json(); +} + +export async function searchFoods(query) { + const res = await fetch(`${API_BASE}/foods/search?q=${query}`); + return res.json(); +} + +export async function getDailySummary(date) { + const user = getUser(); + const userId = user ? user.id : 1; + const res = await fetch(`${API_BASE}/summary/daily?date=${date}&user_id=${userId}`, { headers: getHeaders() }); + return res.json(); +} + +export async function getGoals() { + const res = await fetch(`${API_BASE}/goals`, { headers: getHeaders() }); + return res.json(); +} + +export async function updateGoals(goals) { + const res = await fetch(`${API_BASE}/goals`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...getHeaders() }, + body: JSON.stringify(goals) + }); + return res.json(); +} + +export async function getStreak() { + const res = await fetch(`${API_BASE}/streak`, { headers: getHeaders() }); + return res.json(); +} + +export function logout() { + localStorage.removeItem('user'); + window.location.href = '/login'; +} + +export async function getLeaderboard() { + const res = await fetch(`${API_BASE}/leaderboard`, { headers: getHeaders() }); + return res.json(); +} + +export async function getPhotos() { + const res = await fetch(`${API_BASE}/photos`, { headers: getHeaders() }); + return res.json(); +} + +export async function getWeeklySummary() { + const res = await fetch(`${API_BASE}/summary/weekly`, { headers: getHeaders() }); + return res.json(); +} + +export async function getEntry(id) { + const user = getUser(); + const userId = user ? user.id : 1; + const res = await fetch(`${API_BASE}/entries/${id}?user_id=${userId}`, { headers: getHeaders() }); + if (!res.ok) throw new Error('Entry not found'); + return res.json(); +} diff --git a/client/src/components/Layout.jsx b/client/src/components/Layout.jsx index af4c946..945a595 100644 --- a/client/src/components/Layout.jsx +++ b/client/src/components/Layout.jsx @@ -1,55 +1,23 @@ import { useState, useEffect } from 'react'; import { Link, useSearchParams } from 'react-router-dom'; +import { logout } from '../api'; -export default function Layout({ children, showFavorites = false }) { +export default function Layout({ children }) { const [darkMode, setDarkMode] = useState(true); const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]); const [favorites, setFavorites] = useState([]); const [showFavList, setShowFavList] = useState(false); - const [searchParams] = useSearchParams(); - const returnDate = searchParams.get('date'); + const [showMenu, setShowMenu] = useState(false); const isCurrentDay = selectedDate === new Date().toISOString().split('T')[0]; + const user = JSON.parse(localStorage.getItem('user') || '{}'); + const isAdmin = user.username === 'admin'; - useEffect(() => { - setDarkMode(document.documentElement.classList.contains('dark')); - }, []); + useEffect(() => { setDarkMode(document.documentElement.classList.contains('dark')); }, []); + useEffect(() => { if (darkMode) document.documentElement.classList.add('dark'); else document.documentElement.classList.remove('dark'); }, [darkMode]); + useEffect(() => { fetch('/api/favorites').then(res => res.json()).then(setFavorites).catch(console.error); }, [showFavList]); - useEffect(() => { - if (darkMode) { - document.documentElement.classList.add('dark'); - } else { - document.documentElement.classList.remove('dark'); - } - }, [darkMode]); - - useEffect(() => { - fetch('/api/favorites') - .then(res => res.json()) - .then(data => setFavorites(data)) - .catch(console.error); - }, [showFavList]); - - function handleDateChange(e) { - setSelectedDate(e.target.value); - } - - function goToToday() { - setSelectedDate(new Date().toISOString().split('T')[0]); - } - - // Build query string for adding a favorite - function buildFavoriteUrl(fav) { - const params = new URLSearchParams(); - params.set('name', fav.name); - if (fav.description) params.set('description', fav.description); - if (fav.calories) params.set('calories', fav.calories); - if (fav.protein) params.set('protein', fav.protein); - if (fav.carbs) params.set('carbs', fav.carbs); - if (fav.fat) params.set('fat', fav.fat); - if (fav.notes) params.set('notes', fav.notes); - return '/add?' + params.toString(); - } + function handleLogout() { logout(); window.location.href = '/login'; } const bgMain = darkMode ? 'bg-gray-900' : 'bg-gray-100'; const bgCard = darkMode ? 'bg-gray-800' : 'bg-white'; @@ -59,52 +27,39 @@ export default function Layout({ children, showFavorites = false }) { return (
- {/* Persistent Header */} -
+
🍽️ - - {!isCurrentDay && ( - - )} - + setSelectedDate(e.target.value)} className={inputBg + " rounded px-2 py-1 text-sm"} /> + {!isCurrentDay && } + + +
+
+ +
-
- {/* Favorites Dropdown */} + {/* Dropdown Menu */} + {showMenu && ( +
+
+ setShowMenu(false)} className={"block p-2 rounded hover:bg-gray-700 " + textMain}>📈 Weekly Summary + setShowMenu(false)} className={"block p-2 rounded hover:bg-gray-700 " + textMain}>🏆 Leaderboard + setShowMenu(false)} className={"block p-2 rounded hover:bg-gray-700 " + textMain}>🎯 Set Goals + {isAdmin && setShowMenu(false)} className={"block p-2 rounded hover:bg-gray-700 " + textMain}>⚙️ Admin} +
+
+ )} + {showFavList && (
Add from Favorites
- {favorites.length === 0 ? ( -

No favorites yet

- ) : ( + {favorites.length === 0 ?

No favorites yet

: (
{favorites.map(fav => ( - setShowFavList(false)} - className={"block p-2 rounded hover:bg-gray-700 " + textMuted} - > + setShowFavList(false)} className={"block p-2 rounded hover:bg-gray-700 " + textMuted}> {fav.name} • {fav.calories} cal ))} @@ -113,9 +68,7 @@ export default function Layout({ children, showFavorites = false }) {
)} -
- {children} -
+
{children}
); } diff --git a/client/src/pages/AddEntry.jsx b/client/src/pages/AddEntry.jsx index 751b21f..735aee3 100644 --- a/client/src/pages/AddEntry.jsx +++ b/client/src/pages/AddEntry.jsx @@ -1,110 +1,36 @@ -import { useState, useEffect } from 'react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useState } from 'react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; import Layout from '../components/Layout'; +import { createEntry } from '../api'; export default function AddEntry() { - const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const [darkMode, setDarkMode] = useState(true); - const [foods, setFoods] = useState([]); - const [searchQuery, setSearchQuery] = useState(''); - const [showSearch, setShowSearch] = useState(false); - const [loading, setLoading] = useState(false); - + const navigate = useNavigate(); const [form, setForm] = useState({ - name: '', - description: '', - notes: '', - calories: '', - protein: '', - carbs: '', - fat: '', - image: null + name: searchParams.get('description') || '', + description: searchParams.get('description') || '', + notes: searchParams.get('notes') || '', + calories: searchParams.get('calories') || '', + protein: searchParams.get('protein') || '', + carbs: searchParams.get('carbs') || '', + fat: searchParams.get('fat') || '', + meal_time: '' }); + const [darkMode, setDarkMode] = useState(true); - useEffect(() => { - setDarkMode(document.documentElement.classList.contains('dark')); - }, []); - - // Pre-fill from query params (favorites) - useEffect(() => { - const name = searchParams.get('name'); - if (name) { - setForm({ - ...form, - name: name, - description: searchParams.get('description') || '', - notes: searchParams.get('notes') || '', - calories: searchParams.get('calories') || '', - protein: searchParams.get('protein') || '', - carbs: searchParams.get('carbs') || '', - fat: searchParams.get('fat') || '', - image: null - }); - } - }, [searchParams]); - - const bgCard = darkMode ? 'bg-gray-800' : 'bg-white'; const textMain = darkMode ? 'text-white' : 'text-gray-900'; - const textMuted = darkMode ? 'text-gray-400' : 'text-gray-500'; + const textMuted = darkMode ? 'text-gray-400' : 'text-gray-600'; + const bgCard = darkMode ? 'bg-gray-800' : 'bg-white'; const inputBg = darkMode ? 'bg-gray-700 border-gray-600 text-white' : 'bg-white border-gray-300 text-gray-900'; - async function handleSearch() { - if (!searchQuery) return; - setLoading(true); - try { - const res = await fetch('/api/foods/search?q=' + searchQuery); - const data = await res.json(); - if (Array.isArray(data)) { - setFoods(data); - setShowSearch(true); - } - } catch (err) { - console.error(err); - } finally { - setLoading(false); - } - } - - function selectFood(food) { - setForm({ - ...form, - name: food.name, - calories: Math.round(food.calories), - protein: Math.round(food.protein), - carbs: Math.round(food.carbs), - fat: Math.round(food.fat) - }); - setShowSearch(false); - } - - function handleSubmit(e) { + async function handleSubmit(e) { e.preventDefault(); - - const formData = new FormData(); - formData.append('name', form.name); - formData.append('description', form.description); - formData.append('notes', form.notes); - formData.append('calories', form.calories); - formData.append('protein', form.protein); - formData.append('carbs', form.carbs); - formData.append('fat', form.fat); - if (form.image) { - formData.append('image', form.image); - } - - fetch('/api/entries', { - method: 'POST', - body: formData - }) - .then(res => res.json()) - .then(data => { + try { + await createEntry(form); navigate('/'); - }) - .catch(err => { - console.error(err); + } catch (err) { alert('Failed to save entry'); - }); + } } return ( @@ -112,117 +38,36 @@ export default function AddEntry() {

Add Entry

- {/* Food Search */} -
- -
- setSearchQuery(e.target.value)} - placeholder="Search food..." - className={"flex-1 px-3 py-2 rounded-lg border " + inputBg} - /> - -
- - {showSearch && ( -
- {foods.length === 0 ? ( -

No results found

- ) : ( - foods.map((food, i) => ( - - )) - )} -
- )} -
- - {/* Name */}
- setForm({...form, name: e.target.value})} - placeholder="e.g., Grilled Chicken" - className={"w-full px-3 py-2 rounded-lg border " + inputBg} - /> + setForm({...form, name: e.target.value})} placeholder="e.g., Grilled Chicken" className={"w-full px-3 py-2 rounded-lg border " + inputBg} />
- {/* Description */}
- - setForm({...form, description: e.target.value})} - placeholder="e.g., Lunch" - className={"w-full px-3 py-2 rounded-lg border " + inputBg} - /> + +
+ {['Breakfast', 'Lunch', 'Dinner', 'Snack'].map(time => ( + + ))} +
- {/* Macros */}
-
- - setForm({...form, calories: e.target.value})} className={"w-full px-2 py-1 rounded border " + inputBg} /> -
-
- - setForm({...form, protein: e.target.value})} className={"w-full px-2 py-1 rounded border " + inputBg} /> -
-
- - setForm({...form, carbs: e.target.value})} className={"w-full px-2 py-1 rounded border " + inputBg} /> -
-
- - setForm({...form, fat: e.target.value})} className={"w-full px-2 py-1 rounded border " + inputBg} /> -
+
setForm({...form, calories: e.target.value})} className={"w-full px-2 py-1 rounded border " + inputBg} />
+
setForm({...form, protein: e.target.value})} className={"w-full px-2 py-1 rounded border " + inputBg} />
+
setForm({...form, carbs: e.target.value})} className={"w-full px-2 py-1 rounded border " + inputBg} />
+
setForm({...form, fat: e.target.value})} className={"w-full px-2 py-1 rounded border " + inputBg} />
- {/* Image */} -
- - setForm({...form, image: e.target.files[0]})} - className={"w-full " + textMuted} - /> -
- - {/* Notes */}
-