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
|
- [ ] 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
|
||||||
|
|||||||
@@ -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[] = [
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|||||||
Reference in New Issue
Block a user