fix(knowledge): replace IntersectionObserver with scroll event; increase card height

- Swap IntersectionObserver (race-prone, fired immediately on creation)
  for a passive scroll listener on the grid container — eliminates
  duplicate page loads caused by observer re-creation after DOM updates
- Increase card min-height 100px → 160px so tags + snippet are visible
- Increase snippet line-clamp 3 → 4 for more content preview

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 00:24:19 -04:00
parent 968e536d3a
commit 1fc0004e93
+20 -39
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted, nextTick, watchEffect } from "vue";
import { ref, watch, onMounted, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import { apiGet, apiPatch } from "@/api/client";
import { fmtCompact } from "@/utils/dateFormat";
@@ -244,23 +244,16 @@ function formatDate(iso: string): string {
// ─── Infinite scroll ──────────────────────────────────────────────────────────
const sentinelEl = ref<HTMLElement | null>(null);
const cardGridEl = ref<HTMLElement | null>(null);
let scrollObserver: IntersectionObserver | null = null;
function setupScrollObserver() {
if (scrollObserver) scrollObserver.disconnect();
if (!sentinelEl.value || !cardGridEl.value) return;
scrollObserver = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !loading.value && items.value.length < total.value) {
page.value++;
fetchItems();
}
},
{ root: cardGridEl.value, rootMargin: "200px" }
);
scrollObserver.observe(sentinelEl.value);
function onGridScroll() {
const el = cardGridEl.value;
if (!el || loading.value || items.value.length >= total.value) return;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 300;
if (nearBottom) {
page.value++;
fetchItems();
}
}
// ─── Lifecycle ────────────────────────────────────────────────────────────────
@@ -269,16 +262,10 @@ onMounted(() => {
fetchItems(true);
fetchTags();
fetchTodayBar();
nextTick(() => setupScrollObserver());
});
onUnmounted(() => {
if (searchDebounce) clearTimeout(searchDebounce);
scrollObserver?.disconnect();
});
watchEffect(() => {
if (sentinelEl.value && cardGridEl.value) setupScrollObserver();
});
</script>
@@ -380,8 +367,8 @@ watchEffect(() => {
<p v-else class="empty-hint">Start by creating a note, saving a person or place, or making a list.</p>
</div>
<!-- Card grid (sentinel lives inside so IntersectionObserver root works) -->
<div v-else ref="cardGridEl" class="card-grid">
<!-- Card grid -->
<div v-else ref="cardGridEl" class="card-grid" @scroll.passive="onGridScroll">
<div
v-for="item in items"
:key="item.id"
@@ -446,10 +433,8 @@ watchEffect(() => {
</div>
</div>
<!-- Sentinel after all cards IntersectionObserver triggers next page load -->
<div ref="sentinelEl" class="scroll-sentinel">
<span v-if="loading && items.length > 0" class="sentinel-loading">Loading</span>
</div>
<!-- Loading indicator when fetching additional pages -->
<div v-if="loading && items.length > 0" class="load-more-indicator">Loading</div>
</div>
</div>
@@ -730,7 +715,7 @@ watchEffect(() => {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 100px;
min-height: 160px;
overflow: hidden;
}
.k-card:hover {
@@ -777,7 +762,7 @@ watchEffect(() => {
font-size: 0.8rem;
color: var(--color-muted);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.45;
@@ -877,15 +862,11 @@ watchEffect(() => {
}
.empty-hint { font-size: 0.85rem; opacity: 0.7; }
/* ── Infinite scroll sentinel ────────────────────────────── */
.scroll-sentinel {
grid-column: 1 / -1; /* span all columns so it's not a card-sized slot */
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.sentinel-loading {
/* ── Load more indicator ─────────────────────────────────── */
.load-more-indicator {
grid-column: 1 / -1;
padding: 16px;
text-align: center;
font-size: 0.8rem;
color: var(--color-muted);
}