feat(home stats): load stats progressively

This commit is contained in:
Tommaso Casaburi
2026-06-03 14:47:51 +07:00
parent 84f357cba1
commit d357514397
40 changed files with 420 additions and 181 deletions
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "جي آي إف",
"total_posts": "إجمالي المشاركات:",
"current_users": "المستخدمون الحاليون:",
"boards_tracked": "المنتديات المتابعة:",
"challenge_counter": "{{index}} من {{total}}",
"embed": "تضمين",
"remove": "إزالة",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "فيديو YouTube",
"youtube_thumbnail_link_conversion_notice": "تم اكتشاف رابط YouTube. جارٍ لصق الصورة المصغرة خلال {{count}}..."
"youtube_thumbnail_link_conversion_notice": "تم اكتشاف رابط YouTube. جارٍ لصق الصورة المصغرة خلال {{count}}...",
"stats_scope_all_listed_boards": "جميع اللوحات المُدرجة (بطيء)",
"stats_scope_directory_boards": "لوحات الدليل فقط (سريع)",
"boards_loaded": "اللوحات المحملة:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "জিআইএফ",
"total_posts": "মোট পোস্টসমূহ:",
"current_users": "বর্তমান ব্যবহারকারীরা:",
"boards_tracked": "ট্র্যাক করা বোর্ডসমূহ:",
"challenge_counter": "{{index}} থেকে {{total}}",
"embed": "এম্বেড",
"remove": "অপসারণ",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube ভিডিও",
"youtube_thumbnail_link_conversion_notice": "YouTube লিংক শনাক্ত হয়েছে। {{count}}-এর মধ্যে থাম্বনেইল ছবি পেস্ট করা হচ্ছে..."
"youtube_thumbnail_link_conversion_notice": "YouTube লিংক শনাক্ত হয়েছে। {{count}}-এর মধ্যে থাম্বনেইল ছবি পেস্ট করা হচ্ছে...",
"stats_scope_all_listed_boards": "সব তালিকাভুক্ত বোর্ড (ধীর)",
"stats_scope_directory_boards": "শুধু ডিরেক্টরি বোর্ড (দ্রুত)",
"boards_loaded": "লোড করা বোর্ড:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "GIF",
"total_posts": "Celkem příspěvků:",
"current_users": "současní uživatelé:",
"boards_tracked": "sledované nástěnky:",
"challenge_counter": "{{index}} z {{total}}",
"embed": "vložit",
"remove": "odstranit",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "video YouTube",
"youtube_thumbnail_link_conversion_notice": "Byl zjištěn odkaz na YouTube. Vkládám náhledový obrázek za {{count}}..."
"youtube_thumbnail_link_conversion_notice": "Byl zjištěn odkaz na YouTube. Vkládám náhledový obrázek za {{count}}...",
"stats_scope_all_listed_boards": "Všechny uvedené nástěnky (pomalé)",
"stats_scope_directory_boards": "Pouze nástěnky v adresáři (rychlé)",
"boards_loaded": "načtené nástěnky:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "GIF",
"total_posts": "Samlede opslag:",
"current_users": "nuværende brugere:",
"boards_tracked": "sporede opslagstavler:",
"challenge_counter": "{{index}} af {{total}}",
"embed": "integrer",
"remove": "fjern",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube-video",
"youtube_thumbnail_link_conversion_notice": "YouTube-link registreret. Indsætter miniaturebillede om {{count}}..."
"youtube_thumbnail_link_conversion_notice": "YouTube-link registreret. Indsætter miniaturebillede om {{count}}...",
"stats_scope_all_listed_boards": "Alle viste boards (langsomt)",
"stats_scope_directory_boards": "Kun boards i kataloget (hurtigt)",
"boards_loaded": "indlæste boards:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "GIF",
"total_posts": "Gesamtanzahl der Beiträge:",
"current_users": "aktuelle Benutzer:",
"boards_tracked": "verfolgte Boards:",
"challenge_counter": "{{index}} von {{total}}",
"embed": "einbetten",
"remove": "entfernen",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube-Video",
"youtube_thumbnail_link_conversion_notice": "YouTube-Link erkannt. Vorschaubild wird in {{count}} eingefügt..."
"youtube_thumbnail_link_conversion_notice": "YouTube-Link erkannt. Vorschaubild wird in {{count}} eingefügt...",
"stats_scope_all_listed_boards": "Alle aufgelisteten Boards (langsam)",
"stats_scope_directory_boards": "Nur Verzeichnis-Boards (schnell)",
"boards_loaded": "geladene Boards:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "GIF",
"total_posts": "Συνολικές Δημοσιεύσεις:",
"current_users": "τρέχοντες χρήστες:",
"boards_tracked": "πίνακες που παρακολουθούνται:",
"challenge_counter": "{{index}} από {{total}}",
"embed": "ενσωμάτωση",
"remove": "αφαίρεση",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "βίντεο YouTube",
"youtube_thumbnail_link_conversion_notice": "Εντοπίστηκε σύνδεσμος YouTube. Επικόλληση μικρογραφίας σε {{count}}..."
"youtube_thumbnail_link_conversion_notice": "Εντοπίστηκε σύνδεσμος YouTube. Επικόλληση μικρογραφίας σε {{count}}...",
"stats_scope_all_listed_boards": "Όλα τα καταχωρισμένα boards (αργό)",
"stats_scope_directory_boards": "Μόνο boards καταλόγου (γρήγορο)",
"boards_loaded": "φορτωμένοι πίνακες:"
}
+4 -2
View File
@@ -56,7 +56,6 @@
"gif": "GIF",
"total_posts": "Total Posts:",
"current_users": "current users:",
"boards_tracked": "boards tracked:",
"challenge_counter": "{{index}} of {{total}}",
"embed": "embed",
"remove": "remove",
@@ -420,5 +419,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube video",
"youtube_thumbnail_link_conversion_notice": "YouTube link detected. Pasting thumbnail image in {{count}}..."
"youtube_thumbnail_link_conversion_notice": "YouTube link detected. Pasting thumbnail image in {{count}}...",
"stats_scope_all_listed_boards": "All listed boards (slow)",
"stats_scope_directory_boards": "Directory boards only (fast)",
"boards_loaded": "boards loaded:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "GIF",
"total_posts": "Total de publicaciones:",
"current_users": "usuarios actuales:",
"boards_tracked": "foros seguidos:",
"challenge_counter": "{{index}} de {{total}}",
"embed": "incrustar",
"remove": "eliminar",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "video de YouTube",
"youtube_thumbnail_link_conversion_notice": "Se detectó un enlace de YouTube. Pegando imagen en miniatura en {{count}}..."
"youtube_thumbnail_link_conversion_notice": "Se detectó un enlace de YouTube. Pegando imagen en miniatura en {{count}}...",
"stats_scope_all_listed_boards": "Todos los boards listados (lento)",
"stats_scope_directory_boards": "Solo boards del directorio (rápido)",
"boards_loaded": "tableros cargados:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "گیف",
"total_posts": "کل پست‌ها:",
"current_users": "کاربران فعلی:",
"boards_tracked": "تالارهای پیگیری شده:",
"challenge_counter": "{{index}} از {{total}}",
"embed": "جاسازی",
"remove": "حذف",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "ویدیوی YouTube",
"youtube_thumbnail_link_conversion_notice": "پیوند YouTube شناسایی شد. در حال چسباندن تصویر بندانگشتی در {{count}}..."
"youtube_thumbnail_link_conversion_notice": "پیوند YouTube شناسایی شد. در حال چسباندن تصویر بندانگشتی در {{count}}...",
"stats_scope_all_listed_boards": "همهٔ بردهای فهرست‌شده (کند)",
"stats_scope_directory_boards": "فقط بردهای دایرکتوری (سریع)",
"boards_loaded": "بردهای بارگذاری‌شده:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "GIF",
"total_posts": "Viestejä yhteensä:",
"current_users": "nykyiset käyttäjät:",
"boards_tracked": "seuratut foorumit:",
"challenge_counter": "{{index}} / {{total}}",
"embed": "upota",
"remove": "poista",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube-video",
"youtube_thumbnail_link_conversion_notice": "YouTube-linkki havaittu. Liitetään pikkukuva {{count}} kuluttua..."
"youtube_thumbnail_link_conversion_notice": "YouTube-linkki havaittu. Liitetään pikkukuva {{count}} kuluttua...",
"stats_scope_all_listed_boards": "Kaikki listatut boardit (hidas)",
"stats_scope_directory_boards": "Vain hakemiston boardit (nopea)",
"boards_loaded": "ladatut boardit:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "GIF",
"total_posts": "Kabuuang Posts:",
"current_users": "kasalukuyang mga gumagamit:",
"boards_tracked": "mga board na sinusubaybayan:",
"challenge_counter": "{{index}} ng {{total}}",
"embed": "i-embed",
"remove": "alisin",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "video sa YouTube",
"youtube_thumbnail_link_conversion_notice": "May nakita na link ng YouTube. Ipi-paste ang thumbnail image sa loob ng {{count}}..."
"youtube_thumbnail_link_conversion_notice": "May nakita na link ng YouTube. Ipi-paste ang thumbnail image sa loob ng {{count}}...",
"stats_scope_all_listed_boards": "Lahat ng nakalistang board (mabagal)",
"stats_scope_directory_boards": "Mga board ng directory lang (mabilis)",
"boards_loaded": "mga board na na-load:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "GIF",
"total_posts": "Total des publications:",
"current_users": "utilisateurs actuels:",
"boards_tracked": "forums suivis:",
"challenge_counter": "{{index}} sur {{total}}",
"embed": "intégrer",
"remove": "supprimer",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "vidéo YouTube",
"youtube_thumbnail_link_conversion_notice": "Lien YouTube détecté. Collage de l'image miniature dans {{count}}..."
"youtube_thumbnail_link_conversion_notice": "Lien YouTube détecté. Collage de l'image miniature dans {{count}}...",
"stats_scope_all_listed_boards": "Tous les boards listés (lent)",
"stats_scope_directory_boards": "Boards de l'annuaire uniquement (rapide)",
"boards_loaded": "boards chargés :"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "גיף",
"total_posts": "סה\"כ פוסטים:",
"current_users": "משתמשים נוכחיים:",
"boards_tracked": "פורומים שנמצאים במעקב:",
"challenge_counter": "{{index}} מתוך {{total}}",
"embed": "הטמע",
"remove": "הסר",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "סרטון YouTube",
"youtube_thumbnail_link_conversion_notice": "זוהה קישור YouTube. מדביק תמונת ממוזערת בעוד {{count}}..."
"youtube_thumbnail_link_conversion_notice": "זוהה קישור YouTube. מדביק תמונת ממוזערת בעוד {{count}}...",
"stats_scope_all_listed_boards": "כל הלוחות הרשומים (איטי)",
"stats_scope_directory_boards": "רק לוחות מהספרייה (מהיר)",
"boards_loaded": "לוחות שנטענו:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "जीआईएफ",
"total_posts": "कुल पोस्ट:",
"current_users": "वर्तमान उपयोगकर्ता:",
"boards_tracked": "ट्रैक किए गए बोर्ड:",
"challenge_counter": "{{index}} में से {{total}}",
"embed": "एम्बेड",
"remove": "हटाएं",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube वीडियो",
"youtube_thumbnail_link_conversion_notice": "YouTube लिंक मिला। {{count}} में थंबनेल इमेज पेस्ट की जा रही है..."
"youtube_thumbnail_link_conversion_notice": "YouTube लिंक मिला। {{count}} में थंबनेल इमेज पेस्ट की जा रही है...",
"stats_scope_all_listed_boards": "सभी सूचीबद्ध बोर्ड (धीमा)",
"stats_scope_directory_boards": "सिर्फ डायरेक्टरी बोर्ड (तेज़)",
"boards_loaded": "लोड किए गए बोर्ड:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "GIF",
"total_posts": "Összes bejegyzés:",
"current_users": "jelenlegi felhasználók:",
"boards_tracked": "követett fórumok:",
"challenge_counter": "{{index}} a(z) {{total}}-ból/ből",
"embed": "beágyaz",
"remove": "eltávolít",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube-videó",
"youtube_thumbnail_link_conversion_notice": "YouTube-link észlelve. Bélyegkép beillesztése {{count}} múlva..."
"youtube_thumbnail_link_conversion_notice": "YouTube-link észlelve. Bélyegkép beillesztése {{count}} múlva...",
"stats_scope_all_listed_boards": "Az összes listázott board (lassú)",
"stats_scope_directory_boards": "Csak a könyvtár boardjai (gyors)",
"boards_loaded": "betöltött boardok:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "GIF",
"total_posts": "Total Posting:",
"current_users": "pengguna saat ini:",
"boards_tracked": "papan yang dilacak:",
"challenge_counter": "{{index}} dari {{total}}",
"embed": "sematkan",
"remove": "hapus",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "video YouTube",
"youtube_thumbnail_link_conversion_notice": "Tautan YouTube terdeteksi. Menempelkan gambar thumbnail dalam {{count}}..."
"youtube_thumbnail_link_conversion_notice": "Tautan YouTube terdeteksi. Menempelkan gambar thumbnail dalam {{count}}...",
"stats_scope_all_listed_boards": "Semua board yang terdaftar (lambat)",
"stats_scope_directory_boards": "Hanya board direktori (cepat)",
"boards_loaded": "board dimuat:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "GIF",
"total_posts": "Post totali:",
"current_users": "utenti attuali:",
"boards_tracked": "board tracciate:",
"challenge_counter": "{{index}} di {{total}}",
"embed": "incorpora",
"remove": "rimuovi",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "video YouTube",
"youtube_thumbnail_link_conversion_notice": "Link YouTube rilevato. Incollando l'immagine di anteprima tra {{count}}..."
"youtube_thumbnail_link_conversion_notice": "Link YouTube rilevato. Incollando l'immagine di anteprima tra {{count}}...",
"stats_scope_all_listed_boards": "Tutte le board elencate (lento)",
"stats_scope_directory_boards": "Solo board della directory (veloce)",
"boards_loaded": "board caricati:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "GIF",
"total_posts": "総投稿数:",
"current_users": "現在のユーザー:",
"boards_tracked": "追跡されたボード:",
"challenge_counter": "{{index}} / {{total}}",
"embed": "埋め込み",
"remove": "削除",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube動画",
"youtube_thumbnail_link_conversion_notice": "YouTubeリンクを検出しました。{{count}}後にサムネイル画像を貼り付けます..."
"youtube_thumbnail_link_conversion_notice": "YouTubeリンクを検出しました。{{count}}後にサムネイル画像を貼り付けます...",
"stats_scope_all_listed_boards": "掲載されているすべての掲示板(低速)",
"stats_scope_directory_boards": "ディレクトリの掲示板のみ(高速)",
"boards_loaded": "読み込まれた板:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "GIF",
"total_posts": "총 게시물:",
"current_users": "현재 사용자:",
"boards_tracked": "추적된 게시판:",
"challenge_counter": "{{index}} 중 {{total}}",
"embed": "임베드",
"remove": "제거",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube 동영상",
"youtube_thumbnail_link_conversion_notice": "YouTube 링크가 감지되었습니다. {{count}} 후에 썸네일 이미지를 붙여넣는 중..."
"youtube_thumbnail_link_conversion_notice": "YouTube 링크가 감지되었습니다. {{count}} 후에 썸네일 이미지를 붙여넣는 중...",
"stats_scope_all_listed_boards": "등록된 모든 게시판 (느림)",
"stats_scope_directory_boards": "디렉터리 게시판만 (빠름)",
"boards_loaded": "불러온 게시판:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "जीआईएफ",
"total_posts": "एकूण पोस्ट:",
"current_users": "सध्याचे वापरकर्ते:",
"boards_tracked": "ट्रॅक केलेले बोर्ड:",
"challenge_counter": "{{index}} पैकी {{total}}",
"embed": "एम्बेड",
"remove": "काढा",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube व्हिडिओ",
"youtube_thumbnail_link_conversion_notice": "YouTube लिंक आढळली. {{count}} मध्ये थंबनेल प्रतिमा पेस्ट करत आहे..."
"youtube_thumbnail_link_conversion_notice": "YouTube लिंक आढळली. {{count}} मध्ये थंबनेल प्रतिमा पेस्ट करत आहे...",
"stats_scope_all_listed_boards": "सर्व सूचीबद्ध बोर्ड (मंद)",
"stats_scope_directory_boards": "फक्त निर्देशिका बोर्ड (जलद)",
"boards_loaded": "लोड केलेले बोर्ड:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "GIF",
"total_posts": "Totaal aantal berichten:",
"current_users": "huidige gebruikers:",
"boards_tracked": "gevolgde borden:",
"challenge_counter": "{{index}} van {{total}}",
"embed": "insluiten",
"remove": "verwijderen",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube-video",
"youtube_thumbnail_link_conversion_notice": "YouTube-link gedetecteerd. Miniatuurafbeelding wordt over {{count}} geplakt..."
"youtube_thumbnail_link_conversion_notice": "YouTube-link gedetecteerd. Miniatuurafbeelding wordt over {{count}} geplakt...",
"stats_scope_all_listed_boards": "Alle vermelde boards (traag)",
"stats_scope_directory_boards": "Alleen directory-boards (snel)",
"boards_loaded": "geladen boards:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "GIF",
"total_posts": "Totalt antall innlegg:",
"current_users": "nåværende brukere:",
"boards_tracked": "sporede fora:",
"challenge_counter": "{{index}} av {{total}}",
"embed": "bygg inn",
"remove": "fjern",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube-video",
"youtube_thumbnail_link_conversion_notice": "YouTube-lenke oppdaget. Limer inn miniatyrbilde om {{count}}..."
"youtube_thumbnail_link_conversion_notice": "YouTube-lenke oppdaget. Limer inn miniatyrbilde om {{count}}...",
"stats_scope_all_listed_boards": "Alle oppførte boards (tregt)",
"stats_scope_directory_boards": "Bare katalog-boards (raskt)",
"boards_loaded": "lastede boards:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "GIF",
"total_posts": "Łączna liczba postów:",
"current_users": "obecni użytkownicy:",
"boards_tracked": "śledzone fora:",
"challenge_counter": "{{index}} z {{total}}",
"embed": "osadź",
"remove": "usuń",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "wideo YouTube",
"youtube_thumbnail_link_conversion_notice": "Wykryto link YouTube. Wklejam miniaturę za {{count}}..."
"youtube_thumbnail_link_conversion_notice": "Wykryto link YouTube. Wklejam miniaturę za {{count}}...",
"stats_scope_all_listed_boards": "Wszystkie wymienione boardy (wolno)",
"stats_scope_directory_boards": "Tylko boardy z katalogu (szybko)",
"boards_loaded": "załadowane boardy:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "GIF",
"total_posts": "Total de postagens:",
"current_users": "usuários atuais:",
"boards_tracked": "fóruns rastreados:",
"challenge_counter": "{{index}} de {{total}}",
"embed": "embutir",
"remove": "remover",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "vídeo do YouTube",
"youtube_thumbnail_link_conversion_notice": "Link do YouTube detectado. Colando imagem em miniatura em {{count}}..."
"youtube_thumbnail_link_conversion_notice": "Link do YouTube detectado. Colando imagem em miniatura em {{count}}...",
"stats_scope_all_listed_boards": "Todos os boards listados (lento)",
"stats_scope_directory_boards": "Somente boards do diretório (rápido)",
"boards_loaded": "boards carregados:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "GIF",
"total_posts": "Total postări:",
"current_users": "utilizatori actuali:",
"boards_tracked": "forumuri urmărite:",
"challenge_counter": "{{index}} din {{total}}",
"embed": "încorpora",
"remove": "elimina",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "video YouTube",
"youtube_thumbnail_link_conversion_notice": "Link YouTube detectat. Lipesc imaginea miniatură în {{count}}..."
"youtube_thumbnail_link_conversion_notice": "Link YouTube detectat. Lipesc imaginea miniatură în {{count}}...",
"stats_scope_all_listed_boards": "Toate boardurile listate (lent)",
"stats_scope_directory_boards": "Doar boardurile din director (rapid)",
"boards_loaded": "boarduri încărcate:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "ГИФ",
"total_posts": "Всего сообщений:",
"current_users": "текущие пользователи:",
"boards_tracked": "отслеживаемые форумы:",
"challenge_counter": "{{index}} из {{total}}",
"embed": "встроить",
"remove": "удалить",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "видео YouTube",
"youtube_thumbnail_link_conversion_notice": "Обнаружена ссылка YouTube. Вставка миниатюры через {{count}}..."
"youtube_thumbnail_link_conversion_notice": "Обнаружена ссылка YouTube. Вставка миниатюры через {{count}}...",
"stats_scope_all_listed_boards": "Все перечисленные доски (медленно)",
"stats_scope_directory_boards": "Только доски каталога (быстро)",
"boards_loaded": "загруженные доски:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "GIF",
"total_posts": "Postime totale:",
"current_users": "përdoruesit aktualë:",
"boards_tracked": "forume të gjurmueshme:",
"challenge_counter": "{{index}} nga {{total}}",
"embed": "integro",
"remove": "hiq",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "video YouTube",
"youtube_thumbnail_link_conversion_notice": "U zbulua lidhje YouTube. Po ngjis imazhin miniaturë pas {{count}}..."
"youtube_thumbnail_link_conversion_notice": "U zbulua lidhje YouTube. Po ngjis imazhin miniaturë pas {{count}}...",
"stats_scope_all_listed_boards": "Të gjitha bordet e listuara (ngadalë)",
"stats_scope_directory_boards": "Vetëm bordet e drejtorisë (shpejt)",
"boards_loaded": "bordet e ngarkuara:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "GIF",
"total_posts": "Totalt antal inlägg:",
"current_users": "aktuella användare:",
"boards_tracked": "spårade forum:",
"challenge_counter": "{{index}} av {{total}}",
"embed": "bädda in",
"remove": "ta bort",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube-video",
"youtube_thumbnail_link_conversion_notice": "YouTube-länk upptäckt. Klistrar in miniatyrbild om {{count}}..."
"youtube_thumbnail_link_conversion_notice": "YouTube-länk upptäckt. Klistrar in miniatyrbild om {{count}}...",
"stats_scope_all_listed_boards": "Alla listade boards (långsamt)",
"stats_scope_directory_boards": "Endast katalogens boards (snabbt)",
"boards_loaded": "laddade boards:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "జీఐఎఫ్",
"total_posts": "మొత్తం పోస్ట్‌లు:",
"current_users": "ప్రస్తుత వాడుకరులు:",
"boards_tracked": "ట్రాక్ చేయబడిన బోర్డులు:",
"challenge_counter": "{{index}} లో {{total}}",
"embed": "ఎంబెడ్",
"remove": "తొలగించు",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube వీడియో",
"youtube_thumbnail_link_conversion_notice": "YouTube లింక్ గుర్తించబడింది. {{count}}లో థంబ్‌నెయిల్ చిత్రాన్ని పేస్ట్ చేస్తున్నాము..."
"youtube_thumbnail_link_conversion_notice": "YouTube లింక్ గుర్తించబడింది. {{count}}లో థంబ్‌నెయిల్ చిత్రాన్ని పేస్ట్ చేస్తున్నాము...",
"stats_scope_all_listed_boards": "జాబితాలో ఉన్న అన్ని బోర్డులు (నెమ్మదిగా)",
"stats_scope_directory_boards": "డైరెక్టరీ బోర్డులు మాత్రమే (వేగంగా)",
"boards_loaded": "లోడ్ చేసిన బోర్డులు:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "จีไอเอฟ",
"total_posts": "โพสต์ทั้งหมด:",
"current_users": "ผู้ใช้งานปัจจุบัน:",
"boards_tracked": "บอร์ดที่ติดตาม:",
"challenge_counter": "{{index}} จาก {{total}}",
"embed": "ฝัง",
"remove": "ลบ",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "วิดีโอ YouTube",
"youtube_thumbnail_link_conversion_notice": "ตรวจพบลิงก์ YouTube กำลังวางรูปภาพขนาดย่อในอีก {{count}}..."
"youtube_thumbnail_link_conversion_notice": "ตรวจพบลิงก์ YouTube กำลังวางรูปภาพขนาดย่อในอีก {{count}}...",
"stats_scope_all_listed_boards": "บอร์ดที่แสดงทั้งหมด (ช้า)",
"stats_scope_directory_boards": "เฉพาะบอร์ดในไดเรกทอรี (เร็ว)",
"boards_loaded": "บอร์ดที่โหลดแล้ว:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "GIF",
"total_posts": "Toplam Gönderi:",
"current_users": "mevcut kullanıcılar:",
"boards_tracked": "takip edilen panolar:",
"challenge_counter": "{{index}} / {{total}}",
"embed": "göm",
"remove": "kaldır",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube videosu",
"youtube_thumbnail_link_conversion_notice": "YouTube bağlantısı algılandı. Küçük resim {{count}} içinde yapıştırılıyor..."
"youtube_thumbnail_link_conversion_notice": "YouTube bağlantısı algılandı. Küçük resim {{count}} içinde yapıştırılıyor...",
"stats_scope_all_listed_boards": "Listelenen tüm panolar (yavaş)",
"stats_scope_directory_boards": "Yalnızca dizin panolari (hızlı)",
"boards_loaded": "yüklenen panolar:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "ГІФ",
"total_posts": "Всього повідомлень:",
"current_users": "поточні користувачі:",
"boards_tracked": "відстежувані форуми:",
"challenge_counter": "{{index}} з {{total}}",
"embed": "вбудувати",
"remove": "видалити",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "відео YouTube",
"youtube_thumbnail_link_conversion_notice": "Виявлено посилання YouTube. Вставляю мініатюру через {{count}}..."
"youtube_thumbnail_link_conversion_notice": "Виявлено посилання YouTube. Вставляю мініатюру через {{count}}...",
"stats_scope_all_listed_boards": "Усі перелічені дошки (повільно)",
"stats_scope_directory_boards": "Лише дошки каталогу (швидко)",
"boards_loaded": "завантажені дошки:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "جی آئی ایف",
"total_posts": "کل پوسٹس:",
"current_users": "موجودہ صارفین:",
"boards_tracked": "ٹریک کیے گئے بورڈز:",
"challenge_counter": "{{index}} میں سے {{total}}",
"embed": "ایمبیڈ",
"remove": "ہٹائیں",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube ویڈیو",
"youtube_thumbnail_link_conversion_notice": "YouTube لنک مل گیا۔ {{count}} میں تھمب نیل تصویر پیسٹ کی جا رہی ہے..."
"youtube_thumbnail_link_conversion_notice": "YouTube لنک مل گیا۔ {{count}} میں تھمب نیل تصویر پیسٹ کی جا رہی ہے...",
"stats_scope_all_listed_boards": "تمام فہرست شدہ بورڈز (سست)",
"stats_scope_directory_boards": "صرف ڈائریکٹری بورڈز (تیز)",
"boards_loaded": "لوڈ شدہ بورڈز:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "GIF",
"total_posts": "Tổng số bài viết:",
"current_users": "người dùng hiện tại:",
"boards_tracked": "bảng được theo dõi:",
"challenge_counter": "{{index}} trong {{total}}",
"embed": "nhúng",
"remove": "xóa",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "video YouTube",
"youtube_thumbnail_link_conversion_notice": "Đã phát hiện liên kết YouTube. Đang dán ảnh thu nhỏ sau {{count}}..."
"youtube_thumbnail_link_conversion_notice": "Đã phát hiện liên kết YouTube. Đang dán ảnh thu nhỏ sau {{count}}...",
"stats_scope_all_listed_boards": "Tất cả bảng được liệt kê (chậm)",
"stats_scope_directory_boards": "Chỉ bảng trong thư mục (nhanh)",
"boards_loaded": "bảng đã tải:"
}
+4 -2
View File
@@ -50,7 +50,6 @@
"gif": "GIF",
"total_posts": "总帖数:",
"current_users": "当前用户:",
"boards_tracked": "跟踪的板块:",
"challenge_counter": "{{index}} / {{total}}",
"embed": "嵌入",
"remove": "移除",
@@ -401,5 +400,8 @@
"tag": "tag",
"post_form_flash_upload_prompt": "Recommended SWF host: <catbox>Catbox</catbox>. Upload a .swf, then paste the direct https://files.catbox.moe/...swf link in Link. Other hosts must provide a direct HTTPS .swf URL with CORS enabled.",
"youtube_video": "YouTube 视频",
"youtube_thumbnail_link_conversion_notice": "检测到 YouTube 链接。将在 {{count}} 后粘贴缩略图..."
"youtube_thumbnail_link_conversion_notice": "检测到 YouTube 链接。将在 {{count}} 后粘贴缩略图...",
"stats_scope_all_listed_boards": "所有已列出的版块(较慢)",
"stats_scope_directory_boards": "仅目录板块(快速)",
"boards_loaded": "已加载的版块:"
}
@@ -6,6 +6,7 @@ const flushMicrotasks = async () => {
};
const loadBlotterVisibilityStore = async () => (await import('../use-blotter-visibility-store')).default;
const loadHomepageStatsOptionsStore = async () => (await import('../use-homepage-stats-options-store')).default;
const loadModQueueStore = async () => (await import('../use-mod-queue-store')).default;
const loadPopularThreadsOptionsStore = async () => (await import('../use-popular-threads-options-store')).default;
@@ -56,6 +57,17 @@ describe('persisted extra stores', () => {
expect(localStorage.getItem('showNsfwContentOnly')).toBe('true');
});
it('loads homepage stats scope from localStorage defaults and persists changes', async () => {
const useHomepageStatsOptionsStore = await loadHomepageStatsOptionsStore();
expect(useHomepageStatsOptionsStore.getState().statsScope).toBe('directory');
useHomepageStatsOptionsStore.getState().setStatsScope('all');
expect(useHomepageStatsOptionsStore.getState().statsScope).toBe('all');
expect(localStorage.getItem('5chan-homepage-stats-scope')).toBe('all');
});
it('migrates legacy mod queue storage and computes threshold seconds from the active unit', async () => {
localStorage.setItem(
'mod-queue-storage',
@@ -0,0 +1,22 @@
import { create } from 'zustand';
export type HomepageStatsScope = 'directory' | 'all';
const LOCALSTORAGE_KEY = '5chan-homepage-stats-scope';
const readInitialStatsScope = (): HomepageStatsScope => (localStorage.getItem(LOCALSTORAGE_KEY) === 'all' ? 'all' : 'directory');
interface HomepageStatsOptionsStore {
statsScope: HomepageStatsScope;
setStatsScope: (value: HomepageStatsScope) => void;
}
const useHomepageStatsOptionsStore = create<HomepageStatsOptionsStore>((set) => ({
statsScope: readInitialStatsScope(),
setStatsScope: (value) => {
set({ statsScope: value });
localStorage.setItem(LOCALSTORAGE_KEY, value);
},
}));
export default useHomepageStatsOptionsStore;
+78 -29
View File
@@ -15,9 +15,9 @@ const testState = vi.hoisted(() => ({
directoryAddresses: [] as string[],
directoryListCodes: [] as string[],
directoryListsByCode: {} as Record<string, { boards: Array<{ address: string; score: number; managedByDevs: boolean; addedAt?: number }> }>,
loadingStartTimestamps: [] as number[],
nowSeconds: 1_704_067_210,
navigateMock: vi.fn(),
setStatsScopeMock: vi.fn(),
statsScope: 'directory' as 'directory' | 'all',
communities: {} as Record<string, unknown>,
communityStats: {} as Record<string, { allPostCount?: number; weekActiveUserCount?: number; state?: string }>,
feedStateString: 'Downloading boards',
@@ -70,12 +70,12 @@ vi.mock('../../../hooks/use-communities-stats', () => ({
useCommunitiesStatsStore: (selector: (state: { communityStats: typeof testState.communityStats }) => unknown) => selector({ communityStats: testState.communityStats }),
}));
vi.mock('../../../stores/use-communities-loading-start-timestamps-store', () => ({
default: () => testState.loadingStartTimestamps,
}));
vi.mock('../../../hooks/use-now-seconds', () => ({
useNowSeconds: () => testState.nowSeconds,
vi.mock('../../../stores/use-homepage-stats-options-store', () => ({
default: (selector: (state: { statsScope: typeof testState.statsScope; setStatsScope: typeof testState.setStatsScopeMock }) => unknown) =>
selector({
statsScope: testState.statsScope,
setStatsScope: testState.setStatsScopeMock,
}),
}));
vi.mock('../../../hooks/use-state-string', () => ({
@@ -86,6 +86,11 @@ vi.mock('../../../components/loading-ellipsis', () => ({
default: ({ string }: { string: string }) => createElement('span', { 'data-testid': 'loading-ellipsis' }, string),
}));
vi.mock('../../../components/tooltip', () => ({
default: ({ content, children }: { content: React.ReactNode; children: React.ReactNode }) =>
createElement('span', { 'data-testid': 'tooltip', 'data-content': typeof content === 'string' ? content : undefined }, children),
}));
vi.mock('../../../stores/use-directory-modal-store', () => ({
default: () => ({
closeDirectoryModal: testState.closeDirectoryModalMock,
@@ -135,8 +140,6 @@ describe('Home', () => {
testState.directoryAddresses = ['music-posting.eth', 'tech-posting.eth'];
testState.directoryListCodes = [];
testState.directoryListsByCode = {};
testState.loadingStartTimestamps = [];
testState.nowSeconds = 1_704_067_210;
testState.communities = {
'music-posting.eth': { address: 'music-posting.eth' },
'tech-posting.eth': { address: 'tech-posting.eth' },
@@ -146,6 +149,10 @@ describe('Home', () => {
'tech-posting.eth': { allPostCount: 7, weekActiveUserCount: 5 },
};
testState.feedStateString = 'Downloading boards';
testState.statsScope = 'directory';
testState.setStatsScopeMock.mockImplementation((value: 'directory' | 'all') => {
testState.statsScope = value;
});
container = document.createElement('div');
document.body.appendChild(container);
@@ -160,47 +167,62 @@ describe('Home', () => {
it('renders the home view chrome, child sections, collectors, and aggregated stats', () => {
renderHome();
expect(vi.mocked(useFeedStateString)).not.toHaveBeenCalled();
expect(vi.mocked(useFeedStateString)).toHaveBeenCalledWith([]);
expect(document.title).toBe('5chan');
expect(container.querySelector('[data-testid="disclaimer-modal"]')?.textContent).toBe('disclaimer-modal');
expect(container.querySelector('[data-testid="directory-modal"]')?.textContent).toBe('directory-modal');
expect(container.querySelector('[data-testid="boards-list"]')?.textContent).toBe('boards:2');
expect(container.querySelector('[data-testid="popular-threads-box"]')?.textContent).toBe('popular:2:2');
expect(container.querySelectorAll('[data-testid="stats-collector"]')).toHaveLength(2);
expect(testState.directoryListCodes).toEqual([]);
expect(container.textContent).toContain('stats');
expect(container.textContent).not.toContain('loaded 2/2 boards p2p');
expect(container.querySelector('.yellowOfflineIcon')).toBeNull();
expect(container.querySelectorAll('[data-testid="loading-ellipsis"]')).toHaveLength(0);
expect(container.textContent).toContain('total_posts 12');
expect(container.textContent).toContain('current_users 7');
expect(container.textContent).toContain('boards_tracked 2');
expect(container.textContent).toContain('boards_loaded 2');
expect(container.querySelector('[data-testid="site-legal-meta"]')?.textContent).toBe('site-legal-meta');
expect(container.querySelector<HTMLAnchorElement>('a[href="/pass"]')?.textContent).toBe('support_5chan');
});
it('keeps the stats values loading until every directory has loaded stats', () => {
it('shows the stats state string while no board stats have loaded yet', () => {
testState.communityStats = {};
renderHome();
expect(vi.mocked(useFeedStateString)).toHaveBeenCalledWith(['music-posting.eth', 'tech-posting.eth']);
expect(container.querySelector('.yellowOfflineIcon')).not.toBeNull();
expect(container.querySelector('[data-testid="tooltip"]')?.getAttribute('data-content')).toBe('Downloading boards');
expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('Downloading boards');
expect(container.textContent).not.toContain('total_posts');
expect(container.textContent).not.toContain('boards_loaded');
});
it('shows partial stats while some boards are still loading', () => {
testState.communityStats = {
'music-posting.eth': { allPostCount: 5, weekActiveUserCount: 2 },
};
renderHome();
const loadingValues = Array.from(container.querySelectorAll('[data-testid="loading-ellipsis"]'));
expect(loadingValues).toHaveLength(1);
expect(loadingValues[0]?.textContent).toBe('Downloading boards');
expect(vi.mocked(useFeedStateString)).toHaveBeenCalledWith(['music-posting.eth', 'tech-posting.eth']);
expect(container.textContent).not.toContain('total_posts');
expect(container.textContent).not.toContain('current_users');
expect(container.textContent).not.toContain('boards_tracked');
expect(container.textContent).not.toContain('total_posts 5');
expect(container.textContent).not.toContain('current_users 2');
expect(container.textContent).not.toContain('boards_tracked 1');
expect(container.querySelector('.yellowOfflineIcon')).not.toBeNull();
expect(container.querySelector('[data-testid="tooltip"]')?.getAttribute('data-content')).toBe('Downloading boards');
expect(container.querySelectorAll('[data-testid="loading-ellipsis"]')).toHaveLength(0);
expect(container.textContent).not.toContain('loaded 1/2 boards p2p');
expect(container.textContent).toContain('total_posts 5');
expect(container.textContent).toContain('current_users 2');
expect(container.textContent).toContain('boards_loaded 1');
});
it('uses a ranked directory fallback board when the default board stats stay unresolved', () => {
it('loads every listed board when the slow stats scope is selected', () => {
testState.statsScope = 'all';
testState.directories = [
{ address: 'business-and-finance.bso', title: '/biz/ - Business & Finance', directoryCode: 'biz' },
{ address: 'tech-posting.eth', title: '/g/ - Technology', directoryCode: 'g' },
];
testState.directoryAddresses = ['business-and-finance.bso', 'tech-posting.eth'];
testState.loadingStartTimestamps = [1_704_067_170, 1_704_067_170];
testState.directoryListsByCode = {
biz: {
boards: [
@@ -217,12 +239,15 @@ describe('Home', () => {
renderHome();
const collectorAddresses = Array.from(container.querySelectorAll('[data-testid="stats-collector"]')).map((collector) => collector.getAttribute('data-address'));
expect(testState.directoryListCodes).toEqual(['biz']);
expect(collectorAddresses).toEqual(['business-and-finance.bso', 'tech-posting.eth', 'backup-business.bso']);
expect(testState.directoryListCodes).toEqual(['biz', 'g']);
expect(collectorAddresses).toEqual(['business-and-finance.bso', 'backup-business.bso', 'tech-posting.eth']);
expect(vi.mocked(useFeedStateString)).toHaveBeenCalledWith(['business-and-finance.bso', 'backup-business.bso', 'tech-posting.eth']);
expect(container.querySelector('.yellowOfflineIcon')).not.toBeNull();
expect(container.querySelectorAll('[data-testid="loading-ellipsis"]')).toHaveLength(0);
expect(container.textContent).not.toContain('loaded 2/3 boards p2p');
expect(container.textContent).toContain('total_posts 18');
expect(container.textContent).toContain('current_users 7');
expect(container.textContent).toContain('boards_tracked 2');
expect(container.textContent).toContain('boards_loaded 2');
});
it('shows zero totals after every directory has loaded zero-count stats', () => {
@@ -234,9 +259,11 @@ describe('Home', () => {
renderHome();
expect(container.querySelectorAll('[data-testid="loading-ellipsis"]')).toHaveLength(0);
expect(container.querySelector('.yellowOfflineIcon')).toBeNull();
expect(container.textContent).not.toContain('loaded 2/2 boards p2p');
expect(container.textContent).toContain('total_posts 0');
expect(container.textContent).toContain('current_users 0');
expect(container.textContent).toContain('boards_tracked 2');
expect(container.textContent).toContain('boards_loaded 2');
});
it('does not keep aggregate stats loading after a directory stats fetch fails', () => {
@@ -248,9 +275,31 @@ describe('Home', () => {
renderHome();
expect(container.querySelectorAll('[data-testid="loading-ellipsis"]')).toHaveLength(0);
expect(container.querySelector('.yellowOfflineIcon')).toBeNull();
expect(container.textContent).not.toContain('loaded 2/2 boards p2p');
expect(container.textContent).toContain('total_posts 5');
expect(container.textContent).toContain('current_users 2');
expect(container.textContent).toContain('boards_tracked 2');
expect(container.textContent).toContain('boards_loaded 2');
});
it('switches the stats scope from the options menu', () => {
renderHome();
const optionsButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'options ▼');
expect(optionsButton).toBeTruthy();
act(() => {
optionsButton?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
});
const option = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'stats_scope_all_listed_boards');
expect(option).toBeTruthy();
act(() => {
option?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
});
expect(testState.setStatsScopeMock).toHaveBeenCalledWith('all');
});
it('navigates to the canonical board path when the search form is submitted', async () => {
+31 -4
View File
@@ -57,7 +57,7 @@
font-weight: 700;
}
.boxBar span, .boxBar > button {
.boxBar > span, .boxBar > button {
all: unset;
position: absolute;
top: 0;
@@ -69,7 +69,7 @@
line-height: inherit;
}
.boxBar span:hover, .boxBar > button:hover {
.boxBar > span:hover, .boxBar > button:hover {
cursor: pointer;
color: var(--homepage-box-bar-text-color-hover);
}
@@ -135,13 +135,14 @@
top: 30px;
right: 5px;
display: inline-block;
position: absolute;
text-align: right;
background: #fff;
border: 1px solid gray;
box-shadow: 2px 2px 0 1px rgba(0, 0, 0, .1);
padding: 4px 0;
line-height: 1.7em;
min-width: max-content;
white-space: nowrap;
z-index: 100;
}
@@ -155,12 +156,13 @@
all: unset;
box-sizing: border-box;
display: block;
width: 100%;
min-width: 100%;
padding: 0 10px;
color: inherit;
font: inherit;
line-height: inherit;
text-align: right;
white-space: nowrap;
}
.filterModal .option:hover {
@@ -259,6 +261,31 @@
color: #800;
}
.statsTitle {
padding-right: 6em;
text-transform: none;
}
.statsLoadingIconWrapper {
display: inline-flex;
align-items: center;
margin-left: 6px;
line-height: 1;
vertical-align: middle;
}
.statsLoadingIconWrapper > span {
display: inline-flex;
align-items: center;
}
.statsLoadingIcon {
box-sizing: border-box;
display: inline-block;
width: 14px;
height: 14px;
}
.stats .stat {
display: inline-block;
width: 33%;
+137 -78
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, FormEvent } from 'react';
import { useEffect, useMemo, useRef, useState, type FormEvent, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next';
import styles from './home.module.css';
@@ -9,15 +9,15 @@ import PopularThreadsBox from './popular-threads-box';
import BoardsList from './boards-list';
import SiteLegalMeta from '../../components/site-legal-meta';
import LoadingEllipsis from '../../components/loading-ellipsis';
import { useFeedStateString } from '../../hooks/use-state-string';
import Tooltip from '../../components/tooltip';
import useDirectoryModalStore from '../../stores/use-directory-modal-store';
import useHomepageStatsOptionsStore, { type HomepageStatsScope } from '../../stores/use-homepage-stats-options-store';
import DisclaimerModal from '../../components/disclaimer-modal';
import DirectoryModal from '../../components/directory-modal';
import { extractDirectoryFromTitle, getBoardPath } from '../../lib/utils/route-utils';
import { isWebRuntime } from '../../lib/media-hosting/show-upload-controls';
import { useFeedStateString } from '../../hooks/use-state-string';
import lowerCase from 'lodash/lowerCase';
import useCommunitiesLoadingStartTimestamps from '../../stores/use-communities-loading-start-timestamps-store';
import { useNowSeconds } from '../../hooks/use-now-seconds';
// https://github.com/bitsocialnet/lists/tree/master/5chan-directories
@@ -106,103 +106,152 @@ type HomepageStats = {
state?: string;
};
const StatsLoading = ({ communityAddresses }: { communityAddresses: string[] }) => {
const { t } = useTranslation();
const loadingStateString = useFeedStateString(communityAddresses) || t('loading');
return <LoadingEllipsis string={loadingStateString} />;
};
const STATS_DIRECTORY_FALLBACK_DELAY_SECONDS = 30;
const getDirectoryCode = (directory: DirectoryCommunity): string | null => directory.directoryCode ?? extractDirectoryFromTitle(directory.title ?? '');
const getUniqueAddresses = (addresses: string[]): string[] => [...new Set(addresses.filter((address) => address.length > 0))];
const hasLoadedStats = (stat: HomepageStats | undefined): stat is HomepageStats & { allPostCount: number } => stat?.allPostCount !== undefined;
const hasFailedStats = (stat: HomepageStats | undefined): boolean => stat?.state === 'failed';
const hasResolvedStats = (stat: HomepageStats | undefined): boolean => hasLoadedStats(stat) || hasFailedStats(stat);
const STATS_SCOPE_OPTIONS: Array<{ scope: HomepageStatsScope; labelKey: string }> = [
{ scope: 'directory', labelKey: 'stats_scope_directory_boards' },
{ scope: 'all', labelKey: 'stats_scope_all_listed_boards' },
];
const EMPTY_STATS_LIST: string[] = [];
const StatsOptionsModal = () => {
const { t } = useTranslation();
const [showFilterModal, setShowFilterModal] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const statsScope = useHomepageStatsOptionsStore((state) => state.statsScope);
const setStatsScope = useHomepageStatsOptionsStore((state) => state.setStatsScope);
useEffect(() => {
if (!showFilterModal) return;
const handleClickOutside = (event: globalThis.MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(event.target as Node) && buttonRef.current && !buttonRef.current.contains(event.target as Node)) {
setShowFilterModal(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showFilterModal]);
const selectScope = (scope: HomepageStatsScope) => {
setStatsScope(scope);
setShowFilterModal(false);
};
const handleScopeKey = (event: ReactKeyboardEvent<HTMLButtonElement>, scope: HomepageStatsScope) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
selectScope(scope);
}
};
return (
<>
<button
type='button'
ref={buttonRef}
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
if (!showFilterModal) setShowFilterModal(true);
}
}}
onClick={() => !showFilterModal && setShowFilterModal(true)}
>
{t('options')}
</button>
{showFilterModal && (
<div ref={modalRef} className={styles.filterModal}>
{STATS_SCOPE_OPTIONS.map((option) => (
<button
key={option.scope}
type='button'
className={`${styles.option} ${statsScope === option.scope ? styles.selected : ''}`}
tabIndex={0}
onKeyDown={(event) => handleScopeKey(event, option.scope)}
onClick={() => selectScope(option.scope)}
>
{t(option.labelKey)}
</button>
))}
</div>
)}
</>
);
};
const Stats = ({ directories }: { directories: DirectoryCommunity[] }) => {
const { t } = useTranslation();
const statsScope = useHomepageStatsOptionsStore((state) => state.statsScope);
const communitiesStats = useCommunitiesStatsStore((state) => state.communityStats);
const defaultDirectoryAddresses = useMemo(() => directories.map((directory) => directory.address), [directories]);
const loadingStartTimestamps = useCommunitiesLoadingStartTimestamps(defaultDirectoryAddresses);
const nowSeconds = useNowSeconds(defaultDirectoryAddresses.length > 0);
const fallbackDirectoryCodes = useMemo(
const allDirectoryCodes = useMemo(
() =>
directories.flatMap((directory, index) => {
const defaultStats = communitiesStats[directory.address];
if (hasLoadedStats(defaultStats)) {
return [];
}
const loadingStartTimestamp = loadingStartTimestamps[index];
if (!loadingStartTimestamp || nowSeconds - loadingStartTimestamp < STATS_DIRECTORY_FALLBACK_DELAY_SECONDS) {
return [];
}
const directoryCode = getDirectoryCode(directory);
return directoryCode ? [directoryCode] : [];
}),
[communitiesStats, directories, loadingStartTimestamps, nowSeconds],
getUniqueAddresses(
directories.flatMap((directory) => {
const directoryCode = getDirectoryCode(directory);
return directoryCode ? [directoryCode] : [];
}),
),
[directories],
);
const { listsByCode } = useDirectoryLists(fallbackDirectoryCodes);
const { listsByCode } = useDirectoryLists(statsScope === 'all' ? allDirectoryCodes : EMPTY_STATS_LIST);
const { collectorAddresses, totalPosts, currentUsers, boardsTracked, allDirectoryStatsLoaded } = useMemo(() => {
const collectorAddressSet = new Set(defaultDirectoryAddresses);
const collectorAddresses = useMemo(() => {
if (statsScope === 'directory') {
return defaultDirectoryAddresses;
}
return getUniqueAddresses(
directories.flatMap((directory) => {
const directoryCode = getDirectoryCode(directory);
const directoryList = directoryCode ? listsByCode[directoryCode] : null;
const listAddresses = directoryList ? sortDirectoryBoardsByRank(directoryList.boards).map((board) => board.address) : [];
return listAddresses.length > 0 ? listAddresses : [directory.address];
}),
);
}, [defaultDirectoryAddresses, directories, listsByCode, statsScope]);
const { totalPosts, currentUsers, boardsLoaded, boardsWithStats } = useMemo(() => {
let totalPosts = 0;
let currentUsers = 0;
let boardsTracked = 0;
let allDirectoryStatsLoaded = directories.length > 0;
let boardsLoaded = 0;
let boardsWithStats = 0;
for (const directory of directories) {
const directoryCode = getDirectoryCode(directory);
const directoryList = directoryCode ? listsByCode[directoryCode] : null;
const rankedAddresses = directoryList ? sortDirectoryBoardsByRank(directoryList.boards).map((board) => board.address) : [directory.address];
const candidateAddresses = getUniqueAddresses(rankedAddresses.length > 0 ? rankedAddresses : [directory.address]);
candidateAddresses.forEach((address) => collectorAddressSet.add(address));
let stat: HomepageStats | undefined;
let failedStat: HomepageStats | undefined;
let allCandidateStatsResolved = candidateAddresses.length > 0;
for (const address of candidateAddresses) {
const candidateStats = communitiesStats[address];
if (hasLoadedStats(candidateStats)) {
stat = candidateStats;
break;
}
if (hasFailedStats(candidateStats)) {
failedStat ??= candidateStats;
continue;
}
allCandidateStatsResolved = false;
}
if (!stat && allCandidateStatsResolved) {
stat = failedStat;
}
if (!stat || (!hasLoadedStats(stat) && !hasFailedStats(stat))) {
allDirectoryStatsLoaded = false;
for (const address of collectorAddresses) {
const stats = communitiesStats[address];
if (!hasResolvedStats(stats)) {
continue;
}
totalPosts += stat.allPostCount || 0;
currentUsers += stat.weekActiveUserCount || 0;
boardsTracked++;
boardsLoaded++;
if (hasLoadedStats(stats)) {
boardsWithStats++;
totalPosts += stats.allPostCount || 0;
currentUsers += stats.weekActiveUserCount || 0;
}
}
return {
collectorAddresses: [...collectorAddressSet],
totalPosts,
currentUsers,
boardsTracked,
allDirectoryStatsLoaded,
boardsLoaded,
boardsWithStats,
};
}, [communitiesStats, defaultDirectoryAddresses, directories, listsByCode]);
}, [collectorAddresses, communitiesStats]);
const hasDisplayableStats = boardsWithStats > 0 || (collectorAddresses.length > 0 && boardsLoaded === collectorAddresses.length);
const isStatsLoading = !hasDisplayableStats || boardsLoaded < collectorAddresses.length;
const loadingStateString = useFeedStateString(isStatsLoading ? collectorAddresses : EMPTY_STATS_LIST) || t('loading');
return (
<>
@@ -212,10 +261,20 @@ const Stats = ({ directories }: { directories: DirectoryCommunity[] }) => {
))}
<div className={styles.box}>
<div className={`${styles.boxBar} ${styles.color2ColorBar}`}>
<h2 className='capitalize'>{t('stats')}</h2>
<h2 className={styles.statsTitle}>
{t('stats')}
{isStatsLoading && (
<span className={styles.statsLoadingIconWrapper}>
<Tooltip content={loadingStateString}>
<span className={`${styles.statsLoadingIcon} yellowOfflineIcon`} />
</Tooltip>
</span>
)}
</h2>
<StatsOptionsModal />
</div>
<div className={`${styles.boxContent} ${styles.stats}`}>
{allDirectoryStatsLoaded ? (
{hasDisplayableStats ? (
<>
<div className={styles.stat}>
<b>{t('total_posts')}</b> {totalPosts}
@@ -224,11 +283,11 @@ const Stats = ({ directories }: { directories: DirectoryCommunity[] }) => {
<b>{t('current_users')}</b> {currentUsers}
</div>
<div className={styles.stat}>
<b>{t('boards_tracked')}</b> {boardsTracked}
<b>{t('boards_loaded')}</b> {boardsLoaded}
</div>
</>
) : (
<StatsLoading communityAddresses={collectorAddresses} />
<LoadingEllipsis string={loadingStateString} />
)}
</div>
</div>