implemented infinite scrolling with load more functionality, pagination support, and expanded state handling for articles

This commit is contained in:
2025-08-02 23:53:16 +02:00
parent b82d94c230
commit ab133d0e4f
4 changed files with 125 additions and 20 deletions

View File

@@ -26,7 +26,8 @@
- [ ] Duplicate article detection - Avoid showing same story from multiple sources - [ ] Duplicate article detection - Avoid showing same story from multiple sources
## User Experience ## User Experience
- [ ] Dark/light theme toggle - [x] Dark/light theme toggle
- [x] Infinite scroll / load more functionality for articles
- [ ] Customizable article layout - List view, card view, magazine style - [ ] Customizable article layout - List view, card view, magazine style
- [ ] Keyboard shortcuts - Navigate articles without mouse - [ ] Keyboard shortcuts - Navigate articles without mouse
- [ ] Offline reading support - Cache articles for offline access - [ ] Offline reading support - Cache articles for offline access

View File

@@ -290,7 +290,7 @@ const availableCountries = computed(() => {
return Array.from(countries).sort(); return Array.from(countries).sort();
}); });
const articleCount = computed(() => news.articles.length); const articleCount = computed(() => news.allArticles.length);
// Date presets // Date presets
const datePresets: DatePreset[] = [ const datePresets: DatePreset[] = [

View File

@@ -44,16 +44,19 @@
<!-- Summary --> <!-- Summary -->
<p <p
class="text-sm sm:text-base text-gray-700 dark:text-gray-300 line-clamp-3 mb-4 leading-relaxed"> class="text-sm sm:text-base text-gray-700 dark:text-gray-300 line-clamp-5 leading-relaxed">
{{ article.summary }} {{ article.summary }}
</p> </p>
</div>
<!-- View Summary Button --> <!-- Article Footer -->
<div
class="flex justify-between items-center gap-4 p-4 sm:p6">
<button <button
@click="openModal(article)" @click="openModal(article)"
class="inline-flex items-center text-sm font-medium text-green-600 hover:text-green-800 transition-colors mb-3" class="flex-1 inline-flex items-center justify-center cursor-pointer px-4 py-2 text-sm font-medium rounded-lg bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50 transition-colors"
> >
View full summary Full summary
<svg class="ml-1 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="ml-1 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/> d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
@@ -61,30 +64,33 @@
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/> d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg> </svg>
</button> </button>
</div>
<!-- Article Footer -->
<div class="px-4 sm:px-6 pb-4 sm:pb-6">
<a <a
:href="article.url" :href="article.url"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="inline-flex items-center text-sm font-medium text-green-600 hover:text-green-800 transition-colors" class="flex-1 inline-flex items-center justify-center px-4 py-2 text-sm font-medium rounded-lg bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50 transition-colors"
> >
Read full article Full article
<svg class="ml-1 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="ml-1 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/> d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg> </svg>
</a> </a>
</div> </div>
</article> </article>
</div> </div>
<!-- Load More Button (if you want to add pagination later) --> <!-- Loading State & Load More Trigger -->
<div v-if="news.articles.length > 0" class="text-center mt-8"> <div v-if="news.articles.length > 0" class="text-center mt-8" ref="loadMoreTrigger">
<p class="text-sm text-gray-500"> <p v-if="loading" class="text-sm text-gray-500">
Showing {{ news.articles.length }} articles Loading more articles...
</p>
<p v-else-if="!news.hasMore" class="text-sm text-gray-500">
No more articles to load
</p>
<p v-else class="text-sm text-gray-500">
Showing {{ news.articles.length }} of {{ news.allArticles.length }} articles
</p> </p>
</div> </div>
@@ -98,11 +104,63 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {ref} from 'vue'; import {ref, onMounted, onUnmounted, watch} from 'vue';
import {useNews} from '../stores/useNews'; import {useNews} from '../stores/useNews';
import ArticleModal from './ArticleModal.vue'; import ArticleModal from './ArticleModal.vue';
const news = useNews(); const news = useNews();
const loading = ref(false);
const loadMoreArticles = async () => {
if (loading.value || !news.hasMore) return;
loading.value = true;
try {
await news.loadMore();
} catch (error) {
console.error('Error loading more articles:', error);
} finally {
loading.value = false;
}
};
const observer = ref<IntersectionObserver | null>(null);
const loadMoreTrigger = ref<HTMLElement | null>(null);
onMounted(() => {
observer.value = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMoreArticles();
}
},
{threshold: 0.5}
);
if (loadMoreTrigger.value) {
observer.value.observe(loadMoreTrigger.value);
}
});
onUnmounted(() => {
if (observer.value) {
observer.value.disconnect();
}
});
// Watch for changes to the loadMoreTrigger element
watch(loadMoreTrigger, (newEl, oldEl) => {
if (observer.value) {
// Disconnect from old element if it exists
if (oldEl) {
observer.value.unobserve(oldEl);
}
// Observe new element if it exists
if (newEl) {
observer.value.observe(newEl);
}
}
});
// Modal state // Modal state
const isModalOpen = ref(false); const isModalOpen = ref(false);

View File

@@ -12,7 +12,20 @@ export const useNews = defineStore('news', {
created_at: number created_at: number
}[], }[],
lastSync: 0, lastSync: 0,
clientTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone clientTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
page: 1,
pageSize: 12,
hasMore: true,
allArticles: [] as {
id: number,
title: string,
summary: string,
url: string,
published: number,
country: string,
created_at: number
}[],
currentFilters: {} as Record<string, string | boolean>
}), }),
actions: { actions: {
async loadLastSync() { async loadLastSync() {
@@ -38,7 +51,11 @@ export const useNews = defineStore('news', {
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
this.articles = data; this.allArticles = data;
this.currentFilters = filters;
this.page = 1;
this.hasMore = data.length > this.pageSize;
this.articles = data.slice(0, this.pageSize);
this.lastSync = Date.now(); this.lastSync = Date.now();
} }
}, },
@@ -61,7 +78,11 @@ export const useNews = defineStore('news', {
} }
const data = await res.json(); const data = await res.json();
this.articles = data; this.allArticles = data;
this.currentFilters = filters;
this.page = 1;
this.hasMore = data.length > this.pageSize;
this.articles = data.slice(0, this.pageSize);
return data; return data;
} catch (error) { } catch (error) {
console.error('Failed to get news:', error); console.error('Failed to get news:', error);
@@ -69,6 +90,31 @@ export const useNews = defineStore('news', {
} }
}, },
async loadMore() {
if (!this.hasMore) return;
const nextPage = this.page + 1;
const startIndex = this.page * this.pageSize;
const endIndex = startIndex + this.pageSize;
const nextPageItems = this.allArticles.slice(startIndex, endIndex);
if (nextPageItems.length > 0) {
this.articles = [...this.articles, ...nextPageItems];
this.page = nextPage;
this.hasMore = endIndex < this.allArticles.length;
} else {
this.hasMore = false;
}
},
// Reset pagination state
resetPagination() {
this.page = 1;
this.hasMore = this.allArticles.length > this.pageSize;
this.articles = this.allArticles.slice(0, this.pageSize);
},
// New convenience methods // New convenience methods
async getAllNews() { async getAllNews() {
return this.getNews({ all_countries: 'true', all_dates: 'true' }); return this.getNews({ all_countries: 'true', all_dates: 'true' });