feat(board): default pagination with optional infinite scroll and URL-based page routing

This commit is contained in:
plebeius
2026-02-20 20:16:11 +08:00
parent f660d1a814
commit 60d2f6804d
51 changed files with 775 additions and 82 deletions
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "رفع الوسائط غير مدعوم من متصفحك بسبب قيود تقنية (CORS). إنه مدعوم في تطبيق 5chan لـ Android وتطبيق سطح المكتب لـ Mac/Linux/Windows.",
"upload_not_supported_web_link_text": "تحميل من صفحة الإصدار الرسمية",
"board_subscriptions": "اشتراكات اللوحات",
"chain_ticker": "رمز السلسلة"
"chain_ticker": "رمز السلسلة",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "প্রযুক্তিগত সীমাবদ্ধতার কারণে (CORS) আপনার ব্রাউজার মিডিয়া আপলোড সমর্থন করে না। এটি 5chan অ্যান্ড্রয়েড অ্যাপ এবং Mac/Linux/Windows ডেস্কটপ অ্যাপে সমর্থিত।",
"upload_not_supported_web_link_text": "অফিসিয়াল রিলিজ পৃষ্ঠা থেকে ডাউনলোড করুন",
"board_subscriptions": "বোর্ড সাবস্ক্রিপশন",
"chain_ticker": "চেইন টিকার"
"chain_ticker": "চেইন টিকার",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "Váš prohlížeč z důvodu technických omezení (CORS) nepodporuje nahrávání médií. Je podporováno v aplikaci 5chan pro Android a desktopové aplikaci pro Mac/Linux/Windows.",
"upload_not_supported_web_link_text": "Stáhnout z oficiální stránky vydání",
"board_subscriptions": "Odběry desek",
"chain_ticker": "ticker řetězce"
"chain_ticker": "ticker řetězce",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "Din browser understøtter ikke upload af medier på grund af tekniske begrænsninger (CORS). Det understøttes i 5chan Android-appen og Mac/Linux/Windows desktop-appen.",
"upload_not_supported_web_link_text": "Download fra den officielle udgivelsesside",
"board_subscriptions": "Bordabonnementer",
"chain_ticker": "kæde-ticker"
"chain_ticker": "kæde-ticker",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "Das Hochladen von Medien wird von Ihrem Browser aufgrund technischer Einschränkungen (CORS) nicht unterstützt. Es wird in der 5chan-Android-App und der Mac/Linux/Windows-Desktop-App unterstützt.",
"upload_not_supported_web_link_text": "Von der offiziellen Release-Seite herunterladen",
"board_subscriptions": "Board-Abonnements",
"chain_ticker": "Chain-Ticker"
"chain_ticker": "Chain-Ticker",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "Η μεταφόρτωση πολυμέσων δεν υποστηρίζεται από το πρόγραμμα περιήγησής σας λόγω τεχνικών περιορισμών (CORS). Υποστηρίζεται στην εφαρμογή 5chan για Android και την εφαρμογή επιφάνειας εργασίας Mac/Linux/Windows.",
"upload_not_supported_web_link_text": "Λήψη από τη σελίδα επίσημης έκδοσης",
"board_subscriptions": "Συνδρομές σε πίνακες",
"chain_ticker": "σύμβολο αλυσίδας"
"chain_ticker": "σύμβολο αλυσίδας",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"media_hosting_preferred": "Preferred provider",
"media_hosting_preferred_provider_label": "Choose provider",
"media_hosting_preferred_failure_guidance": "If your preferred provider fails, the app will try other providers (Random mode) or show an error (Preferred mode).",
"media_hosting_all_providers_failed_guidance": "All upload providers failed. Try again or paste a link manually."
"media_hosting_all_providers_failed_guidance": "All upload providers failed. Try again or paste a link manually.",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "La subida de medios no está soportada por tu navegador debido a limitaciones técnicas (CORS). Sí está soportada en la app 5chan para Android y la app de escritorio para Mac/Linux/Windows.",
"upload_not_supported_web_link_text": "Descargar desde la página de release oficial",
"board_subscriptions": "Suscripciones a tableros",
"chain_ticker": "ticker de cadena"
"chain_ticker": "ticker de cadena",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "آپلود رسانه به دلیل محدودیت‌های فنی (CORS) توسط مرورگر شما پشتیبانی نمی‌شود. در اپلیکیشن 5chan اندروید و اپلیکیشن دسکتاپ Mac/Linux/Windows پشتیبانی می‌شود.",
"upload_not_supported_web_link_text": "دانلود از صفحه انتشار رسمی",
"board_subscriptions": "اشتراک‌های تابلو",
"chain_ticker": "نماد زنجیره"
"chain_ticker": "نماد زنجیره",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "Medioiden lataus ei ole tuettu selaimessasi teknisten rajoitusten vuoksi (CORS). Se on tuettu 5chan Android-sovelluksessa ja Mac/Linux/Windows-työpöytäsovelluksessa.",
"upload_not_supported_web_link_text": "Lataa viralliselta julkaisusivulta",
"board_subscriptions": "Alustojen tilaukset",
"chain_ticker": "ketjumerkki"
"chain_ticker": "ketjumerkki",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "Ang pag-upload ng media ay hindi sinusuportahan ng iyong browser dahil sa mga teknikal na limitasyon (CORS). Ito ay sinusuportahan sa 5chan Android app at Mac/Linux/Windows desktop app.",
"upload_not_supported_web_link_text": "I-download mula sa opisyal na release page",
"board_subscriptions": "Mga subscription sa board",
"chain_ticker": "chain ticker"
"chain_ticker": "chain ticker",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "Le téléchargement de médias n'est pas pris en charge par votre navigateur en raison de limitations techniques (CORS). Il est pris en charge dans l'application 5chan Android et l'application de bureau Mac/Linux/Windows.",
"upload_not_supported_web_link_text": "Télécharger depuis la page de version officielle",
"board_subscriptions": "Abonnements aux tableaux",
"chain_ticker": "ticker de chaîne"
"chain_ticker": "ticker de chaîne",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "העלאת מדיה אינה נתמכת על ידי הדפדפן שלך בשל מגבלות טכניות (CORS). זה נתמך באפליקציית 5chan לאנדרואיד ובאפליקציית הדסקטופ ל-Mac/Linux/Windows.",
"upload_not_supported_web_link_text": "הורד מהדף השחרור הרשמי",
"board_subscriptions": "מנויים ללוחות",
"chain_ticker": "טיקר שרשרת"
"chain_ticker": "טיקר שרשרת",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "तकनीकी सीमाओं (CORS) के कारण आपका ब्राउज़र मीडिया अपलोड का समर्थन नहीं करता। यह 5chan एंड्रॉइड ऐप और Mac/Linux/Windows डेस्कटॉप ऐप में समर्थित है।",
"upload_not_supported_web_link_text": "आधिकारिक रिलीज़ पेज से डाउनलोड करें",
"board_subscriptions": "बोर्ड सदस्यता",
"chain_ticker": "श्रृंखला टिकर"
"chain_ticker": "श्रृंखला टिकर",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "A böngésző nem támogatja a média feltöltését technikai korlátozások miatt (CORS). A 5chan Android alkalmazásban és a Mac/Linux/Windows asztali alkalmazásban támogatott.",
"upload_not_supported_web_link_text": "Letöltés a hivatalos kiadási oldalról",
"board_subscriptions": "Fórum feliratkozások",
"chain_ticker": "lánc ticker"
"chain_ticker": "lánc ticker",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "Mengunggah media tidak didukung oleh browser Anda karena batasan teknis (CORS). Didukung di aplikasi 5chan Android dan aplikasi desktop Mac/Linux/Windows.",
"upload_not_supported_web_link_text": "Unduh dari halaman rilis resmi",
"board_subscriptions": "Langganan papan",
"chain_ticker": "ticker rantai"
"chain_ticker": "ticker rantai",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "Il caricamento di file multimediali non è supportato dal tuo browser a causa di limitazioni tecniche (CORS). È supportato nell'app 5chan per Android e nell'app desktop per Mac/Linux/Windows.",
"upload_not_supported_web_link_text": "Scarica dalla pagina di release ufficiale",
"board_subscriptions": "Iscrizioni alle bacheche",
"chain_ticker": "ticker di catena"
"chain_ticker": "ticker di catena",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "技術的な制限(CORS)のため、お使いのブラウザではメディアのアップロードがサポートされていません。5chan AndroidアプリとMac/Linux/Windowsデスクトップアプリではサポートされています。",
"upload_not_supported_web_link_text": "公式リリースページからダウンロード",
"board_subscriptions": "掲示板の購読",
"chain_ticker": "チェーンチッカー"
"chain_ticker": "チェーンチッカー",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "기술적 제한(CORS)으로 인해 브라우저에서는 미디어 업로드를 지원하지 않습니다. 5chan Android 앱과 Mac/Linux/Windows 데스크톱 앱에서는 지원됩니다.",
"upload_not_supported_web_link_text": "공식 출시 페이지에서 다운로드",
"board_subscriptions": "보드 구독",
"chain_ticker": "체인 티커"
"chain_ticker": "체인 티커",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "तांत्रिक मर्यादांमुळे (CORS) तुमचा ब्राउझर मीडिया अपलोड समर्थित नाही. ते 5chan Android अॅप आणि Mac/Linux/Windows डेस्कटॉप अॅपमध्ये समर्थित आहे.",
"upload_not_supported_web_link_text": "अधिकृत रिलीझ पेजवरून डाउनलोड करा",
"board_subscriptions": "बोर्ड सदस्यता",
"chain_ticker": "चेन टिकर"
"chain_ticker": "चेन टिकर",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "Mediabestanden uploaden wordt niet ondersteund door uw browser vanwege technische beperkingen (CORS). Het wordt ondersteund in de 5chan Android-app en de Mac/Linux/Windows desktop-app.",
"upload_not_supported_web_link_text": "Downloaden van de officiële releasepagina",
"board_subscriptions": "Boardabonnementen",
"chain_ticker": "kettingticker"
"chain_ticker": "kettingticker",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "Opplasting av medier støttes ikke av nettleseren din på grunn av tekniske begrensninger (CORS). Det støttes i 5chan Android-appen og Mac/Linux/Windows desktop-appen.",
"upload_not_supported_web_link_text": "Last ned fra den offisielle utgivelsessiden",
"board_subscriptions": "Brettabonnementer",
"chain_ticker": "kjede-ticker"
"chain_ticker": "kjede-ticker",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "Przesyłanie multimediów nie jest obsługiwane przez Twoją przeglądarkę z powodu ograniczeń technicznych (CORS). Jest obsługiwane w aplikacji 5chan na Androida i aplikacji komputerowej Mac/Linux/Windows.",
"upload_not_supported_web_link_text": "Pobierz ze strony oficjalnego wydania",
"board_subscriptions": "Subskrypcje tablic",
"chain_ticker": "ticker łańcucha"
"chain_ticker": "ticker łańcucha",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "O envio de mídia não é suportado pelo seu navegador devido a limitações técnicas (CORS). É suportado no aplicativo 5chan para Android e no aplicativo de desktop Mac/Linux/Windows.",
"upload_not_supported_web_link_text": "Baixar da página de lançamento oficial",
"board_subscriptions": "Assinaturas de quadros",
"chain_ticker": "ticker da cadeia"
"chain_ticker": "ticker da cadeia",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "Încărcarea mediilor nu este acceptată de browserul dvs. din cauza limitărilor tehnice (CORS). Este acceptată în aplicația 5chan pentru Android și aplicația de desktop Mac/Linux/Windows.",
"upload_not_supported_web_link_text": "Descărcare de pe pagina de lansare oficială",
"board_subscriptions": "Abonamente la panouri",
"chain_ticker": "ticker lanț"
"chain_ticker": "ticker lanț",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "Загрузка медиа не поддерживается вашим браузером из-за технических ограничений (CORS). Поддерживается в приложении 5chan для Android и десктопном приложении для Mac/Linux/Windows.",
"upload_not_supported_web_link_text": "Скачать со страницы официального релиза",
"board_subscriptions": "Подписки на доски",
"chain_ticker": "тикер блокчейна"
"chain_ticker": "тикер блокчейна",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "Ngarkimi i mediave nuk mbështetet nga shfletuesi juaj për shkak të kufizimeve teknike (CORS). Mbështetet në aplikacionin 5chan për Android dhe aplikacionin e desktopit Mac/Linux/Windows.",
"upload_not_supported_web_link_text": "Shkarkoni nga faqja e zyrës së publikimit",
"board_subscriptions": "Abonime në tabela",
"chain_ticker": "simboli i zinxhirit"
"chain_ticker": "simboli i zinxhirit",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "Uppladdning av media stöds inte av din webbläsare på grund av tekniska begränsningar (CORS). Det stöds i 5chan Android-appen och Mac/Linux/Windows skrivbordsappen.",
"upload_not_supported_web_link_text": "Ladda ner från den officiella utgivningssidan",
"board_subscriptions": "Brädeabonnemang",
"chain_ticker": "kedjeticker"
"chain_ticker": "kedjeticker",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "సాంకేతిక పరిమితుల కారణంగా (CORS) మీ బ్రౌజర్ మీడియా అప్‌లోడ్‌ను మద్దతు ఇవ్వదు. ఇది 5chan Android యాప్ మరియు Mac/Linux/Windows డెస్క్‌టాప్ యాప్‌లో మద్దతు ఇస్తుంది.",
"upload_not_supported_web_link_text": "అధికారిక రిలీజ్ పేజీ నుండి డౌన్‌లోడ్ చేయండి",
"board_subscriptions": "బోర్డ్ సబ్‌స్క్రిప్షన్లు",
"chain_ticker": "చైన్ టికర్"
"chain_ticker": "చైన్ టికర్",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "การอัปโหลดสื่อไม่รองรับโดยเบราว์เซอร์ของคุณเนื่องจากข้อจำกัดทางเทคนิค (CORS) รองรับในแอป 5chan สำหรับ Android และแอปเดสก์ท็อป Mac/Linux/Windows",
"upload_not_supported_web_link_text": "ดาวน์โหลดจากหน้าปล่อยรุ่นอย่างเป็นทางการ",
"board_subscriptions": "การสมัครสมาชิกบอร์ด",
"chain_ticker": "ไทเกอร์เชน"
"chain_ticker": "ไทเกอร์เชน",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "Teknik kısıtlamalar (CORS) nedeniyle medya yükleme tarayıcınız tarafından desteklenmiyor. 5chan Android uygulaması ve Mac/Linux/Windows masaüstü uygulamasında desteklenmektedir.",
"upload_not_supported_web_link_text": "Resmi sürüm sayfasından indir",
"board_subscriptions": "Pano abonelikleri",
"chain_ticker": "zincir ticker'ı"
"chain_ticker": "zincir ticker'ı",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "Завантаження медіа не підтримується вашим браузером через технічні обмеження (CORS). Підтримується в додатку 5chan для Android та десктопному додатку для Mac/Linux/Windows.",
"upload_not_supported_web_link_text": "Завантажити з офіційної сторінки випуску",
"board_subscriptions": "Підписки на дошки",
"chain_ticker": "тикер ланцюга"
"chain_ticker": "тикер ланцюга",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "تکنیکی پابندیوں (CORS) کی وجہ سے آپ کے براؤزر میڈیا اپ لوڈ کی حمایت نہیں کرتا۔ یہ 5chan Android ایپ اور Mac/Linux/Windows ڈیسک ٹاپ ایپ میں تعاون یافتہ ہے۔",
"upload_not_supported_web_link_text": "سرکاری ریلیز صفحے سے ڈاؤن لوڈ کریں",
"board_subscriptions": "بورڈ سبسکرپشنز",
"chain_ticker": "چین ٹکر"
"chain_ticker": "چین ٹکر",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "Tải lên phương tiện không được trình duyệt của bạn hỗ trợ do hạn chế kỹ thuật (CORS). Được hỗ trợ trong ứng dụng 5chan Android và ứng dụng desktop Mac/Linux/Windows.",
"upload_not_supported_web_link_text": "Tải xuống từ trang phát hành chính thức",
"board_subscriptions": "Đăng ký bảng",
"chain_ticker": "mã chuỗi"
"chain_ticker": "mã chuỗi",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4 -1
View File
@@ -280,5 +280,8 @@
"upload_not_supported_web_before_link": "由于技术限制(CORS),您的浏览器不支持上传媒体。在 5chan Android 应用和 Mac/Linux/Windows 桌面应用中支持。",
"upload_not_supported_web_link_text": "从官方发布页面下载",
"board_subscriptions": "板块订阅",
"chain_ticker": "链代币符号"
"chain_ticker": "链代币符号",
"enable_infinite_scroll": "Enable infinite scroll",
"prev": "Prev",
"next": "Next"
}
+4
View File
@@ -196,16 +196,19 @@ const App = () => (
<Route path='/faq' element={<FAQ />} />
<Route path='/rules/:boardIdentifier?' element={<Rules />} />
<Route element={<BoardLayout />}>
<Route path='/all/:timeFilterName/:pageNumber' element={null} />
<Route path='/all/:timeFilterName?' element={null} />
<Route path='/all/:timeFilterName?/settings' element={null} />
<Route path='/all/catalog/:timeFilterName?' element={null} />
<Route path='/all/catalog/:timeFilterName?/settings' element={null} />
<Route path='/subs/:timeFilterName/:pageNumber' element={null} />
<Route path='/subs/:timeFilterName?' element={null} />
<Route path='/subs/:timeFilterName?/settings' element={null} />
<Route path='/subs/catalog/:timeFilterName?' element={null} />
<Route path='/subs/catalog/:timeFilterName?/settings' element={null} />
<Route path='/mod/:timeFilterName/:pageNumber' element={null} />
<Route path='/mod/:timeFilterName?' element={null} />
<Route path='/mod/:timeFilterName?/settings' element={null} />
<Route path='/mod/catalog/:timeFilterName?' element={null} />
@@ -214,6 +217,7 @@ const App = () => (
<Route path='/mod/modqueue' element={<ModQueueRoute />} />
<Route path='/mod/modqueue/settings' element={<ModQueueRoute />} />
<Route path='/:boardIdentifier/:pageNumber' element={null} />
<Route path='/:boardIdentifier' element={null} />
<Route path='/:boardIdentifier/settings' element={null} />
<Route path='/:boardIdentifier/catalog' element={null} />
@@ -0,0 +1,36 @@
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 0 16px;
flex-wrap: wrap;
}
.paginationButton {
min-width: 32px;
padding: 4px 8px;
font-size: 14px;
cursor: pointer;
text-decoration: var(--button-text-decoration);
color: var(--button-desktop-text-color);
background: transparent;
border: 1px solid var(--border-color, #ccc);
border-radius: 4px;
}
.paginationButton:hover:not(:disabled) {
color: var(--button-desktop-text-color-hover);
cursor: pointer;
}
.paginationButton:disabled,
.paginationButton.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pageButtonActive {
font-weight: bold;
color: var(--button-desktop-text-color-hover);
}
@@ -0,0 +1,61 @@
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import styles from './board-pagination.module.css';
export interface BoardPaginationProps {
basePath: string;
currentPage: number;
totalPages: number;
}
const BoardPagination = ({ basePath, currentPage, totalPages }: BoardPaginationProps) => {
const { t } = useTranslation();
const pageHref = (page: number) => (page === 1 ? basePath : `${basePath}/${page}`);
const prevHref = currentPage > 1 ? pageHref(currentPage - 1) : undefined;
const nextHref = currentPage < totalPages ? pageHref(currentPage + 1) : undefined;
if (totalPages <= 1) {
return null;
}
const pageNumbers = Array.from({ length: totalPages }, (_, i) => i + 1);
return (
<div className={styles.pagination}>
{prevHref ? (
<Link to={prevHref} className={styles.paginationButton} aria-label={t('prev')}>
{t('prev')}
</Link>
) : (
<span className={`${styles.paginationButton} ${styles.disabled}`} aria-disabled>
{t('prev')}
</span>
)}
{pageNumbers.map((page) => {
const href = pageHref(page);
const isCurrent = page === currentPage;
return isCurrent ? (
<span key={page} className={`${styles.paginationButton} ${styles.pageButtonActive}`} aria-label={`Page ${page} (current)`} aria-current='page'>
{page}
</span>
) : (
<Link key={page} to={href} className={styles.paginationButton} aria-label={`Go to page ${page}`}>
{page}
</Link>
);
})}
{nextHref ? (
<Link to={nextHref} className={styles.paginationButton} aria-label={t('next')}>
{t('next')}
</Link>
) : (
<span className={`${styles.paginationButton} ${styles.disabled}`} aria-disabled>
{t('next')}
</span>
)}
</div>
);
};
export default BoardPagination;
+2
View File
@@ -0,0 +1,2 @@
export { default } from './board-pagination';
export type { BoardPaginationProps } from './board-pagination';
@@ -0,0 +1,102 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, Root } from 'react-dom/client';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import InterfaceSettings from '../interface-settings';
import useFeedViewSettingsStore from '../../../../stores/use-feed-view-settings-store';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { changeLanguage: vi.fn(), language: 'en' },
}),
}));
vi.mock('../../../../hooks/use-theme', () => ({
default: () => ['yotsuba', vi.fn()],
}));
vi.mock('../../../../stores/use-expanded-media-store', () => ({
default: () => ({ fitExpandedImagesToScreen: false, setFitExpandedImagesToScreen: vi.fn() }),
}));
vi.mock('../../../../stores/use-special-theme-store', () => ({
default: () => ({ isEnabled: false, setIsEnabled: vi.fn() }),
}));
vi.mock('../../../../lib/utils/time-utils', () => ({
isChristmas: () => false,
}));
vi.mock('../../version', () => ({
default: () => null,
}));
/** Minimal component that subscribes to feed view settings (like Board) to verify re-renders. */
const BoardModeIndicator = () => {
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
return <span data-testid='board-mode'>{enableInfiniteScroll ? 'infinite' : 'pagination'}</span>;
};
const STORAGE_KEY = 'feed-view-settings-store';
let root: Root;
let container: HTMLDivElement;
const render = (children: React.ReactNode) => {
act(() => {
root.render(createElement(MemoryRouter, {}, children));
});
};
describe('InterfaceSettings', () => {
let setItemSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
localStorage.removeItem(STORAGE_KEY);
useFeedViewSettingsStore.getState().setEnableInfiniteScroll(false);
setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
setItemSpy.mockRestore();
});
it('renders enable_infinite_scroll checkbox unchecked by default', () => {
render(createElement(InterfaceSettings));
const label = Array.from(container.querySelectorAll('label')).find((l) => l.textContent?.toLowerCase().includes('enable_infinite_scroll'));
expect(label).toBeTruthy();
const checkbox = label?.querySelector<HTMLInputElement>('input[type="checkbox"]');
expect(checkbox).toBeTruthy();
expect(checkbox?.checked).toBe(false);
});
it('toggling checkbox updates persisted state and re-renders board mode', async () => {
render(createElement(React.Fragment, {}, createElement(InterfaceSettings), createElement(BoardModeIndicator)));
const label = Array.from(container.querySelectorAll('label')).find((l) => l.textContent?.toLowerCase().includes('enable_infinite_scroll'));
const checkbox = label?.querySelector<HTMLInputElement>('input[type="checkbox"]');
expect(checkbox).toBeTruthy();
expect(container.querySelector('[data-testid="board-mode"]')?.textContent).toBe('pagination');
await act(async () => {
checkbox?.click();
});
expect(useFeedViewSettingsStore.getState().enableInfiniteScroll).toBe(true);
expect(checkbox?.checked).toBe(true);
expect(container.querySelector('[data-testid="board-mode"]')?.textContent).toBe('infinite');
expect(setItemSpy).toHaveBeenCalledWith(STORAGE_KEY, expect.stringContaining('"enableInfiniteScroll":true'));
});
});
@@ -5,6 +5,7 @@ import packageJson from '../../../../package.json';
import styles from './interface-settings.module.css';
import capitalize from 'lodash/capitalize';
import useExpandedMediaStore from '../../../stores/use-expanded-media-store';
import useFeedViewSettingsStore from '../../../stores/use-feed-view-settings-store';
import useSpecialThemeStore from '../../../stores/use-special-theme-store';
import { isChristmas } from '../../../lib/utils/time-utils';
import Version from '../../version';
@@ -124,6 +125,7 @@ const InterfaceLanguage = () => {
const InterfaceSettings = () => {
const { t } = useTranslation();
const { fitExpandedImagesToScreen, setFitExpandedImagesToScreen } = useExpandedMediaStore();
const { enableInfiniteScroll, setEnableInfiniteScroll } = useFeedViewSettingsStore();
return (
<div className={styles.interfaceSettings}>
@@ -146,6 +148,12 @@ const InterfaceSettings = () => {
</label>
<div className={styles.settingTip}>{capitalize(t('fit_expanded_images_to_screen_tip'))}</div>
</div>
<div className={styles.setting}>
<label>
<input type='checkbox' checked={enableInfiniteScroll} onChange={(e) => setEnableInfiniteScroll(e.target.checked)} />
{capitalize(t('enable_infinite_scroll'))}
</label>
</div>
</div>
);
};
+40 -20
View File
@@ -16,7 +16,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": false,
"noSpoilerReplies": false
"noSpoilerReplies": false,
"postsPerPage": 15
}
},
{
@@ -31,7 +32,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 20
}
},
{
@@ -46,7 +48,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 20
}
},
{
@@ -61,7 +64,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 15
}
},
{
@@ -76,7 +80,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 15
}
},
{
@@ -91,7 +96,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": false,
"noSpoilerReplies": false
"noSpoilerReplies": false,
"postsPerPage": 20
}
},
{
@@ -106,7 +112,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 15
}
},
{
@@ -121,7 +128,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 15
}
},
{
@@ -136,7 +144,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 15
}
},
{
@@ -151,7 +160,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 15
}
},
{
@@ -166,7 +176,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 15
}
},
{
@@ -181,7 +192,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 15
}
},
{
@@ -196,7 +208,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 15
}
},
{
@@ -211,7 +224,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 15
}
},
{
@@ -226,7 +240,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": false,
"noSpoilerReplies": false
"noSpoilerReplies": false,
"postsPerPage": 15
}
},
{
@@ -241,7 +256,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": false,
"noSpoilerReplies": false
"noSpoilerReplies": false,
"postsPerPage": 15
}
},
{
@@ -256,7 +272,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 15
}
},
{
@@ -271,7 +288,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 15
}
},
{
@@ -286,7 +304,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 15
}
},
{
@@ -301,7 +320,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 15
}
}
]
+20
View File
@@ -0,0 +1,20 @@
import { useMemo } from 'react';
import type { DirectoryCommunity } from './use-directories';
import { computeGuiPostsPerPage, getBoardFeedPageSizeConstants, type CommunityWithPostsPerPage } from '../lib/utils/board-feed-pagination';
export type BoardFeedPageSize = ReturnType<typeof getBoardFeedPageSizeConstants>;
/**
* Compute board feed page-size values from directory community.
*
* - Single directory board: uses community.features.postsPerPage when valid numeric > 0
* - Missing metadata, non-directory board, /all, /subs, /mod: fallback 15
*
* Returns: guiPostsPerPage, maxGuiPages (10), paginationFeedPostsPerPage, infiniteFeedPostsPerPage
*/
export const useBoardFeedPageSize = (community?: DirectoryCommunity | null): BoardFeedPageSize => {
return useMemo(() => {
const guiPostsPerPage = computeGuiPostsPerPage(community as CommunityWithPostsPerPage | null | undefined);
return getBoardFeedPageSizeConstants(guiPostsPerPage);
}, [community]);
};
+1
View File
@@ -9,6 +9,7 @@ export interface DirectoriesMetadata {
}
export interface DirectoryFeatures {
postsPerPage?: number;
pseudonymityMode?: string;
nsfw?: boolean;
noSpoilers?: boolean;
+6
View File
@@ -1,5 +1,6 @@
import assert from 'assert';
import { useParams } from 'react-router-dom';
import { isBoardFeedPageNumber } from '../lib/utils/route-utils';
// the timestamp the last time the user visited
const lastVisitTimestamp = localStorage.getItem('5chanLastVisitTimestamp');
@@ -110,6 +111,11 @@ const useTimeFilter = () => {
const params = useParams();
let timeFilterName = params.timeFilterName;
// Ignore when param is a board feed page number (e.g. /all/3, /biz/2)
if (timeFilterName && isBoardFeedPageNumber(timeFilterName)) {
timeFilterName = undefined;
}
// the default time filter is the last visit time filter
if (!timeFilterName) {
timeFilterName = lastVisitTimeFilterName;
@@ -0,0 +1,105 @@
import { describe, it, expect } from 'vitest';
import { computeGuiPostsPerPage, getBoardFeedPageSizeConstants, getPageSlice } from '../board-feed-pagination';
describe('computeGuiPostsPerPage', () => {
it('uses community.features.postsPerPage when valid numeric > 0', () => {
expect(computeGuiPostsPerPage({ features: { postsPerPage: 15 } })).toBe(15);
expect(computeGuiPostsPerPage({ features: { postsPerPage: 20 } })).toBe(20);
expect(computeGuiPostsPerPage({ features: { postsPerPage: 1 } })).toBe(1);
expect(computeGuiPostsPerPage({ features: { postsPerPage: 100 } })).toBe(100);
});
it('falls back to 15 when postsPerPage is missing, invalid, or non-positive', () => {
expect(computeGuiPostsPerPage(undefined)).toBe(15);
expect(computeGuiPostsPerPage(null)).toBe(15);
expect(computeGuiPostsPerPage({})).toBe(15);
expect(computeGuiPostsPerPage({ features: {} })).toBe(15);
expect(computeGuiPostsPerPage({ features: { postsPerPage: 0 } })).toBe(15);
expect(computeGuiPostsPerPage({ features: { postsPerPage: -1 } })).toBe(15);
expect(computeGuiPostsPerPage({ features: { postsPerPage: NaN } })).toBe(15);
expect(computeGuiPostsPerPage({ features: { postsPerPage: Infinity } })).toBe(15);
expect(computeGuiPostsPerPage({ features: { postsPerPage: '15' as unknown as number } })).toBe(15);
expect(computeGuiPostsPerPage({ features: { postsPerPage: 15.7 } })).toBe(15);
});
});
describe('getBoardFeedPageSizeConstants', () => {
it('directory page size 15 -> pagination feed size 150, 10-page cap', () => {
const c = getBoardFeedPageSizeConstants(15);
expect(c.guiPostsPerPage).toBe(15);
expect(c.maxGuiPages).toBe(10);
expect(c.paginationFeedPostsPerPage).toBe(150);
expect(c.infiniteFeedPostsPerPage).toBe(15);
});
it('directory page size 20 -> pagination feed size 200', () => {
const c = getBoardFeedPageSizeConstants(20);
expect(c.guiPostsPerPage).toBe(20);
expect(c.maxGuiPages).toBe(10);
expect(c.paginationFeedPostsPerPage).toBe(200);
expect(c.infiniteFeedPostsPerPage).toBe(20);
});
});
describe('getPageSlice', () => {
const guiPostsPerPage = 15;
const maxGuiPages = 10;
it('returns correct slice for page 1', () => {
const items = Array.from({ length: 200 }, (_, i) => i);
const slice = getPageSlice(items, 1, guiPostsPerPage, maxGuiPages);
expect(slice).toHaveLength(15);
expect(slice[0]).toBe(0);
expect(slice[14]).toBe(14);
});
it('returns correct slice for middle page', () => {
const items = Array.from({ length: 200 }, (_, i) => i);
const slice = getPageSlice(items, 5, guiPostsPerPage, maxGuiPages);
expect(slice).toHaveLength(15);
expect(slice[0]).toBe(60);
expect(slice[14]).toBe(74);
});
it('returns correct slice for last page', () => {
const items = Array.from({ length: 200 }, (_, i) => i);
const slice = getPageSlice(items, 10, guiPostsPerPage, maxGuiPages);
expect(slice).toHaveLength(15);
expect(slice[0]).toBe(135);
expect(slice[14]).toBe(149);
});
it('returns partial slice when total items do not fill last page', () => {
const items = Array.from({ length: 37 }, (_, i) => i);
const slice = getPageSlice(items, 3, guiPostsPerPage, maxGuiPages);
expect(slice).toHaveLength(7);
expect(slice[0]).toBe(30);
expect(slice[6]).toBe(36);
});
it('clamps page when total items shrink (requested page beyond valid range)', () => {
const items = Array.from({ length: 20 }, (_, i) => i); // only 2 pages of 15
const slice = getPageSlice(items, 10, guiPostsPerPage, maxGuiPages);
expect(slice).toHaveLength(5); // page 2 has 5 items (15-19)
expect(slice[0]).toBe(15);
expect(slice[4]).toBe(19);
});
it('clamps page 1 when requesting page 0 or negative', () => {
const items = Array.from({ length: 50 }, (_, i) => i);
const slice0 = getPageSlice(items, 0, guiPostsPerPage, maxGuiPages);
const sliceNeg = getPageSlice(items, -1, guiPostsPerPage, maxGuiPages);
expect(slice0).toEqual(getPageSlice(items, 1, guiPostsPerPage, maxGuiPages));
expect(sliceNeg).toEqual(getPageSlice(items, 1, guiPostsPerPage, maxGuiPages));
});
it('returns empty array when guiPostsPerPage or maxGuiPages is 0', () => {
const items = [1, 2, 3];
expect(getPageSlice(items, 1, 0, 10)).toEqual([]);
expect(getPageSlice(items, 1, 15, 0)).toEqual([]);
});
it('returns empty array when items is empty', () => {
expect(getPageSlice([], 1, guiPostsPerPage, maxGuiPages)).toEqual([]);
});
});
+49
View File
@@ -0,0 +1,49 @@
const GUI_POSTS_PER_PAGE_FALLBACK = 15;
const MAX_GUI_PAGES = 10;
/** Minimal shape for directory community with optional postsPerPage */
export interface CommunityWithPostsPerPage {
features?: { postsPerPage?: number };
}
/**
* Compute posts-per-page for the GUI from directory features.
* Single directory board: uses community.features.postsPerPage when valid numeric > 0.
* Missing metadata, non-directory board, /all, /subs, /mod: fallback 15.
*/
export const computeGuiPostsPerPage = (community?: CommunityWithPostsPerPage | null): number => {
const value = community?.features?.postsPerPage;
if (typeof value === 'number' && value > 0 && Number.isFinite(value)) {
return Math.floor(value);
}
return GUI_POSTS_PER_PAGE_FALLBACK;
};
/**
* Board feed pagination constants.
* Fallback 15 and max 10 GUI pages are fixed.
*/
export const getBoardFeedPageSizeConstants = (guiPostsPerPage: number) => ({
guiPostsPerPage,
maxGuiPages: MAX_GUI_PAGES,
paginationFeedPostsPerPage: guiPostsPerPage * MAX_GUI_PAGES,
infiniteFeedPostsPerPage: guiPostsPerPage,
});
/**
* Slice items for a given page with page clamping.
* When total items shrink, the requested page is clamped to the last valid page.
*/
export const getPageSlice = <T>(items: T[], page: number, guiPostsPerPage: number, maxGuiPages: number): T[] => {
if (guiPostsPerPage <= 0 || maxGuiPages <= 0) {
return [];
}
const totalItems = items.length;
const totalGuiPages = Math.min(maxGuiPages, Math.ceil(totalItems / guiPostsPerPage) || 1);
const clampedPage = Math.max(1, Math.min(page, totalGuiPages));
const start = (clampedPage - 1) * guiPostsPerPage;
const end = start + guiPostsPerPage;
return items.slice(start, end);
};
+30 -2
View File
@@ -118,8 +118,10 @@ export const isFeedRoute = (pathname: string): boolean => {
if (segments.length >= 1) {
if (segments.length === 1) return true;
if (segments.length === 2 && segments[1] === 'catalog') return true;
if (segments.length === 2 && /^(?:\d+(?:h|d|w|m|y)|all)$/.test(segments[1])) return true;
if (segments.length === 2 && (/^(?:\d+(?:h|d|w|m|y)|all)$/.test(segments[1]) || /^([1-9]|10)$/.test(segments[1]))) return true;
if (segments.length === 3 && segments[1] === 'catalog' && /^(?:\d+(?:h|d|w|m|y)|all)$/.test(segments[2])) return true;
if (segments.length === 3 && /^(?:\d+(?:h|d|w|m|y)|all)$/.test(segments[1]) && /^([1-9]|10)$/.test(segments[2])) return true;
if (segments.length === 2 && segments[0] !== 'all' && segments[0] !== 'subs' && segments[0] !== 'mod' && /^([1-9]|10)$/.test(segments[1])) return true;
}
return false;
@@ -143,6 +145,32 @@ export const isModQueueRoute = (pathname: string): boolean => {
return normalizedPath.includes('/modqueue');
};
/** Page numbers 110 for board feed pagination */
export const BOARD_PAGE_REGEX = /^([1-9]|10)$/;
export const isBoardFeedPageNumber = (segment: string): boolean => BOARD_PAGE_REGEX.test(segment);
/** Strip trailing page number (110) from path for feed cache key */
export const stripPageFromFeedPath = (path: string): string => {
const segments = path.split('/').filter(Boolean);
if (segments.length > 1 && isBoardFeedPageNumber(segments[segments.length - 1])) {
return '/' + segments.slice(0, -1).join('/');
}
return path;
};
/** Parse current page number (110) from feed pathname; returns 1 if none */
export const getPageFromFeedPath = (pathname: string): number => {
const normalized = pathname.replace(/\/settings$/, '').replace(/\/$/, '');
const segments = normalized.split('/').filter(Boolean);
const last = segments[segments.length - 1];
if (last && isBoardFeedPageNumber(last)) {
const n = parseInt(last, 10);
return Math.min(10, Math.max(1, n));
}
return 1;
};
export const getFeedCacheKey = (pathname: string): string | null => {
let normalizedPath = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
normalizedPath = normalizedPath.replace(/\/settings$/, '');
@@ -161,7 +189,7 @@ export const getFeedCacheKey = (pathname: string): string | null => {
}
if (isFeedRoute(pathname)) {
return normalizedPath;
return stripPageFromFeedPath(normalizedPath);
}
return null;
@@ -0,0 +1,36 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import useFeedViewSettingsStore from '../use-feed-view-settings-store';
const STORAGE_KEY = 'feed-view-settings-store';
describe('useFeedViewSettingsStore', () => {
let setItemSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
localStorage.removeItem(STORAGE_KEY);
setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
});
afterEach(() => {
setItemSpy.mockRestore();
useFeedViewSettingsStore.getState().setEnableInfiniteScroll(false);
});
it('defaults enableInfiniteScroll to false', () => {
const { enableInfiniteScroll } = useFeedViewSettingsStore.getState();
expect(enableInfiniteScroll).toBe(false);
});
it('sets enableInfiniteScroll to true when setEnableInfiniteScroll is called with true', () => {
useFeedViewSettingsStore.getState().setEnableInfiniteScroll(true);
const { enableInfiniteScroll } = useFeedViewSettingsStore.getState();
expect(enableInfiniteScroll).toBe(true);
});
it('persists state to localStorage when setEnableInfiniteScroll is called', () => {
useFeedViewSettingsStore.getState().setEnableInfiniteScroll(true);
expect(setItemSpy).toHaveBeenCalledWith(STORAGE_KEY, expect.stringContaining('"state":'));
expect(setItemSpy).toHaveBeenCalledWith(STORAGE_KEY, expect.stringContaining('"enableInfiniteScroll":true'));
});
});
@@ -0,0 +1,21 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface FeedViewSettingsState {
enableInfiniteScroll: boolean;
setEnableInfiniteScroll: (enable: boolean) => void;
}
const useFeedViewSettingsStore = create<FeedViewSettingsState>()(
persist(
(set) => ({
enableInfiniteScroll: false,
setEnableInfiniteScroll: (enable) => set({ enableInfiniteScroll: enable }),
}),
{
name: 'feed-view-settings-store',
},
),
);
export default useFeedViewSettingsStore;
+114 -25
View File
@@ -1,21 +1,25 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Link, useLocation, useNavigationType, useParams } from 'react-router-dom';
import { Link, useLocation, useNavigate, useNavigationType, useParams } from 'react-router-dom';
import { Comment, useAccount, useAccountComments, useAccountSubplebbits, useFeed, useSubplebbit } from '@plebbit/plebbit-react-hooks';
import { useSubplebbitField } from '../../hooks/use-stable-subplebbit';
import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
import { Trans, useTranslation } from 'react-i18next';
import styles from './board.module.css';
import { shouldShowSnow } from '../../lib/snow';
import { useDirectoryAddresses, useDirectories } from '../../hooks/use-directories';
import { useDirectoryAddresses, useDirectories, useDirectoryByAddress } from '../../hooks/use-directories';
import { useFilteredDirectoryAddresses } from '../../hooks/use-filtered-directory-addresses';
import { useResolvedSubplebbitAddress, useBoardPath } from '../../hooks/use-resolved-subplebbit-address';
import { useFeedStateString } from '../../hooks/use-state-string';
import useTimeFilter, { timeFilterNameToSeconds } from '../../hooks/use-time-filter';
import useFeedResetStore from '../../stores/use-feed-reset-store';
import useSortingStore from '../../stores/use-sorting-store';
import { getSubplebbitAddress, isDirectoryBoard } from '../../lib/utils/route-utils';
import useFeedViewSettingsStore from '../../stores/use-feed-view-settings-store';
import { useBoardFeedPageSize } from '../../hooks/use-board-feed-page-size';
import { getPageSlice } from '../../lib/utils/board-feed-pagination';
import { getPageFromFeedPath, getSubplebbitAddress, isDirectoryBoard, stripPageFromFeedPath } from '../../lib/utils/route-utils';
import ErrorDisplay from '../../components/error-display/error-display';
import LoadingEllipsis from '../../components/loading-ellipsis';
import BoardPagination from '../../components/board-pagination';
import { Post } from '../post';
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
@@ -39,6 +43,8 @@ interface BoardFooterProps {
subplebbitState: string | undefined;
subscriptionsLength: number;
accountSubplebbitAddressesLength: number;
/** In pagination mode, suppress infinite-scroll loading cues */
showLoadingEllipsis?: boolean;
}
// Defined outside Board to preserve component identity across renders (Virtuoso optimization)
@@ -63,6 +69,7 @@ const BoardFooter = ({
subplebbitState,
subscriptionsLength,
accountSubplebbitAddressesLength,
showLoadingEllipsis = true,
}: BoardFooterProps) => {
const { t } = useTranslation();
@@ -141,7 +148,7 @@ const BoardFooter = ({
) : isInModView && accountSubplebbitAddressesLength === 0 ? (
<span className='red'>{t('not_mod_of_any_board')}</span>
) : (
hasMore && <LoadingEllipsis string={loadingStateString} />
showLoadingEllipsis && hasMore && <LoadingEllipsis string={loadingStateString} />
)}
</div>
</div>
@@ -201,16 +208,38 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
const timeFilterName = timeFilterNameFromCache || timeFilterNameFromHook;
const timeFilterSeconds = timeFilterNameFromCache ? timeFilterNameToSeconds(timeFilterNameFromCache) : timeFilterSecondsFromHook;
const feedOptions = {
subplebbitAddresses,
sortType,
postsPerPage: isInAllView || isInSubscriptionsView || isInModView ? 5 : 25,
...(isInAllView || isInSubscriptionsView || isInModView ? { newerThan: timeFilterSeconds } : {}),
};
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
const community = useDirectoryByAddress(isInAllView || isInSubscriptionsView || isInModView ? undefined : subplebbitAddress);
const { guiPostsPerPage, maxGuiPages, paginationFeedPostsPerPage, infiniteFeedPostsPerPage } = useBoardFeedPageSize(community);
const feedOptions = useMemo(
() => ({
subplebbitAddresses,
sortType,
postsPerPage: enableInfiniteScroll ? infiniteFeedPostsPerPage : paginationFeedPostsPerPage,
...(isInAllView || isInSubscriptionsView || isInModView ? { newerThan: timeFilterSeconds } : {}),
}),
[
subplebbitAddresses,
sortType,
enableInfiniteScroll,
infiniteFeedPostsPerPage,
paginationFeedPostsPerPage,
isInAllView,
isInSubscriptionsView,
isInModView,
timeFilterSeconds,
],
);
const { feed, hasMore, loadMore, reset, subplebbitAddressesWithNewerPosts } = useFeed(feedOptions);
const { accountComments } = useAccountComments();
const feedContextKey = `${isInAllView ? 'all' : isInSubscriptionsView ? 'subs' : isInModView ? 'mod' : (subplebbitAddress ?? 'board')}-${sortType}-${timeFilterSeconds}-${viewType ?? 'board'}-${enableInfiniteScroll}`;
const pathWithoutSettings = location.pathname.replace(/\/settings$/, '');
const currentPage = getPageFromFeedPath(pathWithoutSettings);
const paginationBasePath = stripPageFromFeedPath(pathWithoutSettings);
const resetTriggeredRef = useRef(false);
const setResetFunction = useFeedResetStore((state) => state.setResetFunction);
@@ -249,6 +278,32 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
return newFeed;
}, [feed, filteredComments]);
const cappedFeed = useMemo(
() => (enableInfiniteScroll ? combinedFeed : combinedFeed.slice(0, guiPostsPerPage * maxGuiPages)),
[enableInfiniteScroll, combinedFeed, guiPostsPerPage, maxGuiPages],
);
const totalPages = useMemo(() => Math.min(maxGuiPages, Math.ceil(cappedFeed.length / guiPostsPerPage) || 1), [cappedFeed.length, guiPostsPerPage, maxGuiPages]);
const currentPageFeed = useMemo(
() => (enableInfiniteScroll ? [] : getPageSlice(cappedFeed, currentPage, guiPostsPerPage, maxGuiPages)),
[enableInfiniteScroll, cappedFeed, currentPage, guiPostsPerPage, maxGuiPages],
);
const navigate = useNavigate();
useEffect(() => {
if (!enableInfiniteScroll && currentPage > totalPages && totalPages > 0) {
const targetPage = totalPages;
const targetPath = targetPage === 1 ? paginationBasePath : `${paginationBasePath}/${targetPage}`;
navigate(targetPage === 1 ? paginationBasePath : `${paginationBasePath}/${targetPage}`, { replace: true });
}
}, [enableInfiniteScroll, currentPage, totalPages, paginationBasePath, navigate]);
// Scroll to top instantly when page changes in pagination mode
useEffect(() => {
if (!enableInfiniteScroll) {
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
}
}, [enableInfiniteScroll, currentPage]);
useEffect(() => {
if (filteredComments.length > 0 && !resetTriggeredRef.current) {
reset();
@@ -328,6 +383,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
subplebbitState={subplebbitState}
subscriptionsLength={subscriptions?.length || 0}
accountSubplebbitAddressesLength={accountSubplebbitAddresses?.length || 0}
showLoadingEllipsis={enableInfiniteScroll}
/>
),
}),
@@ -350,6 +406,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
subplebbitState,
subscriptions?.length,
accountSubplebbitAddresses?.length,
enableInfiniteScroll,
],
);
@@ -415,25 +472,56 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
<ErrorDisplay error={subplebbitError} />
</div>
)}
{/* Use Virtuoso for infinite scroll only when there's more content to paginate */}
{hasMore ? (
<Virtuoso
increaseViewportBy={{ bottom: 1200, top: 1200 }}
totalCount={combinedFeed.length}
data={combinedFeed}
itemContent={(index, post) => <Post index={index} post={post} />}
useWindowScroll={true}
components={footerComponents}
endReached={loadMore}
ref={virtuosoRef}
restoreStateFrom={lastVirtuosoState}
initialScrollTop={lastVirtuosoState?.scrollTop}
/>
{/* Infinite mode: Virtuoso when hasMore, else plain list */}
{enableInfiniteScroll ? (
hasMore ? (
<Virtuoso
increaseViewportBy={{ bottom: 1200, top: 1200 }}
totalCount={combinedFeed.length}
data={combinedFeed}
itemContent={(index, post) => <Post index={index} post={post} />}
useWindowScroll={true}
components={footerComponents}
endReached={loadMore}
ref={virtuosoRef}
restoreStateFrom={lastVirtuosoState}
initialScrollTop={lastVirtuosoState?.scrollTop}
/>
) : (
<>
{combinedFeed.map((post, index) => (
<Post key={post.cid} index={index} post={post} />
))}
<BoardFooter
subplebbitAddresses={subplebbitAddresses}
hasMore={hasMore}
combinedFeedLength={combinedFeed.length}
subplebbitAddressesWithNewerPosts={subplebbitAddressesWithNewerPosts}
onNewerPostsClick={handleNewerPostsButtonClick}
isInAllView={isInAllView}
isInSubscriptionsView={isInSubscriptionsView}
isInModView={isInModView}
showMorePostsSuggestion={showMorePostsSuggestion}
feedLength={feedLength}
weeklyFeedLength={weeklyFeedLength}
monthlyFeedLength={monthlyFeedLength}
yearlyFeedLength={yearlyFeedLength}
boardPath={boardPath}
currentTimeFilterName={currentTimeFilterName}
subplebbitState={subplebbitState}
subscriptionsLength={subscriptions?.length || 0}
accountSubplebbitAddressesLength={accountSubplebbitAddresses?.length || 0}
showLoadingEllipsis={true}
/>
</>
)
) : (
/* Pagination mode: plain list, no Virtuoso, no loadMore */
<>
{combinedFeed.map((post, index) => (
{currentPageFeed.map((post, index) => (
<Post key={post.cid} index={index} post={post} />
))}
<BoardPagination basePath={paginationBasePath} currentPage={currentPage} totalPages={totalPages} />
<BoardFooter
subplebbitAddresses={subplebbitAddresses}
hasMore={hasMore}
@@ -453,6 +541,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
subplebbitState={subplebbitState}
subscriptionsLength={subscriptions?.length || 0}
accountSubplebbitAddressesLength={accountSubplebbitAddresses?.length || 0}
showLoadingEllipsis={false}
/>
</>
)}