implemented infinite scrolling with load more functionality, pagination support, and expanded state handling for articles
This commit is contained in:
3
TODO.md
3
TODO.md
@@ -26,7 +26,8 @@
|
||||
- [ ] Duplicate article detection - Avoid showing same story from multiple sources
|
||||
|
||||
## 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
|
||||
- [ ] Keyboard shortcuts - Navigate articles without mouse
|
||||
- [ ] Offline reading support - Cache articles for offline access
|
||||
|
@@ -290,7 +290,7 @@ const availableCountries = computed(() => {
|
||||
return Array.from(countries).sort();
|
||||
});
|
||||
|
||||
const articleCount = computed(() => news.articles.length);
|
||||
const articleCount = computed(() => news.allArticles.length);
|
||||
|
||||
// Date presets
|
||||
const datePresets: DatePreset[] = [
|
||||
|
@@ -44,16 +44,19 @@
|
||||
|
||||
<!-- Summary -->
|
||||
<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 }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- View Summary Button -->
|
||||
<!-- Article Footer -->
|
||||
<div
|
||||
class="flex justify-between items-center gap-4 p-4 sm:p6">
|
||||
<button
|
||||
@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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
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"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Article Footer -->
|
||||
<div class="px-4 sm:px-6 pb-4 sm:pb-6">
|
||||
<a
|
||||
:href="article.url"
|
||||
target="_blank"
|
||||
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">
|
||||
<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>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- Load More Button (if you want to add pagination later) -->
|
||||
<div v-if="news.articles.length > 0" class="text-center mt-8">
|
||||
<p class="text-sm text-gray-500">
|
||||
Showing {{ news.articles.length }} articles
|
||||
<!-- Loading State & Load More Trigger -->
|
||||
<div v-if="news.articles.length > 0" class="text-center mt-8" ref="loadMoreTrigger">
|
||||
<p v-if="loading" class="text-sm text-gray-500">
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -98,11 +104,63 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue';
|
||||
import {ref, onMounted, onUnmounted, watch} from 'vue';
|
||||
import {useNews} from '../stores/useNews';
|
||||
import ArticleModal from './ArticleModal.vue';
|
||||
|
||||
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
|
||||
const isModalOpen = ref(false);
|
||||
|
@@ -12,7 +12,20 @@ export const useNews = defineStore('news', {
|
||||
created_at: number
|
||||
}[],
|
||||
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: {
|
||||
async loadLastSync() {
|
||||
@@ -38,7 +51,11 @@ export const useNews = defineStore('news', {
|
||||
|
||||
if (res.ok) {
|
||||
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();
|
||||
}
|
||||
},
|
||||
@@ -61,7 +78,11 @@ export const useNews = defineStore('news', {
|
||||
}
|
||||
|
||||
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;
|
||||
} catch (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
|
||||
async getAllNews() {
|
||||
return this.getNews({ all_countries: 'true', all_dates: 'true' });
|
||||
|
Reference in New Issue
Block a user