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