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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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