feat: major platform expansion — Brain service, RSS reader, iOS app, AI assistants, Firefox extension
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:
25
.gitignore
vendored
25
.gitignore
vendored
@@ -37,3 +37,28 @@ gateway/data/
|
|||||||
|
|
||||||
# Test artifacts
|
# Test artifacts
|
||||||
test-results/
|
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
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ services:
|
|||||||
- IMMICH_API_KEY=${IMMICH_API_KEY}
|
- IMMICH_API_KEY=${IMMICH_API_KEY}
|
||||||
- KARAKEEP_URL=${KARAKEEP_URL:-http://192.168.1.42:3005}
|
- KARAKEEP_URL=${KARAKEEP_URL:-http://192.168.1.42:3005}
|
||||||
- KARAKEEP_API_KEY=${KARAKEEP_API_KEY}
|
- KARAKEEP_API_KEY=${KARAKEEP_API_KEY}
|
||||||
|
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||||
|
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-5.2}
|
||||||
- BODY_SIZE_LIMIT=52428800
|
- BODY_SIZE_LIMIT=52428800
|
||||||
- TZ=${TZ:-America/Chicago}
|
- TZ=${TZ:-America/Chicago}
|
||||||
networks:
|
networks:
|
||||||
@@ -64,6 +66,7 @@ services:
|
|||||||
- TASKS_BACKEND_URL=http://tasks-service:8098
|
- TASKS_BACKEND_URL=http://tasks-service:8098
|
||||||
- TASKS_SERVICE_API_KEY=${TASKS_SERVICE_API_KEY}
|
- TASKS_SERVICE_API_KEY=${TASKS_SERVICE_API_KEY}
|
||||||
- BRAIN_BACKEND_URL=http://brain-api:8200
|
- BRAIN_BACKEND_URL=http://brain-api:8200
|
||||||
|
- READER_BACKEND_URL=http://reader-api:8300
|
||||||
- QBITTORRENT_HOST=${QBITTORRENT_HOST:-192.168.1.42}
|
- QBITTORRENT_HOST=${QBITTORRENT_HOST:-192.168.1.42}
|
||||||
- QBITTORRENT_PORT=${QBITTORRENT_PORT:-8080}
|
- QBITTORRENT_PORT=${QBITTORRENT_PORT:-8080}
|
||||||
- QBITTORRENT_USERNAME=${QBITTORRENT_USERNAME:-admin}
|
- QBITTORRENT_USERNAME=${QBITTORRENT_USERNAME:-admin}
|
||||||
|
|||||||
250
extensions/brain-firefox/background.js
Normal file
250
extensions/brain-firefox/background.js
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
BIN
extensions/brain-firefox/brain-firefox.xpi
Normal file
BIN
extensions/brain-firefox/brain-firefox.xpi
Normal file
Binary file not shown.
49
extensions/brain-firefox/manifest.json
Normal file
49
extensions/brain-firefox/manifest.json
Normal 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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
259
extensions/brain-firefox/popup.html
Normal file
259
extensions/brain-firefox/popup.html
Normal 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>
|
||||||
202
extensions/brain-firefox/popup.js
Normal file
202
extensions/brain-firefox/popup.js
Normal 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();
|
||||||
|
});
|
||||||
8
extensions/brain-firefox/test-popup.html
Normal file
8
extensions/brain-firefox/test-popup.html
Normal 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>
|
||||||
@@ -12,8 +12,9 @@
|
|||||||
═══════════════════════════════════════════════ */
|
═══════════════════════════════════════════════ */
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
html, body { overflow-x: hidden; }
|
html { background-color: #f5efe6; }
|
||||||
body { padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); }
|
body { overflow-x: clip; }
|
||||||
|
body { padding-bottom: env(safe-area-inset-bottom); }
|
||||||
:root {
|
:root {
|
||||||
/* ── Fonts ── */
|
/* ── Fonts ── */
|
||||||
--font: 'Outfit', -apple-system, system-ui, sans-serif;
|
--font: 'Outfit', -apple-system, system-ui, sans-serif;
|
||||||
@@ -106,7 +107,7 @@
|
|||||||
|
|
||||||
/* ── LIGHT MODE — Zinc + Emerald ── */
|
/* ── LIGHT MODE — Zinc + Emerald ── */
|
||||||
:root {
|
:root {
|
||||||
--canvas: #FAFAFA;
|
--canvas: #f5efe6;
|
||||||
--surface: #FFFFFF;
|
--surface: #FFFFFF;
|
||||||
--surface-secondary: #F4F4F5;
|
--surface-secondary: #F4F4F5;
|
||||||
--card: #FFFFFF;
|
--card: #FFFFFF;
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<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.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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">
|
<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">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,16 +1,26 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { invalidateAll } from '$app/navigation';
|
import { invalidateAll } from '$app/navigation';
|
||||||
import { tick } from 'svelte';
|
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 ChatRole = 'user' | 'assistant';
|
||||||
type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||||
|
type Domain = 'fitness' | 'brain';
|
||||||
|
|
||||||
|
type SourceLink = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
|
||||||
type Message = {
|
type Message = {
|
||||||
role: ChatRole;
|
role: ChatRole;
|
||||||
content: string;
|
content: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
imageName?: string;
|
imageName?: string;
|
||||||
|
sources?: SourceLink[];
|
||||||
|
domain?: Domain;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Draft = {
|
type Draft = {
|
||||||
@@ -30,21 +40,34 @@
|
|||||||
|
|
||||||
type DraftBundle = Draft[];
|
type DraftBundle = Draft[];
|
||||||
|
|
||||||
|
type UnifiedState = {
|
||||||
|
activeDomain?: Domain;
|
||||||
|
fitnessState?: {
|
||||||
|
draft?: Draft | null;
|
||||||
|
drafts?: DraftBundle;
|
||||||
|
};
|
||||||
|
brainState?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onclose: () => void;
|
onclose: () => void;
|
||||||
entryDate?: string | null;
|
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[]>([
|
let messages = $state<Message[]>([
|
||||||
{ role: 'assistant', content: intro }
|
{ role: 'assistant', content: intro }
|
||||||
]);
|
]);
|
||||||
let draft = $state<Draft | null>(null);
|
let unifiedState = $state<UnifiedState>({});
|
||||||
let drafts = $state<DraftBundle>([]);
|
|
||||||
let input = $state('');
|
let input = $state('');
|
||||||
let sending = $state(false);
|
let sending = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
@@ -66,8 +89,7 @@
|
|||||||
|
|
||||||
function resetThread() {
|
function resetThread() {
|
||||||
messages = [{ role: 'assistant', content: intro }];
|
messages = [{ role: 'assistant', content: intro }];
|
||||||
draft = null;
|
unifiedState = {};
|
||||||
drafts = [];
|
|
||||||
input = '';
|
input = '';
|
||||||
error = '';
|
error = '';
|
||||||
photoPreview = null;
|
photoPreview = null;
|
||||||
@@ -124,16 +146,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/assistant/fitness', {
|
const response = await fetch('/api/assistant', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
action,
|
action,
|
||||||
messages: nextMessages,
|
messages: nextMessages,
|
||||||
draft,
|
state: unifiedState,
|
||||||
drafts,
|
|
||||||
entryDate,
|
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');
|
throw new Error(data?.error || 'Assistant request failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.draft) {
|
unifiedState = data?.state || {};
|
||||||
draft = data.draft;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(data.drafts)) {
|
|
||||||
drafts = data.drafts;
|
|
||||||
if (data.drafts.length > 0) {
|
|
||||||
draft = null;
|
|
||||||
}
|
|
||||||
} else if (data.draft) {
|
|
||||||
drafts = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.reply) {
|
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) {
|
if (data.applied) {
|
||||||
@@ -165,8 +184,6 @@
|
|||||||
attachedPhoto = null;
|
attachedPhoto = null;
|
||||||
attachedPhotoName = '';
|
attachedPhotoName = '';
|
||||||
if (fileInputEl) fileInputEl.value = '';
|
if (fileInputEl) fileInputEl.value = '';
|
||||||
draft = null;
|
|
||||||
drafts = [];
|
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent('fitnessassistantapplied', {
|
new CustomEvent('fitnessassistantapplied', {
|
||||||
detail: { entryDate: data.draft?.entry_date || data.drafts?.[0]?.entry_date || entryDate || null }
|
detail: { entryDate: data.draft?.entry_date || data.drafts?.[0]?.entry_date || entryDate || null }
|
||||||
@@ -236,20 +253,36 @@
|
|||||||
document.body.classList.remove('assistant-open');
|
document.body.classList.remove('assistant-open');
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function activeDraft(): Draft | null {
|
||||||
|
return unifiedState.fitnessState?.draft || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeDrafts(): DraftBundle {
|
||||||
|
return unifiedState.fitnessState?.drafts || [];
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if open}
|
{#if open}
|
||||||
<button class="assistant-backdrop" aria-label="Close assistant" onclick={onclose}></button>
|
<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">
|
<header class="assistant-head">
|
||||||
<div class="assistant-title-wrap">
|
<div class="assistant-title-wrap">
|
||||||
<div class="assistant-mark">
|
<div class="assistant-mark">
|
||||||
|
{#if unifiedState.activeDomain === 'brain'}
|
||||||
|
{#if allowBrain}
|
||||||
|
<Brain size={16} strokeWidth={1.9} />
|
||||||
|
{:else}
|
||||||
<Dumbbell size={16} strokeWidth={1.9} />
|
<Dumbbell size={16} strokeWidth={1.9} />
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<Dumbbell size={16} strokeWidth={1.9} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="assistant-kicker">Assistant</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -285,11 +318,21 @@
|
|||||||
{message.content}
|
{message.content}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if drafts.length > 1}
|
{#if activeDrafts().length > 1}
|
||||||
<div class="bubble-row draft-row">
|
<div class="bubble-row draft-row">
|
||||||
<div class="bubble-icon">
|
<div class="bubble-icon">
|
||||||
<Sparkles size={14} strokeWidth={1.8} />
|
<Sparkles size={14} strokeWidth={1.8} />
|
||||||
@@ -298,12 +341,12 @@
|
|||||||
<div class="draft-card-top">
|
<div class="draft-card-top">
|
||||||
<div>
|
<div>
|
||||||
<div class="draft-card-kicker">Ready to add</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>
|
||||||
<div class="draft-meal">{drafts[0]?.meal_type || 'meal'}</div>
|
<div class="draft-meal">{activeDrafts()[0]?.meal_type || 'meal'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bundle-list">
|
<div class="bundle-list">
|
||||||
{#each drafts as item}
|
{#each activeDrafts() as item}
|
||||||
<div class="bundle-row">
|
<div class="bundle-row">
|
||||||
<div class="bundle-name">{item.food_name}</div>
|
<div class="bundle-name">{item.food_name}</div>
|
||||||
<div class="bundle-calories">{Math.round(item.calories || 0)} cal</div>
|
<div class="bundle-calories">{Math.round(item.calories || 0)} cal</div>
|
||||||
@@ -311,7 +354,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="draft-inline-metrics">
|
<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>
|
||||||
<div class="draft-card-actions">
|
<div class="draft-card-actions">
|
||||||
<button class="draft-apply subtle" onclick={beginRevision} disabled={sending}>
|
<button class="draft-apply subtle" onclick={beginRevision} disabled={sending}>
|
||||||
@@ -323,7 +366,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if draft?.food_name}
|
{:else if activeDraft()?.food_name}
|
||||||
<div class="bubble-row draft-row">
|
<div class="bubble-row draft-row">
|
||||||
<div class="bubble-icon">
|
<div class="bubble-icon">
|
||||||
<Sparkles size={14} strokeWidth={1.8} />
|
<Sparkles size={14} strokeWidth={1.8} />
|
||||||
@@ -332,19 +375,19 @@
|
|||||||
<div class="draft-card-top">
|
<div class="draft-card-top">
|
||||||
<div>
|
<div>
|
||||||
<div class="draft-card-kicker">Ready to add</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>
|
</div>
|
||||||
{#if draft.meal_type}
|
{#if activeDraft()?.meal_type}
|
||||||
<div class="draft-meal">{draft.meal_type}</div>
|
<div class="draft-meal">{activeDraft()?.meal_type}</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="draft-inline-metrics">
|
<div class="draft-inline-metrics">
|
||||||
<span>{Math.round(draft.calories || 0)} cal</span>
|
<span>{Math.round(activeDraft()?.calories || 0)} cal</span>
|
||||||
<span>{Math.round(draft.protein || 0)}p</span>
|
<span>{Math.round(activeDraft()?.protein || 0)}p</span>
|
||||||
<span>{Math.round(draft.carbs || 0)}c</span>
|
<span>{Math.round(activeDraft()?.carbs || 0)}c</span>
|
||||||
<span>{Math.round(draft.fat || 0)}f</span>
|
<span>{Math.round(activeDraft()?.fat || 0)}f</span>
|
||||||
<span>{Math.round(draft.sugar || 0)} sugar</span>
|
<span>{Math.round(activeDraft()?.sugar || 0)} sugar</span>
|
||||||
<span>{Math.round(draft.fiber || 0)} fiber</span>
|
<span>{Math.round(activeDraft()?.fiber || 0)} fiber</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="draft-card-actions">
|
<div class="draft-card-actions">
|
||||||
<button class="draft-apply subtle" onclick={beginRevision} disabled={sending}>
|
<button class="draft-apply subtle" onclick={beginRevision} disabled={sending}>
|
||||||
@@ -390,7 +433,7 @@
|
|||||||
<img src={photoPreview} alt="Selected food" />
|
<img src={photoPreview} alt="Selected food" />
|
||||||
<div class="photo-staged-copy">
|
<div class="photo-staged-copy">
|
||||||
<div class="photo-staged-title">{photoName || 'Food photo'}</div>
|
<div class="photo-staged-title">{photoName || 'Food photo'}</div>
|
||||||
<div class="photo-staged-note">I’ll draft the entry from this photo.</div>
|
<div class="photo-staged-note">Photos currently route to fitness.</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="photo-staged-clear" type="button" onclick={clearPhoto}>Remove</button>
|
<button class="photo-staged-clear" type="button" onclick={clearPhoto}>Remove</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -410,7 +453,7 @@
|
|||||||
class="compose-input"
|
class="compose-input"
|
||||||
bind:value={input}
|
bind:value={input}
|
||||||
bind:this={composerEl}
|
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}
|
onkeydown={handleKeydown}
|
||||||
type="text"
|
type="text"
|
||||||
/>
|
/>
|
||||||
@@ -537,9 +580,12 @@
|
|||||||
.assistant-body {
|
.assistant-body {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thread {
|
.thread {
|
||||||
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
@@ -549,6 +595,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bundle-card {
|
.bundle-card {
|
||||||
@@ -642,6 +689,30 @@
|
|||||||
color: #f8f4ee;
|
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 {
|
.image-bubble {
|
||||||
width: min(240px, 100%);
|
width: min(240px, 100%);
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
LibraryBig,
|
LibraryBig,
|
||||||
Menu,
|
Menu,
|
||||||
Package2,
|
Package2,
|
||||||
|
MessageSquare,
|
||||||
Search,
|
Search,
|
||||||
Settings2,
|
Settings2,
|
||||||
SquareCheckBig,
|
SquareCheckBig,
|
||||||
@@ -193,8 +194,9 @@
|
|||||||
<div class="brand-sub">ops workspace</div>
|
<div class="brand-sub">ops workspace</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<button class="command-trigger mobile" onclick={onOpenCommand}>
|
<button class="command-trigger mobile" onclick={onOpenCommand} aria-label="Open assistant">
|
||||||
<Search size={15} strokeWidth={1.8} />
|
<MessageSquare size={15} strokeWidth={1.8} />
|
||||||
|
<span>AI</span>
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -220,7 +222,8 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</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">
|
<div class="rail-upload-row">
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="paste-box" contenteditable="true" onpaste={onPasteBox} role="textbox" tabindex="0">
|
<div class="paste-box" contenteditable="true" onpaste={onPasteBox} role="textbox" tabindex="0">
|
||||||
@@ -230,10 +233,6 @@
|
|||||||
<Upload size={13} strokeWidth={2} />
|
<Upload size={13} strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -250,13 +249,14 @@
|
|||||||
radial-gradient(circle at top left, rgba(214, 120, 58, 0.08), transparent 22%),
|
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%),
|
radial-gradient(circle at 85% 10%, rgba(65, 91, 82, 0.12), transparent 20%),
|
||||||
linear-gradient(180deg, #f5efe6 0%, #efe6da 48%, #ede7de 100%);
|
linear-gradient(180deg, #f5efe6 0%, #efe6da 48%, #ede7de 100%);
|
||||||
|
background-color: #f5efe6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell {
|
.shell {
|
||||||
--shell-ink: #1e1812;
|
--shell-ink: #1e1812;
|
||||||
--shell-muted: #6b6256;
|
--shell-muted: #6b6256;
|
||||||
--shell-line: rgba(35, 26, 17, 0.11);
|
--shell-line: rgba(35, 26, 17, 0.11);
|
||||||
min-height: 100vh;
|
min-height: 100dvh;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 270px minmax(0, 1fr);
|
grid-template-columns: 270px minmax(0, 1fr);
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -525,10 +525,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.command-trigger.mobile {
|
.command-trigger.mobile {
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
height: 40px;
|
||||||
justify-content: center;
|
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 {
|
.mobile-menu-btn {
|
||||||
@@ -557,33 +564,15 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: min(300px, 82vw);
|
width: min(320px, 86vw);
|
||||||
padding: 16px 14px;
|
padding: 18px 16px;
|
||||||
padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px));
|
|
||||||
background: linear-gradient(180deg, rgba(250, 246, 239, 0.96), rgba(244, 237, 228, 0.94));
|
background: linear-gradient(180deg, rgba(250, 246, 239, 0.96), rgba(244, 237, 228, 0.94));
|
||||||
border-right: 1px solid var(--shell-line);
|
border-right: 1px solid var(--shell-line);
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
z-index: 40;
|
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;
|
display: grid;
|
||||||
gap: 8px;
|
align-content: start;
|
||||||
padding-top: 12px;
|
gap: 18px;
|
||||||
border-top: 1px solid var(--shell-line);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-nav-head {
|
.mobile-nav-head {
|
||||||
@@ -634,5 +623,11 @@
|
|||||||
.mobile-menu-btn {
|
.mobile-menu-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-nav-upload {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--shell-line);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
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';
|
const ICON_MAP: Record<string, any> = {
|
||||||
import PdfInlinePreview from '$lib/components/trips/PdfInlinePreview.svelte';
|
'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 {
|
interface BrainItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -23,6 +38,17 @@
|
|||||||
assets: { id: string; asset_type: string; filename: string; content_type: string | null }[];
|
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 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; }
|
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 activeTagId = $state<string | null>(null);
|
||||||
let searchQuery = $state('');
|
let searchQuery = $state('');
|
||||||
let searching = $state(false);
|
let searching = $state(false);
|
||||||
|
type SortMode = 'discover' | 'newest' | 'oldest';
|
||||||
|
let sortMode = $state<SortMode>('discover');
|
||||||
|
|
||||||
// Sidebar
|
// Sidebar
|
||||||
let sidebarFolders = $state<SidebarFolder[]>([]);
|
let sidebarFolders = $state<SidebarFolder[]>([]);
|
||||||
@@ -52,8 +80,116 @@
|
|||||||
|
|
||||||
// Detail
|
// Detail
|
||||||
let selectedItem = $state<BrainItem | null>(null);
|
let selectedItem = $state<BrainItem | null>(null);
|
||||||
|
let selectedAdditions = $state<BrainAddition[]>([]);
|
||||||
let editingNote = $state(false);
|
let editingNote = $state(false);
|
||||||
let editNoteContent = $state('');
|
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 = {}) {
|
async function api(path: string, opts: RequestInit = {}) {
|
||||||
const res = await fetch(`/api/brain${path}`, { credentials: 'include', ...opts });
|
const res = await fetch(`/api/brain${path}`, { credentials: 'include', ...opts });
|
||||||
@@ -61,6 +197,80 @@
|
|||||||
return res.json();
|
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() {
|
async function loadSidebar() {
|
||||||
try {
|
try {
|
||||||
const data = await api('/taxonomy/sidebar');
|
const data = await api('/taxonomy/sidebar');
|
||||||
@@ -73,12 +283,21 @@
|
|||||||
async function loadItems() {
|
async function loadItems() {
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
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 (activeFolder) params.set('folder', activeFolder);
|
||||||
if (activeTag) params.set('tag', activeTag);
|
if (activeTag) params.set('tag', activeTag);
|
||||||
const data = await api(`/items?${params}`);
|
const data = await api(`/items?${params}`);
|
||||||
items = data.items || [];
|
let fetched = data.items || [];
|
||||||
total = data.total || 0;
|
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 */ }
|
} catch { /* silent */ }
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
@@ -210,10 +429,37 @@
|
|||||||
} catch { /* silent */ }
|
} 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) {
|
async function deleteItem(id: string) {
|
||||||
try {
|
try {
|
||||||
await api(`/items/${id}`, { method: 'DELETE' });
|
await api(`/items/${id}`, { method: 'DELETE' });
|
||||||
selectedItem = null;
|
setSelectedItem(null);
|
||||||
await loadItems();
|
await loadItems();
|
||||||
} catch { /* silent */ }
|
} catch { /* silent */ }
|
||||||
}
|
}
|
||||||
@@ -320,6 +566,14 @@
|
|||||||
|
|
||||||
await loadSidebar();
|
await loadSidebar();
|
||||||
await loadItems();
|
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);
|
onDestroy(stopPolling);
|
||||||
@@ -365,8 +619,16 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
{#each sidebarFolders.filter(f => f.is_active) as folder}
|
{#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(); }}>
|
<button class="nav-item folder-nav-item" class:active={activeFolder === folder.name}
|
||||||
{#if folder.color}<span class="nav-dot" style="background: {folder.color}"></span>{/if}
|
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>
|
<span class="nav-label">{folder.name}</span>
|
||||||
{#if showManage}
|
{#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>
|
<!-- 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>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Masonry card grid -->
|
<!-- Masonry card grid -->
|
||||||
@@ -502,11 +778,15 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="masonry">
|
<div class="masonry">
|
||||||
{#each items as item (item.id)}
|
{#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 -->
|
<!-- 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">
|
<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'}
|
{#if item.processing_status !== 'ready'}
|
||||||
<div class="card-processing-overlay">
|
<div class="card-processing-overlay">
|
||||||
<span class="processing-dot"></span>
|
<span class="processing-dot"></span>
|
||||||
@@ -515,17 +795,18 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
{:else if item.type === 'pdf' && item.assets?.some(a => a.asset_type === 'screenshot')}
|
{:else if item.type === 'pdf' && item.assets?.some(a => a.asset_type === 'screenshot')}
|
||||||
<button class="card-thumb" onclick={() => { selectedItem = item; editingNote = false; }}>
|
{@const pdfSs = item.assets.find(a => a.asset_type === 'screenshot')}
|
||||||
<img src="/api/brain/storage/{item.id}/screenshot/screenshot.png" alt="" loading="lazy" />
|
<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>
|
<div class="card-type-badge">PDF</div>
|
||||||
</button>
|
</button>
|
||||||
{:else if item.type === 'image' && item.assets?.some(a => a.asset_type === 'original_upload')}
|
{: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')}
|
{@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" />
|
<img src="/api/brain/storage/{item.id}/original_upload/{imgAsset?.filename}" alt="" loading="lazy" />
|
||||||
</button>
|
</button>
|
||||||
{:else if item.type === 'note'}
|
{: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 ? '...' : ''}
|
{(item.raw_content || '').slice(0, 200)}{(item.raw_content || '').length > 200 ? '...' : ''}
|
||||||
</button>
|
</button>
|
||||||
{:else if item.processing_status !== 'ready'}
|
{:else if item.processing_status !== 'ready'}
|
||||||
@@ -536,7 +817,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Card content — click opens detail -->
|
<!-- 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>
|
<div class="card-title">{item.title || 'Untitled'}</div>
|
||||||
{#if item.url}
|
{#if item.url}
|
||||||
<div class="card-domain">{(() => { try { return new URL(item.url).hostname; } catch { return ''; } })()}</div>
|
<div class="card-domain">{(() => { try { return new URL(item.url).hostname; } catch { return ''; } })()}</div>
|
||||||
@@ -559,7 +840,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="card-meta">
|
<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>
|
<span class="card-date">{formatDate(item.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -575,18 +856,19 @@
|
|||||||
<!-- ═══ PDF/Image full-screen viewer ═══ -->
|
<!-- ═══ PDF/Image full-screen viewer ═══ -->
|
||||||
{#if selectedItem && (selectedItem.type === 'pdf' || selectedItem.type === 'image')}
|
{#if selectedItem && (selectedItem.type === 'pdf' || selectedItem.type === 'image')}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- 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">
|
<div class="viewer-layout">
|
||||||
<!-- Main viewer area -->
|
<!-- Main viewer area -->
|
||||||
<div class="viewer-main">
|
<div class="viewer-main">
|
||||||
{#if selectedItem.type === 'pdf'}
|
{#if selectedItem.type === 'pdf'}
|
||||||
{@const pdfAsset = selectedItem.assets?.find(a => a.asset_type === 'original_upload')}
|
{@const pdfAsset = selectedItem.assets?.find(a => a.asset_type === 'original_upload')}
|
||||||
{#if pdfAsset}
|
{#if pdfAsset}
|
||||||
<iframe
|
<div class="viewer-pdf-wrap" bind:this={pdfViewerHost}
|
||||||
src="/api/brain/storage/{selectedItem.id}/original_upload/{pdfAsset.filename}#zoom=page-fit"
|
use:pdfAutoRender={`/api/brain/storage/${selectedItem.id}/original_upload/${pdfAsset.filename}`}>
|
||||||
title={selectedItem.title || 'PDF'}
|
{#if pdfViewerLoading}
|
||||||
class="viewer-iframe"
|
<div class="viewer-pdf-loading">Rendering PDF...</div>
|
||||||
></iframe>
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if selectedItem.type === 'image'}
|
{:else if selectedItem.type === 'image'}
|
||||||
{@const imgAsset = selectedItem.assets?.find(a => a.asset_type === 'original_upload')}
|
{@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>
|
<div class="detail-type">{selectedItem.type === 'pdf' ? 'PDF Document' : 'Image'}</div>
|
||||||
<h2 class="detail-title">{selectedItem.title || 'Untitled'}</h2>
|
<h2 class="detail-title">{selectedItem.title || 'Untitled'}</h2>
|
||||||
</div>
|
</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>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -614,16 +896,41 @@
|
|||||||
<div class="detail-summary">{selectedItem.summary}</div>
|
<div class="detail-summary">{selectedItem.summary}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if selectedItem.tags && selectedItem.tags.length > 0}
|
|
||||||
<div class="detail-tags">
|
<div class="detail-tags">
|
||||||
{#each selectedItem.tags as tag}
|
{#each (selectedItem.tags || []) as tag}
|
||||||
<button class="detail-tag" onclick={() => { selectedItem = null; activeTag = tag; activeFolder = null; loadItems(); }}>{tag}</button>
|
<span class="detail-tag editable">
|
||||||
|
{tag}
|
||||||
|
<button class="tag-remove" onclick={() => removeTagFromItem(tag)}>×</button>
|
||||||
|
</span>
|
||||||
{/each}
|
{/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>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="detail-meta-line">
|
<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>
|
<span>{formatDate(selectedItem.created_at)}</span>
|
||||||
{#if selectedItem.metadata_json?.page_count}
|
{#if selectedItem.metadata_json?.page_count}
|
||||||
<span>{selectedItem.metadata_json.page_count} page{selectedItem.metadata_json.page_count !== 1 ? 's' : ''}</span>
|
<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')}
|
{#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>
|
<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}
|
{/if}
|
||||||
<button class="action-btn ghost" onclick={() => reprocessItem(selectedItem.id)}>Reclassify</button>
|
<button class="action-btn ghost" onclick={() => selectedItem && 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={() => { if (selectedItem && confirm('Delete this item?')) deleteItem(selectedItem.id); }}>Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -651,21 +958,22 @@
|
|||||||
<!-- ═══ Detail sheet for links/notes ═══ -->
|
<!-- ═══ Detail sheet for links/notes ═══ -->
|
||||||
{:else if selectedItem}
|
{:else if selectedItem}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- 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-sheet">
|
||||||
<div class="detail-header">
|
<div class="detail-header">
|
||||||
<div>
|
<div>
|
||||||
<div class="detail-type">{selectedItem.type}</div>
|
<div class="detail-type">{selectedItem.type}</div>
|
||||||
<h2 class="detail-title">{selectedItem.title || 'Untitled'}</h2>
|
<h2 class="detail-title">{selectedItem.title || 'Untitled'}</h2>
|
||||||
</div>
|
</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>
|
<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>
|
</button>
|
||||||
</div>
|
</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">
|
<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>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -691,22 +999,61 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="content-body clickable" onclick={startEditNote}>
|
<button class="content-body clickable" onclick={startEditNote}>
|
||||||
{selectedItem.raw_content || 'Empty note — click to edit'}
|
{combinedNoteContent(selectedItem, selectedAdditions) || 'Empty note — click to edit'}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if selectedItem.tags && selectedItem.tags.length > 0}
|
{#if selectedItem.type !== 'note' && selectedAdditions.length > 0}
|
||||||
<div class="detail-tags">
|
<div class="detail-content">
|
||||||
{#each selectedItem.tags as tag}
|
<div class="extracted-label">Notes</div>
|
||||||
<button class="detail-tag" onclick={() => { selectedItem = null; activeTag = tag; activeFolder = null; loadItems(); }}>{tag}</button>
|
<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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/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">
|
<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>
|
<span>{formatDate(selectedItem.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -714,13 +1061,19 @@
|
|||||||
{#if selectedItem.url}
|
{#if selectedItem.url}
|
||||||
<a class="action-btn" href={selectedItem.url} target="_blank" rel="noopener">Open original</a>
|
<a class="action-btn" href={selectedItem.url} target="_blank" rel="noopener">Open original</a>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="action-btn ghost" onclick={() => reprocessItem(selectedItem.id)}>Reclassify</button>
|
<button class="action-btn ghost" onclick={() => selectedItem && 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={() => { if (selectedItem && confirm('Delete this item?')) deleteItem(selectedItem.id); }}>Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<datalist id="available-tags">
|
||||||
|
{#each sidebarTags.filter(t => t.is_active) as tag}
|
||||||
|
<option value={tag.name}></option>
|
||||||
|
{/each}
|
||||||
|
</datalist>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* No .page wrapper — brain-layout is the root */
|
/* No .page wrapper — brain-layout is the root */
|
||||||
|
|
||||||
@@ -935,6 +1288,17 @@
|
|||||||
.nav-dot {
|
.nav-dot {
|
||||||
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.mobile-pills { display: flex; }
|
.mobile-pills { display: flex; }
|
||||||
@@ -963,6 +1327,37 @@
|
|||||||
|
|
||||||
/* ═══ Search ═══ */
|
/* ═══ Search ═══ */
|
||||||
.search-section { margin-bottom: 18px; }
|
.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-wrap { position: relative; }
|
||||||
.search-icon { position: absolute; left: 16px; top: 50%; transform: translateY(-50%); width: 18px; height: 18px; color: #7f7365; pointer-events: none; }
|
.search-icon { position: absolute; left: 16px; top: 50%; transform: translateY(-50%); width: 18px; height: 18px; color: #7f7365; pointer-events: none; }
|
||||||
.search-input {
|
.search-input {
|
||||||
@@ -996,10 +1391,17 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease;
|
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 {
|
.card:hover {
|
||||||
transform: translateY(-3px);
|
transform: translateY(-3px);
|
||||||
box-shadow: 0 12px 32px rgba(42,30,19,0.08);
|
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); }
|
.card:active { transform: scale(0.985); }
|
||||||
|
|
||||||
@@ -1195,11 +1597,22 @@
|
|||||||
background: #f5f0ea;
|
background: #f5f0ea;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewer-iframe {
|
.viewer-pdf-wrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border: none;
|
overflow-y: auto;
|
||||||
background: white;
|
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 {
|
.viewer-image-wrap {
|
||||||
@@ -1326,6 +1739,34 @@
|
|||||||
white-space: pre-wrap; word-break: break-word;
|
white-space: pre-wrap; word-break: break-word;
|
||||||
font-family: var(--mono);
|
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 {
|
.detail-meta-line {
|
||||||
display: flex; gap: 12px; font-size: 0.8rem; color: #8c7b69;
|
display: flex; gap: 12px; font-size: 0.8rem; color: #8c7b69;
|
||||||
@@ -1334,7 +1775,7 @@
|
|||||||
|
|
||||||
/* meta grid removed — folder/date shown inline */
|
/* 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 {
|
.detail-tag {
|
||||||
background: rgba(35,26,17,0.06); padding: 4px 12px;
|
background: rgba(35,26,17,0.06); padding: 4px 12px;
|
||||||
border-radius: 999px; font-size: 0.85rem; color: #5d5248;
|
border-radius: 999px; font-size: 0.85rem; color: #5d5248;
|
||||||
@@ -1342,6 +1783,48 @@
|
|||||||
transition: background 160ms;
|
transition: background 160ms;
|
||||||
}
|
}
|
||||||
.detail-tag:hover { background: rgba(179,92,50,0.12); color: #1e1812; }
|
.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; }
|
.detail-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
.action-btn {
|
.action-btn {
|
||||||
@@ -1385,8 +1868,8 @@
|
|||||||
.brain-layout { margin: 0; }
|
.brain-layout { margin: 0; }
|
||||||
.masonry { columns: 1; }
|
.masonry { columns: 1; }
|
||||||
.detail-sheet { width: 100%; padding: 20px; }
|
.detail-sheet { width: 100%; padding: 20px; }
|
||||||
.viewer-overlay { padding: 0; }
|
.viewer-overlay { padding: 10px; }
|
||||||
.viewer-layout { grid-template-columns: 1fr; grid-template-rows: 55vh 1fr; width: 100%; height: 100vh; border-radius: 0; max-width: 100%; }
|
.viewer-layout { grid-template-columns: 1fr; grid-template-rows: 1fr auto; width: 100%; height: 90vh; border-radius: 18px; }
|
||||||
.viewer-sidebar { max-height: none; border-left: none; border-top: 1px solid rgba(35,26,17,0.08); overflow-y: auto; }
|
.viewer-sidebar { max-height: 40vh; border-left: none; border-top: 1px solid rgba(35,26,17,0.08); overflow-y: auto; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -531,6 +531,65 @@
|
|||||||
resolveError = '';
|
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 ──
|
// ── Food Edit/Delete ──
|
||||||
let editingFood = $state<any>(null);
|
let editingFood = $state<any>(null);
|
||||||
let editFoodName = $state('');
|
let editFoodName = $state('');
|
||||||
@@ -1038,34 +1097,34 @@
|
|||||||
<div class="macro-row">
|
<div class="macro-row">
|
||||||
<div class="macro-item">
|
<div class="macro-item">
|
||||||
<div class="macro-card-head">
|
<div class="macro-card-head">
|
||||||
<span class="macro-name">Protein</span>
|
<span class="macro-name">Fiber</span>
|
||||||
<span class="macro-left">{macroLeft(totals.protein, goal.protein)}</span>
|
<span class="macro-left">{macroLeft(totals.fiber, goal.fiber)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="macro-bar-wrap">
|
<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>
|
||||||
<div class="macro-label">
|
<div class="macro-label">
|
||||||
<span class="macro-current">{totals.protein}g</span>
|
<span class="macro-current">{totals.fiber}g</span>
|
||||||
<span class="macro-target">/ {goal.protein}g</span>
|
<span class="macro-target">{goal.fiber > 0 ? `/ ${goal.fiber}g` : '/ tracking only'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="macro-guidance">
|
<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>
|
</div>
|
||||||
<div class="macro-item">
|
<div class="macro-item">
|
||||||
<div class="macro-card-head">
|
<div class="macro-card-head">
|
||||||
<span class="macro-name">Carbs</span>
|
<span class="macro-name">Sugar</span>
|
||||||
<span class="macro-left">{macroLeft(totals.carbs, goal.carbs)}</span>
|
<span class="macro-left">{macroLeft(totals.sugar, goal.sugar)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="macro-bar-wrap">
|
<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>
|
||||||
<div class="macro-label">
|
<div class="macro-label">
|
||||||
<span class="macro-current">{totals.carbs}g</span>
|
<span class="macro-current">{totals.sugar}g</span>
|
||||||
<span class="macro-target">/ {goal.carbs}g</span>
|
<span class="macro-target">{goal.sugar > 0 ? `/ ${goal.sugar}g` : '/ tracking only'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="macro-guidance">
|
<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>
|
</div>
|
||||||
<div class="macro-item">
|
<div class="macro-item">
|
||||||
@@ -1091,34 +1150,34 @@
|
|||||||
<div class="extra-nutrients-row">
|
<div class="extra-nutrients-row">
|
||||||
<div class="macro-item extra-item sugar-item">
|
<div class="macro-item extra-item sugar-item">
|
||||||
<div class="macro-card-head">
|
<div class="macro-card-head">
|
||||||
<span class="macro-name">Sugar</span>
|
<span class="macro-name">Carbs</span>
|
||||||
<span class="macro-left">{macroLeft(totals.sugar, goal.sugar)}</span>
|
<span class="macro-left">{macroLeft(totals.carbs, goal.carbs)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="macro-bar-wrap">
|
<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>
|
||||||
<div class="macro-label">
|
<div class="macro-label">
|
||||||
<span class="macro-current">{totals.sugar}g</span>
|
<span class="macro-current">{totals.carbs}g</span>
|
||||||
<span class="macro-target">{goal.sugar > 0 ? `/ ${goal.sugar}g` : '/ tracking only'}</span>
|
<span class="macro-target">/ {goal.carbs}g</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="macro-guidance">
|
<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>
|
</div>
|
||||||
<div class="macro-item extra-item fiber-item">
|
<div class="macro-item extra-item fiber-item">
|
||||||
<div class="macro-card-head">
|
<div class="macro-card-head">
|
||||||
<span class="macro-name">Fiber</span>
|
<span class="macro-name">Protein</span>
|
||||||
<span class="macro-left">{macroLeft(totals.fiber, goal.fiber)}</span>
|
<span class="macro-left">{macroLeft(totals.protein, goal.protein)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="macro-bar-wrap">
|
<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>
|
||||||
<div class="macro-label">
|
<div class="macro-label">
|
||||||
<span class="macro-current">{totals.fiber}g</span>
|
<span class="macro-current">{totals.protein}g</span>
|
||||||
<span class="macro-target">{goal.fiber > 0 ? `/ ${goal.fiber}g` : '/ tracking only'}</span>
|
<span class="macro-target">/ {goal.protein}g</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="macro-guidance">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1217,6 +1276,28 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if expandedEntry === entry.id}
|
{#if expandedEntry === entry.id}
|
||||||
<div class="entry-actions">
|
<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">
|
<div class="entry-qty-control">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -1276,19 +1357,22 @@
|
|||||||
<div class="list-card">
|
<div class="list-card">
|
||||||
{#each filteredFoods as food (food.id || `${food.name}-${food.info}-${food.calories}`)}
|
{#each filteredFoods as food (food.id || `${food.name}-${food.info}-${food.calories}`)}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="list-row" onclick={() => openFoodEdit(food)}>
|
<div class="list-row">
|
||||||
<div class="list-row-info">
|
<div class="list-row-info" onclick={() => openFoodEdit(food)} style="cursor:pointer;flex:1;min-width:0;">
|
||||||
<div class="list-row-name">
|
<div class="list-row-name">
|
||||||
{food.name}
|
{food.name}
|
||||||
{#if food.favorite}
|
{#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>
|
<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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="list-row-meta">{food.info}</div>
|
<div class="list-row-meta">{food.info} · {food.calories} cal</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-row-right">
|
<div class="list-row-right">
|
||||||
<span class="list-row-value">{food.calories} cal</span>
|
<button class="log-btn" onclick={(e) => { e.stopPropagation(); openAddToMeal(food); }}>
|
||||||
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -1419,6 +1503,61 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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 -->
|
<!-- Resolve confirmation modal -->
|
||||||
{#if resolvedItems.length > 0}
|
{#if resolvedItems.length > 0}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
@@ -2424,18 +2563,48 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.entry-actions {
|
.entry-actions {
|
||||||
display: flex;
|
display: grid;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px 16px 0;
|
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 {
|
.entry-qty-control {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-qty-input {
|
.entry-qty-input {
|
||||||
@@ -2910,8 +3079,12 @@
|
|||||||
|
|
||||||
.resolve-modal {
|
.resolve-modal {
|
||||||
width: min(520px, calc(100vw - 24px));
|
width: min(520px, calc(100vw - 24px));
|
||||||
|
max-height: calc(100vh - 48px);
|
||||||
|
max-height: calc(100dvh - 48px);
|
||||||
border-radius: 28px;
|
border-radius: 28px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
animation: modalUp 180ms ease;
|
animation: modalUp 180ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2963,6 +3136,9 @@
|
|||||||
|
|
||||||
.resolve-modal-body {
|
.resolve-modal-body {
|
||||||
padding: 20px 22px;
|
padding: 20px 22px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resolve-item + .resolve-item {
|
.resolve-item + .resolve-item {
|
||||||
@@ -3071,6 +3247,46 @@
|
|||||||
margin-top: 12px;
|
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 {
|
@keyframes spin {
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
@@ -3302,6 +3518,14 @@
|
|||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entry-nutrition-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-qty-control {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.food-edit-row {
|
.food-edit-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,16 +29,12 @@
|
|||||||
let scrollRAF: number | null = null;
|
let scrollRAF: number | null = null;
|
||||||
let scrollInterval: ReturnType<typeof setInterval> | null = null;
|
let scrollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
let lastScrollTs = 0;
|
let lastScrollTs = 0;
|
||||||
let scrollCarry = 0;
|
|
||||||
let lastAutoCheckTs = 0;
|
|
||||||
let mobileAutoStartScrollY = 0;
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let loadingMore = $state(false);
|
let loadingMore = $state(false);
|
||||||
let hasMore = $state(true);
|
let hasMore = $state(true);
|
||||||
let totalUnread = $state(0);
|
let totalUnread = $state(0);
|
||||||
const LIMIT = 50;
|
const LIMIT = 50;
|
||||||
let feedCounters: Record<string, number> = {};
|
let feedCounters: Record<string, number> = {};
|
||||||
let stagedAutoReadIds = new Set<number>();
|
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
function timeAgo(dateStr: string): string {
|
function timeAgo(dateStr: string): string {
|
||||||
@@ -173,65 +169,113 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function markAllReadAPI() {
|
async function markAllReadAPI() {
|
||||||
const ids = articles.filter(a => !a.read).map(a => a.id);
|
|
||||||
if (!ids.length) return;
|
|
||||||
try {
|
try {
|
||||||
await api('/entries', {
|
const body: any = {};
|
||||||
|
if (activeFeedId) body.feed_id = activeFeedId;
|
||||||
|
await api('/entries/mark-all-read', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ entry_ids: ids, status: 'read' })
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
} catch { /* silent */ }
|
} catch { /* silent */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Karakeep ──
|
// ── Feed management ──
|
||||||
let karakeepIds = $state<Record<number, string>>({});
|
let showFeedManager = $state(false);
|
||||||
let savingToKarakeep = $state<Set<number>>(new Set());
|
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?.stopPropagation();
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
if (savingToKarakeep.has(article.id)) return;
|
if (savingToBrain.has(article.id)) return;
|
||||||
const articleUrl = article.url || '';
|
const articleUrl = article.url || '';
|
||||||
console.log('Karakeep: saving', article.id, articleUrl);
|
if (!articleUrl && !brainSavedIds[article.id]) return;
|
||||||
if (!articleUrl && !karakeepIds[article.id]) {
|
|
||||||
console.log('Karakeep: no URL, skipping');
|
savingToBrain = new Set([...savingToBrain, article.id]);
|
||||||
return;
|
|
||||||
}
|
|
||||||
savingToKarakeep = new Set([...savingToKarakeep, article.id]);
|
|
||||||
try {
|
try {
|
||||||
if (karakeepIds[article.id]) {
|
if (brainSavedIds[article.id]) {
|
||||||
const res = await fetch('/api/karakeep/delete', {
|
// Un-save: delete from Brain
|
||||||
method: 'POST', credentials: 'include',
|
await fetch(`/api/brain/items/${brainSavedIds[article.id]}`, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
method: 'DELETE', credentials: 'include',
|
||||||
body: JSON.stringify({ id: karakeepIds[article.id] })
|
|
||||||
});
|
});
|
||||||
console.log('Karakeep delete:', res.status);
|
const next = { ...brainSavedIds };
|
||||||
const next = { ...karakeepIds };
|
|
||||||
delete next[article.id];
|
delete next[article.id];
|
||||||
karakeepIds = next;
|
brainSavedIds = next;
|
||||||
} else {
|
} else {
|
||||||
const res = await fetch('/api/karakeep/save', {
|
// Save to Brain
|
||||||
|
const res = await fetch('/api/brain/items', {
|
||||||
method: 'POST', credentials: 'include',
|
method: 'POST', credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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();
|
if (res.ok) {
|
||||||
console.log('Karakeep save:', res.status, text);
|
const data = await res.json();
|
||||||
try {
|
brainSavedIds = { ...brainSavedIds, [article.id]: data.id };
|
||||||
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'); }
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Karakeep error:', err);
|
console.error('Brain save error:', err);
|
||||||
} finally {
|
} finally {
|
||||||
const next = new Set(savingToKarakeep);
|
const next = new Set(savingToBrain);
|
||||||
next.delete(article.id);
|
next.delete(article.id);
|
||||||
savingToKarakeep = next;
|
savingToBrain = next;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +305,26 @@
|
|||||||
markEntryRead(article.id);
|
markEntryRead(article.id);
|
||||||
decrementUnread();
|
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; }
|
function closeArticle() { selectedArticle = null; }
|
||||||
@@ -280,11 +344,18 @@
|
|||||||
else { markEntryUnread(article.id); totalUnread++; navItems[0].count = totalUnread; navItems = [...navItems]; }
|
else { markEntryUnread(article.id); totalUnread++; navItems[0].count = totalUnread; navItems = [...navItems]; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function markAllRead() {
|
async function markAllRead() {
|
||||||
const unreadCount = articles.filter(a => !a.read).length;
|
const label = activeFeedId
|
||||||
markAllReadAPI();
|
? 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 }));
|
articles = articles.map(a => ({ ...a, read: true }));
|
||||||
decrementUnread(unreadCount);
|
totalUnread = 0;
|
||||||
|
navItems[0].count = 0;
|
||||||
|
navItems = [...navItems];
|
||||||
|
// Refresh counters
|
||||||
|
await loadSidebar();
|
||||||
}
|
}
|
||||||
|
|
||||||
function goNext() {
|
function goNext() {
|
||||||
@@ -314,7 +385,7 @@
|
|||||||
|
|
||||||
// ── Auto-scroll (requestAnimationFrame for smoothness) ──
|
// ── Auto-scroll (requestAnimationFrame for smoothness) ──
|
||||||
function usesPageScroll(): boolean {
|
function usesPageScroll(): boolean {
|
||||||
return window.matchMedia('(max-width: 1024px)').matches && !autoScrollActive;
|
return window.matchMedia('(max-width: 1024px)').matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
function startAutoScroll() {
|
function startAutoScroll() {
|
||||||
@@ -323,20 +394,15 @@
|
|||||||
if (!usesPageScroll() && !articleListEl) return;
|
if (!usesPageScroll() && !articleListEl) return;
|
||||||
autoScrollActive = true;
|
autoScrollActive = true;
|
||||||
lastScrollTs = 0;
|
lastScrollTs = 0;
|
||||||
scrollCarry = 0;
|
|
||||||
lastAutoCheckTs = 0;
|
|
||||||
if (usesPageScroll()) {
|
if (usesPageScroll()) {
|
||||||
mobileAutoStartScrollY = window.scrollY;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
articleListEl?.scrollTo({ top: mobileAutoStartScrollY, behavior: 'auto' });
|
|
||||||
});
|
|
||||||
scrollInterval = setInterval(() => {
|
scrollInterval = setInterval(() => {
|
||||||
if (!autoScrollActive) return;
|
if (!autoScrollActive) return;
|
||||||
if (!articleListEl) return;
|
const scroller = document.scrollingElement;
|
||||||
|
if (!scroller) return;
|
||||||
const delta = Math.max(1, Math.round(4 * autoScrollSpeed));
|
const delta = Math.max(1, Math.round(4 * autoScrollSpeed));
|
||||||
const nextY = articleListEl.scrollTop + delta;
|
const nextY = scroller.scrollTop + delta;
|
||||||
const maxY = Math.max(0, articleListEl.scrollHeight - articleListEl.clientHeight);
|
const maxY = Math.max(0, scroller.scrollHeight - window.innerHeight);
|
||||||
articleListEl.scrollTop = Math.min(nextY, maxY);
|
scroller.scrollTop = Math.min(nextY, maxY);
|
||||||
checkScrolledCards();
|
checkScrolledCards();
|
||||||
if (nextY >= maxY - 1) {
|
if (nextY >= maxY - 1) {
|
||||||
stopAutoScroll();
|
stopAutoScroll();
|
||||||
@@ -349,17 +415,12 @@
|
|||||||
const dt = lastScrollTs ? Math.min(34, timestamp - lastScrollTs) : 16;
|
const dt = lastScrollTs ? Math.min(34, timestamp - lastScrollTs) : 16;
|
||||||
lastScrollTs = timestamp;
|
lastScrollTs = timestamp;
|
||||||
const pxPerSecond = 72 * autoScrollSpeed;
|
const pxPerSecond = 72 * autoScrollSpeed;
|
||||||
scrollCarry += pxPerSecond * (dt / 1000);
|
const delta = pxPerSecond * (dt / 1000);
|
||||||
const delta = Math.max(1, Math.round(scrollCarry));
|
|
||||||
scrollCarry -= delta;
|
|
||||||
|
|
||||||
if (!articleListEl) return;
|
if (!articleListEl) return;
|
||||||
articleListEl.scrollTop += delta;
|
articleListEl.scrollTop += delta;
|
||||||
const maxScroll = articleListEl.scrollHeight - articleListEl.clientHeight;
|
const maxScroll = articleListEl.scrollHeight - articleListEl.clientHeight;
|
||||||
if (!lastAutoCheckTs || timestamp - lastAutoCheckTs > 220) {
|
|
||||||
lastAutoCheckTs = timestamp;
|
|
||||||
checkScrolledCards();
|
checkScrolledCards();
|
||||||
}
|
|
||||||
if (articleListEl.scrollTop >= maxScroll - 1) {
|
if (articleListEl.scrollTop >= maxScroll - 1) {
|
||||||
stopAutoScroll();
|
stopAutoScroll();
|
||||||
return;
|
return;
|
||||||
@@ -370,21 +431,10 @@
|
|||||||
scrollRAF = requestAnimationFrame(step);
|
scrollRAF = requestAnimationFrame(step);
|
||||||
}
|
}
|
||||||
function stopAutoScroll() {
|
function stopAutoScroll() {
|
||||||
const restorePageScroll = window.matchMedia('(max-width: 1024px)').matches && articleListEl
|
|
||||||
? articleListEl.scrollTop
|
|
||||||
: null;
|
|
||||||
autoScrollActive = false;
|
autoScrollActive = false;
|
||||||
lastScrollTs = 0;
|
lastScrollTs = 0;
|
||||||
scrollCarry = 0;
|
|
||||||
lastAutoCheckTs = 0;
|
|
||||||
if (scrollRAF) { cancelAnimationFrame(scrollRAF); scrollRAF = null; }
|
if (scrollRAF) { cancelAnimationFrame(scrollRAF); scrollRAF = null; }
|
||||||
if (scrollInterval) { clearInterval(scrollInterval); scrollInterval = null; }
|
if (scrollInterval) { clearInterval(scrollInterval); scrollInterval = null; }
|
||||||
commitStagedAutoReads();
|
|
||||||
if (restorePageScroll !== null) {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
window.scrollTo({ top: restorePageScroll, behavior: 'auto' });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function toggleAutoScroll() {
|
function toggleAutoScroll() {
|
||||||
if (autoScrollActive) stopAutoScroll();
|
if (autoScrollActive) stopAutoScroll();
|
||||||
@@ -431,9 +481,12 @@
|
|||||||
|
|
||||||
const cards = articleListEl.querySelectorAll('[data-entry-id]');
|
const cards = articleListEl.querySelectorAll('[data-entry-id]');
|
||||||
if (usesPageScroll()) {
|
if (usesPageScroll()) {
|
||||||
const pageBottom = window.scrollY + window.innerHeight;
|
// Check if the last card is near the viewport bottom
|
||||||
const loadThreshold = document.documentElement.scrollHeight - 500;
|
const lastCard = cards.length ? cards[cards.length - 1] : null;
|
||||||
if (hasMore && !loadingMore && pageBottom >= loadThreshold) {
|
const nearBottom = lastCard
|
||||||
|
? lastCard.getBoundingClientRect().top < window.innerHeight + 800
|
||||||
|
: (window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 500);
|
||||||
|
if (hasMore && !loadingMore && nearBottom) {
|
||||||
loadEntries(true);
|
loadEntries(true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -455,49 +508,20 @@
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
const article = articles.find(a => a.id === id);
|
const article = articles.find(a => a.id === id);
|
||||||
if (article && !article.read) {
|
if (article && !article.read) {
|
||||||
if (autoScrollActive) {
|
|
||||||
if (!stagedAutoReadIds.has(id)) {
|
|
||||||
stagedAutoReadIds.add(id);
|
|
||||||
newlyRead++;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
article.read = true;
|
article.read = true;
|
||||||
pendingReadIds.push(id);
|
pendingReadIds.push(id);
|
||||||
newlyRead++;
|
newlyRead++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (newlyRead > 0) {
|
if (newlyRead > 0) {
|
||||||
if (!autoScrollActive) {
|
|
||||||
articles = [...articles];
|
articles = [...articles];
|
||||||
decrementUnread(newlyRead);
|
decrementUnread(newlyRead);
|
||||||
if (flushTimer) clearTimeout(flushTimer);
|
if (flushTimer) clearTimeout(flushTimer);
|
||||||
flushTimer = setTimeout(flushPendingReads, 1000);
|
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) {
|
|
||||||
decrementUnread(newlyRead);
|
|
||||||
pendingReadIds.push(...ids);
|
|
||||||
if (flushTimer) clearTimeout(flushTimer);
|
|
||||||
flushTimer = setTimeout(flushPendingReads, 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function flushPendingReads() {
|
async function flushPendingReads() {
|
||||||
if (!pendingReadIds.length) return;
|
if (!pendingReadIds.length) return;
|
||||||
@@ -520,7 +544,6 @@
|
|||||||
if (flushTimer) clearTimeout(flushTimer);
|
if (flushTimer) clearTimeout(flushTimer);
|
||||||
if (scrollCheckTimer) clearTimeout(scrollCheckTimer);
|
if (scrollCheckTimer) clearTimeout(scrollCheckTimer);
|
||||||
if (scrollInterval) clearInterval(scrollInterval);
|
if (scrollInterval) clearInterval(scrollInterval);
|
||||||
commitStagedAutoReads();
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -566,7 +589,32 @@
|
|||||||
<div class="sidebar-separator"></div>
|
<div class="sidebar-separator"></div>
|
||||||
|
|
||||||
<div class="feeds-section">
|
<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}
|
{#each feedCategories as cat, i}
|
||||||
<div class="feed-category">
|
<div class="feed-category">
|
||||||
<button class="category-toggle" onclick={() => toggleCategory(i)}>
|
<button class="category-toggle" onclick={() => toggleCategory(i)}>
|
||||||
@@ -577,12 +625,22 @@
|
|||||||
{#if cat.expanded}
|
{#if cat.expanded}
|
||||||
<div class="feed-list">
|
<div class="feed-list">
|
||||||
{#each cat.feeds as feed}
|
{#each cat.feeds as feed}
|
||||||
|
<div class="feed-item-row">
|
||||||
<button class="feed-item" onclick={() => selectFeed(feed.id || 0)}>
|
<button class="feed-item" onclick={() => selectFeed(feed.id || 0)}>
|
||||||
<span class="feed-name">{feed.name}</span>
|
<span class="feed-name">{feed.name}</span>
|
||||||
{#if feed.count > 0}
|
{#if feed.count > 0}
|
||||||
<span class="feed-count">{feed.count}</span>
|
<span class="feed-count">{feed.count}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</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}
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -598,7 +656,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Middle Panel: Article List -->
|
<!-- Middle Panel: Article List -->
|
||||||
<div class="reader-list" class:auto-scrolling={autoScrollActive}>
|
<div class="reader-list">
|
||||||
<div class="list-header">
|
<div class="list-header">
|
||||||
<div class="list-header-top">
|
<div class="list-header-top">
|
||||||
<button class="mobile-menu" onclick={() => sidebarOpen = !sidebarOpen} aria-label="Toggle sidebar">
|
<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 -->
|
||||||
<!-- 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)}
|
{#each filteredArticles as article, index (article.id)}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
@@ -677,11 +735,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-header-right">
|
<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'}>
|
<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 savingToKarakeep.has(article.id)}
|
{#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>
|
<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}
|
{: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}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<span class="card-time">{article.timeAgo}</span>
|
<span class="card-time">{article.timeAgo}</span>
|
||||||
@@ -713,6 +771,11 @@
|
|||||||
{#if filteredArticles.length === 0}
|
{#if filteredArticles.length === 0}
|
||||||
<div class="list-empty">No articles to show</div>
|
<div class="list-empty">No articles to show</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if hasMore && !loading}
|
||||||
|
<button class="load-more-btn" onclick={() => loadEntries(true)} disabled={loadingMore}>
|
||||||
|
{loadingMore ? 'Loading...' : 'Load more articles'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -737,11 +800,11 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="pane-actions">
|
<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'}>
|
<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 savingToKarakeep.has(selectedArticle.id)}
|
{#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>
|
<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}
|
{: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}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<button class="pane-action-btn" onclick={() => toggleRead(selectedArticle!)} title="Toggle read (m)">
|
<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; }
|
.sidebar-separator { height: 1px; background: rgba(35,26,17,0.08); margin: 12px 18px; }
|
||||||
|
|
||||||
.feeds-section { padding: 0 12px; flex: 1; }
|
.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; }
|
.feed-category { margin-bottom: 1px; }
|
||||||
.category-toggle {
|
.category-toggle {
|
||||||
display: flex; align-items: center; gap: 5px; width: 100%;
|
display: flex; align-items: center; gap: 5px; width: 100%;
|
||||||
@@ -926,6 +1031,23 @@
|
|||||||
gap: 2px;
|
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 {
|
.mobile-scroll-fab {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -1073,10 +1195,10 @@
|
|||||||
}
|
}
|
||||||
.pane-action-btn:hover { color: #1f1811; background: rgba(255,250,244,0.92); }
|
.pane-action-btn:hover { color: #1f1811; background: rgba(255,250,244,0.92); }
|
||||||
.pane-action-btn.active { color: var(--accent); }
|
.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-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 { margin-bottom: 18px; }
|
||||||
.pane-hero-image {
|
.pane-hero-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1094,9 +1216,12 @@
|
|||||||
.pane-source { font-weight: 600; color: #1f1811; }
|
.pane-source { font-weight: 600; color: #1f1811; }
|
||||||
.pane-author { font-style: italic; }
|
.pane-author { font-style: italic; }
|
||||||
.pane-dot { width: 3px; height: 3px; border-radius: 50%; background: #8a7a68; }
|
.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(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(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) { color: var(--accent); text-decoration: underline; }
|
||||||
.pane-body :global(a:hover) { opacity: 0.8; }
|
.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; }
|
.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;
|
position: relative;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
height: auto;
|
height: auto;
|
||||||
min-height: calc(100vh - 56px);
|
min-height: 100vh;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1157,53 +1282,43 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 768px) {
|
@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 { 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; }
|
.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; }
|
.mobile-menu { display: flex; }
|
||||||
.reader-list {
|
.reader-list {
|
||||||
background:
|
background: transparent;
|
||||||
linear-gradient(180deg, rgba(245, 237, 227, 0.96) 0%, rgba(239, 230, 219, 0.94) 100%);
|
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.list-header {
|
.list-header {
|
||||||
padding: 14px 14px 10px;
|
padding: 8px 14px 8px;
|
||||||
position: relative;
|
position: relative;
|
||||||
background: rgba(244, 236, 226, 0.92);
|
background: transparent;
|
||||||
backdrop-filter: blur(14px);
|
backdrop-filter: none;
|
||||||
border-bottom: 1px solid rgba(35,26,17,0.08);
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
.list-header-top {
|
.list-header-top {
|
||||||
gap: 12px;
|
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-view-name { font-size: 1.44rem; }
|
||||||
.list-subtitle { font-size: 0.84rem; line-height: 1.4; }
|
.list-subtitle { font-size: 0.84rem; line-height: 1.4; }
|
||||||
.article-list {
|
.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;
|
gap: 8px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const gatewayUrl = env.GATEWAY_URL || 'http://localhost:8100';
|
|||||||
|
|
||||||
export const load: LayoutServerLoad = async ({ cookies, url }) => {
|
export const load: LayoutServerLoad = async ({ cookies, url }) => {
|
||||||
const host = url.host.toLowerCase();
|
const host = url.host.toLowerCase();
|
||||||
const useAtelierShell = host.includes(':4174') || host.startsWith('test.');
|
const useAtelierShell = true;
|
||||||
const session = cookies.get('platform_session');
|
const session = cookies.get('platform_session');
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw redirect(302, `/login?redirect=${encodeURIComponent(url.pathname)}`);
|
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.
|
// 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 allApps = ['tasks', 'trips', 'fitness', 'inventory', 'budget', 'reader', 'media', 'brain'];
|
||||||
const hiddenByUser: Record<string, string[]> = {
|
const hiddenByUser: Record<string, string[]> = {
|
||||||
'madiha': ['inventory', 'reader'],
|
'madiha': ['inventory', 'reader', 'brain'],
|
||||||
};
|
};
|
||||||
const hidden = hiddenByUser[data.user.username] || [];
|
const hidden = hiddenByUser[data.user.username] || [];
|
||||||
const visibleApps = allApps.filter(a => !hidden.includes(a));
|
const visibleApps = allApps.filter(a => !hidden.includes(a));
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
let assistantEntryDate = $state<string | null>(null);
|
let assistantEntryDate = $state<string | null>(null);
|
||||||
const visibleApps = data?.visibleApps || ['trips', 'fitness', 'inventory', 'budget', 'reader', 'media'];
|
const visibleApps = data?.visibleApps || ['trips', 'fitness', 'inventory', 'budget', 'reader', 'media'];
|
||||||
const userName = data?.user?.display_name || '';
|
const userName = data?.user?.display_name || '';
|
||||||
|
const assistantBrainEnabled = data?.user?.username !== 'madiha';
|
||||||
const useAtelierShell = data?.useAtelierShell || false;
|
const useAtelierShell = data?.useAtelierShell || false;
|
||||||
|
|
||||||
function openCommand() {
|
function openCommand() {
|
||||||
@@ -40,7 +41,7 @@
|
|||||||
<AppShell onOpenCommand={openCommand} {visibleApps} {userName}>
|
<AppShell onOpenCommand={openCommand} {visibleApps} {userName}>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
<FitnessAssistantDrawer bind:open={commandOpen} onclose={closeCommand} entryDate={assistantEntryDate} />
|
<FitnessAssistantDrawer bind:open={commandOpen} onclose={closeCommand} entryDate={assistantEntryDate} allowBrain={assistantBrainEnabled} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<Navbar onOpenCommand={openCommand} {visibleApps} />
|
<Navbar onOpenCommand={openCommand} {visibleApps} />
|
||||||
|
|||||||
140
frontend-v2/src/routes/assistant/+server.ts
Normal file
140
frontend-v2/src/routes/assistant/+server.ts
Normal 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 || {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
799
frontend-v2/src/routes/assistant/brain/+server.ts
Normal file
799
frontend-v2/src/routes/assistant/brain/+server.ts
Normal 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)]
|
||||||
|
});
|
||||||
|
};
|
||||||
21
frontend-v2/static/manifest.json
Normal file
21
frontend-v2/static/manifest.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ WORKDIR /app
|
|||||||
RUN pip install --no-cache-dir bcrypt
|
RUN pip install --no-cache-dir bcrypt
|
||||||
RUN adduser --disabled-password --no-create-home appuser
|
RUN adduser --disabled-password --no-create-home appuser
|
||||||
RUN mkdir -p /app/data && chown -R appuser /app/data
|
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/
|
COPY --chown=appuser integrations/ ./integrations/
|
||||||
EXPOSE 8100
|
EXPOSE 8100
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|||||||
1580
gateway/assistant.py
Normal file
1580
gateway/assistant.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ INVENTORY_URL = os.environ.get("INVENTORY_BACKEND_URL", "http://localhost:4499")
|
|||||||
NOCODB_API_TOKEN = os.environ.get("NOCODB_API_TOKEN", "")
|
NOCODB_API_TOKEN = os.environ.get("NOCODB_API_TOKEN", "")
|
||||||
MINIFLUX_URL = os.environ.get("MINIFLUX_URL", "http://localhost:8767")
|
MINIFLUX_URL = os.environ.get("MINIFLUX_URL", "http://localhost:8767")
|
||||||
MINIFLUX_API_KEY = os.environ.get("MINIFLUX_API_KEY", "")
|
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", "")
|
TRIPS_API_TOKEN = os.environ.get("TRIPS_API_TOKEN", "")
|
||||||
SHELFMARK_URL = os.environ.get("SHELFMARK_URL", "http://shelfmark:8084")
|
SHELFMARK_URL = os.environ.get("SHELFMARK_URL", "http://shelfmark:8084")
|
||||||
SPOTIZERR_URL = os.environ.get("SPOTIZERR_URL", "http://spotizerr-app:7171")
|
SPOTIZERR_URL = os.environ.get("SPOTIZERR_URL", "http://spotizerr-app:7171")
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import bcrypt
|
|||||||
|
|
||||||
from config import (
|
from config import (
|
||||||
DB_PATH, TRIPS_URL, FITNESS_URL, INVENTORY_URL,
|
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
|
# Ensure reader app exists
|
||||||
rdr = c.execute("SELECT id FROM apps WHERE id = 'reader'").fetchone()
|
rdr = c.execute("SELECT id FROM apps WHERE id = 'reader'").fetchone()
|
||||||
if not rdr:
|
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()
|
conn.commit()
|
||||||
print("[Gateway] Added reader app")
|
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)
|
# Ensure books app exists (now media)
|
||||||
books = c.execute("SELECT id FROM apps WHERE id = 'books'").fetchone()
|
books = c.execute("SELECT id FROM apps WHERE id = 'books'").fetchone()
|
||||||
|
|||||||
@@ -90,8 +90,15 @@ def handle_send_to_kindle(handler, book_id: str, body: bytes):
|
|||||||
title = meta.get("title", "Book") if meta else "Book"
|
title = meta.get("title", "Book") if meta else "Book"
|
||||||
author = ", ".join(meta.get("authors", [])) if meta else ""
|
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()
|
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")
|
file_b64 = base64.b64encode(file_data).decode("ascii")
|
||||||
filename = file_path.name
|
filename = file_path.name
|
||||||
|
|
||||||
@@ -133,7 +140,9 @@ def handle_send_to_kindle(handler, book_id: str, body: bytes):
|
|||||||
"size": len(file_data),
|
"size": len(file_data),
|
||||||
})
|
})
|
||||||
else:
|
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:
|
except Exception as e:
|
||||||
handler._send_json({"error": f"SMTP2GO error: {str(e)}"}, 500)
|
handler._send_json({"error": f"SMTP2GO error: {str(e)}"}, 500)
|
||||||
|
|
||||||
@@ -175,6 +184,13 @@ def handle_send_file_to_kindle(handler, body: bytes):
|
|||||||
return
|
return
|
||||||
|
|
||||||
file_data = file_path.read_bytes()
|
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")
|
file_b64 = base64.b64encode(file_data).decode("ascii")
|
||||||
|
|
||||||
ext = file_path.suffix.lower()
|
ext = file_path.suffix.lower()
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ def resolve_service(path):
|
|||||||
remainder = "/" + "/".join(parts[3:]) if len(parts) > 3 else "/"
|
remainder = "/" + "/".join(parts[3:]) if len(parts) > 3 else "/"
|
||||||
# Services that don't use /api prefix (Express apps, etc.)
|
# Services that don't use /api prefix (Express apps, etc.)
|
||||||
NO_API_PREFIX_SERVICES = {"inventory", "music", "budget"}
|
NO_API_PREFIX_SERVICES = {"inventory", "music", "budget"}
|
||||||
SERVICE_PATH_PREFIX = {"reader": "/v1"}
|
SERVICE_PATH_PREFIX = {}
|
||||||
if service_id in SERVICE_PATH_PREFIX:
|
if service_id in SERVICE_PATH_PREFIX:
|
||||||
backend_path = f"{SERVICE_PATH_PREFIX[service_id]}{remainder}"
|
backend_path = f"{SERVICE_PATH_PREFIX[service_id]}{remainder}"
|
||||||
elif service_id in NO_API_PREFIX_SERVICES:
|
elif service_id in NO_API_PREFIX_SERVICES:
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from dashboard import (
|
|||||||
handle_set_connection, handle_pin, handle_unpin, handle_get_pinned,
|
handle_set_connection, handle_pin, handle_unpin, handle_get_pinned,
|
||||||
)
|
)
|
||||||
from command import handle_command
|
from command import handle_command
|
||||||
|
from assistant import handle_assistant, handle_fitness_assistant, handle_brain_assistant
|
||||||
from integrations.booklore import (
|
from integrations.booklore import (
|
||||||
handle_booklore_libraries, handle_booklore_import,
|
handle_booklore_libraries, handle_booklore_import,
|
||||||
handle_booklore_books, handle_booklore_cover,
|
handle_booklore_books, handle_booklore_cover,
|
||||||
@@ -242,6 +243,24 @@ class GatewayHandler(ResponseMixin, BaseHTTPRequestHandler):
|
|||||||
handle_command(self, user, body)
|
handle_command(self, user, body)
|
||||||
return
|
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/"):
|
if path.startswith("/api/"):
|
||||||
self._proxy("POST", path, body)
|
self._proxy("POST", path, body)
|
||||||
return
|
return
|
||||||
@@ -290,8 +309,10 @@ class GatewayHandler(ResponseMixin, BaseHTTPRequestHandler):
|
|||||||
headers["Content-Type"] = ct
|
headers["Content-Type"] = ct
|
||||||
|
|
||||||
# Inject service-level auth
|
# Inject service-level auth
|
||||||
if service_id == "reader" and MINIFLUX_API_KEY:
|
if service_id == "reader":
|
||||||
headers["X-Auth-Token"] = MINIFLUX_API_KEY
|
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:
|
elif service_id == "trips" and TRIPS_API_TOKEN:
|
||||||
headers["Authorization"] = f"Bearer {TRIPS_API_TOKEN}"
|
headers["Authorization"] = f"Bearer {TRIPS_API_TOKEN}"
|
||||||
elif service_id == "inventory" and INVENTORY_SERVICE_API_KEY:
|
elif service_id == "inventory" and INVENTORY_SERVICE_API_KEY:
|
||||||
|
|||||||
467
ios/Platform/Platform.xcodeproj/project.pbxproj
Normal file
467
ios/Platform/Platform.xcodeproj/project.pbxproj
Normal 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 */;
|
||||||
|
}
|
||||||
7
ios/Platform/Platform.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
ios/Platform/Platform.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
6
ios/Platform/Platform/Assets.xcassets/Contents.json
Normal file
6
ios/Platform/Platform/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
5
ios/Platform/Platform/Config.swift
Normal file
5
ios/Platform/Platform/Config.swift
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum Config {
|
||||||
|
static let gatewayURL = "https://dash.quadjourney.com"
|
||||||
|
}
|
||||||
43
ios/Platform/Platform/ContentView.swift
Normal file
43
ios/Platform/Platform/ContentView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
145
ios/Platform/Platform/Core/APIClient.swift
Normal file
145
ios/Platform/Platform/Core/APIClient.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
104
ios/Platform/Platform/Core/AuthManager.swift
Normal file
104
ios/Platform/Platform/Core/AuthManager.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
285
ios/Platform/Platform/Features/Assistant/AssistantChatView.swift
Normal file
285
ios/Platform/Platform/Features/Assistant/AssistantChatView.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
80
ios/Platform/Platform/Features/Auth/LoginView.swift
Normal file
80
ios/Platform/Platform/Features/Auth/LoginView.swift
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
56
ios/Platform/Platform/Features/Fitness/API/FitnessAPI.swift
Normal file
56
ios/Platform/Platform/Features/Fitness/API/FitnessAPI.swift
Normal 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
171
ios/Platform/Platform/Features/Fitness/Views/AddFoodSheet.swift
Normal file
171
ios/Platform/Platform/Features/Fitness/Views/AddFoodSheet.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
85
ios/Platform/Platform/Features/Fitness/Views/GoalsView.swift
Normal file
85
ios/Platform/Platform/Features/Fitness/Views/GoalsView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
103
ios/Platform/Platform/Features/Fitness/Views/TemplatesView.swift
Normal file
103
ios/Platform/Platform/Features/Fitness/Views/TemplatesView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
96
ios/Platform/Platform/Features/Fitness/Views/TodayView.swift
Normal file
96
ios/Platform/Platform/Features/Fitness/Views/TodayView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
161
ios/Platform/Platform/Features/Home/HomeView.swift
Normal file
161
ios/Platform/Platform/Features/Home/HomeView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
ios/Platform/Platform/Features/Home/HomeViewModel.swift
Normal file
54
ios/Platform/Platform/Features/Home/HomeViewModel.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
ios/Platform/Platform/Info.plist
Normal file
10
ios/Platform/Platform/Info.plist
Normal 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>
|
||||||
13
ios/Platform/Platform/PlatformApp.swift
Normal file
13
ios/Platform/Platform/PlatformApp.swift
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct PlatformApp: App {
|
||||||
|
@State private var authManager = AuthManager()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.environment(authManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
ios/Platform/Platform/Shared/Components/LoadingView.swift
Normal file
66
ios/Platform/Platform/Shared/Components/LoadingView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
44
ios/Platform/Platform/Shared/Components/MacroBar.swift
Normal file
44
ios/Platform/Platform/Shared/Components/MacroBar.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
83
ios/Platform/Platform/Shared/Components/MacroRing.swift
Normal file
83
ios/Platform/Platform/Shared/Components/MacroRing.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@ FROM python:3.12-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
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 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
|
RUN pip install --no-cache-dir --upgrade pip yt-dlp
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ from sqlalchemy.orm import selectinload
|
|||||||
|
|
||||||
from app.api.deps import get_user_id, get_db_session
|
from app.api.deps import get_user_id, get_db_session
|
||||||
from app.config import FOLDERS, TAGS
|
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 (
|
from app.models.schema import (
|
||||||
ItemCreate, ItemUpdate, ItemOut, ItemList, SearchQuery, SemanticSearchQuery,
|
ItemCreate, ItemUpdate, ItemOut, ItemList, SearchQuery, SemanticSearchQuery,
|
||||||
HybridSearchQuery, SearchResult, ConfigOut,
|
HybridSearchQuery, SearchResult, ConfigOut, ItemAdditionCreate, ItemAdditionOut,
|
||||||
)
|
)
|
||||||
from app.services.storage import storage
|
from app.services.storage import storage
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
@@ -25,6 +25,46 @@ from app.worker.tasks import enqueue_process_item
|
|||||||
router = APIRouter(prefix="/api", tags=["brain"])
|
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 ──
|
# ── Health ──
|
||||||
|
|
||||||
@router.get("/health")
|
@router.get("/health")
|
||||||
@@ -201,14 +241,31 @@ async def update_item(
|
|||||||
item.title = body.title
|
item.title = body.title
|
||||||
if body.folder is not None:
|
if body.folder is not None:
|
||||||
item.folder = body.folder
|
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:
|
if body.tags is not None:
|
||||||
item.tags = body.tags
|
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:
|
if body.raw_content is not None:
|
||||||
item.raw_content = body.raw_content
|
item.raw_content = body.raw_content
|
||||||
|
|
||||||
item.updated_at = datetime.utcnow()
|
item.updated_at = datetime.utcnow()
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(item)
|
await db.refresh(item)
|
||||||
|
await refresh_item_search_state(db, item)
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
@@ -238,6 +295,100 @@ async def delete_item(
|
|||||||
return {"status": "deleted"}
|
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 ──
|
# ── Reprocess item ──
|
||||||
|
|
||||||
@router.post("/items/{item_id}/reprocess", response_model=ItemOut)
|
@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(".jpg") or filename.endswith(".jpeg"): ct = "image/jpeg"
|
||||||
elif filename.endswith(".html"): ct = "text/html"
|
elif filename.endswith(".html"): ct = "text/html"
|
||||||
elif filename.endswith(".pdf"): ct = "application/pdf"
|
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"})
|
return Response(content=data, media_type=ct, headers={"Cache-Control": "public, max-age=3600"})
|
||||||
|
|||||||
@@ -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_KEY = os.environ.get("MEILI_MASTER_KEY", "brain-meili-key")
|
||||||
MEILI_INDEX = "items"
|
MEILI_INDEX = "items"
|
||||||
|
|
||||||
# ── Browserless ──
|
# ── Crawler ──
|
||||||
BROWSERLESS_URL = os.environ.get("BROWSERLESS_URL", "http://brain-browserless:3000")
|
CRAWLER_URL = os.environ.get("CRAWLER_URL", "http://brain-crawler:3100")
|
||||||
|
|
||||||
# ── OpenAI ──
|
# ── OpenAI ──
|
||||||
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
|
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
|
||||||
@@ -42,14 +42,14 @@ DEBUG = os.environ.get("DEBUG", "").lower() in ("1", "true")
|
|||||||
|
|
||||||
# ── Classification rules ──
|
# ── Classification rules ──
|
||||||
FOLDERS = [
|
FOLDERS = [
|
||||||
"Home", "Family", "Work", "Travel", "Knowledge", "Faith", "Projects"
|
"Home", "Family", "Work", "Travel", "Islam",
|
||||||
|
"Homelab", "Vanlife", "3D Printing", "Documents",
|
||||||
]
|
]
|
||||||
|
|
||||||
TAGS = [
|
TAGS = [
|
||||||
"reference", "important", "legal", "financial", "insurance",
|
"diy", "reference", "home-assistant", "shopping", "video",
|
||||||
"research", "idea", "guide", "tutorial", "setup", "how-to",
|
"tutorial", "server", "kids", "books", "travel",
|
||||||
"tools", "dev", "server", "selfhosted", "home-assistant",
|
"churning", "lawn-garden", "piracy", "work", "3d-printing",
|
||||||
"shopping", "compare", "buy", "product",
|
"lectures", "vanlife", "yusuf", "madiha", "hafsa", "mustafa",
|
||||||
"family", "kids", "health", "travel", "faith",
|
"medical", "legal", "vehicle", "insurance", "financial", "homeschool",
|
||||||
"video", "read-later", "books",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ app.include_router(taxonomy_router)
|
|||||||
async def startup():
|
async def startup():
|
||||||
from sqlalchemy import text as sa_text
|
from sqlalchemy import text as sa_text
|
||||||
from app.database import engine, Base
|
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
|
from app.models.taxonomy import Folder, Tag, ItemTag # noqa: register taxonomy tables
|
||||||
|
|
||||||
# Enable pgvector extension before creating tables
|
# Enable pgvector extension before creating tables
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ class Item(Base):
|
|||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
assets = relationship("ItemAsset", back_populates="item", cascade="all, delete-orphan")
|
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__ = (
|
__table_args__ = (
|
||||||
Index("ix_items_user_status", "user_id", "processing_status"),
|
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 = Column(String(64), nullable=False) # trips|tasks|fitness|inventory
|
||||||
app_entity_id = Column(String(128), nullable=False)
|
app_entity_id = Column(String(128), nullable=False)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow, 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")
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ class ItemUpdate(BaseModel):
|
|||||||
raw_content: Optional[str] = None
|
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):
|
class SearchQuery(BaseModel):
|
||||||
q: str
|
q: str
|
||||||
folder: Optional[str] = None
|
folder: Optional[str] = None
|
||||||
@@ -63,6 +70,19 @@ class AssetOut(BaseModel):
|
|||||||
model_config = {"from_attributes": True}
|
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):
|
class ItemOut(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
type: str
|
type: str
|
||||||
|
|||||||
@@ -74,19 +74,20 @@ DEFAULT_FOLDERS = [
|
|||||||
{"name": "Family", "color": "#D97706", "icon": "heart"},
|
{"name": "Family", "color": "#D97706", "icon": "heart"},
|
||||||
{"name": "Work", "color": "#4338CA", "icon": "briefcase"},
|
{"name": "Work", "color": "#4338CA", "icon": "briefcase"},
|
||||||
{"name": "Travel", "color": "#0EA5E9", "icon": "plane"},
|
{"name": "Travel", "color": "#0EA5E9", "icon": "plane"},
|
||||||
{"name": "Knowledge", "color": "#8B5CF6", "icon": "book-open"},
|
{"name": "Islam", "color": "#10B981", "icon": "moon"},
|
||||||
{"name": "Faith", "color": "#10B981", "icon": "moon"},
|
{"name": "Homelab", "color": "#6366F1", "icon": "server"},
|
||||||
{"name": "Projects", "color": "#F43F5E", "icon": "folder"},
|
{"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 to seed for new users
|
||||||
DEFAULT_TAGS = [
|
DEFAULT_TAGS = [
|
||||||
"reference", "important", "legal", "financial", "insurance",
|
"diy", "reference", "home-assistant", "shopping", "video",
|
||||||
"research", "idea", "guide", "tutorial", "setup", "how-to",
|
"tutorial", "server", "kids", "books", "travel",
|
||||||
"tools", "dev", "server", "selfhosted", "home-assistant",
|
"churning", "lawn-garden", "piracy", "work", "3d-printing",
|
||||||
"shopping", "compare", "buy", "product",
|
"lectures", "vanlife", "yusuf", "madiha", "hafsa", "mustafa",
|
||||||
"family", "kids", "health", "travel", "faith",
|
"medical", "legal", "vehicle", "insurance", "financial", "homeschool",
|
||||||
"video", "read-later", "books",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,20 +9,61 @@ from app.config import OPENAI_API_KEY, OPENAI_MODEL
|
|||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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:
|
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.
|
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:
|
Given an item (URL, note, document, or file), you must return structured JSON with:
|
||||||
- folder: exactly 1 from this list: {json.dumps(folders)}
|
- folder: exactly 1 from this list: {json.dumps(folders)}
|
||||||
- tags: exactly 2 or 3 from this list: {json.dumps(tags)}
|
- 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 (max 80 chars)
|
- 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)
|
- 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.
|
- 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
|
- 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:
|
Rules:
|
||||||
- NEVER invent folders or tags not in the lists above
|
- 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 skip classification
|
||||||
- NEVER return freeform text outside the schema
|
- NEVER return freeform text outside the schema
|
||||||
- For notes: do NOT summarize. Keep the original text. Only fix spelling.
|
- 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": {
|
"tags": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {"type": "string", "enum": tags},
|
"items": {"type": "string", "enum": tags},
|
||||||
"minItems": 2,
|
"minItems": 0,
|
||||||
"maxItems": 3,
|
"maxItems": 3,
|
||||||
},
|
},
|
||||||
"title": {"type": "string"},
|
"title": {"type": "string"},
|
||||||
@@ -88,8 +129,8 @@ async def classify_item(
|
|||||||
if not OPENAI_API_KEY:
|
if not OPENAI_API_KEY:
|
||||||
log.warning("No OPENAI_API_KEY set, returning defaults")
|
log.warning("No OPENAI_API_KEY set, returning defaults")
|
||||||
return {
|
return {
|
||||||
"folder": "Knowledge",
|
"folder": "Home",
|
||||||
"tags": ["reference", "read-later"],
|
"tags": ["reference"],
|
||||||
"title": title or "Untitled",
|
"title": title or "Untitled",
|
||||||
"summary": "No AI classification available",
|
"summary": "No AI classification available",
|
||||||
"confidence": 0.0,
|
"confidence": 0.0,
|
||||||
@@ -122,10 +163,8 @@ async def classify_item(
|
|||||||
|
|
||||||
# Validate folder and tags are in allowed sets
|
# Validate folder and tags are in allowed sets
|
||||||
if result["folder"] not in folders:
|
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]
|
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
|
return result
|
||||||
|
|
||||||
@@ -133,8 +172,8 @@ async def classify_item(
|
|||||||
log.error(f"Classification attempt {attempt + 1} failed: {e}")
|
log.error(f"Classification attempt {attempt + 1} failed: {e}")
|
||||||
if attempt == retries:
|
if attempt == retries:
|
||||||
return {
|
return {
|
||||||
"folder": "Knowledge",
|
"folder": "Home",
|
||||||
"tags": ["reference", "read-later"],
|
"tags": ["reference"],
|
||||||
"title": title or "Untitled",
|
"title": title or "Untitled",
|
||||||
"summary": f"Classification failed: {e}",
|
"summary": f"Classification failed: {e}",
|
||||||
"confidence": 0.0,
|
"confidence": 0.0,
|
||||||
|
|||||||
@@ -1,162 +1,218 @@
|
|||||||
"""Content ingestion — fetch, extract, screenshot, archive."""
|
"""Content ingestion — Playwright crawler for HTML, screenshots, og:image."""
|
||||||
|
|
||||||
|
import base64
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import uuid
|
|
||||||
from html.parser import HTMLParser
|
|
||||||
from io import StringIO
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.config import BROWSERLESS_URL
|
from app.config import CRAWLER_URL
|
||||||
from app.services.storage import storage
|
from app.services.storage import storage
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class _HTMLTextExtractor(HTMLParser):
|
# ── YouTube helpers ──
|
||||||
"""Simple HTML to text converter."""
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self._result = StringIO()
|
|
||||||
self._skip = False
|
|
||||||
self._skip_tags = {"script", "style", "noscript", "svg"}
|
|
||||||
|
|
||||||
def handle_starttag(self, tag, attrs):
|
def _extract_youtube_id(url: str) -> str | None:
|
||||||
if tag in self._skip_tags:
|
patterns = [
|
||||||
self._skip = True
|
r'(?:youtube\.com/watch\?.*v=|youtu\.be/|youtube\.com/shorts/|youtube\.com/embed/)([a-zA-Z0-9_-]{11})',
|
||||||
|
]
|
||||||
def handle_endtag(self, tag):
|
for pat in patterns:
|
||||||
if tag in self._skip_tags:
|
m = re.search(pat, url)
|
||||||
self._skip = False
|
if m:
|
||||||
if tag in ("p", "div", "br", "h1", "h2", "h3", "h4", "li", "tr"):
|
return m.group(1)
|
||||||
self._result.write("\n")
|
return None
|
||||||
|
|
||||||
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 html_to_text(html: str) -> str:
|
def _is_youtube_url(url: str) -> bool:
|
||||||
extractor = _HTMLTextExtractor()
|
return bool(_extract_youtube_id(url))
|
||||||
extractor.feed(html)
|
|
||||||
return extractor.get_text()
|
|
||||||
|
|
||||||
|
|
||||||
def extract_title_from_html(html: str) -> str | None:
|
async def fetch_youtube_metadata(url: str) -> dict | None:
|
||||||
match = re.search(r"<title[^>]*>(.*?)</title>", html, re.IGNORECASE | re.DOTALL)
|
"""Fetch YouTube video metadata via oEmbed. No API key needed."""
|
||||||
return match.group(1).strip() if match else None
|
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:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
resp = await client.get(url, headers={
|
oembed_url = f"https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v={video_id}&format=json"
|
||||||
"User-Agent": "Mozilla/5.0 (compatible; SecondBrain/1.0)"
|
resp = await client.get(oembed_url)
|
||||||
})
|
if resp.status_code == 200:
|
||||||
resp.raise_for_status()
|
data = resp.json()
|
||||||
html = resp.text
|
result["title"] = data.get("title")
|
||||||
result["html"] = html
|
result["author"] = data.get("author_name")
|
||||||
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
|
|
||||||
|
|
||||||
|
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:
|
except Exception as e:
|
||||||
log.warning(f"HTTP fetch failed for {url}: {e}, trying browserless")
|
log.warning(f"YouTube metadata fetch failed: {e}")
|
||||||
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}")
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def fetch_with_browserless(url: str) -> dict | None:
|
async def download_youtube_thumbnail(url: str, item_id: str) -> str | None:
|
||||||
"""Use browserless/chrome to render JS-heavy pages."""
|
"""Download YouTube thumbnail and save as screenshot asset."""
|
||||||
try:
|
video_id = _extract_youtube_id(url)
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
if not video_id:
|
||||||
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),
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"Browserless fetch failed: {e}")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
urls_to_try = [
|
||||||
async def take_screenshot(url: str, item_id: str) -> str | None:
|
f"https://img.youtube.com/vi/{video_id}/maxresdefault.jpg",
|
||||||
"""Take a screenshot of a URL using browserless. Returns storage path or None."""
|
f"https://img.youtube.com/vi/{video_id}/hqdefault.jpg",
|
||||||
|
]
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
resp = await client.post(
|
for thumb_url in urls_to_try:
|
||||||
f"{BROWSERLESS_URL}/screenshot",
|
resp = await client.get(thumb_url)
|
||||||
json={
|
if resp.status_code == 200 and len(resp.content) > 1000:
|
||||||
"url": url,
|
|
||||||
"options": {"type": "png", "fullPage": False},
|
|
||||||
"waitForTimeout": 3000,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if resp.status_code == 200:
|
|
||||||
path = storage.save(
|
path = storage.save(
|
||||||
item_id=item_id,
|
item_id=item_id, asset_type="screenshot",
|
||||||
asset_type="screenshot",
|
filename="thumbnail.jpg", data=resp.content,
|
||||||
filename="screenshot.png",
|
|
||||||
data=resp.content,
|
|
||||||
)
|
)
|
||||||
return path
|
return path
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"Screenshot failed for {url}: {e}")
|
log.warning(f"YouTube thumbnail download failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
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("&", "&")
|
||||||
|
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="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.warning(f"og:image download failed: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def archive_html(html: str, item_id: str) -> str | 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:
|
if not html:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
path = storage.save(
|
path = storage.save(
|
||||||
item_id=item_id,
|
item_id=item_id, asset_type="archived_html",
|
||||||
asset_type="archived_html",
|
filename="page.html", data=html.encode("utf-8"),
|
||||||
filename="page.html",
|
|
||||||
data=html.encode("utf-8"),
|
|
||||||
)
|
)
|
||||||
return path
|
return path
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from sqlalchemy.orm import selectinload
|
|||||||
|
|
||||||
from app.config import REDIS_URL, DATABASE_URL_SYNC
|
from app.config import REDIS_URL, DATABASE_URL_SYNC
|
||||||
from app.models.item import Item, ItemAsset
|
from app.models.item import Item, ItemAsset
|
||||||
|
from app.models.taxonomy import Folder, Tag, ItemTag # noqa: F401 — register FK targets
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ async def _process_item(item_id: str):
|
|||||||
"""Full processing pipeline for a saved item."""
|
"""Full processing pipeline for a saved item."""
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||||
from app.config import DATABASE_URL
|
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.classify import classify_item
|
||||||
from app.services.embed import generate_embedding
|
from app.services.embed import generate_embedding
|
||||||
from app.search.engine import index_item, ensure_meili_index
|
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 ──
|
# ── Step 1: Fetch content for URLs ──
|
||||||
if item.type == "link" and item.url:
|
if item.type == "link" and item.url:
|
||||||
log.info(f"Fetching URL: {item.url}")
|
from app.services.ingest import (
|
||||||
content = await fetch_url_content(item.url)
|
_is_youtube_url, download_youtube_thumbnail,
|
||||||
html_content = content.get("html")
|
download_youtube_video, fetch_youtube_metadata,
|
||||||
extracted_text = content.get("text") or extracted_text
|
|
||||||
if not title:
|
|
||||||
title = content.get("title")
|
|
||||||
item.metadata_json = item.metadata_json or {}
|
|
||||||
item.metadata_json["description"] = content.get("description")
|
|
||||||
item.metadata_json["used_browserless"] = content.get("used_browserless", False)
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
item.metadata_json = item.metadata_json or {}
|
||||||
|
is_yt = _is_youtube_url(item.url)
|
||||||
|
|
||||||
|
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','')}"
|
||||||
|
|
||||||
|
# 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
|
# Archive HTML
|
||||||
if html_content:
|
if html_content:
|
||||||
html_path = await archive_html(html_content, item.id)
|
html_path = await archive_html(html_content, item.id)
|
||||||
if html_path:
|
if html_path:
|
||||||
asset = ItemAsset(
|
db.add(ItemAsset(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()), item_id=item.id,
|
||||||
item_id=item.id,
|
asset_type="archived_html", filename="page.html",
|
||||||
asset_type="archived_html",
|
content_type="text/html", storage_path=html_path,
|
||||||
filename="page.html",
|
))
|
||||||
content_type="text/html",
|
|
||||||
storage_path=html_path,
|
|
||||||
)
|
|
||||||
db.add(asset)
|
|
||||||
|
|
||||||
# ── Step 1b: Process uploaded files (PDF, image, document) ──
|
# ── Step 1b: Process uploaded files (PDF, image, document) ──
|
||||||
if item.type in ("pdf", "image", "document", "file"):
|
if item.type in ("pdf", "image", "document", "file"):
|
||||||
|
|||||||
20
services/brain/crawler/Dockerfile
Normal file
20
services/brain/crawler/Dockerfile
Normal 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"]
|
||||||
24
services/brain/crawler/package.json
Normal file
24
services/brain/crawler/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
370
services/brain/crawler/server.js
Normal file
370
services/brain/crawler/server.js
Normal 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(/&/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(/&/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(/&/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);
|
||||||
|
});
|
||||||
@@ -13,7 +13,7 @@ services:
|
|||||||
- REDIS_URL=redis://brain-redis:6379/0
|
- REDIS_URL=redis://brain-redis:6379/0
|
||||||
- MEILI_URL=http://brain-meili:7700
|
- MEILI_URL=http://brain-meili:7700
|
||||||
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY:-brain-meili-secure-key-2026}
|
- 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_API_KEY=${OPENAI_API_KEY}
|
||||||
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o-mini}
|
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o-mini}
|
||||||
- PORT=8200
|
- PORT=8200
|
||||||
@@ -44,7 +44,7 @@ services:
|
|||||||
- REDIS_URL=redis://brain-redis:6379/0
|
- REDIS_URL=redis://brain-redis:6379/0
|
||||||
- MEILI_URL=http://brain-meili:7700
|
- MEILI_URL=http://brain-meili:7700
|
||||||
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY:-brain-meili-secure-key-2026}
|
- 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_API_KEY=${OPENAI_API_KEY}
|
||||||
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o-mini}
|
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o-mini}
|
||||||
- TZ=${TZ:-America/Chicago}
|
- TZ=${TZ:-America/Chicago}
|
||||||
@@ -90,14 +90,17 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./data/meili:/meili_data
|
- ./data/meili:/meili_data
|
||||||
|
|
||||||
# ── Browserless (headless Chrome for JS rendering + screenshots) ──
|
# ── Crawler (Playwright + stealth for JS rendering + screenshots) ──
|
||||||
brain-browserless:
|
brain-crawler:
|
||||||
image: ghcr.io/browserless/chromium:latest
|
build:
|
||||||
container_name: brain-browserless
|
context: ./crawler
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: brain-crawler
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- MAX_CONCURRENT_SESSIONS=3
|
- PORT=3100
|
||||||
- TIMEOUT=30000
|
- TZ=${TZ:-America/Chicago}
|
||||||
|
shm_size: '1gb'
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
pangolin:
|
pangolin:
|
||||||
|
|||||||
254
services/brain/migrate_karakeep.py
Normal file
254
services/brain/migrate_karakeep.py
Normal 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()
|
||||||
@@ -2035,6 +2035,24 @@ class CalorieHandler(BaseHTTPRequestHandler):
|
|||||||
if user:
|
if user:
|
||||||
return 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
|
return None
|
||||||
|
|
||||||
def _send_json(self, data, status=200):
|
def _send_json(self, data, status=200):
|
||||||
|
|||||||
22
services/reader/Dockerfile.api
Normal file
22
services/reader/Dockerfile.api
Normal 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"]
|
||||||
18
services/reader/Dockerfile.worker
Normal file
18
services/reader/Dockerfile.worker
Normal 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"]
|
||||||
0
services/reader/app/__init__.py
Normal file
0
services/reader/app/__init__.py
Normal file
0
services/reader/app/api/__init__.py
Normal file
0
services/reader/app/api/__init__.py
Normal file
49
services/reader/app/api/categories.py
Normal file
49
services/reader/app/api/categories.py
Normal 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
|
||||||
21
services/reader/app/api/deps.py
Normal file
21
services/reader/app/api/deps.py
Normal 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
|
||||||
264
services/reader/app/api/entries.py
Normal file
264
services/reader/app/api/entries.py
Normal 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)
|
||||||
242
services/reader/app/api/feeds.py
Normal file
242
services/reader/app/api/feeds.py
Normal 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"}
|
||||||
23
services/reader/app/config.py
Normal file
23
services/reader/app/config.py
Normal 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"))
|
||||||
18
services/reader/app/database.py
Normal file
18
services/reader/app/database.py
Normal 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
|
||||||
43
services/reader/app/main.py
Normal file
43
services/reader/app/main.py
Normal 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")
|
||||||
74
services/reader/app/models.py
Normal file
74
services/reader/app/models.py
Normal 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")
|
||||||
0
services/reader/app/worker/__init__.py
Normal file
0
services/reader/app/worker/__init__.py
Normal file
363
services/reader/app/worker/tasks.py
Normal file
363
services/reader/app/worker/tasks.py
Normal 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()
|
||||||
43
services/reader/docker-compose.yml
Normal file
43
services/reader/docker-compose.yml
Normal 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
|
||||||
11
services/reader/requirements.txt
Normal file
11
services/reader/requirements.txt
Normal 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
|
||||||
Reference in New Issue
Block a user