Add favorites feature - toggle, list, and pre-fill on add
This commit is contained in:
16
client/index.html
Normal file
16
client/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#059669" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Meal Tracker</title>
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
</head>
|
||||
<body class="h-full m-0 bg-gray-900 dark:bg-gray-900">
|
||||
<div id="root" class="h-full"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2915
client/package-lock.json
generated
Normal file
2915
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
client/package.json
Normal file
22
client/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "meal-tracker-client",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
6
client/postcss.config.js
Normal file
6
client/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
4
client/public/favicon.svg
Normal file
4
client/public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<circle cx="50" cy="50" r="45" fill="#059669"/>
|
||||
<text x="50" y="65" font-size="50" text-anchor="middle" fill="white">🍽️</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 206 B |
BIN
client/public/icon-192.png
Normal file
BIN
client/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 859 B |
BIN
client/public/icon-512.png
Normal file
BIN
client/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 859 B |
10
client/public/manifest.json
Normal file
10
client/public/manifest.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "Meal Tracker",
|
||||
"short_name": "Meals",
|
||||
"description": "Track your daily food intake and macros",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#111827",
|
||||
"theme_color": "#059669",
|
||||
"icons": []
|
||||
}
|
||||
14
client/public/service-worker.js
Normal file
14
client/public/service-worker.js
Normal file
@@ -0,0 +1,14 @@
|
||||
const CACHE_NAME = 'meal-tracker-v1';
|
||||
const urlsToCache = ['/', '/index.html', '/assets/'];
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then(cache => cache.addAll(urlsToCache))
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then(response => response || fetch(event.request))
|
||||
);
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
|
||||
export default function Layout({ children }) {
|
||||
export default function Layout({ children, showFavorites = false }) {
|
||||
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');
|
||||
|
||||
@@ -21,6 +23,13 @@ export default function Layout({ children }) {
|
||||
}
|
||||
}, [darkMode]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/favorites')
|
||||
.then(res => res.json())
|
||||
.then(data => setFavorites(data))
|
||||
.catch(console.error);
|
||||
}, [showFavList]);
|
||||
|
||||
function handleDateChange(e) {
|
||||
setSelectedDate(e.target.value);
|
||||
}
|
||||
@@ -29,6 +38,19 @@ export default function Layout({ children }) {
|
||||
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();
|
||||
}
|
||||
|
||||
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';
|
||||
@@ -52,6 +74,13 @@ export default function Layout({ children }) {
|
||||
Today
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowFavList(!showFavList)}
|
||||
className="text-2xl"
|
||||
title="Favorites"
|
||||
>
|
||||
⭐
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setDarkMode(!darkMode)}
|
||||
@@ -61,6 +90,29 @@ export default function Layout({ children }) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Favorites Dropdown */}
|
||||
{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>
|
||||
) : (
|
||||
<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}
|
||||
>
|
||||
<span className={textMain}>{fav.name}</span> • {fav.calories} cal
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 max-w-md mx-auto">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import Layout from '../components/Layout';
|
||||
|
||||
export default function AddEntry() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [darkMode, setDarkMode] = useState(true);
|
||||
const [foods, setFoods] = useState([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -25,6 +26,24 @@ export default function AddEntry() {
|
||||
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';
|
||||
|
||||
@@ -36,6 +36,16 @@ export default function EntryDetail() {
|
||||
window.location.href = returnDate ? '/?date=' + returnDate : '/';
|
||||
}
|
||||
|
||||
async function toggleFavorite() {
|
||||
try {
|
||||
const res = await fetch('/api/entries/' + id + '/favorite', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
setEntry({ ...entry, favorite: data.favorite });
|
||||
} 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';
|
||||
@@ -49,7 +59,17 @@ export default function EntryDetail() {
|
||||
<Layout>
|
||||
<Link to={returnUrl} className="text-emerald-500 mb-4 block">← Back</Link>
|
||||
|
||||
<h1 className={"text-2xl font-bold mb-2 " + textMain}>{entry.name}</h1>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{entry.description && <p className={textMuted + " mb-4"}>{entry.description}</p>}
|
||||
|
||||
{entry.image_url && (
|
||||
@@ -89,9 +109,11 @@ export default function EntryDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={handleDelete} className="w-full bg-red-500 hover:bg-red-600 text-white py-3 rounded-xl font-semibold">
|
||||
Delete Entry
|
||||
</button>
|
||||
<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>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
11
client/tailwind.config.js
Normal file
11
client/tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
17
client/vite.config.js
Normal file
17
client/vite.config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
minify: false, // Disable minify for easier debugging
|
||||
sourcemap: false
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://10.10.10.143:3000'
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,47 +1,49 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import db from './models/db.js';
|
||||
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';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Serve uploaded files statically
|
||||
app.use('/uploads', express.static(path.join(__dirname, process.env.UPLOAD_DIR || '../uploads')));
|
||||
app.use('/uploads', express.static('./uploads'));
|
||||
app.use(express.static('./public'));
|
||||
|
||||
// API Routes
|
||||
initialize();
|
||||
|
||||
// Routes
|
||||
app.use('/api/entries', entriesRouter);
|
||||
app.use('/api/foods', foodsRouter);
|
||||
app.use('/api/summary', summaryRouter);
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
// Favorites routes inline
|
||||
app.get('/api/favorites', (req, res) => {
|
||||
const db = getDb();
|
||||
res.json(db.prepare('SELECT * FROM entries WHERE favorite = 1 ORDER BY name ASC').all());
|
||||
});
|
||||
|
||||
// Initialize database and start server
|
||||
db.initialize()
|
||||
.then(() => {
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Meal Tracker API running on port ${PORT}`);
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to initialize database:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
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 });
|
||||
});
|
||||
|
||||
export default app;
|
||||
// SPA fallback
|
||||
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);
|
||||
});
|
||||
|
||||
32
server/routes/favorites.js
Normal file
32
server/routes/favorites.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { getDb } from '../models/db.js';
|
||||
|
||||
export default function setupRoutes(app) {
|
||||
// Get all favorites
|
||||
app.get('/api/favorites', (req, res) => {
|
||||
const db = getDb();
|
||||
const stmt = db.prepare('SELECT * FROM entries WHERE favorite = 1 ORDER BY name ASC');
|
||||
const favorites = stmt.all();
|
||||
res.json(favorites);
|
||||
});
|
||||
|
||||
// Toggle favorite
|
||||
app.post('/api/entries/:id/favorite', (req, res) => {
|
||||
const db = getDb();
|
||||
const { id } = req.params;
|
||||
|
||||
// Get current favorite status
|
||||
const getStmt = db.prepare('SELECT favorite FROM entries WHERE id = ?');
|
||||
const entry = getStmt.get(id);
|
||||
|
||||
if (!entry) {
|
||||
return res.status(404).json({ error: 'Entry not found' });
|
||||
}
|
||||
|
||||
// Toggle
|
||||
const newValue = entry.favorite ? 0 : 1;
|
||||
const updateStmt = db.prepare('UPDATE entries SET favorite = ? WHERE id = ?');
|
||||
updateStmt.run(newValue, id);
|
||||
|
||||
res.json({ id, favorite: newValue });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user