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:
@@ -219,6 +219,22 @@
|
|||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</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>
|
</aside>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -547,10 +563,17 @@
|
|||||||
border-right: 1px solid var(--shell-line);
|
border-right: 1px solid var(--shell-line);
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
display: grid;
|
display: flex;
|
||||||
align-content: start;
|
flex-direction: column;
|
||||||
gap: 18px;
|
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 {
|
.mobile-nav-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -583,7 +583,7 @@
|
|||||||
{@const pdfAsset = selectedItem.assets?.find(a => a.asset_type === 'original_upload')}
|
{@const pdfAsset = selectedItem.assets?.find(a => a.asset_type === 'original_upload')}
|
||||||
{#if pdfAsset}
|
{#if pdfAsset}
|
||||||
<iframe
|
<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'}
|
title={selectedItem.title || 'PDF'}
|
||||||
class="viewer-iframe"
|
class="viewer-iframe"
|
||||||
></iframe>
|
></iframe>
|
||||||
|
|||||||
@@ -27,13 +27,18 @@
|
|||||||
let autoScrollSpeed = $state(1.5);
|
let autoScrollSpeed = $state(1.5);
|
||||||
let articleListEl: HTMLDivElement;
|
let articleListEl: HTMLDivElement;
|
||||||
let scrollRAF: number | null = null;
|
let scrollRAF: number | null = null;
|
||||||
|
let scrollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
let lastScrollTs = 0;
|
let lastScrollTs = 0;
|
||||||
|
let scrollCarry = 0;
|
||||||
|
let lastAutoCheckTs = 0;
|
||||||
|
let mobileAutoStartScrollY = 0;
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let loadingMore = $state(false);
|
let loadingMore = $state(false);
|
||||||
let hasMore = $state(true);
|
let hasMore = $state(true);
|
||||||
let totalUnread = $state(0);
|
let totalUnread = $state(0);
|
||||||
const LIMIT = 50;
|
const LIMIT = 50;
|
||||||
let feedCounters: Record<string, number> = {};
|
let feedCounters: Record<string, number> = {};
|
||||||
|
let stagedAutoReadIds = new Set<number>();
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
function timeAgo(dateStr: string): string {
|
function timeAgo(dateStr: string): string {
|
||||||
@@ -309,51 +314,77 @@
|
|||||||
|
|
||||||
// ── Auto-scroll (requestAnimationFrame for smoothness) ──
|
// ── Auto-scroll (requestAnimationFrame for smoothness) ──
|
||||||
function usesPageScroll(): boolean {
|
function usesPageScroll(): boolean {
|
||||||
return window.matchMedia('(max-width: 1024px)').matches;
|
return window.matchMedia('(max-width: 1024px)').matches && !autoScrollActive;
|
||||||
}
|
}
|
||||||
|
|
||||||
function startAutoScroll() {
|
function startAutoScroll() {
|
||||||
if (scrollRAF) cancelAnimationFrame(scrollRAF);
|
if (scrollRAF) cancelAnimationFrame(scrollRAF);
|
||||||
|
if (scrollInterval) clearInterval(scrollInterval);
|
||||||
if (!usesPageScroll() && !articleListEl) return;
|
if (!usesPageScroll() && !articleListEl) return;
|
||||||
autoScrollActive = true;
|
autoScrollActive = true;
|
||||||
lastScrollTs = 0;
|
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) {
|
function step(timestamp: number) {
|
||||||
if (!autoScrollActive) return;
|
if (!autoScrollActive) return;
|
||||||
const dt = lastScrollTs ? Math.min(34, timestamp - lastScrollTs) : 16;
|
const dt = lastScrollTs ? Math.min(34, timestamp - lastScrollTs) : 16;
|
||||||
lastScrollTs = timestamp;
|
lastScrollTs = timestamp;
|
||||||
const pxPerSecond = 28 * autoScrollSpeed;
|
const pxPerSecond = 72 * autoScrollSpeed;
|
||||||
const delta = pxPerSecond * (dt / 1000);
|
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;
|
if (!articleListEl) return;
|
||||||
articleListEl.scrollTop += delta;
|
articleListEl.scrollTop += delta;
|
||||||
const maxScroll = articleListEl.scrollHeight - articleListEl.clientHeight;
|
const maxScroll = articleListEl.scrollHeight - articleListEl.clientHeight;
|
||||||
|
if (!lastAutoCheckTs || timestamp - lastAutoCheckTs > 220) {
|
||||||
|
lastAutoCheckTs = timestamp;
|
||||||
checkScrolledCards();
|
checkScrolledCards();
|
||||||
|
}
|
||||||
if (articleListEl.scrollTop >= maxScroll - 1) {
|
if (articleListEl.scrollTop >= maxScroll - 1) {
|
||||||
stopAutoScroll();
|
stopAutoScroll();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
scrollRAF = requestAnimationFrame(step);
|
scrollRAF = requestAnimationFrame(step);
|
||||||
}
|
}
|
||||||
scrollRAF = requestAnimationFrame(step);
|
scrollRAF = requestAnimationFrame(step);
|
||||||
}
|
}
|
||||||
function stopAutoScroll() {
|
function stopAutoScroll() {
|
||||||
|
const restorePageScroll = window.matchMedia('(max-width: 1024px)').matches && articleListEl
|
||||||
|
? articleListEl.scrollTop
|
||||||
|
: null;
|
||||||
autoScrollActive = false;
|
autoScrollActive = false;
|
||||||
lastScrollTs = 0;
|
lastScrollTs = 0;
|
||||||
|
scrollCarry = 0;
|
||||||
|
lastAutoCheckTs = 0;
|
||||||
if (scrollRAF) { cancelAnimationFrame(scrollRAF); scrollRAF = null; }
|
if (scrollRAF) { cancelAnimationFrame(scrollRAF); scrollRAF = null; }
|
||||||
|
if (scrollInterval) { clearInterval(scrollInterval); scrollInterval = null; }
|
||||||
|
commitStagedAutoReads();
|
||||||
|
if (restorePageScroll !== null) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
window.scrollTo({ top: restorePageScroll, behavior: 'auto' });
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function toggleAutoScroll() {
|
function toggleAutoScroll() {
|
||||||
if (autoScrollActive) stopAutoScroll();
|
if (autoScrollActive) stopAutoScroll();
|
||||||
@@ -424,20 +455,49 @@
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
const article = articles.find(a => a.id === id);
|
const article = articles.find(a => a.id === id);
|
||||||
if (article && !article.read) {
|
if (article && !article.read) {
|
||||||
|
if (autoScrollActive) {
|
||||||
|
if (!stagedAutoReadIds.has(id)) {
|
||||||
|
stagedAutoReadIds.add(id);
|
||||||
|
newlyRead++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
article.read = true;
|
article.read = true;
|
||||||
pendingReadIds.push(id);
|
pendingReadIds.push(id);
|
||||||
newlyRead++;
|
newlyRead++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (newlyRead > 0) {
|
if (newlyRead > 0) {
|
||||||
|
if (!autoScrollActive) {
|
||||||
articles = [...articles];
|
articles = [...articles];
|
||||||
decrementUnread(newlyRead);
|
decrementUnread(newlyRead);
|
||||||
if (flushTimer) clearTimeout(flushTimer);
|
if (flushTimer) clearTimeout(flushTimer);
|
||||||
flushTimer = setTimeout(flushPendingReads, 1000);
|
flushTimer = setTimeout(flushPendingReads, 1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitStagedAutoReads() {
|
||||||
|
if (!stagedAutoReadIds.size) return;
|
||||||
|
const ids = [...stagedAutoReadIds];
|
||||||
|
stagedAutoReadIds = new Set();
|
||||||
|
let newlyRead = 0;
|
||||||
|
articles = articles.map((article) => {
|
||||||
|
if (ids.includes(article.id) && !article.read) {
|
||||||
|
newlyRead++;
|
||||||
|
return { ...article, read: true };
|
||||||
|
}
|
||||||
|
return article;
|
||||||
|
});
|
||||||
|
if (newlyRead > 0) {
|
||||||
|
decrementUnread(newlyRead);
|
||||||
|
pendingReadIds.push(...ids);
|
||||||
|
if (flushTimer) clearTimeout(flushTimer);
|
||||||
|
flushTimer = setTimeout(flushPendingReads, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function flushPendingReads() {
|
async function flushPendingReads() {
|
||||||
if (!pendingReadIds.length) return;
|
if (!pendingReadIds.length) return;
|
||||||
@@ -459,6 +519,8 @@ onMount(() => {
|
|||||||
return () => {
|
return () => {
|
||||||
if (flushTimer) clearTimeout(flushTimer);
|
if (flushTimer) clearTimeout(flushTimer);
|
||||||
if (scrollCheckTimer) clearTimeout(scrollCheckTimer);
|
if (scrollCheckTimer) clearTimeout(scrollCheckTimer);
|
||||||
|
if (scrollInterval) clearInterval(scrollInterval);
|
||||||
|
commitStagedAutoReads();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -536,7 +598,7 @@ onMount(() => {
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Middle Panel: Article List -->
|
<!-- Middle Panel: Article List -->
|
||||||
<div class="reader-list">
|
<div class="reader-list" class:auto-scrolling={autoScrollActive}>
|
||||||
<div class="list-header">
|
<div class="list-header">
|
||||||
<div class="list-header-top">
|
<div class="list-header-top">
|
||||||
<button class="mobile-menu" onclick={() => sidebarOpen = !sidebarOpen} aria-label="Toggle sidebar">
|
<button class="mobile-menu" onclick={() => sidebarOpen = !sidebarOpen} aria-label="Toggle sidebar">
|
||||||
@@ -594,7 +656,7 @@ onMount(() => {
|
|||||||
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="article-list" 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)}
|
{#each filteredArticles as article, index (article.id)}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
@@ -1102,6 +1164,7 @@ onMount(() => {
|
|||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(245, 237, 227, 0.96) 0%, rgba(239, 230, 219, 0.94) 100%);
|
linear-gradient(180deg, rgba(245, 237, 227, 0.96) 0%, rgba(239, 230, 219, 0.94) 100%);
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
.list-header {
|
.list-header {
|
||||||
padding: 14px 14px 10px;
|
padding: 14px 14px 10px;
|
||||||
@@ -1113,6 +1176,30 @@ onMount(() => {
|
|||||||
.list-header-top {
|
.list-header-top {
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
.reader-list.auto-scrolling {
|
||||||
|
position: fixed;
|
||||||
|
top: 56px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 18;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(245, 237, 227, 0.98) 0%, rgba(239, 230, 219, 0.98) 100%);
|
||||||
|
}
|
||||||
|
.reader-list.auto-scrolling .list-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 28;
|
||||||
|
}
|
||||||
|
.article-list.auto-scrolling {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding-top: 6px;
|
||||||
|
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 18px);
|
||||||
|
}
|
||||||
.list-view-name { font-size: 1.44rem; }
|
.list-view-name { font-size: 1.44rem; }
|
||||||
.list-subtitle { font-size: 0.84rem; line-height: 1.4; }
|
.list-subtitle { font-size: 0.84rem; line-height: 1.4; }
|
||||||
.article-list {
|
.article-list {
|
||||||
|
|||||||
Reference in New Issue
Block a user