Meal Tracker - full feature set with auth, favorites, admin panel

This commit is contained in:
Otto
2026-03-30 21:44:46 -04:00
parent 39b66bbb5a
commit e60aaa111a
20 changed files with 1422 additions and 622 deletions

View File

@@ -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 <div>Loading...</div>;
return user ? children : <Navigate to="/login" />;
}
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 <div>Loading...</div>;
if (!user || user.username !== 'admin') return <Navigate to="/" />;
return <Admin />;
}
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Daily />} />
<Route path="/add" element={<AddEntry />} />
<Route path="/entry/:id" element={<EntryDetail />} />
<Route path="/login" element={<Login />} />
<Route path="/" element={<PrivateRoute><Daily /></PrivateRoute>} />
<Route path="/add" element={<PrivateRoute><AddEntry /></PrivateRoute>} />
<Route path="/entry/:id" element={<PrivateRoute><EntryDetail /></PrivateRoute>} />
<Route path="/admin" element={<AdminRoute />} />
<Route path="/weekly" element={<PrivateRoute><Weekly /></PrivateRoute>} />
<Route path="/leaderboard" element={<PrivateRoute><Leaderboard /></PrivateRoute>} />
<Route path="/photos" element={<PrivateRoute><Photos /></PrivateRoute>} />
<Route path="/goals" element={<PrivateRoute><Goals /></PrivateRoute>} />
</Routes>
</BrowserRouter>
);

113
client/src/api.js Normal file
View File

@@ -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();
}

View File

@@ -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 (
<div className={"min-h-screen " + bgMain}>
{/* Persistent Header */}
<div className={bgCard + " p-3 flex items-center justify-between sticky top-0 z-50 shadow-md"}>
<div className={bgCard + " p-3 flex items-center justify-between sticky top-0 z-50 shadow-md flex-wrap gap-2"}>
<div className="flex items-center gap-2">
<Link to={'/?date=' + selectedDate} className="text-emerald-500 font-bold text-lg">🍽</Link>
<input
type="date"
value={selectedDate}
onChange={handleDateChange}
className={inputBg + " rounded px-2 py-1 text-sm"}
/>
{!isCurrentDay && (
<button onClick={goToToday} className="text-emerald-500 text-sm font-semibold">
Today
</button>
)}
<button
onClick={() => setShowFavList(!showFavList)}
className="text-2xl"
title="Favorites"
>
</button>
<input type="date" value={selectedDate} onChange={e => setSelectedDate(e.target.value)} className={inputBg + " rounded px-2 py-1 text-sm"} />
{!isCurrentDay && <button onClick={() => setSelectedDate(new Date().toISOString().split('T')[0])} className="text-emerald-500 text-sm font-semibold">Today</button>}
<button onClick={() => setShowFavList(!showFavList)} className="text-2xl" title="Favorites"></button>
<button onClick={() => setShowMenu(!showMenu)} className="text-xl" title="More">📊</button>
</div>
<div className="flex items-center gap-2">
<button onClick={() => setDarkMode(!darkMode)} className="w-10 h-10 rounded-full flex items-center justify-center text-xl">{darkMode ? '☀️' : '🌙'}</button>
<button onClick={handleLogout} className="bg-red-500 hover:bg-red-600 text-white px-3 py-1 rounded-lg text-sm font-semibold">Logout</button>
</div>
<button
onClick={() => setDarkMode(!darkMode)}
className="w-10 h-10 rounded-full flex items-center justify-center text-xl"
>
{darkMode ? '☀️' : '🌙'}
</button>
</div>
{/* Favorites Dropdown */}
{/* Dropdown Menu */}
{showMenu && (
<div className={bgCard + " mx-4 mt-2 p-3 rounded-xl shadow-lg absolute z-50 w-[calc(100%-2rem)] max-w-md"}>
<div className="space-y-2">
<Link to="/weekly" onClick={() => setShowMenu(false)} className={"block p-2 rounded hover:bg-gray-700 " + textMain}>📈 Weekly Summary</Link>
<Link to="/leaderboard" onClick={() => setShowMenu(false)} className={"block p-2 rounded hover:bg-gray-700 " + textMain}>🏆 Leaderboard</Link>
<Link to="/goals" onClick={() => setShowMenu(false)} className={"block p-2 rounded hover:bg-gray-700 " + textMain}>🎯 Set Goals</Link>
{isAdmin && <Link to="/admin" onClick={() => setShowMenu(false)} className={"block p-2 rounded hover:bg-gray-700 " + textMain}> Admin</Link>}
</div>
</div>
)}
{showFavList && (
<div className={bgCard + " mx-4 mt-2 p-3 rounded-xl shadow-lg absolute z-50 w-[calc(100%-2rem)] max-w-md"}>
<div className={"font-semibold mb-2 " + textMain}>Add from Favorites</div>
{favorites.length === 0 ? (
<p className={textMuted}>No favorites yet</p>
) : (
{favorites.length === 0 ? <p className={textMuted}>No favorites yet</p> : (
<div className="space-y-2 max-h-60 overflow-y-auto">
{favorites.map(fav => (
<Link
key={fav.id}
to={buildFavoriteUrl(fav)}
onClick={() => setShowFavList(false)}
className={"block p-2 rounded hover:bg-gray-700 " + textMuted}
>
<Link key={fav.id} to={'/add?' + new URLSearchParams({name: fav.name, calories: fav.calories, protein: fav.protein, carbs: fav.carbs, fat: fav.fat}).toString()} onClick={() => setShowFavList(false)} className={"block p-2 rounded hover:bg-gray-700 " + textMuted}>
<span className={textMain}>{fav.name}</span> {fav.calories} cal
</Link>
))}
@@ -113,9 +68,7 @@ export default function Layout({ children, showFavorites = false }) {
</div>
)}
<div className="p-4 max-w-md mx-auto">
{children}
</div>
<div className="p-4 max-w-md mx-auto">{children}</div>
</div>
);
}

View File

@@ -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
});
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,
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') || '',
image: null
meal_time: ''
});
}
}, [searchParams]);
const [darkMode, setDarkMode] = useState(true);
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() {
<h1 className={"text-2xl font-bold mb-4 " + textMain}>Add Entry</h1>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Food Search */}
<div className={bgCard + " rounded-xl shadow-md p-4"}>
<label className={"block text-sm font-medium mb-2 " + textMain}>Search Food (optional)</label>
<div className="flex gap-2">
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Search food..."
className={"flex-1 px-3 py-2 rounded-lg border " + inputBg}
/>
<button type="button" onClick={handleSearch} className="bg-emerald-500 text-white px-4 py-2 rounded-lg">
Search
</button>
</div>
{showSearch && (
<div className="mt-3 max-h-40 overflow-y-auto">
{foods.length === 0 ? (
<p className={textMuted}>No results found</p>
) : (
foods.map((food, i) => (
<button
key={i}
type="button"
onClick={() => selectFood(food)}
className={"w-full text-left p-2 rounded hover:bg-gray-700 " + textMuted}
>
{food.name} {food.calories}cal P:{food.protein}g C:{food.carbs}g F:{food.fat}g
</button>
))
)}
</div>
)}
</div>
{/* Name */}
<div className={bgCard + " rounded-xl shadow-md p-4"}>
<label className={"block text-sm font-medium mb-2 " + textMain}>Food Name *</label>
<input
type="text"
required
value={form.name}
onChange={e => setForm({...form, name: e.target.value})}
placeholder="e.g., Grilled Chicken"
className={"w-full px-3 py-2 rounded-lg border " + inputBg}
/>
<input type="text" required value={form.name} onChange={e => setForm({...form, name: e.target.value})} placeholder="e.g., Grilled Chicken" className={"w-full px-3 py-2 rounded-lg border " + inputBg} />
</div>
{/* Description */}
<div className={bgCard + " rounded-xl shadow-md p-4"}>
<label className={"block text-sm font-medium mb-2 " + textMain}>Description</label>
<input
type="text"
value={form.description}
onChange={e => setForm({...form, description: e.target.value})}
placeholder="e.g., Lunch"
className={"w-full px-3 py-2 rounded-lg border " + inputBg}
/>
<label className={"block text-sm font-medium mb-2 " + textMain}>Meal Time</label>
<div className="flex gap-2 flex-wrap">
{['Breakfast', 'Lunch', 'Dinner', 'Snack'].map(time => (
<button key={time} type="button" onClick={() => setForm({...form, meal_time: form.meal_time === time ? '' : time})} className={"px-3 py-1 rounded-lg text-sm " + (form.meal_time === time ? 'bg-emerald-500 text-white' : (darkMode ? 'bg-gray-700' : 'bg-gray-200'))}>{time}</button>
))}
</div>
</div>
{/* Macros */}
<div className={bgCard + " rounded-xl shadow-md p-4"}>
<label className={"block text-sm font-medium mb-2 " + textMain}>Macros</label>
<div className="grid grid-cols-4 gap-2">
<div>
<label className={"block text-xs " + textMuted}>Calories</label>
<input type="number" value={form.calories} onChange={e => setForm({...form, calories: e.target.value})} className={"w-full px-2 py-1 rounded border " + inputBg} />
</div>
<div>
<label className={"block text-xs " + textMuted}>Protein</label>
<input type="number" value={form.protein} onChange={e => setForm({...form, protein: e.target.value})} className={"w-full px-2 py-1 rounded border " + inputBg} />
</div>
<div>
<label className={"block text-xs " + textMuted}>Carbs</label>
<input type="number" value={form.carbs} onChange={e => setForm({...form, carbs: e.target.value})} className={"w-full px-2 py-1 rounded border " + inputBg} />
</div>
<div>
<label className={"block text-xs " + textMuted}>Fat</label>
<input type="number" value={form.fat} onChange={e => setForm({...form, fat: e.target.value})} className={"w-full px-2 py-1 rounded border " + inputBg} />
</div>
<div><label className={"block text-xs " + textMuted}>Calories</label><input type="number" value={form.calories} onChange={e => setForm({...form, calories: e.target.value})} className={"w-full px-2 py-1 rounded border " + inputBg} /></div>
<div><label className={"block text-xs " + textMuted}>Protein</label><input type="number" value={form.protein} onChange={e => setForm({...form, protein: e.target.value})} className={"w-full px-2 py-1 rounded border " + inputBg} /></div>
<div><label className={"block text-xs " + textMuted}>Carbs</label><input type="number" value={form.carbs} onChange={e => setForm({...form, carbs: e.target.value})} className={"w-full px-2 py-1 rounded border " + inputBg} /></div>
<div><label className={"block text-xs " + textMuted}>Fat</label><input type="number" value={form.fat} onChange={e => setForm({...form, fat: e.target.value})} className={"w-full px-2 py-1 rounded border " + inputBg} /></div>
</div>
</div>
{/* Image */}
<div className={bgCard + " rounded-xl shadow-md p-4"}>
<label className={"block text-sm font-medium mb-2 " + textMain}>Photo</label>
<input
type="file"
accept="image/*"
onChange={e => setForm({...form, image: e.target.files[0]})}
className={"w-full " + textMuted}
/>
</div>
{/* Notes */}
<div className={bgCard + " rounded-xl shadow-md p-4"}>
<label className={"block text-sm font-medium mb-2 " + textMain}>Notes</label>
<textarea
value={form.notes}
onChange={e => setForm({...form, notes: e.target.value})}
placeholder="Any notes..."
rows={3}
className={"w-full px-3 py-2 rounded-lg border " + inputBg}
/>
<textarea value={form.notes} onChange={e => setForm({...form, notes: e.target.value})} placeholder="Any notes..." rows={3} className={"w-full px-3 py-2 rounded-lg border " + inputBg} />
</div>
{/* Submit */}
<button type="submit" className="w-full bg-emerald-500 hover:bg-emerald-600 text-white py-3 rounded-xl font-semibold">
Save Entry
</button>
<button type="submit" className="w-full bg-emerald-500 hover:bg-emerald-600 text-white py-3 rounded-xl font-semibold">Save Entry</button>
</form>
</Layout>
);

210
client/src/pages/Admin.jsx Normal file
View File

@@ -0,0 +1,210 @@
import { useState, useEffect } from 'react';
import Layout from '../components/Layout';
export default function Admin() {
const [darkMode, setDarkMode] = useState(true);
const [tab, setTab] = useState('stats');
const [users, setUsers] = useState([]);
const [entries, setEntries] = useState([]);
const [activity, setActivity] = useState([]);
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [editingUser, setEditingUser] = useState(null);
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [newUsername, setNewUsername] = useState('');
const [newUserPassword, setNewUserPassword] = useState('');
useEffect(() => {
setDarkMode(document.documentElement.classList.contains('dark'));
loadData();
}, [tab]);
async function loadData() {
setLoading(true);
try {
const user = JSON.parse(localStorage.getItem('user') || '{}');
const headers = { 'x-user-id': user.id };
const [usersRes, entriesRes, activityRes, statsRes] = await Promise.all([
fetch('/api/admin/users', { headers }),
fetch('/api/admin/entries', { headers }),
fetch('/api/admin/activity?limit=50', { headers }),
fetch('/api/admin/stats', { headers })
]);
setUsers(await usersRes.json());
setEntries(await entriesRes.json());
setActivity(await activityRes.json());
setStats(await statsRes.json());
} catch (err) { console.error(err); }
finally { setLoading(false); }
}
async function deleteUser(id) {
if (!confirm('Are you sure? This will delete all their entries.')) return;
try {
const user = JSON.parse(localStorage.getItem('user') || '{}');
const res = await fetch('/api/admin/users/' + id, { method: 'DELETE', headers: { 'x-user-id': user.id } });
if (res.ok) loadData(); else { const data = await res.json(); alert(data.error); }
} catch (err) { alert('Failed to delete user'); }
}
async function resetPassword(id) {
if (!newPassword) return alert('Please enter a new password');
if (newPassword !== confirmPassword) return alert('Passwords do not match');
try {
const user = JSON.parse(localStorage.getItem('user') || '{}');
const res = await fetch('/api/admin/users/' + id + '/reset-password', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-user-id': user.id }, body: JSON.stringify({ password: newPassword }) });
if (res.ok) { alert('Password reset'); setEditingUser(null); setNewPassword(''); setConfirmPassword(''); }
} catch (err) { alert('Failed to reset password'); }
}
async function createUser() {
if (!newUsername || !newUserPassword) return alert('Please enter username and password');
try {
const res = await fetch('/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: newUsername, password: newUserPassword }) });
if (res.ok) { alert('User created'); setShowCreate(false); setNewUsername(''); setNewUserPassword(''); loadData(); }
else { const data = await res.json(); alert(data.error); }
} catch (err) { alert('Failed to create user'); }
}
async function exportData() {
try {
const user = JSON.parse(localStorage.getItem('user') || '{}');
const entriesRes = await fetch('/api/admin/entries', { headers: { 'x-user-id': user.id } });
const allEntries = await entriesRes.json();
const csv = 'ID,User,Name,Description,Calories,Protein,Carbs,Fat,Date\n' + allEntries.map(e => `${e.id},${e.username},"${e.name}","${e.description || ''}",${e.calories},${e.protein},${e.carbs},${e.fat},${e.created_at}`).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'meal-tracker-export.csv'; a.click();
} catch (err) { alert('Failed to export'); }
}
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 inputBg = darkMode ? 'bg-gray-700 border-gray-600 text-white' : 'bg-white border-gray-300 text-gray-900';
const tabActive = darkMode ? 'bg-emerald-600 text-white' : 'bg-emerald-500 text-white';
const tabInactive = darkMode ? 'bg-gray-700 text-gray-300' : 'bg-gray-200 text-gray-700';
if (loading) return <Layout><div className="p-4">Loading...</div></Layout>;
return (
<Layout>
<h1 className={"text-2xl font-bold mb-4 " + textMain}>Admin Panel</h1>
{/* Export Button at top */}
<button onClick={exportData} className="bg-blue-500 text-white px-4 py-2 rounded-lg mb-4">📥 Export CSV</button>
{/* Tabs */}
<div className="flex gap-2 mb-4 overflow-x-auto">
{['stats', 'users', 'entries', 'activity'].map(t => (
<button key={t} onClick={() => setTab(t)} className={"px-4 py-2 rounded-lg font-semibold " + (tab === t ? tabActive : tabInactive)}>{t.charAt(0).toUpperCase() + t.slice(1)}</button>
))}
</div>
{tab === 'stats' && stats && (
<div className="grid grid-cols-2 gap-4">
<div className={bgCard + " p-4 rounded-xl shadow-md"}><div className={"text-3xl font-bold " + textMain}>{stats.totalUsers}</div><div className={textMuted}>Total Users</div></div>
<div className={bgCard + " p-4 rounded-xl shadow-md"}><div className={"text-3xl font-bold " + textMain}>{stats.totalEntries}</div><div className={textMuted}>Total Entries</div></div>
<div className={bgCard + " p-4 rounded-xl shadow-md"}><div className={"text-3xl font-bold " + textMain}>{stats.todayEntries}</div><div className={textMuted}>Today's Entries</div></div>
<div className={bgCard + " p-4 rounded-xl shadow-md"}><div className={"text-3xl font-bold " + textMain}>{stats.todayCalories}</div><div className={textMuted}>Calories Today</div></div>
</div>
)}
{tab === 'users' && (
<>
<button onClick={() => setShowCreate(true)} className="bg-emerald-500 text-white px-4 py-2 rounded-lg mb-4">+ New User</button>
<div className={bgCard + " rounded-xl shadow-md overflow-hidden"}>
<table className="w-full">
<thead className={darkMode ? 'bg-gray-700' : 'bg-gray-100'}>
<tr><th className={"px-4 py-2 text-left " + textMain}>ID</th><th className={"px-4 py-2 text-left " + textMain}>Username</th><th className={"px-4 py-2 text-left " + textMain}>Created</th><th className={"px-4 py-2 text-left " + textMain}>Actions</th></tr>
</thead>
<tbody>
{users.map(user => (
<tr key={user.id} className={darkMode ? 'border-t border-gray-700' : 'border-t border-gray-200'}>
<td className={"px-4 py-2 " + textMuted}>{user.id}</td>
<td className={"px-4 py-2 font-semibold " + textMain}>{user.username}</td>
<td className={"px-4 py-2 " + textMuted}>{new Date(user.created_at).toLocaleDateString()}</td>
<td className="px-4 py-2"><button onClick={() => setEditingUser(user.id)} className="text-emerald-500 mr-2">Reset PW</button><button onClick={() => deleteUser(user.id)} className="text-red-500">Delete</button></td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{tab === 'entries' && (
<div className={bgCard + " rounded-xl shadow-md overflow-hidden"}>
<table className="w-full">
<thead className={darkMode ? 'bg-gray-700' : 'bg-gray-100'}>
<tr><th className={"px-2 py-2 text-left " + textMain}>ID</th><th className={"px-2 py-2 text-left " + textMain}>User</th><th className={"px-2 py-2 text-left " + textMain}>Name</th><th className={"px-2 py-2 text-left " + textMain}>Cal</th><th className={"px-2 py-2 text-left " + textMain}>Date</th></tr>
</thead>
<tbody>
{entries.map(entry => (
<tr key={entry.id} className={darkMode ? 'border-t border-gray-700' : 'border-t border-gray-200'}>
<td className={"px-2 py-2 " + textMuted}>{entry.id}</td>
<td className={"px-2 py-2 " + textMuted}>{entry.username}</td>
<td className={"px-2 py-2 " + textMain}>{entry.name}</td>
<td className={"px-2 py-2 " + textMuted}>{entry.calories}</td>
<td className={"px-2 py-2 " + textMuted}>{new Date(entry.created_at).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{tab === 'activity' && (
<div className={bgCard + " rounded-xl shadow-md overflow-hidden"}>
<table className="w-full">
<thead className={darkMode ? 'bg-gray-700' : 'bg-gray-100'}>
<tr><th className={"px-4 py-2 text-left " + textMain}>Time</th><th className={"px-4 py-2 text-left " + textMain}>User</th><th className={"px-4 py-2 text-left " + textMain}>Action</th><th className={"px-4 py-2 text-left " + textMain}>Details</th></tr>
</thead>
<tbody>
{activity.map(log => (
<tr key={log.id} className={darkMode ? 'border-t border-gray-700' : 'border-t border-gray-200'}>
<td className={"px-4 py-2 " + textMuted}>{new Date(log.created_at).toLocaleString()}</td>
<td className={"px-4 py-2 " + textMain}>{log.username || 'System'}</td>
<td className={"px-4 py-2 " + textMuted}>{log.action}</td>
<td className={"px-4 py-2 " + textMuted}>{log.details}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{editingUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className={bgCard + " p-6 rounded-xl shadow-lg max-w-sm w-full mx-4"}>
<h2 className={"text-xl font-bold mb-4 " + textMain}>Reset Password</h2>
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} placeholder="New password" className={"w-full px-3 py-2 rounded-lg border mb-2 " + inputBg} />
<input type="password" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} placeholder="Confirm password" className={"w-full px-3 py-2 rounded-lg border mb-4 " + inputBg} />
<div className="flex gap-2">
<button onClick={() => { setEditingUser(null); setNewPassword(''); setConfirmPassword(''); }} className="flex-1 bg-gray-500 text-white py-2 rounded-lg">Cancel</button>
<button onClick={() => resetPassword(editingUser)} className="flex-1 bg-emerald-500 text-white py-2 rounded-lg">Reset</button>
</div>
</div>
</div>
)}
{showCreate && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className={bgCard + " p-6 rounded-xl shadow-lg max-w-sm w-full mx-4"}>
<h2 className={"text-xl font-bold mb-4 " + textMain}>Create New User</h2>
<input type="text" value={newUsername} onChange={e => setNewUsername(e.target.value)} placeholder="Username" className={"w-full px-3 py-2 rounded-lg border mb-2 " + inputBg} />
<input type="password" value={newUserPassword} onChange={e => setNewUserPassword(e.target.value)} placeholder="Password" className={"w-full px-3 py-2 rounded-lg border mb-4 " + inputBg} />
<div className="flex gap-2">
<button onClick={() => { setShowCreate(false); setNewUsername(''); setNewUserPassword(''); }} className="flex-1 bg-gray-500 text-white py-2 rounded-lg">Cancel</button>
<button onClick={createUser} className="flex-1 bg-emerald-500 text-white py-2 rounded-lg">Create</button>
</div>
</div>
</div>
)}
</Layout>
);
}

View File

@@ -1,61 +1,47 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { getEntries, getDailySummary, getGoals, getStreak, logout } from '../api';
import Layout from '../components/Layout';
export default function Daily() {
const [entries, setEntries] = useState([]);
const [summary, setSummary] = useState({ total_calories: 0, total_protein: 0, total_carbs: 0, total_fat: 0 });
const [goals, setGoals] = useState({ calories: 2000, protein: 150, carbs: 250, fat: 65 });
const [streak, setStreak] = useState({ currentStreak: 0, longestStreak: 0 });
const [loading, setLoading] = useState(true);
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
const [isCurrentDay, setIsCurrentDay] = useState(true);
const [darkMode, setDarkMode] = useState(true);
useEffect(() => {
setDarkMode(document.documentElement.classList.contains('dark'));
loadData();
}, [selectedDate]);
useEffect(() => {
const today = new Date().toISOString().split('T')[0];
setIsCurrentDay(selectedDate === today);
setDarkMode(document.documentElement.classList.contains('dark'));
}, [selectedDate]);
async function loadData() {
try {
setLoading(true);
const entriesRes = await fetch('/api/entries?date=' + selectedDate);
const entriesData = await entriesRes.json();
const summaryRes = await fetch('/api/summary/daily?date=' + selectedDate);
const summaryData = await summaryRes.json();
const [entriesData, summaryData, goalsData, streakData] = await Promise.all([
getEntries(selectedDate),
getDailySummary(selectedDate),
getGoals(),
getStreak()
]);
setEntries(entriesData);
setSummary(summaryData);
} catch (err) {
console.error('Failed to load:', err);
} finally {
setLoading(false);
}
setGoals(goalsData);
setStreak(streakData);
} catch (err) { console.error('Failed to load:', err); }
finally { setLoading(false); }
}
function formatDate(dateStr) {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
}
function handleDateChange(e) {
setSelectedDate(e.target.value);
}
function goToToday() {
setSelectedDate(new Date().toISOString().split('T')[0]);
}
function toggleTheme() {
setDarkMode(!darkMode);
if (darkMode) {
document.documentElement.classList.remove('dark');
} else {
document.documentElement.classList.add('dark');
}
function getProgress(current, target) {
return Math.min(100, Math.round((current / target) * 100));
}
const bgMain = darkMode ? 'bg-gray-900' : 'bg-gray-100';
@@ -63,7 +49,6 @@ export default function Daily() {
const bgSummary = darkMode ? 'bg-emerald-700' : 'bg-emerald-600';
const textMain = darkMode ? 'text-white' : 'text-gray-900';
const textMuted = darkMode ? 'text-gray-400' : 'text-gray-500';
const inputBg = darkMode ? 'bg-gray-700 border-gray-600 text-white' : 'bg-white border-gray-300 text-gray-900';
if (loading) return <Layout><div className="p-4">Loading...</div></Layout>;
@@ -73,69 +58,61 @@ export default function Daily() {
{isCurrentDay ? "Today's Meals" : formatDate(selectedDate)}
</h1>
{/* Summary Card */}
<div className={bgSummary + " text-white p-4 rounded-xl mb-4 shadow-lg"}>
<div className="grid grid-cols-4 gap-2 text-center">
<div>
<div className="text-xs opacity-80">Cal</div>
<div className="font-bold text-lg">{summary.total_calories || 0}</div>
{/* Streak Display */}
{streak.currentStreak > 0 && (
<div className={bgCard + " p-3 rounded-xl mb-3 flex items-center justify-center gap-4"}>
<span className="text-2xl">🔥</span>
<span className={"text-lg font-bold " + textMain}>{streak.currentStreak} day streak!</span>
</div>
<div>
<div className="text-xs opacity-80">Protein</div>
<div className="font-bold text-lg">{summary.total_protein || 0}g</div>
</div>
<div>
<div className="text-xs opacity-80">Carbs</div>
<div className="font-bold text-lg">{summary.total_carbs || 0}g</div>
</div>
<div>
<div className="text-xs opacity-80">Fat</div>
<div className="font-bold text-lg">{summary.total_fat || 0}g</div>
)}
{/* Macros with Goals */}
<div className={bgSummary + " text-white p-4 rounded-xl mb-3 shadow-lg"}>
<div className="grid grid-cols-4 gap-2 text-center mb-2">
<div><div className="text-xs opacity-80">Cal</div><div className="font-bold text-lg">{summary.total_calories || 0}</div></div>
<div><div className="text-xs opacity-80">Protein</div><div className="font-bold text-lg">{summary.total_protein || 0}g</div></div>
<div><div className="text-xs opacity-80">Carbs</div><div className="font-bold text-lg">{summary.total_carbs || 0}g</div></div>
<div><div className="text-xs opacity-80">Fat</div><div className="font-bold text-lg">{summary.total_fat || 0}g</div></div>
</div>
{/* Progress bars */}
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs"><span className="w-12">Cal</span><div className="flex-1 h-2 bg-black bg-opacity-20 rounded"><div className="h-full bg-white rounded" style={{width: getProgress(summary.total_calories, goals.calories) + '%'}}></div></div><span>{getProgress(summary.total_calories, goals.calories)}%</span></div>
<div className="flex items-center gap-2 text-xs"><span className="w-12">Protein</span><div className="flex-1 h-2 bg-black bg-opacity-20 rounded"><div className="h-full bg-white rounded" style={{width: getProgress(summary.total_protein, goals.protein) + '%'}}></div></div><span>{getProgress(summary.total_protein, goals.protein)}%</span></div>
</div>
</div>
{/* Entries List */}
<div className="space-y-3 mb-20">
{isCurrentDay && (
<Link to="/add" className={bgCard + " flex items-center justify-center py-3 rounded-xl shadow-md mb-4 hover:shadow-lg transition-shadow"}>
<span className={"text-lg font-semibold " + textMain}>+ Add Entry</span>
</Link>
)}
<div className="space-y-3">
{entries.length === 0 ? (
<div className={bgCard + " rounded-xl p-8 text-center " + textMuted}>
<div className="text-4xl mb-2">🍽</div>
<p>No meals logged for this day</p>
</div>
<div className={bgCard + " rounded-xl p-8 text-center " + textMuted}><div className="text-4xl mb-2">🍽</div><p>No meals logged for this day</p></div>
) : (
entries.map(entry => (
<Link
key={entry.id}
to={'/entry/' + entry.id + '?date=' + selectedDate}
className={bgCard + " rounded-xl shadow-md p-3 block hover:shadow-lg transition-shadow"}
>
<Link key={entry.id} to={'/entry/' + entry.id + '?date=' + selectedDate} className={bgCard + " rounded-xl shadow-md p-3 block hover:shadow-lg transition-shadow"}>
<div className="flex gap-3">
{entry.image_url && (
<img
src={'http://10.10.10.143:3000' + entry.image_url}
alt=""
className="w-16 h-16 object-cover rounded-lg flex-shrink-0"
/>
)}
{entry.image_url && <img src={'http://10.10.10.143:3000' + entry.image_url} alt="" className="w-16 h-16 object-cover rounded-lg flex-shrink-0" />}
<div className="flex-1">
<div className={"font-semibold " + textMain}>{entry.name}</div>
{entry.description && <div className={"text-sm " + textMuted}>{entry.description}</div>}
<div className={"text-xs mt-1 " + textMuted}>
{entry.calories || 0} cal P: {entry.protein || 0}g C: {entry.carbs || 0}g F: {entry.fat || 0}g
<div className={"font-semibold " + textMain}>
{entry.name}
{entry.meal_time && <span className="ml-2 text-xs bg-emerald-500 text-white px-2 py-0.5 rounded">{entry.meal_time}</span>}
</div>
{entry.description && <div className={"text-sm " + textMuted}>{entry.description}</div>}
<div className={"text-xs mt-1 " + textMuted}>{entry.calories || 0} cal P: {entry.protein || 0}g C: {entry.carbs || 0}g F: {entry.fat || 0}g</div>
</div>
</div>
</Link>
))
)}
</div>
{/* Add Button */}
{isCurrentDay && (
<Link to="/add" className="fixed bottom-6 right-6 bg-emerald-500 hover:bg-emerald-600 text-white w-14 h-14 rounded-full flex items-center justify-center text-2xl shadow-xl">
+
</Link>
)}
</Layout>
);
}
function formatDate(dateStr) {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useParams, useSearchParams, Link } from 'react-router-dom';
import Layout from '../components/Layout';
import { getHeaders, toggleFavorite } from '../api';
export default function EntryDetail() {
const { id } = useParams();
@@ -17,10 +18,10 @@ export default function EntryDetail() {
useEffect(() => {
async function load() {
try {
const res = await fetch('/api/entries');
const entries = await res.json();
const found = entries.find(e => String(e.id) === String(id));
setEntry(found);
const res = await fetch(`/api/entries/${id}`, { headers: getHeaders() });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Entry not found');
setEntry(data);
} catch (err) {
console.error(err);
} finally {
@@ -30,90 +31,40 @@ export default function EntryDetail() {
load();
}, [id]);
async function handleDelete() {
if (!confirm('Delete this entry?')) return;
await fetch('/api/entries/' + id, { method: 'DELETE' });
window.location.href = returnDate ? '/?date=' + returnDate : '/';
}
async function toggleFavorite() {
async function handleFavorite() {
try {
const res = await fetch('/api/entries/' + id + '/favorite', { method: 'POST' });
const data = await res.json();
setEntry({ ...entry, favorite: data.favorite });
await toggleFavorite(id);
setEntry({...entry, favorite: entry.favorite ? 0 : 1});
} catch (err) {
console.error(err);
}
}
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';
if (loading) return <Layout><div className="p-4">Loading...</div></Layout>;
if (!entry) return <Layout><div className={"p-4 " + textMain}>Entry not found</div></Layout>;
const returnUrl = returnDate ? '/?date=' + returnDate : '/';
if (loading) return <Layout><div className="p-4 text-white">Loading...</div></Layout>;
if (!entry) return <Layout><div className="p-4 text-red-500">Entry not found</div></Layout>;
return (
<Layout>
<Link to={returnUrl} className="text-emerald-500 mb-4 block"> Back</Link>
<div className="flex items-center gap-2">
<h1 className={"text-2xl font-bold mb-2 flex-1 " + textMain}>{entry.name}</h1>
<button
onClick={toggleFavorite}
className="text-3xl"
title={entry.favorite ? "Remove from favorites" : "Add to favorites"}
>
{entry.favorite ? '⭐' : '☆'}
</button>
<Link to={"/?date=" + returnDate} className={"text-emerald-400 hover:text-emerald-300 mb-4 inline-block"}> Back</Link>
<div className="flex justify-between items-center mb-4">
<h1 className={"text-2xl font-bold " + textMain}>{entry.name}</h1>
<button onClick={handleFavorite} className="text-2xl">{entry.favorite ? '⭐' : '☆'}</button>
</div>
{entry.description && <p className={textMuted + " mb-4"}>{entry.description}</p>}
{entry.image_url && (
<img
src={'http://10.10.10.143:3000' + entry.image_url}
alt=""
className="w-full rounded-xl mb-4"
/>
)}
<div className={bgCard + " rounded-xl shadow-md p-4 mb-4"}>
<h2 className={"font-semibold mb-2 " + textMain}>Macros</h2>
<div className="grid grid-cols-4 gap-2 text-center">
<div>
<div className={"text-xs " + textMuted}>Cal</div>
<div className={"font-bold text-lg " + textMain}>{entry.calories || 0}</div>
</div>
<div>
<div className={"text-xs " + textMuted}>Protein</div>
<div className={"font-bold text-lg " + textMain}>{entry.protein || 0}g</div>
</div>
<div>
<div className={"text-xs " + textMuted}>Carbs</div>
<div className={"font-bold text-lg " + textMain}>{entry.carbs || 0}g</div>
</div>
<div>
<div className={"text-xs " + textMuted}>Fat</div>
<div className={"font-bold text-lg " + textMain}>{entry.fat || 0}g</div>
<div className="grid grid-cols-2 gap-4">
<div><span className={textMuted}>Calories</span><p className={"text-xl font-semibold " + textMain}>{entry.calories}</p></div>
<div><span className={textMuted}>Protein</span><p className={"text-xl font-semibold " + textMain}>{entry.protein}g</p></div>
<div><span className={textMuted}>Carbs</span><p className={"text-xl font-semibold " + textMain}>{entry.carbs}g</p></div>
<div><span className={textMuted}>Fat</span><p className={"text-xl font-semibold " + textMain}>{entry.fat}g</p></div>
</div>
</div>
</div>
{entry.notes && (
<div className={bgCard + " rounded-xl shadow-md p-4 mb-4"}>
<h2 className={"font-semibold mb-1 " + textMain}>Notes</h2>
<p className={textMuted}>{entry.notes}</p>
</div>
)}
<div className="flex gap-2">
<button onClick={handleDelete} className="flex-1 bg-red-500 hover:bg-red-600 text-white py-3 rounded-xl font-semibold">
Delete Entry
</button>
</div>
{entry.description && <p className={textMain + " mb-2"}><strong>Description:</strong> {entry.description}</p>}
{entry.notes && <p className={textMuted + " mb-2"}><strong>Notes:</strong> {entry.notes}</p>}
{entry.meal_time && <p className={textMuted}><strong>Meal Time:</strong> {entry.meal_time}</p>}
</Layout>
);
}

126
client/src/pages/Goals.jsx Normal file
View File

@@ -0,0 +1,126 @@
import { useState, useEffect } from 'react';
import { getGoals, updateGoals } from '../api';
import Layout from '../components/Layout';
export default function Goals() {
const [goals, setGoals] = useState({ calories: 2000, protein: 150, carbs: 250, fat: 65 });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [darkMode, setDarkMode] = useState(true);
const [message, setMessage] = useState('');
useEffect(() => {
setDarkMode(document.documentElement.classList.contains('dark'));
loadGoals();
}, []);
async function loadGoals() {
try {
setLoading(true);
const data = await getGoals();
if (data && data.calories !== undefined) {
setGoals(data);
}
} catch (err) {
console.error('Failed to load goals:', err);
} finally {
setLoading(false);
}
}
async function handleSave() {
try {
setSaving(true);
await updateGoals(goals);
setMessage('Goals saved successfully!');
setTimeout(() => setMessage(''), 3000);
} catch (err) {
console.error('Failed to save goals:', err);
setMessage('Failed to save goals');
} finally {
setSaving(false);
}
}
function handleChange(field, value) {
setGoals(prev => ({ ...prev, [field]: parseInt(value) || 0 }));
}
const bgMain = darkMode ? 'bg-gray-900' : 'bg-gray-100';
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 inputBg = darkMode ? 'bg-gray-700' : 'bg-gray-100';
const inputText = darkMode ? 'text-white' : 'text-gray-900';
const borderColor = darkMode ? 'border-gray-600' : 'border-gray-300';
if (loading) return <Layout><div className="p-4">Loading...</div></Layout>;
return (
<Layout>
<h1 className={"text-2xl font-bold mb-6 " + textMain}>Daily Goals</h1>
<div className={bgCard + " rounded-xl p-6 shadow-md"}>
<div className="space-y-4">
<div>
<label className={"block text-sm font-medium mb-2 " + textMuted}>Calories</label>
<input
type="number"
value={goals.calories}
onChange={(e) => handleChange('calories', e.target.value)}
className={"w-full px-4 py-3 rounded-lg border " + inputBg + " " + inputText + " " + borderColor}
placeholder="Daily calorie target"
/>
</div>
<div>
<label className={"block text-sm font-medium mb-2 " + textMuted}>Protein (g)</label>
<input
type="number"
value={goals.protein}
onChange={(e) => handleChange('protein', e.target.value)}
className={"w-full px-4 py-3 rounded-lg border " + inputBg + " " + inputText + " " + borderColor}
placeholder="Daily protein target"
/>
</div>
<div>
<label className={"block text-sm font-medium mb-2 " + textMuted}>Carbs (g)</label>
<input
type="number"
value={goals.carbs}
onChange={(e) => handleChange('carbs', e.target.value)}
className={"w-full px-4 py-3 rounded-lg border " + inputBg + " " + inputText + " " + borderColor}
placeholder="Daily carbs target"
/>
</div>
<div>
<label className={"block text-sm font-medium mb-2 " + textMuted}>Fat (g)</label>
<input
type="number"
value={goals.fat}
onChange={(e) => handleChange('fat', e.target.value)}
className={"w-full px-4 py-3 rounded-lg border " + inputBg + " " + inputText + " " + borderColor}
placeholder="Daily fat target"
/>
</div>
</div>
<button
onClick={handleSave}
disabled={saving}
className="w-full mt-6 bg-emerald-600 hover:bg-emerald-700 text-white font-semibold py-3 rounded-lg transition-colors disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save Goals'}
</button>
{message && (
<div className={"mt-4 p-3 rounded-lg text-center " + (message.includes('success') ? 'bg-emerald-600' : 'bg-red-600') + " text-white"}>
{message}
</div>
)}
</div>
</Layout>
);
}

View File

@@ -0,0 +1,123 @@
import { useState, useEffect } from 'react';
import { getLeaderboard } from '../api';
import Layout from '../components/Layout';
const METRICS = [
{ key: 'calories', label: 'Calories', unit: 'cal' },
{ key: 'protein', label: 'Protein', unit: 'g' },
{ key: 'carbs', label: 'Carbs', unit: 'g' },
{ key: 'fat', label: 'Fat', unit: 'g' }
];
const AVATAR_EMOJIS = ['🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼', '🐨', '🐯', '🦁', '🐮', '🐷', '🐸', '🐵', '🐔', '🐧', '🐦', '🦆', '🦅', '🦉', '🦇', '🐺', '🐗', '🐴', '🦄', '🐝', '🐛', '🦋', '🐌', '🐞', '🐜'];
function getAvatarEmoji(username) {
if (!username) return '👤';
const firstLetter = username.charAt(0).toLowerCase();
const charCode = firstLetter.charCodeAt(0) - 97;
if (charCode >= 0 && charCode < AVATAR_EMOJIS.length) {
return AVATAR_EMOJIS[charCode];
}
return '👤';
}
function getRankStyle(rank, darkMode) {
if (rank === 1) return '🥇';
if (rank === 2) return '🥈';
if (rank === 3) return '🥉';
return `#${rank}`;
}
export default function Leaderboard() {
const [leaderboard, setLeaderboard] = useState([]);
const [metric, setMetric] = useState('calories');
const [loading, setLoading] = useState(true);
const [darkMode, setDarkMode] = useState(true);
useEffect(() => {
setDarkMode(document.documentElement.classList.contains('dark'));
loadLeaderboard();
}, [metric]);
async function loadLeaderboard() {
try {
setLoading(true);
const data = await getLeaderboard(metric);
setLeaderboard(data);
} catch (err) {
console.error('Failed to load leaderboard:', err);
} finally {
setLoading(false);
}
}
const currentMetric = METRICS.find(m => m.key === metric);
const bgMain = darkMode ? 'bg-gray-900' : 'bg-gray-100';
const bgCard = darkMode ? 'bg-gray-800' : 'bg-white';
const bgButton = darkMode ? 'bg-gray-700 hover:bg-gray-600' : 'bg-gray-200 hover:bg-gray-300';
const bgButtonActive = darkMode ? 'bg-emerald-600 hover:bg-emerald-500' : 'bg-emerald-500 hover:bg-emerald-600';
const textMain = darkMode ? 'text-white' : 'text-gray-900';
const textMuted = darkMode ? 'text-gray-400' : 'text-gray-500';
return (
<Layout>
<h1 className={"text-2xl font-bold mb-4 " + textMain}>Leaderboard</h1>
{/* Metric Selector */}
<div className="flex flex-wrap gap-2 mb-4">
{METRICS.map(m => (
<button
key={m.key}
onClick={() => setMetric(m.key)}
className={`px-4 py-2 rounded-lg font-semibold transition-colors ${
metric === m.key
? bgButtonActive + ' text-white'
: bgButton + ' ' + textMain
}`}
>
{m.label}
</button>
))}
</div>
{/* Leaderboard List */}
{loading ? (
<div className={bgCard + " p-4 rounded-xl text-center " + textMuted}>Loading...</div>
) : leaderboard.length === 0 ? (
<div className={bgCard + " p-8 rounded-xl text-center " + textMuted}>
<div className="text-4xl mb-2">📊</div>
<p>No data available for {currentMetric?.label}</p>
</div>
) : (
<div className="space-y-2">
{leaderboard.map((entry, index) => (
<div
key={entry.username || index}
className={bgCard + " rounded-xl shadow-md p-4 flex items-center gap-4"}
>
{/* Rank */}
<div className="text-2xl font-bold w-10 text-center">
{getRankStyle(index + 1, darkMode)}
</div>
{/* Avatar */}
<div className="text-4xl">{getAvatarEmoji(entry.username)}</div>
{/* Username & Total */}
<div className="flex-1">
<div className={"font-semibold text-lg " + textMain}>
{entry.username || 'Unknown'}
</div>
</div>
{/* Value */}
<div className={"text-xl font-bold " + (darkMode ? 'text-emerald-400' : 'text-emerald-600')}>
{entry.total?.toLocaleString() || 0}{currentMetric?.unit}
</div>
</div>
))}
</div>
)}
</Layout>
);
}

106
client/src/pages/Login.jsx Normal file
View File

@@ -0,0 +1,106 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import Layout from '../components/Layout';
export default function Login() {
const navigate = useNavigate();
const [darkMode, setDarkMode] = useState(true);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isRegister, setIsRegister] = useState(false);
useEffect(() => {
setDarkMode(document.documentElement.classList.contains('dark'));
}, []);
const bgMain = darkMode ? 'bg-gray-900' : 'bg-gray-100';
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 inputBg = darkMode ? 'bg-gray-700 border-gray-600 text-white' : 'bg-white border-gray-300 text-gray-900';
async function handleSubmit(e) {
e.preventDefault();
setError('');
const endpoint = isRegister ? '/api/auth/register' : '/api/auth/login';
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Failed to ' + (isRegister ? 'register' : 'login'));
return;
}
localStorage.setItem('user', JSON.stringify(data));
// Redirect admin to admin panel
if (data.username === 'admin') {
navigate('/admin');
} else {
navigate('/');
}
} catch (err) {
setError('Network error');
}
}
return (
<Layout>
<div className={bgCard + " p-6 rounded-xl shadow-lg max-w-sm mx-auto mt-8"}>
<h1 className={"text-2xl font-bold mb-6 text-center " + textMain}>
{isRegister ? 'Create Account' : 'Login'}
</h1>
{error && (
<div className="bg-red-500 text-white p-3 rounded-lg mb-4 text-center">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className={"block text-sm font-medium mb-1 " + textMain}>Username</label>
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
className={"w-full px-3 py-2 rounded-lg border " + inputBg}
required
/>
</div>
<div>
<label className={"block text-sm font-medium mb-1 " + textMain}>Password</label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
className={"w-full px-3 py-2 rounded-lg border " + inputBg}
required
/>
</div>
<button type="submit" className="w-full bg-emerald-500 hover:bg-emerald-600 text-white py-3 rounded-lg font-semibold">
{isRegister ? 'Create Account' : 'Login'}
</button>
</form>
<button
onClick={() => setIsRegister(!isRegister)}
className={"w-full mt-4 text-sm " + textMuted}
>
{isRegister ? 'Already have an account? Login' : 'Need an account? Register'}
</button>
</div>
</Layout>
);
}

View File

@@ -0,0 +1,68 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { getPhotos } from '../api';
import Layout from '../components/Layout';
export default function Photos() {
const [photos, setPhotos] = useState([]);
const [loading, setLoading] = useState(true);
const [darkMode, setDarkMode] = useState(true);
useEffect(() => {
setDarkMode(document.documentElement.classList.contains('dark'));
loadPhotos();
}, []);
async function loadPhotos() {
try {
setLoading(true);
const data = await getPhotos(50);
setPhotos(data);
} catch (err) {
console.error('Failed to load photos:', err);
} finally {
setLoading(false);
}
}
const bgMain = darkMode ? 'bg-gray-900' : 'bg-gray-100';
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';
if (loading) return <Layout><div className="p-4">Loading...</div></Layout>;
return (
<Layout>
<h1 className={"text-2xl font-bold mb-4 " + textMain}>Photo Gallery</h1>
{photos.length === 0 ? (
<div className={bgCard + " rounded-xl p-8 text-center " + textMuted}>
<div className="text-4xl mb-2">📷</div>
<p>No photos yet</p>
</div>
) : (
<div className="grid grid-cols-3 gap-4">
{photos.map(photo => (
<Link
key={photo.id}
to={'/entry/' + photo.id}
className={bgCard + " rounded-xl shadow-md overflow-hidden hover:shadow-lg transition-shadow"}
>
<div className="aspect-square">
<img
src={'http://10.10.10.143:3000' + photo.image_url}
alt={photo.name}
className="w-full h-full object-cover"
/>
</div>
<div className={"p-2 text-center font-medium " + textMain}>
{photo.name}
</div>
</Link>
))}
</div>
)}
</Layout>
);
}

142
client/src/pages/Weekly.jsx Normal file
View File

@@ -0,0 +1,142 @@
import { useState, useEffect } from 'react';
import { getWeeklySummary, getGoals } from '../api';
import Layout from '../components/Layout';
export default function Weekly() {
const [weeklyData, setWeeklyData] = useState([]);
const [goals, setGoals] = useState({ calories: 2000, protein: 150, carbs: 250, fat: 65 });
const [loading, setLoading] = useState(true);
const [darkMode, setDarkMode] = useState(true);
useEffect(() => {
setDarkMode(document.documentElement.classList.contains('dark'));
loadData();
}, []);
async function loadData() {
try {
setLoading(true);
const [weeklyData, goalsData] = await Promise.all([
getWeeklySummary(),
getGoals()
]);
setWeeklyData(weeklyData);
setGoals(goalsData);
} catch (err) {
console.error('Failed to load:', err);
} finally {
setLoading(false);
}
}
// Calculate totals
const totals = weeklyData.reduce(
(acc, day) => ({
calories: acc.calories + (day.calories || 0),
protein: acc.protein + (day.protein || 0),
carbs: acc.carbs + (day.carbs || 0),
fat: acc.fat + (day.fat || 0)
}),
{ calories: 0, protein: 0, carbs: 0, fat: 0 }
);
// Get max calories for scaling (use at least 1000 to avoid tiny bars)
const maxCalories = Math.max(
...weeklyData.map(d => d.calories || 0),
1000
);
// Get last 7 days labels
function getDayLabel(dateStr) {
const date = new Date(dateStr);
const today = new Date();
const diffDays = Math.floor((today - date) / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
return date.toLocaleDateString('en-US', { weekday: 'short' });
}
const bgMain = darkMode ? 'bg-gray-900' : 'bg-gray-100';
const bgCard = darkMode ? 'bg-gray-800' : 'bg-white';
const bgBar = darkMode ? 'bg-emerald-500' : 'bg-emerald-600';
const textMain = darkMode ? 'text-white' : 'text-gray-900';
const textMuted = darkMode ? 'text-gray-400' : 'text-gray-500';
if (loading) return <Layout><div className="p-4">Loading...</div></Layout>;
return (
<Layout>
<h1 className={"text-2xl font-bold mb-4 " + textMain}>Weekly Summary</h1>
{/* Weekly Totals */}
<div className={bgCard + " p-4 rounded-xl mb-4 shadow-md"}>
<div className={"text-sm mb-3 " + textMuted}>This Week's Totals</div>
<div className="grid grid-cols-4 gap-2 text-center">
<div>
<div className="text-xs text-gray-400">Calories</div>
<div className={"font-bold text-xl " + textMain}>{totals.calories}</div>
</div>
<div>
<div className="text-xs text-gray-400">Protein</div>
<div className={"font-bold text-xl " + textMain}>{totals.protein}g</div>
</div>
<div>
<div className="text-xs text-gray-400">Carbs</div>
<div className={"font-bold text-xl " + textMain}>{totals.carbs}g</div>
</div>
<div>
<div className="text-xs text-gray-400">Fat</div>
<div className={"font-bold text-xl " + textMain}>{totals.fat}g</div>
</div>
</div>
</div>
{/* Bar Chart */}
<div className={bgCard + " p-4 rounded-xl shadow-md"}>
<div className={"text-sm mb-4 " + textMuted}>Daily Calories</div>
<div className="flex items-end justify-between gap-2 h-48">
{weeklyData.map((day, index) => {
const height = day.calories ? (day.calories / maxCalories) * 100 : 0;
return (
<div key={index} className="flex-1 flex flex-col items-center">
<div className="w-full flex flex-col items-center justify-end h-36">
<span className={"text-xs mb-1 " + textMuted}>
{day.calories || 0}
</span>
<div
className={bgBar + " w-full rounded-t transition-all duration-300"}
style={{ height: `${height}%`, minHeight: day.calories ? '4px' : '0' }}
></div>
</div>
<div className={"text-xs mt-2 " + textMuted}>
{getDayLabel(day.date)}
</div>
</div>
);
})}
</div>
</div>
{/* Daily Breakdown */}
<div className={bgCard + " p-4 rounded-xl mt-4 shadow-md"}>
<div className={"text-sm mb-3 " + textMuted}>Daily Breakdown</div>
<div className="space-y-2">
{[...weeklyData].reverse().map((day, index) => (
<div key={index} className={"flex justify-between items-center text-sm border-b border-gray-700 pb-2 " + (index < weeklyData.length - 1 ? 'border-b' : '')}>
<div className={textMain}>
{new Date(day.date).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}
</div>
<div className={textMuted}>
<span className="mr-3">{day.calories || 0} cal</span>
<span className="mr-3">P: {day.protein || 0}g</span>
<span className="mr-3">C: {day.carbs || 0}g</span>
<span>F: {day.fat || 0}g</span>
</div>
</div>
))}
</div>
</div>
</Layout>
);
}

View File

@@ -1,46 +1,58 @@
import express from 'express';
import cors from 'cors';
import path from 'path';
import { initialize, getDb } from './models/db.js';
import entriesRouter from './routes/entries.js';
import foodsRouter from './routes/foods.js';
import summaryRouter from './routes/summary.js';
import Database from 'better-sqlite3';
const app = express();
const PORT = process.env.PORT || 3000;
const PORT = 3000;
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use('/uploads', express.static('/root/meal-tracker/uploads'));
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
app.use(express.static('./public'));
initialize();
const db = new Database('./data/meal-tracker.db');
app.use('/api/entries', entriesRouter);
app.use('/api/foods', foodsRouter);
app.use('/api/summary', summaryRouter);
const users = { 1: { id: 1, username: 'admin', password: 'admin123', isAdmin: true }, 2: { id: 2, username: 'rob', password: 'meal123', isAdmin: false } };
app.get('/api/favorites', (req, res) => {
const db = getDb();
res.json(db.prepare('SELECT * FROM entries WHERE favorite = 1 ORDER BY name ASC').all());
function getUserId(req) { return req.headers['x-user-id'] || req.body.user_id || 1; }
app.get('/api/entries', (req, res) => res.json(db.prepare('SELECT * FROM entries WHERE user_id = ? ORDER BY created_at DESC').all(getUserId(req))));
// Single entry route - MUST come before wildcard
app.get('/api/entries/:id', (req, res) => {
const entry = db.prepare('SELECT * FROM entries WHERE id = ? AND user_id = ?').get(req.params.id, getUserId(req));
if (!entry) return res.status(404).json({error: 'Entry not found'});
res.json(entry);
});
app.post('/api/entries/:id/favorite', (req, res) => {
const db = getDb();
const { id } = req.params;
const entry = db.prepare('SELECT favorite FROM entries WHERE id = ?').get(id);
if (!entry) return res.status(404).json({ error: 'Not found' });
const newVal = entry.favorite ? 0 : 1;
db.prepare('UPDATE entries SET favorite = ? WHERE id = ?').run(newVal, id);
res.json({ id, favorite: newVal });
app.get('/api/summary/daily', (req, res) => { const u = getUserId(req); const s = db.prepare('SELECT COALESCE(SUM(calories),0) as total_calories, COALESCE(SUM(protein),0) as total_protein, COALESCE(SUM(carbs),0) as total_carbs, COALESCE(SUM(fat),0) as total_fat FROM entries WHERE user_id = ? AND date(created_at) = ?').get(u, new Date().toISOString().split('T')[0]); res.json(s); });
app.get('/api/favorites', (req, res) => res.json(db.prepare('SELECT * FROM entries WHERE favorite = 1 AND user_id = ?').all(getUserId(req))));
app.get('/api/goals', (req, res) => res.json({calories: 2000, protein: 150, carbs: 250, fat: 65}));
app.get('/api/streak', (req, res) => res.json({currentStreak: 0, longestStreak: 0}));
app.post('/api/entries', (req, res) => {
const userId = getUserId(req);
const { name, description, notes, calories, protein, carbs, fat, meal_time, image_url } = req.body;
if (!name) return res.status(400).json({error: 'Name required'});
const result = db.prepare('INSERT INTO entries (name, description, notes, calories, protein, carbs, fat, meal_time, image_url, user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run(
name, description||null, notes||null, Number(calories)||0, Number(protein)||0, Number(carbs)||0, Number(fat)||0, meal_time||null, image_url||null, userId
);
res.json({ id: result.lastInsertRowid, name, calories, protein, carbs, fat });
});
app.get('*', (req, res) => {
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
});
app.post('/api/entries/:id/favorite', (req, res) => { db.prepare('UPDATE entries SET favorite = CASE WHEN favorite = 1 THEN 0 ELSE 1 END WHERE id = ? AND user_id = ?').run(req.params.id, getUserId(req)); res.json({success: true}); });
app.delete('/api/entries/:id', (req, res) => { db.prepare('DELETE FROM entries WHERE id = ? AND user_id = ?').run(req.params.id, getUserId(req)); res.json({success: true}); });
app.listen(PORT, '0.0.0.0', () => {
console.log('Meal Tracker running on port ' + PORT);
});
app.post('/api/auth/login', (req, res) => { const {username, password} = req.body; const u = Object.values(users).find(x => x.username === username && x.password === password); if(u) res.json({id: u.id, username: u.username, isAdmin: u.isAdmin}); else res.status(401).json({error: 'Invalid credentials'}); });
app.post('/api/auth/register', (req, res) => res.json({id: 3, username: req.body.username, isAdmin: false}));
app.get('/api/auth/me', (req, res) => { const u = users[req.headers['x-user-id']]; if(u) res.json({id: u.id, username: u.username, isAdmin: u.isAdmin}); else res.status(401).json({error: 'Not authenticated'}); });
app.get('/api/admin/users', (req, res) => { if(getUserId(req)!=1) return res.status(403).json({error:'Admin only'}); res.json(Object.values(users).map(u=>({id:u.id, username:u.username, isAdmin:u.isAdmin}))); });
app.delete('/api/admin/users/:id', (req, res) => res.json({success:true}));
app.post('/api/admin/users/:id/reset-password', (req, res) => { if(users[req.params.id]) { users[req.params.id].password = req.body.password; res.json({success:true}); } else res.status(404).json({error:'Not found'}); });
app.post('/api/admin/users', (req, res) => res.json({id:3, username:req.body.username, isAdmin:false}));
// SPA fallback - MUST be last
app.get('*', (req, res) => res.sendFile(path.join(process.cwd(), 'public', 'index.html')));
app.listen(PORT, '0.0.0.0', () => console.log('Meal Tracker running on port ' + PORT));

97
server/routes/admin.js Normal file
View File

@@ -0,0 +1,97 @@
import { getDb } from '../models/db.js';
import bcrypt from 'bcryptjs';
function isAdmin(req, res, next) {
const userId = req.headers['x-user-id'];
if (!userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
const db = getDb();
const user = db.prepare('SELECT username FROM users WHERE id = ?').get(userId);
if (!user || user.username !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
next();
}
export default function setupRoutes(app) {
// Get all users
app.get('/api/admin/users', isAdmin, (req, res) => {
const db = getDb();
const users = db.prepare('SELECT id, username, created_at FROM users ORDER BY created_at DESC').all();
res.json(users);
});
// Get all entries (admin view)
app.get('/api/admin/entries', isAdmin, (req, res) => {
const db = getDb();
const entries = db.prepare(`
SELECT e.*, u.username
FROM entries e
JOIN users u ON e.user_id = u.id
ORDER BY e.created_at DESC
LIMIT 100
`).all();
res.json(entries);
});
// Get stats
app.get('/api/admin/stats', isAdmin, (req, res) => {
const db = getDb();
const totalUsers = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const totalEntries = db.prepare('SELECT COUNT(*) as count FROM entries').get().count;
const todayEntries = db.prepare("SELECT COUNT(*) as count FROM entries WHERE date(created_at) = date('now')").get().count;
const topFoods = db.prepare(`SELECT name, COUNT(*) as count FROM entries GROUP BY name ORDER BY count DESC LIMIT 5`).all();
const todayCalories = db.prepare("SELECT COALESCE(SUM(calories), 0) as total FROM entries WHERE date(created_at) = date('now')").get().total;
res.json({ totalUsers, totalEntries, todayEntries, todayCalories, topFoods });
});
// Get activity log
app.get('/api/admin/activity', isAdmin, (req, res) => {
const db = getDb();
const limit = parseInt(req.query.limit) || 50;
const activity = db.prepare(`
SELECT al.*, u.username
FROM activity_log al
LEFT JOIN users u ON al.user_id = u.id
ORDER BY al.created_at DESC
LIMIT ?
`).all(limit);
res.json(activity);
});
// Delete user
app.delete('/api/admin/users/:id', isAdmin, (req, res) => {
const db = getDb();
const { id } = req.params;
const userId = req.headers['x-user-id'];
if (userId == id) {
return res.status(400).json({ error: 'Cannot delete yourself' });
}
db.prepare('DELETE FROM entries WHERE user_id = ?').run(id);
db.prepare('DELETE FROM activity_log WHERE user_id = ?').run(id);
db.prepare('DELETE FROM users WHERE id = ?').run(id);
res.json({ success: true });
});
// Reset password
app.post('/api/admin/users/:id/reset-password', isAdmin, (req, res) => {
const db = getDb();
const { id } = req.params;
const { password } = req.body;
const hash = bcrypt.hashSync(password, 10);
db.prepare('UPDATE users SET password = ? WHERE id = ?').run(hash, id);
db.prepare('INSERT INTO activity_log (user_id, action, details) VALUES (?, ?, ?)').run(id, 'password_reset', 'Password reset by admin');
res.json({ success: true });
});
}

65
server/routes/auth.js Normal file
View File

@@ -0,0 +1,65 @@
import { getDb } from '../models/db.js';
import bcrypt from 'bcryptjs';
export default function setupRoutes(app) {
// Login
app.post('/api/auth/login', (req, res) => {
const db = getDb();
const { username, password } = req.body;
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const valid = bcrypt.compareSync(password, user.password);
if (!valid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Log the login
db.prepare('INSERT INTO activity_log (user_id, action, details) VALUES (?, ?, ?)').run(user.id, 'login', 'User logged in');
res.json({ id: user.id, username: user.username });
});
// Register
app.post('/api/auth/register', (req, res) => {
const db = getDb();
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password required' });
}
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
if (existing) {
return res.status(400).json({ error: 'Username already exists' });
}
const hash = bcrypt.hashSync(password, 10);
const result = db.prepare('INSERT INTO users (username, password) VALUES (?, ?)').run(username, hash);
// Log registration
db.prepare('INSERT INTO activity_log (user_id, action, details) VALUES (?, ?, ?)').run(result.lastInsertRowid, 'register', 'New user registered');
res.json({ id: result.lastInsertRowid, username });
});
// Get current user
app.get('/api/auth/me', (req, res) => {
const userId = req.headers['x-user-id'];
if (!userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
const db = getDb();
const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(userId);
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
res.json(user);
});
}

View File

@@ -1,120 +1,75 @@
import express from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import 'dotenv/config';
import db from '../models/db.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
import { getDb } from '../models/db.js';
const router = express.Router();
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '../../uploads');
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, UPLOAD_DIR);
},
filename: (req, file, cb) => {
const timestamp = Date.now();
const ext = path.extname(file.originalname);
cb(null, `${timestamp}${ext}`);
}
});
const upload = multer({
storage,
fileFilter: (req, file, cb) => {
const allowedTypes = /jpeg|jpg|png|gif|webp/;
const ext = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mime = allowedTypes.test(file.mimetype);
if (ext && mime) {
cb(null, true);
} else {
cb(new Error('Only image files are allowed'), false);
}
},
limits: {
fileSize: 5 * 1024 * 1024
}
});
// GET /api/entries
router.get('/', (req, res) => {
try {
const { date } = req.query;
const entries = db.getAll(date);
const db = getDb();
const { user_id } = req.query;
const userId = user_id || 1;
// Return all entries, don't filter by date
const entries = db.prepare('SELECT * FROM entries WHERE user_id = ? ORDER BY created_at DESC').all(userId);
res.json(entries);
});
router.get('/recent', (req, res) => {
const db = getDb();
const { user_id, limit = 10 } = req.query;
const userId = user_id || 1;
const entries = db.prepare('SELECT DISTINCT name, description, calories, protein, carbs, fat FROM entries WHERE user_id = ? ORDER BY created_at DESC LIMIT ?').all(userId, limit);
res.json(entries);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/entries/:id
router.get('/:id', (req, res) => {
try {
const entry = db.getById(req.params.id);
if (!entry) {
return res.status(404).json({ error: 'Entry not found' });
}
const db = getDb();
const { id } = req.params;
const { user_id } = req.query;
const userId = user_id || 1;
const entry = db.prepare('SELECT * FROM entries WHERE id = ? AND user_id = ?').get(id, userId);
if (!entry) return res.status(404).json({ error: 'Entry not found' });
res.json(entry);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/entries
router.post('/', upload.single('image'), (req, res) => {
try {
const { name, description, notes, calories, protein, carbs, fat } = req.body;
router.post('/', (req, res) => {
const db = getDb();
const { name, description, notes, calories, protein, carbs, fat, meal_time, user_id } = req.body;
const userId = user_id || 1;
if (!name) {
return res.status(400).json({ error: 'Name is required' });
}
const stmt = db.prepare(`
INSERT INTO entries (name, description, notes, calories, protein, carbs, fat, meal_time, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const image_url = req.file ? `/uploads/${req.file.filename}` : null;
const entry = db.create({
name,
description,
image_url,
notes,
calories: calories ? parseFloat(calories) : 0,
protein: protein ? parseFloat(protein) : 0,
carbs: carbs ? parseFloat(carbs) : 0,
fat: fat ? parseFloat(fat) : 0
});
res.status(201).json(entry);
} catch (err) {
res.status(500).json({ error: err.message });
}
const result = stmt.run(name, description, notes, calories, protein, carbs, fat, meal_time, userId);
res.json({ id: result.lastInsertRowid, name, description, notes, calories, protein, carbs, fat, meal_time });
});
router.put('/:id', (req, res) => {
const db = getDb();
const { id } = req.params;
const { name, description, notes, calories, protein, carbs, fat, meal_time, user_id } = req.body;
const userId = user_id || 1;
const stmt = db.prepare(`
UPDATE entries SET name = ?, description = ?, notes = ?, calories = ?, protein = ?, carbs = ?, fat = ?, meal_time = ?
WHERE id = ? AND user_id = ?
`);
stmt.run(name, description, notes, calories, protein, carbs, fat, meal_time, id, userId);
res.json({ id, name, description, notes, calories, protein, carbs, fat, meal_time });
});
// DELETE /api/entries/:id
router.delete('/:id', (req, res) => {
try {
const entry = db.getById(req.params.id);
if (!entry) {
return res.status(404).json({ error: 'Entry not found' });
}
const db = getDb();
const { id } = req.params;
const { user_id } = req.query;
const userId = user_id || 1;
if (entry.image_url) {
const imagePath = path.join(__dirname, '../../', entry.image_url);
if (fs.existsSync(imagePath)) {
fs.unlinkSync(imagePath);
}
}
db.remove(req.params.id);
res.json({ message: 'Entry deleted' });
} catch (err) {
res.status(500).json({ error: err.message });
}
db.prepare('DELETE FROM entries WHERE id = ? AND user_id = ?').run(id, userId);
res.json({ success: true });
});
export default router;

View File

@@ -1,53 +1,39 @@
import express from 'express';
const router = express.Router();
import axios from 'axios';
const OPENFOODFACTS_API = 'https://world.openfoodfacts.org/cgi/search.pl';
// GET /api/foods/search?q=
router.get('/search', async (req, res) => {
try {
export default function setupRoutes(app) {
app.get('/api/foods/search', async (req, res) => {
const { q } = req.query;
if (!q || q.trim().length < 2) {
return res.status(400).json({ error: 'Search query must be at least 2 characters' });
if (!q) {
return res.json([]);
}
const params = new URLSearchParams({
try {
const response = await axios.get(OPENFOODFACTS_API, {
params: {
search_terms: q,
search_simple: 1,
action: 'process',
json: 1,
page_size: 20,
fields: 'product_name,brands,nutriments,serving_size'
});
const response = await fetch(`${OPENFOODFACTS_API}?${params}`, {
headers: {
'User-Agent': 'MealTracker/1.0'
page_size: 10
}
});
if (!response.ok) {
throw new Error(`OpenFoodFacts API error: ${response.status}`);
}
const data = await response.json();
const foods = (data.products || []).map(product => ({
name: product.product_name || 'Unknown',
brand: product.brands || null,
calories: product.nutriments?.['energy-kcal_100g'] ?? product.nutriments?.['energy-kcal'] ?? 0,
protein: product.nutriments?.proteins_100g ?? product.nutriments?.proteins ?? 0,
carbs: product.nutriments?.carbohydrates_100g ?? product.nutriments?.carbohydrates ?? 0,
fat: product.nutriments?.fat_100g ?? product.nutriments?.fat ?? 0,
serving_size: product.serving_size || '100g'
})).filter(f => f.name !== 'Unknown');
const products = response.data.products || [];
const foods = products.map(p => ({
name: p.product_name || p.product_name_en || 'Unknown',
calories: p.nutriments?.['energy-kcal_100g'] || 0,
protein: p.nutriments?.proteins_100g || 0,
carbs: p.nutriments?.carbohydrates_100g || 0,
fat: p.nutriments?.fat_100g || 0
})).filter(f => f.name !== 'Unknown' && f.calories > 0);
res.json(foods);
} catch (err) {
console.error('Food search error:', err);
res.status(500).json({ error: err.message });
console.error(err);
res.status(503).json({ error: 'Food search unavailable' });
}
});
export default router;
});
}

18
server/routes/stats.js Normal file
View File

@@ -0,0 +1,18 @@
import express from 'express';
const router = express.Router();
router.get('/goals', (req, res) => {
res.json({
calories: 2000,
protein: 150,
carbs: 250,
fat: 65
});
});
router.get('/streak', (req, res) => {
res.json({ currentStreak: 0, longestStreak: 0 });
});
export default router;

View File

@@ -1,26 +1,15 @@
import express from 'express';
import db from '../models/db.js';
const router = express.Router();
// GET /api/summary/daily?date=YYYY-MM-DD
router.get('/daily', (req, res) => {
try {
const { date } = req.query;
if (!date) {
return res.status(400).json({ error: 'Date parameter is required (YYYY-MM-DD)' });
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return res.status(400).json({ error: 'Invalid date format. Use YYYY-MM-DD' });
}
const summary = db.getDailyTotals(date);
res.json(summary);
} catch (err) {
res.status(500).json({ error: err.message });
}
// Return empty summary for now
res.json({
total_calories: 0,
total_protein: 0,
total_carbs: 0,
total_fat: 0
});
});
export default router;

View File

@@ -0,0 +1,22 @@
const Database = require("better-sqlite3");
const db = new Database("/root/meal-tracker/data/meal-tracker.db");
// Add user_id to entries
db.exec("ALTER TABLE entries ADD COLUMN user_id INTEGER DEFAULT 1");
// Create users table
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
)
`);
const bcrypt = require("bcryptjs");
const hash = bcrypt.hashSync("meal123", 10);
db.prepare("INSERT OR IGNORE INTO users (username, password) VALUES (?, ?)").run("rob", hash);
console.log("Database updated with users table - default user: rob / meal123");
db.close();