Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 127 additions & 32 deletions frontend/src/components/ArticleList.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup>
import { store } from '../store.js';
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
import { BrowserOpenURL } from '../wailsjs/wailsjs/runtime/runtime.js';
import {
PhCheckCircle, PhArrowClockwise, PhList, PhMagnifyingGlass,
Expand Down Expand Up @@ -28,8 +28,35 @@ const filterTotal = ref(0);
const props = defineProps(['isSidebarOpen']);
const emit = defineEmits(['toggleSidebar']);

// Virtual scrolling configuration
const ITEM_HEIGHT_MOBILE = 70; // Estimated height of each article card on mobile
const ITEM_HEIGHT_DESKTOP = 80; // Estimated height on desktop (sm: breakpoint and above)
const BUFFER_SIZE = 10; // Number of extra items to render above/below viewport

// Virtual scrolling state
const scrollTop = ref(0);
const containerHeight = ref(600); // Will be updated on mount and resize
const isMobile = ref(window.innerWidth < 640); // 640px is Tailwind's sm: breakpoint

// Computed item height based on screen size
const itemHeight = computed(() => isMobile.value ? ITEM_HEIGHT_MOBILE : ITEM_HEIGHT_DESKTOP);

// Update container height and mobile detection for virtual scrolling
function updateContainerHeight() {
if (listRef.value) {
containerHeight.value = listRef.value.clientHeight;
}
isMobile.value = window.innerWidth < 640;
}

// Load translation settings
onMounted(async () => {
// Initialize container height for virtual scrolling
nextTick(() => {
updateContainerHeight();
});
window.addEventListener('resize', updateContainerHeight);

try {
const res = await fetch('/api/settings');
const data = await res.json();
Expand Down Expand Up @@ -58,6 +85,7 @@ onBeforeUnmount(() => {
observer.disconnect();
observer = null;
}
window.removeEventListener('resize', updateContainerHeight);
window.removeEventListener('translation-settings-changed', handleTranslationSettingsChange);
window.removeEventListener('default-view-mode-changed', handleDefaultViewModeChange);
});
Expand Down Expand Up @@ -92,6 +120,7 @@ function handleTranslationSettingsChange(e) {
let observer = null;

function setupIntersectionObserver() {
// Use listRef if available, otherwise fall back to viewport (null)
observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
Expand All @@ -105,7 +134,7 @@ function setupIntersectionObserver() {
}
});
}, {
root: listRef.value,
root: listRef.value || null,
rootMargin: '100px',
threshold: 0.1
});
Expand Down Expand Up @@ -145,15 +174,13 @@ function observeArticle(el) {
}
}

onBeforeUnmount(() => {
if (observer) {
observer.disconnect();
}
});

