feat: major platform expansion — Brain service, RSS reader, iOS app, AI assistants, Firefox extension
All checks were successful
Security Checks / dependency-audit (push) Successful in 1m13s
Security Checks / secret-scanning (push) Successful in 3s
Security Checks / dockerfile-lint (push) Successful in 3s

Brain Service:
- Playwright stealth crawler replacing browserless (og:image, Readability, Reddit JSON API)
- AI classification with tag definitions and folder assignment
- YouTube video download via yt-dlp
- Karakeep migration complete (96 items)
- Taxonomy management (folders with icons/colors, tags)
- Discovery shuffle, sort options, search (Meilisearch + pgvector)
- Item tag/folder editing, card color accents

RSS Reader Service:
- Custom FastAPI reader replacing Miniflux
- Feed management (add/delete/refresh), category support
- Full article extraction via Readability
- Background content fetching for new entries
- Mark all read with confirmation
- Infinite scroll, retention cleanup (30/60 day)
- 17 feeds migrated from Miniflux

iOS App (SwiftUI):
- Native iOS 17+ app with @Observable architecture
- Cookie-based auth, configurable gateway URL
- Dashboard with custom background photo + frosted glass widgets
- Full fitness module (today/templates/goals/food library)
- AI assistant chat (fitness + brain, raw JSON state management)
- 120fps ProMotion support

AI Assistants (Gateway):
- Unified dispatcher with fitness/brain domain detection
- Fitness: natural language food logging, photo analysis, multi-item splitting
- Brain: save/append/update/delete notes, search & answer, undo support
- Madiha user gets fitness-only (brain disabled)

Firefox Extension:
- One-click save to Brain from any page
- Login with platform credentials
- Right-click context menu (save page/link/image)
- Notes field for URL saves
- Signed and published on AMO

Other:
- Reader bookmark button routes to Brain (was Karakeep)
- Fitness food library with "Add" button + add-to-meal popup
- Kindle send file size check (25MB SMTP2GO limit)
- Atelier UI as default (useAtelierShell=true)
- Mobile upload box in nav drawer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-03 00:56:29 -05:00
parent af1765bd8e
commit 4592e35732
97 changed files with 11009 additions and 532 deletions

25
.gitignore vendored
View File

@@ -37,3 +37,28 @@ gateway/data/
# Test artifacts
test-results/
# Brain storage (user data, not code)
services/brain/storage/
services/brain/data/
# Reader data
services/reader/data/
# Screenshots (uploaded images, not code)
screenshots/*.png
screenshots/*.jpg
screenshots/*.jpeg
# iOS build artifacts
ios/Platform/Platform.xcodeproj/xcuserdata/
ios/Platform/Platform.xcodeproj/project.xcworkspace/xcuserdata/
ios/Platform.bak/
# Node modules
**/node_modules/
# Temp files
*.pyc
__pycache__/
.DS_Store

View File

@@ -15,6 +15,8 @@ services:
- IMMICH_API_KEY=${IMMICH_API_KEY}
- KARAKEEP_URL=${KARAKEEP_URL:-http://192.168.1.42:3005}
- KARAKEEP_API_KEY=${KARAKEEP_API_KEY}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-5.2}
- BODY_SIZE_LIMIT=52428800
- TZ=${TZ:-America/Chicago}
networks:
@@ -64,6 +66,7 @@ services:
- TASKS_BACKEND_URL=http://tasks-service:8098
- TASKS_SERVICE_API_KEY=${TASKS_SERVICE_API_KEY}
- BRAIN_BACKEND_URL=http://brain-api:8200
- READER_BACKEND_URL=http://reader-api:8300
- QBITTORRENT_HOST=${QBITTORRENT_HOST:-192.168.1.42}
- QBITTORRENT_PORT=${QBITTORRENT_PORT:-8080}
- QBITTORRENT_USERNAME=${QBITTORRENT_USERNAME:-admin}

View File

@@ -0,0 +1,250 @@
"use strict";
var API_BASE = "https://dash.quadjourney.com";
console.log("[Brain] Background script loaded");
// ── Auth helpers ──
function getSession() {
return browser.storage.local.get("brainSession").then(function(data) {
return data.brainSession || null;
});
}
function apiRequest(path, opts) {
return getSession().then(function(session) {
if (!session) throw new Error("Not logged in");
opts = opts || {};
opts.headers = opts.headers || {};
opts.headers["Cookie"] = "platform_session=" + session;
opts.credentials = "include";
return fetch(API_BASE + path, opts).then(function(resp) {
if (resp.status === 401 || resp.status === 403) {
browser.storage.local.remove("brainSession");
throw new Error("Session expired");
}
if (!resp.ok) throw new Error("API error: " + resp.status);
return resp.json();
});
});
}
// ── Save functions ──
function saveLink(url, title, note) {
var body = { type: "link", url: url };
if (title) body.title = title;
if (note && note.trim()) body.raw_content = note.trim();
return apiRequest("/api/brain/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
function saveNote(text) {
return apiRequest("/api/brain/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "note", raw_content: text }),
});
}
function saveImage(imageUrl) {
return fetch(imageUrl).then(function(resp) {
if (!resp.ok) throw new Error("Failed to download image");
return resp.blob();
}).then(function(blob) {
var parts = imageUrl.split("/");
var filename = (parts[parts.length - 1] || "image.jpg").split("?")[0];
var formData = new FormData();
formData.append("file", blob, filename);
return getSession().then(function(session) {
if (!session) throw new Error("Not logged in");
return fetch(API_BASE + "/api/brain/items/upload", {
method: "POST",
headers: { "Cookie": "platform_session=" + session },
credentials: "include",
body: formData,
});
});
}).then(function(resp) {
if (!resp.ok) throw new Error("Upload failed: " + resp.status);
return resp.json();
});
}
// ── Badge helpers ──
function showBadge(tabId, text, color, duration) {
browser.action.setBadgeText({ text: text, tabId: tabId });
browser.action.setBadgeBackgroundColor({ color: color, tabId: tabId });
setTimeout(function() {
browser.action.setBadgeText({ text: "", tabId: tabId });
}, duration || 2000);
}
// ── Context menus ──
browser.runtime.onInstalled.addListener(function() {
console.log("[Brain] Creating context menus");
browser.contextMenus.create({
id: "save-page",
title: "Save page to Brain",
contexts: ["page"],
});
browser.contextMenus.create({
id: "save-link",
title: "Save link to Brain",
contexts: ["link"],
});
browser.contextMenus.create({
id: "save-image",
title: "Save image to Brain",
contexts: ["image"],
});
});
browser.contextMenus.onClicked.addListener(function(info, tab) {
getSession().then(function(session) {
if (!session) {
browser.action.openPopup();
return;
}
var promise;
if (info.menuItemId === "save-page") {
promise = saveLink(tab.url, tab.title);
} else if (info.menuItemId === "save-link") {
promise = saveLink(info.linkUrl, info.linkText);
} else if (info.menuItemId === "save-image") {
promise = saveImage(info.srcUrl);
}
if (promise) {
promise.then(function() {
showBadge(tab.id, "OK", "#059669", 2000);
}).catch(function(e) {
showBadge(tab.id, "ERR", "#DC2626", 3000);
console.error("[Brain] Save failed:", e);
});
}
});
});
// ── Keyboard shortcut ──
browser.commands.onCommand.addListener(function(command) {
if (command === "save-page") {
browser.tabs.query({ active: true, currentWindow: true }).then(function(tabs) {
var tab = tabs[0];
if (!tab || !tab.url) return;
getSession().then(function(session) {
if (!session) {
browser.action.openPopup();
return;
}
saveLink(tab.url, tab.title).then(function() {
showBadge(tab.id, "OK", "#059669", 2000);
}).catch(function(e) {
showBadge(tab.id, "ERR", "#DC2626", 3000);
});
});
});
}
});
// ── Message handler (from popup) ──
browser.runtime.onMessage.addListener(function(msg, sender, sendResponse) {
console.log("[Brain] Got message:", msg.action);
if (msg.action === "login") {
fetch(API_BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: msg.username,
password: msg.password,
}),
credentials: "include",
}).then(function(resp) {
if (!resp.ok) {
sendResponse({ success: false, error: "Invalid credentials" });
return;
}
// Wait for cookie to be set
return new Promise(function(r) { setTimeout(r, 300); }).then(function() {
return browser.cookies.get({
url: API_BASE,
name: "platform_session",
});
}).then(function(cookie) {
if (cookie && cookie.value) {
return browser.storage.local.set({ brainSession: cookie.value }).then(function() {
sendResponse({ success: true });
});
}
sendResponse({ success: false, error: "Login OK but could not capture session" });
});
}).catch(function(e) {
console.error("[Brain] Login error:", e);
sendResponse({ success: false, error: e.message || "Connection failed" });
});
return true;
}
if (msg.action === "logout") {
browser.storage.local.remove("brainSession").then(function() {
sendResponse({ success: true });
});
return true;
}
if (msg.action === "check-auth") {
getSession().then(function(session) {
if (!session) {
sendResponse({ authenticated: false });
return;
}
return apiRequest("/api/auth/me").then(function(data) {
sendResponse({ authenticated: true, user: data.user || data });
}).catch(function() {
sendResponse({ authenticated: false });
});
});
return true;
}
if (msg.action === "save-link") {
saveLink(msg.url, msg.title, msg.note).then(function(result) {
sendResponse(result);
}).catch(function(e) {
sendResponse({ error: e.message });
});
return true;
}
if (msg.action === "save-note") {
saveNote(msg.text).then(function(result) {
sendResponse(result);
}).catch(function(e) {
sendResponse({ error: e.message });
});
return true;
}
if (msg.action === "save-image") {
saveImage(msg.url).then(function(result) {
sendResponse(result);
}).catch(function(e) {
sendResponse({ error: e.message });
});
return true;
}
});

Binary file not shown.

View File

@@ -0,0 +1,49 @@
{
"manifest_version": 3,
"name": "Brain - Save to Second Brain",
"version": "1.0.0",
"description": "One-click save pages, notes, and images to your Second Brain",
"permissions": [
"activeTab",
"contextMenus",
"storage",
"cookies"
],
"host_permissions": [
"https://dash.quadjourney.com/*"
],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/brain-16.png",
"32": "icons/brain-32.png",
"48": "icons/brain-48.png"
}
},
"background": {
"scripts": ["background.js"]
},
"commands": {
"save-page": {
"suggested_key": {
"default": "Alt+Shift+S"
},
"description": "Save current page to Brain"
}
},
"icons": {
"16": "icons/brain-16.png",
"32": "icons/brain-32.png",
"48": "icons/brain-48.png",
"128": "icons/brain-128.png"
},
"browser_specific_settings": {
"gecko": {
"id": "brain@quadjourney.com",
"data_collection_permissions": {
"required": ["none"],
"optional": []
}
}
}
}

View File

@@ -0,0 +1,259 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 340px;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: #f5efe6;
color: #1e1812;
font-size: 14px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid rgba(35,26,17,0.1);
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
}
.header-mark {
width: 28px; height: 28px;
border-radius: 8px;
background: linear-gradient(135deg, #211912, #5d4c3f);
color: white;
display: grid; place-items: center;
font-size: 12px; font-weight: 700;
}
.header-title {
font-size: 14px;
font-weight: 600;
letter-spacing: -0.02em;
}
.header-user {
font-size: 11px;
color: #8c7b69;
}
.logout-btn {
background: none; border: none;
color: #8c7b69; font-size: 11px;
cursor: pointer; text-decoration: underline;
}
/* ── Login ── */
.login-view { padding: 24px 16px; }
.login-title {
font-size: 16px; font-weight: 600;
margin-bottom: 4px;
}
.login-sub {
font-size: 12px; color: #8c7b69;
margin-bottom: 16px;
}
.field { margin-bottom: 12px; }
.field label {
display: block; font-size: 11px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.06em;
color: #6b6256; margin-bottom: 4px;
}
.field input {
width: 100%; padding: 10px 12px;
border: 1px solid rgba(35,26,17,0.15);
border-radius: 10px; font-size: 14px;
background: rgba(255,255,255,0.7);
color: #1e1812; outline: none;
font-family: inherit;
}
.field input:focus {
border-color: rgba(35,26,17,0.35);
}
.login-btn, .save-btn {
width: 100%; padding: 12px;
border: none; border-radius: 12px;
background: linear-gradient(135deg, #211912, #5d4c3f);
color: white; font-size: 14px; font-weight: 600;
cursor: pointer; font-family: inherit;
transition: opacity 150ms;
}
.login-btn:hover, .save-btn:hover { opacity: 0.9; }
.login-btn:disabled, .save-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.login-error {
color: #8f3928; font-size: 12px;
margin-bottom: 10px;
}
/* ── Save view ── */
.save-view { padding: 12px 16px 16px; }
.mode-tabs {
display: flex; gap: 4px;
margin-bottom: 12px;
}
.mode-tab {
flex: 1; padding: 8px;
border: 1px solid rgba(35,26,17,0.12);
border-radius: 10px;
background: rgba(255,255,255,0.4);
color: #6b6256; font-size: 12px; font-weight: 600;
cursor: pointer; text-align: center;
font-family: inherit;
transition: all 150ms;
}
.mode-tab.active {
background: rgba(255,255,255,0.9);
color: #1e1812;
border-color: rgba(35,26,17,0.25);
}
.url-preview {
padding: 10px 12px;
border-radius: 10px;
background: rgba(255,255,255,0.6);
border: 1px solid rgba(35,26,17,0.08);
margin-bottom: 10px;
}
.url-title {
font-size: 13px; font-weight: 600;
white-space: nowrap; overflow: hidden;
text-overflow: ellipsis;
}
.url-domain {
font-size: 11px; color: #8c7b69;
white-space: nowrap; overflow: hidden;
text-overflow: ellipsis;
}
.note-toggle {
background: none; border: none;
color: #8c7b69; font-size: 12px;
cursor: pointer; padding: 0;
margin-bottom: 8px;
font-family: inherit;
}
.note-toggle:hover { color: #1e1812; }
.note-area {
width: 100%; min-height: 80px;
padding: 10px 12px;
border: 1px solid rgba(35,26,17,0.15);
border-radius: 10px; font-size: 13px;
background: rgba(255,255,255,0.7);
color: #1e1812; outline: none;
font-family: inherit; resize: vertical;
margin-bottom: 10px;
}
.note-area:focus {
border-color: rgba(35,26,17,0.35);
}
/* ── Success state ── */
.success-view {
padding: 32px 16px;
text-align: center;
}
.success-check {
width: 48px; height: 48px;
border-radius: 50%;
background: #059669;
display: inline-flex; align-items: center; justify-content: center;
margin-bottom: 12px;
}
.success-check svg { color: white; }
.success-text {
font-size: 16px; font-weight: 600;
}
.success-sub {
font-size: 12px; color: #8c7b69;
margin-top: 4px;
}
/* ── Error ── */
.error-msg {
padding: 8px 12px;
border-radius: 8px;
background: rgba(143,57,40,0.08);
color: #8f3928;
font-size: 12px;
margin-bottom: 10px;
}
.hidden { display: none; }
</style>
</head>
<body>
<!-- Login View -->
<div id="login-view" class="login-view hidden">
<div class="login-title">Sign in to Brain</div>
<div class="login-sub">Use your Platform credentials</div>
<div id="login-error" class="login-error hidden"></div>
<div class="field">
<label>Username</label>
<input id="login-user" type="text" placeholder="admin" autocomplete="username">
</div>
<div class="field">
<label>Password</label>
<input id="login-pass" type="password" placeholder="" autocomplete="current-password">
</div>
<button id="login-btn" class="login-btn">Sign in</button>
</div>
<!-- Save View -->
<div id="save-view" class="hidden">
<div class="header">
<div class="header-left">
<div class="header-mark">B</div>
<div>
<div class="header-title">Brain</div>
<div class="header-user" id="user-name"></div>
</div>
</div>
<button class="logout-btn" id="logout-btn">Logout</button>
</div>
<div class="save-view">
<div class="mode-tabs">
<button class="mode-tab active" id="tab-link" data-mode="link">Save page</button>
<button class="mode-tab" id="tab-note" data-mode="note">Quick note</button>
</div>
<!-- Link mode -->
<div id="link-mode">
<div class="url-preview">
<div class="url-title" id="page-title"></div>
<div class="url-domain" id="page-url"></div>
</div>
<button class="note-toggle" id="note-toggle-btn">+ Add a note</button>
<textarea id="link-note" class="note-area hidden" placeholder="Optional note about this page..."></textarea>
<button id="save-link-btn" class="save-btn">Save page</button>
</div>
<!-- Note mode -->
<div id="note-mode" class="hidden">
<textarea id="note-text" class="note-area" placeholder="Write a note..." style="min-height:120px;"></textarea>
<button id="save-note-btn" class="save-btn">Save note</button>
</div>
<div id="save-error" class="error-msg hidden"></div>
</div>
</div>
<!-- Success View -->
<div id="success-view" class="success-view hidden">
<div class="success-check">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M20 6L9 17l-5-5"/></svg>
</div>
<div class="success-text">Saved!</div>
<div class="success-sub">AI is classifying in the background</div>
</div>
<script src="popup.js"></script>
</body>
</html>

View File

@@ -0,0 +1,202 @@
const loginView = document.getElementById("login-view");
const saveView = document.getElementById("save-view");
const successView = document.getElementById("success-view");
const loginBtn = document.getElementById("login-btn");
const loginUser = document.getElementById("login-user");
const loginPass = document.getElementById("login-pass");
const loginError = document.getElementById("login-error");
const logoutBtn = document.getElementById("logout-btn");
const userName = document.getElementById("user-name");
const tabLink = document.getElementById("tab-link");
const tabNote = document.getElementById("tab-note");
const linkMode = document.getElementById("link-mode");
const noteMode = document.getElementById("note-mode");
const pageTitle = document.getElementById("page-title");
const pageUrl = document.getElementById("page-url");
const noteToggleBtn = document.getElementById("note-toggle-btn");
const linkNote = document.getElementById("link-note");
const saveLinkBtn = document.getElementById("save-link-btn");
const noteText = document.getElementById("note-text");
const saveNoteBtn = document.getElementById("save-note-btn");
const saveError = document.getElementById("save-error");
let currentTab = null;
// ── View management ──
function showView(view) {
loginView.classList.add("hidden");
saveView.classList.add("hidden");
successView.classList.add("hidden");
view.classList.remove("hidden");
}
function showError(el, msg) {
el.textContent = msg;
el.classList.remove("hidden");
}
function hideError(el) {
el.classList.add("hidden");
}
// ── Init ──
async function init() {
const resp = await browser.runtime.sendMessage({ action: "check-auth" });
if (resp.authenticated) {
userName.textContent = resp.user?.display_name || resp.user?.username || "";
showView(saveView);
await loadCurrentTab();
} else {
showView(loginView);
loginUser.focus();
}
}
async function loadCurrentTab() {
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
if (tab) {
currentTab = tab;
pageTitle.textContent = tab.title || "Untitled";
try {
pageUrl.textContent = new URL(tab.url).hostname;
} catch {
pageUrl.textContent = tab.url;
}
}
}
// ── Login ──
loginBtn.addEventListener("click", async () => {
const user = loginUser.value.trim();
const pass = loginPass.value;
if (!user || !pass) return;
loginBtn.disabled = true;
loginBtn.textContent = "Signing in...";
hideError(loginError);
try {
const resp = await browser.runtime.sendMessage({
action: "login",
username: user,
password: pass,
});
if (resp && resp.success) {
const auth = await browser.runtime.sendMessage({ action: "check-auth" });
userName.textContent = auth.user?.display_name || auth.user?.username || "";
showView(saveView);
await loadCurrentTab();
} else {
showError(loginError, (resp && resp.error) || "Login failed");
}
} catch (e) {
showError(loginError, "Error: " + (e.message || e));
}
loginBtn.disabled = false;
loginBtn.textContent = "Sign in";
});
loginPass.addEventListener("keydown", (e) => {
if (e.key === "Enter") loginBtn.click();
});
// ── Logout ──
logoutBtn.addEventListener("click", async () => {
await browser.runtime.sendMessage({ action: "logout" });
showView(loginView);
loginUser.value = "";
loginPass.value = "";
});
// ── Mode tabs ──
tabLink.addEventListener("click", () => {
tabLink.classList.add("active");
tabNote.classList.remove("active");
linkMode.classList.remove("hidden");
noteMode.classList.add("hidden");
hideError(saveError);
});
tabNote.addEventListener("click", () => {
tabNote.classList.add("active");
tabLink.classList.remove("active");
noteMode.classList.remove("hidden");
linkMode.classList.add("hidden");
hideError(saveError);
noteText.focus();
});
// ── Note toggle on link mode ──
noteToggleBtn.addEventListener("click", () => {
linkNote.classList.toggle("hidden");
if (!linkNote.classList.contains("hidden")) {
noteToggleBtn.textContent = "- Hide note";
linkNote.focus();
} else {
noteToggleBtn.textContent = "+ Add a note";
}
});
// ── Save link ──
saveLinkBtn.addEventListener("click", async () => {
if (!currentTab?.url) return;
saveLinkBtn.disabled = true;
saveLinkBtn.textContent = "Saving...";
hideError(saveError);
try {
await browser.runtime.sendMessage({
action: "save-link",
url: currentTab.url,
title: currentTab.title,
note: linkNote.value.trim() || undefined,
});
showView(successView);
setTimeout(() => window.close(), 1500);
} catch (e) {
showError(saveError, e.message || "Save failed");
saveLinkBtn.disabled = false;
saveLinkBtn.textContent = "Save page";
}
});
// ── Save note ──
saveNoteBtn.addEventListener("click", async () => {
const text = noteText.value.trim();
if (!text) return;
saveNoteBtn.disabled = true;
saveNoteBtn.textContent = "Saving...";
hideError(saveError);
try {
await browser.runtime.sendMessage({ action: "save-note", text });
showView(successView);
setTimeout(() => window.close(), 1500);
} catch (e) {
showError(saveError, e.message || "Save failed");
saveNoteBtn.disabled = false;
saveNoteBtn.textContent = "Save note";
}
});
// ── Start ──
init().catch((e) => {
console.error("[Brain popup] Init failed:", e);
// Show login view as fallback
showView(loginView);
loginUser.focus();
});

View File

@@ -0,0 +1,8 @@
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="width:300px;padding:20px;font-family:sans-serif;">
<h2>Brain Extension</h2>
<p>If you see this, the popup works.</p>
</body>
</html>

View File

@@ -12,8 +12,9 @@
═══════════════════════════════════════════════ */
@layer base {
html, body { overflow-x: hidden; }
body { padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); }
html { background-color: #f5efe6; }
body { overflow-x: clip; }
body { padding-bottom: env(safe-area-inset-bottom); }
:root {
/* ── Fonts ── */
--font: 'Outfit', -apple-system, system-ui, sans-serif;
@@ -106,7 +107,7 @@
/* ── LIGHT MODE — Zinc + Emerald ── */
:root {
--canvas: #FAFAFA;
--canvas: #f5efe6;
--surface: #FFFFFF;
--surface-secondary: #F4F4F5;
--card: #FFFFFF;

View File

@@ -3,6 +3,9 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#f5efe6" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">

View File

@@ -0,0 +1,480 @@
<script lang="ts">
import { tick } from 'svelte';
import { Bot, Brain, SendHorizonal, X } from '@lucide/svelte';
type ChatRole = 'user' | 'assistant';
type SourceLink = {
id: string;
title: string;
type: string;
href: string;
};
type Message = {
role: ChatRole;
content: string;
sources?: SourceLink[];
};
type AssistantState = {
lastMutation?: {
type: 'append' | 'create';
itemId: string;
itemTitle: string;
additionId?: string;
content: string;
createdItemId?: string;
};
pendingDelete?: {
itemId: string;
itemTitle: string;
};
};
interface Props {
open: boolean;
onclose: () => void;
}
let { open = $bindable(), onclose }: Props = $props();
const intro =
'Ask Brain to save a note, add to an existing note, answer from your notes, or delete something. I will reuse the existing Brain workflow when a new item should be created.';
let messages = $state<Message[]>([{ role: 'assistant', content: intro }]);
let input = $state('');
let sending = $state(false);
let error = $state('');
let composerEl: HTMLInputElement | null = $state(null);
let threadEndEl: HTMLDivElement | null = $state(null);
let assistantState = $state<AssistantState>({});
async function scrollThreadToBottom(behavior: ScrollBehavior = 'smooth') {
await tick();
requestAnimationFrame(() => {
threadEndEl?.scrollIntoView({ block: 'end', behavior });
});
}
function resetThread() {
messages = [{ role: 'assistant', content: intro }];
input = '';
error = '';
assistantState = {};
}
async function sendMessage(content: string) {
const clean = content.trim();
if (!clean) return;
error = '';
sending = true;
const nextMessages = [...messages, { role: 'user' as const, content: clean }];
messages = nextMessages;
input = '';
void scrollThreadToBottom('auto');
try {
const response = await fetch('/api/assistant/brain', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
messages: nextMessages,
state: assistantState
})
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data?.error || data?.reply || 'Brain assistant request failed');
}
assistantState = data?.state || {};
messages = [
...nextMessages,
{
role: 'assistant',
content: data?.reply || 'Done.',
sources: Array.isArray(data?.sources) ? data.sources : []
}
];
await scrollThreadToBottom();
} catch (err) {
error = err instanceof Error ? err.message : 'Brain assistant request failed';
} finally {
sending = false;
requestAnimationFrame(() => composerEl?.focus());
}
}
function handleSubmit() {
void sendMessage(input);
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onclose();
return;
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSubmit();
}
}
$effect(() => {
if (open) {
requestAnimationFrame(() => composerEl?.focus());
}
});
$effect(() => {
if (!open) return;
document.body.classList.add('assistant-open');
return () => {
document.body.classList.remove('assistant-open');
};
});
</script>
{#if open}
<button class="assistant-backdrop" aria-label="Close assistant" onclick={onclose}></button>
<div class="assistant-drawer" role="dialog" aria-label="Brain assistant">
<header class="assistant-head">
<div class="assistant-title-wrap">
<div class="assistant-mark">
<Brain size={16} strokeWidth={1.9} />
</div>
<div>
<div class="assistant-kicker">Assistant</div>
<div class="assistant-title">Brain chat</div>
</div>
</div>
<div class="assistant-head-actions">
<button class="assistant-ghost" onclick={resetThread}>Reset</button>
<button class="assistant-close" onclick={onclose} aria-label="Close assistant">
<X size={17} strokeWidth={1.9} />
</button>
</div>
</header>
<div class="assistant-body">
<section class="thread">
<div class="thread-spacer" aria-hidden="true"></div>
{#each messages as message}
<div class="bubble-row" class:user={message.role === 'user'}>
{#if message.role === 'assistant'}
<div class="bubble-icon">
<Bot size={14} strokeWidth={1.8} />
</div>
{/if}
<div class="bubble-stack" class:user={message.role === 'user'}>
<div class="bubble" class:user={message.role === 'user'}>
{message.content}
</div>
{#if message.sources?.length}
<div class="source-list">
{#each message.sources as source}
<a class="source-chip" href={source.href}>
<span>{source.title}</span>
<small>{source.type}</small>
</a>
{/each}
</div>
{/if}
</div>
</div>
{/each}
<div bind:this={threadEndEl}></div>
</section>
{#if error}
<div class="assistant-error">{error}</div>
{/if}
</div>
<footer class="assistant-compose">
<div class="compose-box">
<input
bind:this={composerEl}
bind:value={input}
class="compose-input"
type="text"
placeholder="Ask Brain to save, find, answer, or delete..."
onkeydown={handleKeydown}
disabled={sending}
/>
<button class="send-btn" onclick={handleSubmit} disabled={sending || !input.trim()}>
<SendHorizonal size={15} strokeWidth={2} />
</button>
</div>
</footer>
</div>
{/if}
<style>
.assistant-backdrop {
position: fixed;
inset: 0;
background: rgba(33, 23, 14, 0.18);
backdrop-filter: blur(10px);
z-index: 80;
border: none;
}
:global(body.assistant-open) {
overflow: hidden;
touch-action: none;
}
.assistant-drawer {
position: fixed;
top: 18px;
right: 18px;
bottom: 18px;
width: min(460px, calc(100vw - 24px));
background: linear-gradient(180deg, rgba(249, 244, 238, 0.98), rgba(244, 236, 226, 0.98));
border: 1px solid rgba(35, 26, 17, 0.08);
border-radius: 28px;
box-shadow: 0 28px 80px rgba(35, 26, 17, 0.18);
backdrop-filter: blur(18px);
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
z-index: 90;
overflow: hidden;
}
.assistant-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 18px 14px;
border-bottom: 1px solid rgba(35, 26, 17, 0.08);
}
.assistant-title-wrap {
display: flex;
align-items: center;
gap: 12px;
}
.assistant-mark {
width: 34px;
height: 34px;
border-radius: 12px;
display: grid;
place-items: center;
background: rgba(34, 24, 14, 0.08);
color: #231a11;
}
.assistant-kicker {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #8a7866;
}
.assistant-title {
font-size: 1rem;
font-weight: 700;
letter-spacing: -0.04em;
color: #18120c;
}
.assistant-head-actions {
display: flex;
align-items: center;
gap: 8px;
}
.assistant-ghost,
.assistant-close,
.send-btn {
border: none;
cursor: pointer;
font-family: inherit;
}
.assistant-ghost {
padding: 8px 12px;
border-radius: 999px;
background: rgba(35, 26, 17, 0.06);
color: #5d5248;
font-size: 0.78rem;
font-weight: 600;
}
.assistant-close {
width: 36px;
height: 36px;
border-radius: 999px;
background: rgba(35, 26, 17, 0.06);
color: #2c241d;
display: grid;
place-items: center;
}
.assistant-body {
min-height: 0;
padding: 0 10px 10px;
}
.thread {
height: 100%;
overflow-y: auto;
padding: 10px 8px 12px;
-webkit-overflow-scrolling: touch;
}
.thread-spacer {
height: 24px;
}
.bubble-row {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 14px;
}
.bubble-row.user {
justify-content: flex-end;
}
.bubble-icon {
width: 28px;
height: 28px;
border-radius: 999px;
display: grid;
place-items: center;
background: rgba(35, 26, 17, 0.08);
color: #2d241b;
flex-shrink: 0;
}
.bubble-stack {
max-width: 78%;
display: grid;
gap: 8px;
}
.bubble-stack.user {
justify-items: end;
}
.bubble {
padding: 11px 14px;
border-radius: 18px;
background: rgba(255, 252, 248, 0.92);
border: 1px solid rgba(35, 26, 17, 0.07);
color: #221a13;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
.bubble.user {
background: #1f1812;
color: #fff8f1;
border-color: #1f1812;
}
.source-list {
display: grid;
gap: 8px;
}
.source-chip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border-radius: 14px;
text-decoration: none;
background: rgba(255, 252, 248, 0.78);
border: 1px solid rgba(35, 26, 17, 0.07);
color: #2c241d;
}
.source-chip small {
color: #8a7866;
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.62rem;
}
.assistant-error {
margin: 0 8px 6px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(150, 34, 22, 0.08);
color: #8f3428;
font-size: 0.84rem;
}
.assistant-compose {
padding: 12px 14px 14px;
border-top: 1px solid rgba(35, 26, 17, 0.08);
background: rgba(248, 242, 235, 0.84);
}
.compose-box {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 10px 10px 14px;
border-radius: 18px;
background: rgba(255, 252, 248, 0.88);
border: 1px solid rgba(35, 26, 17, 0.08);
}
.compose-input {
flex: 1;
border: none;
background: transparent;
color: #1f1812;
font-size: 1rem;
font-family: inherit;
outline: none;
}
.compose-input::placeholder {
color: #8a7866;
}
.send-btn {
width: 36px;
height: 36px;
border-radius: 999px;
display: grid;
place-items: center;
background: #1f1812;
color: #fff8f1;
}
.send-btn:disabled {
opacity: 0.45;
cursor: default;
}
@media (max-width: 768px) {
.assistant-drawer {
top: 12px;
right: 12px;
left: 12px;
bottom: 12px;
width: auto;
border-radius: 24px;
}
.bubble-stack {
max-width: 88%;
}
}
</style>

View File

@@ -1,16 +1,26 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { tick } from 'svelte';
import { Bot, Dumbbell, ImagePlus, SendHorizonal, Sparkles, X } from '@lucide/svelte';
import { Bot, Brain, Dumbbell, ImagePlus, SendHorizonal, Sparkles, X } from '@lucide/svelte';
type ChatRole = 'user' | 'assistant';
type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
type Domain = 'fitness' | 'brain';
type SourceLink = {
id: string;
title: string;
type: string;
href: string;
};
type Message = {
role: ChatRole;
content: string;
image?: string;
imageName?: string;
sources?: SourceLink[];
domain?: Domain;
};
type Draft = {
@@ -30,21 +40,34 @@
type DraftBundle = Draft[];
type UnifiedState = {
activeDomain?: Domain;
fitnessState?: {
draft?: Draft | null;
drafts?: DraftBundle;
};
brainState?: Record<string, unknown>;
};
interface Props {
open: boolean;
onclose: () => void;
entryDate?: string | null;
allowBrain?: boolean;
}
let { open = $bindable(), onclose, entryDate = null }: Props = $props();
let { open = $bindable(), onclose, entryDate = null, allowBrain = true }: Props = $props();
const intro = `Tell me what you ate, including multiple items or a nutrition label photo, then correct me naturally until it looks right.`;
const intro = $derived(
allowBrain
? 'Log food, ask about calories, save a note, answer from Brain, or update/delete something. Photos currently route to fitness.'
: 'Log food, ask about calories, or add from a photo. Photos currently route to fitness.'
);
let messages = $state<Message[]>([
{ role: 'assistant', content: intro }
]);
let draft = $state<Draft | null>(null);
let drafts = $state<DraftBundle>([]);
let unifiedState = $state<UnifiedState>({});
let input = $state('');
let sending = $state(false);
let error = $state('');
@@ -66,8 +89,7 @@
function resetThread() {
messages = [{ role: 'assistant', content: intro }];
draft = null;
drafts = [];
unifiedState = {};
input = '';
error = '';
photoPreview = null;
@@ -124,16 +146,16 @@
}
try {
const response = await fetch('/assistant/fitness', {
const response = await fetch('/api/assistant', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
action,
messages: nextMessages,
draft,
drafts,
state: unifiedState,
entryDate,
imageDataUrl: action === 'chat' ? outgoingPhoto || attachedPhoto : null
imageDataUrl: action === 'chat' ? outgoingPhoto || attachedPhoto : null,
allowBrain
})
});
@@ -142,21 +164,18 @@
throw new Error(data?.error || 'Assistant request failed');
}
if (data.draft) {
draft = data.draft;
}
if (Array.isArray(data.drafts)) {
drafts = data.drafts;
if (data.drafts.length > 0) {
draft = null;
}
} else if (data.draft) {
drafts = [];
}
unifiedState = data?.state || {};
if (data.reply) {
messages = [...nextMessages, { role: 'assistant', content: data.reply }];
messages = [
...nextMessages,
{
role: 'assistant',
content: data.reply,
sources: Array.isArray(data?.sources) ? data.sources : [],
domain: data?.domain === 'fitness' || data?.domain === 'brain' ? data.domain : undefined
}
];
}
if (data.applied) {
@@ -165,8 +184,6 @@
attachedPhoto = null;
attachedPhotoName = '';
if (fileInputEl) fileInputEl.value = '';
draft = null;
drafts = [];
window.dispatchEvent(
new CustomEvent('fitnessassistantapplied', {
detail: { entryDate: data.draft?.entry_date || data.drafts?.[0]?.entry_date || entryDate || null }
@@ -236,20 +253,36 @@
document.body.classList.remove('assistant-open');
};
});
function activeDraft(): Draft | null {
return unifiedState.fitnessState?.draft || null;
}
function activeDrafts(): DraftBundle {
return unifiedState.fitnessState?.drafts || [];
}
</script>
{#if open}
<button class="assistant-backdrop" aria-label="Close assistant" onclick={onclose}></button>
<div class="assistant-drawer" role="dialog" aria-label="Fitness assistant">
<div class="assistant-drawer" role="dialog" aria-label="Assistant">
<header class="assistant-head">
<div class="assistant-title-wrap">
<div class="assistant-mark">
<Dumbbell size={16} strokeWidth={1.9} />
{#if unifiedState.activeDomain === 'brain'}
{#if allowBrain}
<Brain size={16} strokeWidth={1.9} />
{:else}
<Dumbbell size={16} strokeWidth={1.9} />
{/if}
{:else}
<Dumbbell size={16} strokeWidth={1.9} />
{/if}
</div>
<div>
<div class="assistant-kicker">Assistant</div>
<div class="assistant-title">Fitness chat</div>
<div class="assistant-title">{allowBrain ? 'One chat' : 'Food chat'}</div>
</div>
</div>
@@ -285,11 +318,21 @@
{message.content}
</div>
{/if}
{#if message.sources?.length}
<div class="source-list">
{#each message.sources as source}
<a class="source-chip" href={source.href}>
<span>{source.title}</span>
<small>{source.type}</small>
</a>
{/each}
</div>
{/if}
</div>
</div>
{/each}
{#if drafts.length > 1}
{#if activeDrafts().length > 1}
<div class="bubble-row draft-row">
<div class="bubble-icon">
<Sparkles size={14} strokeWidth={1.8} />
@@ -298,12 +341,12 @@
<div class="draft-card-top">
<div>
<div class="draft-card-kicker">Ready to add</div>
<div class="draft-card-title">{drafts.length} items</div>
<div class="draft-card-title">{activeDrafts().length} items</div>
</div>
<div class="draft-meal">{drafts[0]?.meal_type || 'meal'}</div>
<div class="draft-meal">{activeDrafts()[0]?.meal_type || 'meal'}</div>
</div>
<div class="bundle-list">
{#each drafts as item}
{#each activeDrafts() as item}
<div class="bundle-row">
<div class="bundle-name">{item.food_name}</div>
<div class="bundle-calories">{Math.round(item.calories || 0)} cal</div>
@@ -311,7 +354,7 @@
{/each}
</div>
<div class="draft-inline-metrics">
<span>{drafts.reduce((sum, item) => sum + Math.round(item.calories || 0), 0)} cal total</span>
<span>{activeDrafts().reduce((sum, item) => sum + Math.round(item.calories || 0), 0)} cal total</span>
</div>
<div class="draft-card-actions">
<button class="draft-apply subtle" onclick={beginRevision} disabled={sending}>
@@ -323,7 +366,7 @@
</div>
</div>
</div>
{:else if draft?.food_name}
{:else if activeDraft()?.food_name}
<div class="bubble-row draft-row">
<div class="bubble-icon">
<Sparkles size={14} strokeWidth={1.8} />
@@ -332,19 +375,19 @@
<div class="draft-card-top">
<div>
<div class="draft-card-kicker">Ready to add</div>
<div class="draft-card-title">{draft.food_name}</div>
<div class="draft-card-title">{activeDraft()?.food_name}</div>
</div>
{#if draft.meal_type}
<div class="draft-meal">{draft.meal_type}</div>
{#if activeDraft()?.meal_type}
<div class="draft-meal">{activeDraft()?.meal_type}</div>
{/if}
</div>
<div class="draft-inline-metrics">
<span>{Math.round(draft.calories || 0)} cal</span>
<span>{Math.round(draft.protein || 0)}p</span>
<span>{Math.round(draft.carbs || 0)}c</span>
<span>{Math.round(draft.fat || 0)}f</span>
<span>{Math.round(draft.sugar || 0)} sugar</span>
<span>{Math.round(draft.fiber || 0)} fiber</span>
<span>{Math.round(activeDraft()?.calories || 0)} cal</span>
<span>{Math.round(activeDraft()?.protein || 0)}p</span>
<span>{Math.round(activeDraft()?.carbs || 0)}c</span>
<span>{Math.round(activeDraft()?.fat || 0)}f</span>
<span>{Math.round(activeDraft()?.sugar || 0)} sugar</span>
<span>{Math.round(activeDraft()?.fiber || 0)} fiber</span>
</div>
<div class="draft-card-actions">
<button class="draft-apply subtle" onclick={beginRevision} disabled={sending}>
@@ -390,7 +433,7 @@
<img src={photoPreview} alt="Selected food" />
<div class="photo-staged-copy">
<div class="photo-staged-title">{photoName || 'Food photo'}</div>
<div class="photo-staged-note">Ill draft the entry from this photo.</div>
<div class="photo-staged-note">Photos currently route to fitness.</div>
</div>
<button class="photo-staged-clear" type="button" onclick={clearPhoto}>Remove</button>
</div>
@@ -410,7 +453,7 @@
class="compose-input"
bind:value={input}
bind:this={composerEl}
placeholder="Add 2 boiled eggs for breakfast..."
placeholder={allowBrain ? 'Log food, save a note, ask Brain...' : 'Log food or ask about calories...'}
onkeydown={handleKeydown}
type="text"
/>
@@ -537,9 +580,12 @@
.assistant-body {
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.thread {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
@@ -549,6 +595,7 @@
display: flex;
flex-direction: column;
gap: 10px;
scrollbar-gutter: stable;
}
.bundle-card {
@@ -642,6 +689,30 @@
color: #f8f4ee;
}
.source-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.source-chip {
display: grid;
gap: 2px;
padding: 8px 10px;
border-radius: 14px;
text-decoration: none;
background: rgba(255, 255, 255, 0.76);
border: 1px solid rgba(36, 26, 18, 0.08);
color: #241c14;
}
.source-chip small {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #7a6a5b;
}
.image-bubble {
width: min(240px, 100%);
padding: 8px;

View File

@@ -12,6 +12,7 @@
LibraryBig,
Menu,
Package2,
MessageSquare,
Search,
Settings2,
SquareCheckBig,
@@ -193,8 +194,9 @@
<div class="brand-sub">ops workspace</div>
</div>
</a>
<button class="command-trigger mobile" onclick={onOpenCommand}>
<Search size={15} strokeWidth={1.8} />
<button class="command-trigger mobile" onclick={onOpenCommand} aria-label="Open assistant">
<MessageSquare size={15} strokeWidth={1.8} />
<span>AI</span>
</button>
</header>
@@ -220,7 +222,8 @@
{/each}
</nav>
<div class="mobile-nav-bottom">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="mobile-nav-upload" ondragover={(e) => e.preventDefault()} ondrop={onRailDrop}>
<div class="rail-upload-row">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="paste-box" contenteditable="true" onpaste={onPasteBox} role="textbox" tabindex="0">
@@ -230,10 +233,6 @@
<Upload size={13} strokeWidth={2} />
</button>
</div>
<div class="rail-date">
<CalendarDays size={14} strokeWidth={1.8} />
<span>{new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }).format(new Date())}</span>
</div>
</div>
</aside>
{/if}
@@ -250,13 +249,14 @@
radial-gradient(circle at top left, rgba(214, 120, 58, 0.08), transparent 22%),
radial-gradient(circle at 85% 10%, rgba(65, 91, 82, 0.12), transparent 20%),
linear-gradient(180deg, #f5efe6 0%, #efe6da 48%, #ede7de 100%);
background-color: #f5efe6;
}
.shell {
--shell-ink: #1e1812;
--shell-muted: #6b6256;
--shell-line: rgba(35, 26, 17, 0.11);
min-height: 100vh;
min-height: 100dvh;
display: grid;
grid-template-columns: 270px minmax(0, 1fr);
position: relative;
@@ -525,10 +525,17 @@
}
.command-trigger.mobile {
width: 40px;
height: 40px;
justify-content: center;
padding: 0;
padding: 0 12px;
gap: 6px;
font-weight: 700;
color: var(--shell-ink);
}
.command-trigger.mobile span {
font-size: 0.78rem;
letter-spacing: 0.02em;
}
.mobile-menu-btn {
@@ -557,33 +564,15 @@
top: 0;
left: 0;
bottom: 0;
width: min(300px, 82vw);
padding: 16px 14px;
padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px));
width: min(320px, 86vw);
padding: 18px 16px;
background: linear-gradient(180deg, rgba(250, 246, 239, 0.96), rgba(244, 237, 228, 0.94));
border-right: 1px solid var(--shell-line);
backdrop-filter: blur(20px);
z-index: 40;
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
}
.mobile-nav-list {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.mobile-nav-list a {
padding: 10px 12px;
}
.mobile-nav-bottom {
display: grid;
gap: 8px;
padding-top: 12px;
border-top: 1px solid var(--shell-line);
flex-shrink: 0;
align-content: start;
gap: 18px;
}
.mobile-nav-head {
@@ -634,5 +623,11 @@
.mobile-menu-btn {
display: inline-flex;
}
.mobile-nav-upload {
margin-top: auto;
padding-top: 16px;
border-top: 1px solid var(--shell-line);
}
}
</style>

View File

@@ -1,8 +1,23 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onDestroy, tick } from 'svelte';
import {
Home, Heart, Briefcase, Plane, Moon, Server, Truck, Printer, FileText,
Folder as FolderIcon, BookOpen, Tag as TagIcon, Star, Globe, Layers, Archive
} from '@lucide/svelte';
import { onDestroy } from 'svelte';
import PdfInlinePreview from '$lib/components/trips/PdfInlinePreview.svelte';
const ICON_MAP: Record<string, any> = {
'home': Home, 'heart': Heart, 'briefcase': Briefcase, 'plane': Plane,
'moon': Moon, 'server': Server, 'truck': Truck, 'printer': Printer,
'file-text': FileText, 'folder': FolderIcon, 'book-open': BookOpen,
'tag': TagIcon, 'star': Star, 'globe': Globe, 'layers': Layers, 'archive': Archive,
};
function getFolderColor(folderName: string | null): string | null {
if (!folderName) return null;
const f = sidebarFolders.find(f => f.name === folderName);
return f?.color || null;
}
interface BrainItem {
id: string;
@@ -23,6 +38,17 @@
assets: { id: string; asset_type: string; filename: string; content_type: string | null }[];
}
interface BrainAddition {
id: string;
item_id: string;
source: string;
kind: string;
content: string;
metadata_json?: any;
created_at: string;
updated_at: string;
}
interface SidebarFolder { id: string; name: string; slug: string; color?: string; icon?: string; is_active: boolean; item_count: number; }
interface SidebarTag { id: string; name: string; slug: string; color?: string; icon?: string; is_active: boolean; item_count: number; }
@@ -35,6 +61,8 @@
let activeTagId = $state<string | null>(null);
let searchQuery = $state('');
let searching = $state(false);
type SortMode = 'discover' | 'newest' | 'oldest';
let sortMode = $state<SortMode>('discover');
// Sidebar
let sidebarFolders = $state<SidebarFolder[]>([]);
@@ -52,8 +80,116 @@
// Detail
let selectedItem = $state<BrainItem | null>(null);
let selectedAdditions = $state<BrainAddition[]>([]);
let editingNote = $state(false);
let editNoteContent = $state('');
let editingTags = $state(false);
let editingFolder = $state(false);
let tagInput = $state('');
async function addTagToItem(tag: string) {
if (!selectedItem || !tag.trim()) return;
const newTags = [...(selectedItem.tags || []), tag.trim()];
if (selectedItem.tags?.includes(tag.trim())) return;
try {
const updated = await api(`/items/${selectedItem.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tags: newTags }),
});
selectedItem = { ...selectedItem, tags: updated.tags };
tagInput = '';
await loadSidebar();
} catch {}
}
async function removeTagFromItem(tag: string) {
if (!selectedItem) return;
const newTags = (selectedItem.tags || []).filter(t => t !== tag);
try {
const updated = await api(`/items/${selectedItem.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tags: newTags }),
});
selectedItem = { ...selectedItem, tags: updated.tags };
await loadSidebar();
} catch {}
}
async function changeItemFolder(folderName: string) {
if (!selectedItem) return;
try {
const updated = await api(`/items/${selectedItem.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder: folderName }),
});
selectedItem = { ...selectedItem, folder: updated.folder };
editingFolder = false;
await loadSidebar();
await loadItems();
} catch {}
}
async function loadAdditions(itemId: string) {
try {
selectedAdditions = await api(`/items/${itemId}/additions`);
} catch {
selectedAdditions = [];
}
}
// PDF viewer
let pdfViewerHost = $state<HTMLDivElement | null>(null);
let pdfViewerLoading = $state(false);
async function renderPdfViewer(url: string) {
pdfViewerLoading = true;
await tick();
if (!pdfViewerHost) { pdfViewerLoading = false; return; }
pdfViewerHost.replaceChildren();
try {
const mod = await import('pdfjs-dist');
const pdfjs = mod.default ?? mod;
if (!pdfjs.GlobalWorkerOptions.workerSrc) {
pdfjs.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
}
const pdf = await pdfjs.getDocument({ url, withCredentials: true }).promise;
const containerWidth = Math.max(280, pdfViewerHost.clientWidth - 32);
const pixelRatio = Math.max(window.devicePixelRatio || 1, 1);
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const baseVp = page.getViewport({ scale: 1 });
const scale = containerWidth / baseVp.width;
const vp = page.getViewport({ scale });
const canvas = document.createElement('canvas');
canvas.width = Math.floor(vp.width * pixelRatio);
canvas.height = Math.floor(vp.height * pixelRatio);
canvas.style.width = `${vp.width}px`;
canvas.style.height = `${vp.height}px`;
canvas.style.borderRadius = '8px';
canvas.style.background = 'white';
canvas.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)';
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
await page.render({ canvas, canvasContext: ctx, viewport: vp }).promise;
}
pdfViewerHost.appendChild(canvas);
}
} catch (err) {
console.error('PDF render failed', err);
if (pdfViewerHost) pdfViewerHost.innerHTML = '<div style="padding:24px;text-align:center;color:#8c3c2d;">Failed to load PDF</div>';
} finally {
pdfViewerLoading = false;
}
}
function pdfAutoRender(node: HTMLDivElement, url: string) {
renderPdfViewer(url);
return { destroy() {} };
}
async function api(path: string, opts: RequestInit = {}) {
const res = await fetch(`/api/brain${path}`, { credentials: 'include', ...opts });
@@ -61,6 +197,80 @@
return res.json();
}
// ── Discovery shuffle (weighted random, refreshes every 10 min) ──
const SHUFFLE_TTL = 10 * 60 * 1000; // 10 minutes
function getShuffleSeed(): number {
const STORAGE_KEY = 'brain_shuffle';
try {
const stored = sessionStorage.getItem(STORAGE_KEY);
if (stored) {
const { seed, ts } = JSON.parse(stored);
if (Date.now() - ts < SHUFFLE_TTL) return seed;
}
} catch {}
const seed = Math.floor(Math.random() * 2147483647);
try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ seed, ts: Date.now() })); } catch {}
return seed;
}
function seededRandom(seed: number) {
let s = seed;
return () => {
s = (s * 16807 + 0) % 2147483647;
return s / 2147483647;
};
}
function weightedShuffle(allItems: BrainItem[]): BrainItem[] {
if (allItems.length <= 1) return allItems;
const now = Date.now();
const twoWeeksAgo = now - 14 * 24 * 60 * 60 * 1000;
const recent: BrainItem[] = [];
const older: BrainItem[] = [];
for (const item of allItems) {
const created = new Date(item.created_at).getTime();
if (created >= twoWeeksAgo) recent.push(item);
else older.push(item);
}
const rng = seededRandom(getShuffleSeed());
// Fisher-Yates with seeded RNG
const shuffle = (arr: BrainItem[]) => {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
};
const shuffledRecent = shuffle(recent);
const shuffledOlder = shuffle(older);
// Interleave: ~30% recent, ~70% older (by ratio)
const result: BrainItem[] = [];
let ri = 0, oi = 0;
while (ri < shuffledRecent.length || oi < shuffledOlder.length) {
// Decide: pick recent or older based on target ratio
const recentRatio = shuffledRecent.length / (shuffledRecent.length + shuffledOlder.length || 1);
if (ri < shuffledRecent.length && (oi >= shuffledOlder.length || rng() < recentRatio)) {
result.push(shuffledRecent[ri++]);
} else if (oi < shuffledOlder.length) {
result.push(shuffledOlder[oi++]);
}
}
return result;
}
function isDefaultView(): boolean {
return !activeFolder && !activeTag && !searchQuery.trim();
}
async function loadSidebar() {
try {
const data = await api('/taxonomy/sidebar');
@@ -73,12 +283,21 @@
async function loadItems() {
loading = true;
try {
const params = new URLSearchParams({ limit: '50' });
const fetchLimit = sortMode === 'discover' ? '100' : '50';
const params = new URLSearchParams({ limit: fetchLimit });
if (activeFolder) params.set('folder', activeFolder);
if (activeTag) params.set('tag', activeTag);
const data = await api(`/items?${params}`);
items = data.items || [];
let fetched = data.items || [];
total = data.total || 0;
if (sortMode === 'discover' && isDefaultView() && fetched.length > 1) {
items = weightedShuffle(fetched);
} else if (sortMode === 'oldest') {
items = [...fetched].reverse();
} else {
items = fetched;
}
} catch { /* silent */ }
loading = false;
}
@@ -210,10 +429,37 @@
} catch { /* silent */ }
}
function setSelectedItem(item: BrainItem | null) {
selectedItem = item;
editingNote = false;
editingFolder = false;
tagInput = '';
selectedAdditions = [];
if (typeof window === 'undefined') return;
const url = new URL(window.location.href);
if (item?.id) {
url.searchParams.set('item', item.id);
void loadAdditions(item.id);
} else {
url.searchParams.delete('item');
}
window.history.replaceState({}, '', url);
}
function combinedNoteContent(item: BrainItem | null, additions: BrainAddition[]): string {
if (!item) return '';
const parts = [
item.raw_content?.trim() || '',
...additions.map((addition) => addition.content.trim()).filter(Boolean)
].filter(Boolean);
return parts.join('\n\n');
}
async function deleteItem(id: string) {
try {
await api(`/items/${id}`, { method: 'DELETE' });
selectedItem = null;
setSelectedItem(null);
await loadItems();
} catch { /* silent */ }
}
@@ -320,6 +566,14 @@
await loadSidebar();
await loadItems();
const itemId = new URL(window.location.href).searchParams.get('item');
if (itemId) {
try {
const item = await api(`/items/${itemId}`);
setSelectedItem(item);
} catch { /* silent */ }
}
});
onDestroy(stopPolling);
@@ -365,8 +619,16 @@
{/if}
<nav class="sidebar-nav">
{#each sidebarFolders.filter(f => f.is_active) as folder}
<button class="nav-item" class:active={activeFolder === folder.name} onclick={() => { activeFolder = folder.name; activeFolderId = folder.id; activeTag = null; activeTagId = null; mobileSidebarOpen = false; loadItems(); }}>
{#if folder.color}<span class="nav-dot" style="background: {folder.color}"></span>{/if}
<button class="nav-item folder-nav-item" class:active={activeFolder === folder.name}
style={folder.color ? `--folder-color: ${folder.color}` : ''}
onclick={() => { activeFolder = folder.name; activeFolderId = folder.id; activeTag = null; activeTagId = null; mobileSidebarOpen = false; loadItems(); }}>
<span class="nav-icon" style={folder.color ? `color: ${folder.color}` : ''}>
{#if folder.icon && ICON_MAP[folder.icon]}
<svelte:component this={ICON_MAP[folder.icon]} size={15} strokeWidth={2} />
{:else}
<FolderIcon size={15} strokeWidth={2} />
{/if}
</span>
<span class="nav-label">{folder.name}</span>
{#if showManage}
<!-- svelte-ignore a11y_no_static_element_interactions --><span class="nav-delete" onclick={(e) => { e.stopPropagation(); if (confirm(`Delete "${folder.name}"? Items will be moved.`)) deleteTaxonomy(folder.id); }}>×</span>
@@ -482,6 +744,20 @@
</button>
{/if}
</div>
<div class="sort-bar">
<button class="sort-btn" class:active={sortMode === 'discover'} onclick={() => { sortMode = 'discover'; loadItems(); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M2 18h6l4-12 4 12h6"/></svg>
Discover
</button>
<button class="sort-btn" class:active={sortMode === 'newest'} onclick={() => { sortMode = 'newest'; loadItems(); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M12 5v14"/><path d="M19 12l-7 7-7-7"/></svg>
Newest
</button>
<button class="sort-btn" class:active={sortMode === 'oldest'} onclick={() => { sortMode = 'oldest'; loadItems(); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M12 19V5"/><path d="M5 12l7-7 7 7"/></svg>
Oldest
</button>
</div>
</div>
<!-- Masonry card grid -->
@@ -502,11 +778,15 @@
{:else}
<div class="masonry">
{#each items as item (item.id)}
<div class="card" class:is-note={item.type === 'note'} class:is-processing={item.processing_status !== 'ready'}>
<div class="card" class:is-note={item.type === 'note'} class:is-processing={item.processing_status !== 'ready'}
style={getFolderColor(item.folder) ? `--card-accent: ${getFolderColor(item.folder)}` : ''}>
<!-- Thumbnail: screenshot for links/PDFs, original image for images -->
{#if item.type === 'link' && item.assets?.some(a => a.asset_type === 'screenshot')}
{#if item.type === 'link' && item.assets?.some(a => a.asset_type === 'screenshot' || a.asset_type === 'og_image')}
{@const ogAsset = item.assets?.find(a => a.asset_type === 'og_image')}
{@const ssAsset = item.assets?.find(a => a.asset_type === 'screenshot')}
{@const thumbAsset = ogAsset || ssAsset}
<a class="card-thumb" href={item.url} target="_blank" rel="noopener">
<img src="/api/brain/storage/{item.id}/screenshot/screenshot.png" alt="" loading="lazy" />
<img src="/api/brain/storage/{item.id}/{thumbAsset?.asset_type}/{thumbAsset?.filename}" alt="" loading="lazy" />
{#if item.processing_status !== 'ready'}
<div class="card-processing-overlay">
<span class="processing-dot"></span>
@@ -515,17 +795,18 @@
{/if}
</a>
{:else if item.type === 'pdf' && item.assets?.some(a => a.asset_type === 'screenshot')}
<button class="card-thumb" onclick={() => { selectedItem = item; editingNote = false; }}>
<img src="/api/brain/storage/{item.id}/screenshot/screenshot.png" alt="" loading="lazy" />
{@const pdfSs = item.assets.find(a => a.asset_type === 'screenshot')}
<button class="card-thumb" onclick={() => setSelectedItem(item)}>
<img src="/api/brain/storage/{item.id}/screenshot/{pdfSs?.filename}" alt="" loading="lazy" />
<div class="card-type-badge">PDF</div>
</button>
{:else if item.type === 'image' && item.assets?.some(a => a.asset_type === 'original_upload')}
{@const imgAsset = item.assets.find(a => a.asset_type === 'original_upload')}
<button class="card-thumb" onclick={() => { selectedItem = item; editingNote = false; }}>
<button class="card-thumb" onclick={() => setSelectedItem(item)}>
<img src="/api/brain/storage/{item.id}/original_upload/{imgAsset?.filename}" alt="" loading="lazy" />
</button>
{:else if item.type === 'note'}
<button class="card-note-body" onclick={() => { selectedItem = item; editingNote = false; }}>
<button class="card-note-body" onclick={() => setSelectedItem(item)}>
{(item.raw_content || '').slice(0, 200)}{(item.raw_content || '').length > 200 ? '...' : ''}
</button>
{:else if item.processing_status !== 'ready'}
@@ -536,7 +817,7 @@
{/if}
<!-- Card content — click opens detail -->
<button class="card-content" onclick={() => { selectedItem = item; editingNote = false; }}>
<button class="card-content" onclick={() => setSelectedItem(item)}>
<div class="card-title">{item.title || 'Untitled'}</div>
{#if item.url}
<div class="card-domain">{(() => { try { return new URL(item.url).hostname; } catch { return ''; } })()}</div>
@@ -559,7 +840,7 @@
</div>
{/if}
<div class="card-meta">
{#if item.folder}<span class="card-folder">{item.folder}</span>{/if}
{#if item.folder}<span class="card-folder" style={getFolderColor(item.folder) ? `color: ${getFolderColor(item.folder)}` : ''}>{item.folder}</span>{/if}
<span class="card-date">{formatDate(item.created_at)}</span>
</div>
</div>
@@ -575,18 +856,19 @@
<!-- ═══ PDF/Image full-screen viewer ═══ -->
{#if selectedItem && (selectedItem.type === 'pdf' || selectedItem.type === 'image')}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="viewer-overlay" onclick={(e) => { if (e.target === e.currentTarget) selectedItem = null; }} onkeydown={(e) => { if (e.key === 'Escape') selectedItem = null; }}>
<div class="viewer-overlay" onclick={(e) => { if (e.target === e.currentTarget) setSelectedItem(null); }} onkeydown={(e) => { if (e.key === 'Escape') setSelectedItem(null); }}>
<div class="viewer-layout">
<!-- Main viewer area -->
<div class="viewer-main">
{#if selectedItem.type === 'pdf'}
{@const pdfAsset = selectedItem.assets?.find(a => a.asset_type === 'original_upload')}
{#if pdfAsset}
<iframe
src="/api/brain/storage/{selectedItem.id}/original_upload/{pdfAsset.filename}#zoom=page-fit"
title={selectedItem.title || 'PDF'}
class="viewer-iframe"
></iframe>
<div class="viewer-pdf-wrap" bind:this={pdfViewerHost}
use:pdfAutoRender={`/api/brain/storage/${selectedItem.id}/original_upload/${pdfAsset.filename}`}>
{#if pdfViewerLoading}
<div class="viewer-pdf-loading">Rendering PDF...</div>
{/if}
</div>
{/if}
{:else if selectedItem.type === 'image'}
{@const imgAsset = selectedItem.assets?.find(a => a.asset_type === 'original_upload')}
@@ -605,7 +887,7 @@
<div class="detail-type">{selectedItem.type === 'pdf' ? 'PDF Document' : 'Image'}</div>
<h2 class="detail-title">{selectedItem.title || 'Untitled'}</h2>
</div>
<button class="close-btn" onclick={() => selectedItem = null}>
<button class="close-btn" onclick={() => setSelectedItem(null)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
@@ -614,16 +896,41 @@
<div class="detail-summary">{selectedItem.summary}</div>
{/if}
{#if selectedItem.tags && selectedItem.tags.length > 0}
<div class="detail-tags">
{#each selectedItem.tags as tag}
<button class="detail-tag" onclick={() => { selectedItem = null; activeTag = tag; activeFolder = null; loadItems(); }}>{tag}</button>
{/each}
</div>
{/if}
<div class="detail-tags">
{#each (selectedItem.tags || []) as tag}
<span class="detail-tag editable">
{tag}
<button class="tag-remove" onclick={() => removeTagFromItem(tag)}>×</button>
</span>
{/each}
<span class="tag-add-wrap">
<input class="tag-add-input" placeholder="+ tag" bind:value={tagInput}
onkeydown={(e) => { if (e.key === 'Enter' && tagInput.trim()) addTagToItem(tagInput); }}
list="available-tags" />
</span>
</div>
<div class="detail-meta-line">
{#if selectedItem.folder}<span class="meta-folder-pill">{selectedItem.folder}</span>{/if}
{#if editingFolder}
<div class="folder-picker">
{#each sidebarFolders.filter(f => f.is_active) as folder}
<button class="folder-pick-btn" class:current={selectedItem.folder === folder.name}
style={folder.color ? `border-color: ${folder.color}` : ''}
onclick={() => changeItemFolder(folder.name)}>
{#if folder.icon && ICON_MAP[folder.icon]}
<svelte:component this={ICON_MAP[folder.icon]} size={13} strokeWidth={2} />
{/if}
{folder.name}
</button>
{/each}
</div>
{:else}
{#if selectedItem.folder}
<button class="meta-folder-pill clickable" style={getFolderColor(selectedItem.folder) ? `background: color-mix(in srgb, ${getFolderColor(selectedItem.folder)} 14%, transparent); color: ${getFolderColor(selectedItem.folder)}` : ''} onclick={() => editingFolder = true}>{selectedItem.folder}</button>
{:else}
<button class="meta-folder-pill clickable" onclick={() => editingFolder = true}>Set folder</button>
{/if}
{/if}
<span>{formatDate(selectedItem.created_at)}</span>
{#if selectedItem.metadata_json?.page_count}
<span>{selectedItem.metadata_json.page_count} page{selectedItem.metadata_json.page_count !== 1 ? 's' : ''}</span>
@@ -641,8 +948,8 @@
{#if selectedItem.assets?.some(a => a.asset_type === 'original_upload')}
<a class="action-btn" href="/api/brain/storage/{selectedItem.id}/original_upload/{selectedItem.assets.find(a => a.asset_type === 'original_upload')?.filename}" target="_blank" rel="noopener">Download</a>
{/if}
<button class="action-btn ghost" onclick={() => reprocessItem(selectedItem.id)}>Reclassify</button>
<button class="action-btn ghost" onclick={() => { if (confirm('Delete this item?')) deleteItem(selectedItem.id); }}>Delete</button>
<button class="action-btn ghost" onclick={() => selectedItem && reprocessItem(selectedItem.id)}>Reclassify</button>
<button class="action-btn ghost" onclick={() => { if (selectedItem && confirm('Delete this item?')) deleteItem(selectedItem.id); }}>Delete</button>
</div>
</div>
</div>
@@ -651,21 +958,22 @@
<!-- ═══ Detail sheet for links/notes ═══ -->
{:else if selectedItem}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="detail-overlay" onclick={(e) => { if (e.target === e.currentTarget) selectedItem = null; }} onkeydown={(e) => { if (e.key === 'Escape') selectedItem = null; }}>
<div class="detail-overlay" onclick={(e) => { if (e.target === e.currentTarget) setSelectedItem(null); }} onkeydown={(e) => { if (e.key === 'Escape') setSelectedItem(null); }}>
<div class="detail-sheet">
<div class="detail-header">
<div>
<div class="detail-type">{selectedItem.type}</div>
<h2 class="detail-title">{selectedItem.title || 'Untitled'}</h2>
</div>
<button class="close-btn" onclick={() => selectedItem = null}>
<button class="close-btn" onclick={() => setSelectedItem(null)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
{#if selectedItem.type === 'link' && selectedItem.assets?.some(a => a.asset_type === 'screenshot')}
{#if selectedItem.type === 'link' && selectedItem.assets?.some(a => a.asset_type === 'screenshot' || a.asset_type === 'og_image')}
{@const detailImg = selectedItem.assets?.find(a => a.asset_type === 'og_image') || selectedItem.assets?.find(a => a.asset_type === 'screenshot')}
<a class="detail-screenshot" href={selectedItem.url} target="_blank" rel="noopener">
<img src="/api/brain/storage/{selectedItem.id}/screenshot/screenshot.png" alt="" />
<img src="/api/brain/storage/{selectedItem.id}/{detailImg?.asset_type}/{detailImg?.filename}" alt="" />
</a>
{/if}
@@ -691,22 +999,61 @@
</div>
{:else}
<button class="content-body clickable" onclick={startEditNote}>
{selectedItem.raw_content || 'Empty note — click to edit'}
{combinedNoteContent(selectedItem, selectedAdditions) || 'Empty note — click to edit'}
</button>
{/if}
</div>
{/if}
{#if selectedItem.tags && selectedItem.tags.length > 0}
<div class="detail-tags">
{#each selectedItem.tags as tag}
<button class="detail-tag" onclick={() => { selectedItem = null; activeTag = tag; activeFolder = null; loadItems(); }}>{tag}</button>
{/each}
{#if selectedItem.type !== 'note' && selectedAdditions.length > 0}
<div class="detail-content">
<div class="extracted-label">Notes</div>
<div class="addition-list">
{#each selectedAdditions as addition}
<div class="addition-row">
<div class="addition-body">{addition.content}</div>
<div class="addition-meta">{formatDate(addition.created_at)}</div>
</div>
{/each}
</div>
</div>
{/if}
<div class="detail-tags">
{#each (selectedItem.tags || []) as tag}
<span class="detail-tag editable">
{tag}
<button class="tag-remove" onclick={() => removeTagFromItem(tag)}>×</button>
</span>
{/each}
<span class="tag-add-wrap">
<input class="tag-add-input" placeholder="+ tag" bind:value={tagInput}
onkeydown={(e) => { if (e.key === 'Enter' && tagInput.trim()) addTagToItem(tagInput); }}
list="available-tags" />
</span>
</div>
<div class="detail-meta-line">
{#if selectedItem.folder}<span class="meta-folder-pill">{selectedItem.folder}</span>{/if}
{#if editingFolder}
<div class="folder-picker">
{#each sidebarFolders.filter(f => f.is_active) as folder}
<button class="folder-pick-btn" class:current={selectedItem.folder === folder.name}
style={folder.color ? `border-color: ${folder.color}` : ''}
onclick={() => changeItemFolder(folder.name)}>
{#if folder.icon && ICON_MAP[folder.icon]}
<svelte:component this={ICON_MAP[folder.icon]} size={13} strokeWidth={2} />
{/if}
{folder.name}
</button>
{/each}
</div>
{:else}
{#if selectedItem.folder}
<button class="meta-folder-pill clickable" style={getFolderColor(selectedItem.folder) ? `background: color-mix(in srgb, ${getFolderColor(selectedItem.folder)} 14%, transparent); color: ${getFolderColor(selectedItem.folder)}` : ''} onclick={() => editingFolder = true}>{selectedItem.folder}</button>
{:else}
<button class="meta-folder-pill clickable" onclick={() => editingFolder = true}>Set folder</button>
{/if}
{/if}
<span>{formatDate(selectedItem.created_at)}</span>
</div>
@@ -714,13 +1061,19 @@
{#if selectedItem.url}
<a class="action-btn" href={selectedItem.url} target="_blank" rel="noopener">Open original</a>
{/if}
<button class="action-btn ghost" onclick={() => reprocessItem(selectedItem.id)}>Reclassify</button>
<button class="action-btn ghost" onclick={() => { if (confirm('Delete this item?')) deleteItem(selectedItem.id); }}>Delete</button>
<button class="action-btn ghost" onclick={() => selectedItem && reprocessItem(selectedItem.id)}>Reclassify</button>
<button class="action-btn ghost" onclick={() => { if (selectedItem && confirm('Delete this item?')) deleteItem(selectedItem.id); }}>Delete</button>
</div>
</div>
</div>
{/if}
<datalist id="available-tags">
{#each sidebarTags.filter(t => t.is_active) as tag}
<option value={tag.name}></option>
{/each}
</datalist>
<style>
/* No .page wrapper — brain-layout is the root */
@@ -935,6 +1288,17 @@
.nav-dot {
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
}
.nav-icon {
display: flex; align-items: center; justify-content: center;
width: 22px; height: 22px; flex-shrink: 0;
border-radius: 6px;
background: color-mix(in srgb, var(--folder-color, #8c7b69) 12%, transparent);
}
.folder-nav-item.active {
background: color-mix(in srgb, var(--folder-color, #8c7b69) 10%, rgba(255,248,241,0.9));
border-left: 2px solid var(--folder-color, transparent);
padding-left: 8px;
}
@media (max-width: 768px) {
.mobile-pills { display: flex; }
@@ -963,6 +1327,37 @@
/* ═══ Search ═══ */
.search-section { margin-bottom: 18px; }
.sort-bar {
display: flex;
gap: 4px;
margin-top: 10px;
}
.sort-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid rgba(35,26,17,0.1);
background: transparent;
color: #8c7b69;
font-size: 0.78rem;
font-weight: 500;
font-family: var(--font);
cursor: pointer;
transition: all 160ms;
}
.sort-btn:hover {
background: rgba(255,248,241,0.8);
color: #5d5248;
}
.sort-btn.active {
background: rgba(255,248,241,0.95);
color: #1e1812;
font-weight: 600;
border-color: rgba(35,26,17,0.2);
}
.search-wrap { position: relative; }
.search-icon { position: absolute; left: 16px; top: 50%; transform: translateY(-50%); width: 18px; height: 18px; color: #7f7365; pointer-events: none; }
.search-input {
@@ -996,10 +1391,17 @@
text-align: left;
transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease;
}
.card::before {
content: '';
display: block;
height: 3px;
background: var(--card-accent, transparent);
border-radius: 20px 20px 0 0;
}
.card:hover {
transform: translateY(-3px);
box-shadow: 0 12px 32px rgba(42,30,19,0.08);
border-color: rgba(35,26,17,0.14);
border-color: var(--card-accent, rgba(35,26,17,0.14));
}
.card:active { transform: scale(0.985); }
@@ -1195,11 +1597,22 @@
background: #f5f0ea;
}
.viewer-iframe {
.viewer-pdf-wrap {
width: 100%;
height: 100%;
border: none;
background: white;
overflow-y: auto;
padding: 16px;
background: rgba(245,240,234,0.5);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.viewer-pdf-loading {
padding: 48px 24px;
text-align: center;
color: rgba(90, 71, 54, 0.7);
font-size: 0.92rem;
}
.viewer-image-wrap {
@@ -1326,6 +1739,34 @@
white-space: pre-wrap; word-break: break-word;
font-family: var(--mono);
}
.addition-list {
display: grid;
gap: 10px;
padding: 14px 16px;
background: rgba(255,255,255,0.5);
border-radius: 14px;
border: 1px solid rgba(35,26,17,0.06);
}
.addition-row {
padding-bottom: 10px;
border-bottom: 1px solid rgba(35,26,17,0.08);
}
.addition-row:last-child {
padding-bottom: 0;
border-bottom: none;
}
.addition-body {
font-size: 0.94rem;
color: #2c241d;
line-height: 1.65;
white-space: pre-wrap;
word-break: break-word;
}
.addition-meta {
margin-top: 6px;
font-size: 0.74rem;
color: #8c7b69;
}
.detail-meta-line {
display: flex; gap: 12px; font-size: 0.8rem; color: #8c7b69;
@@ -1334,7 +1775,7 @@
/* meta grid removed — folder/date shown inline */
.detail-tags { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; }
.detail-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 20px; align-items: center; }
.detail-tag {
background: rgba(35,26,17,0.06); padding: 4px 12px;
border-radius: 999px; font-size: 0.85rem; color: #5d5248;
@@ -1342,6 +1783,48 @@
transition: background 160ms;
}
.detail-tag:hover { background: rgba(179,92,50,0.12); color: #1e1812; }
.detail-tag.editable {
display: inline-flex; align-items: center; gap: 4px; cursor: default;
}
.tag-remove {
background: none; border: none; color: rgba(93,82,72,0.5);
font-size: 0.9rem; cursor: pointer; padding: 0 2px; line-height: 1;
transition: color 160ms;
}
.tag-remove:hover { color: #8f3928; }
.tag-add-wrap { display: inline-flex; }
.tag-add-input {
width: 80px; padding: 4px 10px; border-radius: 999px;
border: 1px dashed rgba(35,26,17,0.15); background: none;
font-size: 0.82rem; color: #5d5248; font-family: var(--font);
outline: none; transition: border-color 160ms, width 160ms;
}
.tag-add-input:focus { border-color: rgba(179,92,50,0.4); width: 120px; border-style: solid; }
.tag-add-input::placeholder { color: rgba(93,82,72,0.4); }
.meta-folder-pill.clickable {
cursor: pointer; border: none; font-family: var(--font); font-size: inherit;
transition: opacity 160ms;
}
.meta-folder-pill.clickable:hover { opacity: 0.7; }
.folder-picker {
display: flex; flex-wrap: wrap; gap: 6px;
}
.folder-pick-btn {
display: inline-flex; align-items: center; gap: 5px;
padding: 4px 10px; border-radius: 999px;
border: 1.5px solid rgba(35,26,17,0.12);
background: rgba(255,252,248,0.8); color: #5d5248;
font-size: 0.8rem; font-family: var(--font); font-weight: 500;
cursor: pointer; transition: all 160ms;
}
.folder-pick-btn:hover { background: rgba(255,248,241,0.95); }
.folder-pick-btn.current {
font-weight: 700; color: #1e1812;
background: rgba(255,248,241,0.95);
box-shadow: inset 0 0 0 1px currentColor;
}
.detail-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.action-btn {
@@ -1385,8 +1868,8 @@
.brain-layout { margin: 0; }
.masonry { columns: 1; }
.detail-sheet { width: 100%; padding: 20px; }
.viewer-overlay { padding: 0; }
.viewer-layout { grid-template-columns: 1fr; grid-template-rows: 55vh 1fr; width: 100%; height: 100vh; border-radius: 0; max-width: 100%; }
.viewer-sidebar { max-height: none; border-left: none; border-top: 1px solid rgba(35,26,17,0.08); overflow-y: auto; }
.viewer-overlay { padding: 10px; }
.viewer-layout { grid-template-columns: 1fr; grid-template-rows: 1fr auto; width: 100%; height: 90vh; border-radius: 18px; }
.viewer-sidebar { max-height: 40vh; border-left: none; border-top: 1px solid rgba(35,26,17,0.08); overflow-y: auto; }
}
</style>

View File

@@ -531,6 +531,65 @@
resolveError = '';
}
// ── Add Food to Meal ──
let addFoodItem = $state<FoodItem | null>(null);
let addFoodDetail = $state<any>(null);
let addQty = $state('1');
let addUnit = $state('serving');
let addMeal = $state<MealType>(guessCurrentMeal());
let addingToMeal = $state(false);
function openAddToMeal(food: FoodItem) {
addFoodItem = food;
addQty = '1';
addMeal = guessCurrentMeal();
addUnit = 'serving';
addFoodDetail = null;
// Fetch full food data
fetch(`/api/fitness/foods/${food.id}`, { credentials: 'include' })
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data) {
addFoodDetail = data;
addUnit = data.base_unit || 'serving';
}
})
.catch(() => {});
}
function closeAddToMeal() {
addFoodItem = null;
addFoodDetail = null;
}
async function confirmAddToMeal() {
if (!addFoodItem) return;
addingToMeal = true;
try {
const res = await fetch('/api/fitness/entries', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
food_id: addFoodItem.id,
quantity: parseFloat(addQty) || 1,
unit: addUnit,
meal_type: addMeal,
entry_date: selectedDate,
entry_method: 'search',
source: 'web'
})
});
if (res.ok) {
addFoodItem = null;
addFoodDetail = null;
activeTab = 'log';
await loadDayData();
}
} catch { /* silent */ }
addingToMeal = false;
}
// ── Food Edit/Delete ──
let editingFood = $state<any>(null);
let editFoodName = $state('');
@@ -1038,34 +1097,34 @@
<div class="macro-row">
<div class="macro-item">
<div class="macro-card-head">
<span class="macro-name">Protein</span>
<span class="macro-left">{macroLeft(totals.protein, goal.protein)}</span>
<span class="macro-name">Fiber</span>
<span class="macro-left">{macroLeft(totals.fiber, goal.fiber)}</span>
</div>
<div class="macro-bar-wrap">
<div class="macro-bar-fill protein" style="width:{Math.min(totals.protein / goal.protein * 100, 100)}%"></div>
<div class="macro-bar-fill fiber" style="width:{nutrientPercent(totals.fiber, goal.fiber)}%"></div>
</div>
<div class="macro-label">
<span class="macro-current">{totals.protein}g</span>
<span class="macro-target">/ {goal.protein}g</span>
<span class="macro-current">{totals.fiber}g</span>
<span class="macro-target">{goal.fiber > 0 ? `/ ${goal.fiber}g` : '/ tracking only'}</span>
</div>
<div class="macro-guidance">
<div class="macro-instruction">{macroInstruction('protein', totals.protein, goal.protein, caloriesRemaining)}</div>
<div class="macro-instruction">{extraInstruction('fiber', totals.fiber, goal.fiber)}</div>
</div>
</div>
<div class="macro-item">
<div class="macro-card-head">
<span class="macro-name">Carbs</span>
<span class="macro-left">{macroLeft(totals.carbs, goal.carbs)}</span>
<span class="macro-name">Sugar</span>
<span class="macro-left">{macroLeft(totals.sugar, goal.sugar)}</span>
</div>
<div class="macro-bar-wrap">
<div class="macro-bar-fill carbs" style="width:{Math.min(totals.carbs / goal.carbs * 100, 100)}%"></div>
<div class="macro-bar-fill sugar" style="width:{nutrientPercent(totals.sugar, goal.sugar)}%"></div>
</div>
<div class="macro-label">
<span class="macro-current">{totals.carbs}g</span>
<span class="macro-target">/ {goal.carbs}g</span>
<span class="macro-current">{totals.sugar}g</span>
<span class="macro-target">{goal.sugar > 0 ? `/ ${goal.sugar}g` : '/ tracking only'}</span>
</div>
<div class="macro-guidance">
<div class="macro-instruction">{macroInstruction('carbs', totals.carbs, goal.carbs, caloriesRemaining)}</div>
<div class="macro-instruction">{extraInstruction('sugar', totals.sugar, goal.sugar)}</div>
</div>
</div>
<div class="macro-item">
@@ -1091,34 +1150,34 @@
<div class="extra-nutrients-row">
<div class="macro-item extra-item sugar-item">
<div class="macro-card-head">
<span class="macro-name">Sugar</span>
<span class="macro-left">{macroLeft(totals.sugar, goal.sugar)}</span>
<span class="macro-name">Carbs</span>
<span class="macro-left">{macroLeft(totals.carbs, goal.carbs)}</span>
</div>
<div class="macro-bar-wrap">
<div class="macro-bar-fill sugar" style="width:{nutrientPercent(totals.sugar, goal.sugar)}%"></div>
<div class="macro-bar-fill carbs" style="width:{Math.min(totals.carbs / goal.carbs * 100, 100)}%"></div>
</div>
<div class="macro-label">
<span class="macro-current">{totals.sugar}g</span>
<span class="macro-target">{goal.sugar > 0 ? `/ ${goal.sugar}g` : '/ tracking only'}</span>
<span class="macro-current">{totals.carbs}g</span>
<span class="macro-target">/ {goal.carbs}g</span>
</div>
<div class="macro-guidance">
<div class="macro-instruction">{extraInstruction('sugar', totals.sugar, goal.sugar)}</div>
<div class="macro-instruction">{macroInstruction('carbs', totals.carbs, goal.carbs, caloriesRemaining)}</div>
</div>
</div>
<div class="macro-item extra-item fiber-item">
<div class="macro-card-head">
<span class="macro-name">Fiber</span>
<span class="macro-left">{macroLeft(totals.fiber, goal.fiber)}</span>
<span class="macro-name">Protein</span>
<span class="macro-left">{macroLeft(totals.protein, goal.protein)}</span>
</div>
<div class="macro-bar-wrap">
<div class="macro-bar-fill fiber" style="width:{nutrientPercent(totals.fiber, goal.fiber)}%"></div>
<div class="macro-bar-fill protein" style="width:{Math.min(totals.protein / goal.protein * 100, 100)}%"></div>
</div>
<div class="macro-label">
<span class="macro-current">{totals.fiber}g</span>
<span class="macro-target">{goal.fiber > 0 ? `/ ${goal.fiber}g` : '/ tracking only'}</span>
<span class="macro-current">{totals.protein}g</span>
<span class="macro-target">/ {goal.protein}g</span>
</div>
<div class="macro-guidance">
<div class="macro-instruction">{extraInstruction('fiber', totals.fiber, goal.fiber)}</div>
<div class="macro-instruction">{macroInstruction('protein', totals.protein, goal.protein, caloriesRemaining)}</div>
</div>
</div>
</div>
@@ -1217,6 +1276,28 @@
</div>
{#if expandedEntry === entry.id}
<div class="entry-actions">
<div class="entry-nutrition-grid">
<div class="entry-nutrient">
<span class="entry-nutrient-label">Protein</span>
<span class="entry-nutrient-value">{entry.protein}g</span>
</div>
<div class="entry-nutrient">
<span class="entry-nutrient-label">Carbs</span>
<span class="entry-nutrient-value">{entry.carbs}g</span>
</div>
<div class="entry-nutrient">
<span class="entry-nutrient-label">Fat</span>
<span class="entry-nutrient-value">{entry.fat}g</span>
</div>
<div class="entry-nutrient">
<span class="entry-nutrient-label">Sugar</span>
<span class="entry-nutrient-value">{entry.sugar}g</span>
</div>
<div class="entry-nutrient">
<span class="entry-nutrient-label">Fiber</span>
<span class="entry-nutrient-value">{entry.fiber}g</span>
</div>
</div>
<div class="entry-qty-control">
<input
type="number"
@@ -1276,19 +1357,22 @@
<div class="list-card">
{#each filteredFoods as food (food.id || `${food.name}-${food.info}-${food.calories}`)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="list-row" onclick={() => openFoodEdit(food)}>
<div class="list-row-info">
<div class="list-row">
<div class="list-row-info" onclick={() => openFoodEdit(food)} style="cursor:pointer;flex:1;min-width:0;">
<div class="list-row-name">
{food.name}
{#if food.favorite}
<svg class="fav-icon" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
{/if}
</div>
<div class="list-row-meta">{food.info}</div>
<div class="list-row-meta">{food.info} · {food.calories} cal</div>
</div>
<div class="list-row-right">
<span class="list-row-value">{food.calories} cal</span>
<svg class="list-row-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
<button class="log-btn" onclick={(e) => { e.stopPropagation(); openAddToMeal(food); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="14" height="14" style="display:inline;vertical-align:-2px;margin-right:4px;"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add
</button>
<svg class="list-row-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" onclick={() => openFoodEdit(food)} style="cursor:pointer;"><path d="M9 18l6-6-6-6"/></svg>
</div>
</div>
{/each}
@@ -1419,6 +1503,61 @@
</div>
{/if}
<!-- Add food to meal modal -->
{#if addFoodItem}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="resolve-overlay" onclick={closeAddToMeal}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="resolve-modal" onclick={(e) => e.stopPropagation()}>
<div class="resolve-modal-header">
<div class="resolve-modal-title">Add to log</div>
<button class="resolve-modal-close" onclick={closeAddToMeal}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="resolve-modal-body">
<div class="add-food-name">{addFoodItem.name}</div>
<div class="add-food-base">{addFoodItem.calories} cal per 1 {addUnit}</div>
<div class="food-edit-row" style="margin-top:16px;">
<div class="food-edit-field">
<label class="food-edit-label">Quantity</label>
<input class="food-edit-input" type="number" step="0.25" min="0.25" bind:value={addQty} />
</div>
<div class="food-edit-field">
<label class="food-edit-label">Unit</label>
<input class="food-edit-input" type="text" bind:value={addUnit} />
</div>
</div>
<div class="food-edit-field" style="margin-top:12px;">
<label class="food-edit-label">Meal</label>
<div class="meal-picker">
{#each mealTypes as meal}
<button class="meal-pick-btn" class:active={addMeal === meal} onclick={() => addMeal = meal}>
{meal}
</button>
{/each}
</div>
</div>
<div class="add-to-meal-preview" style="margin-top:14px;">
{Math.round((parseFloat(addQty) || 1) * (addFoodDetail?.calories_per_base ?? addFoodItem.calories))} cal
· {Math.round((parseFloat(addQty) || 1) * (addFoodDetail?.protein_per_base ?? addFoodItem.protein ?? 0))}g protein
· {Math.round((parseFloat(addQty) || 1) * (addFoodDetail?.carbs_per_base ?? addFoodItem.carbs ?? 0))}g carbs
· {Math.round((parseFloat(addQty) || 1) * (addFoodDetail?.fat_per_base ?? addFoodItem.fat ?? 0))}g fat
</div>
</div>
<div class="resolve-modal-footer">
<button class="btn-secondary" onclick={closeAddToMeal}>Cancel</button>
<button class="btn-primary" onclick={confirmAddToMeal} disabled={addingToMeal}>
{addingToMeal ? 'Adding...' : 'Add to ' + addMeal}
</button>
</div>
</div>
</div>
{/if}
<!-- Resolve confirmation modal -->
{#if resolvedItems.length > 0}
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -2424,18 +2563,48 @@
}
.entry-actions {
display: flex;
justify-content: space-between;
align-items: center;
display: grid;
gap: 12px;
padding: 12px 16px 0;
}
.entry-nutrition-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
}
.entry-nutrient {
display: grid;
gap: 4px;
padding: 10px 10px 9px;
border-radius: 14px;
background: rgba(255,255,255,0.68);
border: 1px solid rgba(44,31,19,0.07);
}
.entry-nutrient-label {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fitness-muted);
font-weight: 700;
}
.entry-nutrient-value {
font-family: var(--mono);
font-size: 0.95rem;
font-weight: 600;
color: var(--fitness-text);
line-height: 1;
}
.entry-qty-control {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: space-between;
}
.entry-qty-input {
@@ -2910,8 +3079,12 @@
.resolve-modal {
width: min(520px, calc(100vw - 24px));
max-height: calc(100vh - 48px);
max-height: calc(100dvh - 48px);
border-radius: 28px;
overflow: hidden;
display: flex;
flex-direction: column;
animation: modalUp 180ms ease;
}
@@ -2963,6 +3136,9 @@
.resolve-modal-body {
padding: 20px 22px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.resolve-item + .resolve-item {
@@ -3071,6 +3247,46 @@
margin-top: 12px;
}
.meal-picker {
display: flex;
gap: 4px;
}
.meal-pick-btn {
padding: 8px 12px;
border-radius: 10px;
border: 1px solid var(--fitness-line);
background: var(--fitness-surface);
font-size: 0.82rem;
font-family: var(--font);
color: var(--fitness-muted);
cursor: pointer;
text-transform: capitalize;
transition: all 160ms;
}
.meal-pick-btn.active {
background: var(--fitness-ink);
color: white;
border-color: var(--fitness-ink);
}
.add-food-name {
font-size: 1.1rem;
font-weight: 600;
color: var(--fitness-ink);
}
.add-food-base {
font-size: 0.85rem;
color: var(--fitness-muted);
margin-top: 2px;
}
.add-to-meal-preview {
font-size: 0.85rem;
font-family: var(--mono);
color: var(--fitness-muted);
padding: 10px 14px;
border-radius: 12px;
background: var(--fitness-surface);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@@ -3302,6 +3518,14 @@
padding: 14px;
}
.entry-nutrition-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.entry-qty-control {
justify-content: flex-start;
}
.food-edit-row {
grid-template-columns: 1fr;
}

View File

@@ -29,16 +29,12 @@
let scrollRAF: number | null = null;
let scrollInterval: ReturnType<typeof setInterval> | null = null;
let lastScrollTs = 0;
let scrollCarry = 0;
let lastAutoCheckTs = 0;
let mobileAutoStartScrollY = 0;
let loading = $state(true);
let loadingMore = $state(false);
let hasMore = $state(true);
let totalUnread = $state(0);
const LIMIT = 50;
let feedCounters: Record<string, number> = {};
let stagedAutoReadIds = new Set<number>();
// ── Helpers ──
function timeAgo(dateStr: string): string {
@@ -173,65 +169,113 @@
}
async function markAllReadAPI() {
const ids = articles.filter(a => !a.read).map(a => a.id);
if (!ids.length) return;
try {
await api('/entries', {
const body: any = {};
if (activeFeedId) body.feed_id = activeFeedId;
await api('/entries/mark-all-read', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entry_ids: ids, status: 'read' })
body: JSON.stringify(body)
});
} catch { /* silent */ }
}
// ── Karakeep ──
let karakeepIds = $state<Record<number, string>>({});
let savingToKarakeep = $state<Set<number>>(new Set());
// ── Feed management ──
let showFeedManager = $state(false);
let addFeedUrl = $state('');
let addFeedCategoryId = $state<number | null>(null);
let addingFeed = $state(false);
async function toggleKarakeep(article: Article, e?: Event) {
async function addFeed() {
if (!addFeedUrl.trim()) return;
addingFeed = true;
try {
const body: any = { feed_url: addFeedUrl.trim() };
if (addFeedCategoryId) body.category_id = addFeedCategoryId;
await api('/feeds', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
addFeedUrl = '';
await loadSidebar();
} catch (e) {
alert('Failed to add feed. Check the URL.');
}
addingFeed = false;
}
async function deleteFeed(feedId: number, feedName: string) {
if (!confirm(`Delete "${feedName}" and all its articles?`)) return;
try {
await api(`/feeds/${feedId}`, { method: 'DELETE' });
if (activeFeedId === feedId) { activeFeedId = null; activeNav = 'Today'; }
await loadSidebar();
await loadEntries();
} catch { /* silent */ }
}
let refreshing = $state(false);
async function refreshFeed(feedId: number) {
refreshing = true;
try {
await api(`/feeds/${feedId}/refresh`, { method: 'POST' });
await loadSidebar();
await loadEntries();
} catch { /* silent */ }
refreshing = false;
}
async function refreshAllFeeds() {
refreshing = true;
try {
await api('/feeds/refresh-all', { method: 'POST' });
await loadSidebar();
await loadEntries();
} catch { /* silent */ }
refreshing = false;
}
// ── Save to Brain ──
let brainSavedIds = $state<Record<number, string>>({});
let savingToBrain = $state<Set<number>>(new Set());
async function toggleBrainSave(article: Article, e?: Event) {
e?.stopPropagation();
e?.preventDefault();
if (savingToKarakeep.has(article.id)) return;
if (savingToBrain.has(article.id)) return;
const articleUrl = article.url || '';
console.log('Karakeep: saving', article.id, articleUrl);
if (!articleUrl && !karakeepIds[article.id]) {
console.log('Karakeep: no URL, skipping');
return;
}
savingToKarakeep = new Set([...savingToKarakeep, article.id]);
if (!articleUrl && !brainSavedIds[article.id]) return;
savingToBrain = new Set([...savingToBrain, article.id]);
try {
if (karakeepIds[article.id]) {
const res = await fetch('/api/karakeep/delete', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: karakeepIds[article.id] })
if (brainSavedIds[article.id]) {
// Un-save: delete from Brain
await fetch(`/api/brain/items/${brainSavedIds[article.id]}`, {
method: 'DELETE', credentials: 'include',
});
console.log('Karakeep delete:', res.status);
const next = { ...karakeepIds };
const next = { ...brainSavedIds };
delete next[article.id];
karakeepIds = next;
brainSavedIds = next;
} else {
const res = await fetch('/api/karakeep/save', {
// Save to Brain
const res = await fetch('/api/brain/items', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: articleUrl })
body: JSON.stringify({ type: 'link', url: articleUrl, title: article.title || undefined })
});
const text = await res.text();
console.log('Karakeep save:', res.status, text);
try {
const data = JSON.parse(text);
if (res.ok && data.ok) {
karakeepIds = { ...karakeepIds, [article.id]: data.id };
console.log('Karakeep: saved as', data.id);
}
} catch { console.error('Karakeep: bad JSON response'); }
if (res.ok) {
const data = await res.json();
brainSavedIds = { ...brainSavedIds, [article.id]: data.id };
}
}
} catch (err) {
console.error('Karakeep error:', err);
console.error('Brain save error:', err);
} finally {
const next = new Set(savingToKarakeep);
const next = new Set(savingToBrain);
next.delete(article.id);
savingToKarakeep = next;
savingToBrain = next;
}
}
@@ -261,6 +305,26 @@
markEntryRead(article.id);
decrementUnread();
}
// Auto-fetch full article content if we only have RSS summary
if (article.content && article.content.length < 1500) {
fetchFullContent(article);
}
}
async function fetchFullContent(article: Article) {
try {
const data = await api(`/entries/${article.id}/fetch-full-content`, { method: 'POST' });
if (data.full_content || data.content) {
const fullHtml = data.full_content || data.content;
if (fullHtml.length > (article.content?.length || 0)) {
article.content = fullHtml;
if (selectedArticle?.id === article.id) {
selectedArticle = { ...article };
}
articles = [...articles];
}
}
} catch { /* silent — keep RSS summary */ }
}
function closeArticle() { selectedArticle = null; }
@@ -280,11 +344,18 @@
else { markEntryUnread(article.id); totalUnread++; navItems[0].count = totalUnread; navItems = [...navItems]; }
}
function markAllRead() {
const unreadCount = articles.filter(a => !a.read).length;
markAllReadAPI();
async function markAllRead() {
const label = activeFeedId
? feedCategories.flatMap(c => c.feeds).find(f => f.id === activeFeedId)?.name || 'this feed'
: 'all feeds';
if (!confirm(`Mark all unread in ${label} as read?`)) return;
await markAllReadAPI();
articles = articles.map(a => ({ ...a, read: true }));
decrementUnread(unreadCount);
totalUnread = 0;
navItems[0].count = 0;
navItems = [...navItems];
// Refresh counters
await loadSidebar();
}
function goNext() {
@@ -314,7 +385,7 @@
// ── Auto-scroll (requestAnimationFrame for smoothness) ──
function usesPageScroll(): boolean {
return window.matchMedia('(max-width: 1024px)').matches && !autoScrollActive;
return window.matchMedia('(max-width: 1024px)').matches;
}
function startAutoScroll() {
@@ -323,20 +394,15 @@
if (!usesPageScroll() && !articleListEl) return;
autoScrollActive = true;
lastScrollTs = 0;
scrollCarry = 0;
lastAutoCheckTs = 0;
if (usesPageScroll()) {
mobileAutoStartScrollY = window.scrollY;
requestAnimationFrame(() => {
articleListEl?.scrollTo({ top: mobileAutoStartScrollY, behavior: 'auto' });
});
scrollInterval = setInterval(() => {
if (!autoScrollActive) return;
if (!articleListEl) return;
const scroller = document.scrollingElement;
if (!scroller) return;
const delta = Math.max(1, Math.round(4 * autoScrollSpeed));
const nextY = articleListEl.scrollTop + delta;
const maxY = Math.max(0, articleListEl.scrollHeight - articleListEl.clientHeight);
articleListEl.scrollTop = Math.min(nextY, maxY);
const nextY = scroller.scrollTop + delta;
const maxY = Math.max(0, scroller.scrollHeight - window.innerHeight);
scroller.scrollTop = Math.min(nextY, maxY);
checkScrolledCards();
if (nextY >= maxY - 1) {
stopAutoScroll();
@@ -349,17 +415,12 @@
const dt = lastScrollTs ? Math.min(34, timestamp - lastScrollTs) : 16;
lastScrollTs = timestamp;
const pxPerSecond = 72 * autoScrollSpeed;
scrollCarry += pxPerSecond * (dt / 1000);
const delta = Math.max(1, Math.round(scrollCarry));
scrollCarry -= delta;
const delta = pxPerSecond * (dt / 1000);
if (!articleListEl) return;
articleListEl.scrollTop += delta;
const maxScroll = articleListEl.scrollHeight - articleListEl.clientHeight;
if (!lastAutoCheckTs || timestamp - lastAutoCheckTs > 220) {
lastAutoCheckTs = timestamp;
checkScrolledCards();
}
checkScrolledCards();
if (articleListEl.scrollTop >= maxScroll - 1) {
stopAutoScroll();
return;
@@ -370,21 +431,10 @@
scrollRAF = requestAnimationFrame(step);
}
function stopAutoScroll() {
const restorePageScroll = window.matchMedia('(max-width: 1024px)').matches && articleListEl
? articleListEl.scrollTop
: null;
autoScrollActive = false;
lastScrollTs = 0;
scrollCarry = 0;
lastAutoCheckTs = 0;
if (scrollRAF) { cancelAnimationFrame(scrollRAF); scrollRAF = null; }
if (scrollInterval) { clearInterval(scrollInterval); scrollInterval = null; }
commitStagedAutoReads();
if (restorePageScroll !== null) {
requestAnimationFrame(() => {
window.scrollTo({ top: restorePageScroll, behavior: 'auto' });
});
}
}
function toggleAutoScroll() {
if (autoScrollActive) stopAutoScroll();
@@ -431,9 +481,12 @@
const cards = articleListEl.querySelectorAll('[data-entry-id]');
if (usesPageScroll()) {
const pageBottom = window.scrollY + window.innerHeight;
const loadThreshold = document.documentElement.scrollHeight - 500;
if (hasMore && !loadingMore && pageBottom >= loadThreshold) {
// Check if the last card is near the viewport bottom
const lastCard = cards.length ? cards[cards.length - 1] : null;
const nearBottom = lastCard
? lastCard.getBoundingClientRect().top < window.innerHeight + 800
: (window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 500);
if (hasMore && !loadingMore && nearBottom) {
loadEntries(true);
}
} else {
@@ -455,45 +508,16 @@
if (!id) return;
const article = articles.find(a => a.id === id);
if (article && !article.read) {
if (autoScrollActive) {
if (!stagedAutoReadIds.has(id)) {
stagedAutoReadIds.add(id);
newlyRead++;
}
} else {
article.read = true;
pendingReadIds.push(id);
newlyRead++;
}
article.read = true;
pendingReadIds.push(id);
newlyRead++;
}
}
});
if (newlyRead > 0) {
if (!autoScrollActive) {
articles = [...articles];
decrementUnread(newlyRead);
if (flushTimer) clearTimeout(flushTimer);
flushTimer = setTimeout(flushPendingReads, 1000);
}
}
}
function commitStagedAutoReads() {
if (!stagedAutoReadIds.size) return;
const ids = [...stagedAutoReadIds];
stagedAutoReadIds = new Set();
let newlyRead = 0;
articles = articles.map((article) => {
if (ids.includes(article.id) && !article.read) {
newlyRead++;
return { ...article, read: true };
}
return article;
});
if (newlyRead > 0) {
articles = [...articles];
decrementUnread(newlyRead);
pendingReadIds.push(...ids);
if (flushTimer) clearTimeout(flushTimer);
flushTimer = setTimeout(flushPendingReads, 1000);
}
@@ -520,7 +544,6 @@
if (flushTimer) clearTimeout(flushTimer);
if (scrollCheckTimer) clearTimeout(scrollCheckTimer);
if (scrollInterval) clearInterval(scrollInterval);
commitStagedAutoReads();
};
});
</script>
@@ -566,7 +589,32 @@
<div class="sidebar-separator"></div>
<div class="feeds-section">
<div class="feeds-header">Feeds</div>
<div class="feeds-header">
<span>Feeds</span>
<div class="feeds-header-actions">
<button class="feeds-action-btn" title="Refresh all feeds" onclick={refreshAllFeeds}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 16h5v5"/></svg>
</button>
<button class="feeds-action-btn" title={showFeedManager ? 'Done' : 'Manage feeds'} onclick={() => showFeedManager = !showFeedManager}>
{#if showFeedManager}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="13" height="13"><path d="M20 6L9 17l-5-5"/></svg>
{:else}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
{/if}
</button>
</div>
</div>
{#if showFeedManager}
<div class="add-feed-row">
<input class="add-feed-input" type="text" placeholder="Paste feed URL..." bind:value={addFeedUrl}
onkeydown={(e) => { if (e.key === 'Enter') addFeed(); }} />
<button class="add-feed-btn" onclick={addFeed} disabled={addingFeed || !addFeedUrl.trim()}>
{addingFeed ? '...' : '+'}
</button>
</div>
{/if}
{#each feedCategories as cat, i}
<div class="feed-category">
<button class="category-toggle" onclick={() => toggleCategory(i)}>
@@ -577,12 +625,22 @@
{#if cat.expanded}
<div class="feed-list">
{#each cat.feeds as feed}
<button class="feed-item" onclick={() => selectFeed(feed.id || 0)}>
<span class="feed-name">{feed.name}</span>
{#if feed.count > 0}
<span class="feed-count">{feed.count}</span>
<div class="feed-item-row">
<button class="feed-item" onclick={() => selectFeed(feed.id || 0)}>
<span class="feed-name">{feed.name}</span>
{#if feed.count > 0}
<span class="feed-count">{feed.count}</span>
{/if}
</button>
{#if showFeedManager}
<button class="feed-manage-btn" title="Refresh" onclick={(e) => { e.stopPropagation(); refreshFeed(feed.id || 0); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="11" height="11"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
</button>
<button class="feed-manage-btn feed-delete-btn" title="Delete" onclick={(e) => { e.stopPropagation(); deleteFeed(feed.id || 0, feed.name); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="11" height="11"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>
</button>
{/if}
</button>
</div>
{/each}
</div>
{/if}
@@ -598,7 +656,7 @@
{/if}
<!-- Middle Panel: Article List -->
<div class="reader-list" class:auto-scrolling={autoScrollActive}>
<div class="reader-list">
<div class="list-header">
<div class="list-header-top">
<button class="mobile-menu" onclick={() => sidebarOpen = !sidebarOpen} aria-label="Toggle sidebar">
@@ -656,7 +714,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="article-list" class:auto-scrolling={autoScrollActive} bind:this={articleListEl} ontouchstart={handleScrollInterrupt} onwheel={handleScrollInterrupt} onscroll={handleListScroll}>
<div class="article-list" bind:this={articleListEl} ontouchstart={handleScrollInterrupt} onwheel={handleScrollInterrupt} onscroll={handleListScroll}>
{#each filteredArticles as article, index (article.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
@@ -677,11 +735,11 @@
{/if}
</div>
<div class="card-header-right">
<button class="bookmark-btn" class:saved={!!karakeepIds[article.id]} onclick={(e) => toggleKarakeep(article, e)} title={karakeepIds[article.id] ? 'Saved to Karakeep' : 'Save to Karakeep'}>
{#if savingToKarakeep.has(article.id)}
<button class="bookmark-btn" class:saved={!!brainSavedIds[article.id]} onclick={(e) => toggleBrainSave(article, e)} title={brainSavedIds[article.id] ? 'Saved to Brain' : 'Save to Brain'}>
{#if savingToBrain.has(article.id)}
<svg class="spinning" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
{:else}
<svg viewBox="0 0 24 24" fill={karakeepIds[article.id] ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
<svg viewBox="0 0 24 24" fill={brainSavedIds[article.id] ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
{/if}
</button>
<span class="card-time">{article.timeAgo}</span>
@@ -713,6 +771,11 @@
{#if filteredArticles.length === 0}
<div class="list-empty">No articles to show</div>
{/if}
{#if hasMore && !loading}
<button class="load-more-btn" onclick={() => loadEntries(true)} disabled={loadingMore}>
{loadingMore ? 'Loading...' : 'Load more articles'}
</button>
{/if}
</div>
</div>
</div>
@@ -737,11 +800,11 @@
</button>
</div>
<div class="pane-actions">
<button class="pane-action-btn" class:saved-karakeep={!!karakeepIds[selectedArticle.id]} onclick={() => toggleKarakeep(selectedArticle!)} title={karakeepIds[selectedArticle.id] ? 'Saved to Karakeep' : 'Save to Karakeep'}>
{#if savingToKarakeep.has(selectedArticle.id)}
<button class="pane-action-btn" class:saved-brain={!!brainSavedIds[selectedArticle.id]} onclick={() => toggleBrainSave(selectedArticle!)} title={brainSavedIds[selectedArticle.id] ? 'Saved to Brain' : 'Save to Brain'}>
{#if savingToBrain.has(selectedArticle.id)}
<svg class="spinning" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
{:else}
<svg viewBox="0 0 24 24" fill={karakeepIds[selectedArticle.id] ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
<svg viewBox="0 0 24 24" fill={brainSavedIds[selectedArticle.id] ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
{/if}
</button>
<button class="pane-action-btn" onclick={() => toggleRead(selectedArticle!)} title="Toggle read (m)">
@@ -842,7 +905,49 @@
.sidebar-separator { height: 1px; background: rgba(35,26,17,0.08); margin: 12px 18px; }
.feeds-section { padding: 0 12px; flex: 1; }
.feeds-header { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: #8a7a68; padding: 6px 12px 8px; }
.feeds-header {
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.12em; color: #8a7a68; padding: 6px 12px 8px;
display: flex; align-items: center; justify-content: space-between;
}
.feeds-header-actions { display: flex; gap: 2px; }
.feeds-action-btn {
width: 24px; height: 24px; border-radius: 6px; border: none;
background: none; color: #8a7a68; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: all 150ms;
}
.feeds-action-btn:hover { background: rgba(255,248,241,0.8); color: #1e1812; }
.add-feed-row {
display: flex; gap: 4px; padding: 4px 12px 8px;
}
.add-feed-input {
flex: 1; padding: 6px 10px; border-radius: 8px;
border: 1px solid rgba(35,26,17,0.12); background: rgba(255,255,255,0.5);
font-size: 12px; font-family: var(--font); color: #1e1812; outline: none;
}
.add-feed-input:focus { border-color: rgba(35,26,17,0.3); }
.add-feed-input::placeholder { color: #8a7a68; }
.add-feed-btn {
width: 28px; height: 28px; border-radius: 8px; border: none;
background: #1e1812; color: white; font-size: 14px; font-weight: 600;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: opacity 150ms;
}
.add-feed-btn:hover { opacity: 0.8; }
.add-feed-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.feed-item-row {
display: flex; align-items: center;
}
.feed-item-row .feed-item { flex: 1; min-width: 0; }
.feed-manage-btn {
width: 22px; height: 22px; border-radius: 5px; border: none;
background: none; color: #8a7a68; cursor: pointer; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
transition: all 150ms;
}
.feed-manage-btn:hover { background: rgba(255,248,241,0.8); color: #1e1812; }
.feed-delete-btn:hover { color: #8f3928; }
.feed-category { margin-bottom: 1px; }
.category-toggle {
display: flex; align-items: center; gap: 5px; width: 100%;
@@ -926,6 +1031,23 @@
gap: 2px;
}
.load-more-btn {
width: 100%;
padding: 14px;
margin: 8px 0;
border-radius: 14px;
border: 1px dashed rgba(35,26,17,0.15);
background: transparent;
color: #8a7a68;
font-size: 0.88rem;
font-weight: 500;
font-family: var(--font);
cursor: pointer;
transition: all 160ms;
}
.load-more-btn:hover { background: rgba(255,248,241,0.7); color: #1e1812; }
.load-more-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.mobile-scroll-fab {
display: none;
}
@@ -1073,10 +1195,10 @@
}
.pane-action-btn:hover { color: #1f1811; background: rgba(255,250,244,0.92); }
.pane-action-btn.active { color: var(--accent); }
.pane-action-btn.saved-karakeep { color: #F59E0B; }
.pane-action-btn.saved-brain { color: #F59E0B; }
.pane-action-btn svg { width: 15px; height: 15px; }
.pane-content { max-width: 680px; margin: 0 auto; padding: 30px 36px 88px; }
.pane-content { max-width: 680px; margin: 0 auto; padding: 30px 36px 88px; overflow-x: hidden; }
.pane-hero { margin-bottom: 18px; }
.pane-hero-image {
width: 100%;
@@ -1094,9 +1216,12 @@
.pane-source { font-weight: 600; color: #1f1811; }
.pane-author { font-style: italic; }
.pane-dot { width: 3px; height: 3px; border-radius: 50%; background: #8a7a68; }
.pane-body { font-size: 1.02rem; line-height: 1.9; color: #42372d; }
.pane-body { font-size: 1.02rem; line-height: 1.9; color: #42372d; overflow-wrap: break-word; word-break: break-word; }
.pane-body :global(p) { margin-bottom: 18px; }
.pane-body :global(img) { max-width: 100%; height: auto; border-radius: var(--radius-md); margin: var(--sp-3) 0; }
.pane-body :global(table) { max-width: 100%; overflow-x: auto; display: block; }
.pane-body :global(iframe) { max-width: 100%; }
.pane-body :global(*) { max-width: 100%; }
.pane-body :global(a) { color: var(--accent); text-decoration: underline; }
.pane-body :global(a:hover) { opacity: 0.8; }
.pane-body :global(blockquote) { border-left: 3px solid var(--border); padding-left: var(--sp-4); margin: var(--sp-4) 0; color: var(--text-3); font-style: italic; }
@@ -1142,7 +1267,7 @@
position: relative;
grid-template-columns: minmax(0, 1fr);
height: auto;
min-height: calc(100vh - 56px);
min-height: 100vh;
overflow: visible;
}
@@ -1157,53 +1282,43 @@
}
}
@media (max-width: 768px) {
:global(.mobile-bar) {
position: fixed;
inset: 0 0 auto 0;
background: transparent;
border-bottom: none;
backdrop-filter: none;
pointer-events: none;
}
:global(.mobile-bar .mobile-menu-btn),
:global(.mobile-bar .command-trigger.mobile),
:global(.mobile-bar .mobile-brand) {
pointer-events: auto;
}
.reader-sidebar { display: none; }
.reader-sidebar.open { display: flex; position: fixed; left: 0; top: 56px; bottom: 0; z-index: 40; box-shadow: 8px 0 24px rgba(0,0,0,0.08); width: 260px; }
.mobile-menu { display: flex; }
.reader-list {
background:
linear-gradient(180deg, rgba(245, 237, 227, 0.96) 0%, rgba(239, 230, 219, 0.94) 100%);
background: transparent;
overflow: visible;
position: relative;
}
.list-header {
padding: 14px 14px 10px;
padding: 8px 14px 8px;
position: relative;
background: rgba(244, 236, 226, 0.92);
backdrop-filter: blur(14px);
border-bottom: 1px solid rgba(35,26,17,0.08);
background: transparent;
backdrop-filter: none;
border-bottom: none;
}
.list-header-top {
gap: 12px;
}
.reader-list.auto-scrolling {
position: fixed;
top: 56px;
left: 0;
right: 0;
bottom: 0;
z-index: 18;
display: flex;
flex-direction: column;
background:
linear-gradient(180deg, rgba(245, 237, 227, 0.98) 0%, rgba(239, 230, 219, 0.98) 100%);
}
.reader-list.auto-scrolling .list-header {
position: sticky;
top: 0;
z-index: 28;
}
.article-list.auto-scrolling {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding-top: 6px;
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 18px);
}
.list-view-name { font-size: 1.44rem; }
.list-subtitle { font-size: 0.84rem; line-height: 1.4; }
.article-list {
padding: 6px 14px calc(env(safe-area-inset-bottom, 0px) + 18px);
padding: 2px 14px calc(env(safe-area-inset-bottom, 0px) + 18px);
gap: 8px;
background: transparent;
overflow: visible;

View File

@@ -6,7 +6,7 @@ const gatewayUrl = env.GATEWAY_URL || 'http://localhost:8100';
export const load: LayoutServerLoad = async ({ cookies, url }) => {
const host = url.host.toLowerCase();
const useAtelierShell = host.includes(':4174') || host.startsWith('test.');
const useAtelierShell = true;
const session = cookies.get('platform_session');
if (!session) {
throw redirect(302, `/login?redirect=${encodeURIComponent(url.pathname)}`);
@@ -26,7 +26,7 @@ export const load: LayoutServerLoad = async ({ cookies, url }) => {
// Hiding reduces clutter for users who don't need certain apps day-to-day.
const allApps = ['tasks', 'trips', 'fitness', 'inventory', 'budget', 'reader', 'media', 'brain'];
const hiddenByUser: Record<string, string[]> = {
'madiha': ['inventory', 'reader'],
'madiha': ['inventory', 'reader', 'brain'],
};
const hidden = hiddenByUser[data.user.username] || [];
const visibleApps = allApps.filter(a => !hidden.includes(a));

View File

@@ -10,6 +10,7 @@
let assistantEntryDate = $state<string | null>(null);
const visibleApps = data?.visibleApps || ['trips', 'fitness', 'inventory', 'budget', 'reader', 'media'];
const userName = data?.user?.display_name || '';
const assistantBrainEnabled = data?.user?.username !== 'madiha';
const useAtelierShell = data?.useAtelierShell || false;
function openCommand() {
@@ -40,7 +41,7 @@
<AppShell onOpenCommand={openCommand} {visibleApps} {userName}>
{@render children()}
</AppShell>
<FitnessAssistantDrawer bind:open={commandOpen} onclose={closeCommand} entryDate={assistantEntryDate} />
<FitnessAssistantDrawer bind:open={commandOpen} onclose={closeCommand} entryDate={assistantEntryDate} allowBrain={assistantBrainEnabled} />
{:else}
<div class="app">
<Navbar onOpenCommand={openCommand} {visibleApps} />

View File

@@ -0,0 +1,140 @@
import { json } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import type { RequestHandler } from './$types';
type ChatRole = 'user' | 'assistant';
type ChatMessage = {
role?: ChatRole;
content?: string;
};
type UnifiedState = {
activeDomain?: 'fitness' | 'brain';
fitnessState?: Record<string, unknown>;
brainState?: Record<string, unknown>;
};
function recentMessages(messages: unknown): Array<{ role: ChatRole; content: string }> {
if (!Array.isArray(messages)) return [];
return messages
.filter((message) => !!message && typeof message === 'object')
.map((message) => {
const item = message as ChatMessage;
return {
role: item.role === 'assistant' ? 'assistant' : 'user',
content: typeof item.content === 'string' ? item.content : ''
};
})
.filter((message) => message.content.trim())
.slice(-16);
}
function lastUserMessage(messages: Array<{ role: ChatRole; content: string }>): string {
return [...messages].reverse().find((message) => message.role === 'user')?.content?.trim() || '';
}
function isFitnessIntent(text: string): boolean {
return /\b(calories?|protein|carbs?|fat|sugar|fiber|breakfast|lunch|dinner|snack|meal|food|ate|eaten|log|track|entries|macros?)\b/i.test(text)
|| /\bfor (breakfast|lunch|dinner|snack)\b/i.test(text)
|| /\bhow many calories do i have left\b/i.test(text);
}
function isBrainIntent(text: string): boolean {
return /\b(note|notes|brain|remember|save this|save that|what do i have|what have i saved|find my|delete (?:that|this|note|item)|update (?:that|this|note)|from my notes)\b/i.test(text);
}
function detectDomain(
messages: Array<{ role: ChatRole; content: string }>,
state: UnifiedState,
imageDataUrl?: string | null
): 'fitness' | 'brain' {
const text = lastUserMessage(messages);
if (imageDataUrl) return 'fitness';
if (!text) return state.activeDomain || 'brain';
const fitness = isFitnessIntent(text);
const brain = isBrainIntent(text);
if (fitness && !brain) return 'fitness';
if (brain && !fitness) return 'brain';
if (state.activeDomain === 'fitness' && !brain) return 'fitness';
if (state.activeDomain === 'brain' && !fitness) return 'brain';
return fitness ? 'fitness' : 'brain';
}
export const POST: RequestHandler = async ({ request, fetch, cookies }) => {
if (!cookies.get('platform_session')) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const { messages = [], state = {}, imageDataUrl = null, entryDate = null, action = 'chat', allowBrain = true } = await request
.json()
.catch(() => ({}));
let brainEnabled = !!allowBrain;
try {
const gatewayUrl = env.GATEWAY_URL || 'http://localhost:8100';
const session = cookies.get('platform_session');
if (session) {
const auth = await fetch(`${gatewayUrl}/api/auth/me`, {
headers: { Cookie: `platform_session=${session}` }
});
if (auth.ok) {
const data = await auth.json().catch(() => null);
if (data?.authenticated && data?.user?.username === 'madiha') {
brainEnabled = false;
}
}
}
} catch {
// keep requested allowBrain fallback
}
const chat = recentMessages(messages);
const unifiedState: UnifiedState = state && typeof state === 'object' ? state : {};
const domain = brainEnabled ? detectDomain(chat, unifiedState, imageDataUrl) : 'fitness';
const response = await fetch(domain === 'fitness' ? '/assistant/fitness' : '/assistant/brain', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(
domain === 'fitness'
? {
action,
messages,
draft: unifiedState.fitnessState && 'draft' in unifiedState.fitnessState ? unifiedState.fitnessState.draft : null,
drafts: unifiedState.fitnessState && 'drafts' in unifiedState.fitnessState ? unifiedState.fitnessState.drafts : [],
entryDate,
imageDataUrl
}
: {
messages,
state: unifiedState.brainState || {}
}
)
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
return json(body, { status: response.status });
}
return json({
...body,
domain,
state: {
activeDomain: domain,
fitnessState:
domain === 'fitness'
? {
draft: body?.draft ?? null,
drafts: Array.isArray(body?.drafts) ? body.drafts : []
}
: unifiedState.fitnessState || {},
brainState: domain === 'brain' ? body?.state || {} : unifiedState.brainState || {}
}
});
};

View File

@@ -0,0 +1,799 @@
import { env } from '$env/dynamic/private';
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
type ChatRole = 'user' | 'assistant';
type ChatMessage = {
role?: ChatRole;
content?: string;
};
type BrainItem = {
id: string;
type: string;
title?: string | null;
url?: string | null;
raw_content?: string | null;
extracted_text?: string | null;
folder?: string | null;
tags?: string[] | null;
summary?: string | null;
created_at?: string;
};
type BrainAddition = {
id: string;
item_id: string;
content: string;
created_at: string;
};
type AssistantState = {
lastMutation?: {
type: 'append' | 'create' | 'update';
itemId: string;
itemTitle: string;
additionId?: string;
content: string;
createdItemId?: string;
previousRawContent?: string;
};
pendingDelete?: {
itemId: string;
itemTitle: string;
};
};
type SourceLink = {
id: string;
title: string;
type: string;
href: string;
};
type SearchQueryDecision = {
queries?: string[];
};
function recentMessages(messages: unknown): Array<{ role: ChatRole; content: string }> {
if (!Array.isArray(messages)) return [];
return messages
.filter((m) => !!m && typeof m === 'object')
.map((m) => {
const message = m as ChatMessage;
return {
role: (message.role === 'assistant' ? 'assistant' : 'user') as ChatRole,
content: typeof message.content === 'string' ? message.content.slice(0, 4000) : ''
};
})
.filter((m) => m.content.trim())
.slice(-12);
}
function lastUserMessage(messages: Array<{ role: ChatRole; content: string }>): string {
return [...messages].reverse().find((message) => message.role === 'user')?.content?.trim() || '';
}
function toSource(item: BrainItem): SourceLink {
return {
id: item.id,
title: item.title || 'Untitled',
type: item.type,
href: `/brain?item=${item.id}`
};
}
function isConfirmation(text: string): boolean {
const clean = text.trim().toLowerCase();
return [
'yes',
'yes delete it',
'delete it',
'confirm',
'yes do it',
'do it',
'go ahead'
].includes(clean);
}
function isUndo(text: string): boolean {
return /^(undo|undo last change|undo that|revert that)$/i.test(text.trim());
}
function wantsNewNoteInstead(text: string): boolean {
return /create (?:a )?new note/i.test(text) || /make (?:that|it) a new note/i.test(text);
}
function moveTargetFromText(text: string): string | null {
const match = text.match(/(?:add|move)\s+(?:that|it)\s+to\s+(.+)$/i);
return match?.[1]?.trim() || null;
}
function wantsDelete(text: string): boolean {
return /\bdelete\b/i.test(text) && /\b(note|item|that)\b/i.test(text);
}
function isExplicitUpdateRequest(text: string): boolean {
return /\b(update|edit|change|replace|correct|set)\b/i.test(text);
}
function isListingIntent(text: string): boolean {
return /\b(what|which|show|list)\b/i.test(text)
&& /\b(do i have|have i saved|saved|notes|items|books?|pdfs?|links?|documents?|files?)\b/i.test(text);
}
function buildCandidateSummary(items: BrainItem[]): string {
if (!items.length) return 'No candidates found.';
return items
.map((item, index) => {
const snippet = (item.raw_content || item.extracted_text || item.summary || '')
.replace(/\s+/g, ' ')
.slice(0, 220);
return `${index + 1}. id=${item.id}
title=${item.title || 'Untitled'}
type=${item.type}
folder=${item.folder || ''}
tags=${(item.tags || []).join(', ')}
snippet=${snippet}`;
})
.join('\n\n');
}
async function brainSearch(fetchFn: typeof fetch, q: string, limit = 5): Promise<BrainItem[]> {
const response = await fetchFn('/api/brain/search/hybrid', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ q, limit })
});
if (!response.ok) return [];
const body = await response.json().catch(() => ({}));
return Array.isArray(body?.items) ? body.items : [];
}
function normalizeSearchSeed(text: string): string {
return text
.trim()
.replace(/^what\s+(books?|notes?|pdfs?|links?|documents?|files?)\s+do i have saved\s*/i, '$1 ')
.replace(/^show me\s+(all\s+)?(books?|notes?|pdfs?|links?|documents?|files?)\s*/i, '$2 ')
.replace(/^list\s+(my\s+)?(books?|notes?|pdfs?|links?|documents?|files?)\s*/i, '$2 ')
.replace(/^add note\s+/i, '')
.replace(/^add\s+/i, '')
.replace(/^save\s+(?:this|that)\s+/i, '')
.replace(/^what do i have about\s+/i, '')
.replace(/^what have i saved about\s+/i, '')
.replace(/^find\s+/i, '')
.replace(/^search for\s+/i, '')
.replace(/^answer\s+/i, '')
.trim();
}
async function deriveSearchQueries(
messages: Array<{ role: ChatRole; content: string }>,
userText: string
): Promise<string[]> {
const normalized = normalizeSearchSeed(userText);
const fallback = [normalized || userText.trim()].filter(Boolean);
if (!env.OPENAI_API_KEY) return fallback;
const systemPrompt = `You extract concise retrieval queries for a personal knowledge base.
Return ONLY JSON:
{
"queries": ["query one", "query two"]
}
Rules:
- Return 1 to 3 short search queries.
- Focus on the underlying topic, not chat filler.
- For "what do I have about X", include just X.
- For advice/tips, you may infer a likely broader topic if strongly implied.
- Keep each query short, usually 1 to 5 words.
- Do not include punctuation-heavy sentences.
- Do not include explanatory text.`;
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${env.OPENAI_API_KEY}`
},
body: JSON.stringify({
model: env.OPENAI_MODEL || 'gpt-5.2',
response_format: { type: 'json_object' },
temperature: 0.1,
max_completion_tokens: 200,
messages: [
{ role: 'system', content: systemPrompt },
...messages.slice(-6),
{ role: 'user', content: `Search intent: ${userText}` }
]
})
});
if (!response.ok) return fallback;
const raw = await response.json().catch(() => null);
const content = raw?.choices?.[0]?.message?.content;
if (typeof content !== 'string') return fallback;
try {
const parsed = JSON.parse(content) as SearchQueryDecision;
const queries = Array.isArray(parsed.queries)
? parsed.queries.map((query) => String(query || '').trim()).filter(Boolean).slice(0, 3)
: [];
return queries.length ? queries : fallback;
} catch {
return fallback;
}
}
async function collectCandidates(
fetchFn: typeof fetch,
messages: Array<{ role: ChatRole; content: string }>,
userText: string,
limit = 8
): Promise<BrainItem[]> {
const queries = await deriveSearchQueries(messages, userText);
const merged = new Map<string, BrainItem>();
for (const query of queries) {
const items = await brainSearch(fetchFn, query, limit);
for (const item of items) {
if (!merged.has(item.id)) merged.set(item.id, item);
}
}
if (!merged.size) {
for (const item of await brainSearch(fetchFn, userText, limit)) {
if (!merged.has(item.id)) merged.set(item.id, item);
}
}
return [...merged.values()].slice(0, limit);
}
async function createBrainNote(fetchFn: typeof fetch, content: string, title?: string) {
const response = await fetchFn('/api/brain/items', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
type: 'note',
raw_content: content,
title: title?.trim() || undefined
})
});
return {
ok: response.ok,
status: response.status,
body: await response.json().catch(() => ({}))
};
}
async function updateBrainItem(fetchFn: typeof fetch, itemId: string, body: { raw_content?: string; title?: string }) {
const response = await fetchFn(`/api/brain/items/${itemId}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
});
return {
ok: response.ok,
status: response.status,
body: await response.json().catch(() => ({}))
};
}
async function appendToItem(fetchFn: typeof fetch, itemId: string, content: string) {
const response = await fetchFn(`/api/brain/items/${itemId}/additions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ content, source: 'assistant', kind: 'append' })
});
return {
ok: response.ok,
status: response.status,
body: await response.json().catch(() => ({}))
};
}
async function deleteAddition(fetchFn: typeof fetch, itemId: string, additionId: string) {
const response = await fetchFn(`/api/brain/items/${itemId}/additions/${additionId}`, {
method: 'DELETE'
});
return response.ok;
}
async function deleteItem(fetchFn: typeof fetch, itemId: string) {
const response = await fetchFn(`/api/brain/items/${itemId}`, { method: 'DELETE' });
return response.ok;
}
async function getItem(fetchFn: typeof fetch, itemId: string): Promise<BrainItem | null> {
const response = await fetchFn(`/api/brain/items/${itemId}`);
if (!response.ok) return null;
return response.json().catch(() => null);
}
async function decideAction(
messages: Array<{ role: ChatRole; content: string }>,
candidates: BrainItem[],
state: AssistantState,
listingIntent = false
) {
const systemPrompt = `You are the Brain assistant inside a personal app.
You help the user save notes naturally, append thoughts to existing items, answer questions from saved notes, and choose whether to create a new note.
Return ONLY JSON with this shape:
{
"action": "append_existing" | "create_new_note" | "update_existing" | "answer" | "list_items" | "delete_target",
"reply": "short reply preview",
"target_item_id": "optional item id",
"formatted_content": "text to append or create",
"create_title": "short AI-generated title when creating a new note",
"answer": "short answer when action=answer",
"source_item_ids": ["id1", "id2"],
"match_confidence": "high" | "low"
}
Rules:
- Use existing items only when the topical match is strong.
- If the match is weak or ambiguous, create a new note.
- The user prefers speed: do not ask which note to use.
- If the user explicitly says update, change, edit, replace, set, or correct, prefer update_existing when one strong existing note clearly matches.
- If the user is asking to list what they have saved, prefer list_items instead of answer.
- For short tips/advice, format as bullets when natural.
- Only fix spelling and grammar. Do not rewrite the meaning.
- For questions, answer briefly and cite up to 3 source ids.
- For list_items, return a concise list-style reply and cite up to 12 source ids.
- For delete requests, choose the most likely target and set action=delete_target.
- Never choose append_existing unless target_item_id is one of the candidate ids.
- Never choose update_existing unless target_item_id is one of the candidate ids.
- If all candidates are weak, set action=create_new_note and match_confidence=low.
- The current assistant state is only for context:
${JSON.stringify(state, null, 2)}
- listing_intent=${listingIntent}
`;
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${env.OPENAI_API_KEY}`
},
body: JSON.stringify({
model: env.OPENAI_MODEL || 'gpt-5.2',
response_format: { type: 'json_object' },
temperature: 0.2,
max_completion_tokens: 1100,
messages: [
{ role: 'system', content: systemPrompt },
...messages,
{
role: 'user',
content: `Candidate items:\n${buildCandidateSummary(candidates)}`
}
]
})
});
if (!response.ok) {
throw new Error(await response.text());
}
const raw = await response.json();
const content = raw?.choices?.[0]?.message?.content;
if (typeof content !== 'string') {
throw new Error('Assistant response was empty.');
}
return JSON.parse(content) as {
action?: 'append_existing' | 'create_new_note' | 'update_existing' | 'answer' | 'list_items' | 'delete_target';
reply?: string;
target_item_id?: string;
formatted_content?: string;
create_title?: string;
answer?: string;
source_item_ids?: string[];
match_confidence?: 'high' | 'low';
};
}
async function rewriteExistingItemContent(
messages: Array<{ role: ChatRole; content: string }>,
target: BrainItem,
userInstruction: string
): Promise<string> {
const existing = (target.raw_content || target.extracted_text || '').trim();
if (!existing) {
throw new Error('Target item has no editable content.');
}
const systemPrompt = `You edit an existing personal note in place.
Return ONLY JSON:
{
"updated_content": "full updated note body"
}
Rules:
- Apply the user's requested update to the existing note.
- Preserve the note's structure and wording as much as possible.
- Make the smallest correct change needed.
- Do not append a new line when the user clearly wants an existing value updated.
- Only fix spelling and grammar where needed for the changed text.
- Return the full updated note body, not a diff.`;
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${env.OPENAI_API_KEY}`
},
body: JSON.stringify({
model: env.OPENAI_MODEL || 'gpt-5.2',
response_format: { type: 'json_object' },
temperature: 0.1,
max_completion_tokens: 900,
messages: [
{ role: 'system', content: systemPrompt },
...messages.slice(-8),
{
role: 'user',
content: `Target title: ${target.title || 'Untitled'}\n\nExisting note:\n${existing}\n\nInstruction: ${userInstruction}`
}
]
})
});
if (!response.ok) {
throw new Error(await response.text());
}
const raw = await response.json();
const content = raw?.choices?.[0]?.message?.content;
if (typeof content !== 'string') {
throw new Error('Update response was empty.');
}
const parsed = JSON.parse(content) as { updated_content?: string };
const updated = parsed.updated_content?.trim();
if (!updated) {
throw new Error('Updated content was empty.');
}
return updated;
}
function sourceLinksFromIds(items: BrainItem[], ids: string[] | undefined): SourceLink[] {
if (!Array.isArray(ids) || !ids.length) return [];
const map = new Map(items.map((item) => [item.id, item]));
return ids.map((id) => map.get(id)).filter(Boolean).map((item) => toSource(item as BrainItem));
}
export const POST: RequestHandler = async ({ request, fetch, cookies }) => {
if (!cookies.get('platform_session')) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (!env.OPENAI_API_KEY) {
return json({ error: 'Assistant is not configured.' }, { status: 500 });
}
const { messages = [], state = {} } = await request.json().catch(() => ({}));
const chat = recentMessages(messages);
const userText = lastUserMessage(chat);
const currentState: AssistantState = state && typeof state === 'object' ? state : {};
if (!userText) {
return json({
reply: 'Tell me what to save, find, answer, or delete.',
state: currentState,
sources: []
});
}
if (currentState.pendingDelete && isConfirmation(userText)) {
const target = currentState.pendingDelete;
const ok = await deleteItem(fetch, target.itemId);
if (!ok) {
return json({ reply: `I couldn't delete "${target.itemTitle}" yet.`, state: currentState, sources: [] }, { status: 500 });
}
return json({
reply: `Deleted "${target.itemTitle}".`,
state: {},
sources: []
});
}
if (currentState.lastMutation && isUndo(userText)) {
if (currentState.lastMutation.type === 'append' && currentState.lastMutation.additionId) {
const ok = await deleteAddition(fetch, currentState.lastMutation.itemId, currentState.lastMutation.additionId);
return json({
reply: ok ? `Undid the change in "${currentState.lastMutation.itemTitle}".` : 'I could not undo that change yet.',
state: ok ? {} : currentState,
sources: ok ? [{ id: currentState.lastMutation.itemId, title: currentState.lastMutation.itemTitle, type: 'note', href: `/brain?item=${currentState.lastMutation.itemId}` }] : []
});
}
if (currentState.lastMutation.type === 'create' && currentState.lastMutation.createdItemId) {
const ok = await deleteItem(fetch, currentState.lastMutation.createdItemId);
return json({
reply: ok ? `Removed "${currentState.lastMutation.itemTitle}".` : 'I could not undo that note creation yet.',
state: ok ? {} : currentState,
sources: []
});
}
if (currentState.lastMutation.type === 'update' && currentState.lastMutation.previousRawContent !== undefined) {
const restored = await updateBrainItem(fetch, currentState.lastMutation.itemId, {
raw_content: currentState.lastMutation.previousRawContent
});
return json({
reply: restored.ok ? `Undid the update in "${currentState.lastMutation.itemTitle}".` : 'I could not undo that update yet.',
state: restored.ok ? {} : currentState,
sources: restored.ok ? [{ id: currentState.lastMutation.itemId, title: currentState.lastMutation.itemTitle, type: 'note', href: `/brain?item=${currentState.lastMutation.itemId}` }] : []
});
}
}
if (currentState.lastMutation && wantsNewNoteInstead(userText)) {
const content = currentState.lastMutation.content;
if (currentState.lastMutation.type === 'append' && currentState.lastMutation.additionId) {
await deleteAddition(fetch, currentState.lastMutation.itemId, currentState.lastMutation.additionId);
}
const titleDecision = await decideAction(
[{ role: 'user', content: `Create a concise title for this note:\n${content}` }],
[],
currentState
).catch(() => ({ create_title: 'New Note' }));
const created = await createBrainNote(fetch, content, titleDecision.create_title);
if (!created.ok) {
return json({ reply: 'I could not create the new note yet.', state: currentState, sources: [] }, { status: 500 });
}
const createdItem = created.body as BrainItem;
return json({
reply: `Created "${createdItem.title || titleDecision.create_title || 'New note'}".`,
state: {
lastMutation: {
type: 'create',
itemId: createdItem.id,
createdItemId: createdItem.id,
itemTitle: createdItem.title || titleDecision.create_title || 'New note',
content
}
},
sources: [toSource(createdItem)]
});
}
const retarget = currentState.lastMutation ? moveTargetFromText(userText) : null;
if (currentState.lastMutation && retarget) {
if (currentState.lastMutation.type === 'append' && currentState.lastMutation.additionId) {
await deleteAddition(fetch, currentState.lastMutation.itemId, currentState.lastMutation.additionId);
} else if (currentState.lastMutation.type === 'create' && currentState.lastMutation.createdItemId) {
await deleteItem(fetch, currentState.lastMutation.createdItemId);
}
const candidates = await collectCandidates(fetch, chat, retarget, 8);
const target = candidates[0];
if (!target) {
const created = await createBrainNote(fetch, currentState.lastMutation.content, retarget);
if (!created.ok) {
return json({ reply: 'I could not move that into a new note yet.', state: currentState, sources: [] }, { status: 500 });
}
const createdItem = created.body as BrainItem;
return json({
reply: `Created "${createdItem.title || retarget}".`,
state: {
lastMutation: {
type: 'create',
itemId: createdItem.id,
createdItemId: createdItem.id,
itemTitle: createdItem.title || retarget,
content: currentState.lastMutation.content
}
},
sources: [toSource(createdItem)]
});
}
const appended = await appendToItem(fetch, target.id, currentState.lastMutation.content);
if (!appended.ok) {
return json({ reply: `I couldn't move that to "${target.title || 'that item'}" yet.`, state: currentState, sources: [] }, { status: 500 });
}
return json({
reply: `Moved it to "${target.title || 'Untitled'}".`,
state: {
lastMutation: {
type: 'append',
itemId: target.id,
itemTitle: target.title || 'Untitled',
additionId: appended.body?.id,
content: currentState.lastMutation.content
}
},
sources: [toSource(target)]
});
}
const listingIntent = isListingIntent(userText);
const candidates = await collectCandidates(fetch, chat, userText, listingIntent ? 24 : 8);
if (isExplicitUpdateRequest(userText) && candidates[0] && (candidates[0].raw_content || candidates[0].extracted_text)) {
const target = candidates[0];
try {
const updatedContent = await rewriteExistingItemContent(chat, target, userText);
const updated = await updateBrainItem(fetch, target.id, { raw_content: updatedContent });
if (!updated.ok) {
return json({ reply: `I couldn't update "${target.title || 'Untitled'}" yet.`, state: currentState, sources: [] }, { status: 500 });
}
return json({
reply: `Updated "${target.title || 'Untitled'}".`,
state: {
lastMutation: {
type: 'update',
itemId: target.id,
itemTitle: target.title || 'Untitled',
content: updatedContent,
previousRawContent: target.raw_content || target.extracted_text || ''
}
},
sources: [toSource(target)]
});
} catch (error) {
return json(
{
reply: `I couldn't update "${target.title || 'Untitled'}" yet.`,
state: currentState,
sources: [toSource(target)],
error: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}
if (wantsDelete(userText) && !currentState.pendingDelete) {
const target = candidates[0];
if (!target) {
return json({ reply: "I couldn't find a note to delete.", state: currentState, sources: [] });
}
return json({
reply: `Delete "${target.title || 'Untitled'}"?`,
state: {
...currentState,
pendingDelete: {
itemId: target.id,
itemTitle: target.title || 'Untitled'
}
},
sources: [toSource(target)]
});
}
let decision;
try {
decision = await decideAction(chat, candidates, currentState, listingIntent);
} catch (error) {
return json(
{
reply: 'The Brain assistant did not respond cleanly.',
state: currentState,
sources: [],
error: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
if (decision.action === 'answer') {
return json({
reply: decision.answer || decision.reply || 'I found a few relevant notes.',
state: currentState,
sources: sourceLinksFromIds(candidates, decision.source_item_ids)
});
}
if (decision.action === 'list_items') {
return json({
reply: decision.answer || decision.reply || 'Here are the matching items I found.',
state: currentState,
sources: sourceLinksFromIds(candidates, decision.source_item_ids)
});
}
if (decision.action === 'delete_target' && decision.target_item_id) {
const target = candidates.find((item) => item.id === decision.target_item_id) || (await getItem(fetch, decision.target_item_id));
if (!target) {
return json({ reply: "I couldn't find that note to delete.", state: currentState, sources: [] });
}
return json({
reply: `Delete "${target.title || 'Untitled'}"?`,
state: {
...currentState,
pendingDelete: {
itemId: target.id,
itemTitle: target.title || 'Untitled'
}
},
sources: [toSource(target)]
});
}
if (decision.action === 'update_existing' && decision.target_item_id && decision.match_confidence === 'high') {
const target = candidates.find((item) => item.id === decision.target_item_id) || (await getItem(fetch, decision.target_item_id));
if (target && (target.raw_content || target.extracted_text)) {
try {
const updatedContent = await rewriteExistingItemContent(chat, target, userText);
const updated = await updateBrainItem(fetch, target.id, { raw_content: updatedContent });
if (!updated.ok) {
return json({ reply: `I couldn't update "${target.title || 'Untitled'}" yet.`, state: currentState, sources: [] }, { status: 500 });
}
return json({
reply: `Updated "${target.title || 'Untitled'}".`,
state: {
lastMutation: {
type: 'update',
itemId: target.id,
itemTitle: target.title || 'Untitled',
content: updatedContent,
previousRawContent: target.raw_content || target.extracted_text || ''
}
},
sources: [toSource(target)]
});
} catch (error) {
return json(
{
reply: `I couldn't update "${target.title || 'Untitled'}" yet.`,
state: currentState,
sources: [toSource(target)],
error: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}
}
const content = decision.formatted_content?.trim() || userText;
if (decision.action === 'append_existing' && decision.target_item_id && decision.match_confidence === 'high') {
const target = candidates.find((item) => item.id === decision.target_item_id) || (await getItem(fetch, decision.target_item_id));
if (!target) {
// fall through to create below
} else {
const appended = await appendToItem(fetch, target.id, content);
if (!appended.ok) {
return json({ reply: `I couldn't add that to "${target.title || 'Untitled'}" yet.`, state: currentState, sources: [] }, { status: 500 });
}
return json({
reply: `Added to "${target.title || 'Untitled'}".`,
state: {
lastMutation: {
type: 'append',
itemId: target.id,
itemTitle: target.title || 'Untitled',
additionId: appended.body?.id,
content
}
},
sources: [toSource(target)]
});
}
}
const created = await createBrainNote(fetch, content, decision.create_title);
if (!created.ok) {
return json({ reply: 'I could not create the note yet.', state: currentState, sources: [] }, { status: 500 });
}
const createdItem = created.body as BrainItem;
return json({
reply: `Created "${createdItem.title || decision.create_title || 'New note'}".`,
state: {
lastMutation: {
type: 'create',
itemId: createdItem.id,
createdItemId: createdItem.id,
itemTitle: createdItem.title || decision.create_title || 'New note',
content
}
},
sources: [toSource(createdItem)]
});
};

View File

@@ -0,0 +1,21 @@
{
"name": "Platform",
"short_name": "Platform",
"start_url": "/",
"display": "standalone",
"background_color": "#f5efe6",
"theme_color": "#f5efe6",
"orientation": "any",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

View File

@@ -3,7 +3,7 @@ WORKDIR /app
RUN pip install --no-cache-dir bcrypt
RUN adduser --disabled-password --no-create-home appuser
RUN mkdir -p /app/data && chown -R appuser /app/data
COPY --chown=appuser server.py config.py database.py sessions.py proxy.py responses.py auth.py dashboard.py command.py ./
COPY --chown=appuser server.py config.py database.py sessions.py proxy.py responses.py auth.py dashboard.py command.py assistant.py ./
COPY --chown=appuser integrations/ ./integrations/
EXPOSE 8100
ENV PYTHONUNBUFFERED=1

1580
gateway/assistant.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,7 @@ INVENTORY_URL = os.environ.get("INVENTORY_BACKEND_URL", "http://localhost:4499")
NOCODB_API_TOKEN = os.environ.get("NOCODB_API_TOKEN", "")
MINIFLUX_URL = os.environ.get("MINIFLUX_URL", "http://localhost:8767")
MINIFLUX_API_KEY = os.environ.get("MINIFLUX_API_KEY", "")
READER_URL = os.environ.get("READER_BACKEND_URL", "http://reader-api:8300")
TRIPS_API_TOKEN = os.environ.get("TRIPS_API_TOKEN", "")
SHELFMARK_URL = os.environ.get("SHELFMARK_URL", "http://shelfmark:8084")
SPOTIZERR_URL = os.environ.get("SPOTIZERR_URL", "http://spotizerr-app:7171")

View File

@@ -8,7 +8,7 @@ import bcrypt
from config import (
DB_PATH, TRIPS_URL, FITNESS_URL, INVENTORY_URL,
MINIFLUX_URL, SHELFMARK_URL, SPOTIZERR_URL, BUDGET_URL, TASKS_URL, BRAIN_URL,
MINIFLUX_URL, READER_URL, SHELFMARK_URL, SPOTIZERR_URL, BUDGET_URL, TASKS_URL, BRAIN_URL,
)
@@ -94,9 +94,13 @@ def init_db():
# Ensure reader app exists
rdr = c.execute("SELECT id FROM apps WHERE id = 'reader'").fetchone()
if not rdr:
c.execute("INSERT INTO apps VALUES ('reader', 'Reader', 'rss', '/reader', ?, 4, 1, 'unread_count')", (MINIFLUX_URL,))
c.execute("INSERT INTO apps VALUES ('reader', 'Reader', 'rss', '/reader', ?, 4, 1, 'unread_count')", (READER_URL,))
conn.commit()
print("[Gateway] Added reader app")
else:
# Update existing reader app to point to new service
c.execute("UPDATE apps SET proxy_target = ? WHERE id = 'reader'", (READER_URL,))
conn.commit()
# Ensure books app exists (now media)
books = c.execute("SELECT id FROM apps WHERE id = 'books'").fetchone()

View File

@@ -90,8 +90,15 @@ def handle_send_to_kindle(handler, book_id: str, body: bytes):
title = meta.get("title", "Book") if meta else "Book"
author = ", ".join(meta.get("authors", [])) if meta else ""
# Read file and encode as base64
# Read file and check size
file_data = file_path.read_bytes()
size_mb = len(file_data) / (1024 * 1024)
if size_mb > 25:
handler._send_json({
"error": f"File too large for email ({size_mb:.1f} MB). SMTP2GO limit is 25 MB. Use the Kindle app or USB instead.",
"size_mb": round(size_mb, 1),
}, 413)
return
file_b64 = base64.b64encode(file_data).decode("ascii")
filename = file_path.name
@@ -133,7 +140,9 @@ def handle_send_to_kindle(handler, book_id: str, body: bytes):
"size": len(file_data),
})
else:
handler._send_json({"error": "Email send failed", "detail": result}, 500)
failures = result.get("data", {}).get("failures", [])
detail = failures[0] if failures else str(result)
handler._send_json({"error": f"Email send failed: {detail}"}, 500)
except Exception as e:
handler._send_json({"error": f"SMTP2GO error: {str(e)}"}, 500)
@@ -175,6 +184,13 @@ def handle_send_file_to_kindle(handler, body: bytes):
return
file_data = file_path.read_bytes()
size_mb = len(file_data) / (1024 * 1024)
if size_mb > 25:
handler._send_json({
"error": f"File too large for email ({size_mb:.1f} MB). SMTP2GO limit is 25 MB. Use the Kindle app or USB instead.",
"size_mb": round(size_mb, 1),
}, 413)
return
file_b64 = base64.b64encode(file_data).decode("ascii")
ext = file_path.suffix.lower()

View File

@@ -56,7 +56,7 @@ def resolve_service(path):
remainder = "/" + "/".join(parts[3:]) if len(parts) > 3 else "/"
# Services that don't use /api prefix (Express apps, etc.)
NO_API_PREFIX_SERVICES = {"inventory", "music", "budget"}
SERVICE_PATH_PREFIX = {"reader": "/v1"}
SERVICE_PATH_PREFIX = {}
if service_id in SERVICE_PATH_PREFIX:
backend_path = f"{SERVICE_PATH_PREFIX[service_id]}{remainder}"
elif service_id in NO_API_PREFIX_SERVICES:

View File

@@ -25,6 +25,7 @@ from dashboard import (
handle_set_connection, handle_pin, handle_unpin, handle_get_pinned,
)
from command import handle_command
from assistant import handle_assistant, handle_fitness_assistant, handle_brain_assistant
from integrations.booklore import (
handle_booklore_libraries, handle_booklore_import,
handle_booklore_books, handle_booklore_cover,
@@ -242,6 +243,24 @@ class GatewayHandler(ResponseMixin, BaseHTTPRequestHandler):
handle_command(self, user, body)
return
if path == "/api/assistant":
user = self._require_auth()
if user:
handle_assistant(self, body, user)
return
if path == "/api/assistant/fitness":
user = self._require_auth()
if user:
handle_fitness_assistant(self, body, user)
return
if path == "/api/assistant/brain":
user = self._require_auth()
if user:
handle_brain_assistant(self, body, user)
return
if path.startswith("/api/"):
self._proxy("POST", path, body)
return
@@ -290,8 +309,10 @@ class GatewayHandler(ResponseMixin, BaseHTTPRequestHandler):
headers["Content-Type"] = ct
# Inject service-level auth
if service_id == "reader" and MINIFLUX_API_KEY:
headers["X-Auth-Token"] = MINIFLUX_API_KEY
if service_id == "reader":
if user:
headers["X-Gateway-User-Id"] = str(user["id"])
headers["X-Gateway-User-Name"] = user.get("display_name", user.get("username", ""))
elif service_id == "trips" and TRIPS_API_TOKEN:
headers["Authorization"] = f"Bearer {TRIPS_API_TOKEN}"
elif service_id == "inventory" and INVENTORY_SERVICE_API_KEY:

View File

@@ -0,0 +1,467 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
F0819A9915F94DECBA67FA89 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF8A4F2EEC3A4EB6B78AA13E /* Config.swift */; };
DD3F166E894F43848CE426C7 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42807C65E9754543B46DBF62 /* ContentView.swift */; };
C9B59C679DF04512BF9F5C69 /* PlatformApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47F2CEB00333491ABEE288C5 /* PlatformApp.swift */; };
F35DB630BE0A46799AEC15A5 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63F8C700FA4E43818AA54E03 /* APIClient.swift */; };
B20540C8E87F42C2AF4AB477 /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4094EAAB413433FA0D70AB9 /* AuthManager.swift */; };
779FB7AAB0244CB68E5C69C8 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 879AEC4095F64FE7B851B6F9 /* LoginView.swift */; };
FB0A7F362F5944C0BF0365C5 /* AssistantChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F36ACA2BA1243B0B8954E8F /* AssistantChatView.swift */; };
45E92CD7F85A49BFA5C05F20 /* AssistantViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4073DF36CE74BB4A5B4F22A /* AssistantViewModel.swift */; };
2BC13A589E944CE3A4C6B708 /* FitnessAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = E149FC023D964ADA86C96EB5 /* FitnessAPI.swift */; };
F7F65E02FB974FACA76AFAF4 /* FitnessModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4DF9C7493C2402A8B8571DB /* FitnessModels.swift */; };
CF63000DFF4F4F728281A31D /* FitnessRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D82B05FA8D84CA98E18C0E2 /* FitnessRepository.swift */; };
400DCD1DE0534E9E856319F8 /* FoodSearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1822D8E4AFBF4B14A5E79050 /* FoodSearchViewModel.swift */; };
96AFE69ACFB54D97A7C3A4A0 /* GoalsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCDED359C0EE4CC2986AE602 /* GoalsViewModel.swift */; };
84CFFF27A99940B5875A65DA /* TemplatesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FED646258BA4DFA8D6078EC /* TemplatesViewModel.swift */; };
03EBDF20EDF547EDB4B177CF /* TodayViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4F05CCCEE9A4E0FA6BC6795 /* TodayViewModel.swift */; };
92E276F3CA8D4400827B2F99 /* AddFoodSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C60F0E61463489689BC84E3 /* AddFoodSheet.swift */; };
C1D87BF5F61847BD952F5C65 /* EntryDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3C34A418B5428A8B2A62B7 /* EntryDetailView.swift */; };
E48F9E44708544D781FC8ACD /* FitnessTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D994E5BA694983BA293390 /* FitnessTabView.swift */; };
2F5AF62DD13D4B0EB8E338A0 /* FoodLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27103840483E431EB0275752 /* FoodLibraryView.swift */; };
6FE7AC0C375242398A5118B4 /* FoodSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98379942DAA4FB992CE1A33 /* FoodSearchView.swift */; };
8064B019D0B742749713A35E /* GoalsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A511884CFF6D40198C9A326B /* GoalsView.swift */; };
060E0115D90647A593BCF8B7 /* MealSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EA9E7B0D02A4E13ADD734A4 /* MealSectionView.swift */; };
7902B9F4789C4C6FB080AF0D /* TemplatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E59243273F494E9C1F63CB /* TemplatesView.swift */; };
A74F6C2CDEF4487383684BB9 /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CE2113036D74BBA9D3DA571 /* TodayView.swift */; };
75DB623876D54D0BAF32B59F /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0067210C0C4833BEF98835 /* HomeView.swift */; };
340D12BB6F234169953639F6 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60B0BE1ED854A1F859C8B6F /* HomeViewModel.swift */; };
D2CF33B6FE4142CA9995E125 /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D88C79EBC3A4E3791482B07 /* LoadingView.swift */; };
CF9C74396EA449D2BDEBAA09 /* MacroBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16A32CB0269E4AF79A96B241 /* MacroBar.swift */; };
C6D6C2D5882245A895C8B606 /* MacroRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF6CAF2179B48C6B338233C /* MacroRing.swift */; };
86D08C3CC8B34AEDB1254EC1 /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C04E1FF020544D08EDD3CCA /* Color+Extensions.swift */; };
C367EA266AED4AB7842B8A6F /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 929DC19B5C81454EB58087AA /* Date+Extensions.swift */; };
F1AA651AF622471EA697E2CA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4C75844F44B444F4A8228158 /* Assets.xcassets */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
AF8A4F2EEC3A4EB6B78AA13E /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Config.swift"; sourceTree = "<group>"; };
42807C65E9754543B46DBF62 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView.swift"; sourceTree = "<group>"; };
47F2CEB00333491ABEE288C5 /* PlatformApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlatformApp.swift"; sourceTree = "<group>"; };
63F8C700FA4E43818AA54E03 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIClient.swift"; sourceTree = "<group>"; };
E4094EAAB413433FA0D70AB9 /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AuthManager.swift"; sourceTree = "<group>"; };
879AEC4095F64FE7B851B6F9 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LoginView.swift"; sourceTree = "<group>"; };
5F36ACA2BA1243B0B8954E8F /* AssistantChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AssistantChatView.swift"; sourceTree = "<group>"; };
F4073DF36CE74BB4A5B4F22A /* AssistantViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AssistantViewModel.swift"; sourceTree = "<group>"; };
E149FC023D964ADA86C96EB5 /* FitnessAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FitnessAPI.swift"; sourceTree = "<group>"; };
B4DF9C7493C2402A8B8571DB /* FitnessModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FitnessModels.swift"; sourceTree = "<group>"; };
6D82B05FA8D84CA98E18C0E2 /* FitnessRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FitnessRepository.swift"; sourceTree = "<group>"; };
1822D8E4AFBF4B14A5E79050 /* FoodSearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FoodSearchViewModel.swift"; sourceTree = "<group>"; };
DCDED359C0EE4CC2986AE602 /* GoalsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GoalsViewModel.swift"; sourceTree = "<group>"; };
4FED646258BA4DFA8D6078EC /* TemplatesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TemplatesViewModel.swift"; sourceTree = "<group>"; };
B4F05CCCEE9A4E0FA6BC6795 /* TodayViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TodayViewModel.swift"; sourceTree = "<group>"; };
8C60F0E61463489689BC84E3 /* AddFoodSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AddFoodSheet.swift"; sourceTree = "<group>"; };
3C3C34A418B5428A8B2A62B7 /* EntryDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EntryDetailView.swift"; sourceTree = "<group>"; };
A5D994E5BA694983BA293390 /* FitnessTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FitnessTabView.swift"; sourceTree = "<group>"; };
27103840483E431EB0275752 /* FoodLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FoodLibraryView.swift"; sourceTree = "<group>"; };
A98379942DAA4FB992CE1A33 /* FoodSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FoodSearchView.swift"; sourceTree = "<group>"; };
A511884CFF6D40198C9A326B /* GoalsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GoalsView.swift"; sourceTree = "<group>"; };
6EA9E7B0D02A4E13ADD734A4 /* MealSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MealSectionView.swift"; sourceTree = "<group>"; };
D0E59243273F494E9C1F63CB /* TemplatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TemplatesView.swift"; sourceTree = "<group>"; };
3CE2113036D74BBA9D3DA571 /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TodayView.swift"; sourceTree = "<group>"; };
FE0067210C0C4833BEF98835 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeView.swift"; sourceTree = "<group>"; };
F60B0BE1ED854A1F859C8B6F /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeViewModel.swift"; sourceTree = "<group>"; };
1D88C79EBC3A4E3791482B07 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LoadingView.swift"; sourceTree = "<group>"; };
16A32CB0269E4AF79A96B241 /* MacroBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MacroBar.swift"; sourceTree = "<group>"; };
FDF6CAF2179B48C6B338233C /* MacroRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MacroRing.swift"; sourceTree = "<group>"; };
1C04E1FF020544D08EDD3CCA /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = "<group>"; };
929DC19B5C81454EB58087AA /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = "<group>"; };
4C75844F44B444F4A8228158 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
0FDDDCE767CF4BF6B6D41677 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
4B7D1D629553482DA83FE35D /* Platform.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Platform.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
037C8FC2A4954FCE91D25A60 /* Platform */ = {
isa = PBXGroup;
children = (
029D94F090324446B082BA63 /* Platform */,
B5E96950287B4399909152DA /* Products */,
);
sourceTree = "<group>";
};
B5E96950287B4399909152DA /* Products */ = {
isa = PBXGroup;
children = (
4B7D1D629553482DA83FE35D /* Platform.app */,
);
sourceTree = "<group>";
};
029D94F090324446B082BA63 /* Platform */ = {
isa = PBXGroup;
children = (
AF8A4F2EEC3A4EB6B78AA13E /* Config.swift */,
42807C65E9754543B46DBF62 /* ContentView.swift */,
47F2CEB00333491ABEE288C5 /* PlatformApp.swift */,
0FDDDCE767CF4BF6B6D41677 /* Info.plist */,
4C75844F44B444F4A8228158 /* Assets.xcassets */,
0DA26F997DC3429889C0B23A /* Core */,
8CF6CD4493114827807F5F6D /* Features */,
047E80495324497B8522ACEC /* Shared */,
);
path = "Platform"; sourceTree = "<group>";
};
0DA26F997DC3429889C0B23A /* Core */ = {
isa = PBXGroup;
children = (
,
);
path = "Core"; sourceTree = "<group>";
};
8CF6CD4493114827807F5F6D /* Features */ = {
isa = PBXGroup;
children = (
824CFF8CF00F41C590FB148C /* Auth */,
DAD6984656494252A7E8A5DC /* Home */,
C94148B12F3443238D763D27 /* Fitness */,
64DDC35730F64FAFA4F2962C /* Assistant */,
);
path = "Features"; sourceTree = "<group>";
};
824CFF8CF00F41C590FB148C /* Auth */ = {
isa = PBXGroup;
children = (
,
);
path = "Auth"; sourceTree = "<group>";
};
DAD6984656494252A7E8A5DC /* Home */ = {
isa = PBXGroup;
children = (
,
);
path = "Home"; sourceTree = "<group>";
};
64DDC35730F64FAFA4F2962C /* Assistant */ = {
isa = PBXGroup;
children = (
,
);
path = "Assistant"; sourceTree = "<group>";
};
C94148B12F3443238D763D27 /* Fitness */ = {
isa = PBXGroup;
children = (
822A533A33DF4047882688E2 /* Models */,
969F179A1EB645CCBAECE591 /* API */,
A5B64A87024F4F66B4A5D8B4 /* Repository */,
4B89164541C1493A80664F6D /* ViewModels */,
BB4E0BAFB7DA45A68F0480A4 /* Views */,
);
path = "Fitness"; sourceTree = "<group>";
};
969F179A1EB645CCBAECE591 /* API */ = {
isa = PBXGroup;
children = (
,
);
path = "API"; sourceTree = "<group>";
};
822A533A33DF4047882688E2 /* Models */ = {
isa = PBXGroup;
children = (
,
);
path = "Models"; sourceTree = "<group>";
};
A5B64A87024F4F66B4A5D8B4 /* Repository */ = {
isa = PBXGroup;
children = (
,
);
path = "Repository"; sourceTree = "<group>";
};
4B89164541C1493A80664F6D /* ViewModels */ = {
isa = PBXGroup;
children = (
,
);
path = "ViewModels"; sourceTree = "<group>";
};
BB4E0BAFB7DA45A68F0480A4 /* Views */ = {
isa = PBXGroup;
children = (
,
);
path = "Views"; sourceTree = "<group>";
};
047E80495324497B8522ACEC /* Shared */ = {
isa = PBXGroup;
children = (
7F73AF13180C459B8275CD39 /* Components */,
D3B81404D3A24B66B6848BB6 /* Extensions */,
);
path = "Shared"; sourceTree = "<group>";
};
7F73AF13180C459B8275CD39 /* Components */ = {
isa = PBXGroup;
children = (
,
);
path = "Components"; sourceTree = "<group>";
};
D3B81404D3A24B66B6848BB6 /* Extensions */ = {
isa = PBXGroup;
children = (
,
);
path = "Extensions"; sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
B66E7E9630EA415E95CE3A85 /* Platform */ = {
isa = PBXNativeTarget;
buildConfigurationList = 0025AB77304B486EB7AE3B2B /* Build configuration list for PBXNativeTarget "Platform" */;
buildPhases = (
F8ADC26469734B15B38E123A /* Sources */,
078D546291EB4BBFA91F6661 /* Frameworks */,
841189B510034E9A81A14A8C /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = Platform;
productName = Platform;
productReference = 4B7D1D629553482DA83FE35D /* Platform.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
1C4E1290ED4B4E0D832C6DD0 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1540;
LastUpgradeCheck = 1540;
};
buildConfigurationList = E9E082A339DE4D0DAF491E2B /* Build configuration list for PBXProject "Platform" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 037C8FC2A4954FCE91D25A60 /* Platform */;
productRefGroup = B5E96950287B4399909152DA /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
B66E7E9630EA415E95CE3A85 /* Platform */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
841189B510034E9A81A14A8C /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
F1AA651AF622471EA697E2CA /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
F8ADC26469734B15B38E123A /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
F0819A9915F94DECBA67FA89 /* Config.swift in Sources */,
DD3F166E894F43848CE426C7 /* ContentView.swift in Sources */,
C9B59C679DF04512BF9F5C69 /* PlatformApp.swift in Sources */,
F35DB630BE0A46799AEC15A5 /* APIClient.swift in Sources */,
B20540C8E87F42C2AF4AB477 /* AuthManager.swift in Sources */,
779FB7AAB0244CB68E5C69C8 /* LoginView.swift in Sources */,
FB0A7F362F5944C0BF0365C5 /* AssistantChatView.swift in Sources */,
45E92CD7F85A49BFA5C05F20 /* AssistantViewModel.swift in Sources */,
2BC13A589E944CE3A4C6B708 /* FitnessAPI.swift in Sources */,
F7F65E02FB974FACA76AFAF4 /* FitnessModels.swift in Sources */,
CF63000DFF4F4F728281A31D /* FitnessRepository.swift in Sources */,
400DCD1DE0534E9E856319F8 /* FoodSearchViewModel.swift in Sources */,
96AFE69ACFB54D97A7C3A4A0 /* GoalsViewModel.swift in Sources */,
84CFFF27A99940B5875A65DA /* TemplatesViewModel.swift in Sources */,
03EBDF20EDF547EDB4B177CF /* TodayViewModel.swift in Sources */,
92E276F3CA8D4400827B2F99 /* AddFoodSheet.swift in Sources */,
C1D87BF5F61847BD952F5C65 /* EntryDetailView.swift in Sources */,
E48F9E44708544D781FC8ACD /* FitnessTabView.swift in Sources */,
2F5AF62DD13D4B0EB8E338A0 /* FoodLibraryView.swift in Sources */,
6FE7AC0C375242398A5118B4 /* FoodSearchView.swift in Sources */,
8064B019D0B742749713A35E /* GoalsView.swift in Sources */,
060E0115D90647A593BCF8B7 /* MealSectionView.swift in Sources */,
7902B9F4789C4C6FB080AF0D /* TemplatesView.swift in Sources */,
A74F6C2CDEF4487383684BB9 /* TodayView.swift in Sources */,
75DB623876D54D0BAF32B59F /* HomeView.swift in Sources */,
340D12BB6F234169953639F6 /* HomeViewModel.swift in Sources */,
D2CF33B6FE4142CA9995E125 /* LoadingView.swift in Sources */,
CF9C74396EA449D2BDEBAA09 /* MacroBar.swift in Sources */,
C6D6C2D5882245A895C8B606 /* MacroRing.swift in Sources */,
86D08C3CC8B34AEDB1254EC1 /* Color+Extensions.swift in Sources */,
C367EA266AED4AB7842B8A6F /* Date+Extensions.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXFrameworksBuildPhase section */
078D546291EB4BBFA91F6661 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin XCBuildConfiguration section */
B7446285A53C413C9BA0229C /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASCETECHNOLOGIES_AWARE = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_DYNAMIC_NO_PIC = NO;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
D697F1D80CFA4E629D58BE0A /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
C29C67A4DC4943FB8112E677 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Platform/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Platform;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.quadjourney.platform;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
5289CC82B720470DA5F3181B /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Platform/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Platform;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.quadjourney.platform;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
E9E082A339DE4D0DAF491E2B /* Build configuration list for PBXProject "Platform" */ = {
isa = XCConfigurationList;
buildConfigurations = (
B7446285A53C413C9BA0229C /* Debug */,
D697F1D80CFA4E629D58BE0A /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
0025AB77304B486EB7AE3B2B /* Build configuration list for PBXNativeTarget "Platform" */ = {
isa = XCConfigurationList;
buildConfigurations = (
C29C67A4DC4943FB8112E677 /* Debug */,
5289CC82B720470DA5F3181B /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 1C4E1290ED4B4E0D832C6DD0 /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.078",
"green" : "0.412",
"red" : "0.545"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,5 @@
import Foundation
enum Config {
static let gatewayURL = "https://dash.quadjourney.com"
}

View File

@@ -0,0 +1,43 @@
import SwiftUI
struct ContentView: View {
@Environment(AuthManager.self) private var authManager
var body: some View {
Group {
if authManager.isCheckingAuth {
ProgressView("Loading...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.canvas)
} else if authManager.isLoggedIn {
MainTabView()
} else {
LoginView()
}
}
.task {
await authManager.checkAuth()
}
}
}
struct MainTabView: View {
@State private var selectedTab = 0
var body: some View {
TabView(selection: $selectedTab) {
HomeView()
.tabItem {
Label("Home", systemImage: "house.fill")
}
.tag(0)
FitnessTabView()
.tabItem {
Label("Fitness", systemImage: "figure.run")
}
.tag(1)
}
.tint(.accent)
}
}

View File

@@ -0,0 +1,145 @@
import Foundation
enum APIError: LocalizedError {
case invalidURL
case httpError(Int, String?)
case decodingError(Error)
case networkError(Error)
case unknown(String)
var errorDescription: String? {
switch self {
case .invalidURL: return "Invalid URL"
case .httpError(let code, let msg): return msg ?? "HTTP error \(code)"
case .decodingError(let err): return "Decoding error: \(err.localizedDescription)"
case .networkError(let err): return err.localizedDescription
case .unknown(let msg): return msg
}
}
}
@Observable
final class APIClient {
static let shared = APIClient()
private let session: URLSession
private let decoder: JSONDecoder
private init() {
let config = URLSessionConfiguration.default
config.httpCookieAcceptPolicy = .always
config.httpShouldSetCookies = true
config.httpCookieStorage = .shared
session = URLSession(configuration: config)
decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
}
private func buildURL(_ path: String) throws -> URL {
guard let url = URL(string: "\(Config.gatewayURL)\(path)") else {
throw APIError.invalidURL
}
return url
}
func request<T: Decodable>(_ method: String, _ path: String, body: Encodable? = nil) async throws -> T {
let url = try buildURL(path)
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue("application/json", forHTTPHeaderField: "Accept")
if let body = body {
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
req.httpBody = try encoder.encode(body)
}
let (data, response): (Data, URLResponse)
do {
(data, response) = try await session.data(for: req)
} catch {
throw APIError.networkError(error)
}
if let httpResponse = response as? HTTPURLResponse,
!(200...299).contains(httpResponse.statusCode) {
let bodyStr = String(data: data, encoding: .utf8)
throw APIError.httpError(httpResponse.statusCode, bodyStr)
}
do {
return try decoder.decode(T.self, from: data)
} catch {
throw APIError.decodingError(error)
}
}
func get<T: Decodable>(_ path: String) async throws -> T {
try await request("GET", path)
}
func post<T: Decodable>(_ path: String, body: Encodable? = nil) async throws -> T {
try await request("POST", path, body: body)
}
func patch<T: Decodable>(_ path: String, body: Encodable? = nil) async throws -> T {
try await request("PATCH", path, body: body)
}
func put<T: Decodable>(_ path: String, body: Encodable? = nil) async throws -> T {
try await request("PUT", path, body: body)
}
func delete(_ path: String) async throws {
let url = try buildURL(path)
var req = URLRequest(url: url)
req.httpMethod = "DELETE"
req.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response): (Data, URLResponse)
do {
(data, response) = try await session.data(for: req)
} catch {
throw APIError.networkError(error)
}
if let httpResponse = response as? HTTPURLResponse,
!(200...299).contains(httpResponse.statusCode) {
let bodyStr = String(data: data, encoding: .utf8)
throw APIError.httpError(httpResponse.statusCode, bodyStr)
}
}
func rawPost(_ path: String, body: Data) async throws -> (Data, URLResponse) {
let url = try buildURL(path)
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("application/json", forHTTPHeaderField: "Accept")
req.httpBody = body
let (data, response): (Data, URLResponse)
do {
(data, response) = try await session.data(for: req)
} catch {
throw APIError.networkError(error)
}
if let httpResponse = response as? HTTPURLResponse,
!(200...299).contains(httpResponse.statusCode) {
let bodyStr = String(data: data, encoding: .utf8)
throw APIError.httpError(httpResponse.statusCode, bodyStr)
}
return (data, response)
}
func clearCookies() {
if let cookies = HTTPCookieStorage.shared.cookies {
for cookie in cookies {
HTTPCookieStorage.shared.deleteCookie(cookie)
}
}
}
}

View File

@@ -0,0 +1,104 @@
import Foundation
struct AuthUser: Codable {
let id: Int
let username: String
let displayName: String?
enum CodingKeys: String, CodingKey {
case id, username
case displayName = "display_name"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// Handle id as Int or String
if let intId = try? container.decode(Int.self, forKey: .id) {
id = intId
} else if let strId = try? container.decode(String.self, forKey: .id),
let parsed = Int(strId) {
id = parsed
} else {
throw DecodingError.typeMismatch(Int.self, .init(codingPath: [CodingKeys.id], debugDescription: "Expected Int or String for id"))
}
username = try container.decode(String.self, forKey: .username)
displayName = try container.decodeIfPresent(String.self, forKey: .displayName)
}
}
struct LoginRequest: Encodable {
let username: String
let password: String
}
struct LoginResponse: Decodable {
let success: Bool
let user: AuthUser?
}
struct MeResponse: Decodable {
let authenticated: Bool
let user: AuthUser?
}
@Observable
final class AuthManager {
var isLoggedIn = false
var isCheckingAuth = true
var user: AuthUser?
var loginError: String?
private let api = APIClient.shared
private let loggedInKey = "isLoggedIn"
init() {
isLoggedIn = UserDefaults.standard.bool(forKey: loggedInKey)
}
func checkAuth() async {
guard UserDefaults.standard.bool(forKey: loggedInKey) else {
isCheckingAuth = false
isLoggedIn = false
return
}
do {
let response: MeResponse = try await api.get("/api/auth/me")
if response.authenticated {
user = response.user
isLoggedIn = true
} else {
isLoggedIn = false
UserDefaults.standard.set(false, forKey: loggedInKey)
}
} catch {
isLoggedIn = false
UserDefaults.standard.set(false, forKey: loggedInKey)
}
isCheckingAuth = false
}
func login(username: String, password: String) async {
loginError = nil
do {
let response: LoginResponse = try await api.post("/api/auth/login", body: LoginRequest(username: username, password: password))
if response.success {
user = response.user
isLoggedIn = true
UserDefaults.standard.set(true, forKey: loggedInKey)
} else {
loginError = "Invalid credentials"
}
} catch let error as APIError {
loginError = error.localizedDescription
} catch {
loginError = error.localizedDescription
}
}
func logout() {
api.clearCookies()
isLoggedIn = false
user = nil
UserDefaults.standard.set(false, forKey: loggedInKey)
}
}

View File

@@ -0,0 +1,285 @@
import SwiftUI
import PhotosUI
enum AssistantTab: String, CaseIterable {
case chat = "AI Chat"
case quickAdd = "Quick Add"
}
struct AssistantChatView: View {
let entryDate: String
let onDismiss: () -> Void
@Environment(AuthManager.self) private var authManager
@Environment(\.dismiss) private var dismiss
@State private var vm: AssistantViewModel
@State private var selectedTab: AssistantTab = .chat
@State private var scrollProxy: ScrollViewProxy?
init(entryDate: String, onDismiss: @escaping () -> Void) {
self.entryDate = entryDate
self.onDismiss = onDismiss
_vm = State(initialValue: AssistantViewModel(entryDate: entryDate, username: nil))
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Tab switcher
HStack(spacing: 0) {
ForEach(AssistantTab.allCases, id: \.self) { tab in
Button {
selectedTab = tab
} label: {
Text(tab.rawValue)
.font(.subheadline.bold())
.foregroundStyle(selectedTab == tab ? Color.accent : Color.textSecondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(selectedTab == tab ? Color.accent.opacity(0.1) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
.padding(4)
.background(Color.surfaceSecondary)
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding(.horizontal, 16)
.padding(.top, 8)
if selectedTab == .chat {
chatContent
} else {
FoodSearchView(mealType: .snack, dateString: entryDate)
}
}
.background(Color.canvas)
.navigationTitle("Assistant")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") {
dismiss()
onDismiss()
}
}
}
.onAppear {
vm = AssistantViewModel(entryDate: entryDate, username: authManager.user?.username)
}
}
}
private var chatContent: some View {
VStack(spacing: 0) {
// Messages
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 12) {
ForEach(vm.messages) { message in
messageBubble(message)
.id(message.id)
}
if vm.isLoading {
HStack {
ProgressView()
.tint(Color.accent)
Text("Thinking...")
.font(.caption)
.foregroundStyle(Color.textSecondary)
Spacer()
}
.padding(.horizontal, 16)
.id("loading")
}
}
.padding(.vertical, 12)
}
.onAppear { scrollProxy = proxy }
.onChange(of: vm.messages.count) { _, _ in
withAnimation {
proxy.scrollTo(vm.messages.last?.id, anchor: .bottom)
}
}
}
if let error = vm.error {
Text(error)
.font(.caption)
.foregroundStyle(.red)
.padding(.horizontal, 16)
.padding(.bottom, 4)
}
// Input bar
HStack(spacing: 8) {
PhotosPicker(selection: Binding(
get: { vm.selectedPhoto },
set: { newVal in
vm.selectedPhoto = newVal
Task { await vm.loadPhoto(newVal) }
}
), matching: .images) {
Image(systemName: vm.photoData != nil ? "photo.fill" : "photo")
.font(.title3)
.foregroundStyle(vm.photoData != nil ? Color.accent : Color.textSecondary)
}
TextField("Ask anything...", text: $vm.inputText)
.textFieldStyle(.plain)
.padding(10)
.background(Color.surfaceSecondary)
.clipShape(RoundedRectangle(cornerRadius: 20))
Button {
Task {
await vm.send()
withAnimation {
scrollProxy?.scrollTo(vm.messages.last?.id, anchor: .bottom)
}
}
} label: {
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
.foregroundStyle(vm.inputText.isEmpty && vm.photoData == nil ? Color.textTertiary : Color.accent)
}
.disabled(vm.inputText.isEmpty && vm.photoData == nil)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.surface)
}
}
@ViewBuilder
private func messageBubble(_ message: ChatMessage) -> some View {
if message.role == "user" {
HStack {
Spacer()
Text(message.content)
.font(.subheadline)
.foregroundStyle(Color.textPrimary)
.padding(12)
.background(Color(hex: "8B6914").opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 16))
.frame(maxWidth: 280, alignment: .trailing)
}
.padding(.horizontal, 16)
} else {
VStack(alignment: .leading, spacing: 8) {
if !message.content.isEmpty {
Text(message.content)
.font(.subheadline)
.foregroundStyle(Color.textPrimary)
.padding(12)
.background(Color.assistantBubble)
.clipShape(RoundedRectangle(cornerRadius: 16))
.frame(maxWidth: 300, alignment: .leading)
}
// Draft cards
ForEach(Array(message.drafts.enumerated()), id: \.offset) { _, draft in
draftCard(draft, applied: message.applied)
}
// Source links
if !message.sources.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(message.sources) { source in
if let url = URL(string: source.href), !source.href.isEmpty {
Link(destination: url) {
sourceChip(source)
}
} else {
sourceChip(source)
}
}
}
}
}
}
.padding(.horizontal, 16)
}
}
private func draftCard(_ draft: FitnessDraft, applied: Bool) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(draft.foodName)
.font(.subheadline.bold())
.foregroundStyle(Color.textPrimary)
Spacer()
Text(draft.mealType.capitalized)
.font(.caption2.bold())
.foregroundStyle(Color.accent)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.accent.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
HStack(spacing: 12) {
macroChip("Cal", Int(draft.calories))
macroChip("P", Int(draft.protein))
macroChip("C", Int(draft.carbs))
macroChip("F", Int(draft.fat))
}
if !applied {
HStack(spacing: 8) {
Button {
Task { await vm.applyDraft() }
} label: {
Text("Add it")
.font(.caption.bold())
.foregroundStyle(.white)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color.emerald)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
} else {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.emerald)
Text("Added")
.font(.caption.bold())
.foregroundStyle(Color.emerald)
}
}
}
.padding(12)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.05), radius: 4, y: 2)
.frame(maxWidth: 300, alignment: .leading)
}
private func macroChip(_ label: String, _ value: Int) -> some View {
VStack(spacing: 2) {
Text("\(value)")
.font(.caption.bold())
.foregroundStyle(Color.textPrimary)
Text(label)
.font(.system(size: 9))
.foregroundStyle(Color.textSecondary)
}
}
private func sourceChip(_ source: SourceLink) -> some View {
HStack(spacing: 4) {
Image(systemName: source.type == "brain" ? "brain" : "link")
.font(.system(size: 10))
Text(source.title)
.font(.caption2)
.lineLimit(1)
}
.foregroundStyle(Color.accent)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.accent.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}

View File

@@ -0,0 +1,150 @@
import SwiftUI
import PhotosUI
struct ChatMessage: Identifiable {
let id = UUID()
let role: String // "user" or "assistant"
let content: String
var drafts: [FitnessDraft] = []
var sources: [SourceLink] = []
var applied: Bool = false
}
@Observable
final class AssistantViewModel {
var messages: [ChatMessage] = []
var inputText = ""
var isLoading = false
var error: String?
var selectedPhoto: PhotosPickerItem?
var photoData: Data?
// Raw JSON state from server never decode this
private var serverState: Any?
private let api = APIClient.shared
private var entryDate: String
private var allowBrain: Bool
init(entryDate: String, username: String?) {
self.entryDate = entryDate
self.allowBrain = (username ?? "") != "madiha"
}
func send(action: String = "chat") async {
let text = inputText.trimmingCharacters(in: .whitespaces)
guard !text.isEmpty || action == "apply" else { return }
if action == "chat" && !text.isEmpty {
messages.append(ChatMessage(role: "user", content: text))
inputText = ""
}
isLoading = true
error = nil
do {
// Build request as raw JSON
var requestDict: [String: Any] = [
"entryDate": entryDate,
"action": action,
"allowBrain": allowBrain
]
// Messages array
let msgArray = messages.filter { $0.role == "user" }.map { msg -> [String: String] in
["role": "user", "content": msg.content]
}
requestDict["messages"] = msgArray
// State pass-through
if let state = serverState {
requestDict["state"] = state
} else {
requestDict["state"] = NSNull()
}
// Photo
if let data = photoData {
let base64 = data.base64EncodedString()
requestDict["imageDataUrl"] = "data:image/jpeg;base64,\(base64)"
photoData = nil
} else {
requestDict["imageDataUrl"] = NSNull()
}
let bodyData = try JSONSerialization.data(withJSONObject: requestDict)
let (responseData, _) = try await api.rawPost("/api/assistant/chat", body: bodyData)
// Parse response as raw JSON
guard let json = try JSONSerialization.jsonObject(with: responseData) as? [String: Any] else {
error = "Invalid response"
isLoading = false
return
}
// Store raw state
serverState = json["state"]
// Extract display fields
let reply = json["reply"] as? String ?? ""
let applied = json["applied"] as? Bool ?? false
// Parse drafts
var drafts: [FitnessDraft] = []
if let draft = json["draft"] as? [String: Any], let d = FitnessDraft(from: draft) {
drafts.append(d)
}
if let draftsArray = json["drafts"] as? [[String: Any]] {
for dict in draftsArray {
if let d = FitnessDraft(from: dict) {
drafts.append(d)
}
}
}
// Parse sources
var sources: [SourceLink] = []
if let sourcesArray = json["sources"] as? [[String: Any]] {
for dict in sourcesArray {
if let s = SourceLink(from: dict) {
sources.append(s)
}
}
}
// Check for error
if let errStr = json["error"] as? String, !errStr.isEmpty {
error = errStr
}
if !reply.isEmpty || !drafts.isEmpty {
messages.append(ChatMessage(
role: "assistant",
content: reply,
drafts: drafts,
sources: sources,
applied: applied
))
}
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func applyDraft() async {
await send(action: "apply")
}
func loadPhoto(_ item: PhotosPickerItem?) async {
guard let item else { return }
if let data = try? await item.loadTransferable(type: Data.self) {
// Compress as JPEG
if let img = UIImage(data: data), let jpeg = img.jpegData(compressionQuality: 0.7) {
photoData = jpeg
}
}
}
}

View File

@@ -0,0 +1,80 @@
import SwiftUI
struct LoginView: View {
@Environment(AuthManager.self) private var authManager
@State private var username = ""
@State private var password = ""
@State private var isLoading = false
var body: some View {
VStack(spacing: 24) {
Spacer()
VStack(spacing: 8) {
Image(systemName: "square.grid.2x2.fill")
.font(.system(size: 48))
.foregroundStyle(Color.accent)
Text("Platform")
.font(.system(size: 32, weight: .bold, design: .rounded))
.foregroundStyle(Color.textPrimary)
Text("Sign in to continue")
.font(.subheadline)
.foregroundStyle(Color.textSecondary)
}
VStack(spacing: 16) {
TextField("Username", text: $username)
.textFieldStyle(.plain)
.padding(14)
.background(Color.surfaceSecondary)
.clipShape(RoundedRectangle(cornerRadius: 12))
.textContentType(.username)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
SecureField("Password", text: $password)
.textFieldStyle(.plain)
.padding(14)
.background(Color.surfaceSecondary)
.clipShape(RoundedRectangle(cornerRadius: 12))
.textContentType(.password)
}
.padding(.horizontal, 32)
if let error = authManager.loginError {
Text(error)
.font(.caption)
.foregroundStyle(.red)
}
Button {
isLoading = true
Task {
await authManager.login(username: username, password: password)
isLoading = false
}
} label: {
Group {
if isLoading {
ProgressView()
.tint(.white)
} else {
Text("Sign In")
.fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.frame(height: 48)
}
.buttonStyle(.borderedProminent)
.tint(Color.accent)
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 32)
.disabled(username.isEmpty || password.isEmpty || isLoading)
Spacer()
Spacer()
}
.background(Color.canvas.ignoresSafeArea())
}
}

View File

@@ -0,0 +1,56 @@
import Foundation
struct FitnessAPI {
private let api = APIClient.shared
func getEntries(date: String) async throws -> [FoodEntry] {
try await api.get("/api/fitness/entries?date=\(date)")
}
func createEntry(_ req: CreateEntryRequest) async throws -> FoodEntry {
try await api.post("/api/fitness/entries", body: req)
}
func updateEntry(id: Int, quantity: Double) async throws -> FoodEntry {
struct Body: Encodable { let quantity: Double }
return try await api.patch("/api/fitness/entries/\(id)", body: Body(quantity: quantity))
}
func deleteEntry(id: Int) async throws {
try await api.delete("/api/fitness/entries/\(id)")
}
func getFoods(limit: Int = 100) async throws -> [FoodItem] {
try await api.get("/api/fitness/foods?limit=\(limit)")
}
func searchFoods(query: String, limit: Int = 20) async throws -> [FoodItem] {
let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query
return try await api.get("/api/fitness/foods/search?q=\(encoded)&limit=\(limit)")
}
func getRecentFoods(limit: Int = 8) async throws -> [FoodItem] {
try await api.get("/api/fitness/foods/recent?limit=\(limit)")
}
func getFood(id: Int) async throws -> FoodItem {
try await api.get("/api/fitness/foods/\(id)")
}
func getGoals(date: String) async throws -> DailyGoal {
try await api.get("/api/fitness/goals/for-date?date=\(date)")
}
func updateGoals(_ req: UpdateGoalsRequest) async throws -> DailyGoal {
try await api.put("/api/fitness/goals", body: req)
}
func getTemplates() async throws -> [MealTemplate] {
try await api.get("/api/fitness/templates")
}
func logTemplate(id: Int, date: String) async throws {
struct Empty: Decodable {}
let _: Empty = try await api.post("/api/fitness/templates/\(id)/log?date=\(date)")
}
}

View File

@@ -0,0 +1,328 @@
import Foundation
import SwiftUI
// MARK: - Meal Type
enum MealType: String, Codable, CaseIterable, Identifiable {
case breakfast, lunch, dinner, snack
var id: String { rawValue }
var displayName: String {
rawValue.capitalized
}
var icon: String {
switch self {
case .breakfast: return "sunrise.fill"
case .lunch: return "sun.max.fill"
case .dinner: return "moon.fill"
case .snack: return "leaf.fill"
}
}
var color: Color {
switch self {
case .breakfast: return .breakfastColor
case .lunch: return .lunchColor
case .dinner: return .dinnerColor
case .snack: return .snackColor
}
}
}
// MARK: - Food Entry
struct FoodEntry: Identifiable, Codable {
let id: Int
let userId: Int?
let foodId: Int?
let mealType: MealType
let quantity: Double
let entryDate: String
let foodName: String
let calories: Double
let protein: Double
let carbs: Double
let fat: Double
let sugar: Double?
let fiber: Double?
let servingSize: String?
let imageFilename: String?
enum CodingKeys: String, CodingKey {
case id
case userId = "user_id"
case foodId = "food_id"
case mealType = "meal_type"
case quantity
case entryDate = "entry_date"
case foodName = "food_name"
case snapshotFoodName = "snapshot_food_name"
case calories, protein, carbs, fat, sugar, fiber
case servingSize = "serving_size"
case imageFilename = "image_filename"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try Self.decodeIntFlex(c, .id)
userId = try? Self.decodeIntFlex(c, .userId)
foodId = try? Self.decodeIntFlex(c, .foodId)
mealType = try c.decode(MealType.self, forKey: .mealType)
quantity = try Self.decodeDoubleFlex(c, .quantity)
entryDate = try c.decode(String.self, forKey: .entryDate)
// Handle food_name or snapshot_food_name
if let name = try? c.decode(String.self, forKey: .foodName) {
foodName = name
} else if let name = try? c.decode(String.self, forKey: .snapshotFoodName) {
foodName = name
} else {
foodName = "Unknown"
}
calories = try Self.decodeDoubleFlex(c, .calories)
protein = try Self.decodeDoubleFlex(c, .protein)
carbs = try Self.decodeDoubleFlex(c, .carbs)
fat = try Self.decodeDoubleFlex(c, .fat)
sugar = try? Self.decodeDoubleFlex(c, .sugar)
fiber = try? Self.decodeDoubleFlex(c, .fiber)
servingSize = try? c.decode(String.self, forKey: .servingSize)
imageFilename = try? c.decode(String.self, forKey: .imageFilename)
}
func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(id, forKey: .id)
try c.encodeIfPresent(userId, forKey: .userId)
try c.encodeIfPresent(foodId, forKey: .foodId)
try c.encode(mealType, forKey: .mealType)
try c.encode(quantity, forKey: .quantity)
try c.encode(entryDate, forKey: .entryDate)
try c.encode(foodName, forKey: .foodName)
try c.encode(calories, forKey: .calories)
try c.encode(protein, forKey: .protein)
try c.encode(carbs, forKey: .carbs)
try c.encode(fat, forKey: .fat)
try c.encodeIfPresent(sugar, forKey: .sugar)
try c.encodeIfPresent(fiber, forKey: .fiber)
try c.encodeIfPresent(servingSize, forKey: .servingSize)
try c.encodeIfPresent(imageFilename, forKey: .imageFilename)
}
// Flexible Int decoding
private static func decodeIntFlex(_ c: KeyedDecodingContainer<CodingKeys>, _ key: CodingKeys) throws -> Int {
if let v = try? c.decode(Int.self, forKey: key) { return v }
if let v = try? c.decode(Double.self, forKey: key) { return Int(v) }
if let v = try? c.decode(String.self, forKey: key), let i = Int(v) { return i }
throw DecodingError.typeMismatch(Int.self, .init(codingPath: [key], debugDescription: "Expected numeric"))
}
// Flexible Double decoding
private static func decodeDoubleFlex(_ c: KeyedDecodingContainer<CodingKeys>, _ key: CodingKeys) throws -> Double {
if let v = try? c.decode(Double.self, forKey: key) { return v }
if let v = try? c.decode(Int.self, forKey: key) { return Double(v) }
if let v = try? c.decode(String.self, forKey: key), let d = Double(v) { return d }
throw DecodingError.typeMismatch(Double.self, .init(codingPath: [key], debugDescription: "Expected numeric"))
}
}
// MARK: - Food Item
struct FoodItem: Identifiable, Codable {
let id: Int
let name: String
let calories: Double
let protein: Double
let carbs: Double
let fat: Double
let sugar: Double?
let fiber: Double?
let servingSize: String?
let imageFilename: String?
enum CodingKeys: String, CodingKey {
case id, name, calories, protein, carbs, fat, sugar, fiber
case servingSize = "serving_size"
case imageFilename = "image_filename"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
if let v = try? c.decode(Int.self, forKey: .id) { id = v }
else if let v = try? c.decode(Double.self, forKey: .id) { id = Int(v) }
else { id = 0 }
name = try c.decode(String.self, forKey: .name)
calories = (try? c.decode(Double.self, forKey: .calories)) ?? Double((try? c.decode(Int.self, forKey: .calories)) ?? 0)
protein = (try? c.decode(Double.self, forKey: .protein)) ?? Double((try? c.decode(Int.self, forKey: .protein)) ?? 0)
carbs = (try? c.decode(Double.self, forKey: .carbs)) ?? Double((try? c.decode(Int.self, forKey: .carbs)) ?? 0)
fat = (try? c.decode(Double.self, forKey: .fat)) ?? Double((try? c.decode(Int.self, forKey: .fat)) ?? 0)
sugar = (try? c.decode(Double.self, forKey: .sugar)) ?? (try? c.decode(Int.self, forKey: .sugar)).map { Double($0) }
fiber = (try? c.decode(Double.self, forKey: .fiber)) ?? (try? c.decode(Int.self, forKey: .fiber)).map { Double($0) }
servingSize = try? c.decode(String.self, forKey: .servingSize)
imageFilename = try? c.decode(String.self, forKey: .imageFilename)
}
}
// MARK: - Daily Goal
struct DailyGoal: Codable {
let calories: Double
let protein: Double
let carbs: Double
let fat: Double
let sugar: Double
let fiber: Double
enum CodingKeys: String, CodingKey {
case calories, protein, carbs, fat, sugar, fiber
}
init(calories: Double = 2000, protein: Double = 150, carbs: Double = 250, fat: Double = 65, sugar: Double = 50, fiber: Double = 30) {
self.calories = calories
self.protein = protein
self.carbs = carbs
self.fat = fat
self.sugar = sugar
self.fiber = fiber
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
calories = (try? c.decode(Double.self, forKey: .calories)) ?? Double((try? c.decode(Int.self, forKey: .calories)) ?? 2000)
protein = (try? c.decode(Double.self, forKey: .protein)) ?? Double((try? c.decode(Int.self, forKey: .protein)) ?? 150)
carbs = (try? c.decode(Double.self, forKey: .carbs)) ?? Double((try? c.decode(Int.self, forKey: .carbs)) ?? 250)
fat = (try? c.decode(Double.self, forKey: .fat)) ?? Double((try? c.decode(Int.self, forKey: .fat)) ?? 65)
sugar = (try? c.decode(Double.self, forKey: .sugar)) ?? Double((try? c.decode(Int.self, forKey: .sugar)) ?? 50)
fiber = (try? c.decode(Double.self, forKey: .fiber)) ?? Double((try? c.decode(Int.self, forKey: .fiber)) ?? 30)
}
}
// MARK: - Meal Template
struct MealTemplate: Identifiable, Codable {
let id: Int
let name: String
let mealType: MealType
let totalCalories: Double?
let totalProtein: Double?
let totalCarbs: Double?
let totalFat: Double?
let itemCount: Int?
enum CodingKeys: String, CodingKey {
case id, name
case mealType = "meal_type"
case totalCalories = "total_calories"
case totalProtein = "total_protein"
case totalCarbs = "total_carbs"
case totalFat = "total_fat"
case itemCount = "item_count"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
if let v = try? c.decode(Int.self, forKey: .id) { id = v }
else if let v = try? c.decode(Double.self, forKey: .id) { id = Int(v) }
else { id = 0 }
name = try c.decode(String.self, forKey: .name)
mealType = try c.decode(MealType.self, forKey: .mealType)
totalCalories = (try? c.decode(Double.self, forKey: .totalCalories)) ?? (try? c.decode(Int.self, forKey: .totalCalories)).map { Double($0) }
totalProtein = (try? c.decode(Double.self, forKey: .totalProtein)) ?? (try? c.decode(Int.self, forKey: .totalProtein)).map { Double($0) }
totalCarbs = (try? c.decode(Double.self, forKey: .totalCarbs)) ?? (try? c.decode(Int.self, forKey: .totalCarbs)).map { Double($0) }
totalFat = (try? c.decode(Double.self, forKey: .totalFat)) ?? (try? c.decode(Int.self, forKey: .totalFat)).map { Double($0) }
itemCount = (try? c.decode(Int.self, forKey: .itemCount)) ?? (try? c.decode(Double.self, forKey: .itemCount)).map { Int($0) }
}
}
// MARK: - Requests
struct CreateEntryRequest: Encodable {
let foodId: Int?
let foodName: String
let mealType: String
let quantity: Double
let entryDate: String
let calories: Double
let protein: Double
let carbs: Double
let fat: Double
let sugar: Double?
let fiber: Double?
enum CodingKeys: String, CodingKey {
case foodId = "food_id"
case foodName = "food_name"
case mealType = "meal_type"
case quantity
case entryDate = "entry_date"
case calories, protein, carbs, fat, sugar, fiber
}
}
struct UpdateGoalsRequest: Encodable {
let calories: Double
let protein: Double
let carbs: Double
let fat: Double
let sugar: Double
let fiber: Double
}
// MARK: - Fitness Draft (AI Chat)
struct FitnessDraft {
let foodName: String
let mealType: String
let calories: Double
let protein: Double
let carbs: Double
let fat: Double
let sugar: Double?
let fiber: Double?
let quantity: Double
init?(from dict: [String: Any]) {
guard let name = dict["food_name"] as? String else { return nil }
foodName = name
mealType = (dict["meal_type"] as? String) ?? "snack"
calories = Self.flexDouble(dict["calories"])
protein = Self.flexDouble(dict["protein"])
carbs = Self.flexDouble(dict["carbs"])
fat = Self.flexDouble(dict["fat"])
sugar = dict["sugar"].flatMap { Self.flexDoubleOpt($0) }
fiber = dict["fiber"].flatMap { Self.flexDoubleOpt($0) }
quantity = Self.flexDouble(dict["quantity"], default: 1)
}
private static func flexDouble(_ val: Any?, default def: Double = 0) -> Double {
if let v = val as? Double { return v }
if let v = val as? Int { return Double(v) }
if let v = val as? NSNumber { return v.doubleValue }
return def
}
private static func flexDoubleOpt(_ val: Any) -> Double? {
if let v = val as? Double { return v }
if let v = val as? Int { return Double(v) }
if let v = val as? NSNumber { return v.doubleValue }
return nil
}
}
// MARK: - Source Link
struct SourceLink: Identifiable {
let id: String
let title: String
let type: String
let href: String
init?(from dict: [String: Any]) {
guard let id = dict["id"] as? String,
let title = dict["title"] as? String else { return nil }
self.id = id
self.title = title
self.type = (dict["type"] as? String) ?? ""
self.href = (dict["href"] as? String) ?? ""
}
}

View File

@@ -0,0 +1,72 @@
import Foundation
@Observable
final class FitnessRepository {
static let shared = FitnessRepository()
var entries: [FoodEntry] = []
var goals: DailyGoal = DailyGoal()
var isLoading = false
var error: String?
private let api = FitnessAPI()
func loadDay(date: String) async {
isLoading = true
error = nil
do {
async let e = api.getEntries(date: date)
async let g = api.getGoals(date: date)
entries = try await e
goals = try await g
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func deleteEntry(id: Int) async {
do {
try await api.deleteEntry(id: id)
entries.removeAll { $0.id == id }
} catch {
self.error = error.localizedDescription
}
}
func addEntry(_ req: CreateEntryRequest) async {
do {
let entry = try await api.createEntry(req)
entries.append(entry)
} catch {
self.error = error.localizedDescription
}
}
func updateEntry(id: Int, quantity: Double) async -> FoodEntry? {
do {
let updated = try await api.updateEntry(id: id, quantity: quantity)
if let idx = entries.firstIndex(where: { $0.id == id }) {
entries[idx] = updated
}
return updated
} catch {
self.error = error.localizedDescription
return nil
}
}
// Computed helpers
var totalCalories: Double { entries.reduce(0) { $0 + $1.calories * $1.quantity } }
var totalProtein: Double { entries.reduce(0) { $0 + $1.protein * $1.quantity } }
var totalCarbs: Double { entries.reduce(0) { $0 + $1.carbs * $1.quantity } }
var totalFat: Double { entries.reduce(0) { $0 + $1.fat * $1.quantity } }
func entriesForMeal(_ meal: MealType) -> [FoodEntry] {
entries.filter { $0.mealType == meal }
}
func mealCalories(_ meal: MealType) -> Double {
entriesForMeal(meal).reduce(0) { $0 + $1.calories * $1.quantity }
}
}

View File

@@ -0,0 +1,51 @@
import Foundation
@Observable
final class FoodSearchViewModel {
var query = ""
var results: [FoodItem] = []
var recentFoods: [FoodItem] = []
var allFoods: [FoodItem] = []
var isSearching = false
var isLoadingInitial = false
private let api = FitnessAPI()
private var searchTask: Task<Void, Never>?
func loadInitial() async {
isLoadingInitial = true
do {
async let r = api.getRecentFoods()
async let a = api.getFoods()
recentFoods = try await r
allFoods = try await a
} catch {
// Silently fail
}
isLoadingInitial = false
}
func search() {
searchTask?.cancel()
let q = query.trimmingCharacters(in: .whitespaces)
guard q.count >= 2 else {
results = []
isSearching = false
return
}
isSearching = true
searchTask = Task {
do {
let items = try await api.searchFoods(query: q)
if !Task.isCancelled {
results = items
isSearching = false
}
} catch {
if !Task.isCancelled {
isSearching = false
}
}
}
}
}

View File

@@ -0,0 +1,54 @@
import Foundation
@Observable
final class GoalsViewModel {
var calories: String = ""
var protein: String = ""
var carbs: String = ""
var fat: String = ""
var sugar: String = ""
var fiber: String = ""
var isLoading = false
var isSaving = false
var error: String?
var saved = false
private let api = FitnessAPI()
func load(date: String) async {
isLoading = true
do {
let goals = try await api.getGoals(date: date)
calories = String(Int(goals.calories))
protein = String(Int(goals.protein))
carbs = String(Int(goals.carbs))
fat = String(Int(goals.fat))
sugar = String(Int(goals.sugar))
fiber = String(Int(goals.fiber))
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func save() async {
isSaving = true
error = nil
saved = false
let req = UpdateGoalsRequest(
calories: Double(calories) ?? 2000,
protein: Double(protein) ?? 150,
carbs: Double(carbs) ?? 250,
fat: Double(fat) ?? 65,
sugar: Double(sugar) ?? 50,
fiber: Double(fiber) ?? 30
)
do {
_ = try await api.updateGoals(req)
saved = true
} catch {
self.error = error.localizedDescription
}
isSaving = false
}
}

View File

@@ -0,0 +1,37 @@
import Foundation
@Observable
final class TemplatesViewModel {
var templates: [MealTemplate] = []
var isLoading = false
var error: String?
var logSuccess: String?
private let api = FitnessAPI()
func load() async {
isLoading = true
error = nil
do {
templates = try await api.getTemplates()
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func logTemplate(_ template: MealTemplate, date: String) async {
do {
try await api.logTemplate(id: template.id, date: date)
logSuccess = "Logged \(template.name)"
// Refresh the repository
await FitnessRepository.shared.loadDay(date: date)
} catch {
self.error = error.localizedDescription
}
}
var groupedByMeal: [MealType: [MealTemplate]] {
Dictionary(grouping: templates, by: { $0.mealType })
}
}

View File

@@ -0,0 +1,36 @@
import Foundation
@Observable
final class TodayViewModel {
var selectedDate: Date = Date()
let repository = FitnessRepository.shared
var dateString: String {
selectedDate.apiDateString
}
var displayDate: String {
if selectedDate.isToday {
return "Today"
}
return selectedDate.displayString
}
func load() async {
await repository.loadDay(date: dateString)
}
func previousDay() {
selectedDate = Calendar.current.date(byAdding: .day, value: -1, to: selectedDate) ?? selectedDate
Task { await load() }
}
func nextDay() {
selectedDate = Calendar.current.date(byAdding: .day, value: 1, to: selectedDate) ?? selectedDate
Task { await load() }
}
func deleteEntry(_ entry: FoodEntry) async {
await repository.deleteEntry(id: entry.id)
}
}

View File

@@ -0,0 +1,171 @@
import SwiftUI
struct AddFoodSheet: View {
let food: FoodItem
let mealType: MealType
let dateString: String
let onAdded: () -> Void
@Environment(\.dismiss) private var dismiss
@State private var quantity: Double = 1.0
@State private var selectedMeal: MealType
@State private var isAdding = false
init(food: FoodItem, mealType: MealType, dateString: String, onAdded: @escaping () -> Void) {
self.food = food
self.mealType = mealType
self.dateString = dateString
self.onAdded = onAdded
_selectedMeal = State(initialValue: mealType)
}
private var scaledCalories: Double { food.calories * quantity }
private var scaledProtein: Double { food.protein * quantity }
private var scaledCarbs: Double { food.carbs * quantity }
private var scaledFat: Double { food.fat * quantity }
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
// Food header
VStack(spacing: 8) {
if let img = food.imageFilename {
AsyncImage(url: URL(string: "\(Config.gatewayURL)/api/fitness/images/\(img)")) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
Color.surfaceSecondary
}
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
Text(food.name)
.font(.title3.bold())
.foregroundStyle(Color.textPrimary)
if let serving = food.servingSize {
Text(serving)
.font(.caption)
.foregroundStyle(Color.textSecondary)
}
}
// Quantity
VStack(spacing: 8) {
Text("Quantity")
.font(.subheadline.bold())
.foregroundStyle(Color.textSecondary)
HStack(spacing: 16) {
Button { if quantity > 0.5 { quantity -= 0.5 } } label: {
Image(systemName: "minus.circle.fill")
.font(.title2)
.foregroundStyle(Color.accent)
}
Text(String(format: "%.1f", quantity))
.font(.title2.bold())
.frame(minWidth: 60)
Button { quantity += 0.5 } label: {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundStyle(Color.accent)
}
}
}
// Meal picker
VStack(spacing: 8) {
Text("Meal")
.font(.subheadline.bold())
.foregroundStyle(Color.textSecondary)
HStack(spacing: 8) {
ForEach(MealType.allCases) { meal in
Button {
selectedMeal = meal
} label: {
Text(meal.displayName)
.font(.caption.bold())
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(selectedMeal == meal ? meal.color.opacity(0.2) : Color.surfaceSecondary)
.foregroundStyle(selectedMeal == meal ? meal.color : Color.textSecondary)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
}
// Nutrition preview
VStack(spacing: 8) {
nutritionRow("Calories", "\(Int(scaledCalories))")
nutritionRow("Protein", "\(Int(scaledProtein))g")
nutritionRow("Carbs", "\(Int(scaledCarbs))g")
nutritionRow("Fat", "\(Int(scaledFat))g")
}
.padding(16)
.background(Color.surfaceSecondary)
.clipShape(RoundedRectangle(cornerRadius: 12))
// Add button
Button {
isAdding = true
Task {
let req = CreateEntryRequest(
foodId: food.id,
foodName: food.name,
mealType: selectedMeal.rawValue,
quantity: quantity,
entryDate: dateString,
calories: food.calories,
protein: food.protein,
carbs: food.carbs,
fat: food.fat,
sugar: food.sugar,
fiber: food.fiber
)
await FitnessRepository.shared.addEntry(req)
isAdding = false
dismiss()
onAdded()
}
} label: {
Group {
if isAdding {
ProgressView().tint(.white)
} else {
Text("Add")
.fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.frame(height: 48)
}
.buttonStyle(.borderedProminent)
.tint(Color.accent)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.padding(20)
}
.background(Color.canvas)
.navigationTitle("Add Food")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
}
private func nutritionRow(_ label: String, _ value: String) -> some View {
HStack {
Text(label)
.font(.subheadline)
.foregroundStyle(Color.textSecondary)
Spacer()
Text(value)
.font(.subheadline.bold())
.foregroundStyle(Color.textPrimary)
}
}
}

View File

@@ -0,0 +1,160 @@
import SwiftUI
struct EntryDetailView: View {
let entry: FoodEntry
let dateString: String
@Environment(\.dismiss) private var dismiss
@State private var quantity: Double
@State private var isDeleting = false
@State private var isSaving = false
init(entry: FoodEntry, dateString: String) {
self.entry = entry
self.dateString = dateString
_quantity = State(initialValue: entry.quantity)
}
private var scaledCalories: Double { entry.calories * quantity }
private var scaledProtein: Double { entry.protein * quantity }
private var scaledCarbs: Double { entry.carbs * quantity }
private var scaledFat: Double { entry.fat * quantity }
private var scaledSugar: Double? { entry.sugar.map { $0 * quantity } }
private var scaledFiber: Double? { entry.fiber.map { $0 * quantity } }
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
// Food name
Text(entry.foodName)
.font(.title2.bold())
.foregroundStyle(Color.textPrimary)
// Meal badge
HStack {
Image(systemName: entry.mealType.icon)
Text(entry.mealType.displayName)
}
.font(.caption.bold())
.foregroundStyle(entry.mealType.color)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(entry.mealType.color.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
// Quantity editor
VStack(spacing: 8) {
Text("Quantity")
.font(.subheadline.bold())
.foregroundStyle(Color.textSecondary)
HStack(spacing: 16) {
Button { if quantity > 0.5 { quantity -= 0.5 } } label: {
Image(systemName: "minus.circle.fill")
.font(.title2)
.foregroundStyle(Color.accent)
}
Text(String(format: "%.1f", quantity))
.font(.title2.bold())
.frame(minWidth: 60)
Button { quantity += 0.5 } label: {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundStyle(Color.accent)
}
}
}
if quantity != entry.quantity {
Button {
isSaving = true
Task {
_ = await FitnessRepository.shared.updateEntry(id: entry.id, quantity: quantity)
isSaving = false
dismiss()
}
} label: {
Group {
if isSaving {
ProgressView().tint(.white)
} else {
Text("Update Quantity")
.fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.frame(height: 44)
}
.buttonStyle(.borderedProminent)
.tint(Color.accent)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
// Nutrition grid
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
nutritionCell("Calories", "\(Int(scaledCalories))", "kcal")
nutritionCell("Protein", "\(Int(scaledProtein))", "g")
nutritionCell("Carbs", "\(Int(scaledCarbs))", "g")
nutritionCell("Fat", "\(Int(scaledFat))", "g")
if let sugar = scaledSugar {
nutritionCell("Sugar", "\(Int(sugar))", "g")
}
if let fiber = scaledFiber {
nutritionCell("Fiber", "\(Int(fiber))", "g")
}
}
.padding(16)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 16))
// Delete
Button(role: .destructive) {
isDeleting = true
Task {
await FitnessRepository.shared.deleteEntry(id: entry.id)
isDeleting = false
dismiss()
}
} label: {
HStack {
if isDeleting {
ProgressView().tint(.red)
} else {
Image(systemName: "trash")
Text("Delete Entry")
}
}
.frame(maxWidth: .infinity)
.frame(height: 44)
}
.buttonStyle(.bordered)
.tint(.red)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.padding(20)
}
.background(Color.canvas)
.navigationTitle("Entry Detail")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
}
}
}
}
private func nutritionCell(_ label: String, _ value: String, _ unit: String) -> some View {
VStack(spacing: 4) {
Text(value)
.font(.title3.bold())
.foregroundStyle(Color.textPrimary)
Text("\(label) (\(unit))")
.font(.caption)
.foregroundStyle(Color.textSecondary)
}
.frame(maxWidth: .infinity)
.padding(12)
.background(Color.surfaceSecondary)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}

View File

@@ -0,0 +1,83 @@
import SwiftUI
enum FitnessTab: String, CaseIterable {
case today = "Today"
case templates = "Templates"
case goals = "Goals"
case foods = "Foods"
}
struct FitnessTabView: View {
@State private var selectedTab: FitnessTab = .today
@State private var showAssistant = false
@State private var todayVM = TodayViewModel()
var body: some View {
NavigationStack {
ZStack(alignment: .bottomTrailing) {
VStack(spacing: 0) {
// Tab bar
HStack(spacing: 24) {
ForEach(FitnessTab.allCases, id: \.self) { tab in
Button {
withAnimation(.easeInOut(duration: 0.2)) {
selectedTab = tab
}
} label: {
Text(tab.rawValue)
.font(.subheadline)
.fontWeight(selectedTab == tab ? .bold : .medium)
.foregroundStyle(selectedTab == tab ? Color.accent : Color.textSecondary)
.padding(.bottom, 8)
.overlay(alignment: .bottom) {
if selectedTab == tab {
Rectangle()
.fill(Color.accent)
.frame(height: 2)
}
}
}
}
}
.padding(.top, 60)
.padding(.horizontal, 24)
// Content
TabView(selection: $selectedTab) {
TodayView(viewModel: todayVM)
.tag(FitnessTab.today)
TemplatesView(dateString: todayVM.dateString)
.tag(FitnessTab.templates)
GoalsView(dateString: todayVM.dateString)
.tag(FitnessTab.goals)
FoodLibraryView(dateString: todayVM.dateString)
.tag(FitnessTab.foods)
}
.tabViewStyle(.page(indexDisplayMode: .never))
}
// Floating + button
Button {
showAssistant = true
} label: {
Image(systemName: "plus")
.font(.title2.bold())
.foregroundStyle(.white)
.frame(width: 56, height: 56)
.background(Color.accent)
.clipShape(Circle())
.shadow(color: Color.accent.opacity(0.3), radius: 8, y: 4)
}
.padding(.trailing, 20)
.padding(.bottom, 20)
}
.background(Color.canvas.ignoresSafeArea())
.toolbar(.hidden, for: .navigationBar)
.sheet(isPresented: $showAssistant) {
AssistantChatView(entryDate: todayVM.dateString) {
Task { await todayVM.load() }
}
}
}
}
}

View File

@@ -0,0 +1,82 @@
import SwiftUI
struct FoodLibraryView: View {
let dateString: String
@State private var vm = FoodSearchViewModel()
@State private var selectedFood: FoodItem?
var body: some View {
VStack(spacing: 0) {
// Search bar
HStack {
Image(systemName: "magnifyingglass")
.foregroundStyle(Color.textTertiary)
TextField("Search foods...", text: $vm.query)
.textFieldStyle(.plain)
.autocorrectionDisabled()
.onChange(of: vm.query) { _, _ in
vm.search()
}
if !vm.query.isEmpty {
Button { vm.query = ""; vm.results = [] } label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Color.textTertiary)
}
}
}
.padding(12)
.background(Color.surfaceSecondary)
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 16)
.padding(.top, 8)
if vm.isLoadingInitial {
LoadingView()
} else {
let foods = vm.query.count >= 2 ? vm.results : vm.allFoods
if foods.isEmpty {
EmptyStateView(icon: "fork.knife", title: "No foods", subtitle: "Your food library is empty")
} else {
List(foods) { food in
Button {
selectedFood = food
} label: {
HStack {
if let img = food.imageFilename {
AsyncImage(url: URL(string: "\(Config.gatewayURL)/api/fitness/images/\(img)")) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
Color.surfaceSecondary
}
.frame(width: 40, height: 40)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
VStack(alignment: .leading, spacing: 2) {
Text(food.name)
.font(.subheadline)
.foregroundStyle(Color.textPrimary)
HStack(spacing: 8) {
Text("\(Int(food.calories)) cal")
Text("P:\(Int(food.protein))g")
Text("C:\(Int(food.carbs))g")
Text("F:\(Int(food.fat))g")
}
.font(.caption)
.foregroundStyle(Color.textSecondary)
}
Spacer()
}
}
}
.listStyle(.plain)
}
}
}
.task {
await vm.loadInitial()
}
.sheet(item: $selectedFood) { food in
AddFoodSheet(food: food, mealType: .snack, dateString: dateString) {}
}
}
}

View File

@@ -0,0 +1,119 @@
import SwiftUI
struct FoodSearchView: View {
let mealType: MealType
let dateString: String
@Environment(\.dismiss) private var dismiss
@State private var vm = FoodSearchViewModel()
@State private var selectedFood: FoodItem?
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Search bar
HStack {
Image(systemName: "magnifyingglass")
.foregroundStyle(Color.textTertiary)
TextField("Search foods...", text: $vm.query)
.textFieldStyle(.plain)
.autocorrectionDisabled()
.onChange(of: vm.query) { _, _ in
vm.search()
}
if !vm.query.isEmpty {
Button { vm.query = ""; vm.results = [] } label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Color.textTertiary)
}
}
}
.padding(12)
.background(Color.surfaceSecondary)
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 16)
.padding(.top, 8)
if vm.isSearching || vm.isLoadingInitial {
LoadingView()
} else if !vm.query.isEmpty && vm.query.count >= 2 {
// Search results
List {
Section {
ForEach(vm.results) { food in
foodRow(food)
}
} header: {
Text("\(vm.results.count) results")
}
}
.listStyle(.plain)
} else {
// Default: recent + all
List {
if !vm.recentFoods.isEmpty {
Section("Recent") {
ForEach(vm.recentFoods) { food in
foodRow(food)
}
}
}
if !vm.allFoods.isEmpty {
Section("All Foods") {
ForEach(vm.allFoods) { food in
foodRow(food)
}
}
}
}
.listStyle(.plain)
}
}
.background(Color.canvas)
.navigationTitle("Add Food")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
.task {
await vm.loadInitial()
}
.sheet(item: $selectedFood) { food in
AddFoodSheet(food: food, mealType: mealType, dateString: dateString) {
dismiss()
}
}
}
private func foodRow(_ food: FoodItem) -> some View {
Button {
selectedFood = food
} label: {
HStack {
if let img = food.imageFilename {
AsyncImage(url: URL(string: "\(Config.gatewayURL)/api/fitness/images/\(img)")) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
Color.surfaceSecondary
}
.frame(width: 40, height: 40)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
VStack(alignment: .leading, spacing: 2) {
Text(food.name)
.font(.subheadline)
.foregroundStyle(Color.textPrimary)
Text("\(Int(food.calories)) cal")
.font(.caption)
.foregroundStyle(Color.textSecondary)
}
Spacer()
Image(systemName: "plus.circle")
.foregroundStyle(Color.accent)
}
}
}
}

View File

@@ -0,0 +1,85 @@
import SwiftUI
struct GoalsView: View {
let dateString: String
@State private var vm = GoalsViewModel()
var body: some View {
ScrollView {
VStack(spacing: 16) {
if vm.isLoading {
LoadingView()
} else {
VStack(spacing: 12) {
goalField("Calories", value: $vm.calories, unit: "kcal")
goalField("Protein", value: $vm.protein, unit: "g")
goalField("Carbs", value: $vm.carbs, unit: "g")
goalField("Fat", value: $vm.fat, unit: "g")
goalField("Sugar", value: $vm.sugar, unit: "g")
goalField("Fiber", value: $vm.fiber, unit: "g")
}
.padding(16)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(.horizontal, 16)
Button {
Task { await vm.save() }
} label: {
Group {
if vm.isSaving {
ProgressView().tint(.white)
} else {
Text("Save Goals")
.fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.frame(height: 48)
}
.buttonStyle(.borderedProminent)
.tint(Color.accent)
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 16)
.disabled(vm.isSaving)
if vm.saved {
Text("Goals saved!")
.font(.caption)
.foregroundStyle(Color.emerald)
}
if let err = vm.error {
ErrorBanner(message: err)
}
}
Spacer(minLength: 80)
}
.padding(.top, 8)
}
.task {
await vm.load(date: dateString)
}
}
private func goalField(_ label: String, value: Binding<String>, unit: String) -> some View {
HStack {
Text(label)
.font(.subheadline)
.foregroundStyle(Color.textSecondary)
.frame(width: 80, alignment: .leading)
TextField("0", text: value)
.keyboardType(.numberPad)
.textFieldStyle(.plain)
.font(.subheadline.bold())
.padding(10)
.background(Color.surfaceSecondary)
.clipShape(RoundedRectangle(cornerRadius: 8))
Text(unit)
.font(.caption)
.foregroundStyle(Color.textTertiary)
.frame(width: 32)
}
}
}

View File

@@ -0,0 +1,131 @@
import SwiftUI
struct MealSectionView: View {
let meal: MealType
let entries: [FoodEntry]
let mealCalories: Double
let onDelete: (FoodEntry) -> Void
let dateString: String
@State private var isExpanded = true
@State private var showFoodSearch = false
@State private var selectedEntry: FoodEntry?
var body: some View {
VStack(spacing: 0) {
// Header
Button {
withAnimation(.easeInOut(duration: 0.2)) {
isExpanded.toggle()
}
} label: {
HStack(spacing: 0) {
// Colored accent bar
RoundedRectangle(cornerRadius: 2)
.fill(meal.color)
.frame(width: 4, height: 44)
.padding(.trailing, 12)
Image(systemName: meal.icon)
.font(.headline)
.foregroundStyle(meal.color)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text(meal.displayName)
.font(.headline)
.foregroundStyle(Color.textPrimary)
Text("\(entries.count) item\(entries.count == 1 ? "" : "s")")
.font(.caption)
.foregroundStyle(Color.textSecondary)
}
.padding(.leading, 8)
Spacer()
Text("\(Int(mealCalories)) cal")
.font(.subheadline.bold())
.foregroundStyle(meal.color)
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(Color.textTertiary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.padding(.leading, 8)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
// Entries
if isExpanded && !entries.isEmpty {
VStack(spacing: 0) {
ForEach(entries) { entry in
Button {
selectedEntry = entry
} label: {
entryRow(entry)
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
onDelete(entry)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.padding(.leading, 40)
}
// Add button
if isExpanded {
Button {
showFoodSearch = true
} label: {
HStack {
Image(systemName: "plus.circle.fill")
.foregroundStyle(meal.color.opacity(0.6))
Text("Add food")
.font(.subheadline)
.foregroundStyle(Color.textSecondary)
}
.padding(.vertical, 8)
.padding(.leading, 44)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.background(meal.color.opacity(0.03))
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 16)
.sheet(isPresented: $showFoodSearch) {
FoodSearchView(mealType: meal, dateString: dateString)
}
.sheet(item: $selectedEntry) { entry in
EntryDetailView(entry: entry, dateString: dateString)
}
}
private func entryRow(_ entry: FoodEntry) -> some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(entry.foodName)
.font(.subheadline)
.foregroundStyle(Color.textPrimary)
.lineLimit(1)
if entry.quantity != 1 {
Text("x\(String(format: "%.1f", entry.quantity))")
.font(.caption)
.foregroundStyle(Color.textSecondary)
}
}
Spacer()
Text("\(Int(entry.calories * entry.quantity))")
.font(.subheadline)
.foregroundStyle(Color.textSecondary)
}
.padding(.vertical, 6)
.padding(.horizontal, 16)
}
}

View File

@@ -0,0 +1,103 @@
import SwiftUI
struct TemplatesView: View {
let dateString: String
@State private var vm = TemplatesViewModel()
@State private var templateToLog: MealTemplate?
@State private var showConfirm = false
var body: some View {
ScrollView {
VStack(spacing: 16) {
if vm.isLoading {
LoadingView()
} else if vm.templates.isEmpty {
EmptyStateView(icon: "doc.on.doc", title: "No templates", subtitle: "Create meal templates from the web app")
} else {
ForEach(MealType.allCases) { meal in
let templates = vm.groupedByMeal[meal] ?? []
if !templates.isEmpty {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: meal.icon)
.foregroundStyle(meal.color)
Text(meal.displayName)
.font(.headline)
.foregroundStyle(Color.textPrimary)
}
.padding(.horizontal, 16)
ForEach(templates) { template in
templateCard(template)
}
}
}
}
}
if let msg = vm.logSuccess {
Text(msg)
.font(.caption)
.foregroundStyle(Color.emerald)
.padding(.top, 4)
}
if let err = vm.error {
ErrorBanner(message: err)
}
Spacer(minLength: 80)
}
.padding(.top, 8)
}
.task {
await vm.load()
}
.alert("Log Meal", isPresented: $showConfirm, presenting: templateToLog) { template in
Button("Log") {
Task { await vm.logTemplate(template, date: dateString) }
}
Button("Cancel", role: .cancel) {}
} message: { template in
Text("Add \(template.name) to today's log?")
}
}
private func templateCard(_ template: MealTemplate) -> some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(template.name)
.font(.subheadline.bold())
.foregroundStyle(Color.textPrimary)
HStack(spacing: 8) {
if let cal = template.totalCalories {
Text("\(Int(cal)) cal")
}
if let items = template.itemCount {
Text("\(items) items")
}
}
.font(.caption)
.foregroundStyle(Color.textSecondary)
}
Spacer()
Button {
templateToLog = template
showConfirm = true
} label: {
Text("Log meal")
.font(.caption.bold())
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.accent)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
.padding(14)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.03), radius: 4, y: 1)
.padding(.horizontal, 16)
}
}

View File

@@ -0,0 +1,96 @@
import SwiftUI
struct TodayView: View {
@Bindable var viewModel: TodayViewModel
@State private var showFoodSearch = false
var body: some View {
ScrollView {
VStack(spacing: 16) {
// Date selector
HStack {
Button { viewModel.previousDay() } label: {
Image(systemName: "chevron.left")
.font(.headline)
.foregroundStyle(Color.accent)
}
Spacer()
Text(viewModel.displayDate)
.font(.headline)
.foregroundStyle(Color.textPrimary)
Spacer()
Button { viewModel.nextDay() } label: {
Image(systemName: "chevron.right")
.font(.headline)
.foregroundStyle(Color.accent)
}
}
.padding(.horizontal, 20)
.padding(.top, 8)
.gesture(
DragGesture(minimumDistance: 50)
.onEnded { value in
if value.translation.width > 0 {
viewModel.previousDay()
} else {
viewModel.nextDay()
}
}
)
// Macro summary card
if !viewModel.repository.isLoading {
macroSummaryCard
}
// Error
if let error = viewModel.repository.error {
ErrorBanner(message: error) { await viewModel.load() }
}
// Meal sections
ForEach(MealType.allCases) { meal in
MealSectionView(
meal: meal,
entries: viewModel.repository.entriesForMeal(meal),
mealCalories: viewModel.repository.mealCalories(meal),
onDelete: { entry in
Task { await viewModel.deleteEntry(entry) }
},
dateString: viewModel.dateString
)
}
Spacer(minLength: 80)
}
}
.refreshable {
await viewModel.load()
}
.task {
await viewModel.load()
}
}
private var macroSummaryCard: some View {
let repo = viewModel.repository
let goals = repo.goals
return VStack(spacing: 12) {
HStack(spacing: 20) {
LargeCalorieRing(consumed: repo.totalCalories, goal: goals.calories)
VStack(spacing: 8) {
MacroBar(label: "Protein", value: repo.totalProtein, goal: goals.protein, color: .blue)
MacroBar(label: "Carbs", value: repo.totalCarbs, goal: goals.carbs, color: .orange)
MacroBar(label: "Fat", value: repo.totalFat, goal: goals.fat, color: .purple)
}
}
}
.padding(16)
.background(Color.surface)
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.04), radius: 8, y: 2)
.padding(.horizontal, 16)
}
}

View File

@@ -0,0 +1,161 @@
import SwiftUI
import PhotosUI
struct HomeView: View {
@Environment(AuthManager.self) private var authManager
@State private var vm = HomeViewModel()
@State private var showAssistant = false
@State private var showProfileMenu = false
@State private var selectedPhoto: PhotosPickerItem?
var body: some View {
NavigationStack {
ZStack(alignment: .bottomTrailing) {
// Background
if let bg = vm.backgroundImage {
Image(uiImage: bg)
.resizable()
.aspectRatio(contentMode: .fill)
.ignoresSafeArea()
.overlay(Color.black.opacity(0.2).ignoresSafeArea())
}
else {
Color.canvas.ignoresSafeArea()
}
ScrollView {
VStack(spacing: 16) {
// Top bar
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Dashboard")
.font(.title2.bold())
.foregroundStyle(vm.backgroundImage != nil ? .white : Color.textPrimary)
if let name = authManager.user?.displayName ?? authManager.user?.username {
Text("Welcome, \(name)")
.font(.subheadline)
.foregroundStyle(vm.backgroundImage != nil ? .white.opacity(0.8) : Color.textSecondary)
}
}
Spacer()
Menu {
PhotosPicker(selection: $selectedPhoto, matching: .images) {
Label("Change Background", systemImage: "photo")
}
if vm.backgroundImage != nil {
Button(role: .destructive) {
vm.removeBackground()
} label: {
Label("Remove Background", systemImage: "trash")
}
}
Divider()
Button(role: .destructive) {
authManager.logout()
} label: {
Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
}
} label: {
Image(systemName: "person.circle.fill")
.font(.title2)
.foregroundStyle(vm.backgroundImage != nil ? .white : Color.accent)
}
}
.padding(.horizontal, 20)
.padding(.top, 60)
// Widget grid
LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) {
calorieWidget
quickStatsWidget
}
.padding(.horizontal, 16)
Spacer(minLength: 100)
}
}
// Floating + button
Button {
showAssistant = true
} label: {
Image(systemName: "plus")
.font(.title2.bold())
.foregroundStyle(.white)
.frame(width: 56, height: 56)
.background(Color.accent)
.clipShape(Circle())
.shadow(color: Color.accent.opacity(0.3), radius: 8, y: 4)
}
.padding(.trailing, 20)
.padding(.bottom, 20)
}
.toolbar(.hidden, for: .navigationBar)
.sheet(isPresented: $showAssistant) {
AssistantChatView(entryDate: Date().apiDateString) {
Task { await vm.loadData() }
}
}
.onChange(of: selectedPhoto) { _, newValue in
guard let item = newValue else { return }
Task {
if let data = try? await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) {
vm.setBackground(image)
}
selectedPhoto = nil
}
}
.task {
await vm.loadData()
}
}
}
private var calorieWidget: some View {
let hasBg = vm.backgroundImage != nil
return VStack(spacing: 8) {
LargeCalorieRing(consumed: vm.caloriesConsumed, goal: vm.caloriesGoal)
Text("Calories")
.font(.caption.bold())
.foregroundStyle(hasBg ? .white : Color.textSecondary)
}
.padding(16)
.frame(maxWidth: .infinity)
.background {
if hasBg {
RoundedRectangle(cornerRadius: 16)
.fill(.ultraThinMaterial)
} else {
RoundedRectangle(cornerRadius: 16)
.fill(Color.surface)
.shadow(color: .black.opacity(0.04), radius: 8, y: 2)
}
}
}
private var quickStatsWidget: some View {
let hasBg = vm.backgroundImage != nil
let repo = FitnessRepository.shared
return VStack(alignment: .leading, spacing: 10) {
Text("Macros")
.font(.caption.bold())
.foregroundStyle(hasBg ? .white : Color.textSecondary)
MacroBar(label: "Protein", value: repo.totalProtein, goal: repo.goals.protein, color: .blue, compact: true)
MacroBar(label: "Carbs", value: repo.totalCarbs, goal: repo.goals.carbs, color: .orange, compact: true)
MacroBar(label: "Fat", value: repo.totalFat, goal: repo.goals.fat, color: .purple, compact: true)
}
.padding(16)
.frame(maxWidth: .infinity)
.background {
if hasBg {
RoundedRectangle(cornerRadius: 16)
.fill(.ultraThinMaterial)
} else {
RoundedRectangle(cornerRadius: 16)
.fill(Color.surface)
.shadow(color: .black.opacity(0.04), radius: 8, y: 2)
}
}
}
}

View File

@@ -0,0 +1,54 @@
import SwiftUI
import PhotosUI
@Observable
final class HomeViewModel {
var backgroundImage: UIImage?
var caloriesConsumed: Double = 0
var caloriesGoal: Double = 2000
var isLoading = false
private let bgKey = "homeBackgroundImage"
private let repo = FitnessRepository.shared
init() {
loadBackgroundFromDefaults()
}
func loadData() async {
isLoading = true
let today = Date().apiDateString
await repo.loadDay(date: today)
caloriesConsumed = repo.totalCalories
caloriesGoal = repo.goals.calories
isLoading = false
}
func setBackground(_ image: UIImage) {
// Resize to max 1200px
let maxDim: CGFloat = 1200
let scale = min(maxDim / image.size.width, maxDim / image.size.height, 1.0)
let newSize = CGSize(width: image.size.width * scale, height: image.size.height * scale)
UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0)
image.draw(in: CGRect(origin: .zero, size: newSize))
let resized = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
if let resized, let data = resized.jpegData(compressionQuality: 0.8) {
UserDefaults.standard.set(data, forKey: bgKey)
backgroundImage = resized
}
}
func removeBackground() {
UserDefaults.standard.removeObject(forKey: bgKey)
backgroundImage = nil
}
private func loadBackgroundFromDefaults() {
if let data = UserDefaults.standard.data(forKey: bgKey),
let img = UIImage(data: data) {
backgroundImage = img
}
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,13 @@
import SwiftUI
@main
struct PlatformApp: App {
@State private var authManager = AuthManager()
var body: some Scene {
WindowGroup {
ContentView()
.environment(authManager)
}
}
}

View File

@@ -0,0 +1,66 @@
import SwiftUI
struct LoadingView: View {
var message: String = "Loading..."
var body: some View {
VStack(spacing: 12) {
ProgressView()
.tint(.accent)
Text(message)
.font(.subheadline)
.foregroundStyle(Color.textSecondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct ErrorBanner: View {
let message: String
var retry: (() async -> Void)?
var body: some View {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text(message)
.font(.subheadline)
.foregroundStyle(Color.textPrimary)
Spacer()
if let retry = retry {
Button("Retry") {
Task { await retry() }
}
.font(.subheadline.bold())
.foregroundStyle(Color.accent)
}
}
.padding(12)
.background(Color.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding(.horizontal)
}
}
struct EmptyStateView: View {
let icon: String
let title: String
let subtitle: String
var body: some View {
VStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 40))
.foregroundStyle(Color.textTertiary)
Text(title)
.font(.headline)
.foregroundStyle(Color.textPrimary)
Text(subtitle)
.font(.subheadline)
.foregroundStyle(Color.textSecondary)
.multilineTextAlignment(.center)
}
.padding(40)
.frame(maxWidth: .infinity)
}
}

View File

@@ -0,0 +1,44 @@
import SwiftUI
struct MacroBar: View {
let label: String
let value: Double
let goal: Double
let color: Color
var compact: Bool = false
private var progress: Double {
guard goal > 0 else { return 0 }
return min(max(value / goal, 0), 1)
}
var body: some View {
VStack(alignment: .leading, spacing: compact ? 2 : 4) {
HStack {
Text(label)
.font(compact ? .caption2 : .caption)
.fontWeight(.medium)
.foregroundStyle(Color.textSecondary)
Spacer()
Text("\(Int(value))/\(Int(goal))g")
.font(compact ? .caption2 : .caption)
.fontWeight(.semibold)
.foregroundStyle(Color.textPrimary)
}
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: compact ? 2 : 3)
.fill(color.opacity(0.15))
.frame(height: compact ? 4 : 6)
RoundedRectangle(cornerRadius: compact ? 2 : 3)
.fill(color)
.frame(width: max(0, geo.size.width * progress), height: compact ? 4 : 6)
.animation(.easeInOut(duration: 0.5), value: progress)
}
}
.frame(height: compact ? 4 : 6)
}
}
}

View File

@@ -0,0 +1,83 @@
import SwiftUI
struct MacroRing: View {
let consumed: Double
let goal: Double
let color: Color
var size: CGFloat = 80
var lineWidth: CGFloat = 8
var showLabel: Bool = true
var labelFontSize: CGFloat = 14
private var progress: Double {
guard goal > 0 else { return 0 }
return min(max(consumed / goal, 0), 1)
}
var body: some View {
ZStack {
Circle()
.stroke(color.opacity(0.15), lineWidth: lineWidth)
Circle()
.trim(from: 0, to: progress)
.stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.easeInOut(duration: 0.6), value: progress)
if showLabel {
VStack(spacing: 0) {
Text("\(Int(consumed))")
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
.foregroundStyle(Color.textPrimary)
if goal > 0 {
Text("/ \(Int(goal))")
.font(.system(size: labelFontSize * 0.65, weight: .medium, design: .rounded))
.foregroundStyle(Color.textSecondary)
}
}
}
}
.frame(width: size, height: size)
}
}
struct LargeCalorieRing: View {
let consumed: Double
let goal: Double
private var remaining: Int {
max(0, Int(goal) - Int(consumed))
}
private var progress: Double {
guard goal > 0 else { return 0 }
return min(max(consumed / goal, 0), 1)
}
var body: some View {
ZStack {
Circle()
.stroke(Color.emerald.opacity(0.15), lineWidth: 14)
Circle()
.trim(from: 0, to: progress)
.stroke(
Color.emerald,
style: StrokeStyle(lineWidth: 14, lineCap: .round)
)
.rotationEffect(.degrees(-90))
.animation(.easeInOut(duration: 0.8), value: progress)
VStack(spacing: 2) {
Text("\(Int(consumed))")
.font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundStyle(Color.textPrimary)
Text("\(remaining) left")
.font(.system(size: 12, weight: .medium, design: .rounded))
.foregroundStyle(Color.textSecondary)
}
}
.frame(width: 120, height: 120)
}
}

View File

@@ -0,0 +1,47 @@
import SwiftUI
extension Color {
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 6:
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8:
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (255, 0, 0, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
// Core palette
static let canvas = Color(hex: "F5EFE6")
static let surface = Color(hex: "FFFFFF")
static let surfaceSecondary = Color(hex: "FAF7F2")
static let accent = Color(hex: "8B6914")
static let accentLight = Color(hex: "D4A843")
static let emerald = Color(hex: "059669")
static let textPrimary = Color(hex: "1C1917")
static let textSecondary = Color(hex: "78716C")
static let textTertiary = Color(hex: "A8A29E")
static let border = Color(hex: "E7E5E4")
// Meal colors
static let breakfastColor = Color(hex: "F59E0B")
static let lunchColor = Color(hex: "059669")
static let dinnerColor = Color(hex: "8B5CF6")
static let snackColor = Color(hex: "EC4899")
// Chat
static let userBubble = Color(hex: "8B6914").opacity(0.15)
static let assistantBubble = Color(hex: "F5F5F4")
}

View File

@@ -0,0 +1,33 @@
import Foundation
extension Date {
var apiDateString: String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.string(from: self)
}
var displayString: String {
let formatter = DateFormatter()
formatter.dateFormat = "EEEE, MMM d"
return formatter.string(from: self)
}
var shortDisplayString: String {
let formatter = DateFormatter()
formatter.dateFormat = "MMM d, yyyy"
return formatter.string(from: self)
}
var isToday: Bool {
Calendar.current.isDateInToday(self)
}
static func from(apiString: String) -> Date? {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.date(from: apiString)
}
}

View File

@@ -2,8 +2,8 @@ FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends libpq-dev tesseract-ocr tesseract-ocr-eng && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir --upgrade pip
RUN apt-get update && apt-get install -y --no-install-recommends libpq-dev tesseract-ocr tesseract-ocr-eng ffmpeg && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir --upgrade pip yt-dlp
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

View File

@@ -13,10 +13,10 @@ from sqlalchemy.orm import selectinload
from app.api.deps import get_user_id, get_db_session
from app.config import FOLDERS, TAGS
from app.models.item import Item, ItemAsset
from app.models.item import Item, ItemAsset, ItemAddition
from app.models.schema import (
ItemCreate, ItemUpdate, ItemOut, ItemList, SearchQuery, SemanticSearchQuery,
HybridSearchQuery, SearchResult, ConfigOut,
HybridSearchQuery, SearchResult, ConfigOut, ItemAdditionCreate, ItemAdditionOut,
)
from app.services.storage import storage
from fastapi.responses import Response
@@ -25,6 +25,46 @@ from app.worker.tasks import enqueue_process_item
router = APIRouter(prefix="/api", tags=["brain"])
async def refresh_item_search_state(db: AsyncSession, item: Item):
"""Recompute embedding + Meilisearch doc after assistant additions change."""
from app.search.engine import index_item
from app.services.embed import generate_embedding
additions_result = await db.execute(
select(ItemAddition)
.where(ItemAddition.item_id == item.id, ItemAddition.user_id == item.user_id)
.order_by(ItemAddition.created_at.asc())
)
additions = additions_result.scalars().all()
additions_text = "\n\n".join(addition.content for addition in additions if addition.content.strip())
searchable_text_parts = [item.raw_content or "", item.extracted_text or "", additions_text]
searchable_text = "\n\n".join(part.strip() for part in searchable_text_parts if part and part.strip())
embed_text = f"{item.title or ''}\n{item.summary or ''}\n{searchable_text}".strip()
embedding = await generate_embedding(embed_text)
if embedding:
item.embedding = embedding
item.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(item)
await index_item({
"id": item.id,
"user_id": item.user_id,
"type": item.type,
"title": item.title,
"url": item.url,
"folder": item.folder,
"tags": item.tags or [],
"summary": item.summary,
"extracted_text": searchable_text[:10000],
"processing_status": item.processing_status,
"created_at": item.created_at.isoformat() if item.created_at else None,
})
# ── Health ──
@router.get("/health")
@@ -201,14 +241,31 @@ async def update_item(
item.title = body.title
if body.folder is not None:
item.folder = body.folder
# Update folder_id FK
from app.models.taxonomy import Folder as FolderModel
folder_row = (await db.execute(
select(FolderModel).where(FolderModel.user_id == user_id, FolderModel.name == body.folder)
)).scalar_one_or_none()
item.folder_id = folder_row.id if folder_row else None
if body.tags is not None:
item.tags = body.tags
# Update item_tags relational entries
from app.models.taxonomy import Tag as TagModel, ItemTag
from sqlalchemy import delete as sa_delete
await db.execute(sa_delete(ItemTag).where(ItemTag.item_id == item.id))
for tag_name in body.tags:
tag_row = (await db.execute(
select(TagModel).where(TagModel.user_id == user_id, TagModel.name == tag_name)
)).scalar_one_or_none()
if tag_row:
db.add(ItemTag(item_id=item.id, tag_id=tag_row.id))
if body.raw_content is not None:
item.raw_content = body.raw_content
item.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(item)
await refresh_item_search_state(db, item)
return item
@@ -238,6 +295,100 @@ async def delete_item(
return {"status": "deleted"}
@router.get("/items/{item_id}/additions", response_model=list[ItemAdditionOut])
async def list_item_additions(
item_id: str,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
item = (await db.execute(
select(Item).where(Item.id == item_id, Item.user_id == user_id)
)).scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Item not found")
additions = (await db.execute(
select(ItemAddition)
.where(ItemAddition.item_id == item_id, ItemAddition.user_id == user_id)
.order_by(ItemAddition.created_at.asc())
)).scalars().all()
return additions
@router.post("/items/{item_id}/additions", response_model=ItemAdditionOut, status_code=201)
async def create_item_addition(
item_id: str,
body: ItemAdditionCreate,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
item = (await db.execute(
select(Item).where(Item.id == item_id, Item.user_id == user_id)
)).scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Item not found")
content = body.content.strip()
if not content:
raise HTTPException(status_code=400, detail="Addition content cannot be empty")
addition = ItemAddition(
id=str(uuid.uuid4()),
item_id=item.id,
user_id=user_id,
source=(body.source or "assistant").strip() or "assistant",
kind=(body.kind or "append").strip() or "append",
content=content,
metadata_json=body.metadata_json or {},
)
db.add(addition)
item.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(addition)
result = await db.execute(
select(Item).where(Item.id == item.id, Item.user_id == user_id)
)
fresh_item = result.scalar_one()
await refresh_item_search_state(db, fresh_item)
return addition
@router.delete("/items/{item_id}/additions/{addition_id}")
async def delete_item_addition(
item_id: str,
addition_id: str,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
item = (await db.execute(
select(Item).where(Item.id == item_id, Item.user_id == user_id)
)).scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Item not found")
addition = (await db.execute(
select(ItemAddition).where(
ItemAddition.id == addition_id,
ItemAddition.item_id == item_id,
ItemAddition.user_id == user_id,
)
)).scalar_one_or_none()
if not addition:
raise HTTPException(status_code=404, detail="Addition not found")
await db.delete(addition)
item.updated_at = datetime.utcnow()
await db.commit()
result = await db.execute(
select(Item).where(Item.id == item.id, Item.user_id == user_id)
)
fresh_item = result.scalar_one()
await refresh_item_search_state(db, fresh_item)
return {"status": "deleted"}
# ── Reprocess item ──
@router.post("/items/{item_id}/reprocess", response_model=ItemOut)
@@ -335,5 +486,7 @@ async def serve_asset(item_id: str, asset_type: str, filename: str):
elif filename.endswith(".jpg") or filename.endswith(".jpeg"): ct = "image/jpeg"
elif filename.endswith(".html"): ct = "text/html"
elif filename.endswith(".pdf"): ct = "application/pdf"
elif filename.endswith(".mp4"): ct = "video/mp4"
elif filename.endswith(".webm"): ct = "video/webm"
return Response(content=data, media_type=ct, headers={"Cache-Control": "public, max-age=3600"})

View File

@@ -17,8 +17,8 @@ MEILI_URL = os.environ.get("MEILI_URL", "http://brain-meili:7700")
MEILI_KEY = os.environ.get("MEILI_MASTER_KEY", "brain-meili-key")
MEILI_INDEX = "items"
# ── Browserless ──
BROWSERLESS_URL = os.environ.get("BROWSERLESS_URL", "http://brain-browserless:3000")
# ── Crawler ──
CRAWLER_URL = os.environ.get("CRAWLER_URL", "http://brain-crawler:3100")
# ── OpenAI ──
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
@@ -42,14 +42,14 @@ DEBUG = os.environ.get("DEBUG", "").lower() in ("1", "true")
# ── Classification rules ──
FOLDERS = [
"Home", "Family", "Work", "Travel", "Knowledge", "Faith", "Projects"
"Home", "Family", "Work", "Travel", "Islam",
"Homelab", "Vanlife", "3D Printing", "Documents",
]
TAGS = [
"reference", "important", "legal", "financial", "insurance",
"research", "idea", "guide", "tutorial", "setup", "how-to",
"tools", "dev", "server", "selfhosted", "home-assistant",
"shopping", "compare", "buy", "product",
"family", "kids", "health", "travel", "faith",
"video", "read-later", "books",
"diy", "reference", "home-assistant", "shopping", "video",
"tutorial", "server", "kids", "books", "travel",
"churning", "lawn-garden", "piracy", "work", "3d-printing",
"lectures", "vanlife", "yusuf", "madiha", "hafsa", "mustafa",
"medical", "legal", "vehicle", "insurance", "financial", "homeschool",
]

View File

@@ -31,7 +31,7 @@ app.include_router(taxonomy_router)
async def startup():
from sqlalchemy import text as sa_text
from app.database import engine, Base
from app.models.item import Item, ItemAsset, AppLink # noqa: import to register models
from app.models.item import Item, ItemAsset, AppLink, ItemAddition # noqa: import to register models
from app.models.taxonomy import Folder, Tag, ItemTag # noqa: register taxonomy tables
# Enable pgvector extension before creating tables

View File

@@ -45,6 +45,12 @@ class Item(Base):
# Relationships
assets = relationship("ItemAsset", back_populates="item", cascade="all, delete-orphan")
additions = relationship(
"ItemAddition",
back_populates="item",
cascade="all, delete-orphan",
order_by="ItemAddition.created_at",
)
__table_args__ = (
Index("ix_items_user_status", "user_id", "processing_status"),
@@ -79,3 +85,19 @@ class AppLink(Base):
app = Column(String(64), nullable=False) # trips|tasks|fitness|inventory
app_entity_id = Column(String(128), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
class ItemAddition(Base):
__tablename__ = "item_additions"
id = Column(UUID(as_uuid=False), primary_key=True, default=new_id)
item_id = Column(UUID(as_uuid=False), ForeignKey("items.id", ondelete="CASCADE"), nullable=False, index=True)
user_id = Column(String(64), nullable=False, index=True)
source = Column(String(32), nullable=False, default="assistant") # assistant|manual
kind = Column(String(32), nullable=False, default="append") # append
content = Column(Text, nullable=False)
metadata_json = Column(JSONB, nullable=True, default=dict)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
item = relationship("Item", back_populates="additions")

View File

@@ -26,6 +26,13 @@ class ItemUpdate(BaseModel):
raw_content: Optional[str] = None
class ItemAdditionCreate(BaseModel):
content: str
source: Optional[str] = "assistant"
kind: Optional[str] = "append"
metadata_json: Optional[dict] = None
class SearchQuery(BaseModel):
q: str
folder: Optional[str] = None
@@ -63,6 +70,19 @@ class AssetOut(BaseModel):
model_config = {"from_attributes": True}
class ItemAdditionOut(BaseModel):
id: str
item_id: str
source: str
kind: str
content: str
metadata_json: Optional[dict] = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class ItemOut(BaseModel):
id: str
type: str

View File

@@ -70,23 +70,24 @@ class ItemTag(Base):
# Default folders with colors and icons
DEFAULT_FOLDERS = [
{"name": "Home", "color": "#059669", "icon": "home"},
{"name": "Family", "color": "#D97706", "icon": "heart"},
{"name": "Work", "color": "#4338CA", "icon": "briefcase"},
{"name": "Travel", "color": "#0EA5E9", "icon": "plane"},
{"name": "Knowledge", "color": "#8B5CF6", "icon": "book-open"},
{"name": "Faith", "color": "#10B981", "icon": "moon"},
{"name": "Projects", "color": "#F43F5E", "icon": "folder"},
{"name": "Home", "color": "#059669", "icon": "home"},
{"name": "Family", "color": "#D97706", "icon": "heart"},
{"name": "Work", "color": "#4338CA", "icon": "briefcase"},
{"name": "Travel", "color": "#0EA5E9", "icon": "plane"},
{"name": "Islam", "color": "#10B981", "icon": "moon"},
{"name": "Homelab", "color": "#6366F1", "icon": "server"},
{"name": "Vanlife", "color": "#F59E0B", "icon": "truck"},
{"name": "3D Printing", "color": "#EC4899", "icon": "printer"},
{"name": "Documents", "color": "#78716C", "icon": "file-text"},
]
# Default tags to seed for new users
DEFAULT_TAGS = [
"reference", "important", "legal", "financial", "insurance",
"research", "idea", "guide", "tutorial", "setup", "how-to",
"tools", "dev", "server", "selfhosted", "home-assistant",
"shopping", "compare", "buy", "product",
"family", "kids", "health", "travel", "faith",
"video", "read-later", "books",
"diy", "reference", "home-assistant", "shopping", "video",
"tutorial", "server", "kids", "books", "travel",
"churning", "lawn-garden", "piracy", "work", "3d-printing",
"lectures", "vanlife", "yusuf", "madiha", "hafsa", "mustafa",
"medical", "legal", "vehicle", "insurance", "financial", "homeschool",
]

View File

@@ -9,20 +9,61 @@ from app.config import OPENAI_API_KEY, OPENAI_MODEL
log = logging.getLogger(__name__)
TAG_DEFINITIONS = {
"home-assistant": "Home Assistant specific content (dashboards, ESPHome, automations, integrations, Lovelace cards)",
"server": "Server/infrastructure content (Docker, backups, networking, self-hosted apps, Linux)",
"kids": "Anything related to children, parenting, or educational content for kids",
"shopping": "A product page, product review, or specific item you might want to buy (Amazon, stores, book reviews with purchase links). NOT general discussion threads or forums comparing many options.",
"diy": "Physical hands-on projects around the house, yard, or vehicle — repairs, woodworking, crafts, building things. NOT software, dashboards, or digital projects.",
"reference": "Lookup info like contacts, sizes, specs, measurements, settings to remember",
"video": "Video content (YouTube, TikTok, etc)",
"tutorial": "How-to guides, step-by-step instructions, learning content",
"books": "Book recommendations, reviews, or reading lists",
"travel": "Destinations, resorts, hotels, trip ideas, reviews, places to visit",
"churning": "Credit card points, miles, award travel, hotel loyalty programs, points maximization, sign-up bonuses",
"lawn-garden": "Lawn care, gardening, yard work, bug spraying, fertilizer, landscaping, plants, outdoor maintenance",
"piracy": "Anything to do with downloading content like Audiobooks, games",
"lectures": "Lecture notes, Islamic talks, sermon recordings, religious class notes",
"3d-printing": "3D printer files (STL), printer mods, filament, slicer settings, 3D printed objects and projects",
"work": "Work-related content",
"vanlife": "Van conversion, Promaster van, van build projects, camping in vans, van electrical/solar, van life lifestyle",
"yusuf": "Personal document belonging to family member Yusuf (look for name in title or content)",
"madiha": "Personal document belonging to family member Madiha (look for name in title or content)",
"hafsa": "Personal document belonging to family member Hafsa (look for name in title or content)",
"mustafa": "Personal document belonging to family member Mustafa (look for name in title or content)",
"medical": "Medical records, allergy results, prescriptions, lab work, vaccination records, doctor notes",
"legal": "Birth certificates, passports, IDs, citizenship papers, contracts, legal agreements",
"vehicle": "Car registration, license plates, insurance cards, vehicle titles, maintenance records",
"insurance": "Insurance policies, insurance cards, coverage documents, claims",
"financial": "Tax documents, bank statements, pay stubs, loan papers, credit reports",
"homeschool": "Homeschooling resources, curriculum, lesson plans, educational materials for teaching kids at home, school projects, science experiments",
}
def build_system_prompt(folders: list[str], tags: list[str]) -> str:
tag_defs = "\n".join(
f" - '{t}': {TAG_DEFINITIONS[t]}" if t in TAG_DEFINITIONS else f" - '{t}'"
for t in tags
)
return f"""You are a classification engine for a personal "second brain" knowledge management system.
Given an item (URL, note, document, or file), you must return structured JSON with:
- folder: exactly 1 from this list: {json.dumps(folders)}
- tags: exactly 2 or 3 from this list: {json.dumps(tags)}
- title: a concise, normalized title (max 80 chars)
- tags: ONLY from this predefined list. Do NOT create any new tags outside this list. If no tags fit, return an empty array.
- title: a concise, normalized title in Title Case with spaces (max 80 chars, e.g. 'Machine Learning', 'Web Development')
- summary: a 1-2 sentence summary of the content (for links/documents only)
- corrected_text: for NOTES ONLY — return the original note text with spelling/grammar fixed. Keep the original meaning, tone, and structure. Only fix typos and obvious errors. Return empty string for non-notes.
- confidence: a float 0.0-1.0 indicating how confident you are
Tag definitions (only assign tags that STRONGLY match the content):
{tag_defs}
Rules:
- NEVER invent folders or tags not in the lists above
- Only assign tags that STRONGLY match the content. 1-2 tags is perfectly fine.
- Do NOT pad with extra tags just to reach a target number. If only one tag fits, only use one.
- If NO tags fit the content, return an empty tags array.
- Name tags: 'yusuf', 'madiha', 'hafsa', or 'mustafa' ONLY when the content is a personal document belonging to that family member (look for their name in the title or content)
- NEVER skip classification
- NEVER return freeform text outside the schema
- For notes: do NOT summarize. Keep the original text. Only fix spelling.
@@ -43,7 +84,7 @@ def build_response_schema(folders: list[str], tags: list[str]) -> dict:
"tags": {
"type": "array",
"items": {"type": "string", "enum": tags},
"minItems": 2,
"minItems": 0,
"maxItems": 3,
},
"title": {"type": "string"},
@@ -88,8 +129,8 @@ async def classify_item(
if not OPENAI_API_KEY:
log.warning("No OPENAI_API_KEY set, returning defaults")
return {
"folder": "Knowledge",
"tags": ["reference", "read-later"],
"folder": "Home",
"tags": ["reference"],
"title": title or "Untitled",
"summary": "No AI classification available",
"confidence": 0.0,
@@ -122,10 +163,8 @@ async def classify_item(
# Validate folder and tags are in allowed sets
if result["folder"] not in folders:
result["folder"] = folders[0] if folders else "Knowledge"
result["folder"] = folders[0] if folders else "Home"
result["tags"] = [t for t in result["tags"] if t in tags][:3]
if len(result["tags"]) < 2:
result["tags"] = (result["tags"] + ["reference", "read-later"])[:3]
return result
@@ -133,8 +172,8 @@ async def classify_item(
log.error(f"Classification attempt {attempt + 1} failed: {e}")
if attempt == retries:
return {
"folder": "Knowledge",
"tags": ["reference", "read-later"],
"folder": "Home",
"tags": ["reference"],
"title": title or "Untitled",
"summary": f"Classification failed: {e}",
"confidence": 0.0,

View File

@@ -1,162 +1,218 @@
"""Content ingestion — fetch, extract, screenshot, archive."""
"""Content ingestion — Playwright crawler for HTML, screenshots, og:image."""
import base64
import logging
import re
import uuid
from html.parser import HTMLParser
from io import StringIO
from urllib.parse import urlparse
import httpx
from app.config import BROWSERLESS_URL
from app.config import CRAWLER_URL
from app.services.storage import storage
log = logging.getLogger(__name__)
class _HTMLTextExtractor(HTMLParser):
"""Simple HTML to text converter."""
def __init__(self):
super().__init__()
self._result = StringIO()
self._skip = False
self._skip_tags = {"script", "style", "noscript", "svg"}
# ── YouTube helpers ──
def handle_starttag(self, tag, attrs):
if tag in self._skip_tags:
self._skip = True
def handle_endtag(self, tag):
if tag in self._skip_tags:
self._skip = False
if tag in ("p", "div", "br", "h1", "h2", "h3", "h4", "li", "tr"):
self._result.write("\n")
def handle_data(self, data):
if not self._skip:
self._result.write(data)
def get_text(self) -> str:
raw = self._result.getvalue()
# Collapse whitespace
lines = [line.strip() for line in raw.splitlines()]
return "\n".join(line for line in lines if line)
def _extract_youtube_id(url: str) -> str | None:
patterns = [
r'(?:youtube\.com/watch\?.*v=|youtu\.be/|youtube\.com/shorts/|youtube\.com/embed/)([a-zA-Z0-9_-]{11})',
]
for pat in patterns:
m = re.search(pat, url)
if m:
return m.group(1)
return None
def html_to_text(html: str) -> str:
extractor = _HTMLTextExtractor()
extractor.feed(html)
return extractor.get_text()
def _is_youtube_url(url: str) -> bool:
return bool(_extract_youtube_id(url))
def extract_title_from_html(html: str) -> str | None:
match = re.search(r"<title[^>]*>(.*?)</title>", html, re.IGNORECASE | re.DOTALL)
return match.group(1).strip() if match else None
async def fetch_youtube_metadata(url: str) -> dict | None:
"""Fetch YouTube video metadata via oEmbed. No API key needed."""
video_id = _extract_youtube_id(url)
if not video_id:
return None
result = {
"title": None,
"description": None,
"author": None,
"thumbnail_url": f"https://img.youtube.com/vi/{video_id}/maxresdefault.jpg",
"video_id": video_id,
"is_short": "/shorts/" in url,
}
def extract_meta_description(html: str) -> str | None:
match = re.search(
r'<meta[^>]*name=["\']description["\'][^>]*content=["\'](.*?)["\']',
html, re.IGNORECASE | re.DOTALL,
)
return match.group(1).strip() if match else None
async def fetch_url_content(url: str) -> dict:
"""Fetch URL content. Returns dict with html, text, title, description, used_browserless."""
result = {"html": None, "text": None, "title": None, "description": None, "used_browserless": False}
# Try HTTP-first extraction
try:
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
resp = await client.get(url, headers={
"User-Agent": "Mozilla/5.0 (compatible; SecondBrain/1.0)"
})
resp.raise_for_status()
html = resp.text
result["html"] = html
result["text"] = html_to_text(html)
result["title"] = extract_title_from_html(html)
result["description"] = extract_meta_description(html)
# If extraction is weak (< 200 chars of text), try browserless
if len(result["text"] or "") < 200:
log.info(f"Weak extraction ({len(result['text'] or '')} chars), trying browserless")
br = await fetch_with_browserless(url)
if br and len(br.get("text", "")) > len(result["text"] or ""):
result.update(br)
result["used_browserless"] = True
async with httpx.AsyncClient(timeout=10) as client:
oembed_url = f"https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v={video_id}&format=json"
resp = await client.get(oembed_url)
if resp.status_code == 200:
data = resp.json()
result["title"] = data.get("title")
result["author"] = data.get("author_name")
noembed_url = f"https://noembed.com/embed?url=https://www.youtube.com/watch?v={video_id}"
resp2 = await client.get(noembed_url)
if resp2.status_code == 200:
data2 = resp2.json()
if not result["title"]:
result["title"] = data2.get("title")
if not result["author"]:
result["author"] = data2.get("author_name")
except Exception as e:
log.warning(f"HTTP fetch failed for {url}: {e}, trying browserless")
try:
br = await fetch_with_browserless(url)
if br:
result.update(br)
result["used_browserless"] = True
except Exception as e2:
log.error(f"Browserless also failed for {url}: {e2}")
log.warning(f"YouTube metadata fetch failed: {e}")
return result
async def fetch_with_browserless(url: str) -> dict | None:
"""Use browserless/chrome to render JS-heavy pages."""
async def download_youtube_thumbnail(url: str, item_id: str) -> str | None:
"""Download YouTube thumbnail and save as screenshot asset."""
video_id = _extract_youtube_id(url)
if not video_id:
return None
urls_to_try = [
f"https://img.youtube.com/vi/{video_id}/maxresdefault.jpg",
f"https://img.youtube.com/vi/{video_id}/hqdefault.jpg",
]
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(
f"{BROWSERLESS_URL}/content",
json={"url": url, "waitForTimeout": 3000},
)
if resp.status_code == 200:
html = resp.text
return {
"html": html,
"text": html_to_text(html),
"title": extract_title_from_html(html),
"description": extract_meta_description(html),
}
async with httpx.AsyncClient(timeout=10) as client:
for thumb_url in urls_to_try:
resp = await client.get(thumb_url)
if resp.status_code == 200 and len(resp.content) > 1000:
path = storage.save(
item_id=item_id, asset_type="screenshot",
filename="thumbnail.jpg", data=resp.content,
)
return path
except Exception as e:
log.error(f"Browserless fetch failed: {e}")
log.warning(f"YouTube thumbnail download failed: {e}")
return None
async def take_screenshot(url: str, item_id: str) -> str | None:
"""Take a screenshot of a URL using browserless. Returns storage path or None."""
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(
f"{BROWSERLESS_URL}/screenshot",
json={
"url": url,
"options": {"type": "png", "fullPage": False},
"waitForTimeout": 3000,
},
async def download_youtube_video(url: str, item_id: str) -> tuple[str | None, dict]:
"""Download YouTube video via yt-dlp."""
import asyncio
import subprocess
import tempfile
import os
video_id = _extract_youtube_id(url)
if not video_id:
return None, {}
with tempfile.TemporaryDirectory() as tmpdir:
outpath = os.path.join(tmpdir, "%(id)s.%(ext)s")
cmd = [
"yt-dlp", "--no-playlist",
"-f", "bestvideo[height<=720][ext=mp4]+bestaudio[ext=m4a]/best[height<=720][ext=mp4]/best[height<=720]",
"--merge-output-format", "mp4",
"--write-info-json", "--no-write-playlist-metafiles",
"-o", outpath, url,
]
try:
proc = await asyncio.to_thread(
subprocess.run, cmd, capture_output=True, text=True, timeout=120,
)
if proc.returncode != 0:
log.warning(f"yt-dlp failed: {proc.stderr[:300]}")
return None, {}
video_file = None
info = {}
for f in os.listdir(tmpdir):
if f.endswith(".mp4"):
video_file = os.path.join(tmpdir, f)
elif f.endswith(".info.json"):
import json as _json
with open(os.path.join(tmpdir, f)) as fh:
info = _json.load(fh)
if not video_file:
return None, {}
file_data = open(video_file, "rb").read()
path = storage.save(
item_id=item_id, asset_type="video",
filename=f"{video_id}.mp4", data=file_data,
)
log.info(f"Downloaded YouTube video: {len(file_data)} bytes -> {path}")
return path, info
except subprocess.TimeoutExpired:
log.warning(f"yt-dlp timed out for {url}")
return None, {}
except Exception as e:
log.error(f"YouTube download failed: {e}")
return None, {}
# ── Main crawler (Playwright stealth service) ──
async def crawl_url(url: str) -> dict:
"""Call the Playwright crawler service. Returns dict with html, text, title,
description, author, og_image_url, screenshot (base64), status_code, error."""
try:
async with httpx.AsyncClient(timeout=45) as client:
resp = await client.post(f"{CRAWLER_URL}/crawl", json={"url": url})
if resp.status_code == 200:
return resp.json()
log.warning(f"Crawler returned {resp.status_code} for {url}")
except Exception as e:
log.error(f"Crawler request failed for {url}: {e}")
return {"url": url, "html": None, "text": None, "title": None,
"description": None, "og_image_url": None, "screenshot": None, "error": str(e) if 'e' in dir() else "unknown"}
async def save_screenshot_from_base64(b64: str, item_id: str) -> str | None:
"""Decode base64 screenshot and save to storage."""
try:
data = base64.b64decode(b64)
if len(data) < 500:
return None
path = storage.save(
item_id=item_id, asset_type="screenshot",
filename="screenshot.jpg", data=data,
)
return path
except Exception as e:
log.error(f"Screenshot save failed: {e}")
return None
async def download_og_image(og_url: str, item_id: str) -> str | None:
"""Download an og:image and save as asset."""
# Clean HTML entities from URL
og_url = og_url.replace("&amp;", "&")
try:
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
resp = await client.get(og_url, headers={
"User-Agent": "Mozilla/5.0 (compatible; SecondBrain/1.0)"
})
if resp.status_code == 200 and len(resp.content) > 1000:
ct = resp.headers.get("content-type", "image/jpeg")
ext = "png" if "png" in ct else "jpg"
path = storage.save(
item_id=item_id,
asset_type="screenshot",
filename="screenshot.png",
data=resp.content,
item_id=item_id, asset_type="og_image",
filename=f"og_image.{ext}", data=resp.content,
)
log.info(f"Downloaded og:image ({len(resp.content)} bytes) for {item_id}")
return path
except Exception as e:
log.error(f"Screenshot failed for {url}: {e}")
log.warning(f"og:image download failed: {e}")
return None
async def archive_html(html: str, item_id: str) -> str | None:
"""Save the full HTML as an archived asset."""
"""Save full HTML as an archived asset."""
if not html:
return None
try:
path = storage.save(
item_id=item_id,
asset_type="archived_html",
filename="page.html",
data=html.encode("utf-8"),
item_id=item_id, asset_type="archived_html",
filename="page.html", data=html.encode("utf-8"),
)
return path
except Exception as e:

View File

@@ -12,6 +12,7 @@ from sqlalchemy.orm import selectinload
from app.config import REDIS_URL, DATABASE_URL_SYNC
from app.models.item import Item, ItemAsset
from app.models.taxonomy import Folder, Tag, ItemTag # noqa: F401 — register FK targets
log = logging.getLogger(__name__)
@@ -34,7 +35,7 @@ async def _process_item(item_id: str):
"""Full processing pipeline for a saved item."""
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from app.config import DATABASE_URL
from app.services.ingest import fetch_url_content, take_screenshot, archive_html
from app.services.ingest import crawl_url, save_screenshot_from_base64, download_og_image, archive_html
from app.services.classify import classify_item
from app.services.embed import generate_embedding
from app.search.engine import index_item, ensure_meili_index
@@ -62,42 +63,96 @@ async def _process_item(item_id: str):
# ── Step 1: Fetch content for URLs ──
if item.type == "link" and item.url:
log.info(f"Fetching URL: {item.url}")
content = await fetch_url_content(item.url)
html_content = content.get("html")
extracted_text = content.get("text") or extracted_text
if not title:
title = content.get("title")
from app.services.ingest import (
_is_youtube_url, download_youtube_thumbnail,
download_youtube_video, fetch_youtube_metadata,
)
item.metadata_json = item.metadata_json or {}
item.metadata_json["description"] = content.get("description")
item.metadata_json["used_browserless"] = content.get("used_browserless", False)
is_yt = _is_youtube_url(item.url)
# Take screenshot
screenshot_path = await take_screenshot(item.url, item.id)
if screenshot_path:
asset = ItemAsset(
id=str(uuid.uuid4()),
item_id=item.id,
asset_type="screenshot",
filename="screenshot.png",
content_type="image/png",
storage_path=screenshot_path,
)
db.add(asset)
if is_yt:
# YouTube: use oEmbed + thumbnail + yt-dlp (no crawler needed)
log.info(f"Processing YouTube URL: {item.url}")
yt_meta = await fetch_youtube_metadata(item.url)
if yt_meta:
if not title:
title = yt_meta.get("title")
extracted_text = f"YouTube: {yt_meta.get('title','')}\nBy: {yt_meta.get('author','')}"
item.metadata_json["youtube"] = {
"video_id": yt_meta.get("video_id"),
"author": yt_meta.get("author"),
"is_short": yt_meta.get("is_short", False),
}
item.metadata_json["description"] = f"YouTube video by {yt_meta.get('author','')}"
# Archive HTML
if html_content:
html_path = await archive_html(html_content, item.id)
if html_path:
asset = ItemAsset(
id=str(uuid.uuid4()),
item_id=item.id,
asset_type="archived_html",
filename="page.html",
content_type="text/html",
storage_path=html_path,
)
db.add(asset)
# Download video
log.info(f"Downloading YouTube video: {item.url}")
video_path, yt_info = await download_youtube_video(item.url, item.id)
if video_path:
db.add(ItemAsset(
id=str(uuid.uuid4()), item_id=item.id,
asset_type="video", filename=f"{yt_meta['video_id']}.mp4",
content_type="video/mp4", storage_path=video_path,
))
if yt_info.get("duration"):
item.metadata_json["youtube"]["duration"] = yt_info["duration"]
if yt_info.get("description"):
item.metadata_json["youtube"]["description"] = yt_info["description"][:500]
extracted_text = f"YouTube: {title or ''}\nBy: {(yt_meta or {}).get('author','')}\n{yt_info['description'][:2000]}"
# Thumbnail
thumb_path = await download_youtube_thumbnail(item.url, item.id)
if thumb_path:
db.add(ItemAsset(
id=str(uuid.uuid4()), item_id=item.id,
asset_type="screenshot", filename="thumbnail.jpg",
content_type="image/jpeg", storage_path=thumb_path,
))
else:
# Regular URL: use Playwright crawler (stealth)
log.info(f"Crawling URL: {item.url}")
crawl = await crawl_url(item.url)
html_content = crawl.get("html")
extracted_text = crawl.get("text") or extracted_text
if not title:
title = crawl.get("title")
item.metadata_json["description"] = crawl.get("description")
item.metadata_json["author"] = crawl.get("author")
item.metadata_json["status_code"] = crawl.get("status_code")
# Screenshot (from crawler, base64 JPEG)
if crawl.get("screenshot"):
ss_path = await save_screenshot_from_base64(crawl["screenshot"], item.id)
if ss_path:
db.add(ItemAsset(
id=str(uuid.uuid4()), item_id=item.id,
asset_type="screenshot", filename="screenshot.jpg",
content_type="image/jpeg", storage_path=ss_path,
))
# og:image (extracted from rendered DOM by crawler)
og_url = crawl.get("og_image_url")
if og_url:
og_path = await download_og_image(og_url, item.id)
if og_path:
db.add(ItemAsset(
id=str(uuid.uuid4()), item_id=item.id,
asset_type="og_image", filename="og_image.jpg",
content_type="image/jpeg", storage_path=og_path,
))
item.metadata_json["og_image_url"] = og_url
# Archive HTML
if html_content:
html_path = await archive_html(html_content, item.id)
if html_path:
db.add(ItemAsset(
id=str(uuid.uuid4()), item_id=item.id,
asset_type="archived_html", filename="page.html",
content_type="text/html", storage_path=html_path,
))
# ── Step 1b: Process uploaded files (PDF, image, document) ──
if item.type in ("pdf", "image", "document", "file"):

View File

@@ -0,0 +1,20 @@
FROM node:20-slim
# Install Playwright system dependencies
RUN npx playwright@1.50.0 install-deps chromium
WORKDIR /app
COPY package.json ./
RUN npm install
# Install Playwright browser
RUN npx playwright install chromium
COPY server.js ./
ENV NODE_ENV=production
EXPOSE 3100
HEALTHCHECK --interval=15s --timeout=5s --retries=3 CMD wget -qO- http://localhost:3100/health || exit 1
CMD ["node", "server.js"]

View File

@@ -0,0 +1,24 @@
{
"name": "brain-crawler",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"playwright-extra": "^4.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"playwright-core": "^1.50.0",
"metascraper": "^5.45.25",
"metascraper-image": "^5.45.25",
"metascraper-title": "^5.45.25",
"metascraper-description": "^5.45.25",
"metascraper-author": "^5.45.25",
"metascraper-date": "^5.45.25",
"metascraper-publisher": "^5.45.25",
"metascraper-url": "^5.45.25",
"@mozilla/readability": "^0.5.0",
"jsdom": "^25.0.0"
}
}

View File

@@ -0,0 +1,370 @@
import http from "node:http";
import { chromium } from "playwright-extra";
import StealthPlugin from "puppeteer-extra-plugin-stealth";
import { Readability } from "@mozilla/readability";
import { JSDOM } from "jsdom";
chromium.use(StealthPlugin());
const PORT = parseInt(process.env.PORT || "3100");
const VIEWPORT = { width: 1440, height: 900 };
const USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
const NAV_TIMEOUT = 30_000;
const SCREENSHOT_TIMEOUT = 8_000;
let browser = null;
async function ensureBrowser() {
if (browser && browser.isConnected()) return browser;
if (browser) {
try { await browser.close(); } catch {}
browser = null;
}
console.log("[crawler] Launching browser...");
browser = await chromium.launch({
headless: true,
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
],
});
console.log("[crawler] Browser ready");
return browser;
}
// Extract og:image and other meta from rendered HTML
function extractMeta(html) {
const meta = {};
const patterns = {
og_image: [
/(?:property|name)=["']og:image["'][^>]*content=["']([^"']+)["']/i,
/content=["']([^"']+)["'][^>]*(?:property|name)=["']og:image["']/i,
],
title: [
/(?:property|name)=["']og:title["'][^>]*content=["']([^"']+)["']/i,
/content=["']([^"']+)["'][^>]*(?:property|name)=["']og:title["']/i,
/<title[^>]*>([^<]+)<\/title>/i,
],
description: [
/(?:property|name)=["']og:description["'][^>]*content=["']([^"']+)["']/i,
/name=["']description["'][^>]*content=["']([^"']+)["']/i,
/content=["']([^"']+)["'][^>]*(?:property|name)=["']og:description["']/i,
],
author: [
/name=["']author["'][^>]*content=["']([^"']+)["']/i,
/property=["']article:author["'][^>]*content=["']([^"']+)["']/i,
],
favicon: [
/rel=["']icon["'][^>]*href=["']([^"']+)["']/i,
/rel=["']shortcut icon["'][^>]*href=["']([^"']+)["']/i,
],
};
for (const [key, pats] of Object.entries(patterns)) {
for (const pat of pats) {
const m = html.match(pat);
if (m) {
meta[key] = m[1].trim();
break;
}
}
}
return meta;
}
function isRedditUrl(url) {
try {
const h = new URL(url).hostname;
return h === "www.reddit.com" || h === "reddit.com";
} catch {}
return false;
}
async function resolveRedditShortUrl(url) {
// Reddit short URLs (/r/sub/s/xxx) redirect to the actual post
if (/\/s\/[a-zA-Z0-9]+/.test(url)) {
try {
const resp = await fetch(url, {
method: "HEAD",
redirect: "follow",
headers: { "User-Agent": "SecondBrain/1.0" },
});
const resolved = resp.url;
if (resolved && resolved.includes("/comments/")) {
console.log(`[crawler] Reddit short URL resolved: ${url} -> ${resolved}`);
return resolved;
}
} catch (e) {
console.warn("[crawler] Reddit short URL resolve failed:", e.message);
}
}
return url;
}
async function fetchRedditJson(url) {
// Resolve short URLs first
url = await resolveRedditShortUrl(url);
// Reddit JSON API — append .json to get structured data
try {
const jsonUrl = url.replace(/\/?(\?.*)?$/, "/.json$1");
const resp = await fetch(jsonUrl, {
headers: { "User-Agent": "SecondBrain/1.0" },
redirect: "follow",
});
if (!resp.ok) return null;
const data = await resp.json();
const post = data?.[0]?.data?.children?.[0]?.data;
if (!post) return null;
const previewImg = (post.preview?.images?.[0]?.source?.url || "").replace(/&amp;/g, "&") || null;
const thumbnail = post.thumbnail?.startsWith("http") ? post.thumbnail : null;
// If no preview image, try to get subreddit icon
let ogImage = previewImg || thumbnail || null;
if (!ogImage && post.subreddit) {
try {
const aboutResp = await fetch(
`https://www.reddit.com/r/${post.subreddit}/about.json`,
{ headers: { "User-Agent": "SecondBrain/1.0" } }
);
if (aboutResp.ok) {
const about = await aboutResp.json();
const icon = about?.data?.community_icon?.replace(/&amp;/g, "&")?.split("?")?.[0]
|| about?.data?.icon_img
|| about?.data?.header_img;
if (icon && icon.startsWith("http")) {
ogImage = icon;
}
}
} catch {}
}
return {
url,
html: null,
text: `${post.title || ""}\n\n${post.selftext || ""}`.trim(),
title: post.title || null,
description: (post.selftext || "").slice(0, 200) || null,
author: post.author ? `u/${post.author}` : null,
og_image_url: ogImage ? ogImage.replace(/&amp;/g, "&") : null,
favicon: null,
screenshot: null,
status_code: 200,
error: null,
subreddit: post.subreddit_name_prefixed || null,
};
} catch (e) {
console.warn("[crawler] Reddit JSON failed:", e.message);
return null;
}
}
async function crawl(url) {
// Reddit: use JSON API (avoids login walls entirely)
if (isRedditUrl(url)) {
const redditData = await fetchRedditJson(url);
if (redditData) {
console.log(`[crawler] Reddit JSON OK: ${url} (og=${!!redditData.og_image_url})`);
return redditData;
}
console.log(`[crawler] Reddit JSON failed, falling back to browser: ${url}`);
}
const crawlUrl = url;
let b;
try {
b = await ensureBrowser();
} catch (e) {
console.error("[crawler] Browser launch failed, retrying:", e.message);
browser = null;
b = await ensureBrowser();
}
const contextOpts = {
viewport: VIEWPORT,
userAgent: USER_AGENT,
ignoreHTTPSErrors: true,
};
// Reddit: set cookies to bypass login walls
if (isRedditUrl(url)) {
contextOpts.extraHTTPHeaders = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
};
}
const context = await b.newContext(contextOpts);
const page = await context.newPage();
const result = {
url,
html: null,
text: null,
readable_html: null,
title: null,
description: null,
author: null,
og_image_url: null,
favicon: null,
screenshot: null, // base64
status_code: null,
error: null,
};
try {
// Navigate (use normalized URL to avoid login walls)
const response = await page.goto(crawlUrl, {
waitUntil: "domcontentloaded",
timeout: NAV_TIMEOUT,
});
result.status_code = response?.status() || null;
// Wait for network to settle (up to 5s)
try {
await page.waitForLoadState("networkidle", { timeout: 5000 });
} catch {
// networkidle timeout is fine, page is probably loaded enough
}
// Reddit: dismiss login modals and overlays
if (isRedditUrl(url)) {
await page.evaluate(() => {
// Remove login modal/overlay
document.querySelectorAll('shreddit-overlay-display, [id*="login"], .overlay-container, reddit-cookie-banner').forEach(el => el.remove());
// Remove any body scroll locks
document.body.style.overflow = 'auto';
document.documentElement.style.overflow = 'auto';
}).catch(() => {});
await page.waitForTimeout(1000);
}
// Get rendered HTML + screenshot in parallel
const [html, screenshot] = await Promise.all([
page.content(),
page
.screenshot({ type: "jpeg", quality: 80, fullPage: false })
.catch((e) => {
console.warn("[crawler] Screenshot failed:", e.message);
return null;
}),
]);
result.html = html;
// Extract text from page
result.text = await page
.evaluate(() => {
const el =
document.querySelector("article") ||
document.querySelector("main") ||
document.querySelector('[role="main"]') ||
document.body;
return el ? el.innerText.slice(0, 10000) : "";
})
.catch(() => "");
// Extract readable article HTML via Mozilla Readability
try {
const dom = new JSDOM(html, { url: crawlUrl });
const reader = new Readability(dom.window.document);
const article = reader.parse();
if (article && article.content) {
result.readable_html = article.content;
if (article.textContent) {
result.text = article.textContent.slice(0, 10000);
}
}
} catch (e) {
console.warn("[crawler] Readability failed:", e.message);
}
// Extract meta from rendered DOM
const meta = extractMeta(html);
result.title = meta.title || (await page.title()) || null;
result.description = meta.description || null;
result.author = meta.author || null;
result.og_image_url = meta.og_image || null;
result.favicon = meta.favicon || null;
// Screenshot as base64
if (screenshot) {
result.screenshot = screenshot.toString("base64");
}
} catch (e) {
result.error = e.message;
console.error("[crawler] Crawl error:", url, e.message);
// If browser crashed, reset it for next request
if (e.message.includes("closed") || e.message.includes("crashed")) {
browser = null;
}
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
return result;
}
// Simple HTTP server
const server = http.createServer(async (req, res) => {
// Health check
if (req.method === "GET" && req.url === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok" }));
return;
}
// Crawl endpoint
if (req.method === "POST" && req.url === "/crawl") {
let body = "";
req.on("data", (chunk) => (body += chunk));
req.on("end", async () => {
try {
const { url } = JSON.parse(body);
if (!url) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "url is required" }));
return;
}
console.log(`[crawler] Crawling: ${url}`);
const result = await crawl(url);
console.log(
`[crawler] Done: ${url} (status=${result.status_code}, og=${!!result.og_image_url}, ss=${!!result.screenshot})`
);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(result));
} catch (e) {
console.error("[crawler] Request error:", e);
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: e.message }));
}
});
return;
}
res.writeHead(404);
res.end("Not found");
});
// Startup
(async () => {
await ensureBrowser();
server.listen(PORT, () => {
console.log(`[crawler] Listening on :${PORT}`);
});
})();
// Graceful shutdown
process.on("SIGTERM", async () => {
console.log("[crawler] Shutting down...");
if (browser) await browser.close().catch(() => {});
process.exit(0);
});

View File

@@ -13,7 +13,7 @@ services:
- REDIS_URL=redis://brain-redis:6379/0
- MEILI_URL=http://brain-meili:7700
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY:-brain-meili-secure-key-2026}
- BROWSERLESS_URL=http://brain-browserless:3000
- CRAWLER_URL=http://brain-crawler:3100
- OPENAI_API_KEY=${OPENAI_API_KEY}
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o-mini}
- PORT=8200
@@ -44,7 +44,7 @@ services:
- REDIS_URL=redis://brain-redis:6379/0
- MEILI_URL=http://brain-meili:7700
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY:-brain-meili-secure-key-2026}
- BROWSERLESS_URL=http://brain-browserless:3000
- CRAWLER_URL=http://brain-crawler:3100
- OPENAI_API_KEY=${OPENAI_API_KEY}
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o-mini}
- TZ=${TZ:-America/Chicago}
@@ -90,14 +90,17 @@ services:
volumes:
- ./data/meili:/meili_data
# ── Browserless (headless Chrome for JS rendering + screenshots) ──
brain-browserless:
image: ghcr.io/browserless/chromium:latest
container_name: brain-browserless
# ── Crawler (Playwright + stealth for JS rendering + screenshots) ──
brain-crawler:
build:
context: ./crawler
dockerfile: Dockerfile
container_name: brain-crawler
restart: unless-stopped
environment:
- MAX_CONCURRENT_SESSIONS=3
- TIMEOUT=30000
- PORT=3100
- TZ=${TZ:-America/Chicago}
shm_size: '1gb'
networks:
pangolin:

View File

@@ -0,0 +1,254 @@
"""Migrate all bookmarks from Karakeep into Brain service via API."""
import json
import os
import sys
import time
import urllib.request
import urllib.error
import tempfile
KARAKEEP_URL = os.environ.get("KARAKEEP_URL", "http://192.168.1.42:3005")
KARAKEEP_API_KEY = os.environ.get("KARAKEEP_API_KEY", "ak2_f4141e5fe7265e23bd6f_4549c932c262010eafd08acb2139f1ac")
BRAIN_URL = "http://localhost:8200"
BRAIN_USER = "admin"
def karakeep_get(path):
req = urllib.request.Request(
f"{KARAKEEP_URL}{path}",
headers={"Authorization": f"Bearer {KARAKEEP_API_KEY}"},
)
return json.loads(urllib.request.urlopen(req, timeout=30).read())
def karakeep_download(asset_id):
req = urllib.request.Request(
f"{KARAKEEP_URL}/api/v1/assets/{asset_id}",
headers={"Authorization": f"Bearer {KARAKEEP_API_KEY}"},
)
resp = urllib.request.urlopen(req, timeout=120)
return resp.read(), resp.headers.get("Content-Type", "application/octet-stream")
def brain_post_json(path, data):
body = json.dumps(data).encode()
req = urllib.request.Request(
f"{BRAIN_URL}/api{path}",
data=body,
headers={"X-Gateway-User-Id": BRAIN_USER, "Content-Type": "application/json"},
method="POST",
)
resp = urllib.request.urlopen(req, timeout=30)
return json.loads(resp.read())
def brain_upload(file_data, filename, content_type, title=None):
"""Multipart upload to /api/items/upload."""
boundary = "----MigrationBoundary12345"
parts = []
# File part
parts.append(f"--{boundary}\r\n".encode())
parts.append(f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode())
parts.append(f"Content-Type: {content_type}\r\n\r\n".encode())
parts.append(file_data)
parts.append(b"\r\n")
# Title part
if title:
parts.append(f"--{boundary}\r\n".encode())
parts.append(b'Content-Disposition: form-data; name="title"\r\n\r\n')
parts.append(title.encode())
parts.append(b"\r\n")
parts.append(f"--{boundary}--\r\n".encode())
body = b"".join(parts)
req = urllib.request.Request(
f"{BRAIN_URL}/api/items/upload",
data=body,
headers={
"X-Gateway-User-Id": BRAIN_USER,
"Content-Type": f"multipart/form-data; boundary={boundary}",
},
method="POST",
)
resp = urllib.request.urlopen(req, timeout=60)
return json.loads(resp.read())
def brain_get_item(item_id):
req = urllib.request.Request(
f"{BRAIN_URL}/api/items/{item_id}",
headers={"X-Gateway-User-Id": BRAIN_USER},
)
resp = urllib.request.urlopen(req, timeout=15)
return json.loads(resp.read())
def fetch_all_bookmarks():
all_bk = []
cursor = None
while True:
url = "/api/v1/bookmarks?limit=100"
if cursor:
url += f"&cursor={cursor}"
data = karakeep_get(url)
bks = data.get("bookmarks", [])
all_bk.extend(bks)
cursor = data.get("nextCursor")
if not cursor or not bks:
break
return all_bk
def wait_for_processing(item_id, timeout=120):
"""Poll until item is done processing."""
start = time.time()
while time.time() - start < timeout:
item = brain_get_item(item_id)
status = item.get("processing_status", "pending")
if status in ("ready", "error"):
return item
time.sleep(3)
return brain_get_item(item_id)
def main():
print("Fetching all Karakeep bookmarks...")
bookmarks = fetch_all_bookmarks()
print(f"Found {len(bookmarks)} bookmarks\n")
# Sort: notes first, then links, then assets (PDFs take longer)
def sort_key(b):
t = b.get("content", {}).get("type", "")
return {"text": 0, "link": 1, "asset": 2}.get(t, 3)
bookmarks.sort(key=sort_key)
results = {"success": 0, "error": 0, "skipped": 0}
comparison = []
for i, bk in enumerate(bookmarks):
content = bk.get("content", {})
bk_type = content.get("type", "unknown")
bk_title = bk.get("title") or "Untitled"
bk_tags = [t["name"] for t in bk.get("tags", [])]
bk_list = bk.get("list", {})
bk_folder = bk_list.get("name") if bk_list else None
print(f"[{i+1}/{len(bookmarks)}] {bk_type}: {bk_title[:60]}")
try:
if bk_type == "link":
url = content.get("url", "")
if not url:
print(" SKIP: no URL")
results["skipped"] += 1
continue
resp = brain_post_json("/items", {
"type": "link",
"url": url,
"title": bk_title if bk_title != "Untitled" else None,
})
elif bk_type == "text":
text = content.get("text", "")
if not text:
print(" SKIP: no text")
results["skipped"] += 1
continue
resp = brain_post_json("/items", {
"type": "note",
"raw_content": text,
"title": bk_title if bk_title != "Untitled" else None,
})
elif bk_type == "asset":
asset_id = content.get("assetId")
asset_type = content.get("assetType", "unknown")
if not asset_id:
print(" SKIP: no assetId")
results["skipped"] += 1
continue
print(f" Downloading {asset_type} ({asset_id[:8]})...")
file_data, ct = karakeep_download(asset_id)
ext = {"pdf": ".pdf", "image": ".png"}.get(asset_type, ".bin")
filename = f"{bk_title[:50]}{ext}" if bk_title != "Untitled" else f"upload{ext}"
# Clean filename
filename = filename.replace("/", "-").replace("\\", "-")
if asset_type == "pdf":
ct = "application/pdf"
resp = brain_upload(file_data, filename, ct, title=bk_title if bk_title != "Untitled" else None)
else:
print(f" SKIP: unknown type '{bk_type}'")
results["skipped"] += 1
continue
item_id = resp.get("id")
print(f" Created: {item_id} — waiting for AI classification...")
# Wait for processing
final = wait_for_processing(item_id, timeout=90)
status = final.get("processing_status", "?")
ai_folder = final.get("folder", "?")
ai_tags = final.get("tags", [])
ai_title = final.get("title", "?")
# Compare
entry = {
"karakeep_title": bk_title,
"karakeep_tags": bk_tags,
"karakeep_folder": bk_folder,
"ai_title": ai_title,
"ai_folder": ai_folder,
"ai_tags": ai_tags,
"status": status,
}
comparison.append(entry)
tag_match = "OK" if set(bk_tags) & set(ai_tags) or (not bk_tags and not ai_tags) else "DIFF"
print(f" Status: {status}")
print(f" AI Folder: {ai_folder} (Karakeep: {bk_folder or 'none'})")
print(f" AI Tags: {ai_tags} vs Karakeep: {bk_tags} [{tag_match}]")
print(f" AI Title: {ai_title}")
results["success"] += 1
except Exception as e:
print(f" ERROR: {e}")
results["error"] += 1
print()
# Summary
print("=" * 60)
print(f"MIGRATION COMPLETE")
print(f" Success: {results['success']}")
print(f" Errors: {results['error']}")
print(f" Skipped: {results['skipped']}")
print()
# Tag comparison summary
matches = 0
diffs = 0
for c in comparison:
kk = set(c["karakeep_tags"])
ai = set(c["ai_tags"])
if kk & ai or (not kk and not ai):
matches += 1
else:
diffs += 1
print(f"Tag overlap: {matches}/{len(comparison)} items had at least one matching tag")
print(f"Tag differences: {diffs}/{len(comparison)} items had zero overlap")
# Save comparison
with open("/tmp/migration_comparison.json", "w") as f:
json.dump(comparison, f, indent=2)
print("\nFull comparison saved to /tmp/migration_comparison.json")
if __name__ == "__main__":
main()

View File

@@ -2035,6 +2035,24 @@ class CalorieHandler(BaseHTTPRequestHandler):
if user:
return user
# Internal gateway auth (trusted Docker network only)
gateway_user_id = self.headers.get('X-Gateway-User-Id', '')
gateway_user_name = self.headers.get('X-Gateway-User-Name', '')
if gateway_user_id:
conn = get_db()
row = None
# Try username match
if gateway_user_name:
row = conn.execute("SELECT * FROM users WHERE username = ? COLLATE NOCASE", (gateway_user_name.lower(),)).fetchone()
# Try display name match
if not row:
row = conn.execute("SELECT * FROM users WHERE display_name = ? COLLATE NOCASE", (gateway_user_name,)).fetchone()
if not row:
row = conn.execute("SELECT * FROM users WHERE id = ?", (gateway_user_id,)).fetchone()
conn.close()
if row:
return dict(row)
return None
def _send_json(self, data, status=200):

View File

@@ -0,0 +1,22 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends libpq-dev && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir --upgrade pip
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN adduser --disabled-password --no-create-home appuser
COPY --chown=appuser app/ app/
EXPOSE 8300
ENV PYTHONUNBUFFERED=1
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8300/api/health', timeout=3)" || exit 1
USER appuser
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8300"]

View File

@@ -0,0 +1,18 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends libpq-dev && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir --upgrade pip
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN adduser --disabled-password --no-create-home appuser
COPY --chown=appuser app/ app/
ENV PYTHONUNBUFFERED=1
USER appuser
CMD ["python", "-m", "app.worker.tasks"]

View File

View File

View File

@@ -0,0 +1,49 @@
"""Category endpoints."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_user_id, get_db_session
from app.models import Category
router = APIRouter(prefix="/api/categories", tags=["categories"])
class CategoryOut(BaseModel):
id: int
title: str
class Config:
from_attributes = True
class CategoryCreate(BaseModel):
title: str
@router.get("", response_model=list[CategoryOut])
async def list_categories(
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
result = await db.execute(
select(Category)
.where(Category.user_id == user_id)
.order_by(Category.title)
)
return result.scalars().all()
@router.post("", response_model=CategoryOut, status_code=201)
async def create_category(
body: CategoryCreate,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
cat = Category(user_id=user_id, title=body.title)
db.add(cat)
await db.commit()
await db.refresh(cat)
return cat

View File

@@ -0,0 +1,21 @@
"""API dependencies — auth, database session."""
from fastapi import Header, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
async def get_user_id(
x_gateway_user_id: str = Header(None, alias="X-Gateway-User-Id"),
) -> str:
"""Extract authenticated user ID from gateway-injected header."""
if not x_gateway_user_id:
raise HTTPException(status_code=401, detail="Not authenticated")
return x_gateway_user_id
async def get_db_session() -> AsyncSession:
"""Provide an async database session."""
async for session in get_db():
yield session

View File

@@ -0,0 +1,264 @@
"""Entry endpoints."""
import logging
from typing import Optional
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.api.deps import get_db_session, get_user_id
from app.config import CRAWLER_URL
from app.models import Entry, Feed
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/entries", tags=["entries"])
# ── Schemas ──────────────────────────────────────────────────────────────
class FeedRef(BaseModel):
id: int
title: str
class Config:
from_attributes = True
class EntryOut(BaseModel):
id: int
title: str | None = None
url: str | None = None
content: str | None = None
full_content: str | None = None
author: str | None = None
published_at: str | None = None
status: str = "unread"
starred: bool = False
reading_time: int = 1
feed: FeedRef | None = None
class Config:
from_attributes = True
@classmethod
def from_entry(cls, entry: Entry) -> "EntryOut":
# Use full_content if available, otherwise RSS content
best_content = entry.full_content if entry.full_content else entry.content
return cls(
id=entry.id,
title=entry.title,
url=entry.url,
content=best_content,
full_content=entry.full_content,
author=entry.author,
published_at=entry.published_at.isoformat() if entry.published_at else None,
status=entry.status,
starred=entry.starred,
reading_time=entry.reading_time,
feed=FeedRef(id=entry.feed.id, title=entry.feed.title) if entry.feed else None,
)
class EntryListOut(BaseModel):
total: int
entries: list[EntryOut]
class EntryBulkUpdate(BaseModel):
entry_ids: list[int]
status: str
# ── Routes ───────────────────────────────────────────────────────────────
@router.get("", response_model=EntryListOut)
async def list_entries(
status: Optional[str] = Query(None),
starred: Optional[bool] = Query(None),
feed_id: Optional[int] = Query(None),
category_id: Optional[int] = Query(None),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
direction: str = Query("desc"),
order: str = Query("published_at"),
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
query = select(Entry).where(Entry.user_id == user_id)
count_query = select(func.count(Entry.id)).where(Entry.user_id == user_id)
if status:
query = query.where(Entry.status == status)
count_query = count_query.where(Entry.status == status)
if starred is not None:
query = query.where(Entry.starred == starred)
count_query = count_query.where(Entry.starred == starred)
if feed_id is not None:
query = query.where(Entry.feed_id == feed_id)
count_query = count_query.where(Entry.feed_id == feed_id)
if category_id is not None:
# Join through feed to filter by category
query = query.join(Feed, Entry.feed_id == Feed.id).where(Feed.category_id == category_id)
count_query = count_query.join(Feed, Entry.feed_id == Feed.id).where(Feed.category_id == category_id)
# Ordering
order_col = Entry.published_at if order == "published_at" else Entry.created_at
if direction == "asc":
query = query.order_by(order_col.asc().nullslast())
else:
query = query.order_by(order_col.desc().nullsfirst())
# Total count
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Paginate
query = query.options(selectinload(Entry.feed)).offset(offset).limit(limit)
result = await db.execute(query)
entries = result.scalars().all()
return EntryListOut(
total=total,
entries=[EntryOut.from_entry(e) for e in entries],
)
@router.put("")
async def bulk_update_entries(
body: EntryBulkUpdate,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
if body.status not in ("read", "unread"):
raise HTTPException(status_code=400, detail="Status must be 'read' or 'unread'")
await db.execute(
update(Entry)
.where(Entry.user_id == user_id, Entry.id.in_(body.entry_ids))
.values(status=body.status)
)
await db.commit()
return {"ok": True}
class MarkAllReadBody(BaseModel):
feed_id: int | None = None
category_id: int | None = None
@router.put("/mark-all-read")
async def mark_all_read(
body: MarkAllReadBody,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
"""Mark ALL unread entries as read, optionally filtered by feed or category."""
q = update(Entry).where(Entry.user_id == user_id, Entry.status == "unread")
if body.feed_id:
q = q.where(Entry.feed_id == body.feed_id)
elif body.category_id:
from app.models import Feed
feed_ids_q = select(Feed.id).where(Feed.category_id == body.category_id, Feed.user_id == user_id)
q = q.where(Entry.feed_id.in_(feed_ids_q))
result = await db.execute(q.values(status="read"))
await db.commit()
return {"ok": True, "marked": result.rowcount}
@router.get("/{entry_id}", response_model=EntryOut)
async def get_entry(
entry_id: int,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
result = await db.execute(
select(Entry)
.options(selectinload(Entry.feed))
.where(Entry.id == entry_id, Entry.user_id == user_id)
)
entry = result.scalar_one_or_none()
if not entry:
raise HTTPException(status_code=404, detail="Entry not found")
return EntryOut.from_entry(entry)
@router.put("/{entry_id}/bookmark")
async def toggle_bookmark(
entry_id: int,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
result = await db.execute(
select(Entry).where(Entry.id == entry_id, Entry.user_id == user_id)
)
entry = result.scalar_one_or_none()
if not entry:
raise HTTPException(status_code=404, detail="Entry not found")
entry.starred = not entry.starred
await db.commit()
return {"starred": entry.starred}
@router.post("/{entry_id}/fetch-full-content", response_model=EntryOut)
async def fetch_full_content(
entry_id: int,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
result = await db.execute(
select(Entry)
.options(selectinload(Entry.feed))
.where(Entry.id == entry_id, Entry.user_id == user_id)
)
entry = result.scalar_one_or_none()
if not entry:
raise HTTPException(status_code=404, detail="Entry not found")
if not entry.url:
raise HTTPException(status_code=400, detail="Entry has no URL to crawl")
try:
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(
f"{CRAWLER_URL}/crawl",
json={"url": entry.url},
)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPError as e:
log.error("Crawler error for entry %d: %s", entry_id, e)
raise HTTPException(status_code=502, detail="Failed to fetch full content")
# Prefer readable_html (Readability-extracted clean article with images)
readable = data.get("readable_html", "")
full_text = data.get("text", "")
if readable:
entry.full_content = readable
elif full_text:
paragraphs = [p.strip() for p in full_text.split("\n\n") if p.strip()]
if not paragraphs:
paragraphs = [p.strip() for p in full_text.split("\n") if p.strip()]
entry.full_content = "\n".join(f"<p>{p}</p>" for p in paragraphs)
else:
entry.full_content = ""
# Recalculate reading time from plain text
if full_text:
word_count = len(full_text.split())
entry.reading_time = max(1, word_count // 200)
await db.commit()
await db.refresh(entry)
return EntryOut.from_entry(entry)

View File

@@ -0,0 +1,242 @@
"""Feed endpoints."""
import logging
import re
import feedparser
import httpx
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db_session, get_user_id
from app.models import Category, Entry, Feed
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/feeds", tags=["feeds"])
# ── Schemas ──────────────────────────────────────────────────────────────
class CategoryRef(BaseModel):
id: int
title: str
class Config:
from_attributes = True
class FeedOut(BaseModel):
id: int
title: str
feed_url: str
site_url: str | None = None
category: CategoryRef | None = None
class Config:
from_attributes = True
class FeedCreate(BaseModel):
feed_url: str
category_id: int | None = None
class CountersOut(BaseModel):
unreads: dict[str, int]
# ── Helpers ──────────────────────────────────────────────────────────────
def _discover_feed_url(html: str, base_url: str) -> str | None:
"""Try to find an RSS/Atom feed link in HTML."""
patterns = [
r'<link[^>]+type=["\']application/(?:rss|atom)\+xml["\'][^>]+href=["\']([^"\']+)["\']',
r'<link[^>]+href=["\']([^"\']+)["\'][^>]+type=["\']application/(?:rss|atom)\+xml["\']',
]
for pat in patterns:
match = re.search(pat, html, re.IGNORECASE)
if match:
href = match.group(1)
if href.startswith("/"):
# Resolve relative URL
from urllib.parse import urljoin
href = urljoin(base_url, href)
return href
return None
async def _fetch_and_parse_feed(feed_url: str) -> tuple[str, str, str | None]:
"""
Fetch a URL. If it's a valid feed, return (feed_url, title, site_url).
If it's HTML, try to discover the feed link and follow it.
"""
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
resp = await client.get(feed_url, headers={"User-Agent": "Reader/1.0"})
resp.raise_for_status()
body = resp.text
parsed = feedparser.parse(body)
# Check if it's a valid feed
if parsed.feed.get("title") or parsed.entries:
title = parsed.feed.get("title", feed_url)
site_url = parsed.feed.get("link")
return feed_url, title, site_url
# Not a feed — try to discover from HTML
discovered = _discover_feed_url(body, feed_url)
if not discovered:
raise HTTPException(status_code=400, detail="No RSS/Atom feed found at this URL")
# Fetch the discovered feed
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
resp2 = await client.get(discovered, headers={"User-Agent": "Reader/1.0"})
resp2.raise_for_status()
parsed2 = feedparser.parse(resp2.text)
title = parsed2.feed.get("title", discovered)
site_url = parsed2.feed.get("link") or feed_url
return discovered, title, site_url
# ── Routes ───────────────────────────────────────────────────────────────
@router.get("/counters", response_model=CountersOut)
async def feed_counters(
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
result = await db.execute(
select(Entry.feed_id, func.count(Entry.id))
.where(Entry.user_id == user_id, Entry.status == "unread")
.group_by(Entry.feed_id)
)
unreads = {str(row[0]): row[1] for row in result.all()}
return {"unreads": unreads}
@router.get("", response_model=list[FeedOut])
async def list_feeds(
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
result = await db.execute(
select(Feed)
.where(Feed.user_id == user_id)
.order_by(Feed.title)
)
return result.scalars().all()
@router.post("", response_model=FeedOut, status_code=201)
async def create_feed(
body: FeedCreate,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
# Check for duplicate
existing = await db.execute(
select(Feed).where(Feed.feed_url == body.feed_url)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Feed already exists")
# Validate category belongs to user
if body.category_id:
cat = await db.execute(
select(Category).where(
Category.id == body.category_id,
Category.user_id == user_id,
)
)
if not cat.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Category not found")
# Fetch and discover feed
try:
actual_url, title, site_url = await _fetch_and_parse_feed(body.feed_url)
except httpx.HTTPError as e:
log.warning("Failed to fetch feed %s: %s", body.feed_url, e)
raise HTTPException(status_code=400, detail=f"Could not fetch feed: {e}")
# Check again with discovered URL
if actual_url != body.feed_url:
existing = await db.execute(
select(Feed).where(Feed.feed_url == actual_url)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Feed already exists")
feed = Feed(
user_id=user_id,
category_id=body.category_id,
title=title,
feed_url=actual_url,
site_url=site_url,
)
db.add(feed)
await db.commit()
await db.refresh(feed)
return feed
@router.delete("/{feed_id}", status_code=204)
async def delete_feed(
feed_id: int,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
result = await db.execute(
select(Feed).where(Feed.id == feed_id, Feed.user_id == user_id)
)
feed = result.scalar_one_or_none()
if not feed:
raise HTTPException(status_code=404, detail="Feed not found")
await db.delete(feed)
await db.commit()
@router.post("/{feed_id}/refresh")
async def refresh_feed(
feed_id: int,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
result = await db.execute(
select(Feed).where(Feed.id == feed_id, Feed.user_id == user_id)
)
feed = result.scalar_one_or_none()
if not feed:
raise HTTPException(status_code=404, detail="Feed not found")
import asyncio
from app.worker.tasks import fetch_single_feed
await asyncio.to_thread(fetch_single_feed, feed_id)
return {"ok": True, "message": f"Refreshed {feed.title}"}
@router.post("/refresh-all")
async def refresh_all_feeds(
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db_session),
):
result = await db.execute(
select(Feed).where(Feed.user_id == user_id)
)
feeds = result.scalars().all()
import asyncio
from app.worker.tasks import fetch_single_feed
for feed in feeds:
try:
await asyncio.to_thread(fetch_single_feed, feed.id)
except Exception:
pass
return {"ok": True, "message": f"Refreshed {len(feeds)} feeds"}

View File

@@ -0,0 +1,23 @@
"""Reader service configuration — all from environment variables."""
import os
# ── Database (reuse Brain's PostgreSQL) ──
DATABASE_URL = os.environ.get(
"DATABASE_URL",
"postgresql+asyncpg://brain:brain@brain-db:5432/brain",
)
DATABASE_URL_SYNC = DATABASE_URL.replace("+asyncpg", "")
# ── Redis (reuse Brain's Redis) ──
REDIS_URL = os.environ.get("REDIS_URL", "redis://brain-redis:6379/0")
# ── Crawler (reuse Brain's Playwright crawler) ──
CRAWLER_URL = os.environ.get("CRAWLER_URL", "http://brain-crawler:3100")
# ── Service ──
PORT = int(os.environ.get("PORT", "8300"))
DEBUG = os.environ.get("DEBUG", "").lower() in ("1", "true")
# ── Feed fetch interval (seconds) ──
FEED_FETCH_INTERVAL = int(os.environ.get("FEED_FETCH_INTERVAL", "600"))

View File

@@ -0,0 +1,18 @@
"""Database session and engine setup."""
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.config import DATABASE_URL
engine = create_async_engine(DATABASE_URL, echo=False, pool_size=10, max_overflow=5)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
async with async_session() as session:
yield session

View File

@@ -0,0 +1,43 @@
"""Reader service — FastAPI entrypoint."""
import logging
from fastapi import FastAPI
from app.api.categories import router as categories_router
from app.api.feeds import router as feeds_router
from app.api.entries import router as entries_router
from app.config import DEBUG
logging.basicConfig(
level=logging.DEBUG if DEBUG else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
app = FastAPI(
title="Reader",
description="Self-hosted RSS reader — replaces Miniflux.",
version="1.0.0",
docs_url="/api/docs" if DEBUG else None,
redoc_url=None,
)
app.include_router(categories_router)
app.include_router(feeds_router)
app.include_router(entries_router)
@app.get("/api/health")
async def health():
return {"status": "ok"}
@app.on_event("startup")
async def startup():
from app.database import engine, Base
from app.models import Category, Feed, Entry # noqa: register models
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logging.getLogger(__name__).info("Reader service started")

View File

@@ -0,0 +1,74 @@
"""SQLAlchemy models for the reader service."""
from datetime import datetime
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import relationship
from app.database import Base
class Category(Base):
__tablename__ = "reader_categories"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(String(64), nullable=False)
title = Column(String(255), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
feeds = relationship("Feed", back_populates="category", lazy="selectin")
class Feed(Base):
__tablename__ = "reader_feeds"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(String(64), nullable=False)
category_id = Column(Integer, ForeignKey("reader_categories.id", ondelete="SET NULL"), nullable=True)
title = Column(String(500), nullable=False)
feed_url = Column(Text, nullable=False, unique=True)
site_url = Column(Text)
etag = Column(String(255))
last_modified = Column(String(255))
last_fetched_at = Column(DateTime)
created_at = Column(DateTime, default=datetime.utcnow)
category = relationship("Category", back_populates="feeds", lazy="selectin")
entries = relationship("Entry", back_populates="feed", lazy="noload", cascade="all, delete-orphan")
class Entry(Base):
__tablename__ = "reader_entries"
__table_args__ = (
UniqueConstraint("feed_id", "url", name="uq_reader_entries_feed_url"),
Index("idx_reader_entries_user_status", "user_id", "status"),
Index("idx_reader_entries_user_starred", "user_id", "starred"),
Index("idx_reader_entries_feed", "feed_id"),
Index("idx_reader_entries_published", "published_at"),
)
id = Column(Integer, primary_key=True, autoincrement=True)
feed_id = Column(Integer, ForeignKey("reader_feeds.id", ondelete="CASCADE"), nullable=False)
user_id = Column(String(64), nullable=False)
title = Column(String(1000))
url = Column(Text)
content = Column(Text)
full_content = Column(Text)
author = Column(String(500))
published_at = Column(DateTime)
status = Column(String(10), default="unread")
starred = Column(Boolean, default=False)
reading_time = Column(Integer, default=1)
created_at = Column(DateTime, default=datetime.utcnow)
feed = relationship("Feed", back_populates="entries", lazy="selectin")

View File

View File

@@ -0,0 +1,363 @@
"""Feed fetching worker — RQ tasks and scheduling loop."""
import logging
import re
import time
from datetime import datetime, timezone
import feedparser
import httpx
from dateutil import parser as dateparser
from redis import Redis
from rq import Queue
from sqlalchemy import create_engine, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session, sessionmaker
from app.config import DATABASE_URL_SYNC, FEED_FETCH_INTERVAL, REDIS_URL
from app.models import Category, Entry, Feed
log = logging.getLogger(__name__)
# ── Sync DB engine (for RQ worker) ──
_engine = create_engine(DATABASE_URL_SYNC, echo=False, pool_size=5, max_overflow=3)
SyncSession = sessionmaker(_engine, class_=Session, expire_on_commit=False)
# ── RQ queue ──
_redis = Redis.from_url(REDIS_URL)
queue = Queue("reader", connection=_redis)
# HTML tag stripper
_html_re = re.compile(r"<[^>]+>")
def _strip_html(text: str) -> str:
"""Remove HTML tags for word counting."""
if not text:
return ""
return _html_re.sub("", text)
def _calc_reading_time(html_content: str) -> int:
"""Estimate reading time in minutes from HTML content."""
plain = _strip_html(html_content)
word_count = len(plain.split())
return max(1, word_count // 200)
def _parse_date(entry: dict) -> datetime | None:
"""Parse published date from a feedparser entry."""
for field in ("published", "updated", "created"):
val = entry.get(field)
if val:
try:
return dateparser.parse(val)
except (ValueError, TypeError):
continue
# Try struct_time fields
for field in ("published_parsed", "updated_parsed", "created_parsed"):
val = entry.get(field)
if val:
try:
return datetime(*val[:6], tzinfo=timezone.utc)
except (ValueError, TypeError):
continue
return None
def _get_entry_content(entry: dict) -> str:
"""Extract the best content from a feedparser entry."""
# Prefer content field (often full HTML)
if entry.get("content"):
return entry["content"][0].get("value", "")
# Fall back to summary
if entry.get("summary"):
return entry["summary"]
# Fall back to description
if entry.get("description"):
return entry["description"]
return ""
def _get_entry_author(entry: dict) -> str | None:
"""Extract author from a feedparser entry."""
if entry.get("author"):
return entry["author"]
if entry.get("author_detail", {}).get("name"):
return entry["author_detail"]["name"]
return None
def _ensure_uncategorized(db: Session, user_id: str) -> int:
"""Ensure an 'Uncategorized' category exists for the user, return its ID."""
row = db.execute(
select(Category).where(
Category.user_id == user_id,
Category.title == "Uncategorized",
)
).scalar_one_or_none()
if row:
return row.id
cat = Category(user_id=user_id, title="Uncategorized")
db.add(cat)
db.flush()
return cat.id
def fetch_single_feed(feed_id: int):
"""Fetch and parse a single feed, inserting new entries."""
with SyncSession() as db:
feed = db.execute(select(Feed).where(Feed.id == feed_id)).scalar_one_or_none()
if not feed:
log.warning("Feed %d not found, skipping", feed_id)
return
log.info("Fetching feed %d: %s", feed.id, feed.feed_url)
headers = {"User-Agent": "Reader/1.0"}
if feed.etag:
headers["If-None-Match"] = feed.etag
if feed.last_modified:
headers["If-Modified-Since"] = feed.last_modified
try:
resp = httpx.get(feed.feed_url, headers=headers, timeout=30, follow_redirects=True)
except httpx.HTTPError as e:
log.error("HTTP error fetching feed %d: %s", feed.id, e)
return
# 304 Not Modified
if resp.status_code == 304:
log.debug("Feed %d not modified", feed.id)
feed.last_fetched_at = datetime.utcnow()
db.commit()
return
if resp.status_code != 200:
log.warning("Feed %d returned status %d", feed.id, resp.status_code)
return
# Update etag/last-modified
feed.etag = resp.headers.get("ETag")
feed.last_modified = resp.headers.get("Last-Modified")
feed.last_fetched_at = datetime.utcnow()
parsed = feedparser.parse(resp.text)
if not parsed.entries:
log.debug("Feed %d has no entries", feed.id)
db.commit()
return
new_count = 0
new_entry_ids = []
for fe in parsed.entries:
url = fe.get("link")
if not url:
continue
content = _get_entry_content(fe)
pub_date = _parse_date(fe)
stmt = pg_insert(Entry).values(
feed_id=feed.id,
user_id=feed.user_id,
title=fe.get("title", "")[:1000] if fe.get("title") else None,
url=url,
content=content,
author=_get_entry_author(fe),
published_at=pub_date,
status="unread",
starred=False,
reading_time=_calc_reading_time(content),
).on_conflict_do_nothing(
constraint="uq_reader_entries_feed_url"
).returning(Entry.id)
result = db.execute(stmt)
row = result.fetchone()
if row:
new_entry_ids.append(row[0])
new_count += 1
db.commit()
log.info("Feed %d: %d new entries from %d total", feed.id, new_count, len(parsed.entries))
# Fetch full content for new entries
if new_entry_ids:
_fetch_full_content_for_entries(db, new_entry_ids)
def _fetch_full_content_for_entries(db, entry_ids: list[int]):
"""Fetch full article content for specific entries."""
from app.config import CRAWLER_URL
entries = db.execute(
select(Entry).where(Entry.id.in_(entry_ids))
).scalars().all()
log.info("Fetching full content for %d new entries", len(entries))
for entry in entries:
if not entry.url:
continue
try:
resp = httpx.post(
f"{CRAWLER_URL}/crawl",
json={"url": entry.url},
timeout=45,
)
if resp.status_code == 200:
data = resp.json()
readable = data.get("readable_html", "")
full_text = data.get("text", "")
if readable:
entry.full_content = readable
if full_text:
entry.reading_time = max(1, len(full_text.split()) // 200)
elif full_text and len(full_text) > len(_strip_html(entry.content or "")):
paragraphs = [p.strip() for p in full_text.split("\n\n") if p.strip()]
if not paragraphs:
paragraphs = [p.strip() for p in full_text.split("\n") if p.strip()]
entry.full_content = "\n".join(f"<p>{p}</p>" for p in paragraphs)
entry.reading_time = max(1, len(full_text.split()) // 200)
else:
entry.full_content = entry.content or ""
else:
entry.full_content = entry.content or ""
except Exception as e:
log.warning("Full content fetch failed for entry %d: %s", entry.id, e)
entry.full_content = entry.content or ""
db.commit()
log.info("Full content done for %d entries", len(entries))
def fetch_full_content_batch():
"""Fetch full article content for entries that only have RSS summaries."""
from app.config import CRAWLER_URL
with SyncSession() as db:
# Find entries with short content and no full_content (limit batch size)
entries = db.execute(
select(Entry).where(
Entry.full_content.is_(None),
Entry.url.isnot(None),
Entry.status == "unread",
).order_by(Entry.published_at.desc()).limit(20)
).scalars().all()
if not entries:
return
log.info("Fetching full content for %d entries", len(entries))
for entry in entries:
try:
resp = httpx.post(
f"{CRAWLER_URL}/crawl",
json={"url": entry.url},
timeout=45,
)
if resp.status_code == 200:
data = resp.json()
readable = data.get("readable_html", "")
full_text = data.get("text", "")
if readable:
entry.full_content = readable
if full_text:
entry.reading_time = max(1, len(full_text.split()) // 200)
elif full_text and len(full_text) > len(_strip_html(entry.content or "")):
paragraphs = [p.strip() for p in full_text.split("\n\n") if p.strip()]
if not paragraphs:
paragraphs = [p.strip() for p in full_text.split("\n") if p.strip()]
entry.full_content = "\n".join(f"<p>{p}</p>" for p in paragraphs)
entry.reading_time = max(1, len(full_text.split()) // 200)
else:
entry.full_content = entry.content or ""
else:
entry.full_content = entry.content or ""
except Exception as e:
log.warning("Full content fetch failed for entry %d: %s", entry.id, e)
entry.full_content = entry.content or ""
db.commit()
log.info("Full content fetched for %d entries", len(entries))
def fetch_all_feeds():
"""Fetch all feeds — called on schedule."""
with SyncSession() as db:
feeds = db.execute(select(Feed)).scalars().all()
log.info("Scheduling fetch for %d feeds", len(feeds))
for feed in feeds:
try:
fetch_single_feed(feed.id)
except Exception:
log.exception("Error fetching feed %d", feed.id)
# Full content is now fetched inline for each new entry
def cleanup_old_entries():
"""Delete old entries: read > 30 days, unread > 60 days."""
from sqlalchemy import delete as sa_delete
with SyncSession() as db:
now = datetime.utcnow()
thirty_days = now - __import__('datetime').timedelta(days=30)
sixty_days = now - __import__('datetime').timedelta(days=60)
# Read entries older than 30 days
result1 = db.execute(
sa_delete(Entry).where(
Entry.status == "read",
Entry.created_at < thirty_days,
)
)
# Unread entries older than 60 days
result2 = db.execute(
sa_delete(Entry).where(
Entry.status == "unread",
Entry.created_at < sixty_days,
)
)
db.commit()
total = (result1.rowcount or 0) + (result2.rowcount or 0)
if total > 0:
log.info("Cleanup: deleted %d old entries (%d read, %d unread)",
total, result1.rowcount or 0, result2.rowcount or 0)
def run_scheduler():
"""Simple loop that runs fetch_all_feeds every FEED_FETCH_INTERVAL seconds."""
log.info("Reader scheduler started — interval: %ds", FEED_FETCH_INTERVAL)
# Create tables on first run (for the sync engine)
from app.database import Base
from app.models import Category, Feed, Entry # noqa: register models
Base.metadata.create_all(_engine)
cycles = 0
while True:
try:
fetch_all_feeds()
except Exception:
log.exception("Scheduler error in fetch_all_feeds")
# Run cleanup once per day (every ~144 cycles at 10min interval)
cycles += 1
if cycles % 144 == 0:
try:
cleanup_old_entries()
except Exception:
log.exception("Scheduler error in cleanup")
time.sleep(FEED_FETCH_INTERVAL)
if __name__ == "__main__":
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
run_scheduler()

View File

@@ -0,0 +1,43 @@
services:
# ── API ──
reader-api:
build:
context: .
dockerfile: Dockerfile.api
container_name: reader-api
restart: unless-stopped
environment:
- DATABASE_URL=postgresql+asyncpg://brain:brain@brain-db:5432/brain
- REDIS_URL=redis://brain-redis:6379/0
- CRAWLER_URL=http://brain-crawler:3100
- PORT=8300
- DEBUG=${DEBUG:-0}
- TZ=${TZ:-America/Chicago}
networks:
- default
- pangolin
- brain
# ── Worker (feed fetcher + scheduler) ──
reader-worker:
build:
context: .
dockerfile: Dockerfile.worker
container_name: reader-worker
restart: unless-stopped
environment:
- DATABASE_URL=postgresql+asyncpg://brain:brain@brain-db:5432/brain
- REDIS_URL=redis://brain-redis:6379/0
- CRAWLER_URL=http://brain-crawler:3100
- FEED_FETCH_INTERVAL=600
- TZ=${TZ:-America/Chicago}
networks:
- default
- brain
networks:
pangolin:
external: true
brain:
name: brain_default
external: true

View File

@@ -0,0 +1,11 @@
fastapi==0.115.0
uvicorn[standard]==0.32.0
sqlalchemy[asyncio]==2.0.35
asyncpg==0.30.0
psycopg2-binary==2.9.10
pydantic==2.10.0
httpx==0.28.0
feedparser==6.0.11
redis==5.2.0
rq==2.1.0
python-dateutil==2.9.0