fix: mobile nav full height + upload paste box, PDF zoom fit

- Mobile nav sheet: flex layout, bottom section pushed to bottom
- Upload paste box + file picker added to mobile nav drawer
- Date shown at bottom of mobile nav
- PDF viewer: added #zoom=page-fit to iframe URL

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-02 00:18:45 -05:00
parent 2ab27d048a
commit 5098545580
3 changed files with 142 additions and 32 deletions

View File

@@ -219,6 +219,22 @@
</a>
{/each}
</nav>
<div class="mobile-nav-bottom">
<div class="rail-upload-row">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="paste-box" contenteditable="true" onpaste={onPasteBox} role="textbox" tabindex="0">
{#if uploadStatus === 'uploading'}Uploading...{:else if uploadStatus === 'done'}Saved!{:else}Paste screenshot{/if}
</div>
<button class="upload-icon-btn" onclick={() => uploadInput?.click()} title="Browse files">
<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}
@@ -547,10 +563,17 @@
border-right: 1px solid var(--shell-line);
backdrop-filter: blur(20px);
z-index: 40;
display: grid;
align-content: start;
display: flex;
flex-direction: column;
gap: 18px;
}
.mobile-nav-list { flex: 1; }
.mobile-nav-bottom {
display: grid;
gap: 10px;
padding-top: 14px;
border-top: 1px solid var(--shell-line);
}
.mobile-nav-head {
display: flex;

View File

@@ -583,7 +583,7 @@
{@const pdfAsset = selectedItem.assets?.find(a => a.asset_type === 'original_upload')}
{#if pdfAsset}
<iframe
src="/api/brain/storage/{selectedItem.id}/original_upload/{pdfAsset.filename}"
src="/api/brain/storage/{selectedItem.id}/original_upload/{pdfAsset.filename}#zoom=page-fit"
title={selectedItem.title || 'PDF'}
class="viewer-iframe"
></iframe>

View File

@@ -27,13 +27,18 @@
let autoScrollSpeed = $state(1.5);
let articleListEl: HTMLDivElement;
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 {
@@ -309,51 +314,77 @@
// ── Auto-scroll (requestAnimationFrame for smoothness) ──
function usesPageScroll(): boolean {
return window.matchMedia('(max-width: 1024px)').matches;
return window.matchMedia('(max-width: 1024px)').matches && !autoScrollActive;
}
function startAutoScroll() {
if (scrollRAF) cancelAnimationFrame(scrollRAF);
if (scrollInterval) clearInterval(scrollInterval);
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 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);
checkScrolledCards();
if (nextY >= maxY - 1) {
stopAutoScroll();
}
}, 40);
return;
}
function step(timestamp: number) {
if (!autoScrollActive) return;
const dt = lastScrollTs ? Math.min(34, timestamp - lastScrollTs) : 16;
lastScrollTs = timestamp;
const pxPerSecond = 28 * autoScrollSpeed;
const delta = pxPerSecond * (dt / 1000);
const pxPerSecond = 72 * autoScrollSpeed;
scrollCarry += pxPerSecond * (dt / 1000);
const delta = Math.max(1, Math.round(scrollCarry));
scrollCarry -= delta;
if (usesPageScroll()) {
const scroller = document.scrollingElement;
if (!scroller) return;
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();
return;
}
} else {
if (!articleListEl) return;
articleListEl.scrollTop += delta;
const maxScroll = articleListEl.scrollHeight - articleListEl.clientHeight;
if (!lastAutoCheckTs || timestamp - lastAutoCheckTs > 220) {
lastAutoCheckTs = timestamp;
checkScrolledCards();
}
if (articleListEl.scrollTop >= maxScroll - 1) {
stopAutoScroll();
return;
}
}
scrollRAF = requestAnimationFrame(step);
}
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();
@@ -424,20 +455,49 @@
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++;
}
}
}
});
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) {
decrementUnread(newlyRead);
pendingReadIds.push(...ids);
if (flushTimer) clearTimeout(flushTimer);
flushTimer = setTimeout(flushPendingReads, 1000);
}
}
async function flushPendingReads() {
if (!pendingReadIds.length) return;
@@ -459,6 +519,8 @@ onMount(() => {
return () => {
if (flushTimer) clearTimeout(flushTimer);
if (scrollCheckTimer) clearTimeout(scrollCheckTimer);
if (scrollInterval) clearInterval(scrollInterval);
commitStagedAutoReads();
};
});
</script>
@@ -536,7 +598,7 @@ onMount(() => {
{/if}
<!-- Middle Panel: Article List -->
<div class="reader-list">
<div class="reader-list" class:auto-scrolling={autoScrollActive}>
<div class="list-header">
<div class="list-header-top">
<button class="mobile-menu" onclick={() => sidebarOpen = !sidebarOpen} aria-label="Toggle sidebar">
@@ -594,7 +656,7 @@ onMount(() => {
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="article-list" bind:this={articleListEl} ontouchstart={handleScrollInterrupt} onwheel={handleScrollInterrupt} onscroll={handleListScroll}>
<div class="article-list" class:auto-scrolling={autoScrollActive} bind:this={articleListEl} ontouchstart={handleScrollInterrupt} onwheel={handleScrollInterrupt} onscroll={handleListScroll}>
{#each filteredArticles as article, index (article.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
@@ -1102,6 +1164,7 @@ onMount(() => {
background:
linear-gradient(180deg, rgba(245, 237, 227, 0.96) 0%, rgba(239, 230, 219, 0.94) 100%);
overflow: visible;
position: relative;
}
.list-header {
padding: 14px 14px 10px;
@@ -1113,6 +1176,30 @@ onMount(() => {
.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 {