Add release APK workflow and server plans
This commit is contained in:
parent
5fa4b59335
commit
f42e8c8784
7 changed files with 279 additions and 66 deletions
|
|
@ -1,5 +1,8 @@
|
|||
name: Android Release
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
|
|
@ -31,6 +34,25 @@ jobs:
|
|||
mkdir -p dist
|
||||
cp app/build/outputs/apk/debug/app-debug.apk "dist/calorie-ai-${GITHUB_REF_NAME}.apk"
|
||||
|
||||
- name: Publish Forgejo release asset
|
||||
env:
|
||||
FORGEJO_API: http://forgejo:3000/api/v1
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
apk="dist/calorie-ai-${GITHUB_REF_NAME}.apk"
|
||||
auth_header="Authorization: token ${TOKEN}"
|
||||
release_json="$(curl -fsS -H "$auth_header" "$FORGEJO_API/repos/${GITHUB_REPOSITORY}/releases/tags/${GITHUB_REF_NAME}" || true)"
|
||||
if [ -z "$release_json" ]; then
|
||||
release_json="$(node -e 'console.log(JSON.stringify({ tag_name: process.env.GITHUB_REF_NAME, name: `Calorie AI ${process.env.GITHUB_REF_NAME}`, body: "Installable Android debug APK for Calorie AI.", draft: false, prerelease: false }))' | curl -fsS -X POST -H "$auth_header" -H 'Content-Type: application/json' --data-binary @- "$FORGEJO_API/repos/${GITHUB_REPOSITORY}/releases")"
|
||||
fi
|
||||
release_id="$(printf '%s' "$release_json" | node -e 'let data=""; process.stdin.on("data", c => data += c); process.stdin.on("end", () => console.log(JSON.parse(data).id));')"
|
||||
asset_id="$(printf '%s' "$release_json" | node -e 'let data=""; process.stdin.on("data", c => data += c); process.stdin.on("end", () => { const release = JSON.parse(data); const asset = (release.assets || []).find(item => item.name === `calorie-ai-${process.env.GITHUB_REF_NAME}.apk`); if (asset) console.log(asset.id); });')"
|
||||
if [ -n "$asset_id" ]; then
|
||||
curl -fsS -X DELETE -H "$auth_header" "$FORGEJO_API/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets/${asset_id}"
|
||||
fi
|
||||
curl -fsS -X POST -H "$auth_header" -F "attachment=@${apk}" "$FORGEJO_API/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?name=calorie-ai-${GITHUB_REF_NAME}.apk"
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
|
|
|
|||
34
README.md
34
README.md
|
|
@ -1,16 +1,17 @@
|
|||
# Calorie AI
|
||||
|
||||
Native Android and Dockerized web calorie tracker with uploaded meal images and admin-configurable AI models.
|
||||
Native Android and Dockerized web calorie tracker with meal images, AI nutrition estimates, server-backed plans, and admin-configurable AI models.
|
||||
|
||||
## Features
|
||||
|
||||
- Add meals by description, portion estimate, and uploaded image.
|
||||
- Analyze meals through OpenAI-compatible chat completions.
|
||||
- Uses a vision model for food image calorie and portion estimates.
|
||||
- Uses a tasking model to normalize calories, protein, carbs, fat, fruit servings, vegetable servings, food groups, and notes.
|
||||
- Uses an advice model to normalize calories, protein, carbs, fat, fruit servings, vegetable servings, food groups, and notes.
|
||||
- Stores daily meal entries locally on Android or in browser local storage.
|
||||
- Stores generated web weight-loss plans on the server in `web/data/plans.json`.
|
||||
- Shows daily totals plus charts for macros, 7-day calories, and fruit/vegetable intake.
|
||||
- Admin settings control API base URL, API key, vision model, and tasking model. The two model fields can contain the same model name.
|
||||
- Admin settings control API base URL, API key, image model, and nutrition/advice model. The two model fields can contain the same model name.
|
||||
|
||||
## Android Build
|
||||
|
||||
|
|
@ -22,7 +23,30 @@ gradle :app:assembleDebug
|
|||
|
||||
Forgejo Actions builds the debug APK on every push to `main` and uploads it as the `calorie-ai-debug-apk` artifact.
|
||||
|
||||
Tagged versions also build an installable APK named `calorie-ai-vX.Y.Z.apk` through the Android Release workflow.
|
||||
Tagged versions also build an installable APK named `calorie-ai-vX.Y.Z.apk` and attach it to a Forgejo release through the Android Release workflow.
|
||||
|
||||
## Install With Obtainium
|
||||
|
||||
Use the Forgejo releases page as the source:
|
||||
|
||||
```text
|
||||
https://git.danvics.com/danvics/calorie-ai-android/releases
|
||||
```
|
||||
|
||||
If Obtainium asks for a direct APK URL, use the latest release asset URL pattern:
|
||||
|
||||
```text
|
||||
https://git.danvics.com/danvics/calorie-ai-android/releases/download/v0.1.1/calorie-ai-v0.1.1.apk
|
||||
```
|
||||
|
||||
Recommended Obtainium setup:
|
||||
|
||||
- App source: `HTML` or `Gitea/Forgejo` if your Obtainium version offers it.
|
||||
- URL: `https://git.danvics.com/danvics/calorie-ai-android/releases`
|
||||
- APK link filter: `calorie-ai-.*\.apk`
|
||||
- Version extraction: release tag, for example `v0.1.1`.
|
||||
|
||||
The APK is a debug build, so Android may require allowing installs from Obtainium and accepting the debug signing certificate.
|
||||
|
||||
## Web Frontend
|
||||
|
||||
|
|
@ -58,4 +82,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 after web login. The web login is separate from the Android admin PIN.
|
||||
The web frontend stores AI settings in browser local storage after web login, while generated plan versions are stored server-side. The web login is separate from the Android admin PIN.
|
||||
|
|
|
|||
136
web/server.js
136
web/server.js
|
|
@ -11,6 +11,7 @@ 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 modelsFile = path.join(dataDir, 'models.json');
|
||||
const plansFile = path.join(dataDir, 'plans.json');
|
||||
const sessionCookieName = 'calorie_ai_session';
|
||||
const sessionSeconds = 60 * 60 * 24 * 7;
|
||||
let auth;
|
||||
|
|
@ -136,7 +137,66 @@ function tokenHash(token) {
|
|||
return crypto.createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
async function sendMail(to, subject, text) {
|
||||
function escHtml(value) {
|
||||
return String(value || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function emailWrapper(body) {
|
||||
const siteName = 'Calorie AI';
|
||||
const font = '-apple-system,BlinkMacSystemFont,\'Segoe UI\',Helvetica,Arial,sans-serif';
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>${escHtml(siteName)}</title>
|
||||
<style>
|
||||
body,table,td,p,a{font-family:${font};}
|
||||
@media only screen and (max-width:600px){.outer{padding:24px 16px !important;}}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#ffffff;-webkit-text-size-adjust:100%;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td class="outer" align="center" style="padding:48px 24px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="max-width:520px;width:100%;">
|
||||
<tr><td style="padding-bottom:32px;border-bottom:1px solid #e5e7eb;">
|
||||
<p style="margin:0;font-size:14px;font-weight:600;color:#111827;letter-spacing:-0.1px;">${escHtml(siteName)}</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:32px 0;">${body}</td></tr>
|
||||
<tr><td style="padding-top:24px;border-top:1px solid #e5e7eb;">
|
||||
<p style="margin:0;font-size:12px;color:#9ca3af;line-height:1.7;">
|
||||
${escHtml(siteName)} — meal tracking and weight-loss planning<br>
|
||||
If you didn’t request this, you can safely ignore it.
|
||||
</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function buttonHtml(href, label) {
|
||||
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:24px 0;">
|
||||
<tr><td style="border-radius:5px;background:#111827;">
|
||||
<a href="${escHtml(href)}" target="_blank" style="display:inline-block;color:#ffffff;padding:11px 22px;border-radius:5px;text-decoration:none;font-weight:500;font-size:14px;line-height:1;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">${escHtml(label)}</a>
|
||||
</td></tr>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function linkFallback(url) {
|
||||
return `<p style="margin:16px 0 4px;font-size:13px;color:#6b7280;line-height:1.5;">If the button doesn’t work, copy this link:</p>
|
||||
<p style="margin:0;padding:10px 12px;background:#f9fafb;border:1px solid #e5e7eb;border-radius:4px;font-size:12px;font-family:'SFMono-Regular',Consolas,'Liberation Mono',Menlo,monospace;word-break:break-all;color:#374151;line-height:1.6;">${escHtml(url)}</p>`;
|
||||
}
|
||||
|
||||
function actionEmailHtml(title, body, link, label) {
|
||||
return emailWrapper(`<p style="margin:0 0 8px;font-size:20px;font-weight:600;color:#111827;">${escHtml(title)}</p>
|
||||
<p style="color:#4b5563;margin:12px 0 20px;line-height:1.6;font-size:14px;">${escHtml(body).replace(/\n/g, '<br>')}</p>
|
||||
${buttonHtml(link, label)}
|
||||
${linkFallback(link)}`);
|
||||
}
|
||||
|
||||
async function sendMail(to, subject, text, html = '') {
|
||||
const config = mailConfig();
|
||||
if (!config.configured) throw new Error('SMTP is not configured');
|
||||
const transporter = nodemailer.createTransport({
|
||||
|
|
@ -145,7 +205,7 @@ async function sendMail(to, subject, text) {
|
|||
secure: config.secure,
|
||||
auth: config.user || config.pass ? { user: config.user, pass: config.pass } : undefined,
|
||||
});
|
||||
await transporter.sendMail({ from: config.from, to, subject, text });
|
||||
await transporter.sendMail({ from: config.from, to, subject, text, html: html || undefined });
|
||||
}
|
||||
|
||||
function loadModelConfig() {
|
||||
|
|
@ -163,6 +223,58 @@ function saveModelConfig(config = modelConfig) {
|
|||
writePrivateJson(modelsFile, { ...config, updatedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
function loadPlanConfig() {
|
||||
const stored = readJsonFile(plansFile, {});
|
||||
return {
|
||||
plans: Array.isArray(stored.plans) ? stored.plans : [],
|
||||
selectedPlanId: stored.selectedPlanId || '',
|
||||
};
|
||||
}
|
||||
|
||||
function savePlanConfig(config) {
|
||||
writePrivateJson(plansFile, { ...config, updatedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
async function getPlans(req, res) {
|
||||
send(res, 200, JSON.stringify(loadPlanConfig()));
|
||||
}
|
||||
|
||||
async function createPlan(req, res) {
|
||||
const payload = JSON.parse(await readBody(req) || '{}');
|
||||
if (!payload.content) return send(res, 400, JSON.stringify({ error: 'Plan content required' }));
|
||||
const config = loadPlanConfig();
|
||||
const plan = {
|
||||
id: payload.id || crypto.randomUUID(),
|
||||
title: String(payload.title || `Weight-loss plan ${config.plans.length + 1}`),
|
||||
version: Number(payload.version || Math.max(0, ...config.plans.map(item => Number(item.version || 0))) + 1),
|
||||
content: String(payload.content),
|
||||
previousPlanId: payload.previousPlanId || undefined,
|
||||
createdAt: payload.createdAt || new Date().toISOString(),
|
||||
};
|
||||
config.plans = [plan, ...config.plans];
|
||||
config.selectedPlanId = plan.id;
|
||||
savePlanConfig(config);
|
||||
send(res, 200, JSON.stringify(config));
|
||||
}
|
||||
|
||||
async function selectPlanRoute(req, res) {
|
||||
const payload = JSON.parse(await readBody(req) || '{}');
|
||||
const config = loadPlanConfig();
|
||||
if (payload.id && !config.plans.some(plan => plan.id === payload.id)) return send(res, 404, JSON.stringify({ error: 'Plan not found' }));
|
||||
config.selectedPlanId = payload.id || '';
|
||||
savePlanConfig(config);
|
||||
send(res, 200, JSON.stringify(config));
|
||||
}
|
||||
|
||||
async function deletePlanRoute(req, res) {
|
||||
const payload = JSON.parse(await readBody(req) || '{}');
|
||||
const config = loadPlanConfig();
|
||||
config.plans = config.plans.filter(plan => plan.id !== payload.id);
|
||||
if (config.selectedPlanId === payload.id) config.selectedPlanId = config.plans[0]?.id || '';
|
||||
savePlanConfig(config);
|
||||
send(res, 200, JSON.stringify(config));
|
||||
}
|
||||
|
||||
function dedupeModels(models) {
|
||||
return Array.from(new Map((models || []).filter(model => model && model.id).map(model => [model.id, {
|
||||
id: String(model.id),
|
||||
|
|
@ -380,7 +492,7 @@ 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' }));
|
||||
if (!payload.newPassword || payload.newPassword.length < 8) return send(res, 400, JSON.stringify({ error: 'Use at least 8 characters for the new password' }));
|
||||
auth.passwordHash = await hashPassword(payload.newPassword);
|
||||
saveAuthConfig({ passwordHash: auth.passwordHash, resetTokenHash: undefined, resetExpiresAt: undefined });
|
||||
send(res, 200, JSON.stringify({ ok: true }));
|
||||
|
|
@ -395,7 +507,9 @@ async function resetPassword(req, res) {
|
|||
const token = crypto.randomBytes(32).toString('base64url');
|
||||
saveAuthConfig({ resetTokenHash: tokenHash(token), resetExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() });
|
||||
const link = `${appOrigin(req)}/reset?token=${encodeURIComponent(token)}`;
|
||||
await sendMail(auth.email, 'Calorie AI password reset', `Use this link to reset your Calorie AI password. It expires in 1 hour.\n\n${link}`);
|
||||
const text = `Use this link to reset your Calorie AI password. It expires in 1 hour.\n\n${link}`;
|
||||
const html = actionEmailHtml('Password reset request', 'Someone requested a password reset for your Calorie AI account. If that was you, click the button below to choose a new password. This link expires in 1 hour. If you did not request this, no action is needed.', link, 'Reset My Password');
|
||||
await sendMail(auth.email, 'Reset your Calorie AI password', text, html);
|
||||
send(res, 200, JSON.stringify({ ok: true, message: 'Reset email sent.' }));
|
||||
} catch (error) {
|
||||
send(res, 400, JSON.stringify({ error: error.message }));
|
||||
|
|
@ -413,7 +527,9 @@ async function requestPasswordReset(req, res) {
|
|||
const token = crypto.randomBytes(32).toString('base64url');
|
||||
saveAuthConfig({ resetTokenHash: tokenHash(token), resetExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() });
|
||||
const link = `${appOrigin(req)}/reset?token=${encodeURIComponent(token)}`;
|
||||
await sendMail(auth.email, 'Calorie AI password reset', `Use this link to reset your Calorie AI password. It expires in 1 hour.\n\n${link}`);
|
||||
const text = `Use this link to reset your Calorie AI password. It expires in 1 hour.\n\n${link}`;
|
||||
const html = actionEmailHtml('Password reset request', 'Someone requested a password reset for your Calorie AI account. If that was you, click the button below to choose a new password. This link expires in 1 hour. If you did not request this, no action is needed.', link, 'Reset My Password');
|
||||
await sendMail(auth.email, 'Reset your Calorie AI password', text, html);
|
||||
send(res, 200, JSON.stringify({ ok: true, message: 'If the account exists, a reset email was sent.' }));
|
||||
} catch (error) {
|
||||
send(res, 400, JSON.stringify({ error: error.message }));
|
||||
|
|
@ -423,7 +539,7 @@ async function requestPasswordReset(req, res) {
|
|||
async function confirmPasswordReset(req, res) {
|
||||
try {
|
||||
const payload = JSON.parse(await readBody(req) || '{}');
|
||||
if (!payload.newPassword || payload.newPassword.length < 12) return send(res, 400, JSON.stringify({ error: 'Use at least 12 characters for the new password' }));
|
||||
if (!payload.newPassword || payload.newPassword.length < 8) return send(res, 400, JSON.stringify({ error: 'Use at least 8 characters for the new password' }));
|
||||
const valid = auth.resetTokenHash && auth.resetExpiresAt && new Date(auth.resetExpiresAt).getTime() > Date.now() && safeEqual(auth.resetTokenHash, tokenHash(payload.token || ''));
|
||||
if (!valid) return send(res, 400, JSON.stringify({ error: 'Reset link is invalid or expired' }));
|
||||
const passwordHash = await hashPassword(payload.newPassword);
|
||||
|
|
@ -452,7 +568,9 @@ async function sendVerification(req, res) {
|
|||
const token = crypto.randomBytes(32).toString('base64url');
|
||||
saveAuthConfig({ verificationTokenHash: tokenHash(token), verificationExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() });
|
||||
const link = `${appOrigin(req)}/verify?token=${encodeURIComponent(token)}`;
|
||||
await sendMail(auth.email, 'Verify your Calorie AI account', `Use this link to verify your Calorie AI account email. It expires in 24 hours.\n\n${link}`);
|
||||
const text = `Use this link to verify your Calorie AI account email. It expires in 24 hours.\n\n${link}`;
|
||||
const html = actionEmailHtml('Verify your email', 'Please verify your Calorie AI account email by clicking the button below. This link expires in 24 hours.', link, 'Verify My Email');
|
||||
await sendMail(auth.email, 'Verify your Calorie AI account', text, html);
|
||||
send(res, 200, JSON.stringify({ ok: true, message: 'Verification email sent.' }));
|
||||
} catch (error) {
|
||||
send(res, 400, JSON.stringify({ error: error.message }));
|
||||
|
|
@ -536,6 +654,10 @@ async function start() {
|
|||
app.post('/api/models/search', wrap(searchModels));
|
||||
app.post('/api/models/add', wrap(addModel));
|
||||
app.post('/api/models/remove', wrap(removeModel));
|
||||
app.get('/api/plans', wrap(getPlans));
|
||||
app.post('/api/plans', wrap(createPlan));
|
||||
app.post('/api/plans/select', wrap(selectPlanRoute));
|
||||
app.post('/api/plans/delete', wrap(deletePlanRoute));
|
||||
app.post('/api/chat', wrap(proxyChat));
|
||||
app.use((req, res) => {
|
||||
if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res);
|
||||
|
|
|
|||
|
|
@ -10,8 +10,6 @@
|
|||
const entriesKey = 'calorie-ai-web-entries';
|
||||
const trashKey = 'calorie-ai-web-trash';
|
||||
const settingsKey = 'calorie-ai-web-settings';
|
||||
const plansKey = 'calorie-ai-web-plans';
|
||||
const selectedPlanKey = 'calorie-ai-web-selected-plan';
|
||||
const pages = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: '⌂', group: 'Tracker' },
|
||||
{ id: 'log', label: 'Log meal', icon: '+', group: 'Tracker' },
|
||||
|
|
@ -115,14 +113,13 @@
|
|||
onMount(async () => {
|
||||
entries = readJson(entriesKey, []);
|
||||
trash = readJson(trashKey, []);
|
||||
plans = readJson(plansKey, []);
|
||||
selectedPlanId = localStorage.getItem(selectedPlanKey) || plans[0]?.id || '';
|
||||
settings = { ...settings, ...readJson(settingsKey, settings) };
|
||||
await loadSession();
|
||||
if (location.pathname === '/verify' && resetToken) await verifyAccountToken();
|
||||
page = localStorage.getItem('calorie_ai_last_tab') || 'dashboard';
|
||||
setDefaultMealDateTime();
|
||||
await loadModels();
|
||||
await loadPlans();
|
||||
await drawCharts();
|
||||
});
|
||||
|
||||
|
|
@ -190,11 +187,6 @@
|
|||
localStorage.setItem('calorie_ai_last_tab', id);
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
if (window.matchMedia('(min-width: 1024px)').matches) sidebarCollapsed = !sidebarCollapsed;
|
||||
else menuOpen = true;
|
||||
}
|
||||
|
||||
async function login() {
|
||||
loginStatus = 'Checking credentials...';
|
||||
loginError = false;
|
||||
|
|
@ -296,12 +288,26 @@
|
|||
saveJson(settingsKey, settings);
|
||||
}
|
||||
|
||||
function savePlans(nextPlans = plans, nextSelectedPlanId = selectedPlanId) {
|
||||
plans = nextPlans;
|
||||
selectedPlanId = nextSelectedPlanId || plans[0]?.id || '';
|
||||
saveJson(plansKey, plans);
|
||||
if (selectedPlanId) localStorage.setItem(selectedPlanKey, selectedPlanId);
|
||||
else localStorage.removeItem(selectedPlanKey);
|
||||
function applyPlanConfig(config) {
|
||||
plans = config.plans || [];
|
||||
selectedPlanId = config.selectedPlanId || plans[0]?.id || '';
|
||||
}
|
||||
|
||||
async function loadPlans() {
|
||||
try {
|
||||
const response = await fetch('/api/plans');
|
||||
if (response.ok) applyPlanConfig(await response.json());
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function savePlan(plan) {
|
||||
const response = await fetch('/api/plans', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(plan),
|
||||
});
|
||||
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || 'Plan save failed.');
|
||||
applyPlanConfig(await response.json());
|
||||
}
|
||||
|
||||
function emptyEstimate() {
|
||||
|
|
@ -668,7 +674,7 @@ Image estimate: ${imageEstimate}` },
|
|||
],
|
||||
});
|
||||
const plan = { id: entryId(), title: `Weight-loss plan ${plans.length + 1}`, version: plans.length + 1, content, createdAt: new Date().toISOString() };
|
||||
savePlans([plan, ...plans], plan.id);
|
||||
await savePlan(plan);
|
||||
planStatus = 'Plan generated.';
|
||||
saveSettings();
|
||||
} catch (error) {
|
||||
|
|
@ -701,7 +707,7 @@ Image estimate: ${imageEstimate}` },
|
|||
});
|
||||
const version = Math.max(0, ...plans.map(plan => Number(plan.version || 0))) + 1;
|
||||
const updatedPlan = { id: entryId(), title: `Updated plan ${version}`, version, content, createdAt: new Date().toISOString(), previousPlanId: currentPlan.id };
|
||||
savePlans([updatedPlan, ...plans], updatedPlan.id);
|
||||
await savePlan(updatedPlan);
|
||||
tuneNote = '';
|
||||
planStatus = 'Plan updated.';
|
||||
} catch (error) {
|
||||
|
|
@ -712,14 +718,23 @@ Image estimate: ${imageEstimate}` },
|
|||
}
|
||||
}
|
||||
|
||||
function selectPlan(planId) {
|
||||
async function selectPlan(planId) {
|
||||
selectedPlanId = planId;
|
||||
localStorage.setItem(selectedPlanKey, planId);
|
||||
const response = await fetch('/api/plans/select', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: planId }),
|
||||
});
|
||||
if (response.ok) applyPlanConfig(await response.json());
|
||||
}
|
||||
|
||||
function deletePlan(planId) {
|
||||
const nextPlans = plans.filter(plan => plan.id !== planId);
|
||||
savePlans(nextPlans, selectedPlanId === planId ? nextPlans[0]?.id || '' : selectedPlanId);
|
||||
async function deletePlan(planId) {
|
||||
const response = await fetch('/api/plans/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: planId }),
|
||||
});
|
||||
if (response.ok) applyPlanConfig(await response.json());
|
||||
}
|
||||
|
||||
async function drawCharts() {
|
||||
|
|
@ -808,20 +823,21 @@ Image estimate: ${imageEstimate}` },
|
|||
<div class="mb-6 flex h-12 w-12 items-center justify-center rounded-2xl bg-blue-600 font-black text-white">CA</div>
|
||||
<h2 class="text-2xl font-bold">Reset password</h2>
|
||||
<label class="mt-6 block text-sm font-semibold text-slate-700">New password
|
||||
<input class="mt-2 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-100" bind:value={resetNewPassword} type="password" autocomplete="new-password" minlength="12">
|
||||
<input class="mt-2 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-100" bind:value={resetNewPassword} type="password" autocomplete="new-password" minlength="8">
|
||||
</label>
|
||||
<label class="mt-4 block text-sm font-semibold text-slate-700">Confirm password
|
||||
<input class="mt-2 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-100" bind:value={resetConfirmPassword} type="password" autocomplete="new-password" minlength="12">
|
||||
<input class="mt-2 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-100" bind:value={resetConfirmPassword} type="password" autocomplete="new-password" minlength="8">
|
||||
</label>
|
||||
<button class="mt-6 w-full rounded-xl bg-blue-600 px-4 py-3 font-bold text-white hover:bg-blue-700" type="submit">Set new password</button>
|
||||
{#if resetStatus}<p class:!text-red-600={resetError} class="mt-4 text-sm font-semibold text-blue-700">{resetStatus}</p>{/if}
|
||||
{#if resetStatus && !resetError}<a class="mt-4 inline-block w-full rounded-xl border border-slate-300 px-4 py-3 text-center font-bold text-slate-700 hover:border-blue-500 hover:text-blue-700" href="/login">Go to sign in</a>{/if}
|
||||
</form>
|
||||
{:else if location.pathname === '/verify'}
|
||||
<section class="rounded-3xl border border-slate-200 bg-white p-8 shadow-xl lg:p-10">
|
||||
<div class="mb-6 flex h-12 w-12 items-center justify-center rounded-2xl bg-blue-600 font-black text-white">CA</div>
|
||||
<h2 class="text-2xl font-bold">Verify email</h2>
|
||||
{#if verifyStatus}<p class:!text-red-600={verifyError} class="mt-4 text-sm font-semibold text-blue-700">{verifyStatus}</p>{/if}
|
||||
<a class="mt-6 inline-block rounded-xl bg-blue-600 px-4 py-3 font-bold text-white hover:bg-blue-700" href="/login">Go to sign in</a>
|
||||
<a class="mt-6 inline-block w-full rounded-xl bg-blue-600 px-4 py-3 text-center font-bold text-white hover:bg-blue-700" href="/login">Go to sign in</a>
|
||||
</section>
|
||||
{:else}
|
||||
<form class="rounded-3xl border border-slate-200 bg-white p-8 shadow-xl lg:p-10" on:submit|preventDefault={login}>
|
||||
|
|
@ -848,28 +864,18 @@ Image estimate: ${imageEstimate}` },
|
|||
</div>
|
||||
</main>
|
||||
{:else}
|
||||
<div class="min-h-screen bg-slate-50 text-slate-900">
|
||||
<header class="sticky top-0 z-30 bg-gradient-to-r from-blue-600 to-indigo-700 text-white shadow-sm">
|
||||
<div class="flex items-center justify-between gap-3 px-4 py-3 lg:px-6">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<button class="grid h-10 w-10 place-items-center rounded-xl bg-white/15 hover:bg-white/25" type="button" on:click={toggleMenu} aria-label="Toggle menu">☰</button>
|
||||
<div class="grid h-11 w-11 place-items-center rounded-2xl bg-white text-sm font-black text-blue-700">CA</div>
|
||||
<div class="min-w-0">
|
||||
<h1 class="truncate text-lg font-bold">Calorie AI</h1>
|
||||
<p class="text-xs text-blue-100">Food diary</p>
|
||||
<div class="min-h-screen bg-slate-50 text-slate-900 lg:grid lg:grid-cols-[auto_1fr]">
|
||||
<aside class:translate-x-0={menuOpen} class:collapsed-sidebar={sidebarCollapsed} 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-all duration-200 lg:sticky lg:top-0 lg:h-screen lg:translate-x-0 lg:shadow-none lg:w-60">
|
||||
<div class="sidebar-header flex h-[68px] items-center justify-between border-b border-slate-200 px-4">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<div class="sidebar-logo grid h-11 w-11 flex-none place-items-center rounded-2xl bg-blue-600 text-sm font-black text-white">CA</div>
|
||||
<div class="sidebar-brand min-w-0">
|
||||
<h1 class="truncate text-lg font-black text-slate-950">Calorie AI</h1>
|
||||
<p class="text-xs font-semibold text-slate-500">Food diary</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<div class="flex">
|
||||
<aside class:translate-x-0={menuOpen} class:collapsed-sidebar={sidebarCollapsed} 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-all 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">
|
||||
<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="collapse-button hidden h-10 w-10 place-items-center rounded-xl bg-slate-100 text-slate-700 hover:bg-slate-200 lg:grid" type="button" on:click={() => sidebarCollapsed = !sidebarCollapsed} aria-label="Collapse menu">☰</button>
|
||||
<button class="rounded-lg bg-slate-100 px-3 py-1.5 text-sm font-bold lg:hidden" type="button" on:click={() => menuOpen = false}>Close</button>
|
||||
</div>
|
||||
<nav class="p-2" aria-label="Main menu">
|
||||
{#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}
|
||||
|
|
@ -877,8 +883,21 @@ Image estimate: ${imageEstimate}` },
|
|||
{#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" 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>
|
||||
</aside>
|
||||
{#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}
|
||||
</aside>
|
||||
{#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}
|
||||
|
||||
<div class="min-w-0">
|
||||
<header class="sticky top-0 z-20 border-b border-slate-200 bg-white/95 shadow-sm backdrop-blur">
|
||||
<div class="flex h-[68px] items-center justify-between gap-3 px-4 lg:px-6">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<button class="grid h-10 w-10 place-items-center rounded-xl bg-blue-600 text-white hover:bg-blue-700 lg:hidden" type="button" on:click={() => menuOpen = true} aria-label="Open menu">☰</button>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-lg font-black text-slate-950">{pages.find(item => item.id === page)?.label || 'Dashboard'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-bold text-slate-700 hover:border-blue-300 hover:text-blue-700" type="button" on:click={logout}>Sign out</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="min-w-0 flex-1 p-4 lg:p-6">
|
||||
{#if page === 'dashboard'}
|
||||
|
|
@ -1002,6 +1021,8 @@ Image estimate: ${imageEstimate}` },
|
|||
.active-tab { border-left-color: #2563eb; background: #dbeafe; color: #2563eb; }
|
||||
.tab-icon { display: grid; width: 1.35rem; height: 1.35rem; flex: 0 0 1.35rem; place-items: center; font-weight: 900; }
|
||||
:global(.collapsed-sidebar) { width: 4.75rem !important; }
|
||||
:global(.collapsed-sidebar .tab-label), :global(.collapsed-sidebar .sidebar-section-label) { display: none; }
|
||||
:global(.collapsed-sidebar .sidebar-header) { justify-content: center; padding-left: 0; padding-right: 0; }
|
||||
:global(.collapsed-sidebar .tab-label), :global(.collapsed-sidebar .sidebar-section-label), :global(.collapsed-sidebar .sidebar-brand), :global(.collapsed-sidebar .sidebar-logo) { display: none; }
|
||||
:global(.collapsed-sidebar .tab-button) { justify-content: center; padding-left: 0.75rem; padding-right: 0.75rem; }
|
||||
:global(.collapsed-sidebar .collapse-button) { height: 2.5rem; width: 2.5rem; border-radius: 0.75rem; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@
|
|||
<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>
|
||||
|
|
@ -32,7 +31,7 @@
|
|||
<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>
|
||||
<Field label="Meal image"><input id="image" class="input" type="file" accept="image/*" 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}
|
||||
|
|
|
|||
|
|
@ -46,8 +46,8 @@
|
|||
<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>
|
||||
<Field label="New password"><input id="newPassword" class="input" type="password" bind:value={newPassword} autocomplete="new-password" minlength="8"></Field>
|
||||
<Field label="Confirm new password"><input id="confirmPassword" class="input" type="password" bind:value={confirmPassword} autocomplete="new-password" minlength="8"></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}>Email reset link</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}
|
||||
|
|
@ -73,7 +73,7 @@
|
|||
<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}
|
||||
{#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}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -50,8 +50,33 @@ async function mockModels(page) {
|
|||
});
|
||||
}
|
||||
|
||||
async function mockPlans(page) {
|
||||
let plans = [];
|
||||
let selectedPlanId = '';
|
||||
await page.route('**/api/plans', async (route) => {
|
||||
if (route.request().method() === 'GET') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ plans, selectedPlanId }) });
|
||||
}
|
||||
const plan = route.request().postDataJSON();
|
||||
plans = [plan, ...plans];
|
||||
selectedPlanId = plan.id;
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ plans, selectedPlanId }) });
|
||||
});
|
||||
await page.route('**/api/plans/select', async (route) => {
|
||||
selectedPlanId = route.request().postDataJSON().id || '';
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ plans, selectedPlanId }) });
|
||||
});
|
||||
await page.route('**/api/plans/delete', async (route) => {
|
||||
const id = route.request().postDataJSON().id;
|
||||
plans = plans.filter(plan => plan.id !== id);
|
||||
if (selectedPlanId === id) selectedPlanId = plans[0]?.id || '';
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ plans, selectedPlanId }) });
|
||||
});
|
||||
}
|
||||
|
||||
async function login(page) {
|
||||
await mockModels(page);
|
||||
await mockPlans(page);
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
|
||||
|
|
|
|||
Loading…
Reference in a new issue