Add web auth and app navigation
All checks were successful
Android CI / build (push) Successful in 31s
Web CI / test (push) Successful in 46s

This commit is contained in:
Daniel 2026-05-19 17:59:02 +02:00
parent 71c8837b48
commit f57c04c989
14 changed files with 695 additions and 188 deletions

View file

@ -24,18 +24,11 @@ jobs:
npm install
npx playwright install --with-deps chromium
- name: Start web app
run: |
cd web
PORT=8095 node server.js > /tmp/calorie-ai-web.log 2>&1 &
for i in $(seq 1 30); do
curl -fsS http://127.0.0.1:8095 >/dev/null && exit 0
sleep 1
done
cat /tmp/calorie-ai-web.log
exit 1
- name: Run Playwright tests
run: |
cd web
CI=true \
CALORIE_AI_WEB_USER=admin \
CALORIE_AI_WEB_PASSWORD=test-password \
CALORIE_AI_SESSION_SECRET=test-session-secret \
npm test

2
.gitignore vendored
View file

@ -8,3 +8,5 @@ local.properties
web/node_modules/
web/test-results/
web/playwright-report/
web/data/
web/.env

View file

@ -26,7 +26,7 @@ Tagged versions also build an installable APK named `calorie-ai-vX.Y.Z.apk` thro
## Web Frontend
The web frontend lives in `web/`. It serves a browser UI and a tiny Node proxy at `/api/chat` so OpenAI-compatible endpoints are called server-side instead of directly from the browser.
The web frontend lives in `web/`. It serves an authenticated browser UI and a tiny Node proxy at `/api/chat` so OpenAI-compatible endpoints are called server-side instead of directly from the browser.
Run it with Docker:
@ -41,6 +41,8 @@ Then open:
http://127.0.0.1:8095
```
The Docker web server creates credentials on first boot in `web/data/auth.json` if `CALORIE_AI_WEB_PASSWORD` and `CALORIE_AI_SESSION_SECRET` are not supplied. To pin credentials, copy `web/.env.example` to `web/.env` and set strong values before starting Docker Compose.
## AI Endpoint
The app expects an OpenAI-compatible endpoint at:
@ -56,4 +58,4 @@ Example base URLs:
Default admin PIN is `admin`. Change it in the Admin AI Settings panel after first launch.
The web frontend stores AI settings in browser local storage and does not require a PIN by default.
The web frontend stores AI settings in browser local storage after web login. The web login is separate from the Android admin PIN.

View file

@ -1,3 +1,5 @@
node_modules
npm-debug.log
.DS_Store
data
.env

3
web/.env.example Normal file
View file

@ -0,0 +1,3 @@
CALORIE_AI_WEB_USER=admin
CALORIE_AI_WEB_PASSWORD=change-this-password
CALORIE_AI_SESSION_SECRET=change-this-session-secret

View file

@ -5,3 +5,5 @@ services:
restart: unless-stopped
ports:
- "127.0.0.1:8095:8080"
volumes:
- ./data:/app/data

View file

@ -3,7 +3,18 @@ const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
testDir: './tests',
timeout: 30000,
webServer: {
command: 'node server.js',
url: 'http://127.0.0.1:8096',
reuseExistingServer: !process.env.CI,
env: {
PORT: '8096',
CALORIE_AI_WEB_USER: 'admin',
CALORIE_AI_WEB_PASSWORD: 'test-password',
CALORIE_AI_SESSION_SECRET: 'test-session-secret',
},
},
use: {
baseURL: 'http://127.0.0.1:8095',
baseURL: 'http://127.0.0.1:8096',
},
});

View file

@ -5,6 +5,11 @@ const entriesKey = 'calorie-ai-web-entries';
let selectedImageDataUrl = '';
let selectedImageName = '';
function setText(id, value) {
const element = $(id);
if (element) element.textContent = value;
}
function todayKey() {
return dateKey(new Date());
}
@ -147,14 +152,14 @@ function drawMacroChart(total) {
ctx.stroke();
start += arc;
});
ctx.fillStyle = '#26372d';
ctx.fillStyle = '#17251d';
ctx.font = '700 20px sans-serif';
ctx.fillText('Today', 240, 88);
ctx.font = '15px sans-serif';
labels.forEach((label, index) => {
ctx.fillStyle = colors[index];
ctx.fillRect(240, 112 + index * 34, 14, 14);
ctx.fillStyle = '#26372d';
ctx.fillStyle = '#17251d';
ctx.fillText(label, 264, 125 + index * 34);
});
}
@ -207,6 +212,7 @@ function renderEntries() {
<span>${e.calories} kcal | P ${e.proteinGrams}g | C ${e.carbsGrams}g | F ${e.fatGrams}g</span>
<small>Fruit ${Number(e.fruitServings || 0).toFixed(1)} | Vegetables ${Number(e.vegetableServings || 0).toFixed(1)}${e.imageIncluded ? ' | image analyzed' : ''}</small>
<small>${escapeHtml(e.description || '')}</small>
${e.foodGroups ? `<small>Groups: ${escapeHtml(e.foodGroups)}</small>` : ''}
${e.notes ? `<small>${escapeHtml(e.notes)}</small>` : ''}
</article>`).join('');
}
@ -217,16 +223,33 @@ function escapeHtml(value) {
function render() {
const today = totalsFor(todayKey());
$('todayCalories').textContent = `${today.calories} kcal`;
$('todayMacroText').textContent = `P ${today.protein}g | C ${today.carbs}g | F ${today.fat}g`;
$('todayProduceText').textContent = `Fruit ${today.fruit.toFixed(1)} | Veg ${today.vegetables.toFixed(1)}`;
const week = last7Days();
const weekAverage = Math.round(week.reduce((sum, day) => sum + day.calories, 0) / week.length);
const produce = today.fruit + today.vegetables;
setText('todayCalories', `${today.calories} kcal`);
setText('todayMacroText', `P ${today.protein}g | C ${today.carbs}g | F ${today.fat}g`);
setText('todayProduceText', `Fruit ${today.fruit.toFixed(1)} | Veg ${today.vegetables.toFixed(1)}`);
setText('mealCountText', String(today.meals));
setText('weekAverageText', `${weekAverage} kcal`);
setText('produceGoalText', `${produce.toFixed(1)} / 5`);
drawMacroChart(today);
drawBars('calorieChart', week, 'calories');
drawBars('produceChart', week, 'produce');
renderEntries();
}
function showSection(sectionName) {
document.querySelectorAll('.section-page').forEach(section => {
section.hidden = section.dataset.section !== sectionName;
});
document.querySelectorAll('[data-nav]').forEach(button => {
button.classList.toggle('active', button.dataset.nav === sectionName);
});
document.body.classList.remove('menu-open');
}
function loadSettings() {
const cfg = settings();
$('baseUrl').value = cfg.baseUrl;
@ -244,6 +267,19 @@ $('saveSettings').addEventListener('click', () => {
});
});
document.querySelectorAll('[data-nav]').forEach(button => {
button.addEventListener('click', () => showSection(button.dataset.nav));
});
$('menuToggle').addEventListener('click', () => {
document.body.classList.toggle('menu-open');
});
$('logout').addEventListener('click', async () => {
await fetch('/api/logout', { method: 'POST' });
window.location.href = '/login.html';
});
$('image').addEventListener('change', async (event) => {
const file = event.target.files[0];
selectedImageDataUrl = file ? await fileToDataUrl(file) : '';

View file

@ -8,75 +8,155 @@
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main class="shell">
<section class="hero">
<div>
<p class="eyebrow">AI food diary</p>
<h1>Calorie AI</h1>
<p>Upload a meal image, add a portion note, and track calories, macros, fruit, and vegetables per day.</p>
<div class="app-frame">
<aside class="sidebar" id="sidebar">
<div class="brand-block">
<span class="brand-mark">CA</span>
<div>
<strong>Calorie AI</strong>
<small>Private food diary</small>
</div>
</div>
<div class="today-card">
<span>Today</span>
<strong id="todayCalories">0 kcal</strong>
<small id="todayMacroText">P 0g | C 0g | F 0g</small>
<small id="todayProduceText">Fruit 0.0 | Veg 0.0</small>
<nav class="main-nav" aria-label="Main menu">
<button class="nav-item active" type="button" data-nav="dashboard">Dashboard</button>
<button class="nav-item" type="button" data-nav="log">Log meal</button>
<button class="nav-item" type="button" data-nav="analytics">Analytics</button>
<button class="nav-item" type="button" data-nav="diary">Diary</button>
<button class="nav-item" type="button" data-nav="settings">AI settings</button>
</nav>
<div class="sidebar-footer">
<small>Protected by local web auth</small>
<button id="logout" class="ghost" type="button">Sign out</button>
</div>
</section>
</aside>
<section class="grid two">
<form id="mealForm" class="panel">
<h2>Add meal</h2>
<label>
Description
<textarea id="description" rows="4" placeholder="Example: grilled salmon, rice, broccoli, berries"></textarea>
</label>
<label>
Portion or measure
<input id="measure" placeholder="Example: one plate, 450g, 2 cups">
</label>
<label>
Meal image
<input id="image" type="file" accept="image/*">
</label>
<img id="preview" class="preview" alt="Selected meal preview" hidden>
<button type="submit">Analyze and save</button>
<p id="status" class="status"></p>
</form>
<div class="content-shell">
<header class="topbar">
<button id="menuToggle" class="menu-toggle" type="button" aria-label="Open menu">Menu</button>
<div>
<p class="eyebrow">AI nutrition cockpit</p>
<h1>Private food tracking with image-backed nutrition estimates</h1>
</div>
<button class="primary-action" type="button" data-nav="log">Add meal</button>
</header>
<section class="panel settings">
<h2>Admin AI settings</h2>
<label>API base URL<input id="baseUrl" placeholder="https://api.openai.com/v1"></label>
<label>API key<input id="apiKey" type="password" placeholder="Optional for local endpoints"></label>
<label>Vision model<input id="visionModel" placeholder="gpt-4o-mini"></label>
<label>Tasking model<input id="taskModel" placeholder="gpt-4o-mini"></label>
<button id="saveSettings" type="button">Save settings</button>
<p class="muted">The vision model estimates food and calories from images. The tasking model normalizes the final nutrition JSON. They can be the same model.</p>
</section>
</section>
<main class="content">
<section class="section-page" data-section="dashboard">
<div class="hero-grid">
<article class="hero-card">
<p class="eyebrow">Today</p>
<strong id="todayCalories">0 kcal</strong>
<span id="todayMacroText">P 0g | C 0g | F 0g</span>
<span id="todayProduceText">Fruit 0.0 | Veg 0.0</span>
</article>
<article class="metric-card">
<small>Meals logged</small>
<strong id="mealCountText">0</strong>
<span>Entries saved locally</span>
</article>
<article class="metric-card warm">
<small>7-day average</small>
<strong id="weekAverageText">0 kcal</strong>
<span>Calories per day</span>
</article>
<article class="metric-card cool">
<small>Produce today</small>
<strong id="produceGoalText">0.0 / 5</strong>
<span>Fruit + vegetable servings</span>
</article>
</div>
<section class="grid three">
<section class="panel chart-panel">
<h2>Macros today</h2>
<canvas id="macroChart" width="420" height="260"></canvas>
</section>
<section class="panel chart-panel wide">
<h2>Calories, last 7 days</h2>
<canvas id="calorieChart" width="720" height="260"></canvas>
</section>
<section class="panel chart-panel wide">
<h2>Fruit and vegetables</h2>
<canvas id="produceChart" width="720" height="240"></canvas>
</section>
</section>
<section class="panel split-panel">
<div>
<p class="eyebrow">Quick log</p>
<h2>Capture the meal while it is still in front of you.</h2>
<p>Upload a plate photo, add a portion note, and let the vision model hand context to the tasking model for clean nutrition JSON.</p>
<button type="button" data-nav="log">Open meal logger</button>
</div>
<canvas id="macroChart" width="420" height="260"></canvas>
</section>
</section>
<section class="panel">
<div class="row-title">
<h2>Food diary</h2>
<button id="clearData" class="secondary" type="button">Clear local diary</button>
</div>
<div id="entries" class="entries"></div>
</section>
</main>
<section class="section-page" data-section="log" hidden>
<form id="mealForm" class="panel meal-panel">
<div class="panel-heading">
<p class="eyebrow">Meal intake</p>
<h2>Add meal</h2>
<p>Text-only works, but images give the vision model more evidence for portion and produce estimates.</p>
</div>
<div class="form-grid">
<label class="span-2">
Description
<textarea id="description" rows="5" placeholder="Example: grilled salmon, rice, broccoli, berries"></textarea>
</label>
<label>
Portion or measure
<input id="measure" placeholder="Example: one plate, 450g, 2 cups">
</label>
<label>
Meal image
<input id="image" type="file" accept="image/*">
</label>
</div>
<img id="preview" class="preview" alt="Selected meal preview" hidden>
<div class="form-actions">
<button type="submit">Analyze and save</button>
<p id="status" class="status"></p>
</div>
</form>
</section>
<section class="section-page" data-section="analytics" hidden>
<div class="grid two analytics-grid">
<section class="panel chart-panel wide">
<div class="panel-heading compact">
<p class="eyebrow">Trend</p>
<h2>Calories, last 7 days</h2>
</div>
<canvas id="calorieChart" width="720" height="260"></canvas>
</section>
<section class="panel chart-panel wide">
<div class="panel-heading compact">
<p class="eyebrow">Produce</p>
<h2>Fruit and vegetables</h2>
</div>
<canvas id="produceChart" width="720" height="240"></canvas>
</section>
</div>
</section>
<section class="section-page" data-section="diary" hidden>
<section class="panel">
<div class="row-title">
<div>
<p class="eyebrow">Local history</p>
<h2>Food diary</h2>
</div>
<button id="clearData" class="secondary" type="button">Clear local diary</button>
</div>
<div id="entries" class="entries"></div>
</section>
</section>
<section class="section-page" data-section="settings" hidden>
<section class="panel settings-panel">
<div class="panel-heading">
<p class="eyebrow">Admin</p>
<h2>AI settings</h2>
<p>These settings stay in browser local storage. The API key is sent only to this server-side proxy, not directly to the model endpoint from the browser.</p>
</div>
<div class="form-grid">
<label class="span-2">API base URL<input id="baseUrl" placeholder="https://api.openai.com/v1"></label>
<label class="span-2">API key<input id="apiKey" type="password" placeholder="Optional for local endpoints"></label>
<label>Vision model<input id="visionModel" placeholder="gpt-4o-mini"></label>
<label>Tasking model<input id="taskModel" placeholder="gpt-4o-mini"></label>
</div>
<button id="saveSettings" type="button">Save settings</button>
</section>
</section>
</main>
</div>
</div>
<script src="/app.js"></script>
</body>
</html>

29
web/public/login.html Normal file
View file

@ -0,0 +1,29 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in - Calorie AI</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/styles.css">
</head>
<body class="login-body">
<main class="login-shell">
<section class="login-brand">
<p class="eyebrow">Private nutrition console</p>
<h1>Calorie AI</h1>
<p>Protected meal logging for calories, macros, fruit, vegetables, and AI image estimates.</p>
</section>
<form id="loginForm" class="login-card">
<span class="brand-mark">CA</span>
<h2>Sign in to Calorie AI</h2>
<p class="muted">Use the local web credentials configured on this host.</p>
<label>Username<input id="username" autocomplete="username" value="admin"></label>
<label>Password<input id="password" type="password" autocomplete="current-password" autofocus></label>
<button type="submit">Sign in</button>
<p id="loginStatus" class="status"></p>
</form>
</main>
<script src="/login.js"></script>
</body>
</html>

31
web/public/login.js Normal file
View file

@ -0,0 +1,31 @@
const loginForm = document.getElementById('loginForm');
const loginStatus = document.getElementById('loginStatus');
loginForm.addEventListener('submit', async (event) => {
event.preventDefault();
const button = loginForm.querySelector('button');
button.disabled = true;
loginStatus.textContent = 'Checking credentials...';
loginStatus.className = 'status ok';
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
username: document.getElementById('username').value.trim(),
password: document.getElementById('password').value,
}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Sign in failed' }));
throw new Error(error.error || 'Sign in failed');
}
window.location.href = '/';
} catch (error) {
loginStatus.textContent = error.message;
loginStatus.className = 'status error';
} finally {
button.disabled = false;
}
});

