card view, minor css bugfixes
This commit is contained in:
@@ -4,7 +4,7 @@ import { RouterLink, useRouter } from 'vue-router'
|
|||||||
import { useFeeds } from '@/composables/useFeeds'
|
import { useFeeds } from '@/composables/useFeeds'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { sync, showModal, viewMode, toggleViewMode, markAllRead } = useFeeds()
|
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead } = useFeeds()
|
||||||
|
|
||||||
const menuOpen = ref(false)
|
const menuOpen = ref(false)
|
||||||
|
|
||||||
@@ -42,6 +42,11 @@ function handleToggleViewMode() {
|
|||||||
toggleViewMode()
|
toggleViewMode()
|
||||||
closeMenu()
|
closeMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleToggleLayout() {
|
||||||
|
toggleLayout()
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -74,6 +79,9 @@ function handleToggleViewMode() {
|
|||||||
<button class="app-nav__menu-item" type="button" @click="handleToggleViewMode">
|
<button class="app-nav__menu-item" type="button" @click="handleToggleViewMode">
|
||||||
{{ viewMode === 'list' ? 'Article view' : 'List view' }}
|
{{ viewMode === 'list' ? 'Article view' : 'List view' }}
|
||||||
</button>
|
</button>
|
||||||
|
<button v-if="viewMode === 'list'" class="app-nav__menu-item" type="button" @click="handleToggleLayout">
|
||||||
|
{{ layout === 'list' ? 'Card layout' : 'List layout' }}
|
||||||
|
</button>
|
||||||
<button class="app-nav__menu-item" type="button" @click="handleSync">Sync</button>
|
<button class="app-nav__menu-item" type="button" @click="handleSync">Sync</button>
|
||||||
<button class="app-nav__menu-item" type="button" @click="handleMarkAllRead">Mark all as read</button>
|
<button class="app-nav__menu-item" type="button" @click="handleMarkAllRead">Mark all as read</button>
|
||||||
<button class="app-nav__menu-item" type="button" @click="openAddModal">Add RSS</button>
|
<button class="app-nav__menu-item" type="button" @click="openAddModal">Add RSS</button>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const {
|
|||||||
showModal,
|
showModal,
|
||||||
viewMode,
|
viewMode,
|
||||||
currentIndex,
|
currentIndex,
|
||||||
|
leaveArticleView,
|
||||||
|
layout,
|
||||||
nextArticle,
|
nextArticle,
|
||||||
prevArticle,
|
prevArticle,
|
||||||
fetchData,
|
fetchData,
|
||||||
@@ -38,7 +40,7 @@ onMounted(async () => {
|
|||||||
<div>
|
<div>
|
||||||
<div v-if="showMessage" class="message">{{ message }}</div>
|
<div v-if="showMessage" class="message">{{ message }}</div>
|
||||||
|
|
||||||
<div v-if="viewMode === 'list'" id='article' class='article'>
|
<div v-if="viewMode === 'list'" id='article' class='article' :class="{ 'article--cards': layout === 'cards' }">
|
||||||
<p v-if="feeds.length == 0">No unread articles.</p>
|
<p v-if="feeds.length == 0">No unread articles.</p>
|
||||||
<template v-for="( feed, index ) in feeds ">
|
<template v-for="( feed, index ) in feeds ">
|
||||||
<div v-bind:id="index" class="observe">
|
<div v-bind:id="index" class="observe">
|
||||||
@@ -48,13 +50,13 @@ onMounted(async () => {
|
|||||||
<p v-if="!feed.readable" class="feed-original-link">
|
<p v-if="!feed.readable" class="feed-original-link">
|
||||||
<a :href="feed.url" target="_blank" rel="noopener noreferrer">Read original article ↗</a>
|
<a :href="feed.url" target="_blank" rel="noopener noreferrer">Read original article ↗</a>
|
||||||
</p>
|
</p>
|
||||||
<p class="feed-content" v-html='feed.content'></p>
|
<p class="feed-content" :class="{ 'feed-content--clamped': layout === 'cards' && !feed.readable }" v-html='feed.content'></p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="article-single">
|
<div v-else class="article-single">
|
||||||
<button type="button" class="article-single__back" @click="viewMode = 'list'">← Back to list</button>
|
<button type="button" class="article-single__back" @click="leaveArticleView">← Back to list</button>
|
||||||
<p v-if="feeds.length == 0">No unread articles.</p>
|
<p v-if="feeds.length == 0">No unread articles.</p>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p class="feed-source">{{ feeds[currentIndex].feedTitle }}</p>
|
<p class="feed-source">{{ feeds[currentIndex].feedTitle }}</p>
|
||||||
@@ -87,6 +89,46 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.article--cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
align-items: start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article--cards .observe {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--color-background-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article--cards .feed-title {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article--cards h3 {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 1em 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article--cards .feed-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article--cards .feed-content--clamped {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article--cards .feed-content img {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.feed-original-link a {
|
.feed-original-link a {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -15,13 +15,14 @@ describe('AppNav', () => {
|
|||||||
localStorage.setItem('user-id', '7')
|
localStorage.setItem('user-id', '7')
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
|
||||||
const { feeds, showMessage, message, showModal, viewMode, currentIndex } = useFeeds()
|
const { feeds, showMessage, message, showModal, viewMode, currentIndex, layout } = useFeeds()
|
||||||
feeds.value = []
|
feeds.value = []
|
||||||
showMessage.value = false
|
showMessage.value = false
|
||||||
message.value = ''
|
message.value = ''
|
||||||
showModal.value = false
|
showModal.value = false
|
||||||
viewMode.value = 'list'
|
viewMode.value = 'list'
|
||||||
currentIndex.value = 0
|
currentIndex.value = 0
|
||||||
|
layout.value = 'list'
|
||||||
|
|
||||||
router = createRouter({
|
router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -104,6 +105,26 @@ describe('AppNav', () => {
|
|||||||
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
|
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('switches the list layout from the menu and closes it', async () => {
|
||||||
|
const wrapper = await mountWithMenuOpen()
|
||||||
|
const { layout } = useFeeds()
|
||||||
|
|
||||||
|
const layoutButton = wrapper.findAll('.app-nav__menu-item').find(el => el.text() === 'Card layout')
|
||||||
|
await layoutButton.trigger('click')
|
||||||
|
|
||||||
|
expect(layout.value).toBe('cards')
|
||||||
|
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides the layout toggle while in article view', async () => {
|
||||||
|
const { viewMode } = useFeeds()
|
||||||
|
viewMode.value = 'article'
|
||||||
|
|
||||||
|
const wrapper = await mountWithMenuOpen()
|
||||||
|
|
||||||
|
expect(wrapper.findAll('.app-nav__menu-item').find(el => el.text().includes('layout'))).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
it('marks all articles as read from the menu after confirmation', async () => {
|
it('marks all articles as read from the menu after confirmation', async () => {
|
||||||
const { feeds } = useFeeds()
|
const { feeds } = useFeeds()
|
||||||
feeds.value = [
|
feeds.value = [
|
||||||
|
|||||||
@@ -23,13 +23,14 @@ describe('RssFeeds', () => {
|
|||||||
|
|
||||||
// useFeeds() returns module-level singleton refs shared across the whole
|
// useFeeds() returns module-level singleton refs shared across the whole
|
||||||
// app (and this spec file) — reset them so state doesn't leak between tests.
|
// app (and this spec file) — reset them so state doesn't leak between tests.
|
||||||
const { feeds, showMessage, message, showModal, viewMode, currentIndex } = useFeeds()
|
const { feeds, showMessage, message, showModal, viewMode, currentIndex, layout } = useFeeds()
|
||||||
feeds.value = []
|
feeds.value = []
|
||||||
showMessage.value = false
|
showMessage.value = false
|
||||||
message.value = ''
|
message.value = ''
|
||||||
showModal.value = false
|
showModal.value = false
|
||||||
viewMode.value = 'list'
|
viewMode.value = 'list'
|
||||||
currentIndex.value = 0
|
currentIndex.value = 0
|
||||||
|
layout.value = 'list'
|
||||||
})
|
})
|
||||||
|
|
||||||
it('fetches the current user articles and shows the empty state', async () => {
|
it('fetches the current user articles and shows the empty state', async () => {
|
||||||
@@ -70,6 +71,72 @@ describe('RssFeeds', () => {
|
|||||||
expect(wrapper.text()).not.toContain('No unread articles.')
|
expect(wrapper.text()).not.toContain('No unread articles.')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders the list as cards when the card layout is selected', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
feeds: [
|
||||||
|
{
|
||||||
|
title: 'My Feed',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Article one',
|
||||||
|
content: '<p>hello</p>',
|
||||||
|
url: 'https://example.test/1',
|
||||||
|
timestamp: '2026-01-01',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { layout } = useFeeds()
|
||||||
|
layout.value = 'cards'
|
||||||
|
|
||||||
|
const wrapper = mount(RssFeeds)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.article').classes()).toContain('article--cards')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lets a card grow to fit the full article once its readable content has loaded', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
feeds: [
|
||||||
|
{
|
||||||
|
title: 'My Feed',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Article one',
|
||||||
|
content: '<p>short summary</p>',
|
||||||
|
url: 'https://example.test/1',
|
||||||
|
timestamp: '2026-01-01',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
axios.post.mockResolvedValueOnce({ data: { content: '<html><body><article><p>full text</p></article></body></html>' } })
|
||||||
|
|
||||||
|
const { layout } = useFeeds()
|
||||||
|
layout.value = 'cards'
|
||||||
|
|
||||||
|
const wrapper = mount(RssFeeds)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// Clamped to a fixed number of lines while only the short summary is shown...
|
||||||
|
expect(wrapper.find('.feed-content').classes()).toContain('feed-content--clamped')
|
||||||
|
|
||||||
|
await wrapper.find('.feed-title').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// ...but allowed to grow once the user has loaded the full readable article.
|
||||||
|
expect(wrapper.find('.feed-content').classes()).not.toContain('feed-content--clamped')
|
||||||
|
})
|
||||||
|
|
||||||
it('sorts articles by date across feeds, newest first', async () => {
|
it('sorts articles by date across feeds, newest first', async () => {
|
||||||
axios.get.mockResolvedValueOnce({
|
axios.get.mockResolvedValueOnce({
|
||||||
data: {
|
data: {
|
||||||
@@ -209,4 +276,58 @@ describe('RssFeeds', () => {
|
|||||||
|
|
||||||
expect(wrapper.find('.article-single .feed-title').text()).toBe('Article one')
|
expect(wrapper.find('.article-single .feed-title').text()).toBe('Article one')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('drops articles read while paging through article view once back in the list', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
feeds: [
|
||||||
|
{
|
||||||
|
title: 'My Feed',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Article one',
|
||||||
|
content: '<p>one</p>',
|
||||||
|
url: 'https://example.test/1',
|
||||||
|
timestamp: '2026-03-01 10:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'Article two',
|
||||||
|
content: '<p>two</p>',
|
||||||
|
url: 'https://example.test/2',
|
||||||
|
timestamp: '2026-02-01 10:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: 'Article three',
|
||||||
|
content: '<p>three</p>',
|
||||||
|
url: 'https://example.test/3',
|
||||||
|
timestamp: '2026-01-01 10:00:00',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
|
||||||
|
const wrapper = mount(RssFeeds)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const { toggleViewMode, leaveArticleView } = useFeeds()
|
||||||
|
|
||||||
|
// Enter article view (marks "Article one" read), page forward to "Article
|
||||||
|
// two" (marks it read too), then leave without visiting "Article three".
|
||||||
|
toggleViewMode()
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.findAll('.article-nav__btn')[1].trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
leaveArticleView()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const titles = wrapper.findAll('.feed-title').map(el => el.text())
|
||||||
|
expect(titles).toEqual(['Article three'])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -118,6 +118,34 @@ describe('useFeeds', () => {
|
|||||||
expect(feeds.value[0].content).not.toMatch(/src="[^"]*(\$\{|%7[bB])/)
|
expect(feeds.value[0].content).not.toMatch(/src="[^"]*(\$\{|%7[bB])/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('resolves templated images regardless of the placeholder name and scrubs other lazy-load attributes', async () => {
|
||||||
|
feeds.value = [{
|
||||||
|
id: 1,
|
||||||
|
title: 'Article one',
|
||||||
|
url: 'https://www.dw.com/en/article-one/a-1',
|
||||||
|
content: '',
|
||||||
|
}]
|
||||||
|
axios.post.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
content: `<html><body><article>
|
||||||
|
<img data-format="MASTER_LANDSCAPE" data-id="76212061"
|
||||||
|
data-url="https://static.dw.com/image/76212061_\${size}.jpg"
|
||||||
|
data-src="https://static.dw.com/image/76212061_\${size}.jpg"
|
||||||
|
alt="Merz and Trump" src="data:image/gif;base64,placeholder">
|
||||||
|
<p>some article text long enough for readability to keep the image and paragraph together in the parsed output, padded with extra words to pass the content-length heuristics used by Mozilla Readability when scoring candidate nodes for the main article body.</p>
|
||||||
|
</article></body></html>`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await getReadable(feeds.value[0], 0)
|
||||||
|
|
||||||
|
// `${size}` isn't the hardcoded `${formatId}` placeholder, but `data-format`
|
||||||
|
// is still the right substitution — and lazy-load attributes like `data-src`
|
||||||
|
// (which Readability may promote into `src`) must be cleaned too, not just
|
||||||
|
// `data-url`, so a stale template can't resurface in the rendered output.
|
||||||
|
expect(feeds.value[0].content).not.toMatch(/src="[^"]*(\$\{|%7[bB])/)
|
||||||
|
})
|
||||||
|
|
||||||
it('drops unresolvable templated images instead of leaving a broken src', async () => {
|
it('drops unresolvable templated images instead of leaving a broken src', async () => {
|
||||||
feeds.value = [{
|
feeds.value = [{
|
||||||
id: 1,
|
id: 1,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const message = ref('')
|
|||||||
const showModal = ref(false)
|
const showModal = ref(false)
|
||||||
const viewMode = ref('list') // 'list' | 'article' — toggled from the hamburger menu
|
const viewMode = ref('list') // 'list' | 'article' — toggled from the hamburger menu
|
||||||
const currentIndex = ref(0)
|
const currentIndex = ref(0)
|
||||||
|
const layout = ref('list') // 'list' | 'cards' — list-view display style, toggled from the hamburger menu
|
||||||
|
|
||||||
let observer; // Declare observer outside the setup function
|
let observer; // Declare observer outside the setup function
|
||||||
let initialLoad = false
|
let initialLoad = false
|
||||||
@@ -23,18 +24,33 @@ function authHeaders() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Some feeds (e.g. Deutsche Welle) ship <img> tags whose `src`/`data-url`
|
// Some feeds (e.g. Deutsche Welle) ship <img> tags whose `src` and various
|
||||||
// contain an unresolved `${formatId}` template that their own frontend fills
|
// lazy-load attributes (`data-url`, `data-src`, `srcset`, ...) contain an
|
||||||
// in from the sibling `data-format` attribute before loading — verbatim they
|
// unresolved `${placeholderName}` template — or its URL-encoded `%7B...%7D`
|
||||||
// 404. Resolve them the same way here, or drop the <img> if we can't, so
|
// form — that their own frontend fills in from the sibling `data-format`
|
||||||
// Readability doesn't carry a broken image into the parsed article.
|
// attribute before loading; verbatim they 404. Resolve every such attribute
|
||||||
|
// the same way (so Readability's own lazy-image handling can't resurrect a
|
||||||
|
// stale template into `src`), preferring `data-url` as the source of truth
|
||||||
|
// for `src`, and drop the <img> entirely if a template still remains.
|
||||||
|
const TEMPLATE_PATTERN = /\$\{[^}]+\}|%7[bB][^%]*%7[dD]/
|
||||||
|
const TEMPLATE_PATTERN_GLOBAL = /\$\{[^}]+\}|%7[bB][^%]*%7[dD]/g
|
||||||
|
|
||||||
function resolveTemplatedImage(img) {
|
function resolveTemplatedImage(img) {
|
||||||
const placeholder = '${formatId}'
|
|
||||||
const format = img.getAttribute('data-format')
|
const format = img.getAttribute('data-format')
|
||||||
const dataUrl = img.getAttribute('data-url')
|
const dataUrl = img.getAttribute('data-url')
|
||||||
if (format && dataUrl && dataUrl.includes(placeholder)) {
|
|
||||||
img.setAttribute('src', dataUrl.replace(placeholder, format))
|
if (format) {
|
||||||
} else if (/[{]|%7[bB]/.test(img.getAttribute('src') ?? '')) {
|
if (dataUrl && TEMPLATE_PATTERN.test(dataUrl)) {
|
||||||
|
img.setAttribute('src', dataUrl.replace(TEMPLATE_PATTERN_GLOBAL, format))
|
||||||
|
}
|
||||||
|
for (const attr of [...img.attributes]) {
|
||||||
|
if (attr.name !== 'src' && TEMPLATE_PATTERN.test(attr.value)) {
|
||||||
|
img.setAttribute(attr.name, attr.value.replace(TEMPLATE_PATTERN_GLOBAL, format))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TEMPLATE_PATTERN.test(img.getAttribute('src') ?? '')) {
|
||||||
img.remove()
|
img.remove()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,17 +191,36 @@ function markCurrentArticleRead() {
|
|||||||
const feed = feeds.value[currentIndex.value]
|
const feed = feeds.value[currentIndex.value]
|
||||||
// Marking read here (rather than via removeFeed, as the scroll-based list
|
// Marking read here (rather than via removeFeed, as the scroll-based list
|
||||||
// view does) keeps the array stable so currentIndex stays valid while paging.
|
// view does) keeps the array stable so currentIndex stays valid while paging.
|
||||||
if (feed) markRead(feed.id)
|
// The local `read` flag lets leaveArticleView() drop these once we're done.
|
||||||
|
if (feed) {
|
||||||
|
feed.read = true
|
||||||
|
markRead(feed.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function leaveArticleView() {
|
||||||
|
// Articles paged past in article view were marked read but deliberately kept
|
||||||
|
// in place so currentIndex stayed valid — drop them now so they don't keep
|
||||||
|
// showing up in the list view.
|
||||||
|
feeds.value = feeds.value.filter(feed => !feed.read)
|
||||||
|
currentIndex.value = 0
|
||||||
|
viewMode.value = 'list'
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleViewMode() {
|
function toggleViewMode() {
|
||||||
viewMode.value = viewMode.value === 'list' ? 'article' : 'list'
|
|
||||||
if (viewMode.value === 'article') {
|
if (viewMode.value === 'article') {
|
||||||
|
leaveArticleView()
|
||||||
|
} else {
|
||||||
|
viewMode.value = 'article'
|
||||||
currentIndex.value = 0
|
currentIndex.value = 0
|
||||||
markCurrentArticleRead()
|
markCurrentArticleRead()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleLayout() {
|
||||||
|
layout.value = layout.value === 'list' ? 'cards' : 'list'
|
||||||
|
}
|
||||||
|
|
||||||
function nextArticle() {
|
function nextArticle() {
|
||||||
if (currentIndex.value < feeds.value.length - 1) {
|
if (currentIndex.value < feeds.value.length - 1) {
|
||||||
currentIndex.value += 1
|
currentIndex.value += 1
|
||||||
@@ -209,6 +244,9 @@ export function useFeeds() {
|
|||||||
viewMode,
|
viewMode,
|
||||||
currentIndex,
|
currentIndex,
|
||||||
toggleViewMode,
|
toggleViewMode,
|
||||||
|
leaveArticleView,
|
||||||
|
layout,
|
||||||
|
toggleLayout,
|
||||||
nextArticle,
|
nextArticle,
|
||||||
prevArticle,
|
prevArticle,
|
||||||
fetchData,
|
fetchData,
|
||||||
|
|||||||
Reference in New Issue
Block a user