fix some frontend issues
This commit is contained in:
@@ -93,6 +93,17 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) -> a
|
||||
let frag = Html::parse_fragment(base_content);
|
||||
let mut content = "".to_string();
|
||||
|
||||
// Some feeds (e.g. Stuttgarter Nachrichten) embed a social-sharing widget
|
||||
// (WhatsApp/Email/Facebook/... links plus a "Link kopiert" tooltip) in the
|
||||
// article content. It's not part of the article and isn't present in the
|
||||
// scraped/readable edition either, so skip its text when flattening below.
|
||||
let selector_social_bar =
|
||||
Selector::parse("#article-social-bar").expect("\"#article-social-bar\" is a valid CSS selector");
|
||||
let excluded_node_ids: std::collections::HashSet<_> = frag
|
||||
.select(&selector_social_bar)
|
||||
.flat_map(|el| el.descendants().map(|node| node.id()))
|
||||
.collect();
|
||||
|
||||
let selector_img = Selector::parse("img").expect("\"img\" is a valid CSS selector");
|
||||
match frag.select(&selector_img).find(image_src_is_resolvable) {
|
||||
Some(image) => {
|
||||
@@ -108,6 +119,9 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) -> a
|
||||
}
|
||||
|
||||
for node in frag.tree.nodes() {
|
||||
if excluded_node_ids.contains(&node.id()) {
|
||||
continue;
|
||||
}
|
||||
if let scraper::node::Node::Text(text) = node.value() {
|
||||
content.push_str(&text.text);
|
||||
}
|
||||
@@ -382,4 +396,67 @@ mod tests {
|
||||
.execute(&mut connection)
|
||||
.ok();
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn create_feed_item_strips_social_sharing_widget() {
|
||||
let mut connection = establish_connection();
|
||||
let suffix = unique_suffix();
|
||||
|
||||
let new_user = NewUser::new(
|
||||
format!("social_bar_test_{suffix}"),
|
||||
format!("social_bar_{suffix}@example.test"),
|
||||
"secret".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let user: User = diesel::insert_into(users::table)
|
||||
.values(&new_user)
|
||||
.get_result(&mut connection)
|
||||
.unwrap();
|
||||
|
||||
let new_feed = NewFeed::new(
|
||||
format!("Social bar test feed {suffix}"),
|
||||
format!("https://example.test/feed/{suffix}"),
|
||||
user.id,
|
||||
);
|
||||
let feed: Feed = diesel::insert_into(feed::table)
|
||||
.values(&new_feed)
|
||||
.get_result(&mut connection)
|
||||
.unwrap();
|
||||
|
||||
let mut item = Item::default();
|
||||
item.set_title(Some(format!("Social bar article {suffix}")));
|
||||
item.set_link(Some(format!("https://example.test/article/{suffix}")));
|
||||
item.set_content(Some(
|
||||
r#"<p>Article text</p>
|
||||
<div id="article-social-bar" data-noprint="true">
|
||||
<ul>
|
||||
<li><a id="whatsapp" href="whatsapp://send?text=foo"> </a></li>
|
||||
<li><a id="link_copy" onclick="copyToClipboard()"> </a>
|
||||
<p>Link kopiert</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>"#
|
||||
.to_string(),
|
||||
));
|
||||
|
||||
create_feed_item(item, &feed, &mut connection).unwrap();
|
||||
|
||||
let items: Vec<FeedItem> = feed_item::table
|
||||
.filter(feed_id.eq(feed.id))
|
||||
.load(&mut connection)
|
||||
.unwrap();
|
||||
assert_eq!(1, items.len());
|
||||
assert!(items[0].content.contains("Article text"));
|
||||
assert!(!items[0].content.contains("Link kopiert"));
|
||||
|
||||
diesel::delete(feed_item::table.filter(feed_id.eq(feed.id)))
|
||||
.execute(&mut connection)
|
||||
.ok();
|
||||
diesel::delete(feed::table.filter(feed::id.eq(feed.id)))
|
||||
.execute(&mut connection)
|
||||
.ok();
|
||||
diesel::delete(users::table.filter(users::id.eq(user.id)))
|
||||
.execute(&mut connection)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,10 @@
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
/* Full-bleed article images use a `100vw`-based breakout, which can be
|
||||
wider than the visible content area (scrollbar) and would otherwise
|
||||
introduce a horizontal scrollbar. */
|
||||
overflow-x: hidden;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
transition: color 0.5s, background-color 0.5s;
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { RouterLink, useRouter, useRoute } from 'vue-router'
|
||||
import { useFeeds } from '@/composables/useFeeds'
|
||||
import Modal from './modal/AddUrl.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feeds } = useFeeds()
|
||||
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feeds, navTitleVisible } = useFeeds()
|
||||
|
||||
const titleRef = ref(null)
|
||||
let titleObserver
|
||||
|
||||
onMounted(() => {
|
||||
titleObserver = new IntersectionObserver(([entry]) => {
|
||||
navTitleVisible.value = entry.isIntersecting
|
||||
})
|
||||
titleObserver.observe(titleRef.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
titleObserver?.disconnect()
|
||||
})
|
||||
|
||||
const onFeedsPage = computed(() => route.path === '/feeds')
|
||||
|
||||
@@ -58,7 +72,7 @@ function handleToggleLayout() {
|
||||
<template>
|
||||
<header class="app-nav">
|
||||
<div class="app-nav__wrapper">
|
||||
<span class="app-nav__title">RSS Reader<span v-if="unreadCount" class="app-nav__unread"> ({{ unreadCount }})</span></span>
|
||||
<span ref="titleRef" class="app-nav__title">RSS Reader<span v-if="unreadCount" class="app-nav__unread"> ({{ unreadCount }})</span></span>
|
||||
<button
|
||||
class="app-nav__hamburger"
|
||||
type="button"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { onMounted, computed, nextTick, watch } from 'vue';
|
||||
import { useFeeds } from '@/composables/useFeeds';
|
||||
|
||||
const {
|
||||
@@ -16,10 +16,50 @@ const {
|
||||
getReadable,
|
||||
setInitialLoad,
|
||||
showMessageForXSeconds,
|
||||
navTitleVisible,
|
||||
} = useFeeds()
|
||||
|
||||
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
|
||||
|
||||
const shareLabel = navigator.share ? 'Share' : 'Copy link'
|
||||
|
||||
function scrollToNextArticle() {
|
||||
const articles = document.querySelectorAll('#article .observe')
|
||||
const threshold = window.scrollY + 1
|
||||
for (const el of articles) {
|
||||
if (el.offsetTop > threshold) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Small images (icons, logos, ...) look bad stretched to the full-bleed
|
||||
// width used for readable article images — leave them at their natural size
|
||||
// instead. Intrinsic size is only known once the image has loaded, so check
|
||||
// on load (or immediately if it's already cached/complete).
|
||||
const SMALL_IMAGE_THRESHOLD = 200
|
||||
|
||||
function markSmallImages() {
|
||||
document.querySelectorAll('.article-feature__content--readable img').forEach(img => {
|
||||
const checkSize = () => {
|
||||
if (img.naturalWidth && img.naturalWidth <= SMALL_IMAGE_THRESHOLD) {
|
||||
img.classList.add('article-feature__image--small')
|
||||
}
|
||||
}
|
||||
if (img.complete) {
|
||||
checkSize()
|
||||
} else {
|
||||
img.addEventListener('load', checkSize, { once: true })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => feeds.value[currentIndex.value]?.content, async () => {
|
||||
await nextTick()
|
||||
markSmallImages()
|
||||
})
|
||||
|
||||
async function shareUrl(url) {
|
||||
if (navigator.share) {
|
||||
await navigator.share({ url })
|
||||
@@ -44,6 +84,10 @@ onMounted(async () => {
|
||||
<div v-if="showMessage" class="message">{{ message }}</div>
|
||||
|
||||
<div v-if="viewMode === 'list'" id='article' class='article' :class="{ 'article--cards': layout === 'cards' }">
|
||||
<div v-if="feeds.length" class="list-topbar">
|
||||
<span v-if="!navTitleVisible" class="list-topbar__title">RSS Reader<span v-if="unreadCount" class="list-topbar__unread"> ({{ unreadCount }})</span></span>
|
||||
<button type="button" class="list-topbar__next" @click="scrollToNextArticle">Skip to next article ↓</button>
|
||||
</div>
|
||||
<div v-if="feeds.length == 0" class="empty-state">
|
||||
<svg class="empty-state__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
@@ -68,7 +112,12 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<div v-else class="article-single">
|
||||
<div class="article-single__topbar">
|
||||
<div class="article-single__topbar-inner">
|
||||
<button type="button" class="article-single__back" @click="leaveArticleView">← Back to list</button>
|
||||
<span v-if="feeds.length" class="article-single__progress">{{ currentIndex + 1 }} / {{ feeds.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="feeds.length == 0" class="empty-state">
|
||||
<svg class="empty-state__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
@@ -77,14 +126,16 @@ onMounted(async () => {
|
||||
<p class="empty-state__label">All caught up</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<p class="feed-source">{{ feeds[currentIndex].feedTitle }}</p>
|
||||
<h2 @click="getReadable(feeds[currentIndex], currentIndex)" class="feed-title">{{ feeds[currentIndex].title }}</h2>
|
||||
<h3>{{ feeds[currentIndex].timestamp }}</h3>
|
||||
<article class="article-feature">
|
||||
<p class="article-feature__source">{{ feeds[currentIndex].feedTitle }}</p>
|
||||
<h2 @click="getReadable(feeds[currentIndex], currentIndex)" class="article-feature__title">{{ feeds[currentIndex].title }}</h2>
|
||||
<h3 class="article-feature__meta">{{ feeds[currentIndex].timestamp }}</h3>
|
||||
<p v-if="!feeds[currentIndex].readable" class="feed-original-link">
|
||||
<a :href="feeds[currentIndex].url" target="_blank" rel="noopener noreferrer">Read original article ↗</a>
|
||||
<button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feeds[currentIndex].url)">{{ shareLabel }}</button>
|
||||
</p>
|
||||
<p class="feed-content" v-html="feeds[currentIndex].content"></p>
|
||||
<p class="article-feature__content" :class="{ 'article-feature__content--readable': feeds[currentIndex].readable }" v-html="feeds[currentIndex].content"></p>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<div class="article-nav">
|
||||
@@ -108,6 +159,50 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0;
|
||||
margin-bottom: 0.5rem;
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.list-topbar__title {
|
||||
font-weight: bold;
|
||||
font-size: clamp(1.1rem, 4vw, 1.4rem);
|
||||
}
|
||||
|
||||
.list-topbar__unread {
|
||||
font-weight: normal;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.list-topbar__next {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 0.5rem 0.9rem;
|
||||
margin-left: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list-topbar__next:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
|
||||
.observe {
|
||||
scroll-margin-top: 3.5rem;
|
||||
}
|
||||
|
||||
/* Plain vertical stack of bordered "cards" — deliberately not flex/grid, and
|
||||
with no truncation/max-height: normal block flow lets each card grow to fit
|
||||
its own full content (images included), with no cross-element interaction. */
|
||||
@@ -202,15 +297,39 @@ onMounted(async () => {
|
||||
|
||||
.article-single {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-bottom: 5rem;
|
||||
}
|
||||
|
||||
.article-single__topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
align-self: flex-start;
|
||||
width: 100vw;
|
||||
margin-left: 50%;
|
||||
margin-bottom: 1rem;
|
||||
transform: translateX(-50%);
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.article-single__topbar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.article-single__back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 0.5rem 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
@@ -222,6 +341,139 @@ onMounted(async () => {
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
|
||||
.article-single__progress {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text);
|
||||
opacity: 0.6;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.article-feature {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.article-feature__source {
|
||||
margin: 0 0 0.5em;
|
||||
padding: 0 1rem;
|
||||
font-size: clamp(0.75rem, 2vw, 0.85rem);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.article-feature__title {
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
padding: 0 1rem;
|
||||
font-family: 'Courier New';
|
||||
font-size: clamp(1.75rem, 6vw, 2.75rem);
|
||||
font-weight: bold;
|
||||
line-height: 1.15;
|
||||
color: var(--color-accent-2);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.article-feature__title:hover {
|
||||
color: var(--color-accent-2-hover);
|
||||
}
|
||||
|
||||
.article-feature__meta {
|
||||
margin: 0.75em 0 1.5em;
|
||||
padding: 0 1rem 1.5em;
|
||||
font-size: clamp(0.85rem, 2.5vw, 1rem);
|
||||
font-weight: normal;
|
||||
opacity: 0.55;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.article-feature .feed-original-link {
|
||||
margin-bottom: 1.5em;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.article-feature__content {
|
||||
padding: 0 1rem;
|
||||
font-family: Georgia, 'Times New Roman', Times, serif;
|
||||
font-size: clamp(1rem, 3.5vw, 1.25rem);
|
||||
line-height: 1.75;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.article-feature__content :deep(p) {
|
||||
padding: 0.5em 0;
|
||||
}
|
||||
|
||||
.article-feature__content :deep(h3) {
|
||||
padding: 0.5em 0;
|
||||
font-size: clamp(1rem, 3vw, 1.3rem);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.article-feature__content :deep(img),
|
||||
.article-feature__content :deep(video) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.article-feature__content--readable :deep(img),
|
||||
.article-feature__content--readable :deep(video) {
|
||||
display: block;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
height: auto;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 1.5em;
|
||||
margin-left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
/* Small images (icons, logos, ...) keep their natural size instead of being
|
||||
stretched to the full-bleed width. */
|
||||
.article-feature__content--readable :deep(img.article-feature__image--small) {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
margin: 1.5em auto;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.article-feature__content :deep(a) {
|
||||
color: var(--color-accent);
|
||||
text-decoration-color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.article-feature__content :deep(blockquote) {
|
||||
margin: 1.5em 0;
|
||||
padding: 1em 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-family: Georgia, 'Times New Roman', Times, serif;
|
||||
font-size: 1.25em;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.article-feature__content :deep(pre) {
|
||||
overflow-x: auto;
|
||||
padding: 1em;
|
||||
background: var(--color-background-soft);
|
||||
}
|
||||
|
||||
.article-feature__content :deep(code) {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.article-feature__content :deep(figcaption) {
|
||||
margin-top: 0.5em;
|
||||
font-size: 0.85em;
|
||||
text-align: center;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.article-nav {
|
||||
position: fixed;
|
||||
right: 1rem;
|
||||
|
||||
@@ -11,6 +11,7 @@ const showModal = ref(false)
|
||||
const viewMode = ref('list') // 'list' | 'article' — toggled from the hamburger menu
|
||||
const currentIndex = ref(0)
|
||||
const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards' — list-view display style, toggled from the hamburger menu
|
||||
const navTitleVisible = ref(true) // whether AppNav's "RSS Reader (N)" title is currently in view
|
||||
|
||||
let observer; // Declare observer outside the setup function
|
||||
let initialLoad = false
|
||||
@@ -92,6 +93,11 @@ async function getReadable(feed, index) {
|
||||
doc.head.prepend(base);
|
||||
doc.querySelectorAll('img').forEach(resolveTemplatedImage);
|
||||
doc.querySelectorAll('video, audio').forEach(el => el.remove());
|
||||
// Some feeds (e.g. Stuttgarter Nachrichten) embed a social-sharing widget
|
||||
// (WhatsApp/Email/Facebook/... links plus a "Link kopiert" tooltip) in the
|
||||
// article body. It's not part of the article, so strip it before Readability
|
||||
// pulls it into the parsed content.
|
||||
doc.querySelectorAll('#article-social-bar').forEach(el => el.remove())
|
||||
// Some feeds (e.g. Deutsche Welle) leave behind a heading + play-icon SVG
|
||||
// for an embedded video/audio player whose actual <video>/<audio>/<iframe>
|
||||
// we already stripped — without it, the heading is just a giant orphaned
|
||||
@@ -160,9 +166,14 @@ function setupIntersectionObserver() {
|
||||
observer.disconnect();
|
||||
}
|
||||
|
||||
observer = new IntersectionObserver(handleIntersection, {
|
||||
// The sticky topbar overlays the top of the viewport, so an article fully
|
||||
// hidden behind it should already count as "scrolled past" — shrink the
|
||||
// observer's root by that height so it stops intersecting at that point.
|
||||
const topbarHeight = document.querySelector('.list-topbar')?.getBoundingClientRect().height ?? 0;
|
||||
|
||||
observer = new IntersectionObserver((entries) => handleIntersection(entries, topbarHeight), {
|
||||
root: null, // Use the viewport as the root
|
||||
rootMargin: '0px',
|
||||
rootMargin: `-${topbarHeight}px 0px 0px 0px`,
|
||||
// threshold: 0.5, // Fire the callback when at least 50% of the element is visible
|
||||
});
|
||||
|
||||
@@ -174,14 +185,15 @@ function setupIntersectionObserver() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIntersection(entries) {
|
||||
// An article that has scrolled above the viewport (not intersecting,
|
||||
// bounding box above the top edge) has been read. Resolve all affected
|
||||
// feeds up front, before any removal — splicing `feeds` while iterating
|
||||
// would shift the array indices that later entries' `target.id` refer to,
|
||||
// causing the wrong item to be marked read and removed.
|
||||
async function handleIntersection(entries, topbarHeight = 0) {
|
||||
// An article that has scrolled past the (possibly sticky-bar-shrunk) top
|
||||
// edge of the viewport (not intersecting, bounding box above that edge)
|
||||
// has been read. Resolve all affected feeds up front, before any removal —
|
||||
// splicing `feeds` while iterating would shift the array indices that later
|
||||
// entries' `target.id` refer to, causing the wrong item to be marked read
|
||||
// and removed.
|
||||
const readFeeds = entries
|
||||
.filter(entry => initialLoad === true && !entry.isIntersecting && entry.boundingClientRect.y < 0)
|
||||
.filter(entry => initialLoad === true && !entry.isIntersecting && entry.boundingClientRect.y < topbarHeight)
|
||||
.map(entry => feeds.value[entry.target.id])
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -280,6 +292,7 @@ export function useFeeds() {
|
||||
leaveArticleView,
|
||||
layout,
|
||||
toggleLayout,
|
||||
navTitleVisible,
|
||||
nextArticle,
|
||||
prevArticle,
|
||||
fetchData,
|
||||
|
||||
Reference in New Issue
Block a user