Update web model management and meal diary
This commit is contained in:
parent
87102dbc5b
commit
2e2ba12cf3
9 changed files with 1852 additions and 123 deletions
1059
web/package-lock.json
generated
1059
web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -16,5 +16,8 @@
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"vite": "^8.0.13"
|
"vite": "^8.0.13"
|
||||||
},
|
},
|
||||||
"dependencies": {}
|
"dependencies": {
|
||||||
|
"argon2": "^0.44.0",
|
||||||
|
"express": "^5.2.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
218
web/server.js
218
web/server.js
|
|
@ -2,15 +2,18 @@ const http = require('http');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const argon2 = require('argon2');
|
||||||
|
|
||||||
const port = Number(process.env.PORT || 8080);
|
const port = Number(process.env.PORT || 8080);
|
||||||
const publicDir = fs.existsSync(path.join(__dirname, 'dist')) ? path.join(__dirname, 'dist') : path.join(__dirname, 'public');
|
const publicDir = fs.existsSync(path.join(__dirname, 'dist')) ? path.join(__dirname, 'dist') : path.join(__dirname, 'public');
|
||||||
const publicRoot = publicDir + path.sep;
|
const publicRoot = publicDir + path.sep;
|
||||||
const dataDir = process.env.CALORIE_AI_DATA_DIR || path.join(__dirname, 'data');
|
const dataDir = process.env.CALORIE_AI_DATA_DIR || path.join(__dirname, 'data');
|
||||||
const authFile = path.join(dataDir, 'auth.json');
|
const authFile = path.join(dataDir, 'auth.json');
|
||||||
|
const modelsFile = path.join(dataDir, 'models.json');
|
||||||
const sessionCookieName = 'calorie_ai_session';
|
const sessionCookieName = 'calorie_ai_session';
|
||||||
const sessionSeconds = 60 * 60 * 24 * 7;
|
const sessionSeconds = 60 * 60 * 24 * 7;
|
||||||
const auth = loadAuthConfig();
|
let auth;
|
||||||
|
let modelConfig;
|
||||||
const aiConfig = {
|
const aiConfig = {
|
||||||
baseUrl: (process.env.CALORIE_AI_API_BASE_URL || process.env.LITELLM_API_BASE || 'https://llm.danvics.com/v1').replace(/\/+$/, ''),
|
baseUrl: (process.env.CALORIE_AI_API_BASE_URL || process.env.LITELLM_API_BASE || 'https://llm.danvics.com/v1').replace(/\/+$/, ''),
|
||||||
apiKey: process.env.CALORIE_AI_API_KEY || process.env.LITELLM_API_KEY || process.env.OPENAI_API_KEY || '',
|
apiKey: process.env.CALORIE_AI_API_KEY || process.env.LITELLM_API_KEY || process.env.OPENAI_API_KEY || '',
|
||||||
|
|
@ -41,26 +44,74 @@ function readBody(req) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadAuthConfig() {
|
function readJsonFile(file, fallback) {
|
||||||
|
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePrivateJson(file, value) {
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
fs.writeFileSync(file, JSON.stringify(value, null, 2));
|
||||||
|
fs.chmodSync(file, 0o600);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hashPassword(password) {
|
||||||
|
return argon2.hash(password, { type: argon2.argon2id });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAuthConfig() {
|
||||||
let stored = {};
|
let stored = {};
|
||||||
try {
|
stored = readJsonFile(authFile, {});
|
||||||
stored = JSON.parse(fs.readFileSync(authFile, 'utf8'));
|
|
||||||
} catch {
|
|
||||||
stored = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = process.env.CALORIE_AI_WEB_USER || stored.user || 'admin';
|
const user = process.env.CALORIE_AI_WEB_USER || stored.user || 'admin';
|
||||||
const password = process.env.CALORIE_AI_WEB_PASSWORD || stored.password || crypto.randomBytes(18).toString('base64url');
|
if (process.env.CALORIE_AI_WEB_PASSWORD && process.env.CALORIE_AI_SESSION_SECRET) {
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
passwordHash: await hashPassword(process.env.CALORIE_AI_WEB_PASSWORD),
|
||||||
|
sessionSecret: process.env.CALORIE_AI_SESSION_SECRET,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const generatedPassword = crypto.randomBytes(18).toString('base64url');
|
||||||
|
const plainPassword = stored.password || (!stored.passwordHash ? generatedPassword : '');
|
||||||
|
const passwordHash = stored.passwordHash || await hashPassword(plainPassword);
|
||||||
const sessionSecret = process.env.CALORIE_AI_SESSION_SECRET || stored.sessionSecret || crypto.randomBytes(32).toString('hex');
|
const sessionSecret = process.env.CALORIE_AI_SESSION_SECRET || stored.sessionSecret || crypto.randomBytes(32).toString('hex');
|
||||||
|
|
||||||
if (!process.env.CALORIE_AI_WEB_PASSWORD || !process.env.CALORIE_AI_SESSION_SECRET) {
|
if (!stored.passwordHash || !stored.sessionSecret || stored.password) {
|
||||||
fs.mkdirSync(dataDir, { recursive: true });
|
writePrivateJson(authFile, {
|
||||||
fs.writeFileSync(authFile, JSON.stringify({ user, password, sessionSecret, createdAt: new Date().toISOString() }, null, 2));
|
user,
|
||||||
fs.chmodSync(authFile, 0o600);
|
passwordHash,
|
||||||
|
sessionSecret,
|
||||||
|
createdAt: stored.createdAt || new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
...(plainPassword && !stored.passwordHash ? { initialPassword: plainPassword } : {}),
|
||||||
|
});
|
||||||
console.log(`Calorie AI auth is stored at ${authFile}`);
|
console.log(`Calorie AI auth is stored at ${authFile}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { user, password, sessionSecret };
|
return { user, passwordHash, sessionSecret };
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadModelConfig() {
|
||||||
|
const stored = readJsonFile(modelsFile, {});
|
||||||
|
const defaultModel = stored.defaultModel || aiConfig.defaultModel;
|
||||||
|
const models = Array.isArray(stored.models) && stored.models.length
|
||||||
|
? stored.models
|
||||||
|
: [{ id: defaultModel, name: defaultModel, tag: 'DEFAULT' }];
|
||||||
|
const config = { defaultModel, models: dedupeModels(models) };
|
||||||
|
if (!fs.existsSync(modelsFile)) saveModelConfig(config);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveModelConfig(config = modelConfig) {
|
||||||
|
writePrivateJson(modelsFile, { ...config, updatedAt: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeModels(models) {
|
||||||
|
return Array.from(new Map((models || []).filter(model => model && model.id).map(model => [model.id, {
|
||||||
|
id: String(model.id),
|
||||||
|
name: String(model.name || model.id),
|
||||||
|
tag: model.tag || 'ADDED',
|
||||||
|
}])).values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
function send(res, status, body, contentType = 'application/json; charset=utf-8', headOnly = false, headers = {}) {
|
function send(res, status, body, contentType = 'application/json; charset=utf-8', headOnly = false, headers = {}) {
|
||||||
|
|
@ -173,35 +224,78 @@ async function proxyChat(req, res) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchProviderModels(search = '') {
|
||||||
|
if (!/^https?:\/\//.test(aiConfig.baseUrl)) throw new Error('AI endpoint is not configured');
|
||||||
|
|
||||||
|
const headers = {};
|
||||||
|
if (aiConfig.apiKey) headers.authorization = `Bearer ${aiConfig.apiKey}`;
|
||||||
|
const infoBase = aiConfig.baseUrl.replace(/\/v1$/, '');
|
||||||
|
|
||||||
|
let models = [];
|
||||||
|
try {
|
||||||
|
const info = await fetch(`${infoBase}/model/info`, { headers });
|
||||||
|
if (info.ok) {
|
||||||
|
const data = await info.json();
|
||||||
|
models = (data.data || [])
|
||||||
|
.filter(item => item.model_name && (!item.model_info || !item.model_info.mode || item.model_info.mode === 'chat'))
|
||||||
|
.map(item => ({ id: item.model_name, name: item.model_name }));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (!models.length) {
|
||||||
|
const upstream = await fetch(`${aiConfig.baseUrl}/models`, { headers });
|
||||||
|
const data = await upstream.json();
|
||||||
|
models = (data.data || []).map(item => ({ id: item.id, name: item.id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = search.trim().toLowerCase();
|
||||||
|
return dedupeModels(models)
|
||||||
|
.filter(model => !query || `${model.name} ${model.id}`.toLowerCase().includes(query))
|
||||||
|
.slice(0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
async function proxyModels(req, res) {
|
async function proxyModels(req, res) {
|
||||||
try {
|
try {
|
||||||
if (!/^https?:\/\//.test(aiConfig.baseUrl)) throw new Error('AI endpoint is not configured');
|
send(res, 200, JSON.stringify({ models: modelConfig.models, defaultModel: modelConfig.defaultModel }));
|
||||||
|
} catch (error) {
|
||||||
|
send(res, 400, JSON.stringify({ error: error.message }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const headers = {};
|
async function searchModels(req, res) {
|
||||||
if (aiConfig.apiKey) headers.authorization = `Bearer ${aiConfig.apiKey}`;
|
try {
|
||||||
const infoBase = aiConfig.baseUrl.replace(/\/v1$/, '');
|
const payload = req.method === 'POST' ? JSON.parse(await readBody(req) || '{}') : {};
|
||||||
|
if (!payload.query || String(payload.query).trim().length < 2) {
|
||||||
let models = [];
|
return send(res, 200, JSON.stringify({ models: [], count: 0 }));
|
||||||
try {
|
|
||||||
const info = await fetch(`${infoBase}/model/info`, { headers });
|
|
||||||
if (info.ok) {
|
|
||||||
const data = await info.json();
|
|
||||||
models = (data.data || [])
|
|
||||||
.filter(item => item.model_name && (!item.model_info || !item.model_info.mode || item.model_info.mode === 'chat'))
|
|
||||||
.map(item => ({ id: item.model_name, name: item.model_name }));
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
if (!models.length) {
|
|
||||||
const upstream = await fetch(`${aiConfig.baseUrl}/models`, { headers });
|
|
||||||
const data = await upstream.json();
|
|
||||||
models = (data.data || []).map(item => ({ id: item.id, name: item.id }));
|
|
||||||
}
|
}
|
||||||
|
const models = await fetchProviderModels(payload.query || '');
|
||||||
|
send(res, 200, JSON.stringify({ models, count: models.length }));
|
||||||
|
} catch (error) {
|
||||||
|
send(res, 400, JSON.stringify({ error: error.message }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const deduped = Array.from(new Map(models.filter(m => m.id).map(m => [m.id, m])).values())
|
async function addModel(req, res) {
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
try {
|
||||||
if (!deduped.some(model => model.id === aiConfig.defaultModel)) deduped.unshift({ id: aiConfig.defaultModel, name: aiConfig.defaultModel });
|
const payload = JSON.parse(await readBody(req));
|
||||||
send(res, 200, JSON.stringify({ models: deduped, defaultModel: aiConfig.defaultModel }));
|
if (!payload.id) return send(res, 400, JSON.stringify({ error: 'Model ID required' }));
|
||||||
|
modelConfig.models = dedupeModels([...modelConfig.models, { id: payload.id, name: payload.name || payload.id, tag: 'ADDED' }]);
|
||||||
|
if (!modelConfig.defaultModel) modelConfig.defaultModel = payload.id;
|
||||||
|
saveModelConfig();
|
||||||
|
send(res, 200, JSON.stringify({ ok: true, models: modelConfig.models, defaultModel: modelConfig.defaultModel }));
|
||||||
|
} catch (error) {
|
||||||
|
send(res, 400, JSON.stringify({ error: error.message }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeModel(req, res) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(await readBody(req));
|
||||||
|
modelConfig.models = modelConfig.models.filter(model => model.id !== payload.id);
|
||||||
|
if (!modelConfig.models.length) modelConfig.models = [{ id: aiConfig.defaultModel, name: aiConfig.defaultModel, tag: 'DEFAULT' }];
|
||||||
|
if (!modelConfig.models.some(model => model.id === modelConfig.defaultModel)) modelConfig.defaultModel = modelConfig.models[0].id;
|
||||||
|
saveModelConfig();
|
||||||
|
send(res, 200, JSON.stringify({ ok: true, models: modelConfig.models, defaultModel: modelConfig.defaultModel }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
send(res, 400, JSON.stringify({ error: error.message }));
|
send(res, 400, JSON.stringify({ error: error.message }));
|
||||||
}
|
}
|
||||||
|
|
@ -210,7 +304,9 @@ async function proxyModels(req, res) {
|
||||||
async function login(req, res) {
|
async function login(req, res) {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(await readBody(req));
|
const payload = JSON.parse(await readBody(req));
|
||||||
if (!safeEqual(payload.username || '', auth.user) || !safeEqual(payload.password || '', auth.password)) {
|
const validUser = safeEqual(payload.username || '', auth.user);
|
||||||
|
const validPassword = validUser && await argon2.verify(auth.passwordHash, payload.password || '');
|
||||||
|
if (!validUser || !validPassword) {
|
||||||
return send(res, 401, JSON.stringify({ error: 'Invalid username or password' }));
|
return send(res, 401, JSON.stringify({ error: 'Invalid username or password' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -222,13 +318,37 @@ async function login(req, res) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function changePassword(req, res) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(await readBody(req));
|
||||||
|
if (!await argon2.verify(auth.passwordHash, payload.currentPassword || '')) return send(res, 401, JSON.stringify({ error: 'Current password is incorrect' }));
|
||||||
|
if (!payload.newPassword || payload.newPassword.length < 12) return send(res, 400, JSON.stringify({ error: 'Use at least 12 characters for the new password' }));
|
||||||
|
auth.passwordHash = await hashPassword(payload.newPassword);
|
||||||
|
writePrivateJson(authFile, { user: auth.user, passwordHash: auth.passwordHash, sessionSecret: auth.sessionSecret, updatedAt: new Date().toISOString() });
|
||||||
|
send(res, 200, JSON.stringify({ ok: true }));
|
||||||
|
} catch (error) {
|
||||||
|
send(res, 400, JSON.stringify({ error: error.message }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetPassword(req, res) {
|
||||||
|
try {
|
||||||
|
const generatedPassword = crypto.randomBytes(18).toString('base64url');
|
||||||
|
auth.passwordHash = await hashPassword(generatedPassword);
|
||||||
|
writePrivateJson(authFile, { user: auth.user, passwordHash: auth.passwordHash, sessionSecret: auth.sessionSecret, updatedAt: new Date().toISOString() });
|
||||||
|
send(res, 200, JSON.stringify({ ok: true, password: generatedPassword }));
|
||||||
|
} catch (error) {
|
||||||
|
send(res, 400, JSON.stringify({ error: error.message }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function logout(req, res) {
|
function logout(req, res) {
|
||||||
send(res, 200, JSON.stringify({ ok: true }), undefined, false, {
|
send(res, 200, JSON.stringify({ ok: true }), undefined, false, {
|
||||||
'set-cookie': `${sessionCookieName}=; ${cookieOptions(req, 0)}`,
|
'set-cookie': `${sessionCookieName}=; ${cookieOptions(req, 0)}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
http.createServer((req, res) => {
|
async function handleRequest(req, res) {
|
||||||
const url = req.url.split('?')[0];
|
const url = req.url.split('?')[0];
|
||||||
const authenticated = isAuthenticated(req);
|
const authenticated = isAuthenticated(req);
|
||||||
|
|
||||||
|
|
@ -240,11 +360,27 @@ http.createServer((req, res) => {
|
||||||
if (authenticated && (req.method === 'GET' || req.method === 'HEAD') && url === '/login') return redirect(res, '/');
|
if (authenticated && (req.method === 'GET' || req.method === 'HEAD') && url === '/login') return redirect(res, '/');
|
||||||
if (req.method === 'POST' && url === '/api/login') return login(req, res);
|
if (req.method === 'POST' && url === '/api/login') return login(req, res);
|
||||||
if (req.method === 'POST' && url === '/api/logout') return logout(req, res);
|
if (req.method === 'POST' && url === '/api/logout') return logout(req, res);
|
||||||
|
if (req.method === 'POST' && url === '/api/password/change') return changePassword(req, res);
|
||||||
|
if (req.method === 'POST' && url === '/api/password/reset') return resetPassword(req, res);
|
||||||
if (req.method === 'GET' && url === '/api/session') return send(res, 200, JSON.stringify({ user: auth.user }));
|
if (req.method === 'GET' && url === '/api/session') return send(res, 200, JSON.stringify({ user: auth.user }));
|
||||||
if (req.method === 'POST' && url === '/api/models') return proxyModels(req, res);
|
if (req.method === 'POST' && url === '/api/models') return proxyModels(req, res);
|
||||||
|
if (req.method === 'POST' && url === '/api/models/search') return searchModels(req, res);
|
||||||
|
if (req.method === 'POST' && url === '/api/models/add') return addModel(req, res);
|
||||||
|
if (req.method === 'POST' && url === '/api/models/remove') return removeModel(req, res);
|
||||||
if (req.method === 'POST' && req.url === '/api/chat') return proxyChat(req, res);
|
if (req.method === 'POST' && req.url === '/api/chat') return proxyChat(req, res);
|
||||||
if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res);
|
if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res);
|
||||||
send(res, 405, 'Method not allowed', 'text/plain; charset=utf-8');
|
send(res, 405, 'Method not allowed', 'text/plain; charset=utf-8');
|
||||||
}).listen(port, () => {
|
}
|
||||||
console.log(`Calorie AI web listening on ${port}`);
|
|
||||||
|
async function start() {
|
||||||
|
auth = await loadAuthConfig();
|
||||||
|
modelConfig = loadModelConfig();
|
||||||
|
http.createServer((req, res) => handleRequest(req, res).catch(error => send(res, 500, JSON.stringify({ error: error.message })))).listen(port, () => {
|
||||||
|
console.log(`Calorie AI web listening on ${port}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
start().catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,20 @@
|
||||||
import { onMount, tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
import Field from './Field.svelte';
|
import Field from './Field.svelte';
|
||||||
import Stat from './Stat.svelte';
|
import Stat from './Stat.svelte';
|
||||||
|
import MealForm from './MealForm.svelte';
|
||||||
|
import DiaryPage from './DiaryPage.svelte';
|
||||||
|
import SettingsPage from './SettingsPage.svelte';
|
||||||
|
|
||||||
const entriesKey = 'calorie-ai-web-entries';
|
const entriesKey = 'calorie-ai-web-entries';
|
||||||
|
const trashKey = 'calorie-ai-web-trash';
|
||||||
const settingsKey = 'calorie-ai-web-settings';
|
const settingsKey = 'calorie-ai-web-settings';
|
||||||
const pages = [
|
const pages = [
|
||||||
{ id: 'dashboard', label: 'Dashboard', group: 'Tracker' },
|
{ id: 'dashboard', label: 'Dashboard', icon: '⌂', group: 'Tracker' },
|
||||||
{ id: 'log', label: 'Log meal', group: 'Tracker' },
|
{ id: 'log', label: 'Log meal', icon: '+', group: 'Tracker' },
|
||||||
{ id: 'analytics', label: 'Analytics', group: 'Tracker' },
|
{ id: 'analytics', label: 'Analytics', icon: '↗', group: 'Tracker' },
|
||||||
{ id: 'diary', label: 'Diary', group: 'Tracker' },
|
{ id: 'diary', label: 'Diary', icon: '☰', group: 'Tracker' },
|
||||||
{ id: 'models', label: 'Models', group: 'Admin' },
|
{ id: 'trash', label: 'Trash', icon: '×', group: 'Tracker' },
|
||||||
{ id: 'settings', label: 'Settings', group: 'Admin' },
|
{ id: 'settings', label: 'Settings', icon: '⚙', group: 'Admin' },
|
||||||
];
|
];
|
||||||
const mealTypes = ['Breakfast', 'Lunch', 'Dinner', 'Snack', 'Other'];
|
const mealTypes = ['Breakfast', 'Lunch', 'Dinner', 'Snack', 'Other'];
|
||||||
const fallbackModels = [
|
const fallbackModels = [
|
||||||
|
|
@ -30,14 +34,21 @@
|
||||||
let page = 'dashboard';
|
let page = 'dashboard';
|
||||||
let menuSearch = '';
|
let menuSearch = '';
|
||||||
let menuOpen = false;
|
let menuOpen = false;
|
||||||
let sidebarCollapsed = false;
|
|
||||||
|
|
||||||
let entries = [];
|
let entries = [];
|
||||||
let models = fallbackModels;
|
let models = fallbackModels;
|
||||||
|
let discoveredModels = [];
|
||||||
let modelSearch = '';
|
let modelSearch = '';
|
||||||
let modelStatus = '';
|
let modelStatus = '';
|
||||||
let modelError = false;
|
let modelError = false;
|
||||||
let settings = { visionModel: fallbackModels[0].id, taskModel: fallbackModels[0].id };
|
let modelSearching = false;
|
||||||
|
let settings = { visionModel: fallbackModels[0].id, taskModel: fallbackModels[0].id, heightCm: '', weightKg: '', targetWeightKg: '', activityLevel: 'Moderate', pace: 'Steady' };
|
||||||
|
let currentPassword = '';
|
||||||
|
let newPassword = '';
|
||||||
|
let confirmPassword = '';
|
||||||
|
let passwordStatus = '';
|
||||||
|
let passwordError = false;
|
||||||
|
let resetPasswordValue = '';
|
||||||
|
|
||||||
let mealDate = '';
|
let mealDate = '';
|
||||||
let mealTime = '';
|
let mealTime = '';
|
||||||
|
|
@ -49,11 +60,18 @@
|
||||||
let status = '';
|
let status = '';
|
||||||
let statusError = false;
|
let statusError = false;
|
||||||
let savingMeal = false;
|
let savingMeal = false;
|
||||||
|
let editingEntryId = '';
|
||||||
|
let estimateDraft = emptyEstimate();
|
||||||
|
let planStatus = '';
|
||||||
|
let planError = false;
|
||||||
|
let planLoading = false;
|
||||||
|
let planAdvice = '';
|
||||||
|
|
||||||
let diarySearch = '';
|
let diarySearch = '';
|
||||||
let diaryType = '';
|
let diaryType = '';
|
||||||
let diaryDate = '';
|
let diaryDate = '';
|
||||||
let clearArmed = false;
|
let selectedEntryIds = [];
|
||||||
|
let trash = [];
|
||||||
|
|
||||||
let macroCanvas;
|
let macroCanvas;
|
||||||
let calorieCanvas;
|
let calorieCanvas;
|
||||||
|
|
@ -66,7 +84,6 @@
|
||||||
$: week = last7Days();
|
$: week = last7Days();
|
||||||
$: weekAverage = Math.round(week.reduce((sum, day) => sum + day.calories, 0) / week.length);
|
$: weekAverage = Math.round(week.reduce((sum, day) => sum + day.calories, 0) / week.length);
|
||||||
$: produceToday = today.fruit + today.vegetables;
|
$: produceToday = today.fruit + today.vegetables;
|
||||||
$: filteredModels = models.filter(model => !modelSearch || `${model.name} ${model.id}`.toLowerCase().includes(modelSearch.toLowerCase()));
|
|
||||||
$: diaryRows = entries
|
$: diaryRows = entries
|
||||||
.filter(entry => !diaryType || entry.mealType === diaryType)
|
.filter(entry => !diaryType || entry.mealType === diaryType)
|
||||||
.filter(entry => !diaryDate || entry.date === diaryDate)
|
.filter(entry => !diaryDate || entry.date === diaryDate)
|
||||||
|
|
@ -76,11 +93,12 @@
|
||||||
.some(value => String(value || '').toLowerCase().includes(query));
|
.some(value => String(value || '').toLowerCase().includes(query));
|
||||||
})
|
})
|
||||||
.sort((a, b) => `${b.date} ${b.time}`.localeCompare(`${a.date} ${a.time}`));
|
.sort((a, b) => `${b.date} ${b.time}`.localeCompare(`${a.date} ${a.time}`));
|
||||||
|
$: diaryDays = Array.from(new Set(diaryRows.map(entry => entry.date))).sort((a, b) => b.localeCompare(a));
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
entries = readJson(entriesKey, []);
|
entries = readJson(entriesKey, []);
|
||||||
|
trash = readJson(trashKey, []);
|
||||||
settings = { ...settings, ...readJson(settingsKey, settings) };
|
settings = { ...settings, ...readJson(settingsKey, settings) };
|
||||||
sidebarCollapsed = localStorage.getItem('calorie_ai_sidebar_collapsed') === '1';
|
|
||||||
page = localStorage.getItem('calorie_ai_last_tab') || 'dashboard';
|
page = localStorage.getItem('calorie_ai_last_tab') || 'dashboard';
|
||||||
setDefaultMealDateTime();
|
setDefaultMealDateTime();
|
||||||
await loadModels();
|
await loadModels();
|
||||||
|
|
@ -151,11 +169,6 @@
|
||||||
localStorage.setItem('calorie_ai_last_tab', id);
|
localStorage.setItem('calorie_ai_last_tab', id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSidebarCollapse() {
|
|
||||||
sidebarCollapsed = !sidebarCollapsed;
|
|
||||||
localStorage.setItem('calorie_ai_sidebar_collapsed', sidebarCollapsed ? '1' : '0');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function login() {
|
async function login() {
|
||||||
loginStatus = 'Checking credentials...';
|
loginStatus = 'Checking credentials...';
|
||||||
loginError = false;
|
loginError = false;
|
||||||
|
|
@ -190,10 +203,10 @@
|
||||||
if (!models.some(model => model.id === settings.visionModel)) settings.visionModel = defaultModel;
|
if (!models.some(model => model.id === settings.visionModel)) settings.visionModel = defaultModel;
|
||||||
if (!models.some(model => model.id === settings.taskModel)) settings.taskModel = defaultModel;
|
if (!models.some(model => model.id === settings.taskModel)) settings.taskModel = defaultModel;
|
||||||
saveSettings();
|
saveSettings();
|
||||||
modelStatus = `${models.length} models loaded.`;
|
modelStatus = `${models.length} saved model${models.length === 1 ? '' : 's'} loaded.`;
|
||||||
} catch {
|
} catch {
|
||||||
models = fallbackModels;
|
models = fallbackModels;
|
||||||
modelStatus = 'Could not load server models. Showing defaults.';
|
modelStatus = 'Could not load saved models. Showing defaults.';
|
||||||
modelError = true;
|
modelError = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -202,6 +215,10 @@
|
||||||
saveJson(settingsKey, settings);
|
saveJson(settingsKey, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emptyEstimate() {
|
||||||
|
return { mealName: '', calories: 0, proteinGrams: 0, carbsGrams: 0, fatGrams: 0, fruitServings: 0, vegetableServings: 0, foodGroups: '', notes: '' };
|
||||||
|
}
|
||||||
|
|
||||||
function saveSelectedModels() {
|
function saveSelectedModels() {
|
||||||
saveSettings();
|
saveSettings();
|
||||||
modelStatus = 'Models saved.';
|
modelStatus = 'Models saved.';
|
||||||
|
|
@ -214,6 +231,116 @@
|
||||||
saveSelectedModels();
|
saveSelectedModels();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function searchModels() {
|
||||||
|
if (modelSearch.trim().length < 2) {
|
||||||
|
discoveredModels = [];
|
||||||
|
modelStatus = 'Type at least 2 characters to search provider models.';
|
||||||
|
modelError = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modelSearching = true;
|
||||||
|
modelStatus = 'Searching provider...';
|
||||||
|
modelError = false;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/models/search', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ query: modelSearch }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(await response.text());
|
||||||
|
const data = await response.json();
|
||||||
|
discoveredModels = data.models || [];
|
||||||
|
modelStatus = discoveredModels.length ? `Found ${data.count} model${data.count === 1 ? '' : 's'}.` : 'No models found.';
|
||||||
|
} catch (error) {
|
||||||
|
discoveredModels = [];
|
||||||
|
modelStatus = error.message || 'Model search failed.';
|
||||||
|
modelError = true;
|
||||||
|
} finally {
|
||||||
|
modelSearching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addModel(model) {
|
||||||
|
const response = await fetch('/api/models/add', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(model),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
modelStatus = await response.text();
|
||||||
|
modelError = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
models = data.models?.length ? data.models : models;
|
||||||
|
settings.visionModel = models.some(item => item.id === settings.visionModel) ? settings.visionModel : models[0].id;
|
||||||
|
settings.taskModel = model.id;
|
||||||
|
saveSettings();
|
||||||
|
modelStatus = `Added ${model.name || model.id}.`;
|
||||||
|
modelError = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeModel(modelId) {
|
||||||
|
const response = await fetch('/api/models/remove', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: modelId }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
modelStatus = await response.text();
|
||||||
|
modelError = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
models = data.models?.length ? data.models : fallbackModels;
|
||||||
|
if (!models.some(model => model.id === settings.visionModel)) settings.visionModel = data.defaultModel || models[0].id;
|
||||||
|
if (!models.some(model => model.id === settings.taskModel)) settings.taskModel = data.defaultModel || models[0].id;
|
||||||
|
saveSettings();
|
||||||
|
modelStatus = 'Model removed.';
|
||||||
|
modelError = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePassword() {
|
||||||
|
passwordStatus = 'Updating password...';
|
||||||
|
passwordError = false;
|
||||||
|
resetPasswordValue = '';
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
passwordStatus = 'New passwords do not match.';
|
||||||
|
passwordError = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await fetch('/api/password/change', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ currentPassword, newPassword }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ error: 'Password update failed.' }));
|
||||||
|
passwordStatus = error.error || 'Password update failed.';
|
||||||
|
passwordError = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentPassword = '';
|
||||||
|
newPassword = '';
|
||||||
|
confirmPassword = '';
|
||||||
|
passwordStatus = 'Password updated.';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetPassword() {
|
||||||
|
passwordStatus = 'Generating a new password...';
|
||||||
|
passwordError = false;
|
||||||
|
const response = await fetch('/api/password/reset', { method: 'POST' });
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ error: 'Password reset failed.' }));
|
||||||
|
passwordStatus = error.error || 'Password reset failed.';
|
||||||
|
passwordError = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
resetPasswordValue = data.password || '';
|
||||||
|
passwordStatus = 'Password reset. Save the generated password now.';
|
||||||
|
}
|
||||||
|
|
||||||
async function fileToDataUrl(file) {
|
async function fileToDataUrl(file) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
|
|
@ -290,6 +417,10 @@ Image estimate: ${imageEstimate}` },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function entryId() {
|
||||||
|
return crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function saveMeal() {
|
async function saveMeal() {
|
||||||
if (!mealDate || !mealTime || !mealType) return setMealStatus('Choose a date, time, and meal type.', true);
|
if (!mealDate || !mealTime || !mealType) return setMealStatus('Choose a date, time, and meal type.', true);
|
||||||
if (!description.trim() && !selectedImageDataUrl) return setMealStatus('Describe the meal or upload an image.', true);
|
if (!description.trim() && !selectedImageDataUrl) return setMealStatus('Describe the meal or upload an image.', true);
|
||||||
|
|
@ -298,8 +429,10 @@ Image estimate: ${imageEstimate}` },
|
||||||
setMealStatus(selectedImageDataUrl ? 'Analyzing image...' : 'Estimating meal...');
|
setMealStatus(selectedImageDataUrl ? 'Analyzing image...' : 'Estimating meal...');
|
||||||
const imageEstimate = await requestImageEstimate();
|
const imageEstimate = await requestImageEstimate();
|
||||||
const estimate = parseEstimate(await requestMealEstimate(imageEstimate));
|
const estimate = parseEstimate(await requestMealEstimate(imageEstimate));
|
||||||
entries = [...entries, {
|
estimateDraft = estimate;
|
||||||
|
const meal = {
|
||||||
...estimate,
|
...estimate,
|
||||||
|
id: editingEntryId || entryId(),
|
||||||
date: mealDate,
|
date: mealDate,
|
||||||
time: mealTime,
|
time: mealTime,
|
||||||
mealType,
|
mealType,
|
||||||
|
|
@ -307,14 +440,18 @@ Image estimate: ${imageEstimate}` },
|
||||||
measure,
|
measure,
|
||||||
imageIncluded: Boolean(selectedImageDataUrl),
|
imageIncluded: Boolean(selectedImageDataUrl),
|
||||||
imageName: selectedImageName,
|
imageName: selectedImageName,
|
||||||
}];
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
entries = editingEntryId ? entries.map(entry => entry.id === editingEntryId ? { ...entry, ...meal } : entry) : [...entries, meal];
|
||||||
saveJson(entriesKey, entries);
|
saveJson(entriesKey, entries);
|
||||||
description = '';
|
description = '';
|
||||||
measure = '';
|
measure = '';
|
||||||
selectedImageDataUrl = '';
|
selectedImageDataUrl = '';
|
||||||
selectedImageName = '';
|
selectedImageName = '';
|
||||||
|
editingEntryId = '';
|
||||||
|
estimateDraft = emptyEstimate();
|
||||||
setDefaultMealDateTime();
|
setDefaultMealDateTime();
|
||||||
setMealStatus('Saved.');
|
setMealStatus('Saved. You can edit it from Diary or run analysis again.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setMealStatus(error.message, true);
|
setMealStatus(error.message, true);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -327,14 +464,100 @@ Image estimate: ${imageEstimate}` },
|
||||||
statusError = error;
|
statusError = error;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearDiary() {
|
function editEntry(entry) {
|
||||||
if (!clearArmed) {
|
editingEntryId = entry.id;
|
||||||
clearArmed = true;
|
mealDate = entry.date;
|
||||||
return;
|
mealTime = entry.time;
|
||||||
}
|
mealType = entry.mealType;
|
||||||
entries = [];
|
description = entry.description || '';
|
||||||
|
measure = entry.measure || '';
|
||||||
|
estimateDraft = { ...emptyEstimate(), ...entry };
|
||||||
|
setMealStatus('Editing saved meal. Change details, then save edits or analyze again.');
|
||||||
|
selectPage('log');
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveMealEdits() {
|
||||||
|
if (!editingEntryId) return;
|
||||||
|
entries = entries.map(entry => entry.id === editingEntryId ? {
|
||||||
|
...entry,
|
||||||
|
...estimateDraft,
|
||||||
|
date: mealDate,
|
||||||
|
time: mealTime,
|
||||||
|
mealType,
|
||||||
|
description,
|
||||||
|
measure,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
} : entry);
|
||||||
saveJson(entriesKey, entries);
|
saveJson(entriesKey, entries);
|
||||||
clearArmed = false;
|
editingEntryId = '';
|
||||||
|
estimateDraft = emptyEstimate();
|
||||||
|
description = '';
|
||||||
|
measure = '';
|
||||||
|
setDefaultMealDateTime();
|
||||||
|
setMealStatus('Edits saved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editingEntryId = '';
|
||||||
|
estimateDraft = emptyEstimate();
|
||||||
|
description = '';
|
||||||
|
measure = '';
|
||||||
|
selectedImageDataUrl = '';
|
||||||
|
selectedImageName = '';
|
||||||
|
setDefaultMealDateTime();
|
||||||
|
setMealStatus('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelected(id) {
|
||||||
|
selectedEntryIds = selectedEntryIds.includes(id) ? selectedEntryIds.filter(item => item !== id) : [...selectedEntryIds, id];
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveEntriesToTrash(ids) {
|
||||||
|
const idSet = new Set(ids);
|
||||||
|
const moving = entries.filter(entry => idSet.has(entry.id)).map(entry => ({ ...entry, trashedAt: new Date().toISOString() }));
|
||||||
|
if (!moving.length) return;
|
||||||
|
trash = [...moving, ...trash];
|
||||||
|
entries = entries.filter(entry => !idSet.has(entry.id));
|
||||||
|
selectedEntryIds = selectedEntryIds.filter(id => !idSet.has(id));
|
||||||
|
saveJson(entriesKey, entries);
|
||||||
|
saveJson(trashKey, trash);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveDayToTrash(date) {
|
||||||
|
moveEntriesToTrash(entries.filter(entry => entry.date === date).map(entry => entry.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreEntry(id) {
|
||||||
|
const found = trash.find(entry => entry.id === id);
|
||||||
|
if (!found) return;
|
||||||
|
entries = [{ ...found, trashedAt: undefined }, ...entries];
|
||||||
|
trash = trash.filter(entry => entry.id !== id);
|
||||||
|
saveJson(entriesKey, entries);
|
||||||
|
saveJson(trashKey, trash);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generatePlan() {
|
||||||
|
planLoading = true;
|
||||||
|
planStatus = 'Generating plan...';
|
||||||
|
planError = false;
|
||||||
|
try {
|
||||||
|
if (!settings.heightCm || !settings.weightKg) throw new Error('Enter height and current weight first.');
|
||||||
|
planAdvice = await chatCompletion({
|
||||||
|
model: settings.taskModel,
|
||||||
|
temperature: 0.2,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: 'Give safe, concise nutrition and weight-loss guidance. Do not diagnose disease. Recommend medical review for pregnancy, eating disorder history, chronic disease, or aggressive goals.' },
|
||||||
|
{ role: 'user', content: `Create a personalized weight-loss plan using these details: height ${settings.heightCm} cm, current weight ${settings.weightKg} kg, target weight ${settings.targetWeightKg || 'not set'} kg, activity ${settings.activityLevel}, pace ${settings.pace}. Include daily calorie range, protein target, habits, and safety notes.` },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
planStatus = 'Plan generated.';
|
||||||
|
saveSettings();
|
||||||
|
} catch (error) {
|
||||||
|
planStatus = error.message;
|
||||||
|
planError = true;
|
||||||
|
} finally {
|
||||||
|
planLoading = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function drawCharts() {
|
async function drawCharts() {
|
||||||
|
|
@ -446,33 +669,29 @@ Image estimate: ${imageEstimate}` },
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<select class="hidden max-w-64 rounded-lg border border-white/20 bg-white/15 px-3 py-2 text-sm text-white outline-none md:block" bind:value={settings.taskModel} on:change={saveSelectedModels} aria-label="Nutrition estimate model">
|
<button class="hidden rounded-lg bg-white/15 px-3 py-2 text-sm font-semibold hover:bg-white/25 md:block" type="button" on:click={() => selectPage('settings')}>Settings</button>
|
||||||
{#each models as model}<option class="text-slate-900" value={model.id}>{model.name}</option>{/each}
|
|
||||||
</select>
|
|
||||||
<button class="rounded-lg bg-white/15 px-3 py-2 text-sm font-semibold hover:bg-white/25" type="button" on:click={logout}>Sign out</button>
|
<button class="rounded-lg bg-white/15 px-3 py-2 text-sm font-semibold hover:bg-white/25" type="button" on:click={logout}>Sign out</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<aside class:translate-x-0={menuOpen} class:lg:w-0={sidebarCollapsed} class="fixed inset-y-0 left-0 z-40 w-72 -translate-x-full overflow-hidden border-r border-slate-200 bg-white shadow-xl transition-all duration-200 lg:sticky lg:top-[68px] lg:h-[calc(100vh-68px)] lg:translate-x-0 lg:shadow-none lg:w-60">
|
<aside class:translate-x-0={menuOpen} class="sidebar-shell fixed inset-y-0 left-0 z-40 w-72 -translate-x-full overflow-hidden border-r border-slate-200 bg-white shadow-xl transition-transform duration-200 lg:sticky lg:top-[68px] lg:h-[calc(100vh-68px)] lg:translate-x-0 lg:shadow-none lg:w-60">
|
||||||
<div class="flex items-center justify-between border-b border-slate-200 px-4 py-3 lg:hidden">
|
<div class="flex items-center justify-between border-b border-slate-200 px-4 py-3 lg:hidden">
|
||||||
<strong>Menu</strong>
|
<strong>Menu</strong>
|
||||||
<button class="rounded-lg bg-slate-100 px-3 py-1.5 text-sm" type="button" on:click={() => menuOpen = false}>Close</button>
|
<button class="rounded-lg bg-slate-100 px-3 py-1.5 text-sm" type="button" on:click={() => menuOpen = false}>Close</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-b border-slate-200 p-3">
|
<div class="sidebar-expanded-only border-b border-slate-200 p-3">
|
||||||
<label for="menu-search" class="mb-1 block text-xs font-bold uppercase tracking-wide text-slate-500">Search pages</label>
|
<label for="menu-search" class="mb-1 block text-xs font-bold uppercase tracking-wide text-slate-500">Search pages</label>
|
||||||
<input id="menu-search" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-100" bind:value={menuSearch} placeholder="Search menu...">
|
<input id="menu-search" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-100" bind:value={menuSearch} placeholder="Search menu...">
|
||||||
</div>
|
</div>
|
||||||
<nav class="p-2" aria-label="Main menu">
|
<nav class="p-2" aria-label="Main menu">
|
||||||
{#if trackerPages.length}<div class="px-3 py-2 text-xs font-bold uppercase tracking-wide text-slate-400">Tracker</div>{/if}
|
{#if trackerPages.length}<div class="sidebar-section-label px-3 py-2 text-xs font-bold uppercase tracking-wide text-slate-400">Tracker</div>{/if}
|
||||||
{#each trackerPages as item}<button class:active-tab={page === item.id} class="tab-button" type="button" on:click={() => selectPage(item.id)}>{item.label}</button>{/each}
|
{#each trackerPages as item}<button class:active-tab={page === item.id} class="tab-button" type="button" title={item.label} on:click={() => selectPage(item.id)}><span class="tab-icon">{item.icon}</span><span class="tab-label">{item.label}</span></button>{/each}
|
||||||
{#if adminPages.length}<div class="px-3 py-2 text-xs font-bold uppercase tracking-wide text-slate-400">Admin</div>{/if}
|
{#if adminPages.length}<div class="sidebar-section-label px-3 py-2 text-xs font-bold uppercase tracking-wide text-slate-400">Admin</div>{/if}
|
||||||
{#each adminPages as item}<button class:active-tab={page === item.id} class="tab-button" type="button" on:click={() => selectPage(item.id)}>{item.label}</button>{/each}
|
{#each adminPages as item}<button class:active-tab={page === item.id} class="tab-button" type="button" title={item.label} on:click={() => selectPage(item.id)}><span class="tab-icon">{item.icon}</span><span class="tab-label">{item.label}</span></button>{/each}
|
||||||
</nav>
|
</nav>
|
||||||
<button class="absolute bottom-0 hidden w-full border-t border-slate-200 px-4 py-3 text-sm font-semibold text-slate-500 hover:text-blue-600 lg:block" type="button" on:click={toggleSidebarCollapse}>Collapse menu</button>
|
|
||||||
</aside>
|
</aside>
|
||||||
{#if sidebarCollapsed}<button class="fixed left-0 top-1/2 z-30 hidden rounded-r-xl border border-l-0 border-slate-200 bg-white px-2 py-3 shadow lg:block" type="button" on:click={toggleSidebarCollapse}>›</button>{/if}
|
|
||||||
{#if menuOpen}<button class="fixed inset-0 z-30 bg-slate-950/40 lg:hidden" type="button" aria-label="Close menu" on:click={() => menuOpen = false}></button>{/if}
|
{#if menuOpen}<button class="fixed inset-0 z-30 bg-slate-950/40 lg:hidden" type="button" aria-label="Close menu" on:click={() => menuOpen = false}></button>{/if}
|
||||||
|
|
||||||
<main class="min-w-0 flex-1 p-4 lg:p-6">
|
<main class="min-w-0 flex-1 p-4 lg:p-6">
|
||||||
|
|
@ -486,34 +705,92 @@ Image estimate: ${imageEstimate}` },
|
||||||
<Stat label="Produce" value={`${produceToday.toFixed(1)} / 5`} note="Fruit + vegetables" />
|
<Stat label="Produce" value={`${produceToday.toFixed(1)} / 5`} note="Fruit + vegetables" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-4 lg:grid-cols-[0.8fr_1.2fr]">
|
<div class="grid gap-4 lg:grid-cols-[0.8fr_1.2fr]">
|
||||||
<section class="card"><h3 class="card-title">Quick actions</h3><div class="grid gap-2 p-4"><button class="btn-primary" type="button" on:click={() => selectPage('log')}>Log a meal</button><button class="btn-secondary" type="button" on:click={() => selectPage('diary')}>Review diary</button><button class="btn-secondary" type="button" on:click={() => selectPage('models')}>Choose models</button></div></section>
|
<section class="card"><h3 class="card-title">Quick actions</h3><div class="grid gap-2 p-4"><button class="btn-primary" type="button" on:click={() => selectPage('log')}>Log a meal</button><button class="btn-secondary" type="button" on:click={() => selectPage('diary')}>Review diary</button><button class="btn-secondary" type="button" on:click={() => selectPage('settings')}>Settings</button></div></section>
|
||||||
<section class="card"><h3 class="card-title">Macros today</h3><div class="p-4"><canvas id="macroChart" bind:this={macroCanvas} width="520" height="260"></canvas></div></section>
|
<section class="card"><h3 class="card-title">Macros today</h3><div class="p-4"><canvas id="macroChart" bind:this={macroCanvas} width="520" height="260"></canvas></div></section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{:else if page === 'log'}
|
{:else if page === 'log'}
|
||||||
<section class="space-y-4"><div><h2 class="text-xl font-bold">Log meal</h2><p class="text-sm text-slate-500">Backfill meals for any date and time.</p></div>
|
<MealForm
|
||||||
<form class="card" on:submit|preventDefault={saveMeal}>
|
{mealTypes}
|
||||||
<h3 class="card-title">Meal details</h3>
|
bind:mealDate
|
||||||
<div class="grid gap-4 p-4 md:grid-cols-3">
|
bind:mealTime
|
||||||
<Field label="Date"><input id="mealDate" class="input" type="date" bind:value={mealDate} required></Field>
|
bind:mealType
|
||||||
<Field label="Time of day"><input id="mealTime" class="input" type="time" bind:value={mealTime} required></Field>
|
bind:description
|
||||||
<Field label="Meal type"><select id="mealType" class="input" bind:value={mealType} required>{#each mealTypes as type}<option>{type}</option>{/each}</select></Field>
|
bind:measure
|
||||||
<Field className="md:col-span-3" label="Description"><textarea id="description" class="input min-h-32" bind:value={description} placeholder="Example: grilled salmon, rice, broccoli, berries"></textarea></Field>
|
bind:selectedImageDataUrl
|
||||||
<Field label="Portion or measure"><input id="measure" class="input" bind:value={measure} placeholder="Example: one plate, 450g, 2 cups"></Field>
|
{status}
|
||||||
<Field label="Meal image"><input id="image" class="input" type="file" accept="image/*" on:change={chooseImage}></Field>
|
{statusError}
|
||||||
</div>
|
{savingMeal}
|
||||||
{#if selectedImageDataUrl}<img id="preview" class="mx-4 mb-4 max-h-80 w-[calc(100%-2rem)] rounded-2xl object-cover" src={selectedImageDataUrl} alt="Selected meal preview">{/if}
|
{editingEntryId}
|
||||||
<div class="flex flex-col gap-3 border-t border-slate-200 p-4 md:flex-row md:items-center"><button class="btn-primary" type="submit" disabled={savingMeal}>{savingMeal ? 'Saving...' : 'Analyze and save'}</button>{#if status}<p id="status" class:text-red-600={statusError} class="text-sm font-semibold text-green-700">{status}</p>{/if}</div>
|
bind:estimateDraft
|
||||||
</form>
|
{chooseImage}
|
||||||
</section>
|
{saveMeal}
|
||||||
|
{saveMealEdits}
|
||||||
|
{cancelEdit}
|
||||||
|
/>
|
||||||
{:else if page === 'analytics'}
|
{:else if page === 'analytics'}
|
||||||
<section class="space-y-4"><div><h2 class="text-xl font-bold">Analytics</h2><p class="text-sm text-slate-500">Seven-day calorie and produce trends.</p></div><div class="grid gap-4 xl:grid-cols-2"><section class="card"><h3 class="card-title">Calories, last 7 days</h3><div class="p-4"><canvas id="calorieChart" bind:this={calorieCanvas} width="720" height="260"></canvas></div></section><section class="card"><h3 class="card-title">Fruit and vegetables</h3><div class="p-4"><canvas id="produceChart" bind:this={produceCanvas} width="720" height="240"></canvas></div></section></div></section>
|
<section class="space-y-4"><div><h2 class="text-xl font-bold">Analytics</h2><p class="text-sm text-slate-500">Seven-day calorie and produce trends.</p></div><div class="grid gap-4 xl:grid-cols-2"><section class="card"><h3 class="card-title">Calories, last 7 days</h3><div class="p-4"><canvas id="calorieChart" bind:this={calorieCanvas} width="720" height="260"></canvas></div></section><section class="card"><h3 class="card-title">Fruit and vegetables</h3><div class="p-4"><canvas id="produceChart" bind:this={produceCanvas} width="720" height="240"></canvas></div></section></div></section>
|
||||||
{:else if page === 'diary'}
|
{:else if page === 'diary'}
|
||||||
<section class="space-y-4"><div><h2 class="text-xl font-bold">Diary</h2><p class="text-sm text-slate-500">Search and filter local meal entries.</p></div><section class="card"><div class="grid gap-3 p-4 md:grid-cols-[1fr_180px_170px_150px]"><Field label="Search"><input id="diarySearch" class="input" bind:value={diarySearch} placeholder="Search meals, notes, groups..."></Field><Field label="Meal type"><select id="diaryTypeFilter" class="input" bind:value={diaryType}><option value="">All types</option>{#each mealTypes as type}<option>{type}</option>{/each}</select></Field><Field label="Date"><input id="diaryDateFilter" class="input" type="date" bind:value={diaryDate}></Field><div class="flex items-end"><button class="w-full rounded-lg border border-red-200 px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50" type="button" on:click={clearDiary}>{clearArmed ? 'Click again' : 'Clear diary'}</button></div></div></section><div id="entries" class="grid gap-3">{#if diaryRows.length}{#each diaryRows as entry}<article class="entry card p-4"><div class="flex flex-col justify-between gap-2 md:flex-row"><div><strong>{entry.mealName}</strong><div class="text-sm text-slate-500">{entry.date} {entry.time} · {entry.mealType}</div></div><div class="font-bold">{entry.calories} kcal</div></div><div class="mt-2 text-sm">P {entry.proteinGrams}g · C {entry.carbsGrams}g · F {entry.fatGrams}g · Fruit {Number(entry.fruitServings || 0).toFixed(1)} · Vegetables {Number(entry.vegetableServings || 0).toFixed(1)}</div>{#if entry.description}<div class="mt-2 text-sm text-slate-500">{entry.description}</div>{/if}{#if entry.foodGroups}<div class="text-sm text-slate-500">Groups: {entry.foodGroups}</div>{/if}{#if entry.notes}<div class="text-sm text-slate-500">{entry.notes}</div>{/if}</article>{/each}{:else}<div class="empty-state">No meals match the current filters.</div>{/if}</div></section>
|
<DiaryPage
|
||||||
{:else if page === 'models'}
|
{mealTypes}
|
||||||
<section class="space-y-4"><div><h2 class="text-xl font-bold">Models</h2><p class="text-sm text-slate-500">Choose from the models configured on the server. Model IDs are not typed manually.</p></div><div class="grid gap-4 lg:grid-cols-[0.8fr_1.2fr]"><section class="card"><h3 class="card-title">Selected models</h3><div class="grid gap-3 p-4"><Field label="Image analysis model"><select id="visionModel" class="input" bind:value={settings.visionModel}>{#each models as model}<option value={model.id}>{model.name}</option>{/each}</select></Field><Field label="Nutrition estimate model"><select id="taskModel" class="input" bind:value={settings.taskModel}>{#each models as model}<option value={model.id}>{model.name}</option>{/each}</select></Field><button class="btn-primary" type="button" on:click={saveSelectedModels}>Save selected models</button>{#if modelStatus}<p id="modelStatus" class:text-red-600={modelError} class="text-sm font-semibold text-green-700">{modelStatus}</p>{/if}</div></section><section class="card"><div class="flex flex-col justify-between gap-2 border-b border-slate-200 p-4 md:flex-row md:items-center"><h3 class="font-bold">Available models</h3><button class="btn-secondary" type="button" on:click={loadModels}>Refresh models</button></div><div class="p-4"><input id="modelSearch" class="input mb-3" bind:value={modelSearch} placeholder="Search models..."><div class="grid gap-2">{#each filteredModels as model}<button class="flex items-center justify-between rounded-lg border border-slate-200 px-3 py-2 text-left text-sm hover:border-blue-300 hover:bg-blue-50" type="button" on:click={() => selectModel(model.id)}><span class="truncate">{model.name}</span><small class="ml-3 shrink-0 text-slate-500">{model.id === settings.visionModel ? 'Image' : ''}{model.id === settings.visionModel && model.id === settings.taskModel ? ' + ' : ''}{model.id === settings.taskModel ? 'Nutrition' : ''}</small></button>{/each}</div></div></section></div></section>
|
{diaryRows}
|
||||||
|
{diaryDays}
|
||||||
|
bind:diarySearch
|
||||||
|
bind:diaryType
|
||||||
|
bind:diaryDate
|
||||||
|
bind:selectedEntryIds
|
||||||
|
{trash}
|
||||||
|
{editEntry}
|
||||||
|
{toggleSelected}
|
||||||
|
{moveEntriesToTrash}
|
||||||
|
{moveDayToTrash}
|
||||||
|
{restoreEntry}
|
||||||
|
/>
|
||||||
|
{:else if page === 'trash'}
|
||||||
|
<DiaryPage
|
||||||
|
{mealTypes}
|
||||||
|
diaryRows={[]}
|
||||||
|
diaryDays={[]}
|
||||||
|
bind:diarySearch
|
||||||
|
bind:diaryType
|
||||||
|
bind:diaryDate
|
||||||
|
bind:selectedEntryIds
|
||||||
|
{trash}
|
||||||
|
{editEntry}
|
||||||
|
{toggleSelected}
|
||||||
|
{moveEntriesToTrash}
|
||||||
|
{moveDayToTrash}
|
||||||
|
{restoreEntry}
|
||||||
|
trashMode={true}
|
||||||
|
/>
|
||||||
{:else if page === 'settings'}
|
{:else if page === 'settings'}
|
||||||
<section class="space-y-4"><div><h2 class="text-xl font-bold">Settings</h2><p class="text-sm text-slate-500">Connection settings are managed on the server.</p></div><section class="card"><h3 class="card-title">AI connection</h3><div class="p-4 text-sm text-slate-600"><p>API base URL and API key are read from environment variables.</p><p class="mt-2">Use the Models page to refresh and select available models.</p></div></section></section>
|
<SettingsPage
|
||||||
|
bind:settings
|
||||||
|
{models}
|
||||||
|
{discoveredModels}
|
||||||
|
bind:modelSearch
|
||||||
|
{modelStatus}
|
||||||
|
{modelError}
|
||||||
|
{modelSearching}
|
||||||
|
{passwordStatus}
|
||||||
|
{passwordError}
|
||||||
|
{resetPasswordValue}
|
||||||
|
bind:currentPassword
|
||||||
|
bind:newPassword
|
||||||
|
bind:confirmPassword
|
||||||
|
{planStatus}
|
||||||
|
{planError}
|
||||||
|
{planLoading}
|
||||||
|
{planAdvice}
|
||||||
|
{saveSelectedModels}
|
||||||
|
{loadModels}
|
||||||
|
{searchModels}
|
||||||
|
{addModel}
|
||||||
|
{removeModel}
|
||||||
|
{changePassword}
|
||||||
|
{resetPassword}
|
||||||
|
{generatePlan}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -521,17 +798,8 @@ Image estimate: ${imageEstimate}` },
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.tab-button { display: flex; width: 100%; align-items: center; border-left: 3px solid transparent; padding: 0.65rem 1rem; text-align: left; font-size: 0.875rem; font-weight: 600; color: #475569; }
|
.tab-button { display: flex; width: 100%; align-items: center; gap: 0.65rem; border-left: 3px solid transparent; border-radius: 0.55rem; padding: 0.65rem 1rem; text-align: left; font-size: 0.875rem; font-weight: 600; color: #475569; }
|
||||||
.tab-button:hover { background: #f8fafc; color: #0f172a; }
|
.tab-button:hover { background: #f8fafc; color: #0f172a; }
|
||||||
.active-tab { border-left-color: #2563eb; background: #dbeafe; color: #2563eb; }
|
.active-tab { border-left-color: #2563eb; background: #dbeafe; color: #2563eb; }
|
||||||
.card { border-radius: 0.9rem; border: 1px solid #e2e8f0; background: white; box-shadow: 0 1px 3px rgba(15,23,42,.06); overflow: hidden; }
|
.tab-icon { display: grid; width: 1.35rem; height: 1.35rem; flex: 0 0 1.35rem; place-items: center; font-weight: 900; }
|
||||||
.card-title { border-bottom: 1px solid #e2e8f0; background: #f8fafc; padding: 0.75rem 1rem; font-size: 0.9rem; font-weight: 800; color: #334155; }
|
|
||||||
.input { width: 100%; border-radius: 0.65rem; border: 1px solid #cbd5e1; padding: 0.6rem 0.75rem; font-size: 0.9rem; outline: none; }
|
|
||||||
.input:focus { border-color: #2563eb; box-shadow: 0 0 0 4px #dbeafe; }
|
|
||||||
.btn-primary { border-radius: 0.65rem; background: #2563eb; padding: 0.65rem 1rem; font-size: 0.9rem; font-weight: 800; color: white; }
|
|
||||||
.btn-primary:hover { background: #1d4ed8; }
|
|
||||||
.btn-primary:disabled { opacity: .55; }
|
|
||||||
.btn-secondary { border-radius: 0.65rem; border: 1px solid #cbd5e1; background: white; padding: 0.65rem 1rem; font-size: 0.9rem; font-weight: 800; color: #334155; }
|
|
||||||
.btn-secondary:hover { border-color: #2563eb; color: #2563eb; }
|
|
||||||
.empty-state { border-radius: 0.9rem; border: 1px dashed #cbd5e1; background: white; padding: 2rem; text-align: center; color: #64748b; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
76
web/src/DiaryPage.svelte
Normal file
76
web/src/DiaryPage.svelte
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
<script>
|
||||||
|
import Field from './Field.svelte';
|
||||||
|
|
||||||
|
export let mealTypes = [];
|
||||||
|
export let diaryRows = [];
|
||||||
|
export let diaryDays = [];
|
||||||
|
export let diarySearch;
|
||||||
|
export let diaryType;
|
||||||
|
export let diaryDate;
|
||||||
|
export let selectedEntryIds = [];
|
||||||
|
export let trash = [];
|
||||||
|
export let editEntry;
|
||||||
|
export let toggleSelected;
|
||||||
|
export let moveEntriesToTrash;
|
||||||
|
export let moveDayToTrash;
|
||||||
|
export let restoreEntry;
|
||||||
|
export let trashMode = false;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-bold">{trashMode ? 'Trash' : 'Diary'}</h2>
|
||||||
|
<p class="text-sm text-slate-500">{trashMode ? 'Restore meals moved out of the diary.' : 'Search, edit, select, or move meals to trash.'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if trashMode}
|
||||||
|
<div id="trashEntries" class="grid gap-3">
|
||||||
|
{#if trash.length}
|
||||||
|
{#each trash as entry}
|
||||||
|
<article class="entry card p-4">
|
||||||
|
<div class="flex flex-col justify-between gap-2 md:flex-row">
|
||||||
|
<div><strong>{entry.mealName}</strong><div class="text-sm text-slate-500">{entry.date} {entry.time} · {entry.mealType}</div></div>
|
||||||
|
<button class="btn-secondary" type="button" on:click={() => restoreEntry(entry.id)}>Restore</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
{:else}<div class="empty-state">Trash is empty.</div>{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<section class="card">
|
||||||
|
<div class="grid gap-3 p-4 md:grid-cols-[1fr_180px_170px_auto]">
|
||||||
|
<Field label="Search"><input id="diarySearch" class="input" bind:value={diarySearch} placeholder="Search meals, notes, groups..."></Field>
|
||||||
|
<Field label="Meal type"><select id="diaryTypeFilter" class="input" bind:value={diaryType}><option value="">All types</option>{#each mealTypes as type}<option>{type}</option>{/each}</select></Field>
|
||||||
|
<Field label="Date"><input id="diaryDateFilter" class="input" type="date" bind:value={diaryDate}></Field>
|
||||||
|
<div class="flex items-end"><button class="btn-secondary w-full" type="button" disabled={!selectedEntryIds.length} on:click={() => moveEntriesToTrash(selectedEntryIds)}>Move selected to trash</button></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="entries" class="grid gap-3">
|
||||||
|
{#if diaryRows.length}
|
||||||
|
{#each diaryDays as day}
|
||||||
|
<section class="card">
|
||||||
|
<div class="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-4 py-2">
|
||||||
|
<h3 class="font-bold">{day}</h3>
|
||||||
|
<button class="rounded-md bg-red-50 px-3 py-1.5 text-xs font-bold text-red-600 hover:bg-red-100" type="button" on:click={() => moveDayToTrash(day)}>Move day to trash</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2 p-3">
|
||||||
|
{#each diaryRows.filter(entry => entry.date === day) as entry}
|
||||||
|
<article class="entry rounded-xl border border-slate-200 p-3">
|
||||||
|
<div class="flex flex-col justify-between gap-2 md:flex-row">
|
||||||
|
<label class="flex min-w-0 gap-3"><input type="checkbox" checked={selectedEntryIds.includes(entry.id)} on:change={() => toggleSelected(entry.id)}><span class="min-w-0"><strong>{entry.mealName}</strong><div class="text-sm text-slate-500">{entry.date} {entry.time} · {entry.mealType}</div></span></label>
|
||||||
|
<div class="flex items-center gap-2"><div class="font-bold">{entry.calories} kcal</div><button class="btn-secondary" type="button" on:click={() => editEntry(entry)}>Edit</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-sm">P {entry.proteinGrams}g · C {entry.carbsGrams}g · F {entry.fatGrams}g · Fruit {Number(entry.fruitServings || 0).toFixed(1)} · Vegetables {Number(entry.vegetableServings || 0).toFixed(1)}</div>
|
||||||
|
{#if entry.description}<div class="mt-2 text-sm text-slate-500">{entry.description}</div>{/if}
|
||||||
|
{#if entry.foodGroups}<div class="text-sm text-slate-500">Groups: {entry.foodGroups}</div>{/if}
|
||||||
|
{#if entry.notes}<div class="text-sm text-slate-500">{entry.notes}</div>{/if}
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
{:else}<div class="empty-state">No meals match the current filters.</div>{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
57
web/src/MealForm.svelte
Normal file
57
web/src/MealForm.svelte
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
<script>
|
||||||
|
import Field from './Field.svelte';
|
||||||
|
|
||||||
|
export let mealTypes = [];
|
||||||
|
export let mealDate;
|
||||||
|
export let mealTime;
|
||||||
|
export let mealType;
|
||||||
|
export let description;
|
||||||
|
export let measure;
|
||||||
|
export let selectedImageDataUrl;
|
||||||
|
export let status;
|
||||||
|
export let statusError;
|
||||||
|
export let savingMeal;
|
||||||
|
export let editingEntryId;
|
||||||
|
export let estimateDraft;
|
||||||
|
export let chooseImage;
|
||||||
|
export let saveMeal;
|
||||||
|
export let saveMealEdits;
|
||||||
|
export let cancelEdit;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-bold">{editingEntryId ? 'Edit meal' : 'Log meal'}</h2>
|
||||||
|
<p class="text-sm text-slate-500">Backfill meals for any date and time. After analysis, edit the values or run analysis again.</p>
|
||||||
|
</div>
|
||||||
|
<form class="card" on:submit|preventDefault={saveMeal}>
|
||||||
|
<h3 class="card-title">Meal details</h3>
|
||||||
|
<div class="grid gap-4 p-4 md:grid-cols-3">
|
||||||
|
<Field label="Date"><input id="mealDate" class="input" type="date" bind:value={mealDate} required></Field>
|
||||||
|
<Field label="Time of day"><input id="mealTime" class="input" type="time" bind:value={mealTime} required></Field>
|
||||||
|
<Field label="Meal type"><select id="mealType" class="input" bind:value={mealType} required>{#each mealTypes as type}<option>{type}</option>{/each}</select></Field>
|
||||||
|
<Field className="md:col-span-3" label="Description"><textarea id="description" class="input min-h-32" bind:value={description} placeholder="Example: grilled salmon, rice, broccoli, berries"></textarea></Field>
|
||||||
|
<Field label="Portion or measure"><input id="measure" class="input" bind:value={measure} placeholder="Example: one plate, 450g, 2 cups"></Field>
|
||||||
|
<Field label="Meal image or camera"><input id="image" class="input" type="file" accept="image/*" capture="environment" on:change={chooseImage}></Field>
|
||||||
|
</div>
|
||||||
|
{#if selectedImageDataUrl}<img id="preview" class="mx-4 mb-4 max-h-80 w-[calc(100%-2rem)] rounded-2xl object-cover" src={selectedImageDataUrl} alt="Selected meal preview">{/if}
|
||||||
|
{#if editingEntryId}
|
||||||
|
<div class="grid gap-3 border-t border-slate-200 p-4 md:grid-cols-4">
|
||||||
|
<Field className="md:col-span-2" label="Meal name"><input class="input" bind:value={estimateDraft.mealName}></Field>
|
||||||
|
<Field label="Calories"><input class="input" type="number" min="0" bind:value={estimateDraft.calories}></Field>
|
||||||
|
<Field label="Protein (g)"><input class="input" type="number" min="0" bind:value={estimateDraft.proteinGrams}></Field>
|
||||||
|
<Field label="Carbs (g)"><input class="input" type="number" min="0" bind:value={estimateDraft.carbsGrams}></Field>
|
||||||
|
<Field label="Fat (g)"><input class="input" type="number" min="0" bind:value={estimateDraft.fatGrams}></Field>
|
||||||
|
<Field label="Fruit servings"><input class="input" type="number" min="0" step="0.1" bind:value={estimateDraft.fruitServings}></Field>
|
||||||
|
<Field label="Vegetable servings"><input class="input" type="number" min="0" step="0.1" bind:value={estimateDraft.vegetableServings}></Field>
|
||||||
|
<Field className="md:col-span-2" label="Food groups"><input class="input" bind:value={estimateDraft.foodGroups}></Field>
|
||||||
|
<Field className="md:col-span-2" label="Notes"><input class="input" bind:value={estimateDraft.notes}></Field>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex flex-col gap-3 border-t border-slate-200 p-4 md:flex-row md:items-center">
|
||||||
|
<button class="btn-primary" type="submit" disabled={savingMeal}>{savingMeal ? 'Saving...' : editingEntryId ? 'Analyze again' : 'Analyze and save'}</button>
|
||||||
|
{#if editingEntryId}<button class="btn-secondary" type="button" on:click={saveMealEdits}>Save edits</button><button class="btn-secondary" type="button" on:click={cancelEdit}>Cancel</button>{/if}
|
||||||
|
{#if status}<p id="status" class:text-red-600={statusError} class="text-sm font-semibold text-green-700">{status}</p>{/if}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
85
web/src/SettingsPage.svelte
Normal file
85
web/src/SettingsPage.svelte
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
<script>
|
||||||
|
import Field from './Field.svelte';
|
||||||
|
|
||||||
|
export let settings;
|
||||||
|
export let models = [];
|
||||||
|
export let discoveredModels = [];
|
||||||
|
export let modelSearch;
|
||||||
|
export let modelStatus;
|
||||||
|
export let modelError;
|
||||||
|
export let modelSearching;
|
||||||
|
export let passwordStatus;
|
||||||
|
export let passwordError;
|
||||||
|
export let resetPasswordValue;
|
||||||
|
export let currentPassword;
|
||||||
|
export let newPassword;
|
||||||
|
export let confirmPassword;
|
||||||
|
export let planStatus;
|
||||||
|
export let planError;
|
||||||
|
export let planLoading;
|
||||||
|
export let planAdvice;
|
||||||
|
export let saveSelectedModels;
|
||||||
|
export let loadModels;
|
||||||
|
export let searchModels;
|
||||||
|
export let addModel;
|
||||||
|
export let removeModel;
|
||||||
|
export let changePassword;
|
||||||
|
export let resetPassword;
|
||||||
|
export let generatePlan;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="space-y-4">
|
||||||
|
<div><h2 class="text-xl font-bold">Settings</h2><p class="text-sm text-slate-500">Account, profile, and model controls.</p></div>
|
||||||
|
<div class="grid gap-4 xl:grid-cols-[0.85fr_1.15fr]">
|
||||||
|
<section class="card">
|
||||||
|
<h3 class="card-title">Password</h3>
|
||||||
|
<form class="grid gap-3 p-4" on:submit|preventDefault={changePassword}>
|
||||||
|
<Field label="Current password"><input id="currentPassword" class="input" type="password" bind:value={currentPassword} autocomplete="current-password"></Field>
|
||||||
|
<Field label="New password"><input id="newPassword" class="input" type="password" bind:value={newPassword} autocomplete="new-password" minlength="12"></Field>
|
||||||
|
<Field label="Confirm new password"><input id="confirmPassword" class="input" type="password" bind:value={confirmPassword} autocomplete="new-password" minlength="12"></Field>
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row"><button class="btn-primary" type="submit">Change password</button><button class="btn-secondary" type="button" on:click={resetPassword}>Reset password</button></div>
|
||||||
|
{#if passwordStatus}<p id="passwordStatus" class:text-red-600={passwordError} class="text-sm font-semibold text-green-700">{passwordStatus}</p>{/if}
|
||||||
|
{#if resetPasswordValue}<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm font-mono text-amber-900" id="resetPasswordValue">{resetPasswordValue}</div>{/if}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h3 class="card-title">Weight-loss plan</h3>
|
||||||
|
<div class="grid gap-3 p-4 md:grid-cols-2">
|
||||||
|
<Field label="Height (cm)"><input class="input" type="number" min="80" bind:value={settings.heightCm}></Field>
|
||||||
|
<Field label="Current weight (kg)"><input class="input" type="number" min="20" step="0.1" bind:value={settings.weightKg}></Field>
|
||||||
|
<Field label="Target weight (kg)"><input class="input" type="number" min="20" step="0.1" bind:value={settings.targetWeightKg}></Field>
|
||||||
|
<Field label="Activity"><select class="input" bind:value={settings.activityLevel}><option>Low</option><option>Moderate</option><option>High</option></select></Field>
|
||||||
|
<Field label="Pace"><select class="input" bind:value={settings.pace}><option>Gentle</option><option>Steady</option><option>Aggressive</option></select></Field>
|
||||||
|
<div class="flex items-end"><button class="btn-primary w-full" type="button" on:click={generatePlan} disabled={planLoading}>{planLoading ? 'Generating...' : 'Generate plan'}</button></div>
|
||||||
|
{#if planStatus}<p class:text-red-600={planError} class="text-sm font-semibold text-green-700 md:col-span-2">{planStatus}</p>{/if}
|
||||||
|
{#if planAdvice}<div class="whitespace-pre-wrap rounded-xl border border-blue-100 bg-blue-50 p-4 text-sm text-slate-800 md:col-span-2">{planAdvice}</div>{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card xl:col-span-2">
|
||||||
|
<h3 class="card-title">Models</h3>
|
||||||
|
<div class="grid gap-4 p-4 lg:grid-cols-[0.8fr_1.2fr]">
|
||||||
|
<div class="grid gap-3 content-start">
|
||||||
|
<Field label="Image analysis model"><select id="visionModel" class="input" bind:value={settings.visionModel}>{#each models as model}<option value={model.id}>{model.name}</option>{/each}</select></Field>
|
||||||
|
<Field label="Nutrition and advice model"><select id="taskModel" class="input" bind:value={settings.taskModel}>{#each models as model}<option value={model.id}>{model.name}</option>{/each}</select></Field>
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row"><button class="btn-primary" type="button" on:click={saveSelectedModels}>Save selected models</button><button class="btn-secondary" type="button" on:click={loadModels}>Refresh saved list</button></div>
|
||||||
|
{#if modelStatus}<p id="modelStatus" class:text-red-600={modelError} class="text-sm font-semibold text-green-700">{modelStatus}</p>{/if}
|
||||||
|
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||||
|
<div class="mb-2 text-xs font-bold uppercase tracking-wide text-slate-500">Saved models</div>
|
||||||
|
<div class="max-h-40 overflow-y-auto pr-1">
|
||||||
|
{#each models as model}<div class="mb-2 flex items-center gap-2 rounded-lg bg-white px-3 py-2 text-sm shadow-sm"><span class="min-w-0 flex-1 truncate"><strong>{model.name}</strong> <span class="text-slate-400">{model.id}</span></span><button class="rounded-md bg-red-50 px-2 py-1 text-xs font-bold text-red-600 hover:bg-red-100" type="button" on:click={() => removeModel(model.id)}>Remove</button></div>{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-bold text-slate-700" for="modelSearch">Search provider models</label>
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row"><input id="modelSearch" class="input" bind:value={modelSearch} placeholder="Search current models, e.g. claude, gemini, gpt" on:keydown={(event) => event.key === 'Enter' && searchModels()}><button class="btn-primary sm:w-36" type="button" on:click={searchModels} disabled={modelSearching}>{modelSearching ? 'Searching...' : 'Search'}</button></div>
|
||||||
|
<div class="mt-3 max-h-72 overflow-y-auto rounded-xl border border-slate-200 bg-white p-2">
|
||||||
|
{#if discoveredModels.length}{#each discoveredModels as model}<div class="mb-2 flex items-center gap-2 rounded-lg border border-slate-100 px-3 py-2 text-sm"><button class="rounded-md bg-blue-600 px-2.5 py-1 text-xs font-black text-white hover:bg-blue-700" type="button" on:click={() => addModel(model)}>+</button><span class="min-w-0 flex-1 truncate"><strong>{model.name}</strong> <span class="text-slate-400">{model.id}</span></span></div>{/each}{:else}<p class="px-2 py-6 text-center text-sm text-slate-500">Search to query the current provider. Results stay in this scroll panel.</p>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
@ -8,3 +8,14 @@ html { background: #f8fafc; }
|
||||||
body { margin: 0; min-height: 100vh; font-family: var(--font-sans); color: #0f172a; }
|
body { margin: 0; min-height: 100vh; font-family: var(--font-sans); color: #0f172a; }
|
||||||
button, input, select, textarea { font: inherit; }
|
button, input, select, textarea { font: inherit; }
|
||||||
canvas { max-width: 100%; }
|
canvas { max-width: 100%; }
|
||||||
|
|
||||||
|
.card { border-radius: 0.9rem; border: 1px solid #e2e8f0; background: white; box-shadow: 0 1px 3px rgba(15,23,42,.06); overflow: hidden; }
|
||||||
|
.card-title { border-bottom: 1px solid #e2e8f0; background: #f8fafc; padding: 0.75rem 1rem; font-size: 0.9rem; font-weight: 800; color: #334155; }
|
||||||
|
.input { width: 100%; border-radius: 0.65rem; border: 1px solid #cbd5e1; padding: 0.6rem 0.75rem; font-size: 0.9rem; outline: none; }
|
||||||
|
.input:focus { border-color: #2563eb; box-shadow: 0 0 0 4px #dbeafe; }
|
||||||
|
.btn-primary { border-radius: 0.65rem; background: #2563eb; padding: 0.65rem 1rem; font-size: 0.9rem; font-weight: 800; color: white; }
|
||||||
|
.btn-primary:hover { background: #1d4ed8; }
|
||||||
|
.btn-primary:disabled { opacity: .55; }
|
||||||
|
.btn-secondary { border-radius: 0.65rem; border: 1px solid #cbd5e1; background: white; padding: 0.65rem 1rem; font-size: 0.9rem; font-weight: 800; color: #334155; }
|
||||||
|
.btn-secondary:hover { border-color: #2563eb; color: #2563eb; }
|
||||||
|
.empty-state { border-radius: 0.9rem; border: 1px dashed #cbd5e1; background: white; padding: 2rem; text-align: center; color: #64748b; }
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,40 @@ async function mockModels(page) {
|
||||||
defaultModel: 'claude-haiku-4.5',
|
defaultModel: 'claude-haiku-4.5',
|
||||||
models: [
|
models: [
|
||||||
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
|
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route('**/api/models/search', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
count: 2,
|
||||||
|
models: [
|
||||||
{ id: 'claude-sonnet-4.6', name: 'Claude Sonnet 4.6' },
|
{ id: 'claude-sonnet-4.6', name: 'Claude Sonnet 4.6' },
|
||||||
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash' },
|
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await page.route('**/api/models/add', async (route) => {
|
||||||
|
const body = route.request().postDataJSON();
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
defaultModel: 'claude-haiku-4.5',
|
||||||
|
models: [
|
||||||
|
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
|
||||||
|
{ id: body.id, name: body.name || body.id },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login(page) {
|
async function login(page) {
|
||||||
|
|
@ -60,7 +88,7 @@ test.describe('authenticated app', () => {
|
||||||
await expect(page.locator('#status')).toContainText('Describe the meal or upload an image.');
|
await expect(page.locator('#status')).toContainText('Describe the meal or upload an image.');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('selects models from dropdowns, backfills a meal, updates charts, and clears diary', async ({ page }) => {
|
test('adds curated models in settings, backfills a meal, edits, trashes, and restores', async ({ page }) => {
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
await page.route('**/api/chat', async (route) => {
|
await page.route('**/api/chat', async (route) => {
|
||||||
calls += 1;
|
calls += 1;
|
||||||
|
|
@ -75,9 +103,13 @@ test.describe('authenticated app', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Models', exact: true }).click();
|
await page.getByRole('banner').getByRole('button', { name: 'Settings' }).click();
|
||||||
|
await page.locator('#modelSearch').fill('gemini');
|
||||||
|
await page.getByRole('button', { name: 'Search' }).click();
|
||||||
|
await expect(page.getByText('Gemini 2.5 Flash')).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: '+' }).last().click();
|
||||||
await page.locator('#visionModel').selectOption('gemini-2.5-flash');
|
await page.locator('#visionModel').selectOption('gemini-2.5-flash');
|
||||||
await page.locator('#taskModel').selectOption('claude-sonnet-4.6');
|
await page.locator('#taskModel').selectOption('gemini-2.5-flash');
|
||||||
await page.getByRole('button', { name: 'Save selected models' }).click();
|
await page.getByRole('button', { name: 'Save selected models' }).click();
|
||||||
await expect(page.locator('#modelStatus')).toContainText('Models saved.');
|
await expect(page.locator('#modelStatus')).toContainText('Models saved.');
|
||||||
|
|
||||||
|
|
@ -93,17 +125,25 @@ test.describe('authenticated app', () => {
|
||||||
await page.getByRole('button', { name: 'Analyze and save' }).click();
|
await page.getByRole('button', { name: 'Analyze and save' }).click();
|
||||||
await expect(page.locator('#status')).toContainText('Saved.');
|
await expect(page.locator('#status')).toContainText('Saved.');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Diary', exact: true }).click();
|
await page.locator('nav').getByRole('button').filter({ hasText: 'Diary' }).click();
|
||||||
await expect(page.locator('.entry')).toContainText('Salmon rice bowl');
|
await expect(page.locator('.entry')).toContainText('Salmon rice bowl');
|
||||||
await expect(page.locator('.entry')).toContainText('2026-05-18 08:15 · Breakfast');
|
await expect(page.locator('.entry')).toContainText('2026-05-18 08:15 · Breakfast');
|
||||||
|
await page.getByRole('button', { name: 'Edit' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Edit meal' })).toBeVisible();
|
||||||
|
await page.getByLabel('Calories').fill('610');
|
||||||
|
await page.getByRole('button', { name: 'Save edits' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Analytics' }).click();
|
await page.locator('nav').getByRole('button').filter({ hasText: 'Analytics' }).click();
|
||||||
await expect(page.locator('#calorieChart')).toBeVisible();
|
await expect(page.locator('#calorieChart')).toBeVisible();
|
||||||
await expect(page.locator('#produceChart')).toBeVisible();
|
await expect(page.locator('#produceChart')).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Diary', exact: true }).click();
|
await page.locator('nav').getByRole('button').filter({ hasText: 'Diary' }).click();
|
||||||
await page.getByRole('button', { name: 'Clear diary' }).click();
|
await page.getByRole('checkbox').check();
|
||||||
await page.getByRole('button', { name: 'Click again' }).click();
|
await page.getByRole('button', { name: 'Move selected to trash' }).click();
|
||||||
await expect(page.locator('#entries')).toContainText('No meals match the current filters.');
|
await expect(page.locator('#entries')).toContainText('No meals match the current filters.');
|
||||||
|
await page.locator('nav').getByRole('button').filter({ hasText: 'Trash' }).click();
|
||||||
|
await expect(page.locator('#trashEntries')).toContainText('Salmon rice bowl');
|
||||||
|
await page.getByRole('button', { name: 'Restore' }).click();
|
||||||
|
await expect(page.locator('#trashEntries')).toContainText('Trash is empty.');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue