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
|
name: Android Release
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
|
|
@ -31,6 +34,25 @@ jobs:
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
cp app/build/outputs/apk/debug/app-debug.apk "dist/calorie-ai-${GITHUB_REF_NAME}.apk"
|
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
|
- name: Upload APK artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
34
README.md
34
README.md
|
|
@ -1,16 +1,17 @@
|
||||||
# Calorie AI
|
# 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
|
## Features
|
||||||
|
|
||||||
- Add meals by description, portion estimate, and uploaded image.
|
- Add meals by description, portion estimate, and uploaded image.
|
||||||
- Analyze meals through OpenAI-compatible chat completions.
|
- Analyze meals through OpenAI-compatible chat completions.
|
||||||
- Uses a vision model for food image calorie and portion estimates.
|
- 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 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.
|
- 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
|
## 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.
|
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
|
## 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.
|
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 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 modelsFile = path.join(dataDir, 'models.json');
|
||||||
|
const plansFile = path.join(dataDir, 'plans.json');
|
||||||
const sessionCookieName = 'calorie_ai_session';
|
const sessionCookieName = 'calorie_ai_session';
|
||||||
const sessionSeconds = 60 * 60 * 24 * 7;
|
const sessionSeconds = 60 * 60 * 24 * 7;
|
||||||
let auth;
|
let auth;
|
||||||
|
|
@ -136,7 +137,66 @@ function tokenHash(token) {
|
||||||
return crypto.createHash('sha256').update(token).digest('hex');
|
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();
|
const config = mailConfig();
|
||||||
if (!config.configured) throw new Error('SMTP is not configured');
|
if (!config.configured) throw new Error('SMTP is not configured');
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = nodemailer.createTransport({
|
||||||
|
|
@ -145,7 +205,7 @@ async function sendMail(to, subject, text) {
|
||||||
secure: config.secure,
|
secure: config.secure,
|
||||||
auth: config.user || config.pass ? { user: config.user, pass: config.pass } : undefined,
|
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() {
|
function loadModelConfig() {
|
||||||
|
|
@ -163,6 +223,58 @@ function saveModelConfig(config = modelConfig) {
|
||||||
writePrivateJson(modelsFile, { ...config, updatedAt: new Date().toISOString() });
|
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) {
|
function dedupeModels(models) {
|
||||||
return Array.from(new Map((models || []).filter(model => model && model.id).map(model => [model.id, {
|
return Array.from(new Map((models || []).filter(model => model && model.id).map(model => [model.id, {
|
||||||
id: String(model.id),
|
id: String(model.id),
|
||||||
|
|
@ -380,7 +492,7 @@ async function changePassword(req, res) {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(await readBody(req));
|
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 (!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);
|
auth.passwordHash = await hashPassword(payload.newPassword);
|
||||||
saveAuthConfig({ passwordHash: auth.passwordHash, resetTokenHash: undefined, resetExpiresAt: undefined });
|
saveAuthConfig({ passwordHash: auth.passwordHash, resetTokenHash: undefined, resetExpiresAt: undefined });
|
||||||
send(res, 200, JSON.stringify({ ok: true }));
|
send(res, 200, JSON.stringify({ ok: true }));
|
||||||
|
|
@ -395,7 +507,9 @@ async function resetPassword(req, res) {
|
||||||
const token = crypto.randomBytes(32).toString('base64url');
|
const token = crypto.randomBytes(32).toString('base64url');
|
||||||
saveAuthConfig({ resetTokenHash: tokenHash(token), resetExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() });
|
saveAuthConfig({ resetTokenHash: tokenHash(token), resetExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() });
|
||||||
const link = `${appOrigin(req)}/reset?token=${encodeURIComponent(token)}`;
|
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.' }));
|
send(res, 200, JSON.stringify({ ok: true, message: 'Reset email sent.' }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
send(res, 400, JSON.stringify({ error: error.message }));
|
send(res, 400, JSON.stringify({ error: error.message }));
|
||||||
|
|
@ -413,7 +527,9 @@ async function requestPasswordReset(req, res) {
|
||||||
const token = crypto.randomBytes(32).toString('base64url');
|
const token = crypto.randomBytes(32).toString('base64url');
|
||||||
saveAuthConfig({ resetTokenHash: tokenHash(token), resetExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() });
|
saveAuthConfig({ resetTokenHash: tokenHash(token), resetExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() });
|
||||||
const link = `${appOrigin(req)}/reset?token=${encodeURIComponent(token)}`;
|
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.' }));
|
send(res, 200, JSON.stringify({ ok: true, message: 'If the account exists, a reset email was sent.' }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
send(res, 400, JSON.stringify({ error: error.message }));
|
send(res, 400, JSON.stringify({ error: error.message }));
|
||||||
|
|
@ -423,7 +539,7 @@ async function requestPasswordReset(req, res) {
|
||||||
async function confirmPasswordReset(req, res) {
|
async function confirmPasswordReset(req, res) {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(await readBody(req) || '{}');
|
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 || ''));
|
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' }));
|
if (!valid) return send(res, 400, JSON.stringify({ error: 'Reset link is invalid or expired' }));
|
||||||
const passwordHash = await hashPassword(payload.newPassword);
|
const passwordHash = await hashPassword(payload.newPassword);
|
||||||
|
|
@ -452,7 +568,9 @@ async function sendVerification(req, res) {
|
||||||
const token = crypto.randomBytes(32).toString('base64url');
|
const token = crypto.randomBytes(32).toString('base64url');
|
||||||
saveAuthConfig({ verificationTokenHash: tokenHash(token), verificationExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() });
|
saveAuthConfig({ verificationTokenHash: tokenHash(token), verificationExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() });
|
||||||
const link = `${appOrigin(req)}/verify?token=${encodeURIComponent(token)}`;
|
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.' }));
|
send(res, 200, JSON.stringify({ ok: true, message: 'Verification email sent.' }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
send(res, 400, JSON.stringify({ error: error.message }));
|
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/search', wrap(searchModels));
|
||||||
app.post('/api/models/add', wrap(addModel));
|
app.post('/api/models/add', wrap(addModel));
|
||||||
app.post('/api/models/remove', wrap(removeModel));
|
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.post('/api/chat', wrap(proxyChat));
|
||||||
app.use((req, res) => {
|
app.use((req, res) => {
|
||||||
if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res);
|
if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res);
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,6 @@
|
||||||
const entriesKey = 'calorie-ai-web-entries';
|
const entriesKey = 'calorie-ai-web-entries';
|
||||||
const trashKey = 'calorie-ai-web-trash';
|
const trashKey = 'calorie-ai-web-trash';
|
||||||
const settingsKey = 'calorie-ai-web-settings';
|
const settingsKey = 'calorie-ai-web-settings';
|
||||||
const plansKey = 'calorie-ai-web-plans';
|
|
||||||
const selectedPlanKey = 'calorie-ai-web-selected-plan';
|
|
||||||
const pages = [
|
const pages = [
|
||||||
{ id: 'dashboard', label: 'Dashboard', icon: '⌂', group: 'Tracker' },
|
{ id: 'dashboard', label: 'Dashboard', icon: '⌂', group: 'Tracker' },
|
||||||
{ id: 'log', label: 'Log meal', icon: '+', group: 'Tracker' },
|
{ id: 'log', label: 'Log meal', icon: '+', group: 'Tracker' },
|
||||||
|
|
@ -115,14 +113,13 @@
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
entries = readJson(entriesKey, []);
|
entries = readJson(entriesKey, []);
|
||||||
trash = readJson(trashKey, []);
|
trash = readJson(trashKey, []);
|
||||||
plans = readJson(plansKey, []);
|
|
||||||
selectedPlanId = localStorage.getItem(selectedPlanKey) || plans[0]?.id || '';
|
|
||||||
settings = { ...settings, ...readJson(settingsKey, settings) };
|
settings = { ...settings, ...readJson(settingsKey, settings) };
|
||||||
await loadSession();
|
await loadSession();
|
||||||
if (location.pathname === '/verify' && resetToken) await verifyAccountToken();
|
if (location.pathname === '/verify' && resetToken) await verifyAccountToken();
|
||||||
page = localStorage.getItem('calorie_ai_last_tab') || 'dashboard';
|
page = localStorage.getItem('calorie_ai_last_tab') || 'dashboard';
|
||||||
setDefaultMealDateTime();
|
setDefaultMealDateTime();
|
||||||
await loadModels();
|
await loadModels();
|
||||||
|
await loadPlans();
|
||||||
await drawCharts();
|
await drawCharts();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -190,11 +187,6 @@
|
||||||
localStorage.setItem('calorie_ai_last_tab', id);
|
localStorage.setItem('calorie_ai_last_tab', id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleMenu() {
|
|
||||||
if (window.matchMedia('(min-width: 1024px)').matches) sidebarCollapsed = !sidebarCollapsed;
|
|
||||||
else menuOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function login() {
|
async function login() {
|
||||||
loginStatus = 'Checking credentials...';
|
loginStatus = 'Checking credentials...';
|
||||||
loginError = false;
|
loginError = false;
|
||||||
|
|
@ -296,12 +288,26 @@
|
||||||
saveJson(settingsKey, settings);
|
saveJson(settingsKey, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
function savePlans(nextPlans = plans, nextSelectedPlanId = selectedPlanId) {
|
function applyPlanConfig(config) {
|
||||||
plans = nextPlans;
|
plans = config.plans || [];
|
||||||
selectedPlanId = nextSelectedPlanId || plans[0]?.id || '';
|
selectedPlanId = config.selectedPlanId || plans[0]?.id || '';
|
||||||
saveJson(plansKey, plans);
|
}
|
||||||
if (selectedPlanId) localStorage.setItem(selectedPlanKey, selectedPlanId);
|
|
||||||
else localStorage.removeItem(selectedPlanKey);
|
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() {
|
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() };
|
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.';
|
planStatus = 'Plan generated.';
|
||||||
saveSettings();
|
saveSettings();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -701,7 +707,7 @@ Image estimate: ${imageEstimate}` },
|
||||||
});
|
});
|
||||||
const version = Math.max(0, ...plans.map(plan => Number(plan.version || 0))) + 1;
|
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 };
|
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 = '';
|
tuneNote = '';
|
||||||
planStatus = 'Plan updated.';
|
planStatus = 'Plan updated.';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -712,14 +718,23 @@ Image estimate: ${imageEstimate}` },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectPlan(planId) {
|
async function selectPlan(planId) {
|
||||||
selectedPlanId = 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) {
|
async function deletePlan(planId) {
|
||||||
const nextPlans = plans.filter(plan => plan.id !== planId);
|
const response = await fetch('/api/plans/delete', {
|
||||||
savePlans(nextPlans, selectedPlanId === planId ? nextPlans[0]?.id || '' : selectedPlanId);
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: planId }),
|
||||||
|
});
|
||||||
|
if (response.ok) applyPlanConfig(await response.json());
|
||||||
}
|
}
|
||||||
|
|
||||||
async function drawCharts() {
|
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>
|
<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>
|
<h2 class="text-2xl font-bold">Reset password</h2>
|
||||||
<label class="mt-6 block text-sm font-semibold text-slate-700">New password
|
<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>
|
||||||
<label class="mt-4 block text-sm font-semibold text-slate-700">Confirm password
|
<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>
|
</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>
|
<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}<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>
|
</form>
|
||||||
{:else if location.pathname === '/verify'}
|
{:else if location.pathname === '/verify'}
|
||||||
<section class="rounded-3xl border border-slate-200 bg-white p-8 shadow-xl lg:p-10">
|
<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>
|
<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>
|
<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}
|
{#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>
|
</section>
|
||||||
{:else}
|
{:else}
|
||||||
<form class="rounded-3xl border border-slate-200 bg-white p-8 shadow-xl lg:p-10" on:submit|preventDefault={login}>
|
<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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="min-h-screen bg-slate-50 text-slate-900">
|
<div class="min-h-screen bg-slate-50 text-slate-900 lg:grid lg:grid-cols-[auto_1fr]">
|
||||||
<header class="sticky top-0 z-30 bg-gradient-to-r from-blue-600 to-indigo-700 text-white shadow-sm">
|
<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="flex items-center justify-between gap-3 px-4 py-3 lg:px-6">
|
<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="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="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="grid h-11 w-11 place-items-center rounded-2xl bg-white text-sm font-black text-blue-700">CA</div>
|
<div class="sidebar-brand min-w-0">
|
||||||
<div class="min-w-0">
|
<h1 class="truncate text-lg font-black text-slate-950">Calorie AI</h1>
|
||||||
<h1 class="truncate text-lg font-bold">Calorie AI</h1>
|
<p class="text-xs font-semibold text-slate-500">Food diary</p>
|
||||||
<p class="text-xs text-blue-100">Food diary</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<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>
|
||||||
<div class="flex items-center gap-2">
|
<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>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<nav class="p-2" aria-label="Main menu">
|
<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}
|
{#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}
|
{#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}
|
{#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>
|
||||||
</aside>
|
</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}
|
{#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">
|
<main class="min-w-0 flex-1 p-4 lg:p-6">
|
||||||
{#if page === 'dashboard'}
|
{#if page === 'dashboard'}
|
||||||
|
|
@ -1002,6 +1021,8 @@ Image estimate: ${imageEstimate}` },
|
||||||
.active-tab { border-left-color: #2563eb; background: #dbeafe; color: #2563eb; }
|
.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; }
|
.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) { 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 .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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-bold">{editingEntryId ? 'Edit meal' : 'Log meal'}</h2>
|
<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>
|
</div>
|
||||||
<form class="card" on:submit|preventDefault={saveMeal}>
|
<form class="card" on:submit|preventDefault={saveMeal}>
|
||||||
<h3 class="card-title">Meal details</h3>
|
<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 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 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="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>
|
</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 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}
|
{#if editingEntryId}
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,8 @@
|
||||||
<h3 class="card-title">Password</h3>
|
<h3 class="card-title">Password</h3>
|
||||||
<form class="grid gap-3 p-4" on:submit|preventDefault={changePassword}>
|
<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="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="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="12"></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>
|
<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 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}
|
{#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>
|
<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="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">
|
<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>
|
</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) {
|
async function login(page) {
|
||||||
await mockModels(page);
|
await mockModels(page);
|
||||||
|
await mockPlans(page);
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page).toHaveURL(/\/login$/);
|
await expect(page).toHaveURL(/\/login$/);
|
||||||
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue