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

@@ -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;