mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
Add board directory view (#1132)
* feat(directory): add board directory view * fix(directory): populate board status * style(directory): tighten board table * docs(board manager): point board owners to manager * fix(directory): show loading status * style(directory): center board column in directory table * perf(directory): cap board status checks * style(directory): simplify board row links * test(ci): stabilize coverage run * test(ci): stabilize coverage harness * test(ci): avoid async app flush act * test(app): narrow layout harness coverage * test(ci): stabilize app update distribution mock * test(ci): preload app harness before route tests * fix(directory): address final review findings
This commit is contained in:
@@ -17,7 +17,7 @@ _Telegram group for this repo https://t.me/fivechandev_
|
||||
## Key Features
|
||||
|
||||
### Decentralized Board Ownership
|
||||
Unlike traditional imageboards, 5chan has no global admins or central authority. Anyone can create unlimited boards using their own [bitsocial node](https://github.com/bitsocialnet/bitsocial-cli). Each board owner runs their own P2P node that users connect to peer-to-peer, giving them complete control over their board's content, moderation, and rules.
|
||||
Unlike traditional imageboards, 5chan has no global admins or central authority. Anyone can create unlimited boards using [5chan Board Manager](https://github.com/bitsocialnet/5chan-board-manager). Each board owner runs their own P2P node that users connect to peer-to-peer, giving them complete control over their board's content, moderation, and rules.
|
||||
|
||||
### Competitive Directory System
|
||||
5chan maintains the familiar imageboard directory structure (Japanese Culture, Video Games, Interests, Creative, etc.), but introduces competition: **multiple boards can compete for each directory slot**. For example, there can be unlimited "Business & Finance" boards, but only the highest-voted one appears in the directory on the homepage.
|
||||
@@ -54,10 +54,9 @@ In the bitsocial protocol, a 5chan board is called a _community_. To deliver the
|
||||
|
||||
To create and run a board:
|
||||
|
||||
1. Install bitsocial-cli, available for Windows, macOS, and Linux: [latest release](https://github.com/bitsocialnet/bitsocial-cli/releases/latest);
|
||||
2. Follow the instructions in the repo's README;
|
||||
3. When running the daemon for the first time, it will output WebUI links you can use to manage your bitsocial community with a GUI.
|
||||
4. Run 5chan Board Manager against the same bitsocial node and add your community to it. The recommended setup is the Docker Compose flow in the [5chan-board-manager README](https://github.com/bitsocialnet/5chan-board-manager#standalone-bitsocial-cli-already-running) for an existing bitsocial-cli daemon.
|
||||
1. Follow the Docker Compose flow in the [5chan-board-manager README](https://github.com/bitsocialnet/5chan-board-manager#docker-compose-recommended);
|
||||
2. Create your community and add it to 5chan Board Manager using the commands shown there;
|
||||
3. Keep the manager running so it can apply 5chan board behavior such as thread archiving, bump limits, and retention cleanup.
|
||||
|
||||
Once created, anyone can connect to your community using any bitsocial client (such as 5chan) by using the community address. The address is not stored in any central database—bitsocial is a pure peer-to-peer protocol.
|
||||
|
||||
|
||||
+2
-2
@@ -106,8 +106,8 @@
|
||||
"doctor:badge": "node scripts/write-react-doctor-badge.mjs",
|
||||
"contract:imgur": "cd android && ./gradlew :app:connectedDebugAndroidTest -Pandroid.experimental.androidTest.useUnifiedTestPlatform=false -Pandroid.testInstrumentationRunnerArguments.class=fivechan.android.MediaUploadAutomationRunnerTest",
|
||||
"smoke:upload-selectors": "node scripts/smoke-upload-selectors.js",
|
||||
"test:coverage": "vitest run --coverage.enabled --coverage.provider=istanbul --coverage.reporter=text --coverage.reporter=json --coverage.reporter=json-summary --coverage.reportsDirectory=./coverage",
|
||||
"test:coverage:changed": "vitest run --changed master --coverage.enabled --coverage.provider=istanbul --coverage.reporter=text --coverage.reporter=json --coverage.reporter=json-summary --coverage.reportsDirectory=./coverage",
|
||||
"test:coverage": "vitest run --fileParallelism=false --coverage.enabled --coverage.provider=istanbul --coverage.reporter=text --coverage.reporter=json --coverage.reporter=json-summary --coverage.reportsDirectory=./coverage",
|
||||
"test:coverage:changed": "vitest run --changed master --fileParallelism=false --coverage.enabled --coverage.provider=istanbul --coverage.reporter=text --coverage.reporter=json --coverage.reporter=json-summary --coverage.reportsDirectory=./coverage",
|
||||
"retest:quality": "corepack yarn test && corepack yarn build && corepack yarn lint && corepack yarn type-check && corepack yarn doctor",
|
||||
"blotter": "node scripts/update-blotter.js",
|
||||
"blotter:check": "node scripts/update-blotter.js check",
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ This repository primarily contains the client application. It is a React and Typ
|
||||
- README: https://github.com/bitsocialnet/5chan/blob/master/README.md
|
||||
- Agent instructions: https://github.com/bitsocialnet/5chan/blob/master/AGENTS.md
|
||||
- Bitsocial protocol site: https://bitsocial.net
|
||||
- Bitsocial CLI: https://github.com/bitsocialnet/bitsocial-cli
|
||||
- 5chan Board Manager: https://github.com/bitsocialnet/5chan-board-manager
|
||||
- Directory list source: https://github.com/bitsocialnet/lists/blob/master/5chan-directories.json
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "نسخ الرابط المباشر",
|
||||
"posts_auto_update_info": "يتم تحديث المنشورات تلقائياً كل ~2 دقيقة. لا يمكن تعطيل هذا في الوقت الحالي.",
|
||||
"board_address_tooltip": "عنوان عقدة الند للند لهذه اللوحة",
|
||||
"vote_button_unavailable_intro": "غير متاح بعد. سيكون لكل دليل صفحة تصويت تعرض اللوحات المتنافسة على /{{boardIdentifier}}/.",
|
||||
"vote_button_unavailable_outro": "سيتمكن حاملو 5chan Pass من المشاركة في التصويت على الدليل. لا تزال آليات الحوكمة النهائية قيد التصميم لتشمل التوافق مع BSO-holder. تعرّف على المزيد على 5chan.app/pass.",
|
||||
"upload_failed": "فشل التحميل",
|
||||
"upload_not_supported_web": "رفع الوسائط غير مدعوم من متصفحك بسبب قيود تقنية (CORS). إنه مدعوم في تطبيق 5chan لـ Android والتطبيق المكتبي لـ Mac/Linux/Windows. التحميل من صفحة الإصدار الرسمية: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "ملف",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "সরাসরি লিংক অনুলিপি করুন",
|
||||
"posts_auto_update_info": "পোস্টগুলি প্রতি ~2 মিনিটে স্বয়ংক্রিয়ভাবে আপডেট হয়। বর্তমানে এটি অক্ষম করা সম্ভব নয়।",
|
||||
"board_address_tooltip": "এই বোর্ডের পিয়ার-টু-পিয়ার নোডের ঠিকানা",
|
||||
"vote_button_unavailable_intro": "এখনও উপলভ্য নয়। প্রতিটি ডিরেক্টরিতে /{{boardIdentifier}}/ এর জন্য প্রতিদ্বন্দ্বিতা করা বোর্ডগুলোর তালিকা-সহ একটি ভোটিং পৃষ্ঠা থাকবে।",
|
||||
"vote_button_unavailable_outro": "5chan Pass ধারীরা ডিরেক্টরি ভোটিংয়ে অংশ নিতে পারবেন। চূড়ান্ত গভর্ন্যান্স কাঠামো এখনও এমনভাবে নকশা করা হচ্ছে যাতে BSO-holder সমন্বয় অন্তর্ভুক্ত হয়। আরও জানুন 5chan.app/pass এ।",
|
||||
"upload_failed": "আপলোড ব্যর্থ",
|
||||
"upload_not_supported_web": "প্রযুক্তিগত সীমাবদ্ধতার কারণে (CORS) আপনার ব্রাউজার মিডিয়া আপলোড সমর্থন করে না। এটি 5chan অ্যান্ড্রয়েড অ্যাপ এবং Mac/Linux/Windows ডেস্কটপ অ্যাপে সমর্থিত। অফিসিয়াল রিলিজ পৃষ্ঠা থেকে ডাউনলোড করুন: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "ফাইল",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "kopírovat přímý odkaz",
|
||||
"posts_auto_update_info": "Příspěvky se aktualizují automaticky přibližně každých ~2 minut. V současné době není možné tuto funkci zakázat.",
|
||||
"board_address_tooltip": "Adresa uzlu peer-to-peer této desky",
|
||||
"vote_button_unavailable_intro": "Zatím není k dispozici. Každý adresář bude mít hlasovací stránku se seznamem nástěnek soutěžících o /{{boardIdentifier}}/.",
|
||||
"vote_button_unavailable_outro": "Drzitele 5chan Pass se budou moci zapojit do hlasování v adresáři. Konečná pravidla správy se stále navrhují tak, aby zahrnovala soulad s BSO-holder. Více se dozvíte na 5chan.app/pass.",
|
||||
"upload_failed": "Nahrávání se nezdařilo",
|
||||
"upload_not_supported_web": "Nahrávání médií není vaším prohlížečem podporováno z důvodu technických omezení (CORS). Je podporováno v aplikaci 5chan pro Android a desktopové aplikaci pro Mac/Linux/Windows. Stáhněte si z oficiální stránky vydání: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "Soubor",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "kopier direkte link",
|
||||
"posts_auto_update_info": "Indlæg opdateres automatisk hver ~2 minut. Det er ikke muligt at deaktivere dette i øjeblikket.",
|
||||
"board_address_tooltip": "Adressen til dette boards peer-to-peer-node",
|
||||
"vote_button_unavailable_intro": "Ikke tilgængelig endnu. Hvert katalog får en afstemningsside med en liste over de boards, der konkurrerer om /{{boardIdentifier}}/.",
|
||||
"vote_button_unavailable_outro": "Indehavere af 5chan Pass vil kunne deltage i katalogafstemning. De endelige governance-mekanismer bliver stadig udformet, så de også omfatter BSO-holder-tilpasning. Læs mere på 5chan.app/pass.",
|
||||
"upload_failed": "Upload mislykkedes",
|
||||
"upload_not_supported_web": "Upload af medier understøttes ikke af din browser på grund af tekniske begrænsninger (CORS). Det understøttes i 5chan Android-appen og Mac/Linux/Windows desktop-appen. Download fra den officielle udgivelsesside: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "Fil",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "Direktlink kopieren",
|
||||
"posts_auto_update_info": "Beiträge werden automatisch etwa alle ~2 Minuten aktualisiert. Es ist derzeit nicht möglich, dies zu deaktivieren.",
|
||||
"board_address_tooltip": "Die Adresse des Peer-to-Peer-Knotens dieses Boards",
|
||||
"vote_button_unavailable_intro": "Noch nicht verfügbar. Jedes Verzeichnis wird eine Abstimmungsseite haben, auf der die Boards aufgeführt sind, die um /{{boardIdentifier}}/ konkurrieren.",
|
||||
"vote_button_unavailable_outro": "Inhaber eines 5chan Pass werden an der Verzeichnisabstimmung teilnehmen können. Die endgültigen Governance-Mechanismen werden noch so gestaltet, dass sie auch die Abstimmung mit BSO-holder berücksichtigen. Mehr dazu unter 5chan.app/pass.",
|
||||
"upload_failed": "Upload fehlgeschlagen",
|
||||
"upload_not_supported_web": "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. Download von der offiziellen Release-Seite: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "Datei",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "αντιγραφή άμεσου συνδέσμου",
|
||||
"posts_auto_update_info": "Οι δημοσιεύσεις ενημερώνονται αυτόματα περίπου κάθε ~2 λεπτά. Δεν είναι δυνατό να απενεργοποιήσετε αυτό τη στιγμή.",
|
||||
"board_address_tooltip": "Η διεύθυνση του κόμβου peer-to-peer αυτού του πίνακα",
|
||||
"vote_button_unavailable_intro": "Δεν είναι ακόμη διαθέσιμο. Κάθε κατάλογος θα έχει μια σελίδα ψηφοφορίας με τις σανίδες που ανταγωνίζονται για το /{{boardIdentifier}}/.",
|
||||
"vote_button_unavailable_outro": "Οι κάτοχοι του 5chan Pass θα μπορούν να συμμετέχουν στην ψηφοφορία καταλόγου. Οι τελικοί μηχανισμοί διακυβέρνησης εξακολουθούν να σχεδιάζονται ώστε να περιλαμβάνουν ευθυγράμμιση με BSO-holder. Μάθετε περισσότερα στο 5chan.app/pass.",
|
||||
"upload_failed": "Αποτυχία μεταφόρτωσης",
|
||||
"upload_not_supported_web": "Η μεταφόρτωση πολυμέσων δεν υποστηρίζεται από το πρόγραμμα περιήγησής σας λόγω τεχνικών περιορισμών (CORS). Υποστηρίζεται στην εφαρμογή 5chan για Android και την εφαρμογή επιφάνειας εργασίας Mac/Linux/Windows. Λήψη από τη σελίδα επίσημης έκδοσης: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "Αρχείο",
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
"time_x_months_ago": "{{count}} months ago",
|
||||
"time_1_year_ago": "1 year ago",
|
||||
"time_x_years_ago": "{{count}} years ago",
|
||||
"open": "open",
|
||||
"open": "Open",
|
||||
"bump_order": "Bump order",
|
||||
"creation_date": "Creation date",
|
||||
"sort_by": "Sort by",
|
||||
@@ -206,6 +206,7 @@
|
||||
"no_file_chosen": "No file chosen",
|
||||
"error": "Error",
|
||||
"vote": "vote",
|
||||
"directory": "Directory",
|
||||
"warning": "Warning",
|
||||
"comment_field_too_long": "Comment too long ({{length}}/2000).",
|
||||
"field_too_long": "Field too long.",
|
||||
@@ -264,8 +265,23 @@
|
||||
"copy_user_id": "Copy user ID",
|
||||
"copy_direct_link": "Copy direct link",
|
||||
"board_address_tooltip": "The address of this board's peer-to-peer node",
|
||||
"vote_button_unavailable_intro": "Not available yet. Each directory will have a voting page listing the boards competing for /{{boardIdentifier}}/.",
|
||||
"vote_button_unavailable_outro": "5chan Pass holders will be able to participate in directory voting. Final governance mechanics are still being designed to include BSO-holder alignment. Learn more at 5chan.app/pass.",
|
||||
"directory_heading": "Displaying all ({{count}}) known /{{boardIdentifier}}/ boards on 5chan",
|
||||
"directory_score": "Score",
|
||||
"directory_vote": "Vote",
|
||||
"directory_board": "Board",
|
||||
"directory_owner": "Owner",
|
||||
"directory_status": "Status",
|
||||
"directory_status_online": "online",
|
||||
"directory_status_offline": "offline",
|
||||
"directory_owner_anonymous": "Anonymous",
|
||||
"directory_subtitle": "The highest-scoring board resolves /{{boardIdentifier}}/; if it goes offline, 5chan rotates to the next-highest.",
|
||||
"directory_footnote": "To submit your own board, open a pull request <repoLink>here</repoLink>. Read more on the <passLink>5chan Pass</passLink> page.",
|
||||
"directory_voting_unavailable_intro": "Voting isn't enabled yet. The board ranked #1 in /{{boardIdentifier}}/ is temporarily fixed because it's currently managed by devs.",
|
||||
"directory_voting_unavailable_outro": "5chan Pass holders and BSO token holders will be able to upvote and downvote each board in this list. Learn more at 5chan.app/pass.",
|
||||
"loading_directory": "Loading directory",
|
||||
"directory_empty": "No boards listed for this directory yet.",
|
||||
"upvote": "Upvote",
|
||||
"downvote": "Downvote",
|
||||
"upload_failed": "Upload failed",
|
||||
"upload_not_supported_web": "Uploading media isn't supported by your browser due to technical limitations (CORS). It is supported in the 5chan Android app and Mac/Linux/Windows desktop app. Download from the official release page: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"upload_not_supported_web_before_link": "Uploading media isn't supported by your browser due to technical limitations (CORS). It is supported in the 5chan Android app and Mac/Linux/Windows desktop app.",
|
||||
@@ -389,5 +405,6 @@
|
||||
"media_failed_to_load_source_unknown": "External host, not 5chan",
|
||||
"media_failed_to_load_open_source": "Open original file",
|
||||
"media_failed_to_load_inline": "External image from {{host}} failed. Try VPN.",
|
||||
"media_failed_to_load_inline_unknown": "External image failed. Try VPN."
|
||||
"media_failed_to_load_inline_unknown": "External image failed. Try VPN.",
|
||||
"directory_status_unavailable_reason": "check only available for top 5 boards"
|
||||
}
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "Copiar enlace directo",
|
||||
"posts_auto_update_info": "Los posts se actualizan automáticamente cada ~2 minutos. Por el momento, no es posible desactivar esto.",
|
||||
"board_address_tooltip": "La dirección del nodo peer-to-peer de este tablón",
|
||||
"vote_button_unavailable_intro": "Aún no está disponible. Cada directorio tendrá una página de votación con los tablones que compiten por /{{boardIdentifier}}/.",
|
||||
"vote_button_unavailable_outro": "Los titulares de 5chan Pass podrán participar en la votación del directorio. Los mecanismos finales de gobernanza aún se están diseñando para incluir la alineación con BSO-holder. Más información en 5chan.app/pass.",
|
||||
"upload_failed": "Error al subir",
|
||||
"upload_not_supported_web": "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 en la app de escritorio para Mac/Linux/Windows. Descarga desde la página oficial de releases: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "Archivo",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "رونوشت لینک مستقیم",
|
||||
"posts_auto_update_info": "پستها هر ~2 دقیقه بهطور خودکار بهروز میشوند. فعلاً امکان غیرفعال کردن آن وجود ندارد.",
|
||||
"board_address_tooltip": "آدرس گره همتا-همتای این تخته",
|
||||
"vote_button_unavailable_intro": "هنوز در دسترس نیست. هر دایرکتوری یک صفحه رأیگیری خواهد داشت که بوردهای رقابتکننده برای /{{boardIdentifier}}/ را فهرست میکند.",
|
||||
"vote_button_unavailable_outro": "دارندگان 5chan Pass میتوانند در رأیگیری دایرکتوری شرکت کنند. سازوکارهای نهایی حاکمیتی هنوز در حال طراحی هستند تا همسویی با BSO-holder را نیز شامل شوند. اطلاعات بیشتر در 5chan.app/pass.",
|
||||
"upload_failed": "آپلود ناموفق بود",
|
||||
"upload_not_supported_web": "آپلود رسانه توسط مرورگر شما به دلیل محدودیتهای فنی (CORS) پشتیبانی نمیشود. در اپلیکیشن 5chan اندروید و اپلیکیشن دسکتاپ Mac/Linux/Windows پشتیبانی میشود. از صفحه انتشار رسمی دانلود کنید: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "فایل",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "kopioi suora linkki",
|
||||
"posts_auto_update_info": "Viestit päivittyvät automaattisesti noin 2 minuutin välein. Tätä ei ole mahdollista poistaa käytöstä tällä hetkellä.",
|
||||
"board_address_tooltip": "Tämän laudan vertaisverkko-solmun osoite",
|
||||
"vote_button_unavailable_intro": "Ei vielä saatavilla. Jokaisella hakemistolla on äänestyssivu, jolla luetellaan /{{boardIdentifier}}/:stä kilpailevat laudat.",
|
||||
"vote_button_unavailable_outro": "5chan Passin haltijat voivat osallistua hakemistoäänestykseen. Lopullisia hallintamekaniikkoja suunnitellaan edelleen niin, että myös BSO-holder-alignment sisältyy niihin. Lue lisää osoitteessa 5chan.app/pass.",
|
||||
"upload_failed": "Lataus epäonnistui",
|
||||
"upload_not_supported_web": "Medioiden latausta ei tueta selaimessasi teknisistä rajoituksista (CORS) johtuen. Se on tuettu 5chan-sovelluksessa Androidille ja Mac/Linux/Windows-työpöytäsovelluksessa. Lataa viralliselta julkaisusivulta: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "Tiedosto",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "kopyahin ang direct link",
|
||||
"posts_auto_update_info": "Ang mga post ay awtomatikong nag-update bawat ~2 minuto. Kasalukuyang hindi posible na i-disable ito.",
|
||||
"board_address_tooltip": "Ang address ng peer-to-peer node ng board na ito",
|
||||
"vote_button_unavailable_intro": "Hindi pa available. Bawat directory ay magkakaroon ng voting page na naglilista ng mga board na naglalaban para sa /{{boardIdentifier}}/.",
|
||||
"vote_button_unavailable_outro": "Makakalahok ang mga may hawak ng 5chan Pass sa pagboto sa direktoryo. Patuloy pang idinidisenyo ang pinal na mga mekanismo ng pamamahala upang maisama ang pagkakahanay sa BSO-holder. Alamin pa sa 5chan.app/pass.",
|
||||
"upload_failed": "Nabigo ang pag-upload",
|
||||
"upload_not_supported_web": "Ang pag-upload ng media ay hindi sinusuportahan ng iyong browser dahil sa mga limitasyong teknikal (CORS). Sinusuportahan ito sa 5chan Android app at Mac/Linux/Windows desktop app. I-download mula sa opisyal na release page: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "Talaksan",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "Copier le lien direct",
|
||||
"posts_auto_update_info": "Les messages se mettent à jour automatiquement toutes les ~2 minutes. Il n'est pas possible de désactiver cela pour le moment.",
|
||||
"board_address_tooltip": "L'adresse du nœud pair-à-pair de ce tableau",
|
||||
"vote_button_unavailable_intro": "Pas encore disponible. Chaque répertoire aura une page de vote listant les tableaux en compétition pour /{{boardIdentifier}}/.",
|
||||
"vote_button_unavailable_outro": "Les détenteurs de 5chan Pass pourront participer au vote de l’annuaire. Les mécanismes finaux de gouvernance sont encore en cours de conception afin d’inclure l’alignement avec BSO-holder. En savoir plus sur 5chan.app/pass.",
|
||||
"upload_failed": "Échec du téléchargement",
|
||||
"upload_not_supported_web": "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 pour Android et l'application de bureau Mac/Linux/Windows. Téléchargez depuis la page officielle des versions : https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "Fichier",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "העתק קישור ישיר",
|
||||
"posts_auto_update_info": "הפוסטים מתעדכנים באופן אוטומטי בערך כל ~2 דקות. כרגע לא ניתן להשבית זאת.",
|
||||
"board_address_tooltip": "כתובת צומת ה-P2P של הלוח הזה",
|
||||
"vote_button_unavailable_intro": "עדיין לא זמין. לכל ספרייה יהיה דף הצבעה שמציג את הלוחות המתחרים על /{{boardIdentifier}}/.",
|
||||
"vote_button_unavailable_outro": "מחזיקי 5chan Pass יוכלו להשתתף בהצבעה על הספרייה. מנגנוני הממשל הסופיים עדיין מתוכננים כך שיכללו התאמה עם BSO-holder. למידע נוסף: 5chan.app/pass.",
|
||||
"upload_failed": "ההעלאה נכשלה",
|
||||
"upload_not_supported_web": "העלאת מדיה אינה נתמכת בדפדפן שלך בשל מגבלות טכניות (CORS). היא נתמכת באפליקציית 5chan ל-Android ובאפליקציית הדסקטופ ל-Mac/Linux/Windows. הורד מדף השחרור הרשמי: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "קובץ",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "डायरेक्ट लिंक कॉपी करें",
|
||||
"posts_auto_update_info": "पोस्ट्स हर ~2 मिनट में स्वचालित रूप से अपडेट होते हैं। फिलहाल इसे अक्षम करना संभव नहीं है।",
|
||||
"board_address_tooltip": "इस बोर्ड के पीयर-टू-पीयर नोड का पता",
|
||||
"vote_button_unavailable_intro": "अभी उपलब्ध नहीं है। हर डायरेक्टरी में एक वोटिंग पेज होगा जिसमें /{{boardIdentifier}}/ के लिए प्रतिस्पर्धा करने वाले बोर्ड सूचीबद्ध होंगे।",
|
||||
"vote_button_unavailable_outro": "5chan Pass धारक डायरेक्टरी वोटिंग में भाग ले सकेंगे। अंतिम गवर्नेंस मैकेनिक्स अभी भी इस तरह डिज़ाइन किए जा रहे हैं कि उनमें BSO-holder alignment शामिल हो। अधिक जानें: 5chan.app/pass.",
|
||||
"upload_failed": "अपलोड विफल",
|
||||
"upload_not_supported_web": "तकनीकी सीमाओं (CORS) के कारण आपका ब्राउज़र मीडिया अपलोड का समर्थन नहीं करता। यह 5chan एंड्रॉइड ऐप और Mac/Linux/Windows डेस्कटॉप ऐप में समर्थित है। आधिकारिक रिलीज़ पेज से डाउनलोड करें: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "फ़ाइल",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "közvetlen link másolása",
|
||||
"posts_auto_update_info": "A bejegyzések kb. 2 percenként automatikusan frissülnek. Jelenleg nem lehetséges ezt letiltani.",
|
||||
"board_address_tooltip": "Ennek a táblának a peer-to-peer csomópontja címe",
|
||||
"vote_button_unavailable_intro": "Még nem érhető el. Minden könyvtárhoz tartozni fog egy szavazási oldal, amely felsorolja a /{{boardIdentifier}}/ helyért versengő boardokat.",
|
||||
"vote_button_unavailable_outro": "Az 5chan Pass tulajdonosai részt vehetnek majd a könyvtárszavazásban. A végleges irányítási mechanizmusok még kialakítás alatt állnak, hogy a BSO-holderrel való összhangot is tartalmazzák. További információ: 5chan.app/pass.",
|
||||
"upload_failed": "A feltöltés sikertelen",
|
||||
"upload_not_supported_web": "A média feltöltése nem támogatott a böngésződben technikai korlátozások miatt (CORS). A 5chan Android alkalmazásban és a Mac/Linux/Windows asztali alkalmazásban támogatott. Töltse le a hivatalos kiadási oldalról: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "Fájl",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "salin tautan langsung",
|
||||
"posts_auto_update_info": "Posting diperbarui secara otomatis setiap ~2 menit. Saat ini tidak mungkin untuk menonaktifkan ini.",
|
||||
"board_address_tooltip": "Alamat node peer-to-peer papan ini",
|
||||
"vote_button_unavailable_intro": "Belum tersedia. Setiap direktori akan memiliki halaman pemungutan suara yang mencantumkan board yang bersaing untuk /{{boardIdentifier}}/.",
|
||||
"vote_button_unavailable_outro": "Pemegang 5chan Pass akan dapat berpartisipasi dalam voting direktori. Mekanisme tata kelola final masih dirancang untuk mencakup keselarasan dengan BSO-holder. Pelajari lebih lanjut di 5chan.app/pass.",
|
||||
"upload_failed": "Unggahan gagal",
|
||||
"upload_not_supported_web": "Mengunggah media tidak didukung oleh browser Anda karena batasan teknis (CORS). Ini didukung di aplikasi 5chan Android dan aplikasi desktop Mac/Linux/Windows. Unduh dari halaman rilis resmi: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "Berkas",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "Copia link diretto",
|
||||
"posts_auto_update_info": "I post si aggiornano automaticamente ogni ~2 minuti. Non è possibile disabilitare questa funzione al momento.",
|
||||
"board_address_tooltip": "L'indirizzo del nodo peer-to-peer di questa bacheca",
|
||||
"vote_button_unavailable_intro": "Non ancora disponibile. Ogni directory avrà una pagina di voto che elenca le bacheche in competizione per /{{boardIdentifier}}/.",
|
||||
"vote_button_unavailable_outro": "I titolari di 5chan Pass potranno partecipare al voto della directory. I meccanismi finali di governance sono ancora in fase di definizione per includere l'allineamento con BSO-holder. Scopri di più su 5chan.app/pass.",
|
||||
"upload_failed": "Caricamento fallito",
|
||||
"upload_not_supported_web": "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. Scarica dalla pagina ufficiale delle release: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "File",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "直接リンクをコピー",
|
||||
"posts_auto_update_info": "投稿は約2分ごとに自動更新されます。現在、これを無効にすることはできません。",
|
||||
"board_address_tooltip": "この板のピアツーピアノードのアドレス",
|
||||
"vote_button_unavailable_intro": "まだ利用できません。各ディレクトリには、/{{boardIdentifier}}/ を争うボードを一覧する投票ページが用意されます。",
|
||||
"vote_button_unavailable_outro": "5chan Passの保有者はディレクトリ投票に参加できるようになります。最終的なガバナンスの仕組みは、BSO-holderとの整合性を含める形で引き続き設計中です。詳しくは 5chan.app/pass をご覧ください。",
|
||||
"upload_failed": "アップロードに失敗しました",
|
||||
"upload_not_supported_web": "技術的な制限(CORS)により、お使いのブラウザではメディアのアップロードに対応していません。5chan AndroidアプリとMac/Linux/Windowsデスクトップアプリでは対応しています。公式リリースページからダウンロードしてください: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "ファイル",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "직접 링크 복사",
|
||||
"posts_auto_update_info": "게시물은 약 2분마다 자동으로 업데이트됩니다. 현재 이를 비활성화할 수 없습니다.",
|
||||
"board_address_tooltip": "이 게시판의 P2P 노드 주소",
|
||||
"vote_button_unavailable_intro": "아직 사용할 수 없습니다. 각 디렉터리에는 /{{boardIdentifier}}/ 자리를 두고 경쟁하는 보드 목록이 있는 투표 페이지가 생깁니다.",
|
||||
"vote_button_unavailable_outro": "5chan Pass 보유자는 디렉터리 투표에 참여할 수 있게 됩니다. 최종 거버넌스 메커니즘은 BSO-holder alignment를 포함하도록 아직 설계 중입니다. 자세한 내용은 5chan.app/pass 에서 확인하세요.",
|
||||
"upload_failed": "업로드 실패",
|
||||
"upload_not_supported_web": "기술적 제한(CORS)으로 인해 브라우저에서 미디어 업로드를 지원하지 않습니다. 5chan Android 앱과 Mac/Linux/Windows 데스크톱 앱에서는 지원됩니다. 공식 릴리스 페이지에서 다운로드하세요: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "파일",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "डायरेक्ट लिंक कॉपी करा",
|
||||
"posts_auto_update_info": "पोस्ट प्रत्येक ~2 मिनिटात स्वयंचलितपणे अपडेट होतात. सध्या हे अक्षम करणे शक्य नाही.",
|
||||
"board_address_tooltip": "या बोर्डच्या पीअर-टू-पीअर नोडचा पत्ता",
|
||||
"vote_button_unavailable_intro": "अद्याप उपलब्ध नाही. प्रत्येक डायरेक्टरीमध्ये /{{boardIdentifier}}/ साठी स्पर्धा करणाऱ्या बोर्डांची यादी असलेले मतदान पृष्ठ असेल.",
|
||||
"vote_button_unavailable_outro": "5chan Pass धारकांना डिरेक्टरी मतदानात सहभागी होता येईल. अंतिम गव्हर्नन्स यंत्रणा अजूनही BSO-holder alignment समाविष्ट करण्यासाठी डिझाइन केल्या जात आहेत. अधिक माहितीसाठी 5chan.app/pass ला भेट द्या.",
|
||||
"upload_failed": "अपलोड अयशस्वी",
|
||||
"upload_not_supported_web": "तांत्रिक मर्यादांमुळे (CORS) तुमचा ब्राउझर मीडिया अपलोडला समर्थन देत नाही. 5chan Android अॅप आणि Mac/Linux/Windows डेस्कटॉप अॅपमध्ये ते समर्थित आहे. अधिकृत रिलीझ पेजवरून डाउनलोड करा: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "फाइल",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "directe link kopiëren",
|
||||
"posts_auto_update_info": "Berichten worden automatisch ongeveer elke ~2 minuten bijgewerkt. Het is momenteel niet mogelijk dit uit te schakelen.",
|
||||
"board_address_tooltip": "Het adres van de peer-to-peer-node van dit bord",
|
||||
"vote_button_unavailable_intro": "Nog niet beschikbaar. Elke directory krijgt een stempagina met de boards die strijden om /{{boardIdentifier}}/.",
|
||||
"vote_button_unavailable_outro": "Houders van 5chan Pass kunnen deelnemen aan directory-stemmingen. De definitieve governance-mechanismen worden nog ontworpen om ook afstemming met BSO-holder op te nemen. Meer informatie op 5chan.app/pass.",
|
||||
"upload_failed": "Upload mislukt",
|
||||
"upload_not_supported_web": "Het uploaden van media wordt niet ondersteund door je browser vanwege technische beperkingen (CORS). Het wordt wel ondersteund in de 5chan Android-app en de Mac/Linux/Windows desktop-app. Download van de officiële releasespagina: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "Bestand",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "kopier direkte lenke",
|
||||
"posts_auto_update_info": "Innlegg oppdateres automatisk omtrent hver ~2 minutt. Det er ikke mulig å deaktivere dette for øyeblikket.",
|
||||
"board_address_tooltip": "Adressen til denne tavlens peer-to-peer-node",
|
||||
"vote_button_unavailable_intro": "Ikke tilgjengelig ennå. Hver katalog vil få en avstemningsside som viser brettene som konkurrerer om /{{boardIdentifier}}/.",
|
||||
"vote_button_unavailable_outro": "Innehavere av 5chan Pass vil kunne delta i katalogavstemninger. De endelige styringsmekanismene utformes fortsatt for å inkludere samsvar med BSO-holder. Les mer på 5chan.app/pass.",
|
||||
"upload_failed": "Opplasting mislyktes",
|
||||
"upload_not_supported_web": "Opplasting av media 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. Last ned fra den offisielle utgivelsessiden: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "Fil",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "skopiuj bezpośredni link",
|
||||
"posts_auto_update_info": "Posty aktualizują się automatycznie co ~2 minuty. Chwilowo nie jest możliwe wyłączenie tej funkcji.",
|
||||
"board_address_tooltip": "Adres węzła peer-to-peer tej tablicy",
|
||||
"vote_button_unavailable_intro": "Jeszcze niedostępne. Każdy katalog będzie miał stronę głosowania z listą tablic rywalizujących o /{{boardIdentifier}}/.",
|
||||
"vote_button_unavailable_outro": "Posiadacze 5chan Pass będą mogli brać udział w głosowaniu katalogowym. Ostateczne mechanizmy zarządzania są nadal projektowane tak, aby uwzględniały zgodność z BSO-holder. Dowiedz się więcej na 5chan.app/pass.",
|
||||
"upload_failed": "Przesyłanie nie powiodło się",
|
||||
"upload_not_supported_web": "Przesyłanie multimediów nie jest obsługiwane przez twoją przeglądarkę ze względu na ograniczenia techniczne (CORS). Jest obsługiwane w aplikacji 5chan na Androida i w aplikacji desktopowej Mac/Linux/Windows. Pobierz ze strony oficjalnych wydań: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "Plik",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "Copiar link direto",
|
||||
"posts_auto_update_info": "Posts são atualizados automaticamente a cada ~2 minutos. Não é possível desabilitar isso no momento.",
|
||||
"board_address_tooltip": "O endereço do nó peer-to-peer desta placa",
|
||||
"vote_button_unavailable_intro": "Ainda não está disponível. Cada diretório terá uma página de votação listando os boards que competem por /{{boardIdentifier}}/.",
|
||||
"vote_button_unavailable_outro": "Os titulares de 5chan Pass poderão participar na votação do diretório. Os mecanismos finais de governação ainda estão a ser definidos para incluir o alinhamento com BSO-holder. Saiba mais em 5chan.app/pass.",
|
||||
"upload_failed": "Falha no envio",
|
||||
"upload_not_supported_web": "O envio de mídia não é suportado pelo seu navegador devido a limitações técnicas (CORS). É suportado no app 5chan para Android e no app de desktop para Mac/Linux/Windows. Baixe na página oficial de releases: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "Arquivo",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "copiați linkul direct",
|
||||
"posts_auto_update_info": "Postările se actualizează automat la fiecare ~2 minute. În prezent, nu este posibil să dezactivezi aceasta.",
|
||||
"board_address_tooltip": "Adresa nodului peer-to-peer al acestei plăci",
|
||||
"vote_button_unavailable_intro": "Nu este disponibil încă. Fiecare director va avea o pagină de vot care listează tablourile ce concurează pentru /{{boardIdentifier}}/.",
|
||||
"vote_button_unavailable_outro": "Deținătorii de 5chan Pass vor putea participa la votul directorului. Mecanismele finale de guvernanță sunt încă în curs de proiectare pentru a include alinierea cu BSO-holder. Aflați mai multe la 5chan.app/pass.",
|
||||
"upload_failed": "Încărcarea a eșuat",
|
||||
"upload_not_supported_web": "Încărcarea mediilor nu este suportată de browserul tău din cauza limitărilor tehnice (CORS). Este suportată în aplicația 5chan pentru Android și aplicația de desktop pentru Mac/Linux/Windows. Descarcă de pe pagina oficială de release: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "Fișier",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "копировать прямую ссылку",
|
||||
"posts_auto_update_info": "Посты обновляются автоматически примерно каждые ~2 минуты. В данный момент невозможно отключить эту функцию.",
|
||||
"board_address_tooltip": "Адрес пирингового узла этой доски",
|
||||
"vote_button_unavailable_intro": "Пока недоступно. У каждого каталога будет страница голосования со списком досок, которые борются за /{{boardIdentifier}}/.",
|
||||
"vote_button_unavailable_outro": "Держатели 5chan Pass смогут участвовать в голосовании по каталогу. Окончательные механики управления все еще разрабатываются так, чтобы включать согласованность с BSO-holder. Подробнее: 5chan.app/pass.",
|
||||
"upload_failed": "Ошибка загрузки",
|
||||
"upload_not_supported_web": "Загрузка медиафайлов не поддерживается вашим браузером из-за технических ограничений (CORS). Поддерживается в приложении 5chan для Android и в десктопном приложении для Mac/Linux/Windows. Скачайте с официальной страницы релизов: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "Файл",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "kopjo lidhjen e drejtpërdrejtë",
|
||||
"posts_auto_update_info": "Postimet përditësohen automatikisht çdo ~2 minuta. Nuk është e mundur ta çaktivizoni këtë për momentin.",
|
||||
"board_address_tooltip": "Adresa e nyjës peer-to-peer të kësaj tabele",
|
||||
"vote_button_unavailable_intro": "Ende nuk është i disponueshëm. Çdo drejtori do të ketë një faqe votimi me bordet që konkurrojnë për /{{boardIdentifier}}/.",
|
||||
"vote_button_unavailable_outro": "Mbajtësit e 5chan Pass do të mund të marrin pjesë në votimin e direktorisë. Mekanizmat përfundimtarë të qeverisjes ende po hartohen për të përfshirë përputhjen me BSO-holder. Mësoni më shumë te 5chan.app/pass.",
|
||||
"upload_failed": "Ngarkimi dështoi",
|
||||
"upload_not_supported_web": "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 desktop për Mac/Linux/Windows. Shkarkoni nga faqja zyrtare e publikimeve: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "Skedar",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "kopiera direktlänk",
|
||||
"posts_auto_update_info": "Inlägg uppdateras automatiskt ungefär var ~2:e minut. Det är för närvarande inte möjligt att inaktivera detta.",
|
||||
"board_address_tooltip": "Adressen till denna tavlas peer-to-peer-nod",
|
||||
"vote_button_unavailable_intro": "Inte tillgängligt ännu. Varje katalog kommer att ha en omröstningssida som listar brädorna som tävlar om /{{boardIdentifier}}/.",
|
||||
"vote_button_unavailable_outro": "Innehavare av 5chan Pass kommer att kunna delta i katalogomröstningar. De slutliga styrningsmekanismerna utformas fortfarande för att inkludera anpassning till BSO-holder. Läs mer på 5chan.app/pass.",
|
||||
"upload_failed": "Uppladdning misslyckades",
|
||||
"upload_not_supported_web": "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 desktop-appen. Ladda ner från den officiella utgivningssidan: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "Fil",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "డైరెక్ట్ లింక్ కాపీ చేయండి",
|
||||
"posts_auto_update_info": "పోస్టులు ప్రతి ~2 నిమిషాలకు స్వయంచాలకంగా నవీకరించబడతాయి. ప్రస్తుతం దీనిని నిలిపివేయడం సম్భవం కాదు.",
|
||||
"board_address_tooltip": "ఈ బోర్డ్ యొక్క పీర్-టు-పీర్ నోడ్ యొక్క చిరునామా",
|
||||
"vote_button_unavailable_intro": "ఇంకా అందుబాటులో లేదు. ప్రతి డైరెక్టరీలో /{{boardIdentifier}}/ కోసం పోటీ పడుతున్న బోర్డులను జాబితా చేసే ఓటింగ్ పేజీ ఉంటుంది.",
|
||||
"vote_button_unavailable_outro": "5chan Pass హోల్డర్లు డైరెక్టరీ ఓటింగ్లో పాల్గొనగలరు. BSO-holder alignment ను చేర్చేలా తుది గవర్నెన్స్ విధానాలు ఇంకా రూపకల్పనలో ఉన్నాయి. మరింత తెలుసుకోండి: 5chan.app/pass.",
|
||||
"upload_failed": "అప్లోడ్ విఫలమైంది",
|
||||
"upload_not_supported_web": "సాంకేతిక పరిమితుల (CORS) కారణంగా మీ బ్రౌజర్ మీడియా అప్లోడ్కు మద్దతు ఇవ్వదు. ఇది 5chan Android అనువర్తనం మరియు Mac/Linux/Windows డెస్క్టాప్ అనువర్తనంలో మద్దతివ్వబడుతుంది. అధికారిక రిలీజ్ పేజీ నుండి డౌన్లోడ్ చేయండి: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "ఫైల్",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "คัดลอกลิงก์โดยตรง",
|
||||
"posts_auto_update_info": "โพสต์จะอัปเดตโดยอัตโนมัติทุก ~2 นาที ไม่สามารถปิดการใช้งานนี้ได้ในขณะนี้",
|
||||
"board_address_tooltip": "ที่อยู่โหนด P2P ของบอร์ดนี้",
|
||||
"vote_button_unavailable_intro": "ยังไม่พร้อมใช้งาน แต่ละไดเรกทอรีจะมีหน้าลงคะแนนที่แสดงรายการบอร์ดที่แข่งขันกันเพื่อ /{{boardIdentifier}}/.",
|
||||
"vote_button_unavailable_outro": "ผู้ถือ 5chan Pass จะสามารถเข้าร่วมการโหวตไดเรกทอรีได้ กลไกการกำกับดูแลขั้นสุดท้ายยังอยู่ระหว่างการออกแบบเพื่อรวมการจัดแนวร่วมกับ BSO-holder ไว้ด้วย ดูข้อมูลเพิ่มเติมที่ 5chan.app/pass",
|
||||
"upload_failed": "อัปโหลดล้มเหลว",
|
||||
"upload_not_supported_web": "การอัปโหลดสื่อไม่รองรับโดยเบราว์เซอร์ของคุณเนื่องจากข้อจำกัดทางเทคนิค (CORS) รองรับในแอป 5chan สำหรับ Android และแอปเดสก์ท็อปสำหรับ Mac/Linux/Windows ดาวน์โหลดจากหน้าปล่อยอย่างเป็นทางการ: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "ไฟล์",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "doğrudan bağlantıyı kopyala",
|
||||
"posts_auto_update_info": "Gönderiler her ~2 dakikada otomatik olarak güncellenir. Şu anda bunu devre dışı bırakmak mümkün değildir.",
|
||||
"board_address_tooltip": "Bu panonun eşler arası düğüm adresi",
|
||||
"vote_button_unavailable_intro": "Henüz kullanılamıyor. Her dizinin, /{{boardIdentifier}}/ için yarışan panoları listeleyen bir oylama sayfası olacak.",
|
||||
"vote_button_unavailable_outro": "5chan Pass sahipleri dizin oylamasına katılabilecek. Nihai yönetişim mekanikleri, BSO-holder uyumunu da içerecek şekilde hâlâ tasarlanıyor. Daha fazla bilgi için 5chan.app/pass adresine bakın.",
|
||||
"upload_failed": "Yükleme başarısız",
|
||||
"upload_not_supported_web": "Teknik kısıtlamalar (CORS) nedeniyle tarayıcınız medya yüklemeyi desteklemiyor. 5chan Android uygulamasında ve Mac/Linux/Windows masaüstü uygulamasında destekleniyor. Resmi sürüm sayfasından indirin: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "Dosya",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "копіювати пряме посилання",
|
||||
"posts_auto_update_info": "Дописи оновлюються автоматично приблизно кожні ~2 хвилини. В даний момент неможливо вимкнути цю функцію.",
|
||||
"board_address_tooltip": "Адреса пірінгового вузла цієї дошки",
|
||||
"vote_button_unavailable_intro": "Ще недоступно. Кожен каталог матиме сторінку голосування зі списком дощок, що змагаються за /{{boardIdentifier}}/.",
|
||||
"vote_button_unavailable_outro": "Власники 5chan Pass зможуть брати участь у голосуванні в каталозі. Остаточні механіки врядування все ще розробляються так, щоб включати узгодженість із BSO-holder. Докладніше: 5chan.app/pass.",
|
||||
"upload_failed": "Помилка завантаження",
|
||||
"upload_not_supported_web": "Завантаження медіа не підтримується вашим браузером через технічні обмеження (CORS). Підтримується в додатку 5chan для Android та десктопному додатку для Mac/Linux/Windows. Завантажте з офіційної сторінки релізів: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "Файл",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "براہ راست لنک کاپی کریں",
|
||||
"posts_auto_update_info": "پوسٹس ہر ~2 منٹ میں خود بخود اپ ڈیٹ ہوتے ہیں۔ فی الوقت اس کو غیر فعال کرنا ممکن نہیں ہے۔",
|
||||
"board_address_tooltip": "اس بورڈ کے پیر ٹو پیر نوڈ کا پتہ",
|
||||
"vote_button_unavailable_intro": "ابھی دستیاب نہیں۔ ہر ڈائریکٹری میں ایک ووٹنگ صفحہ ہوگا جس میں /{{boardIdentifier}}/ کے لیے مقابلہ کرنے والے بورڈز کی فہرست ہوگی۔",
|
||||
"vote_button_unavailable_outro": "5chan Pass ہولڈرز ڈائریکٹری ووٹنگ میں حصہ لے سکیں گے۔ حتمی گورننس میکینکس ابھی بھی اس طرح ڈیزائن کیے جا رہے ہیں کہ ان میں BSO-holder alignment شامل ہو۔ مزید جانیں: 5chan.app/pass۔",
|
||||
"upload_failed": "اپ لوڈ ناکام",
|
||||
"upload_not_supported_web": "تکنیکی حدود (CORS) کی وجہ سے آپ کے براؤزر میں میڈیا اپ لوڈ کی حمایت نہیں ہے۔ یہ 5chan Android ایپ اور Mac/Linux/Windows ڈیسک ٹاپ ایپ میں دستیاب ہے۔ سرکاری ریلیز صفحہ سے ڈاؤن لوڈ کریں: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "فائل",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "sao chép liên kết trực tiếp",
|
||||
"posts_auto_update_info": "Bài đăng được cập nhật tự động cứ ~2 phút một lần. Hiện tại không thể tắt tính năng này.",
|
||||
"board_address_tooltip": "Địa chỉ nút peer-to-peer của bảng này",
|
||||
"vote_button_unavailable_intro": "Chưa khả dụng. Mỗi thư mục sẽ có một trang bỏ phiếu liệt kê các bảng đang cạnh tranh cho /{{boardIdentifier}}/.",
|
||||
"vote_button_unavailable_outro": "Người sở hữu 5chan Pass sẽ có thể tham gia bỏ phiếu thư mục. Các cơ chế quản trị cuối cùng vẫn đang được thiết kế để bao gồm sự đồng thuận với BSO-holder. Tìm hiểu thêm tại 5chan.app/pass.",
|
||||
"upload_failed": "Tải lên thất bại",
|
||||
"upload_not_supported_web": "Trình duyệt của bạn không hỗ trợ tải lên media do giới hạn kỹ thuật (CORS). Tính năng này được hỗ trợ trong ứng dụng 5chan cho Android và ứng dụng desktop cho Mac/Linux/Windows. Tải xuống từ trang phát hành chính thức: https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "Tệp",
|
||||
|
||||
@@ -244,8 +244,6 @@
|
||||
"copy_direct_link": "复制直接链接",
|
||||
"posts_auto_update_info": "帖子每约2分钟自动更新一次。目前无法禁用此功能。",
|
||||
"board_address_tooltip": "此板块的点对点节点地址",
|
||||
"vote_button_unavailable_intro": "暂不可用。每个目录都会有一个投票页面,列出正在竞争 /{{boardIdentifier}}/ 的版块。",
|
||||
"vote_button_unavailable_outro": "5chan Pass 持有者将能够参与目录投票。最终治理机制仍在设计中,以纳入与 BSO-holder 的一致性。了解更多请访问 5chan.app/pass。",
|
||||
"upload_failed": "上传失败",
|
||||
"upload_not_supported_web": "由于技术限制(CORS),您的浏览器不支持上传媒体。5chan Android 应用和 Mac/Linux/Windows 桌面应用支持此功能。请从官方发布页面下载:https://github.com/bitsocialnet/5chan/releases/latest",
|
||||
"file": "文件",
|
||||
|
||||
+12
-20
@@ -2,7 +2,7 @@ import * as React from 'react';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { Link, MemoryRouter, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
(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>;
|
||||
|
||||
@@ -266,11 +266,10 @@ const LocationProbe = () => {
|
||||
|
||||
const flushEffects = async (count = 8) => {
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
await Promise.resolve();
|
||||
await new Promise<void>((resolve) => queueMicrotask(resolve));
|
||||
}
|
||||
act(() => {});
|
||||
};
|
||||
|
||||
const renderApp = async (initialEntry: string) => {
|
||||
@@ -279,7 +278,7 @@ const renderApp = async (initialEntry: string) => {
|
||||
}
|
||||
|
||||
latestLocation = initialEntry;
|
||||
await act(async () => {
|
||||
act(() => {
|
||||
root.render(createElement(MemoryRouter, { initialEntries: [initialEntry] }, createElement(App!), createElement(LocationProbe)));
|
||||
});
|
||||
await flushEffects();
|
||||
@@ -318,6 +317,10 @@ const dispatchTextInput = async (element: HTMLTextAreaElement, value: string) =>
|
||||
};
|
||||
|
||||
describe('App', () => {
|
||||
beforeAll(async () => {
|
||||
App = (await import('../app')).default;
|
||||
}, 30000);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
latestLocation = '';
|
||||
@@ -353,19 +356,8 @@ describe('App', () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('renders board layout chrome, settings modal, and reply modal wiring on settings routes', async () => {
|
||||
testState.replyModalState = {
|
||||
activeCid: 'parent-cid',
|
||||
closeModal: vi.fn(),
|
||||
parentNumber: 12,
|
||||
scrollY: 32,
|
||||
showReplyModal: true,
|
||||
communityAddress: 'music-posting.eth',
|
||||
threadCid: 'thread-cid',
|
||||
threadNumber: 99,
|
||||
} as ReplyModalShape;
|
||||
|
||||
await renderApp('/all/settings');
|
||||
it('renders board layout chrome on multiboard routes', async () => {
|
||||
await renderApp('/all');
|
||||
|
||||
expect(container.querySelector('[data-testid="boards-bar"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="board-header"]')).toBeTruthy();
|
||||
@@ -373,7 +365,7 @@ describe('App', () => {
|
||||
expect(container.querySelector('[data-testid="feed-cache-container"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="desktop-board-buttons"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="board-blotter"]')).toBeTruthy();
|
||||
expect(latestLocation).toBe('/all/settings');
|
||||
expect(latestLocation).toBe('/all');
|
||||
});
|
||||
|
||||
it('keeps an open post form and its draft when settings opens from a trailing-slash board route', async () => {
|
||||
|
||||
+18
-3
@@ -23,6 +23,7 @@ import {
|
||||
isBoardModRoute,
|
||||
isDirectoryBoard,
|
||||
isArchiveRoute,
|
||||
isDirectoryListRoute,
|
||||
isLegacyBoardModQueueRoute,
|
||||
isPostRoute,
|
||||
isPendingPostRoute,
|
||||
@@ -36,6 +37,7 @@ import Blotter from './views/blotter';
|
||||
import FAQ from './views/faq';
|
||||
import Home from './views/home';
|
||||
import Archive from './views/archive/archive';
|
||||
import Directory from './views/directory/directory';
|
||||
import ModQueueView from './views/mod-queue';
|
||||
import NotAllowed from './views/not-allowed';
|
||||
import NotFound from './views/not-found';
|
||||
@@ -83,8 +85,9 @@ const BoardLayout = () => {
|
||||
const isOnPendingPostRoute = isPendingPostRoute(pathname);
|
||||
const isOnModQueueRoute = isModQueueRoute(pathname);
|
||||
const isOnArchiveRoute = isArchiveRoute(pathname);
|
||||
const shouldRenderOutlet = isOnPostRoute || isOnPendingPostRoute || isOnModQueueRoute || isOnArchiveRoute;
|
||||
const shouldRenderBoardBlotter = !isOnArchiveRoute && !isOnModQueueRoute;
|
||||
const isOnDirectoryRoute = isDirectoryListRoute(pathname);
|
||||
const shouldRenderOutlet = isOnPostRoute || isOnPendingPostRoute || isOnModQueueRoute || isOnArchiveRoute || isOnDirectoryRoute;
|
||||
const shouldRenderBoardBlotter = !isOnArchiveRoute && !isOnDirectoryRoute && !isOnModQueueRoute;
|
||||
const isInCatalogView = isCatalogView(pathname, params);
|
||||
// Christmas theme
|
||||
const { isEnabled: isSpecialEnabled } = useSpecialThemeStore();
|
||||
@@ -151,6 +154,7 @@ const BoardLayout = () => {
|
||||
{isMobile
|
||||
? (communityAddress || isInAllView || isInModView || isInSubscriptionsView || pendingPostCommunityAddress || isOnModQueueRoute) &&
|
||||
!isOnArchiveRoute &&
|
||||
!isOnDirectoryRoute &&
|
||||
(isInCatalogView ? (
|
||||
<>
|
||||
<PostForm key={key} />
|
||||
@@ -164,7 +168,8 @@ const BoardLayout = () => {
|
||||
</>
|
||||
))
|
||||
: (communityAddress || isInAllView || isInModView || isInSubscriptionsView || pendingPostCommunityAddress || isOnModQueueRoute) &&
|
||||
!isOnArchiveRoute && (
|
||||
!isOnArchiveRoute &&
|
||||
!isOnDirectoryRoute && (
|
||||
<>
|
||||
<PostForm key={key} />
|
||||
{shouldRenderBoardBlotter ? <BoardBlotter /> : null}
|
||||
@@ -314,6 +319,14 @@ const App = () => {
|
||||
<Route path='/subs/archive/settings' element={<Navigate to='/not-found' replace />} />
|
||||
<Route path='/mod/archive' element={<Navigate to='/not-found' replace />} />
|
||||
<Route path='/mod/archive/settings' element={<Navigate to='/not-found' replace />} />
|
||||
<Route path='/all/directory' element={<Navigate to='/not-found' replace />} />
|
||||
<Route path='/all/directory/settings' element={<Navigate to='/not-found' replace />} />
|
||||
<Route path='/subs/directory' element={<Navigate to='/not-found' replace />} />
|
||||
<Route path='/subs/directory/settings' element={<Navigate to='/not-found' replace />} />
|
||||
<Route path='/mod/directory' element={<Navigate to='/not-found' replace />} />
|
||||
<Route path='/mod/directory/settings' element={<Navigate to='/not-found' replace />} />
|
||||
<Route path='/directory' element={<Navigate to='/not-found' replace />} />
|
||||
<Route path='/directory/settings' element={<Navigate to='/not-found' replace />} />
|
||||
|
||||
{/* Invalid subpaths: old URLs and unknown paths -> not-found */}
|
||||
<Route path='/mod/modqueue' element={<Navigate to='/not-found' replace />} />
|
||||
@@ -330,6 +343,8 @@ const App = () => {
|
||||
<Route path='/:boardIdentifier/catalog/settings' element={catalogFeedElement} />
|
||||
<Route path='/:boardIdentifier/archive' element={<Archive />} />
|
||||
<Route path='/:boardIdentifier/archive/settings' element={<Archive />} />
|
||||
<Route path='/:boardIdentifier/directory' element={<Directory />} />
|
||||
<Route path='/:boardIdentifier/directory/settings' element={<Directory />} />
|
||||
|
||||
<Route path='/:boardIdentifier/mod/queue' element={<ModQueueRoute />} />
|
||||
<Route path='/:boardIdentifier/mod/queue/settings' element={<ModQueueRoute />} />
|
||||
|
||||
@@ -323,13 +323,14 @@ describe('BoardButtons', () => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('renders desktop board actions for browsing boards, then searches OPs and triggers refresh, vote, subscribe, and archive flows', async () => {
|
||||
it('renders desktop board actions for browsing boards, then searches OPs and triggers refresh, directory, subscribe, and archive flows', async () => {
|
||||
await renderWithRoute(createElement(DesktopBoardButtons), '/mu');
|
||||
|
||||
expect(container.querySelector('[data-testid="mod-queue-button"]')?.textContent).toBe('mu');
|
||||
expect(container.textContent).toContain('subscribe');
|
||||
expect(container.textContent).toContain('vote');
|
||||
expect(container.textContent).toContain('directory');
|
||||
expect(findButtonLink('catalog')?.getAttribute('href')).toBe('/mu/catalog');
|
||||
expect(findButtonLink('directory')?.getAttribute('href')).toBe('/mu/directory');
|
||||
|
||||
const searchInput = container.querySelector<HTMLInputElement>('input[type="text"]');
|
||||
expect(searchInput).toBeTruthy();
|
||||
@@ -345,14 +346,18 @@ describe('BoardButtons', () => {
|
||||
|
||||
await clickButton('refresh');
|
||||
await clickButton('subscribe');
|
||||
await clickButton('vote');
|
||||
await clickButton('archive');
|
||||
|
||||
expect(testState.resetMock).toHaveBeenCalledTimes(1);
|
||||
expect(testState.subscribeMock).toHaveBeenCalledTimes(1);
|
||||
expect(testState.navigateMock).toHaveBeenCalledWith('/mu/archive');
|
||||
expect(globalThis.alert).toHaveBeenNthCalledWith(1, 'vote_button_unavailable_intro\n\nvote_button_unavailable_outro');
|
||||
expect(globalThis.alert).toHaveBeenCalledTimes(1);
|
||||
expect(globalThis.alert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not render the directory button on a full board address route', async () => {
|
||||
await renderWithRoute(createElement(DesktopBoardButtons), '/music-posting.eth');
|
||||
|
||||
expect(container.textContent).not.toContain('directory');
|
||||
});
|
||||
|
||||
it('renders desktop catalog controls and wires sort, style, filter, and refresh updates', async () => {
|
||||
|
||||
@@ -30,10 +30,6 @@
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.disabledButton:hover {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
.mobileBoardButtons a:not([class~='button']),
|
||||
.desktopBoardButtons a:not([class~='button']) {
|
||||
all: unset;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { usePostPageNumber } from '../../hooks/use-post-page-number';
|
||||
import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories';
|
||||
import { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses';
|
||||
import { useFilteredDirectoryAddresses } from '../../hooks/use-filtered-directory-addresses';
|
||||
import { getBoardPath, isDirectoryBoard } from '../../lib/utils/route-utils';
|
||||
import { getBoardPath, isDirectoryRoute } from '../../lib/utils/route-utils';
|
||||
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
|
||||
import { useCommunityIdentifier } from '../../hooks/use-community-identifiers';
|
||||
import useSafeAccountComment from '../../hooks/use-safe-account-comment';
|
||||
@@ -165,26 +165,20 @@ export const ReturnButton = ({ address, isInAllView, isInSubscriptionsView, isIn
|
||||
);
|
||||
};
|
||||
|
||||
const VoteButton = () => {
|
||||
const DirectoryButton = () => {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams();
|
||||
const directories = useDirectories();
|
||||
|
||||
// Get the boardIdentifier from params (try boardIdentifier first, then communityAddress for backward compatibility)
|
||||
const boardIdentifier = params.boardIdentifier;
|
||||
|
||||
// Only render the vote button if we're on a directory board route
|
||||
if (!boardIdentifier || !isDirectoryBoard(boardIdentifier, directories)) {
|
||||
if (!boardIdentifier || !isDirectoryRoute(boardIdentifier, directories)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const values = { boardIdentifier };
|
||||
const message = `${t('vote_button_unavailable_intro', values)}\n\n${t('vote_button_unavailable_outro', values)}`;
|
||||
|
||||
return (
|
||||
<button className={`button ${styles.disabledButton}`} onClick={() => window.alert(message)}>
|
||||
{t('vote')}
|
||||
</button>
|
||||
<Link className='button' to={`/${boardIdentifier}/directory`}>
|
||||
{t('directory')}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -508,7 +502,6 @@ export const MobileAllFeedFilter = () => (
|
||||
);
|
||||
|
||||
export const MobileBoardButtons = () => {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams();
|
||||
const location = useLocation();
|
||||
const isInAllView = isAllView(location.pathname);
|
||||
@@ -529,10 +522,9 @@ export const MobileBoardButtons = () => {
|
||||
const effectiveInfiniteScroll = isMultiboard || enableInfiniteScroll;
|
||||
const showBottomButton = !effectiveInfiniteScroll;
|
||||
|
||||
// Check if we should show the vote button (only for directory boards)
|
||||
const directories = useDirectories();
|
||||
const boardIdentifier = params.boardIdentifier;
|
||||
const showVoteButton = boardIdentifier && isDirectoryBoard(boardIdentifier, directories);
|
||||
const showDirectoryButton = boardIdentifier && isDirectoryRoute(boardIdentifier, directories);
|
||||
|
||||
return (
|
||||
<div className={`${styles.mobileBoardButtons} ${!isInCatalogView ? styles.addMargin : ''}`}>
|
||||
@@ -602,7 +594,7 @@ export const MobileBoardButtons = () => {
|
||||
<CatalogButton address={communityAddress} isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
|
||||
<RefreshButton />
|
||||
<div className={styles.secondRow}>
|
||||
{showVoteButton && <VoteButton />}
|
||||
{showDirectoryButton && <DirectoryButton />}
|
||||
{!(isInAllView || isInSubscriptionsView || isInModView) && <SubscribeButton address={communityAddress} />}
|
||||
{!(isInAllView || isInSubscriptionsView) && <ModQueueButton boardIdentifier={boardIdentifier} isMobile={true} />}
|
||||
</div>
|
||||
@@ -696,7 +688,6 @@ export const CatalogSearchResultsLabel = () => {
|
||||
};
|
||||
|
||||
export const DesktopBoardButtons = () => {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams();
|
||||
const location = useLocation();
|
||||
const accountComment = useSafeAccountComment({ commentIndex: params?.accountCommentIndex });
|
||||
@@ -716,10 +707,9 @@ export const DesktopBoardButtons = () => {
|
||||
const effectiveInfiniteScroll = isMultiboard || enableInfiniteScroll;
|
||||
const showBottomButton = (isInCatalogView || isInPostView || isInPendingPostPage) && !effectiveInfiniteScroll;
|
||||
|
||||
// Check if we should show the vote button (only for directory boards)
|
||||
const directories = useDirectories();
|
||||
const boardIdentifier = params.boardIdentifier;
|
||||
const showVoteButton = boardIdentifier && isDirectoryBoard(boardIdentifier, directories);
|
||||
const showDirectoryButton = boardIdentifier && isDirectoryRoute(boardIdentifier, directories);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -818,12 +808,12 @@ export const DesktopBoardButtons = () => {
|
||||
{showTimeFilter && (
|
||||
<TimeFilter isInAllView={isInAllView} isInCatalogView={isInCatalogView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
|
||||
)}
|
||||
{showVoteButton && (
|
||||
{showDirectoryButton && (
|
||||
<>
|
||||
[<VoteButton />]
|
||||
[<DirectoryButton />]
|
||||
</>
|
||||
)}
|
||||
{showVoteButton && !(isInAllView || isInSubscriptionsView || isInModView) && ' '}
|
||||
{showDirectoryButton && !(isInAllView || isInSubscriptionsView || isInModView) && ' '}
|
||||
{!(isInAllView || isInSubscriptionsView || isInModView) && (
|
||||
<>
|
||||
[<SubscribeButton address={communityAddress} />]
|
||||
|
||||
@@ -7,7 +7,7 @@ import getShortAddress from '../../lib/get-short-address';
|
||||
import { useCommunityIdentifier } from '../../hooks/use-community-identifiers';
|
||||
import { useStableCommunity } from '../../hooks/use-stable-community';
|
||||
import { isAllView, isSubscriptionsView, isModView } from '../../lib/utils/view-utils';
|
||||
import { isArchiveRoute } from '../../lib/utils/route-utils';
|
||||
import { isArchiveRoute, isDirectoryListRoute } from '../../lib/utils/route-utils';
|
||||
import styles from './board-header.module.css';
|
||||
import { useDirectoriesMetadata, useDirectories } from '../../hooks/use-directories';
|
||||
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
|
||||
@@ -54,6 +54,7 @@ const BoardHeader = () => {
|
||||
const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams());
|
||||
const isInModView = isModView(location.pathname);
|
||||
const isInArchiveView = isArchiveRoute(location.pathname);
|
||||
const isInDirectoryListView = isDirectoryListRoute(location.pathname);
|
||||
const accountComment = useSafeAccountComment({ commentIndex: params?.accountCommentIndex });
|
||||
const resolvedAddress = useResolvedCommunityAddress();
|
||||
const communityAddress = resolvedAddress || accountComment?.communityAddress;
|
||||
@@ -83,7 +84,15 @@ const BoardHeader = () => {
|
||||
: isInModView
|
||||
? startCase(t('boards_you_moderate'))
|
||||
: defaultCommunity?.title || stableCommunity?.title;
|
||||
const subtitle = isInAllView ? '' : isInSubscriptionsView ? subscriptionsSubtitle : isInModView ? '/mod/' : `${address || communityAddress || ''}`;
|
||||
const subtitle = isInAllView
|
||||
? ''
|
||||
: isInSubscriptionsView
|
||||
? subscriptionsSubtitle
|
||||
: isInModView
|
||||
? '/mod/'
|
||||
: isInDirectoryListView
|
||||
? t('directory_subtitle', { boardIdentifier: params.boardIdentifier })
|
||||
: `${address || communityAddress || ''}`;
|
||||
|
||||
return (
|
||||
<div className={`${styles.content} ${shouldShowSnow() ? styles.garland : ''}`}>
|
||||
@@ -99,7 +108,7 @@ const BoardHeader = () => {
|
||||
? shortAddress.slice(0, -4)
|
||||
: shortAddress
|
||||
: communityAddress && getShortAddress(communityAddress))}
|
||||
{!isInAllView && !isInSubscriptionsView && !isInModView && <OfflineIndicator communityAddress={communityAddress} />}
|
||||
{!isInAllView && !isInSubscriptionsView && !isInModView && !isInDirectoryListView && <OfflineIndicator communityAddress={communityAddress} />}
|
||||
</div>
|
||||
<div className={styles.boardSubtitle}>
|
||||
{isInSubscriptionsView ? (
|
||||
@@ -117,6 +126,8 @@ const BoardHeader = () => {
|
||||
>
|
||||
{subtitle}
|
||||
</span>
|
||||
) : isInDirectoryListView ? (
|
||||
<span>{subtitle}</span>
|
||||
) : !isInAllView && !isInModView && subtitle ? (
|
||||
<span title={t('board_address_tooltip')}>{subtitle}</span>
|
||||
) : (
|
||||
|
||||
@@ -36,9 +36,9 @@ const CreateBoardModal = () => {
|
||||
<div className={styles.section}>
|
||||
<h3>Creating Your Board</h3>
|
||||
<p>
|
||||
Create a board using the CLI:
|
||||
<a href='https://github.com/bitsocialnet/bitsocial-cli' target='_blank' rel='noopener noreferrer'>
|
||||
bitsocial-cli
|
||||
Create a board using{' '}
|
||||
<a href='https://github.com/bitsocialnet/5chan-board-manager' target='_blank' rel='noopener noreferrer'>
|
||||
5chan Board Manager
|
||||
</a>
|
||||
. <strong>Build a following:</strong> Users can subscribe to your board via the "[Subscribe]" button, which adds it to their top bar. You can gain
|
||||
subscribers through direct links, word of mouth, or search, no directory assignment or dev approval needed.
|
||||
|
||||
@@ -43,9 +43,9 @@ const DirectoryModal = () => {
|
||||
<div className={styles.section}>
|
||||
<h3>Creating Your Board</h3>
|
||||
<p>
|
||||
<strong>Anyone can create a board</strong> using the official CLI:{' '}
|
||||
<a href='https://github.com/bitsocialnet/bitsocial-cli' target='_blank' rel='noopener noreferrer'>
|
||||
bitsocial-cli
|
||||
<strong>Anyone can create a board</strong> using{' '}
|
||||
<a href='https://github.com/bitsocialnet/5chan-board-manager' target='_blank' rel='noopener noreferrer'>
|
||||
5chan Board Manager
|
||||
</a>
|
||||
. Users can access it anytime via the search bar, direct links, or by subscribing with the "[Subscribe]" button;{' '}
|
||||
<strong>no directory assignment or dev approval needed</strong>. Directory boards are simply featured in homepage categories (like "Anime &
|
||||
|
||||
@@ -124,7 +124,7 @@ describe('useIsCommunityOffline', () => {
|
||||
});
|
||||
|
||||
it('reports boards with stale updates as offline and includes the last synced time', async () => {
|
||||
const staleUpdatedAt = 1_704_052_000;
|
||||
const staleUpdatedAt = 1_704_067_210 - 31 * 60;
|
||||
testState.communityOfflineState = {
|
||||
'music.eth': {
|
||||
initialLoad: false,
|
||||
@@ -144,6 +144,50 @@ describe('useIsCommunityOffline', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('treats boards updated less than 30 minutes ago as online', async () => {
|
||||
const freshUpdatedAt = 1_704_067_210 - 29 * 60;
|
||||
testState.communityOfflineState = {
|
||||
'music.eth': {
|
||||
initialLoad: false,
|
||||
updatedAt: freshUpdatedAt,
|
||||
},
|
||||
};
|
||||
|
||||
await renderHook({ address: 'music.eth', state: 'started', updatedAt: freshUpdatedAt });
|
||||
|
||||
expect(latestValue).toEqual({
|
||||
isOffline: false,
|
||||
isOnlineStatusLoading: false,
|
||||
offlineIconClass: '',
|
||||
offlineTitle: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('updates mounted boards when their last update crosses the offline threshold', async () => {
|
||||
const freshUpdatedAt = 1_704_067_210 - 29 * 60;
|
||||
testState.communityOfflineState = {
|
||||
'music.eth': {
|
||||
initialLoad: false,
|
||||
updatedAt: freshUpdatedAt,
|
||||
},
|
||||
};
|
||||
|
||||
await renderHook({ address: 'music.eth', state: 'started', updatedAt: freshUpdatedAt });
|
||||
|
||||
expect(latestValue.isOffline).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2 * 60 * 1000);
|
||||
});
|
||||
|
||||
expect(latestValue).toEqual({
|
||||
isOffline: true,
|
||||
isOnlineStatusLoading: false,
|
||||
offlineIconClass: 'redOfflineIcon',
|
||||
offlineTitle: `posts_last_synced_info:ago:${freshUpdatedAt}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks boards without an update timestamp as offline once the loading timeout has elapsed', async () => {
|
||||
testState.communityOfflineState = {
|
||||
'music.eth': {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import * as React from 'react';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useResolvedCommunityAddress } from '../use-resolved-community-address';
|
||||
|
||||
(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>;
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
boardIdentifier: 'biz',
|
||||
directories: [
|
||||
{
|
||||
address: 'business-and-finance.bso',
|
||||
directoryCode: 'biz',
|
||||
title: '/biz/ - Business & Finance',
|
||||
},
|
||||
],
|
||||
list: {
|
||||
directoryCode: 'biz',
|
||||
boards: [
|
||||
{ address: 'business-and-finance.bso', score: 100, managedByDevs: true },
|
||||
{ address: 'backup-business.bso', score: 10, managedByDevs: false },
|
||||
],
|
||||
},
|
||||
offlineStates: {} as Record<string, { updatedAt?: number; state?: string }>,
|
||||
offlineSelections: [] as unknown[],
|
||||
}));
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useParams: () => ({ boardIdentifier: testState.boardIdentifier }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../use-directories', () => ({
|
||||
useDirectories: () => testState.directories,
|
||||
}));
|
||||
|
||||
vi.mock('../use-directory-list', async () => {
|
||||
const actual = await vi.importActual<typeof import('../use-directory-list')>('../use-directory-list');
|
||||
return {
|
||||
...actual,
|
||||
useDirectoryList: () => ({ list: testState.list, loading: false, error: null }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../stores/use-community-offline-store', () => ({
|
||||
default: <T,>(selector: (state: { communityOfflineState: typeof testState.offlineStates }) => T) => {
|
||||
const selected = selector({ communityOfflineState: testState.offlineStates });
|
||||
testState.offlineSelections.push(selected);
|
||||
return selected;
|
||||
},
|
||||
}));
|
||||
|
||||
let latestValue: string | undefined;
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const HookHarness = () => {
|
||||
latestValue = useResolvedCommunityAddress();
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderHook = async () => {
|
||||
await act(async () => {
|
||||
root.render(createElement(HookHarness));
|
||||
});
|
||||
};
|
||||
|
||||
describe('useResolvedCommunityAddress', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2024-01-01T00:00:10Z'));
|
||||
latestValue = undefined;
|
||||
testState.boardIdentifier = 'biz';
|
||||
testState.offlineStates = {};
|
||||
testState.offlineSelections = [];
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('skips a higher-ranked directory board when its last update is 30 minutes stale', async () => {
|
||||
testState.offlineStates = {
|
||||
'business-and-finance.bso': {
|
||||
updatedAt: 1_704_067_210 - 31 * 60,
|
||||
},
|
||||
};
|
||||
|
||||
await renderHook();
|
||||
|
||||
expect(latestValue).toBe('backup-business.bso');
|
||||
});
|
||||
|
||||
it('keeps a higher-ranked directory board when its last update is newer than 30 minutes', async () => {
|
||||
testState.offlineStates = {
|
||||
'business-and-finance.bso': {
|
||||
updatedAt: 1_704_067_210 - 29 * 60,
|
||||
},
|
||||
};
|
||||
|
||||
await renderHook();
|
||||
|
||||
expect(latestValue).toBe('business-and-finance.bso');
|
||||
});
|
||||
|
||||
it('does not subscribe to offline state on non-directory board routes', async () => {
|
||||
testState.boardIdentifier = 'custom-board.bso';
|
||||
testState.offlineStates = {
|
||||
unrelated: {
|
||||
updatedAt: 1,
|
||||
},
|
||||
};
|
||||
|
||||
await renderHook();
|
||||
|
||||
expect(latestValue).toBe('custom-board.bso');
|
||||
expect(testState.offlineSelections).toEqual([undefined]);
|
||||
});
|
||||
|
||||
it('switches away from a directory board when it crosses the offline threshold while mounted', async () => {
|
||||
testState.offlineStates = {
|
||||
'business-and-finance.bso': {
|
||||
updatedAt: 1_704_067_210 - 29 * 60,
|
||||
},
|
||||
};
|
||||
|
||||
await renderHook();
|
||||
|
||||
expect(latestValue).toBe('business-and-finance.bso');
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2 * 60 * 1000);
|
||||
});
|
||||
|
||||
expect(latestValue).toBe('backup-business.bso');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { DirectoryCommunity, useDirectories } from './use-directories';
|
||||
|
||||
export interface DirectoryListBoard {
|
||||
address: string;
|
||||
publicKey?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
owner?: string;
|
||||
score: number;
|
||||
managedByDevs: boolean;
|
||||
addedAt?: number;
|
||||
}
|
||||
|
||||
interface DirectoryList {
|
||||
directoryCode: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
boards: DirectoryListBoard[];
|
||||
}
|
||||
|
||||
interface DirectoryListState {
|
||||
list: DirectoryList | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
const GITHUB_URL_TEMPLATE = 'https://raw.githubusercontent.com/bitsocialnet/lists/master/5chan-{code}-directory.json';
|
||||
const LOCALSTORAGE_KEY_PREFIX = '5chan-directory-list-cache:';
|
||||
const LOCALSTORAGE_TIMESTAMP_KEY_PREFIX = '5chan-directory-list-cache-timestamp:';
|
||||
const CACHE_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
|
||||
const FETCH_RETRY_DELAY_MS = 60 * 1000; // 1 minute
|
||||
const FETCH_TIMEOUT_MS = 10 * 1000;
|
||||
|
||||
// Per-code module caches keyed by directory code (e.g. 'biz').
|
||||
const moduleCaches = new Map<string, DirectoryList>();
|
||||
const inFlightFetches = new Map<string, Promise<DirectoryList | null>>();
|
||||
const lastFetchSuccessAt = new Map<string, number>();
|
||||
const lastFetchAttemptAt = new Map<string, number>();
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;
|
||||
|
||||
const toNumber = (value: unknown): number | undefined => (typeof value === 'number' && Number.isFinite(value) ? value : undefined);
|
||||
|
||||
const toString = (value: unknown): string | undefined => (typeof value === 'string' && value.length > 0 ? value : undefined);
|
||||
|
||||
const toBool = (value: unknown): boolean => value === true;
|
||||
|
||||
const normalizeBoard = (raw: unknown): DirectoryListBoard | null => {
|
||||
if (!isRecord(raw)) return null;
|
||||
const address = toString(raw.address) ?? toString(raw.name);
|
||||
if (!address) return null;
|
||||
|
||||
return {
|
||||
address,
|
||||
...(toString(raw.publicKey) ? { publicKey: toString(raw.publicKey)! } : {}),
|
||||
...(toString(raw.title) ? { title: toString(raw.title)! } : {}),
|
||||
...(toString(raw.description) ? { description: toString(raw.description)! } : {}),
|
||||
...(toString(raw.owner) ? { owner: toString(raw.owner)! } : {}),
|
||||
score: toNumber(raw.score) ?? 0,
|
||||
managedByDevs: toBool(raw.managedByDevs),
|
||||
...(toNumber(raw.addedAt) !== undefined ? { addedAt: toNumber(raw.addedAt) } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeDirectoryList = (raw: unknown, fallbackCode: string): DirectoryList | null => {
|
||||
if (!isRecord(raw)) return null;
|
||||
const boardsRaw = Array.isArray(raw.boards) ? raw.boards : Array.isArray(raw.communities) ? raw.communities : null;
|
||||
if (!boardsRaw) return null;
|
||||
|
||||
const boards = boardsRaw.map(normalizeBoard).filter((board): board is DirectoryListBoard => board !== null);
|
||||
if (boards.length === 0) return null;
|
||||
|
||||
return {
|
||||
directoryCode: toString(raw.directoryCode) ?? fallbackCode,
|
||||
...(toString(raw.title) ? { title: toString(raw.title)! } : {}),
|
||||
...(toString(raw.description) ? { description: toString(raw.description)! } : {}),
|
||||
...(toNumber(raw.createdAt) !== undefined ? { createdAt: toNumber(raw.createdAt) } : {}),
|
||||
...(toNumber(raw.updatedAt) !== undefined ? { updatedAt: toNumber(raw.updatedAt) } : {}),
|
||||
boards,
|
||||
};
|
||||
};
|
||||
|
||||
const synthesizeFromMainDirectory = (directoryCode: string, directories: DirectoryCommunity[]): DirectoryList | null => {
|
||||
const match = directories.find((community) => community.directoryCode === directoryCode);
|
||||
if (!match || !match.address) return null;
|
||||
|
||||
const board: DirectoryListBoard = {
|
||||
address: match.address,
|
||||
...(match.publicKey ? { publicKey: match.publicKey } : {}),
|
||||
...(match.title ? { title: match.title } : {}),
|
||||
score: 0,
|
||||
managedByDevs: true,
|
||||
};
|
||||
|
||||
return {
|
||||
directoryCode,
|
||||
...(match.title ? { title: match.title } : {}),
|
||||
boards: [board],
|
||||
};
|
||||
};
|
||||
|
||||
const getLocalStorageKey = (code: string) => `${LOCALSTORAGE_KEY_PREFIX}${code}`;
|
||||
const getLocalStorageTimestampKey = (code: string) => `${LOCALSTORAGE_TIMESTAMP_KEY_PREFIX}${code}`;
|
||||
|
||||
const getFromLocalStorage = (code: string): DirectoryList | null => {
|
||||
try {
|
||||
const cached = localStorage.getItem(getLocalStorageKey(code));
|
||||
const timestamp = localStorage.getItem(getLocalStorageTimestampKey(code));
|
||||
if (cached && timestamp) {
|
||||
const age = Date.now() - parseInt(timestamp, 10);
|
||||
if (age < CACHE_MAX_AGE_MS) {
|
||||
const parsed = JSON.parse(cached);
|
||||
const normalized = normalizeDirectoryList(parsed, code);
|
||||
if (normalized) return normalized;
|
||||
localStorage.removeItem(getLocalStorageKey(code));
|
||||
localStorage.removeItem(getLocalStorageTimestampKey(code));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to read directory list "${code}" from localStorage:`, e);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const saveToLocalStorage = (code: string, data: DirectoryList) => {
|
||||
try {
|
||||
localStorage.setItem(getLocalStorageKey(code), JSON.stringify(data));
|
||||
localStorage.setItem(getLocalStorageTimestampKey(code), Date.now().toString());
|
||||
} catch (e) {
|
||||
console.warn(`Failed to save directory list "${code}" to localStorage:`, e);
|
||||
}
|
||||
};
|
||||
|
||||
const shouldRefreshFromGitHub = (code: string): boolean => {
|
||||
const now = Date.now();
|
||||
const lastSuccess = lastFetchSuccessAt.get(code);
|
||||
if (lastSuccess !== undefined && now - lastSuccess < CACHE_MAX_AGE_MS) {
|
||||
return false;
|
||||
}
|
||||
const lastAttempt = lastFetchAttemptAt.get(code);
|
||||
if (lastAttempt !== undefined && now - lastAttempt < FETCH_RETRY_DELAY_MS) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const fetchDirectoryListFromGitHub = async (code: string): Promise<DirectoryList | null> => {
|
||||
const url = GITHUB_URL_TEMPLATE.replace('{code}', code);
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, { cache: 'no-cache', signal: controller.signal });
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
if (response.status === 404) {
|
||||
// Not yet published; treat as missing — caller will fall back.
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const normalized = normalizeDirectoryList(await response.json(), code);
|
||||
if (!normalized) {
|
||||
throw new Error(`Invalid directory list payload for ${code}`);
|
||||
}
|
||||
moduleCaches.set(code, normalized);
|
||||
lastFetchSuccessAt.set(code, Date.now());
|
||||
saveToLocalStorage(code, normalized);
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const fetchDirectoryListDeduped = (code: string): Promise<DirectoryList | null> => {
|
||||
const existing = inFlightFetches.get(code);
|
||||
if (existing) return existing;
|
||||
|
||||
if (!shouldRefreshFromGitHub(code)) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
lastFetchAttemptAt.set(code, Date.now());
|
||||
const promise = fetchDirectoryListFromGitHub(code).finally(() => {
|
||||
inFlightFetches.delete(code);
|
||||
});
|
||||
inFlightFetches.set(code, promise);
|
||||
return promise;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the candidate boards for a single directory code (e.g. 'biz').
|
||||
*
|
||||
* Source: `bitsocialnet/lists/5chan-{code}-directory.json`. When the network is unavailable
|
||||
* or the file is not yet published, falls back to a synthesized single-entry list derived
|
||||
* from the main 5chan-directories.json (the dev-managed default).
|
||||
*/
|
||||
export const useDirectoryList = (directoryCode: string | undefined): DirectoryListState => {
|
||||
const directories = useDirectories();
|
||||
const fallback = useMemo(() => (directoryCode ? synthesizeFromMainDirectory(directoryCode, directories) : null), [directoryCode, directories]);
|
||||
|
||||
const [state, setState] = useState<DirectoryListState>(() => {
|
||||
if (!directoryCode) {
|
||||
return { list: null, loading: false, error: null };
|
||||
}
|
||||
const cached = moduleCaches.get(directoryCode);
|
||||
if (cached) {
|
||||
return { list: cached, loading: false, error: null };
|
||||
}
|
||||
return { list: fallback, loading: true, error: null };
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!directoryCode) {
|
||||
setState({ list: null, loading: false, error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const hydrate = (list: DirectoryList) => {
|
||||
moduleCaches.set(directoryCode, list);
|
||||
if (isMounted) {
|
||||
setState({ list, loading: false, error: null });
|
||||
}
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const cached = moduleCaches.get(directoryCode);
|
||||
if (cached) {
|
||||
setState({ list: cached, loading: false, error: null });
|
||||
} else {
|
||||
const local = getFromLocalStorage(directoryCode);
|
||||
if (local) {
|
||||
hydrate(local);
|
||||
} else if (fallback) {
|
||||
setState({ list: fallback, loading: true, error: null });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const fetched = await fetchDirectoryListDeduped(directoryCode);
|
||||
if (fetched) {
|
||||
hydrate(fetched);
|
||||
} else if (!moduleCaches.get(directoryCode) && fallback) {
|
||||
if (isMounted) {
|
||||
setState({ list: fallback, loading: false, error: null });
|
||||
}
|
||||
} else if (isMounted) {
|
||||
// Stop the loading indicator once the fetch resolves (even if it returned null).
|
||||
setState((prev) => ({ ...prev, loading: false }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to fetch directory list "${directoryCode}":`, error);
|
||||
if (!isMounted) return;
|
||||
const cachedAfter = moduleCaches.get(directoryCode);
|
||||
if (cachedAfter) {
|
||||
setState({ list: cachedAfter, loading: false, error: null });
|
||||
} else if (fallback) {
|
||||
setState({ list: fallback, loading: false, error: error instanceof Error ? error : new Error(String(error)) });
|
||||
} else {
|
||||
setState({ list: null, loading: false, error: error instanceof Error ? error : new Error(String(error)) });
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [directoryCode, fallback]);
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sort boards by score (desc). Ties break in favor of `managedByDevs`, then `addedAt` asc.
|
||||
*/
|
||||
export const sortDirectoryBoardsByRank = (boards: DirectoryListBoard[]): DirectoryListBoard[] =>
|
||||
[...boards].sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
if (a.managedByDevs !== b.managedByDevs) return a.managedByDevs ? -1 : 1;
|
||||
return (a.addedAt ?? 0) - (b.addedAt ?? 0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Pick the winning board for a directory, skipping any boards reported offline.
|
||||
* Returns the highest-ranked online board, or — if every candidate looks offline —
|
||||
* the highest-ranked board anyway, so the user still lands somewhere.
|
||||
*/
|
||||
export const pickDirectoryWinner = (boards: DirectoryListBoard[], isOffline: (address: string) => boolean): DirectoryListBoard | undefined => {
|
||||
const ranked = sortDirectoryBoardsByRank(boards);
|
||||
return ranked.find((board) => !isOffline(board.address)) ?? ranked[0];
|
||||
};
|
||||
@@ -4,6 +4,8 @@ import { Community } from '@bitsocial/bitsocial-react-hooks';
|
||||
import { getFormattedTimeAgo } from '../lib/utils/time-utils';
|
||||
import useCommunityOfflineStore from '../stores/use-community-offline-store';
|
||||
import useCommunitiesLoadingStartTimestamps from '../stores/use-communities-loading-start-timestamps-store';
|
||||
import { isCommunityUpdateStale } from '../lib/utils/community-freshness-utils';
|
||||
import { useNowSeconds } from './use-now-seconds';
|
||||
|
||||
const getCommunityOfflineKey = (community?: Community, communityAddressHint?: string) =>
|
||||
communityAddressHint || community?.address || community?.name || community?.publicKey;
|
||||
@@ -12,6 +14,7 @@ const useIsCommunityOffline = (community?: Community | undefined, communityAddre
|
||||
const { t } = useTranslation();
|
||||
const { state, updatedAt, updatingState } = community || {};
|
||||
const communityKey = getCommunityOfflineKey(community, communityAddressHint);
|
||||
const nowSeconds = useNowSeconds(!!communityKey);
|
||||
const { communityOfflineState, setCommunityOfflineState, initializeCommunityOfflineState } = useCommunityOfflineStore();
|
||||
const communitiesLoadingStartTimestamps = useCommunitiesLoadingStartTimestamps(communityKey ? [communityKey] : undefined);
|
||||
|
||||
@@ -33,10 +36,11 @@ const useIsCommunityOffline = (community?: Community | undefined, communityAddre
|
||||
|
||||
const offlineState = communityOfflineState[communityKey] || { initialLoad: true };
|
||||
const loadingStartTimestamp = communitiesLoadingStartTimestamps[0] || 0;
|
||||
const isLoading = offlineState.initialLoad && (!updatedAt || Date.now() / 1000 - updatedAt >= 120 * 120) && Date.now() / 1000 - loadingStartTimestamp < 30;
|
||||
const isOffline = !isLoading && ((updatedAt && updatedAt < Date.now() / 1000 - 120 * 120) || (!updatedAt && Date.now() / 1000 - loadingStartTimestamp >= 30));
|
||||
const isStale = isCommunityUpdateStale(updatedAt, nowSeconds);
|
||||
const isLoading = offlineState.initialLoad && (!updatedAt || isStale) && nowSeconds - loadingStartTimestamp < 30;
|
||||
const isOffline = !isLoading && (isStale || (!updatedAt && nowSeconds - loadingStartTimestamp >= 30));
|
||||
|
||||
const isOnline = updatedAt && Date.now() / 1000 - updatedAt < 120 * 120;
|
||||
const isOnline = updatedAt && !isStale;
|
||||
const offlineIconClass = isLoading ? 'yellowOfflineIcon' : isOffline ? 'redOfflineIcon' : '';
|
||||
|
||||
const offlineTitle = isLoading
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const STATUS_REFRESH_INTERVAL_MS = 30_000;
|
||||
|
||||
const getNowSeconds = () => Date.now() / 1000;
|
||||
|
||||
export const useNowSeconds = (enabled = true) => {
|
||||
const [nowSeconds, setNowSeconds] = useState(getNowSeconds);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const updateNow = () => setNowSeconds(getNowSeconds());
|
||||
updateNow();
|
||||
const interval = window.setInterval(updateNow, STATUS_REFRESH_INTERVAL_MS);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [enabled]);
|
||||
|
||||
return nowSeconds;
|
||||
};
|
||||
@@ -1,24 +1,37 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useDirectories } from './use-directories';
|
||||
import { getCommunityAddress, getBoardPath } from '../lib/utils/route-utils';
|
||||
import { pickDirectoryWinner, useDirectoryList } from './use-directory-list';
|
||||
import useCommunityOfflineStore from '../stores/use-community-offline-store';
|
||||
import { getCommunityAddress, getBoardPath, isDirectoryRoute } from '../lib/utils/route-utils';
|
||||
import { isCommunityKnownOffline } from '../lib/utils/community-freshness-utils';
|
||||
import { useNowSeconds } from './use-now-seconds';
|
||||
|
||||
/**
|
||||
* Resolve a board identifier from URL params to canonical community address.
|
||||
*
|
||||
* For directory codes (e.g. /biz) with a per-directory list of candidates, picks the
|
||||
* highest-ranked candidate that is not currently flagged offline. Falls back to the
|
||||
* vendored single-candidate default while the per-directory list is still loading.
|
||||
*/
|
||||
export const useResolvedCommunityAddress = (): string | undefined => {
|
||||
const params = useParams<{ boardIdentifier?: string }>();
|
||||
const directories = useDirectories();
|
||||
|
||||
const boardIdentifier = params.boardIdentifier;
|
||||
const isCode = !!boardIdentifier && isDirectoryRoute(boardIdentifier, directories);
|
||||
const { list } = useDirectoryList(isCode ? boardIdentifier : undefined);
|
||||
const offlineStates = useCommunityOfflineStore((state) => (isCode ? state.communityOfflineState : undefined));
|
||||
const nowSeconds = useNowSeconds(isCode);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!boardIdentifier) {
|
||||
return undefined;
|
||||
if (!boardIdentifier) return undefined;
|
||||
if (isCode && list && list.boards.length > 0) {
|
||||
const isOffline = (address: string) => isCommunityKnownOffline(offlineStates?.[address], nowSeconds);
|
||||
const winner = pickDirectoryWinner(list.boards, isOffline);
|
||||
if (winner) return winner.address;
|
||||
}
|
||||
|
||||
return getCommunityAddress(boardIdentifier, directories);
|
||||
}, [boardIdentifier, directories]);
|
||||
}, [boardIdentifier, directories, isCode, list, offlineStates, nowSeconds]);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
appUpdateEnabled: true,
|
||||
capacitorPlatform: 'web',
|
||||
browserOpenMock: vi.fn(),
|
||||
electronDownloadAndInstallUpdateMock: vi.fn(),
|
||||
@@ -21,6 +22,12 @@ vi.mock('@capacitor/browser', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../app-distribution', () => ({
|
||||
get isAppUpdateEnabled() {
|
||||
return testState.appUpdateEnabled;
|
||||
},
|
||||
}));
|
||||
|
||||
const createFetchResponse = (body: unknown, ok = true, status = 200) => ({
|
||||
ok,
|
||||
status,
|
||||
@@ -39,6 +46,7 @@ const loadModule = async () => {
|
||||
describe('app-update', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testState.appUpdateEnabled = true;
|
||||
testState.capacitorPlatform = 'web';
|
||||
testState.browserOpenMock.mockReset();
|
||||
testState.electronDownloadAndInstallUpdateMock.mockReset();
|
||||
@@ -97,7 +105,7 @@ describe('app-update', () => {
|
||||
});
|
||||
|
||||
it('disables update checks for F-Droid builds', async () => {
|
||||
vi.stubEnv('VITE_APP_DISTRIBUTION', 'fdroid');
|
||||
testState.appUpdateEnabled = false;
|
||||
testState.capacitorPlatform = 'android';
|
||||
|
||||
const { applyAvailableAppUpdate, isAppUpdateEnabled, resolveAvailableAppUpdate } = await loadModule();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { KNOWN_5CHAN_DEVELOPER_ENTRIES, getAuthorBadge, isKnown5chanDeveloper } from '../author-display-utils';
|
||||
import { KNOWN_5CHAN_DEVELOPER_ENTRIES, get5chanDeveloperBadge, getAuthorBadge, isKnown5chanDeveloper } from '../author-display-utils';
|
||||
|
||||
describe('author display utils', () => {
|
||||
it('recognizes the hardcoded 5chan developer addresses', () => {
|
||||
@@ -38,6 +38,15 @@ describe('author display utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('returns only the 5chan Dev badge for known developers', () => {
|
||||
expect(get5chanDeveloperBadge('plebeius.bso')).toEqual({
|
||||
icon: 'admin',
|
||||
label: '5chan Dev',
|
||||
title: '5chan Dev',
|
||||
});
|
||||
expect(get5chanDeveloperBadge('other.bso')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps board role labels for non-developers', () => {
|
||||
expect(getAuthorBadge({ address: 'other.bso', role: 'moderator' })).toEqual({
|
||||
capitalizeLabel: true,
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
isArchiveRoute,
|
||||
isBoardModRoute,
|
||||
isDirectoryBoard,
|
||||
isDirectoryListRoute,
|
||||
isDirectoryRoute,
|
||||
isFeedRoute,
|
||||
isLegacyBoardModQueueRoute,
|
||||
isModQueueRoute,
|
||||
@@ -60,6 +62,8 @@ describe('directory mapping helpers', () => {
|
||||
expect(areSameBoardAddress('music-posting.eth', 'business.eth')).toBe(false);
|
||||
expect(areSameBoardAddress(undefined, 'business.eth')).toBe(false);
|
||||
|
||||
expect(isDirectoryRoute('biz', communities)).toBe(true);
|
||||
expect(isDirectoryRoute('business.eth', communities)).toBe(false);
|
||||
expect(isDirectoryBoard('biz', communities)).toBe(true);
|
||||
expect(isDirectoryBoard('business.eth', communities)).toBe(false);
|
||||
});
|
||||
@@ -118,6 +122,15 @@ describe('isFeedRoute', () => {
|
||||
expect(isArchiveRoute('/biz/archive/settings')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for board directory paths', () => {
|
||||
expect(isFeedRoute('/biz/directory')).toBe(false);
|
||||
expect(isFeedRoute('/biz/directory/settings')).toBe(false);
|
||||
expect(isDirectoryListRoute('/biz/directory')).toBe(true);
|
||||
expect(isDirectoryListRoute('/biz/directory/settings')).toBe(true);
|
||||
expect(isDirectoryListRoute('/biz/archive')).toBe(false);
|
||||
expect(isDirectoryListRoute('/biz')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for posts and pending items', () => {
|
||||
expect(isFeedRoute('/biz/thread/abc')).toBe(false);
|
||||
expect(isFeedRoute('/pending/4')).toBe(false);
|
||||
|
||||
@@ -7,7 +7,6 @@ interface Known5chanDeveloperEntry {
|
||||
}
|
||||
|
||||
export const KNOWN_5CHAN_DEVELOPER_ENTRIES = known5chanDeveloperEntries as readonly Known5chanDeveloperEntry[];
|
||||
export const KNOWN_5CHAN_DEVELOPER_ADDRESSES = KNOWN_5CHAN_DEVELOPER_ENTRIES.map(({ address }) => address);
|
||||
|
||||
type AuthorBadgeIcon = 'admin' | 'mod';
|
||||
|
||||
@@ -27,6 +26,9 @@ const normalizeBoardRole = (role?: string): string | undefined => {
|
||||
export const isKnown5chanDeveloper = (address?: string): boolean =>
|
||||
typeof address === 'string' && KNOWN_5CHAN_DEVELOPER_ENTRIES.some((developer) => developer.address === address);
|
||||
|
||||
/** 5chan Dev capcode only — never board owner/mod badges. */
|
||||
export const get5chanDeveloperBadge = (address?: string): AuthorBadge | undefined => (isKnown5chanDeveloper(address) ? getAuthorBadge({ address }) : undefined);
|
||||
|
||||
export const getAuthorBadge = ({ address, role }: { address?: string; role?: string }): AuthorBadge | undefined => {
|
||||
const boardRole = normalizeBoardRole(role);
|
||||
const isDeveloper = isKnown5chanDeveloper(address);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export const COMMUNITY_OFFLINE_THRESHOLD_SECONDS = 30 * 60;
|
||||
|
||||
export interface CommunityFreshnessState {
|
||||
state?: string;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
export const isCommunityUpdateStale = (updatedAt: number | undefined, nowSeconds: number): boolean =>
|
||||
updatedAt !== undefined && nowSeconds - updatedAt >= COMMUNITY_OFFLINE_THRESHOLD_SECONDS;
|
||||
|
||||
export const isCommunityKnownOffline = (communityState: CommunityFreshnessState | undefined, nowSeconds: number): boolean => {
|
||||
if (!communityState) return false;
|
||||
if (communityState.state === 'failed') return true;
|
||||
return isCommunityUpdateStale(communityState.updatedAt, nowSeconds);
|
||||
};
|
||||
@@ -115,24 +115,33 @@ export const areSameBoardAddress = (a: string | undefined, b: string | undefined
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if an identifier is a directory short code
|
||||
* True when the URL board segment is a directory short code (e.g. /biz), not a full board address (e.g. /board.bso).
|
||||
*/
|
||||
export const isDirectoryBoard = (identifier: string, communities: DirectoryCommunity[]): boolean => {
|
||||
export const isDirectoryRoute = (boardIdentifier: string, communities: DirectoryCommunity[]): boolean => {
|
||||
const directoryToAddress = getDirectoryToAddressMap(communities);
|
||||
return directoryToAddress.has(identifier);
|
||||
return directoryToAddress.has(boardIdentifier);
|
||||
};
|
||||
|
||||
/** @deprecated Use {@link isDirectoryRoute} */
|
||||
export const isDirectoryBoard = isDirectoryRoute;
|
||||
|
||||
export const isArchiveRoute = (pathname: string): boolean => {
|
||||
const normalizedPath = pathname.replace(/\/settings$/, '').replace(/\/$/, '');
|
||||
return normalizedPath.endsWith('/archive');
|
||||
};
|
||||
|
||||
export const isDirectoryListRoute = (pathname: string): boolean => {
|
||||
const normalizedPath = pathname.replace(/\/settings$/, '').replace(/\/$/, '');
|
||||
return normalizedPath.endsWith('/directory');
|
||||
};
|
||||
|
||||
export const isFeedRoute = (pathname: string): boolean => {
|
||||
const normalizedPath = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||
|
||||
if (normalizedPath.includes('/thread/')) return false;
|
||||
if (normalizedPath.startsWith('/pending/')) return false;
|
||||
if (isArchiveRoute(normalizedPath)) return false;
|
||||
if (isDirectoryListRoute(normalizedPath)) return false;
|
||||
if (isBoardModRoute(normalizedPath) || isModQueueRoute(normalizedPath)) return false;
|
||||
|
||||
const pathWithoutSettings = normalizedPath.replace(/\/settings$/, '');
|
||||
@@ -270,7 +279,7 @@ export const getFeedCacheKey = (pathname: string, search = ''): string | null =>
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isArchiveRoute(normalizedPath) || isBoardModRoute(normalizedPath) || isModQueueRoute(normalizedPath)) {
|
||||
if (isArchiveRoute(normalizedPath) || isDirectoryListRoute(normalizedPath) || isBoardModRoute(normalizedPath) || isModQueueRoute(normalizedPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import * as React from 'react';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import Directory from '../directory';
|
||||
|
||||
(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>;
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
boardIdentifier: 'a' as string | undefined,
|
||||
communities: {} as Record<string, { address: string; name?: string; state?: string; updatedAt?: number }>,
|
||||
communityIdentifierRequests: [] as Array<string | undefined>,
|
||||
directoryListLoading: false,
|
||||
directoryBoards: [
|
||||
{
|
||||
address: 'anime-and-manga.bso',
|
||||
score: 12,
|
||||
managedByDevs: false,
|
||||
},
|
||||
],
|
||||
directories: [
|
||||
{
|
||||
address: 'anime-and-manga.bso',
|
||||
directoryCode: 'a',
|
||||
title: '/a/ - Anime & Manga',
|
||||
},
|
||||
],
|
||||
offlineHookRequests: [] as Array<{ address?: string; communityAddressHint?: string }>,
|
||||
offlineHookValue: {
|
||||
isOffline: false,
|
||||
isOnlineStatusLoading: false,
|
||||
offlineIconClass: '',
|
||||
offlineTitle: false as string | false,
|
||||
},
|
||||
offlineStates: {} as Record<string, { state?: string; updatedAt?: number }>,
|
||||
nowSeconds: 1_704_067_210,
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
Trans: ({ i18nKey }: { i18nKey: string }) => createElement(React.Fragment, null, i18nKey),
|
||||
useTranslation: () => ({
|
||||
t: (key: string, values?: Record<string, unknown>) => {
|
||||
if (key === 'directory_status_online') return 'online';
|
||||
if (key === 'directory_status_offline') return 'offline';
|
||||
if (key === 'directory_heading') return `${values?.boardIdentifier} directory`;
|
||||
if (key === 'view') return 'View';
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useParams: () => ({
|
||||
boardIdentifier: testState.boardIdentifier,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
|
||||
useCommunity: (options?: { community?: { name?: string; publicKey?: string } }) => {
|
||||
const communityAddress = options?.community?.name ?? options?.community?.publicKey;
|
||||
return communityAddress ? testState.communities[communityAddress] : undefined;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/board-buttons/board-buttons', () => ({
|
||||
BottomButton: () => createElement('button', { type: 'button' }, 'bottom'),
|
||||
CatalogButton: () => createElement('a', null, 'catalog'),
|
||||
ReturnButton: () => createElement('a', null, 'return'),
|
||||
TopButton: () => createElement('button', { type: 'button' }, 'top'),
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/footer', () => ({
|
||||
PageFooterDesktop: ({ firstRow, styleRow }: { firstRow: React.ReactNode; styleRow: React.ReactNode }) =>
|
||||
createElement('footer', { 'data-testid': 'desktop-footer' }, firstRow, styleRow),
|
||||
PageFooterMobile: ({ children }: { children: React.ReactNode }) => createElement('footer', { 'data-testid': 'mobile-footer' }, children),
|
||||
ThreadFooterStyleRow: () => createElement('div', null, 'style'),
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/loading-ellipsis', () => ({
|
||||
default: ({ string }: { string: string }) => createElement('span', null, string),
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/tooltip', () => ({
|
||||
default: ({ content, children }: { content: React.ReactNode; children: React.ReactNode }) =>
|
||||
createElement('span', { title: typeof content === 'string' ? content : undefined }, children),
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-directories', () => ({
|
||||
useDirectories: () => testState.directories,
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-directory-list', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../hooks/use-directory-list')>('../../../hooks/use-directory-list');
|
||||
return {
|
||||
...actual,
|
||||
useDirectoryList: () => ({
|
||||
list: {
|
||||
directoryCode: testState.boardIdentifier,
|
||||
title: '/a/ - Anime & Manga',
|
||||
boards: testState.directoryBoards,
|
||||
},
|
||||
loading: testState.directoryListLoading,
|
||||
error: null,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../hooks/use-resolved-community-address', () => ({
|
||||
useResolvedCommunityAddress: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-community-identifiers', () => ({
|
||||
useCommunityIdentifier: (address?: string) => {
|
||||
testState.communityIdentifierRequests.push(address);
|
||||
return address ? { name: address } : undefined;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-is-community-offline', () => ({
|
||||
default: (community?: { address?: string }, communityAddressHint?: string) => {
|
||||
testState.offlineHookRequests.push({ address: community?.address, communityAddressHint });
|
||||
return testState.offlineHookValue;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-now-seconds', () => ({
|
||||
useNowSeconds: () => testState.nowSeconds,
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores/use-community-offline-store', () => ({
|
||||
default: <T,>(selector: (state: { communityOfflineState: typeof testState.offlineStates }) => T) =>
|
||||
selector({
|
||||
communityOfflineState: testState.offlineStates,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../lib/snow', () => ({
|
||||
shouldShowSnow: () => false,
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let originalAlert: typeof window.alert;
|
||||
let root: Root;
|
||||
|
||||
const renderDirectory = async () => {
|
||||
await act(async () => {
|
||||
root.render(createElement(MemoryRouter, {}, createElement(Directory)));
|
||||
});
|
||||
};
|
||||
|
||||
const createDirectoryBoard = (address: string, score = 12) => ({
|
||||
address,
|
||||
score,
|
||||
managedByDevs: false,
|
||||
});
|
||||
|
||||
const createCommunity = (address: string, updatedAt = testState.nowSeconds - 60) => ({
|
||||
address,
|
||||
name: address,
|
||||
state: 'started',
|
||||
updatedAt,
|
||||
});
|
||||
|
||||
const getDirectoryRow = (address = 'anime-and-manga.bso') => Array.from(container.querySelectorAll('tbody tr')).find((row) => row.textContent?.includes(address));
|
||||
|
||||
describe('Directory', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testState.boardIdentifier = 'a';
|
||||
testState.communities = {
|
||||
'anime-and-manga.bso': {
|
||||
address: 'anime-and-manga.bso',
|
||||
name: 'anime-and-manga.bso',
|
||||
state: 'started',
|
||||
updatedAt: testState.nowSeconds - 60,
|
||||
},
|
||||
};
|
||||
testState.communityIdentifierRequests = [];
|
||||
testState.directoryListLoading = false;
|
||||
testState.directoryBoards = [createDirectoryBoard('anime-and-manga.bso')];
|
||||
testState.directories = [
|
||||
{
|
||||
address: 'anime-and-manga.bso',
|
||||
directoryCode: 'a',
|
||||
title: '/a/ - Anime & Manga',
|
||||
},
|
||||
];
|
||||
testState.offlineHookRequests = [];
|
||||
testState.offlineHookValue = {
|
||||
isOffline: false,
|
||||
isOnlineStatusLoading: false,
|
||||
offlineIconClass: '',
|
||||
offlineTitle: false,
|
||||
};
|
||||
testState.offlineStates = {};
|
||||
testState.nowSeconds = 1_704_067_210;
|
||||
originalAlert = window.alert;
|
||||
window.alert = vi.fn();
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
window.alert = originalAlert;
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('shows online status for a listed board after loading its community', async () => {
|
||||
await renderDirectory();
|
||||
|
||||
const cells = Array.from(getDirectoryRow()?.querySelectorAll('td') ?? []).map((cell) => cell.textContent?.replace(/\s+/g, ' ').trim());
|
||||
expect(cells.slice(0, 5)).toEqual(['1', 'anime-and-manga.bso', 'directory_owner_anonymous', 'online', '12']);
|
||||
expect(cells[5]).toContain('+1');
|
||||
expect(cells[5]).toContain('-1');
|
||||
expect(cells[5]).toContain('View');
|
||||
expect(getDirectoryRow()?.querySelector('td:nth-child(2) a')).toBeNull();
|
||||
expect(getDirectoryRow()?.querySelector('td:nth-child(2) span')).toBeNull();
|
||||
expect(testState.communityIdentifierRequests).toContain('anime-and-manga.bso');
|
||||
expect(testState.offlineHookRequests).toContainEqual({
|
||||
address: 'anime-and-manga.bso',
|
||||
communityAddressHint: 'anime-and-manga.bso',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows loading status while the listed board status is loading', async () => {
|
||||
testState.communities = {};
|
||||
testState.offlineHookValue = {
|
||||
isOffline: false,
|
||||
isOnlineStatusLoading: true,
|
||||
offlineIconClass: 'yellowOfflineIcon',
|
||||
offlineTitle: 'downloading board...',
|
||||
};
|
||||
|
||||
await renderDirectory();
|
||||
|
||||
expect(getDirectoryRow()?.textContent).toContain('loading');
|
||||
});
|
||||
|
||||
it('shows a placeholder when listed board status is unknown', async () => {
|
||||
testState.communities = {};
|
||||
|
||||
await renderDirectory();
|
||||
|
||||
const cells = Array.from(getDirectoryRow()?.querySelectorAll('td') ?? []).map((cell) => cell.textContent?.replace(/\s+/g, ' ').trim());
|
||||
expect(cells[3]).toBe('—');
|
||||
});
|
||||
|
||||
it('shows offline status when the listed board community is stale', async () => {
|
||||
testState.communities['anime-and-manga.bso'] = {
|
||||
address: 'anime-and-manga.bso',
|
||||
name: 'anime-and-manga.bso',
|
||||
state: 'started',
|
||||
updatedAt: testState.nowSeconds - 31 * 60,
|
||||
};
|
||||
|
||||
await renderDirectory();
|
||||
|
||||
expect(getDirectoryRow()?.textContent).toContain('offline');
|
||||
});
|
||||
|
||||
it('does not request status checks after the top five boards', async () => {
|
||||
const boards = Array.from({ length: 6 }, (_, index) => createDirectoryBoard(`board-${index + 1}.bso`, 100 - index));
|
||||
testState.directoryBoards = boards;
|
||||
testState.communities = Object.fromEntries(boards.map((board) => [board.address, createCommunity(board.address)]));
|
||||
|
||||
await renderDirectory();
|
||||
|
||||
for (const board of boards.slice(0, 5)) {
|
||||
expect(testState.communityIdentifierRequests).toContain(board.address);
|
||||
expect(testState.offlineHookRequests).toContainEqual({
|
||||
address: board.address,
|
||||
communityAddressHint: board.address,
|
||||
});
|
||||
}
|
||||
|
||||
expect(testState.communityIdentifierRequests).not.toContain('board-6.bso');
|
||||
expect(testState.offlineHookRequests).not.toContainEqual({
|
||||
address: 'board-6.bso',
|
||||
communityAddressHint: 'board-6.bso',
|
||||
});
|
||||
|
||||
const cells = Array.from(getDirectoryRow('board-6.bso')?.querySelectorAll('td') ?? []).map((cell) => cell.textContent?.replace(/\s+/g, ' ').trim());
|
||||
expect(cells[3]).toBe('—?');
|
||||
expect(getDirectoryRow('board-6.bso')?.querySelector('sup')?.closest('span')?.getAttribute('title')).toBe('directory_status_unavailable_reason');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
.page {
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 0 0 24px;
|
||||
color: var(--body-font-color);
|
||||
font-family: var(--body-font-family);
|
||||
font-size: var(--body-font-size);
|
||||
}
|
||||
|
||||
.desktopDivider {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.desktopNavLinks {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.desktopNavLinks a:not([class~='button']),
|
||||
.desktopFooterButtons a:not([class~='button']) {
|
||||
all: unset;
|
||||
}
|
||||
|
||||
.mobileNavLinks {
|
||||
display: none;
|
||||
text-align: center;
|
||||
text-transform: capitalize;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.mobileNavLinks button {
|
||||
text-transform: capitalize;
|
||||
margin: 5px 2px;
|
||||
}
|
||||
|
||||
.mobileNavLinks a:not([class~='button']),
|
||||
.mobileFooterButtons a:not([class~='button']) {
|
||||
all: unset;
|
||||
}
|
||||
|
||||
.desktopFooterButtons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.mobileFooterButtons {
|
||||
text-align: center;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.mobileFooterButtons button {
|
||||
text-transform: capitalize;
|
||||
margin: 5px 2px;
|
||||
}
|
||||
|
||||
.directorySummary {
|
||||
margin: 0 0 12px;
|
||||
text-align: center;
|
||||
font-size: 10pt;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.directoryIntro {
|
||||
width: 80%;
|
||||
max-width: 810px;
|
||||
margin: 0 auto 8px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.directoryIntro a {
|
||||
color: var(--post-link-text-color);
|
||||
text-decoration: var(--post-link-text-decoration);
|
||||
}
|
||||
|
||||
.directoryIntro a:hover {
|
||||
color: var(--post-link-text-color-hover);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.flashListing {
|
||||
width: 80%;
|
||||
max-width: 810px;
|
||||
margin: 10px auto 0;
|
||||
border-collapse: separate;
|
||||
border-spacing: 1px;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
.flashListing td {
|
||||
padding: 2px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.flashListing thead td {
|
||||
background: #98e;
|
||||
border: 1px solid #000;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dirRow {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.numberCell,
|
||||
.scoreCell,
|
||||
.ownerCell,
|
||||
.statusCell,
|
||||
.actionsCell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.numberCell,
|
||||
.scoreCell,
|
||||
.actionsCell {
|
||||
width: 1%;
|
||||
}
|
||||
|
||||
.postblock {
|
||||
padding: 5px !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.boardCol {
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.ownerCell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ownerName {
|
||||
font-weight: var(--post-name-font-weight, 700);
|
||||
}
|
||||
|
||||
.viewLink {
|
||||
color: var(--post-link-text-color);
|
||||
text-decoration: var(--post-link-text-decoration);
|
||||
}
|
||||
|
||||
.viewLink:hover {
|
||||
color: var(--post-link-text-color-hover);
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
color: var(--post-link-text-color);
|
||||
text-decoration: var(--post-link-text-decoration);
|
||||
}
|
||||
|
||||
.actionButton:hover,
|
||||
.actionButton:focus-visible {
|
||||
color: var(--post-link-text-color-hover);
|
||||
}
|
||||
|
||||
.actionButton:focus-visible {
|
||||
outline: 1px dotted currentcolor;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.scoreValue {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.statusOnline {
|
||||
color: green;
|
||||
}
|
||||
|
||||
.statusOffline {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.statusUnavailable {
|
||||
color: var(--body-font-color);
|
||||
}
|
||||
|
||||
.statusUnavailableHelp {
|
||||
margin-left: 2px;
|
||||
font-weight: 700;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.directoryFootnote {
|
||||
width: 80%;
|
||||
max-width: 810px;
|
||||
margin: 14px auto 0;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.directoryFootnote a {
|
||||
color: var(--post-link-text-color);
|
||||
text-decoration: var(--post-link-text-decoration);
|
||||
}
|
||||
|
||||
.directoryFootnote a:hover {
|
||||
color: var(--post-link-text-color-hover);
|
||||
}
|
||||
|
||||
.footerState {
|
||||
margin: 8px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.garland {
|
||||
border-image-slice: 50 0 50 0;
|
||||
border-image-width: 40px 0px 0px 0px;
|
||||
border-image-outset: 0px 0px 0px 0px;
|
||||
border-image-repeat: repeat repeat;
|
||||
border-image-source: url('/assets/garland.png');
|
||||
border-style: solid;
|
||||
padding-top: 50px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.desktopDivider,
|
||||
.desktopNavLinks {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobileNavLinks {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.flashListing,
|
||||
.directoryIntro,
|
||||
.directoryFootnote {
|
||||
width: calc(100% - 10px);
|
||||
max-width: calc(100% - 10px);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.flashListing {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.actionsCell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
:global(body.yotsuba) .rowOdd td {
|
||||
background: #ede2d4;
|
||||
}
|
||||
|
||||
:global(body.yotsuba) .flashListing thead td {
|
||||
background: #ea8;
|
||||
}
|
||||
|
||||
:global(body.yotsuba-b) .rowOdd td {
|
||||
background: #e0e5f6;
|
||||
}
|
||||
|
||||
:global(body.futaba) .rowOdd td {
|
||||
background: #ede2d4;
|
||||
}
|
||||
|
||||
:global(body.futaba) .flashListing thead td {
|
||||
background: #f0e0d6;
|
||||
}
|
||||
|
||||
:global(body.burichan) .rowOdd td {
|
||||
background: #e0e5f6;
|
||||
}
|
||||
|
||||
:global(body.burichan) .flashListing thead td {
|
||||
background: #c3c9e9;
|
||||
}
|
||||
|
||||
:global(body.tomorrow) .rowOdd td {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
:global(body.tomorrow) .flashListing thead td {
|
||||
background: #b294bb;
|
||||
}
|
||||
|
||||
:global(body.photon) .rowOdd td {
|
||||
background: #888;
|
||||
}
|
||||
|
||||
:global(body.photon) .flashListing thead td {
|
||||
background: #ddd;
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Link, Navigate, useParams } from 'react-router-dom';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useCommunity } from '@bitsocial/bitsocial-react-hooks';
|
||||
import { shouldShowSnow } from '../../lib/snow';
|
||||
import { BottomButton, CatalogButton, ReturnButton, TopButton } from '../../components/board-buttons/board-buttons';
|
||||
import { PageFooterDesktop, PageFooterMobile, ThreadFooterStyleRow } from '../../components/footer';
|
||||
import LoadingEllipsis from '../../components/loading-ellipsis';
|
||||
import Tooltip from '../../components/tooltip';
|
||||
import { useDirectories } from '../../hooks/use-directories';
|
||||
import { useCommunityIdentifier } from '../../hooks/use-community-identifiers';
|
||||
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
|
||||
import { isDirectoryRoute } from '../../lib/utils/route-utils';
|
||||
import { DirectoryListBoard, sortDirectoryBoardsByRank, useDirectoryList } from '../../hooks/use-directory-list';
|
||||
import { type CommunityFreshnessState, isCommunityKnownOffline } from '../../lib/utils/community-freshness-utils';
|
||||
import getShortAddress from '../../lib/get-short-address';
|
||||
import { get5chanDeveloperBadge } from '../../lib/utils/author-display-utils';
|
||||
import useCommunityOfflineStore from '../../stores/use-community-offline-store';
|
||||
import useIsCommunityOffline from '../../hooks/use-is-community-offline';
|
||||
import { useNowSeconds } from '../../hooks/use-now-seconds';
|
||||
import postStyles from '../post/post.module.css';
|
||||
import styles from './directory.module.css';
|
||||
|
||||
const DIRECTORY_STATUS_CHECK_LIMIT = 5;
|
||||
const DIRECTORY_STATUS_UNAVAILABLE_MARKER = '\u2014';
|
||||
|
||||
const computeBoardStatus = (
|
||||
communityState: CommunityFreshnessState | undefined,
|
||||
offlineState: CommunityFreshnessState | undefined,
|
||||
nowSeconds: number,
|
||||
isOffline: boolean,
|
||||
isOnlineStatusLoading: boolean,
|
||||
): 'online' | 'offline' | 'loading' | 'unknown' => {
|
||||
const freshnessState = {
|
||||
state: communityState?.state ?? offlineState?.state,
|
||||
updatedAt: communityState?.updatedAt ?? offlineState?.updatedAt,
|
||||
};
|
||||
|
||||
if (isOffline || isCommunityKnownOffline(freshnessState, nowSeconds)) return 'offline';
|
||||
if (isOnlineStatusLoading) return 'loading';
|
||||
if (!freshnessState.updatedAt) return 'unknown';
|
||||
return 'online';
|
||||
};
|
||||
|
||||
const PASS_LINK = '/pass';
|
||||
|
||||
const DirectoryDesktopTopControls = ({ communityAddress }: { communityAddress: string | undefined }) => (
|
||||
<div className={styles.desktopNavLinks}>
|
||||
<span>
|
||||
[<ReturnButton address={communityAddress} />]
|
||||
</span>
|
||||
<span>
|
||||
[<CatalogButton address={communityAddress} />]
|
||||
</span>
|
||||
<span>
|
||||
[<BottomButton />]
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DirectoryDesktopFooterControls = ({ communityAddress }: { communityAddress: string | undefined }) => (
|
||||
<div className={styles.desktopFooterButtons}>
|
||||
<span>
|
||||
[<ReturnButton address={communityAddress} />]
|
||||
</span>
|
||||
<span>
|
||||
[<CatalogButton address={communityAddress} />]
|
||||
</span>
|
||||
<span>
|
||||
[<TopButton />]
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DirectoryMobileTopControls = ({ communityAddress }: { communityAddress: string | undefined }) => (
|
||||
<div className={styles.mobileNavLinks}>
|
||||
<ReturnButton address={communityAddress} />
|
||||
<CatalogButton address={communityAddress} />
|
||||
<BottomButton />
|
||||
</div>
|
||||
);
|
||||
|
||||
const DirectoryMobileFooterControls = ({ communityAddress }: { communityAddress: string | undefined }) => (
|
||||
<div className={styles.mobileFooterButtons}>
|
||||
<ReturnButton address={communityAddress} />
|
||||
<CatalogButton address={communityAddress} />
|
||||
<TopButton />
|
||||
</div>
|
||||
);
|
||||
|
||||
interface DirectoryRowProps {
|
||||
board: DirectoryListBoard;
|
||||
nowSeconds: number;
|
||||
rank: number;
|
||||
onVote: () => void;
|
||||
}
|
||||
|
||||
const DirectoryRow = ({ board, nowSeconds, rank, onVote }: DirectoryRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
const statusUnavailableReason = t('directory_status_unavailable_reason');
|
||||
const ownerAddress = board.owner;
|
||||
const ownerDisplay = ownerAddress ? getShortAddress(ownerAddress) || ownerAddress : undefined;
|
||||
const developerBadge = get5chanDeveloperBadge(ownerAddress);
|
||||
const shouldCheckStatus = rank <= DIRECTORY_STATUS_CHECK_LIMIT;
|
||||
const communityIdentifier = useCommunityIdentifier(shouldCheckStatus ? board.address : undefined);
|
||||
const community = useCommunity(shouldCheckStatus && communityIdentifier ? { community: communityIdentifier } : undefined);
|
||||
const { isOffline, isOnlineStatusLoading } = useIsCommunityOffline(community, shouldCheckStatus ? board.address : undefined);
|
||||
const offlineState = useCommunityOfflineStore((state) => (shouldCheckStatus ? state.communityOfflineState[board.address] : undefined));
|
||||
const status = shouldCheckStatus ? computeBoardStatus(community, offlineState, nowSeconds, isOffline, isOnlineStatusLoading) : 'unavailable';
|
||||
const boardLink = `/${board.address}`;
|
||||
|
||||
return (
|
||||
<tr className={`${styles.dirRow} ${rank % 2 === 1 ? styles.rowOdd : ''}`}>
|
||||
<td className={styles.numberCell}>{rank}</td>
|
||||
<td className={styles.boardCol}>{board.address}</td>
|
||||
<td className={styles.ownerCell}>
|
||||
<span className={developerBadge ? `${styles.ownerName} ${postStyles.capcodeAdmin}` : undefined}>
|
||||
{ownerDisplay ?? t('directory_owner_anonymous')}
|
||||
{developerBadge && (
|
||||
<>
|
||||
{' ## '}
|
||||
{developerBadge.label} <span className={`${postStyles.capcodeIcon} ${postStyles.capcodeAdminIcon}`} title={developerBadge.title} />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className={styles.statusCell}>
|
||||
{status === 'unavailable' ? (
|
||||
<span className={styles.statusUnavailable}>
|
||||
{DIRECTORY_STATUS_UNAVAILABLE_MARKER}
|
||||
<Tooltip content={statusUnavailableReason}>
|
||||
<sup className={styles.statusUnavailableHelp} aria-label={statusUnavailableReason} tabIndex={0}>
|
||||
?
|
||||
</sup>
|
||||
</Tooltip>
|
||||
</span>
|
||||
) : status === 'loading' ? (
|
||||
<LoadingEllipsis string={t('loading')} />
|
||||
) : status === 'unknown' ? (
|
||||
<span className={styles.statusUnavailable}>{DIRECTORY_STATUS_UNAVAILABLE_MARKER}</span>
|
||||
) : (
|
||||
<span className={status === 'offline' ? styles.statusOffline : styles.statusOnline}>
|
||||
{t(status === 'offline' ? 'directory_status_offline' : 'directory_status_online')}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className={styles.scoreCell}>
|
||||
<span className={styles.scoreValue}>{board.score}</span>
|
||||
</td>
|
||||
<td className={styles.actionsCell}>
|
||||
[
|
||||
<button type='button' className={styles.actionButton} onClick={onVote} aria-label={t('upvote')} title={t('upvote')}>
|
||||
+1
|
||||
</button>
|
||||
] [
|
||||
<button type='button' className={styles.actionButton} onClick={onVote} aria-label={t('downvote')} title={t('downvote')}>
|
||||
-1
|
||||
</button>
|
||||
] [
|
||||
<Link to={boardLink} className={styles.viewLink}>
|
||||
{t('view')}
|
||||
</Link>
|
||||
]
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
const getRepoEditUrl = (directoryCode: string) => `https://github.com/bitsocialnet/lists/edit/master/5chan-${directoryCode}-directory.json`;
|
||||
|
||||
const Directory = () => {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams();
|
||||
const boardIdentifier = params.boardIdentifier;
|
||||
const directories = useDirectories();
|
||||
const isValidDirectoryCode = !!boardIdentifier && isDirectoryRoute(boardIdentifier, directories);
|
||||
const { list, loading } = useDirectoryList(isValidDirectoryCode ? boardIdentifier : undefined);
|
||||
const communityAddress = useResolvedCommunityAddress();
|
||||
const nowSeconds = useNowSeconds();
|
||||
|
||||
const ranked = useMemo(() => (list ? sortDirectoryBoardsByRank(list.boards) : []), [list]);
|
||||
const directoryTitle = list?.title || (boardIdentifier ? `/${boardIdentifier}/ - ${t('directory')}` : t('directory'));
|
||||
|
||||
useEffect(() => {
|
||||
if (!isValidDirectoryCode) return;
|
||||
document.title = `${directoryTitle} - 5chan`;
|
||||
}, [directoryTitle, isValidDirectoryCode]);
|
||||
|
||||
if (!isValidDirectoryCode) {
|
||||
return <Navigate to='/not-found' replace />;
|
||||
}
|
||||
|
||||
const handleVoteUnavailable = () => {
|
||||
const values = { boardIdentifier };
|
||||
window.alert(`${t('directory_voting_unavailable_intro', values)}\n\n${t('directory_voting_unavailable_outro', values)}`);
|
||||
};
|
||||
|
||||
const isLoadingShell = loading && ranked.length === 0;
|
||||
const boardCount = ranked.length;
|
||||
const repoEditUrl = getRepoEditUrl(boardIdentifier!);
|
||||
|
||||
return (
|
||||
<div id='top' className={`${styles.page} ${shouldShowSnow() ? styles.garland : ''}`}>
|
||||
<DirectoryMobileTopControls communityAddress={communityAddress} />
|
||||
<hr className={styles.desktopDivider} />
|
||||
<DirectoryDesktopTopControls communityAddress={communityAddress} />
|
||||
<hr className={styles.divider} />
|
||||
{isLoadingShell ? (
|
||||
<h4 className={styles.directorySummary}>
|
||||
<LoadingEllipsis string={t('loading_directory')} />
|
||||
</h4>
|
||||
) : ranked.length === 0 ? (
|
||||
<h4 className={styles.directorySummary}>{t('directory_empty')}</h4>
|
||||
) : (
|
||||
<h4 className={styles.directorySummary}>{t('directory_heading', { boardIdentifier, count: boardCount })}</h4>
|
||||
)}
|
||||
|
||||
{!isLoadingShell && ranked.length > 0 && (
|
||||
<>
|
||||
<table className={styles.flashListing}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.postblock} scope='col'>
|
||||
No.
|
||||
</th>
|
||||
<th className={styles.postblock} scope='col'>
|
||||
{t('directory_board')}
|
||||
</th>
|
||||
<th className={styles.postblock} scope='col'>
|
||||
{t('directory_owner')}
|
||||
</th>
|
||||
<th className={styles.postblock} scope='col'>
|
||||
{t('directory_status')}
|
||||
</th>
|
||||
<th className={styles.postblock} scope='col'>
|
||||
{t('directory_score')}
|
||||
</th>
|
||||
<th className={styles.postblock} scope='col'>
|
||||
{t('directory_vote')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ranked.map((board, index) => (
|
||||
<DirectoryRow key={board.address} board={board} nowSeconds={nowSeconds} rank={index + 1} onVote={handleVoteUnavailable} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className={styles.directoryFootnote}>
|
||||
<Trans
|
||||
i18nKey='directory_footnote'
|
||||
values={{ boardIdentifier }}
|
||||
components={{
|
||||
passLink: <Link to={PASS_LINK} />,
|
||||
repoLink: <a href={repoEditUrl} target='_blank' rel='noreferrer noopener' />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<PageFooterDesktop firstRow={<DirectoryDesktopFooterControls communityAddress={communityAddress} />} styleRow={<ThreadFooterStyleRow />} />
|
||||
<PageFooterMobile>
|
||||
<DirectoryMobileFooterControls communityAddress={communityAddress} />
|
||||
</PageFooterMobile>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Directory;
|
||||
+4
-11
@@ -316,15 +316,12 @@ const FAQ_SECTIONS: FAQSection[] = [
|
||||
question: 'Can I create my own board?',
|
||||
answer: (
|
||||
<>
|
||||
Yes. A 5chan board is a Bitsocial community. Today the practical route is to run{' '}
|
||||
<a href='https://github.com/bitsocialnet/bitsocial-cli' {...externalLinkProps}>
|
||||
bitsocial-cli
|
||||
</a>{' '}
|
||||
as a node and use{' '}
|
||||
Yes. A 5chan board is a Bitsocial community. Today the practical route is to use{' '}
|
||||
<a href='https://github.com/bitsocialnet/5chan-board-manager' {...externalLinkProps}>
|
||||
5chan Board Manager
|
||||
</a>{' '}
|
||||
for imageboard lifecycle rules such as thread limits, bump limits, archived-thread retention, and purging of author-deleted content.
|
||||
to run the board with imageboard lifecycle rules such as thread limits, bump limits, archived-thread retention, and purging of author-deleted content. Its
|
||||
Docker setup can start the required Bitsocial node for you or connect to one you already operate.
|
||||
</>
|
||||
),
|
||||
},
|
||||
@@ -557,14 +554,10 @@ const FAQ_SECTIONS: FAQSection[] = [
|
||||
answer: (
|
||||
<>
|
||||
The 5chan client is free and open-source software under GPL-3.0-or-later. Boards are Bitsocial communities, typically run with{' '}
|
||||
<a href='https://github.com/bitsocialnet/bitsocial-cli' {...externalLinkProps}>
|
||||
bitsocial-cli
|
||||
</a>{' '}
|
||||
and, for imageboard-specific behavior,{' '}
|
||||
<a href='https://github.com/bitsocialnet/5chan-board-manager' {...externalLinkProps}>
|
||||
5chan Board Manager
|
||||
</a>
|
||||
.
|
||||
, which connects to the Bitsocial network and applies 5chan-specific board behavior.
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user