View file

@ -1,80 +1,246 @@
:root {
color-scheme: light;
--bg: #f8f4ec;
--ink: #26372d;
--muted: #5b635c;
--card: #ffffff;
--line: #e5ddcf;
--green: #2f7d59;
--orange: #da9648;
--blue: #6177c4;
--soft: #eef3ee;
--bg: #f3efe3;
--panel: rgba(255, 253, 247, .94);
--panel-strong: #fffaf0;
--ink: #17251d;
--muted: #667268;
--line: #e3d8c4;
--green: #236b4b;
--green-2: #39a36d;
--orange: #d5873a;
--blue: #5168bc;
--nav: #112018;
--nav-soft: #1c3427;
--danger: #a43434;
--shadow: 0 28px 80px rgba(41, 31, 17, .12);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: radial-gradient(circle at top left, #fff8df, transparent 30rem), var(--bg);
background:
radial-gradient(circle at 15% 10%, rgba(255, 226, 154, .65), transparent 28rem),
radial-gradient(circle at 80% 0%, rgba(79, 138, 104, .22), transparent 30rem),
var(--bg);
color: var(--ink);
}
.shell { width: min(1180px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0; }
.hero { display: grid; grid-template-columns: 1fr minmax(220px, 320px); gap: 20px; align-items: stretch; margin-bottom: 20px; }
.eyebrow { color: var(--green); font-weight: 800; letter-spacing: .12em; margin: 0 0 8px; text-transform: uppercase; }
h1 { font-size: clamp(2.7rem, 8vw, 6rem); line-height: .9; margin: 0; }
h2 { margin: 0 0 16px; font-size: 1.15rem; }
p { color: var(--muted); line-height: 1.5; }
.today-card, .panel {
border: 1px solid var(--line);
border-radius: 24px;
background: rgba(255,255,255,.88);
box-shadow: 0 24px 60px rgba(48, 38, 20, .08);
}
.today-card { padding: 24px; display: grid; align-content: center; gap: 8px; }
.today-card strong { font-size: 2.3rem; color: var(--green); }
.today-card small { color: var(--muted); }
.grid { display: grid; gap: 20px; margin-bottom: 20px; }
.two { grid-template-columns: 1.2fr .8fr; }
.three { grid-template-columns: minmax(260px, .8fr) minmax(320px, 1.1fr); }
.wide { grid-column: auto; }
.panel { padding: 22px; }
label { display: grid; gap: 7px; margin-bottom: 14px; color: var(--muted); font-weight: 700; }
input, textarea {
width: 100%;
border: 1px solid #dbd3c5;
border-radius: 14px;
background: #fcfaf6;
color: var(--ink);
font: inherit;
padding: 12px 13px;
}
textarea { resize: vertical; }
button, input, textarea { font: inherit; }
button {
border: 0;
border-radius: 14px;
background: var(--green);
border-radius: 16px;
background: linear-gradient(135deg, var(--green), var(--green-2));
color: white;
cursor: pointer;
font: inherit;
font-weight: 800;
padding: 12px 15px;
font-weight: 850;
padding: 13px 17px;
transition: transform .18s ease, box-shadow .18s ease, opacity .18s ease;
}
button.secondary { background: #e9e3d7; color: var(--ink); }
button:disabled { opacity: .55; cursor: wait; }
.preview { width: 100%; max-height: 280px; object-fit: cover; border-radius: 18px; margin: 4px 0 14px; border: 1px solid var(--line); }
.status { min-height: 1.5em; margin-bottom: 0; }
.status.error { color: #a43434; }
.status.ok { color: var(--green); }
.muted { color: var(--muted); font-size: .9rem; }
.chart-panel canvas { width: 100%; height: auto; }
.entries { display: grid; gap: 12px; }
.entry { border: 1px solid var(--line); border-radius: 18px; padding: 14px; background: #fffdf8; }
.entry strong { display: block; margin-bottom: 5px; }
.entry small { color: var(--muted); display: block; margin-top: 4px; }
.row-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
@media (max-width: 860px) {
.hero, .two, .three { grid-template-columns: 1fr; }
.shell { width: min(100% - 20px, 1180px); padding: 20px 0; }
button:hover { transform: translateY(-1px); box-shadow: 0 12px 28px rgba(35, 107, 75, .22); }
button:disabled { cursor: wait; opacity: .55; transform: none; box-shadow: none; }
button.secondary { background: #ebe1d0; color: var(--ink); }
button.ghost { width: 100%; background: rgba(255,255,255,.09); color: #f4ead6; box-shadow: none; }
input, textarea {
width: 100%;
border: 1px solid #d9ceb8;
border-radius: 16px;
background: #fffaf2;
color: var(--ink);
outline: none;
padding: 13px 14px;
}
input:focus, textarea:focus { border-color: var(--green-2); box-shadow: 0 0 0 4px rgba(57, 163, 109, .14); }
textarea { resize: vertical; }
label { display: grid; gap: 8px; color: var(--muted); font-weight: 800; }
h1, h2, p { margin-top: 0; }
h1 { max-width: 820px; margin-bottom: 0; font-size: clamp(2rem, 4vw, 4.2rem); line-height: .94; letter-spacing: -.06em; }
h2 { margin-bottom: 12px; font-size: clamp(1.35rem, 2vw, 2rem); letter-spacing: -.035em; }
p { color: var(--muted); line-height: 1.6; }
canvas { max-width: 100%; }
.app-frame { display: grid; grid-template-columns: 280px 1fr; min-height: 100vh; }
.sidebar {
position: sticky;
top: 0;
height: 100vh;
padding: 22px;
display: grid;
grid-template-rows: auto 1fr auto;
gap: 28px;
background: linear-gradient(180deg, var(--nav), #0b140f);
color: #f8ecd7;
}
.brand-block { display: flex; align-items: center; gap: 12px; }
.brand-block strong { display: block; font-size: 1.05rem; }
.brand-block small, .sidebar-footer small { color: rgba(248, 236, 215, .62); }
.brand-mark {
width: 48px;
height: 48px;
display: inline-grid;
place-items: center;
border-radius: 17px;
background: linear-gradient(135deg, #e7a24e, #3fb473);
color: #102017;
font-weight: 950;
letter-spacing: -.04em;
}
.main-nav { display: grid; align-content: start; gap: 9px; }
.nav-item {
width: 100%;
padding: 13px 14px;
border-radius: 15px;
background: transparent;
color: rgba(248, 236, 215, .72);
text-align: left;
box-shadow: none;
}
.nav-item:hover, .nav-item.active { background: var(--nav-soft); color: #fff4df; box-shadow: none; }
.sidebar-footer { display: grid; gap: 12px; }
.content-shell { min-width: 0; }
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 26px clamp(18px, 4vw, 48px);
border-bottom: 1px solid rgba(227, 216, 196, .7);
background: rgba(243, 239, 227, .82);
backdrop-filter: blur(20px);
}
.menu-toggle { display: none; background: #e9dfcc; color: var(--ink); box-shadow: none; }
.primary-action { white-space: nowrap; }
.content { width: min(1180px, calc(100% - 36px)); margin: 0 auto; padding: 28px 0 44px; }
.section-page { display: grid; gap: 22px; }
.eyebrow {
margin: 0 0 8px;
color: var(--green);
font-size: .76rem;
font-weight: 950;
letter-spacing: .14em;
text-transform: uppercase;
}
.hero-grid { display: grid; grid-template-columns: minmax(300px, 1.4fr) repeat(3, minmax(150px, .62fr)); gap: 18px; }
.hero-card, .metric-card, .panel, .login-card, .login-brand {
border: 1px solid rgba(227, 216, 196, .9);
border-radius: 28px;
background: var(--panel);
box-shadow: var(--shadow);
}
.hero-card {
min-height: 220px;
padding: 28px;
display: grid;
align-content: end;
background:
linear-gradient(135deg, rgba(22, 42, 30, .9), rgba(35, 107, 75, .84)),
radial-gradient(circle at top right, rgba(235, 173, 79, .6), transparent 20rem);
color: white;
}
.hero-card .eyebrow, .hero-card span { color: rgba(255,255,255,.78); }
.hero-card strong { display: block; margin-bottom: 8px; font-size: clamp(3rem, 8vw, 5.6rem); line-height: .9; letter-spacing: -.07em; }
.hero-card span { display: block; margin-top: 6px; }
.metric-card { padding: 22px; display: grid; align-content: end; gap: 8px; background: linear-gradient(180deg, #fffdf8, #f7efde); }
.metric-card small { color: var(--muted); font-weight: 850; text-transform: uppercase; letter-spacing: .08em; }
.metric-card strong { font-size: 2.2rem; line-height: 1; letter-spacing: -.05em; }
.metric-card span { color: var(--muted); font-size: .92rem; }
.metric-card.warm strong { color: var(--orange); }
.metric-card.cool strong { color: var(--blue); }
.panel { padding: clamp(20px, 3vw, 32px); }
.split-panel { display: grid; grid-template-columns: minmax(280px, .85fr) minmax(320px, 1fr); gap: 24px; align-items: center; }
.panel-heading { max-width: 760px; margin-bottom: 20px; }
.panel-heading.compact { margin-bottom: 8px; }
.grid { display: grid; gap: 20px; }
.grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.span-2 { grid-column: span 2; }
.form-actions { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 18px; }
.meal-panel { background: linear-gradient(135deg, rgba(255,253,247,.96), rgba(244, 233, 210, .94)); }
.preview { width: 100%; max-height: 360px; object-fit: cover; border: 1px solid var(--line); border-radius: 24px; margin-top: 18px; }
.status { min-height: 1.5em; margin: 0; font-weight: 800; }
.status.error { color: var(--danger); }
.status.ok { color: var(--green); }
.muted { color: var(--muted); font-size: .93rem; }
.chart-panel canvas, .split-panel canvas { width: 100%; height: auto; }
.row-title { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
.row-title h2 { margin-bottom: 0; }
.entries { display: grid; gap: 12px; }
.entry {
display: grid;
gap: 7px;
border: 1px solid var(--line);
border-radius: 20px;
padding: 16px;
background: var(--panel-strong);
}
.entry strong { font-size: 1.02rem; }
.entry small { color: var(--muted); }
.login-body { display: grid; place-items: center; padding: 24px; }
.login-shell { width: min(980px, 100%); display: grid; grid-template-columns: 1.1fr .9fr; gap: 22px; align-items: stretch; }
.login-brand {
min-height: 560px;
padding: clamp(28px, 5vw, 54px);
display: grid;
align-content: end;
background:
linear-gradient(135deg, rgba(15, 29, 21, .94), rgba(29, 88, 61, .86)),
radial-gradient(circle at top right, rgba(218, 150, 72, .82), transparent 22rem);
color: #fff4df;
}
.login-brand h1 { color: white; }
.login-brand p, .login-brand .eyebrow { color: rgba(255,244,223,.78); }
.login-card { padding: clamp(24px, 4vw, 40px); align-self: center; display: grid; gap: 16px; }
.login-card h2 { margin-bottom: 0; }
@media (max-width: 1050px) {
.app-frame { grid-template-columns: 1fr; }
.sidebar {
position: fixed;
left: 14px;
top: 14px;
bottom: 14px;
z-index: 30;
width: min(320px, calc(100vw - 28px));
height: auto;
border-radius: 26px;
transform: translateX(calc(-100% - 18px));
transition: transform .2s ease;
box-shadow: 0 30px 90px rgba(0,0,0,.34);
}
body.menu-open .sidebar { transform: translateX(0); }
.menu-toggle { display: inline-flex; }
.hero-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.hero-card { grid-column: span 2; }
.split-panel, .grid.two, .login-shell { grid-template-columns: 1fr; }
.login-brand { min-height: 360px; }
}
@media (max-width: 700px) {
.topbar { align-items: flex-start; padding: 18px; }
.primary-action { display: none; }
.content { width: min(100% - 22px, 1180px); padding-top: 18px; }
.hero-grid, .form-grid { grid-template-columns: 1fr; }
.hero-card, .span-2 { grid-column: auto; }
.row-title, .form-actions { align-items: stretch; flex-direction: column; }
.row-title button, .form-actions button { width: 100%; }
.login-body { padding: 12px; }
.login-brand { min-height: 260px; }
}

View file

@ -1,10 +1,16 @@
const http = require('http');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const port = Number(process.env.PORT || 8080);
const publicDir = path.join(__dirname, 'public');
const publicRoot = publicDir + path.sep;
const dataDir = process.env.CALORIE_AI_DATA_DIR || path.join(__dirname, 'data');
const authFile = path.join(dataDir, 'auth.json');
const sessionCookieName = 'calorie_ai_session';
const sessionSeconds = 60 * 60 * 24 * 7;
const auth = loadAuthConfig();
const types = {
'.html': 'text/html; charset=utf-8',
@ -29,14 +35,98 @@ function readBody(req) {
});
}
function send(res, status, body, contentType = 'application/json; charset=utf-8', headOnly = false) {
function loadAuthConfig() {
let stored = {};
try {
stored = JSON.parse(fs.readFileSync(authFile, 'utf8'));
} catch {
stored = {};
}
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');
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) {
fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(authFile, JSON.stringify({ user, password, sessionSecret, createdAt: new Date().toISOString() }, null, 2));
fs.chmodSync(authFile, 0o600);
console.log(`Calorie AI auth is stored at ${authFile}`);
}
return { user, password, sessionSecret };
}
function send(res, status, body, contentType = 'application/json; charset=utf-8', headOnly = false, headers = {}) {
res.writeHead(status, {
'content-type': contentType,
'cache-control': 'no-store',
'x-content-type-options': 'nosniff',
'referrer-policy': 'same-origin',
'x-frame-options': 'DENY',
'content-security-policy': "default-src 'self'; img-src 'self' data: blob:; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'",
...headers,
});
res.end(headOnly ? '' : body);
}
function redirect(res, location) {
res.writeHead(302, {
location,
'cache-control': 'no-store',
'referrer-policy': 'same-origin',
'x-frame-options': 'DENY',
});
res.end();
}
function parseCookies(req) {
return Object.fromEntries(String(req.headers.cookie || '').split(';').map(part => {
const index = part.indexOf('=');
if (index < 0) return ['', ''];
return [part.slice(0, index).trim(), decodeURIComponent(part.slice(index + 1).trim())];
}).filter(([key]) => key));
}
function safeEqual(left, right) {
const leftBuffer = Buffer.from(String(left));
const rightBuffer = Buffer.from(String(right));
return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer);
}
function sign(value) {
return crypto.createHmac('sha256', auth.sessionSecret).update(value).digest('base64url');
}
function createSession() {
const payload = Buffer.from(JSON.stringify({ user: auth.user, exp: Date.now() + sessionSeconds * 1000 })).toString('base64url');
return `${payload}.${sign(payload)}`;
}
function isAuthenticated(req) {
const token = parseCookies(req)[sessionCookieName];
if (!token) return false;
const [payload, signature] = token.split('.');
if (!payload || !signature || !safeEqual(sign(payload), signature)) return false;
try {
const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
return decoded.user === auth.user && decoded.exp > Date.now();
} catch {
return false;
}
}
function cookieOptions(req, maxAge = sessionSeconds) {
const secure = req.headers['x-forwarded-proto'] === 'https' || process.env.CALORIE_AI_COOKIE_SECURE === 'true';
return `HttpOnly; SameSite=Lax; Path=/; Max-Age=${maxAge}${secure ? '; Secure' : ''}`;
}
function publicRequest(req) {
const url = req.url.split('?')[0];
return (req.method === 'POST' && url === '/api/login')
|| ((req.method === 'GET' || req.method === 'HEAD') && ['/login.html', '/login.js', '/styles.css', '/favicon.svg'].includes(url));
}
function serveStatic(req, res) {
const cleanUrl = decodeURIComponent(req.url.split('?')[0]);
const file = cleanUrl === '/' ? '/index.html' : cleanUrl;
@ -71,7 +161,40 @@ async function proxyChat(req, res) {
}
}
async function login(req, res) {
try {
const payload = JSON.parse(await readBody(req));
if (!safeEqual(payload.username || '', auth.user) || !safeEqual(payload.password || '', auth.password)) {
return send(res, 401, JSON.stringify({ error: 'Invalid username or password' }));
}
send(res, 200, JSON.stringify({ ok: true, user: auth.user }), undefined, false, {
'set-cookie': `${sessionCookieName}=${encodeURIComponent(createSession())}; ${cookieOptions(req)}`,
});
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
}
function logout(req, res) {
send(res, 200, JSON.stringify({ ok: true }), undefined, false, {
'set-cookie': `${sessionCookieName}=; ${cookieOptions(req, 0)}`,
});
}
http.createServer((req, res) => {
const url = req.url.split('?')[0];
const authenticated = isAuthenticated(req);
if (!authenticated && !publicRequest(req)) {
if (url.startsWith('/api/')) return send(res, 401, JSON.stringify({ error: 'Authentication required' }));
return redirect(res, '/login.html');
}
if (authenticated && (req.method === 'GET' || req.method === 'HEAD') && url === '/login.html') return redirect(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 === 'GET' && url === '/api/session') return send(res, 200, JSON.stringify({ user: auth.user }));
if (req.method === 'POST' && req.url === '/api/chat') return proxyChat(req, res);
if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res);
send(res, 405, 'Method not allowed', 'text/plain; charset=utf-8');

View file

@ -5,55 +5,82 @@ const png = Buffer.from(
'base64',
);
test.beforeEach(async ({ page }) => {
async function login(page) {
await page.goto('/');
await page.evaluate(() => localStorage.clear());
await page.reload();
await expect(page).toHaveURL(/login\.html/);
await expect(page.getByRole('heading', { name: 'Sign in to Calorie AI' })).toBeVisible();
await page.locator('#password').fill('test-password');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('heading', { name: /Private food tracking/ })).toBeVisible();
}
test('requires authentication before loading the app', async ({ page, request }) => {
await page.goto('/');
await expect(page).toHaveURL(/login\.html/);
await expect(page.getByRole('heading', { name: 'Sign in to Calorie AI' })).toBeVisible();
const response = await request.post('/api/chat', { data: {} });
expect(response.status()).toBe(401);
});
test('loads the dashboard and validates missing meal input', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Calorie AI' })).toBeVisible();
await page.getByRole('button', { name: 'Analyze and save' }).click();
await expect(page.locator('#status')).toContainText('Describe the meal or upload an image.');
});
test('saves settings, uploads an image, logs nutrition, updates charts, and clears diary', async ({ page }) => {
let calls = 0;
await page.route('**/api/chat', async (route) => {
calls += 1;
const content = calls === 1
? '{"foods":["salmon","rice","broccoli","berries"],"calories":620,"proteinGrams":42,"carbsGrams":58,"fatGrams":22,"fruitServings":0.5,"vegetableServings":1.5}'
: '{"mealName":"Salmon rice bowl","calories":640,"proteinGrams":44,"carbsGrams":60,"fatGrams":23,"fruitServings":0.5,"vegetableServings":1.5,"foodGroups":"fish, grains, fruit, vegetables","notes":"Estimated from uploaded image and portion note."}';
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ choices: [{ message: { content } }] }),
});
test.describe('authenticated app', () => {
test.beforeEach(async ({ page }) => {
await login(page);
await page.evaluate(() => localStorage.clear());
await page.reload();
});
await page.locator('#baseUrl').fill('https://api.example.test/v1');
await page.locator('#visionModel').fill('vision-test-model');
await page.locator('#taskModel').fill('task-test-model');
await page.getByRole('button', { name: 'Save settings' }).click();
test('loads the dashboard, menu, and validates missing meal input', async ({ page }) => {
await expect(page.getByRole('navigation', { name: 'Main menu' })).toBeVisible();
await expect(page.locator('#todayCalories')).toHaveText('0 kcal');
await page.getByRole('button', { name: 'Log meal' }).click();
await page.getByRole('button', { name: 'Analyze and save' }).click();
await expect(page.locator('#status')).toContainText('Describe the meal or upload an image.');
});
await page.locator('#description').fill('salmon rice bowl with broccoli and berries');
await page.locator('#measure').fill('one dinner bowl');
await page.locator('#image').setInputFiles({ name: 'meal.png', mimeType: 'image/png', buffer: png });
await expect(page.locator('#preview')).toBeVisible();
test('saves settings, uploads an image, logs nutrition, updates charts, and clears diary', async ({ page }) => {
let calls = 0;
await page.route('**/api/chat', async (route) => {
calls += 1;
const content = calls === 1
? '{"foods":["salmon","rice","broccoli","berries"],"calories":620,"proteinGrams":42,"carbsGrams":58,"fatGrams":22,"fruitServings":0.5,"vegetableServings":1.5}'
: '{"mealName":"Salmon rice bowl","calories":640,"proteinGrams":44,"carbsGrams":60,"fatGrams":23,"fruitServings":0.5,"vegetableServings":1.5,"foodGroups":"fish, grains, fruit, vegetables","notes":"Estimated from uploaded image and portion note."}';
await page.getByRole('button', { name: 'Analyze and save' }).click();
await expect(page.locator('#status')).toContainText('Saved.');
await expect(page.locator('#todayCalories')).toHaveText('640 kcal');
await expect(page.locator('#todayMacroText')).toHaveText('P 44g | C 60g | F 23g');
await expect(page.locator('#todayProduceText')).toHaveText('Fruit 0.5 | Veg 1.5');
await expect(page.locator('.entry')).toContainText('Salmon rice bowl');
await expect(page.locator('#macroChart')).toBeVisible();
await expect(page.locator('#calorieChart')).toBeVisible();
await expect(page.locator('#produceChart')).toBeVisible();
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ choices: [{ message: { content } }] }),
});
});
page.once('dialog', dialog => dialog.accept());
await page.getByRole('button', { name: 'Clear local diary' }).click();
await expect(page.locator('#todayCalories')).toHaveText('0 kcal');
await expect(page.locator('#entries')).toContainText('No meals logged yet.');
await page.getByRole('button', { name: 'AI settings' }).click();
await page.locator('#baseUrl').fill('https://api.example.test/v1');
await page.locator('#visionModel').fill('vision-test-model');
await page.locator('#taskModel').fill('task-test-model');
await page.getByRole('button', { name: 'Save settings' }).click();
await page.getByRole('button', { name: 'Log meal' }).click();
await page.locator('#description').fill('salmon rice bowl with broccoli and berries');
await page.locator('#measure').fill('one dinner bowl');
await page.locator('#image').setInputFiles({ name: 'meal.png', mimeType: 'image/png', buffer: png });
await expect(page.locator('#preview')).toBeVisible();
await page.getByRole('button', { name: 'Analyze and save' }).click();
await expect(page.locator('#status')).toContainText('Saved.');
await expect(page.locator('#todayCalories')).toHaveText('640 kcal');
await expect(page.locator('#todayMacroText')).toHaveText('P 44g | C 60g | F 23g');
await expect(page.locator('#todayProduceText')).toHaveText('Fruit 0.5 | Veg 1.5');
await page.getByRole('button', { name: 'Analytics' }).click();
await expect(page.locator('#calorieChart')).toBeVisible();
await expect(page.locator('#produceChart')).toBeVisible();
await page.getByRole('button', { name: 'Diary', exact: true }).click();
await expect(page.locator('.entry')).toContainText('Salmon rice bowl');
page.once('dialog', dialog => dialog.accept());
await page.getByRole('button', { name: 'Clear local diary' }).click();
await expect(page.locator('#todayCalories')).toHaveText('0 kcal');
await expect(page.locator('#entries')).toContainText('No meals logged yet.');
});
});