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
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() {
<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>
</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>
{/* 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>
)}
{/* 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>
<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>
{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>
);
}