function handleScroll(e) {
const { scrollTop, clientHeight, scrollHeight } = e.target;
if (scrollTop + clientHeight >= scrollHeight - 200) {
const target = e.target;
// Update scroll position for virtual scrolling
scrollTop.value = target.scrollTop;

// Load more when near bottom
if (target.scrollTop + target.clientHeight >= target.scrollHeight - 200) {
// If filters are active, load more filtered results, otherwise use store's loadMore
if (activeFilters.value.length > 0) {
loadMoreFilteredArticles();
Expand Down Expand Up @@ -207,6 +234,45 @@ const filteredArticles = computed(() => {
return articles;
});

// Total height of all articles for stable scrollbar
const totalHeight = computed(() => filteredArticles.value.length * itemHeight.value);

// Virtual scrolling computed properties
const visibleRange = computed(() => {
const height = itemHeight.value;
const total = filteredArticles.value.length;

// Calculate the first visible item based on scroll position
const firstVisibleIndex = Math.floor(scrollTop.value / height);

// Calculate start and end with buffer
const startIndex = Math.max(0, firstVisibleIndex - BUFFER_SIZE);
const visibleCount = Math.ceil(containerHeight.value / height);
const endIndex = Math.min(total, firstVisibleIndex + visibleCount + BUFFER_SIZE);

return { startIndex, endIndex };
});

const visibleArticles = computed(() => {
const { startIndex, endIndex } = visibleRange.value;
return filteredArticles.value.slice(startIndex, endIndex);
});

const paddingTop = computed(() => visibleRange.value.startIndex * itemHeight.value);
const paddingBottom = computed(() => {
const total = filteredArticles.value.length;
const { endIndex } = visibleRange.value;
return Math.max(0, (total - endIndex) * itemHeight.value);
});

// Reset scroll position when filter/feed changes
watch(() => [store.currentFilter, store.currentFeedId, store.currentCategory], () => {
scrollTop.value = 0;
if (listRef.value) {
listRef.value.scrollTop = 0;
}
});

// Reset filter state
function resetFilterState() {
filteredArticlesFromServer.value = [];
Expand Down Expand Up @@ -275,6 +341,12 @@ async function loadMoreFilteredArticles() {
// Filter handlers
async function handleApplyFilters(filters) {
activeFilters.value = filters;
// Reset scroll position when filters change
scrollTop.value = 0;
if (listRef.value) {
listRef.value.scrollTop = 0;
}

if (filters.length === 0) {
// When clearing filters, reset to normal article list by refreshing store
resetFilterState();
Expand Down Expand Up @@ -446,30 +518,33 @@ async function markAllAsRead() {
{{ store.i18n.t('noArticles') }}
</div>

<div v-for="article in filteredArticles" :key="article.id"
:data-article-id="article.id"
:ref="el => observeArticle(el)"
@click="selectArticle(article)"
@contextmenu="onArticleContextMenu($event, article)"
:class="['article-card', article.is_read ? 'read' : '', article.is_favorite ? 'favorite' : '', article.is_hidden ? 'hidden' : '', store.currentArticleId === article.id ? 'active' : '']">

<img v-if="article.image_url" :src="article.image_url" class="w-16 h-12 sm:w-20 sm:h-[60px] object-cover rounded bg-bg-tertiary shrink-0 border border-border" @error="$event.target.style.display='none'">

<div class="flex-1 min-w-0">
<div class="flex items-start gap-1.5 sm:gap-2">
<h4 v-if="!article.translated_title || article.translated_title === article.title" class="flex-1 m-0 mb-1 sm:mb-1.5 text-sm sm:text-base font-semibold leading-snug text-text-primary">{{ article.title }}</h4>
<div v-else class="flex-1">
<h4 class="m-0 mb-0.5 sm:mb-1 text-sm sm:text-base font-semibold leading-snug text-text-primary">{{ article.translated_title }}</h4>
<div class="text-[10px] sm:text-xs text-text-secondary italic mb-0.5 sm:mb-1">{{ article.title }}</div>
<!-- Virtual scroll container -->
<div v-else :style="{ paddingTop: paddingTop + 'px', paddingBottom: paddingBottom + 'px' }">
<div v-for="article in visibleArticles" :key="article.id"
:data-article-id="article.id"
:ref="el => observeArticle(el)"
@click="selectArticle(article)"
@contextmenu="onArticleContextMenu($event, article)"
:class="['article-card', article.is_read ? 'read' : '', article.is_favorite ? 'favorite' : '', article.is_hidden ? 'hidden' : '', store.currentArticleId === article.id ? 'active' : '']">

<img v-if="article.image_url" :src="article.image_url" class="w-16 h-12 sm:w-20 sm:h-[60px] object-cover rounded bg-bg-tertiary shrink-0 border border-border" @error="$event.target.style.display='none'">

<div class="flex-1 min-w-0">
<div class="flex items-start gap-1.5 sm:gap-2">
<h4 v-if="!article.translated_title || article.translated_title === article.title" class="flex-1 m-0 mb-1 sm:mb-1.5 text-sm sm:text-base font-semibold leading-snug text-text-primary">{{ article.title }}</h4>
<div v-else class="flex-1">
<h4 class="m-0 mb-0.5 sm:mb-1 text-sm sm:text-base font-semibold leading-snug text-text-primary">{{ article.translated_title }}</h4>
<div class="text-[10px] sm:text-xs text-text-secondary italic mb-0.5 sm:mb-1">{{ article.title }}</div>
</div>
<PhEyeSlash v-if="article.is_hidden" :size="18" class="text-text-secondary flex-shrink-0 sm:w-5 sm:h-5" :title="store.i18n.t('hideArticle')" />
</div>
<PhEyeSlash v-if="article.is_hidden" :size="18" class="text-text-secondary flex-shrink-0 sm:w-5 sm:h-5" :title="store.i18n.t('hideArticle')" />
</div>

<div class="flex justify-between items-center text-[10px] sm:text-xs text-text-secondary mt-1.5 sm:mt-2">
<span class="font-medium text-accent truncate flex-1 min-w-0 mr-2">{{ article.feed_title }}</span>
<div class="flex items-center gap-1 sm:gap-2 shrink-0">
<PhStar v-if="article.is_favorite" :size="14" class="text-yellow-500 sm:w-[18px] sm:h-[18px]" weight="fill" />
<span class="whitespace-nowrap">{{ formatDate(article.published_at) }}</span>
<div class="flex justify-between items-center text-[10px] sm:text-xs text-text-secondary mt-1.5 sm:mt-2">
<span class="font-medium text-accent truncate flex-1 min-w-0 mr-2">{{ article.feed_title }}</span>
<div class="flex items-center gap-1 sm:gap-2 shrink-0">
<PhStar v-if="article.is_favorite" :size="14" class="text-yellow-500 sm:w-[18px] sm:h-[18px]" weight="fill" />
<span class="whitespace-nowrap">{{ formatDate(article.published_at) }}</span>
</div>
</div>
</div>
</div>
Expand All @@ -496,6 +571,26 @@ async function markAllAsRead() {
width: var(--article-list-width, 400px);
}
}

/* Custom thin scrollbar for article list */
.article-list :deep(.overflow-y-auto) {
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
}
.article-list :deep(.overflow-y-auto)::-webkit-scrollbar {
width: 6px;
}
.article-list :deep(.overflow-y-auto)::-webkit-scrollbar-track {
background: transparent;
}
.article-list :deep(.overflow-y-auto)::-webkit-scrollbar-thumb {
background-color: var(--color-border);
border-radius: 3px;
}
.article-list :deep(.overflow-y-auto)::-webkit-scrollbar-thumb:hover {
background-color: var(--color-text-secondary);
}

.article-card {
@apply p-2 sm:p-3 border-b border-border cursor-pointer transition-colors flex gap-2 sm:gap-3 relative hover:bg-bg-tertiary;
}
Expand Down
Loading