diff --git a/README.md b/README.md index c38dc3af..6b32e80d 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ To have your board appear in a directory on the 5chan homepage: - Node.js v22 (Download from https://nodejs.org) - Yarn: `npm install -g yarn` -- [Portless](https://github.com/vercel-labs/portless): `npm install -g portless` +- Portless is installed with project dependencies via `yarn install` ### Setup diff --git a/docs/agent-playbooks/known-surprises.md b/docs/agent-playbooks/known-surprises.md index 4ae083ed..5a4ac4c2 100644 --- a/docs/agent-playbooks/known-surprises.md +++ b/docs/agent-playbooks/known-surprises.md @@ -28,4 +28,12 @@ If uncertain, ask the developer before adding an entry. ## Entries -No confirmed surprises logged yet. +### Portless breaks Windows installs + +- **Date:** 2026-03-04 +- **Observed by:** Codex +- **Context:** GitHub Actions `Test Windows` dependency install on `windows-2022` +- **What was surprising:** `portless@0.5.2` is a local dev-only tool, but keeping it in `devDependencies` makes `yarn install` fail on Windows because the package declares `win32` unsupported. +- **Impact:** Windows CI fails before build steps run, even though the app does not need `portless` there. +- **Mitigation:** Keep `portless` in `optionalDependencies` and make `yarn start` fall back to direct `vite` startup when `portless` is unavailable. +- **Status:** confirmed diff --git a/package.json b/package.json index d8115218..b4a14c27 100644 --- a/package.json +++ b/package.json @@ -36,14 +36,9 @@ "react-ace": "14.0.1", "react-dom": "19.1.2", "react-i18next": "15.7.4", - "react-markdown": "8.0.6", "react-router-dom": "6.30.2", "react-router-hash-link": "2.4.3", "react-virtuoso": "4.17.0", - "rehype-raw": "7.0.0", - "rehype-sanitize": "5.0.1", - "remark-gfm": "3.0.1", - "remark-supersub": "1.0.0", "tcp-port-used": "1.0.2", "typescript": "5.1.6", "workbox-build": "7.4.0", @@ -58,7 +53,7 @@ "postinstall": "node scripts/patch-capacitor-cli-tar.cjs", "prebuild": "yarn sync:directories && yarn generate:assets", "prestart": "yarn sync:directories && yarn generate:assets", - "start": "portless 5chan vite", + "start": "node scripts/start-dev.js", "build": "cross-env PUBLIC_URL=./ GENERATE_SOURCEMAP=false vite build", "build:preload": "vite build --config electron/vite.preload.config.js", "build-vercel": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" PUBLIC_URL=./ GENERATE_SOURCEMAP=true VITE_COMMIT_REF=$COMMIT_REF CI='' vite build", @@ -160,6 +155,9 @@ "vitest": "4.0.15", "wait-on": "9.0.3" }, + "optionalDependencies": { + "portless": "0.5.2" + }, "resolutions": { "axios": "1.13.5", "js-yaml": "4.1.1", diff --git a/public/translations/ar/default.json b/public/translations/ar/default.json index 225f3022..89866618 100644 --- a/public/translations/ar/default.json +++ b/public/translations/ar/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "ما هو 5chan؟", "go": "اذهب", "5chan_description": "5chan لوحة صور لامركزية بالكامل ومفتوحة المصدر، حيث يمكن لأي شخص إنشاء وامتلاك عدد غير محدود من اللوحات بشكل تشفيري. يضيف مالكو اللوحات ويشرفون على لوحتهم ويحددون <1>Rules. جميع البيانات نصية فقط وتُشارك من نظير إلى نظير، بما في ذلك روابط تضمين الصور. راجع <2>FAQs لمعرفة المزيد. لا تتردد في النقر على لوحة أدناه تهمك والبدء فوراً!", - "no_global_rules_info": "يمكنك دائمًا الاتصال بأي منتدى، بشكل كامل P2P، لا يمكن لأحد إيقافك. ببساطة الصق عنوانه في حقل البحث أعلاه.", + "no_global_rules_info": "5chan تطبيق بدون خادم، هذا الموقع مجرد طريقة واحدة لفتحه. لتجنب الاعتماد على توفر الموقع، <1>حمّل إصداراً.", "boards": "منتديات", "popular_threads": "المواضيع الشائعة", "options": "الخيارات", diff --git a/public/translations/bn/default.json b/public/translations/bn/default.json index e0498482..ff3dd91a 100644 --- a/public/translations/bn/default.json +++ b/public/translations/bn/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "5chan কি?", "go": "যাও", "5chan_description": "5chan একটি সম্পূর্ণ বিকেন্দ্রীভূত এবং ওপেন-সোর্স ইমেজবোর্ড, যেখানে যে কেউ সীমাহীন বোর্ড তৈরি করতে এবং ক্রিপ্টোগ্রাফিকভাবে মালিকানা নিতে পারে। বোর্ড মালিকরা তাদের বোর্ড হোস্ট এবং মডারেট করেন এবং এর <1>Rules সেট করেন। সমস্ত ডেটা শুধুমাত্র টেক্সট এবং পিয়ার-টু-পিয়ার শেয়ার করা হয়, এম্বেড ইমেজের লিঙ্ক সহ। আরও জানতে <2>FAQs দেখুন। নীচের যে বোর্ডে আপনার আগ্রহ আছে সেটিতে ক্লিক করতে এবং সরাসরি যোগ দিতে নির্দ্বিধায়!", - "no_global_rules_info": "আপনি সবসময় যেকোনো বোর্ডে সংযোগ করতে পারেন, সম্পূর্ণ P2P, কেউ আপনাকে থামাতে পারে না। উপরের সার্চ ফিল্ডে এর ঠিকানা পেস্ট করুন।", + "no_global_rules_info": "5chan একটি সার্ভারলেস অ্যাপ, এই ওয়েবসাইট এটি খোলার একটি উপায় মাত্র। ওয়েবসাইটের আপটাইমের উপর নির্ভরতা এড়াতে <1>একটি রিলিজ ডাউনলোড করুন।", "boards": "বোর্ডসমূহ", "popular_threads": "জনপ্রিয় থ্রেডগুলি", "options": "বিকল্পসমূহ", diff --git a/public/translations/cs/default.json b/public/translations/cs/default.json index e1d7f3c1..b6aa9318 100644 --- a/public/translations/cs/default.json +++ b/public/translations/cs/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "Co je 5chan?", "go": "jít", "5chan_description": "5chan je plně decentralizované a open-source imageboard, kde kdokoli může vytvářet a kryptograficky vlastnit neomezený počet desek. Majitelé desek hostují a moderují svou desku a nastavují její <1>Rules. Všechna data jsou pouze textová a sdílená peer-to-peer, včetně odkazů na vložené obrázky. Pro více informací se podívejte na <2>FAQs. Klikněte na libovolnou desku níže a můžete začít!", - "no_global_rules_info": "Můžete se vždy připojit k jakékoli nástěnce, plně P2P, nikdo vás nemůže zastavit. Jednoduše vložte její adresu do vyhledávacího pole výše.", + "no_global_rules_info": "5chan je bezserverová aplikace, tento web je jen jedním způsobem, jak ji otevřít. Abyste se nebyli závislí na dostupnosti webu, <1>stáhněte si vydání.", "boards": "nástěnky", "popular_threads": "Oblíbená vlákna", "options": "Možnosti", diff --git a/public/translations/da/default.json b/public/translations/da/default.json index d45f88ce..2eae9b12 100644 --- a/public/translations/da/default.json +++ b/public/translations/da/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "Hvad er 5chan?", "go": "gå", "5chan_description": "5chan er et fuldt decentraliseret og open-source imageboard, hvor alle kan oprette og kryptografisk eje ubegrænsede boards. Board-ejere hoster og modererer deres board og sætter dets <1>Rules. Alle data er kun tekst og deles peer-to-peer, inklusiv links til indlejrede billeder. Tjek <2>FAQs for at lære mere. Klik gerne på et board nedenfor der interesserer dig og kom i gang!", - "no_global_rules_info": "Du kan altid forbinde til enhver opslagstavle, fuldt P2P, ingen kan stoppe dig. Indsæt simpelthen dens adresse i søgefeltet ovenfor.", + "no_global_rules_info": "5chan er en serverløs app, denne hjemmeside er blot én måde at åbne den på. For at undgå at stole på hjemmesidens oppetid, <1>hent en udgivelse.", "boards": "opslagstavler", "popular_threads": "Populære tråde", "options": "Indstillinger", diff --git a/public/translations/de/default.json b/public/translations/de/default.json index f7089aa8..3f2138b6 100644 --- a/public/translations/de/default.json +++ b/public/translations/de/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "Was ist 5chan?", "go": "gehen", "5chan_description": "5chan ist ein vollständig dezentrales und quelloffenes Imageboard, auf dem jeder unbegrenzt Boards erstellen und kryptografisch besitzen kann. Board-Inhaber hosten und moderieren ihr Board und legen dessen <1>Rules fest. Alle Daten sind rein textbasiert und werden peer-to-peer geteilt, inklusive Links zu eingebetteten Bildern. Schau dir die <2>FAQs an, um mehr zu erfahren. Klicke einfach auf ein Board unten, das dich interessiert, und leg los!", - "no_global_rules_info": "Sie können sich jederzeit mit jedem Forum verbinden, vollständig P2P, niemand kann Sie aufhalten. Fügen Sie einfach seine Adresse in das Suchfeld oben ein.", + "no_global_rules_info": "5chan ist eine serverlose App, diese Website ist nur eine Möglichkeit, sie zu öffnen. Um nicht von der Verfügbarkeit der Website abhängig zu sein, <1>lade eine Version herunter.", "boards": "Foren", "popular_threads": "Beliebte Threads", "options": "Optionen", diff --git a/public/translations/el/default.json b/public/translations/el/default.json index ad304bbd..e62e50c3 100644 --- a/public/translations/el/default.json +++ b/public/translations/el/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "Τι είναι το 5chan;", "go": "πήγαινε", "5chan_description": "Το 5chan είναι ένα πλήρως αποκεντρωμένο και ανοιχτού κώδικα imageboard, όπου οποιοσδήποτε μπορεί να δημιουργήσει και να κατέχει κρυπτογραφικά απεριόριστες σανίδες. Οι ιδιοκτήτες σανίδων φιλοξενούν και ελέγχουν τη σανίδα τους και ορίζουν τους <1>Rules. Όλα τα δεδομένα είναι μόνο κείμενο και κοινοποιούνται peer-to-peer, συμπεριλαμβανομένων συνδέσμων για ενσωματωμένες εικόνες. Δείτε τα <2>FAQs για να μάθετε περισσότερα. Μη διστάσετε να κάνετε κλικ σε μια σανίδα παρακάτω που σας ενδιαφέρει και ξεκινήστε!", - "no_global_rules_info": "Μπορείτε πάντα να συνδεθείτε σε οποιοδήποτε board, πλήρως P2P, κανείς δεν μπορεί να σας σταματήσει. Απλά επικολλήστε τη διεύθυνσή του στο πεδίο αναζήτησης παραπάνω.", + "no_global_rules_info": "Το 5chan είναι μια εφαρμογή χωρίς διακομιστή, αυτός ο ιστότοπος είναι απλώς ένας τρόπος να την ανοίξετε. Για να μην εξαρτάστε από τη διαθεσιμότητα του ιστότοπου, <1>κατεβάστε μια έκδοση.", "boards": "διαδικτυακές πινακίδες", "popular_threads": "Δημοφιλή νήματα", "options": "Επιλογές", diff --git a/public/translations/en/default.json b/public/translations/en/default.json index df5fa63a..f6ecd47a 100644 --- a/public/translations/en/default.json +++ b/public/translations/en/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "What is 5chan?", "go": "go", "5chan_description": "5chan is a fully decentralized and open-source imageboard, where anyone can create and cryptographically own unlimited boards. Board owners host and moderate their board, and set its <1>Rules. All data is text-only and it's shared peer-to-peer, including links to embed images. Check out the <2>FAQs to learn more. Feel free to click on a board below that interests you and jump right in!", - "no_global_rules_info": "You can always connect to any board, fully P2P, nobody can stop you. Simply paste its address in the search field above.", + "no_global_rules_info": "5chan is a serverless app, this website is just one way to open it. To avoid relying on the website's uptime, <1>download a release.", "boards": "boards", "popular_threads": "Popular threads", "options": "Options", diff --git a/public/translations/es/default.json b/public/translations/es/default.json index d560334e..95fc3a59 100644 --- a/public/translations/es/default.json +++ b/public/translations/es/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "¿Qué es 5chan?", "go": "ir", "5chan_description": "5chan es un imageboard totalmente descentralizado y de código abierto, donde cualquiera puede crear y poseer criptográficamente tableros ilimitados. Los propietarios de tableros alojan y moderan su tablero y establecen sus <1>Rules. Todos los datos son solo texto y se comparten peer-to-peer, incluyendo enlaces para incrustar imágenes. Consulta las <2>FAQs para saber más. ¡Haz clic en cualquier tablero de abajo que te interese y empieza!", - "no_global_rules_info": "Siempre puedes conectarte a cualquier foro, completamente P2P, nadie puede detenerte. Simplemente pega su dirección en el campo de búsqueda de arriba.", + "no_global_rules_info": "5chan es una app sin servidor, este sitio web es solo una forma de abrirlo. Para no depender del tiempo de actividad del sitio, <1>descarga una versión.", "boards": "foros", "popular_threads": "Hilos populares", "options": "Opciones", diff --git a/public/translations/fa/default.json b/public/translations/fa/default.json index 152bda27..092ae9ae 100644 --- a/public/translations/fa/default.json +++ b/public/translations/fa/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "5chan چیست؟", "go": "برو", "5chan_description": "5chan یک imageboard کاملاً غیرمتمرکز و متن‌باز است که هر کسی می‌تواند تخته‌های نامحدود ایجاد کند و به صورت رمزنگاری مالک آن‌ها شود. مالکان تخته، تخته خود را میزبانی و نظارت می‌کنند و <1>Rules آن را تعیین می‌کنند. تمام داده‌ها فقط متنی هستند و به صورت peer-to-peer به اشتراک گذاشته می‌شوند، از جمله لینک‌های تصاویر جاسازی شده. برای اطلاعات بیشتر <2>FAQs را ببینید. روی هر تخته‌ای که به آن علاقه دارید کلیک کنید و شروع کنید!", - "no_global_rules_info": "شما همیشه می‌توانید به هر تالاری متصل شوید، کاملاً P2P، هیچ کس نمی‌تواند شما را متوقف کند. به سادگی آدرس آن را در فیلد جستجوی بالا قرار دهید.", + "no_global_rules_info": "5chan یک اپلیکیشن بدون سرور است، این وب‌سایت تنها یکی از راه‌های باز کردن آن است. برای جلوگیری از وابستگی به در دسترس بودن وب‌سایت، <1>یک نسخه دانلود کنید.", "boards": "تالارهای پیام", "popular_threads": "نخستین موضوعات", "options": "گزینه‌ها", diff --git a/public/translations/fi/default.json b/public/translations/fi/default.json index 02da1f61..188a09eb 100644 --- a/public/translations/fi/default.json +++ b/public/translations/fi/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "Mikä on 5chan?", "go": "mennä", "5chan_description": "5chan on täysin hajautettu ja avoimen lähdekoodin imageboard, jossa kuka tahansa voi luoda ja kryptografisesti omistaa rajattomasti lautia. Lautan omistajat isännöivät ja moderointavat lautaa ja asettavat sen <1>Rules. Kaikki data on vain tekstiä ja jaetaan peer-to-peer -verkossa, mukaan lukien linkit upotettaviin kuviin. Lue lisää <2>FAQs-osiosta. Voit vapaasti klikata alla olevaa lautaa, joka kiinnostaa sinua, ja aloittaa!", - "no_global_rules_info": "Voit aina yhdistää mihin tahansa foorumiin, täysin P2P, kukaan ei voi pysäyttää sinua. Liitä vain sen osoite yllä olevaan hakukenttään.", + "no_global_rules_info": "5chan on palvelimeton sovellus, tämä sivusto on vain yksi tapa avata se. Jotta et joutuisi luottamaan sivuston käytettävyyteen, <1>lataa julkaisu.", "boards": "foorumit", "popular_threads": "Suositut langat", "options": "Vaihtoehdot", diff --git a/public/translations/fil/default.json b/public/translations/fil/default.json index e0fa618e..d51f1c72 100644 --- a/public/translations/fil/default.json +++ b/public/translations/fil/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "Ano ang 5chan?", "go": "pumunta", "5chan_description": "Ang 5chan ay isang ganap na desentralisado at open-source na imageboard, kung saan sinuman ay maaaring lumikha at kriptograpikong magmay-ari ng walang limitasyong mga board. Ang mga may-ari ng board ay nagho-host at nagmo-moderate ng kanilang board, at nagtatakda ng <1>Rules nito. Lahat ng data ay text-only at ibinabahagi peer-to-peer, kasama ang mga link para sa embed na mga larawan. Tingnan ang <2>FAQs para matuto pa. Huwag mag-atubiling i-click ang anumang board sa ibaba na interesado ka at sumali na!", - "no_global_rules_info": "Maaari mong palaging kumonekta sa anumang board, ganap na P2P, walang makakapigil sa iyo. Ilagay lamang ang address nito sa search field sa itaas.", + "no_global_rules_info": "Ang 5chan ay isang serverless na app, ang website na ito ay isang paraan lamang para buksan ito. Para hindi umasa sa uptime ng website, <1>mag-download ng release.", "boards": "mga board", "popular_threads": "Mga popular na thread", "options": "Mga Opsyon", diff --git a/public/translations/fr/default.json b/public/translations/fr/default.json index c0a5bea0..fda406a3 100644 --- a/public/translations/fr/default.json +++ b/public/translations/fr/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "Qu'est-ce que 5chan?", "go": "aller", "5chan_description": "5chan est un imageboard entièrement décentralisé et open-source, où chacun peut créer et posséder cryptographiquement des boards illimités. Les propriétaires de boards hébergent et modèrent leur board et définissent ses <1>Rules. Toutes les données sont en texte uniquement et partagées en peer-to-peer, y compris les liens pour intégrer des images. Consultez les <2>FAQs pour en savoir plus. N'hésitez pas à cliquer sur un board ci-dessous qui vous intéresse et à vous lancer !", - "no_global_rules_info": "Vous pouvez toujours vous connecter à n'importe quel forum, entièrement P2P, personne ne peut vous arrêter. Collez simplement son adresse dans le champ de recherche ci-dessus.", + "no_global_rules_info": "5chan est une application sans serveur, ce site web n'est qu'une façon de l'ouvrir. Pour ne pas dépendre de la disponibilité du site, <1>téléchargez une version.", "boards": "forums", "popular_threads": "Fils populaires", "options": "Options", diff --git a/public/translations/he/default.json b/public/translations/he/default.json index 13540c10..3a45ebd6 100644 --- a/public/translations/he/default.json +++ b/public/translations/he/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "מה זה 5chan?", "go": "לך", "5chan_description": "5chan הוא imageboard מבוזר לחלוטין וקוד פתוח, שבו כל אחד יכול ליצור ולבעלות קריפטוגרפית על לוחות בלתי מוגבלים. בעלי לוחות מארחים ומנהלים את הלוח שלהם ומגדירים את <1>Rules שלו. כל הנתונים הם טקסט בלבד ומשותפים peer-to-peer, כולל קישורים להטמעת תמונות. בדוק את <2>FAQs כדי ללמוד עוד. אל תהסס ללחוץ על לוח למטה שמעניין אותך ולהצטרף!", - "no_global_rules_info": "תמיד תוכל להתחבר לכל לוח, לגמרי P2P, אף אחד לא יכול לעצור אותך. פשוט הדבק את הכתובת שלו בשדה החיפוש למעלה.", + "no_global_rules_info": "5chan הוא אפליקציה ללא שרת, האתר הזה הוא רק דרך אחת לפתוח אותה. כדי לא להסתמך על זמינות האתר, <1>הורד גרסה.", "boards": "פורומים", "popular_threads": "אשכולות פופולריים", "options": "אפשרויות", diff --git a/public/translations/hi/default.json b/public/translations/hi/default.json index 1eb6344e..0781292d 100644 --- a/public/translations/hi/default.json +++ b/public/translations/hi/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "5chan क्या है?", "go": "जाओ", "5chan_description": "5chan एक पूरी तरह से विकेंद्रीकृत और ओपन-सोर्स इमेजबोर्ड है, जहाँ कोई भी असीमित बोर्ड बना सकता है और क्रिप्टोग्राफिक रूप से उनका मालिक हो सकता है। बोर्ड मालिक अपने बोर्ड को होस्ट और मॉडरेट करते हैं और इसके <1>Rules सेट करते हैं। सभी डेटा केवल टेक्स्ट है और इसे पीयर-टू-पीयर साझा किया जाता है, जिसमें एम्बेड इमेज के लिंक शामिल हैं। अधिक जानने के लिए <2>FAQs देखें। नीचे दिए गए किसी भी बोर्ड पर क्लिक करने में संकोच न करें जो आपको रुचिकर लगे और शुरू करें!", - "no_global_rules_info": "आप हमेशा किसी भी बोर्ड से जुड़ सकते हैं, पूरी तरह से P2P, कोई आपको रोक नहीं सकता। बस उसका पता ऊपर के खोज क्षेत्र में पेस्ट करें।", + "no_global_rules_info": "5chan एक सर्वरलेस ऐप है, यह वेबसाइट इसे खोलने का सिर्फ एक तरीका है। वेबसाइट की अपटाइम पर निर्भरता से बचने के लिए <1>एक रिलीज़ डाउनलोड करें।", "boards": "मंच", "popular_threads": "लोकप्रिय थ्रेड्स", "options": "विकल्प", diff --git a/public/translations/hu/default.json b/public/translations/hu/default.json index e3a30504..76cef903 100644 --- a/public/translations/hu/default.json +++ b/public/translations/hu/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "Mi az a 5chan?", "go": "menni", "5chan_description": "A 5chan egy teljesen decentralizált és nyílt forráskódú imageboard, ahol bárki létrehozhat és kriptográfiailag birtokolhat korlátlan számú fórumot. A fórum tulajdonosok hostolják és moderálják fórumukat, és beállítják annak <1>Rules. Minden adat csak szöveges és peer-to-peer módon osztozik meg, beleértve a beágyazott képek linkjeit is. Nézd meg a <2>FAQs oldalt, hogy többet tudj meg. Nyugodtan kattints egy alábbi fórumra, ami érdekel, és kezdd el!", - "no_global_rules_info": "Bármikor csatlakozhatsz bármely fórumhoz, teljesen P2P, senki sem tud megállítani. Egyszerűen illeszd be a címét a fenti keresőmezőbe.", + "no_global_rules_info": "A 5chan egy szerver nélküli alkalmazás, ez a weboldal csak egy módja annak megnyitásának. Hogy ne a weboldal elérhetőségétől függj, <1>tölts le egy kiadást.", "boards": "fórumok", "popular_threads": "Népszerű szálak", "options": "Beállítások", diff --git a/public/translations/id/default.json b/public/translations/id/default.json index 25e0e303..5f88b887 100644 --- a/public/translations/id/default.json +++ b/public/translations/id/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "Apa itu 5chan?", "go": "pergi", "5chan_description": "5chan adalah imageboard yang sepenuhnya terdesentralisasi dan open-source, di mana siapa pun dapat membuat dan secara kriptografis memiliki board tanpa batas. Pemilik board menghosting dan memoderasi board mereka serta menetapkan <1>Rules-nya. Semua data hanya berupa teks dan dibagikan peer-to-peer, termasuk tautan untuk menyematkan gambar. Lihat <2>FAQs untuk mempelajari lebih lanjut. Silakan klik board di bawah yang menarik minat Anda dan mulai!", - "no_global_rules_info": "Anda selalu dapat terhubung ke papan mana pun, sepenuhnya P2P, tidak ada yang bisa menghentikan Anda. Cukup tempelkan alamatnya di bidang pencarian di atas.", + "no_global_rules_info": "5chan adalah aplikasi tanpa server, situs web ini hanyalah salah satu cara untuk membukanya. Agar tidak bergantung pada ketersediaan situs, <1>unduh rilis.", "boards": "papan pesan", "popular_threads": "Benang populer", "options": "Opsi", diff --git a/public/translations/it/default.json b/public/translations/it/default.json index 932221ec..43d3701c 100644 --- a/public/translations/it/default.json +++ b/public/translations/it/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "Cos'è 5chan?", "go": "vai", "5chan_description": "5chan è un imageboard completamente decentralizzato e open-source, dove chiunque può creare e possedere criptograficamente board illimitati. I proprietari dei board ospitano e moderano il proprio board e ne impostano le <1>Rules. Tutti i dati sono solo testuali e condivisi peer-to-peer, inclusi i link per incorporare immagini. Consulta le <2>FAQs per saperne di più. Clicca pure su un board qui sotto che ti interessa e inizia!", - "no_global_rules_info": "Puoi sempre connetterti a qualsiasi board, completamente P2P, nessuno può fermarti. Basta incollare il suo indirizzo nel campo di ricerca sopra.", + "no_global_rules_info": "5chan è un'app serverless, questo sito è solo un modo per aprirla. Per non dipendere dalla disponibilità del sito, <1>scarica una versione.", "boards": "board", "popular_threads": "Thread popolari", "options": "Opzioni", diff --git a/public/translations/ja/default.json b/public/translations/ja/default.json index f814960b..0017201e 100644 --- a/public/translations/ja/default.json +++ b/public/translations/ja/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "5chanとは何ですか?", "go": "行く", "5chan_description": "5chanは完全に分散化されたオープンソースの画像掲示板で、誰でも無制限の板を作成し、暗号学的に所有できます。板のオーナーは板をホスト・モデレートし、<1>Rulesを設定します。すべてのデータはテキストのみで、画像埋め込み用のリンクを含め、ピアツーピアで共有されます。詳しくは<2>FAQsをご覧ください。興味のある板をクリックして、すぐに参加してください!", - "no_global_rules_info": "いつでも任意のボードに接続できます、完全にP2P、誰もあなたを止めることはできません。上記の検索フィールドにそのアドレスを貼り付けるだけです。", + "no_global_rules_info": "5chanはサーバーレスアプリで、このウェブサイトはそれを開く一つの方法に過ぎません。ウェブサイトの稼働に依存しないように、<1>リリースをダウンロードしてください。", "boards": "掲示板", "popular_threads": "人気のあるスレッド", "options": "オプション", diff --git a/public/translations/ko/default.json b/public/translations/ko/default.json index fe21c5ec..300a5cfc 100644 --- a/public/translations/ko/default.json +++ b/public/translations/ko/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "5chan이란 무엇인가요?", "go": "가기", "5chan_description": "5chan은 완전히 분산화된 오픈소스 이미지보드로, 누구나 무제한 보드를 생성하고 암호화적으로 소유할 수 있습니다. 보드 소유자는 보드를 호스팅하고 관리하며 <1>Rules를 설정합니다. 모든 데이터는 텍스트만 포함하며 이미지 임베드 링크를 포함해 피어 투 피어로 공유됩니다. 자세한 내용은 <2>FAQs를 확인하세요. 관심 있는 보드를 클릭하고 바로 참여하세요!", - "no_global_rules_info": "언제든지 어떤 게시판이든 연결할 수 있습니다, 완전히 P2P, 아무도 당신을 막을 수 없습니다. 위의 검색 필드에 주소를 붙여넣기만 하면 됩니다.", + "no_global_rules_info": "5chan은 서버리스 앱이며, 이 웹사이트는 열 수 있는 방법 중 하나일 뿐입니다. 웹사이트 가동 시간에 의존하지 않으려면 <1>릴리스를 다운로드하세요.", "boards": "게시판", "popular_threads": "인기 있는 스레드", "options": "옵션", diff --git a/public/translations/mr/default.json b/public/translations/mr/default.json index fd39694e..8fa75510 100644 --- a/public/translations/mr/default.json +++ b/public/translations/mr/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "5chan काय आहे?", "go": "जा", "5chan_description": "5chan एक पूर्णपणे विकेंद्रीकृत आणि ओपन-सोर्स इमेजबोर्ड आहे, जिथे कोणीही अमर्यादित बोर्ड तयार करू शकतो आणि क्रिप्टोग्राफिकदृष्ट्या त्यांचा मालक होऊ शकतो. बोर्ड मालक त्यांच्या बोर्डचे होस्टिंग आणि मॉडरेशन करतात आणि त्याचे <1>Rules सेट करतात. सर्व डेटा फक्त मजकूर आहे आणि तो पीअर-टू-पीअर शेअर केला जातो, एम्बेड इमेजेसच्या लिंक्ससह. अधिक जाणून घेण्यासाठी <2>FAQs पहा. खाली असलेल्या कोणत्याही बोर्डवर क्लिक करण्यास संकोच करू नका आणि सुरू करा!", - "no_global_rules_info": "तुम्ही नेहमी कोणत्याही बोर्डशी कनेक्ट होऊ शकता, पूर्णपणे P2P, कोणीही तुम्हाला थांबवू शकत नाही. फक्त वरील शोध फील्डमध्ये त्याचा पत्ता पेस्ट करा.", + "no_global_rules_info": "5chan हे सर्वरलेस अॅप आहे, ही वेबसाइट ते उघडण्याचा फक्त एक मार्ग आहे. वेबसाइटच्या अपटाइमवर अवलंबून राहणे टाळण्यासाठी <1>रिलीझ डाउनलोड करा.", "boards": "संदेश फलक", "popular_threads": "लोकप्रिय थ्रेड्स", "options": "पर्याय", diff --git a/public/translations/nl/default.json b/public/translations/nl/default.json index 11a91597..a5d428c8 100644 --- a/public/translations/nl/default.json +++ b/public/translations/nl/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "Wat is 5chan?", "go": "gaan", "5chan_description": "5chan is een volledig gedecentraliseerd en open-source imageboard, waar iedereen onbeperkt boards kan aanmaken en cryptografisch bezitten. Board-eigenaren hosten en modereren hun board en stellen de <1>Rules in. Alle data is alleen tekst en wordt peer-to-peer gedeeld, inclusief links voor ingesloten afbeeldingen. Bekijk de <2>FAQs voor meer informatie. Klik gerust op een board hieronder dat je interesseert en begin!", - "no_global_rules_info": "Je kunt altijd verbinden met elk forum, volledig P2P, niemand kan je stoppen. Plak gewoon het adres in het zoekveld hierboven.", + "no_global_rules_info": "5chan is een serverloze app, deze website is slechts één manier om het te openen. Om niet af te hangen van de beschikbaarheid van de website, <1>download een release.", "boards": "forums", "popular_threads": "Populaire threads", "options": "Opties", diff --git a/public/translations/no/default.json b/public/translations/no/default.json index d5fe9e88..5e1f87ee 100644 --- a/public/translations/no/default.json +++ b/public/translations/no/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "Hva er 5chan?", "go": "gå", "5chan_description": "5chan er et fullstendig desentralisert og åpen kildekode-imageboard, hvor hvem som helst kan opprette og kryptografisk eie ubegrensede boards. Board-eiere hoster og modererer sitt board og setter dets <1>Rules. Alle data er kun tekst og deles peer-to-peer, inkludert lenker for å bygge inn bilder. Sjekk <2>FAQs for å lære mer. Klikk gjerne på et board nedenfor som interesserer deg og kom i gang!", - "no_global_rules_info": "Du kan alltid koble til hvilket som helst forum, fullt P2P, ingen kan stoppe deg. Lim bare adressen i søkefeltet ovenfor.", + "no_global_rules_info": "5chan er en serverløs app, denne nettsiden er bare én måte å åpne den på. For å unngå å stole på nettsidens oppetid, <1>last ned en utgivelse.", "boards": "fora", "popular_threads": "Populære tråder", "options": "Alternativer", diff --git a/public/translations/pl/default.json b/public/translations/pl/default.json index 6dec70a7..6017fb3e 100644 --- a/public/translations/pl/default.json +++ b/public/translations/pl/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "Co to jest 5chan?", "go": "iść", "5chan_description": "5chan to w pełni zdecentralizowany i open-source imageboard, gdzie każdy może tworzyć i kryptograficznie posiadać nieograniczoną liczbę tablic. Właściciele tablic hostują i moderują swoje tablice oraz ustawiają ich <1>Rules. Wszystkie dane są wyłącznie tekstowe i udostępniane peer-to-peer, w tym linki do osadzania obrazów. Sprawdź <2>FAQs, aby dowiedzieć się więcej. Kliknij dowolną tablicę poniżej, która Cię interesuje, i dołącz!", - "no_global_rules_info": "Zawsze możesz połączyć się z dowolnym forum, w pełni P2P, nikt nie może cię zatrzymać. Po prostu wklej jego adres w polu wyszukiwania powyżej.", + "no_global_rules_info": "5chan to aplikacja bezserwerowa, ta strona to tylko jeden ze sposobów jej otwarcia. Aby nie polegać na dostępności strony, <1>pobierz wydanie.", "boards": "fora", "popular_threads": "Popularne wątki", "options": "Opcje", diff --git a/public/translations/pt/default.json b/public/translations/pt/default.json index a87b6978..054cf3a9 100644 --- a/public/translations/pt/default.json +++ b/public/translations/pt/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "O que é 5chan?", "go": "vai", "5chan_description": "5chan é um imageboard totalmente descentralizado e de código aberto, onde qualquer pessoa pode criar e possuir criptograficamente boards ilimitados. Os proprietários de boards hospedam e moderam seu board e definem suas <1>Rules. Todos os dados são apenas texto e compartilhados peer-to-peer, incluindo links para incorporar imagens. Confira as <2>FAQs para saber mais. Sinta-se à vontade para clicar em um board abaixo que te interessa e comece!", - "no_global_rules_info": "Você sempre pode se conectar a qualquer fórum, completamente P2P, ninguém pode impedi-lo. Simplesmente cole o endereço no campo de pesquisa acima.", + "no_global_rules_info": "5chan é um app sem servidor, este site é apenas uma forma de abri-lo. Para não depender da disponibilidade do site, <1>baixe uma versão.", "boards": "fóruns", "popular_threads": "Tópicos populares", "options": "Opções", diff --git a/public/translations/ro/default.json b/public/translations/ro/default.json index 84c32cc4..e4cd1ea4 100644 --- a/public/translations/ro/default.json +++ b/public/translations/ro/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "Ce este 5chan?", "go": "du-te", "5chan_description": "5chan este un imageboard complet descentralizat și open-source, unde oricine poate crea și deține criptografic panouri nelimitate. Proprietarii de panouri găzduiesc și moderază panoul lor și îi stabilesc <1>Rules. Toate datele sunt doar text și sunt partajate peer-to-peer, inclusiv linkuri pentru imagini încorporate. Consultați <2>FAQs pentru a afla mai multe. Nu ezitați să dați clic pe un panou de mai jos care vă interesează și să începeți!", - "no_global_rules_info": "Poți să te conectezi întotdeauna la orice forum, complet P2P, nimeni nu te poate opri. Pur și simplu lipește adresa în câmpul de căutare de mai sus.", + "no_global_rules_info": "5chan este o aplicație serverless, acest site este doar o modalitate de a-l deschide. Pentru a nu depinde de disponibilitatea site-ului, <1>descarcă o versiune.", "boards": "forumuri", "popular_threads": "Threaduri populare", "options": "Opțiuni", diff --git a/public/translations/ru/default.json b/public/translations/ru/default.json index df1bec3a..be09f4dc 100644 --- a/public/translations/ru/default.json +++ b/public/translations/ru/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "Что такое 5chan?", "go": "идти", "5chan_description": "5chan — это полностью децентрализованный и открытый imageboard, где любой может создавать и криптографически владеть неограниченным количеством досок. Владельцы досок размещают и модерируют свою доску и устанавливают её <1>Rules. Все данные только текстовые и распространяются peer-to-peer, включая ссылки для встраивания изображений. Ознакомьтесь с <2>FAQs, чтобы узнать больше. Нажмите на любую доску ниже, которая вас интересует, и присоединяйтесь!", - "no_global_rules_info": "Вы всегда можете подключиться к любому форуму, полностью P2P, никто не может вас остановить. Просто вставьте адрес в поле поиска выше.", + "no_global_rules_info": "5chan — это безсерверное приложение, этот сайт — лишь один способ его открыть. Чтобы не зависеть от доступности сайта, <1>скачайте релиз.", "boards": "форумы", "popular_threads": "Популярные темы", "options": "Опции", diff --git a/public/translations/sq/default.json b/public/translations/sq/default.json index f1bbe3f0..d8c5aa20 100644 --- a/public/translations/sq/default.json +++ b/public/translations/sq/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "Çfarë është 5chan?", "go": "shko", "5chan_description": "5chan është një imageboard plotësisht i decentralizuar dhe me burim të hapur, ku kushdo mund të krijojë dhe të zotërojë kriptografikisht borda të pakufizuara. Pronarët e bordave hostojnë dhe moderojnë bordin e tyre dhe vendosin <1>Rules e tij. Të gjitha të dhënat janë vetëm teksti dhe ndahen peer-to-peer, duke përfshirë lidhjet për imazhe të ngulitura. Shikoni <2>FAQs për të mësuar më shumë. Mos ngurroni të klikoni në një bord më poshtë që ju intereson dhe filloni!", - "no_global_rules_info": "Mund të lidheni gjithmonë me çdo forum, plotësisht P2P, askush nuk mund t'ju ndalojë. Thjesht ngjisni adresën në fushën e kërkimit më sipër.", + "no_global_rules_info": "5chan është një aplikacion pa server, ky faqe është vetëm një mënyrë për ta hapur. Për të shmangur varësinë nga disponueshmëria e faqes, <1>shkarkoni një version.", "boards": "forume", "popular_threads": "Threadet e njohur", "options": "Mundësitë", diff --git a/public/translations/sv/default.json b/public/translations/sv/default.json index 6cd8f83e..5fd96444 100644 --- a/public/translations/sv/default.json +++ b/public/translations/sv/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "Vad är 5chan?", "go": "gå", "5chan_description": "5chan är en helt decentraliserad och öppen källkod imageboard, där vem som helst kan skapa och kryptografiskt äga obegränsade tavlor. Tavlägare hostar och modererar sin tavla och sätter dess <1>Rules. All data är endast text och delas peer-to-peer, inklusive länkar för inbäddade bilder. Kolla in <2>FAQs för att lära dig mer. Klicka gärna på en tavla nedan som intresserar dig och börja!", - "no_global_rules_info": "Du kan alltid ansluta till vilket forum som helst, helt P2P, ingen kan stoppa dig. Klistra bara in adressen i sökfältet ovan.", + "no_global_rules_info": "5chan är en serverlös app, denna webbplats är bara ett sätt att öppna den. För att inte vara beroende av webbplatsens drifttid, <1>ladda ner en version.", "boards": "forum", "popular_threads": "Populära trådar", "options": "Alternativ", diff --git a/public/translations/te/default.json b/public/translations/te/default.json index f2c51f17..8d30a1e3 100644 --- a/public/translations/te/default.json +++ b/public/translations/te/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "5chan ఏమిటి?", "go": "వెళ్లు", "5chan_description": "5chan పూర్తిగా వికేంద్రీకృతమైన మరియు ఓపెన్-సోర్స్ ఇమేజ్ బోర్డ్, ఇక్కడ ఎవరైనా అపరిమిత బోర్డ్లను సృష్టించవచ్చు మరియు క్రిప్టోగ్రాఫికల్గా స్వంతం చేసుకోవచ్చు. బోర్డ్ యజమానులు తమ బోర్డ్ను హోస్ట్ చేసి మోడరేట్ చేస్తారు మరియు దాని <1>Rules సెట్ చేస్తారు. అన్ని డేటా టెక్స్ట్ మాత్రమే మరియు ఎంబెడ్ చిత్రాల లింక్లతో సహా పీయర్-టు-పీయర్ షేర్ చేయబడుతుంది. మరింత తెలుసుకోవడానికి <2>FAQs చూడండి. మీకు ఆసక్తి ఉన్న క్రింది బోర్డ్పై క్లిక్ చేయడానికి సంకోచించకండి మరియు ప్రారంభించండి!", - "no_global_rules_info": "మీరు ఎప్పుడైనా ఏదైనా బోర్డ్‌కు కనెక్ట్ అవ్వవచ్చు, పూర్తిగా P2P, మిమ్మల్ని ఎవరూ ఆపలేరు. దాని చిరునామాను పైన ఉన్న శోధన ఫీల్డ్‌లో అతికించండి.", + "no_global_rules_info": "5chan ఒక సర్వర్‌లెస్ యాప్, ఈ వెబ్‌సైట్ దాన్ని తెరవడానికి ఒక మార్గం మాత్రమే. వెబ్‌సైట్ అప్‌టైమ్‌పై ఆధారపడకుండా ఉండడానికి <1>రిలీజ్ డౌన్‌లోడ్ చేయండి.", "boards": "సందేశ బోర్డులు", "popular_threads": "జనప్రియమైన థ్రెడ్‌లు", "options": "ఎంపికలు", diff --git a/public/translations/th/default.json b/public/translations/th/default.json index e1da89fe..ee5e34f2 100644 --- a/public/translations/th/default.json +++ b/public/translations/th/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "5chan คืออะไร?", "go": "ไป", "5chan_description": "5chan เป็น imageboard ที่กระจายอำนาจอย่างสมบูรณ์และโอเพนซอร์ส ที่ใครก็ตามสามารถสร้างและเป็นเจ้าของบอร์ดได้ไม่จำกัดด้วยการเข้ารหัส เจ้าของบอร์ดโฮสต์และดูแลบอร์ดของตน และตั้งค่า <1>Rules ข้อมูลทั้งหมดเป็นข้อความเท่านั้นและแชร์แบบ peer-to-peer รวมถึงลิงก์สำหรับฝังรูปภาพ ดู <2>FAQs เพื่อเรียนรู้เพิ่มเติม คลิกบอร์ดด้านล่างที่คุณสนใจและเริ่มต้นได้เลย!", - "no_global_rules_info": "คุณสามารถเชื่อมต่อกับบอร์ดใดก็ได้เสมอ แบบ P2P เต็มรูปแบบ ไม่มีใครสามารถหยุดคุณได้ เพียงแค่วางที่อยู่ของมันในช่องค้นหาด้านบน", + "no_global_rules_info": "5chan เป็นแอปแบบ serverless เว็บไซต์นี้เป็นเพียงวิธีหนึ่งในการเปิดใช้งาน เพื่อไม่ให้พึ่งพาเวลาทำงานของเว็บไซต์ <1>ดาวน์โหลดเวอร์ชัน", "boards": "กระดานข่าว", "popular_threads": "กระทู้ยอดนิยม", "options": "ตัวเลือก", diff --git a/public/translations/tr/default.json b/public/translations/tr/default.json index f252c7c2..d7e3f91d 100644 --- a/public/translations/tr/default.json +++ b/public/translations/tr/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "5chan nedir?", "go": "git", "5chan_description": "5chan, herkesin sınırsız board oluşturabileceği ve kriptografik olarak sahip olabileceği tamamen merkeziyetsiz ve açık kaynaklı bir imageboard'dur. Board sahipleri boardlarını barındırır ve moderatörlük yapar, <1>Rules ayarlar. Tüm veriler yalnızca metindir ve gömülü resimlerin bağlantıları dahil peer-to-peer paylaşılır. Daha fazla bilgi için <2>FAQs bölümüne bakın. İlginizi çeken bir boarda tıklayıp hemen başlayın!", - "no_global_rules_info": "Her zaman herhangi bir foruma bağlanabilirsiniz, tamamen P2P, kimse sizi durduramaz. Adresini yukarıdaki arama alanına yapıştırmanız yeterli.", + "no_global_rules_info": "5chan sunucusuz bir uygulamadır, bu web sitesi onu açmanın sadece bir yoludur. Web sitesinin çalışma süresine güvenmemek için <1>bir sürüm indirin.", "boards": "forumlar", "popular_threads": "Popüler konular", "options": "Seçenekler", diff --git a/public/translations/uk/default.json b/public/translations/uk/default.json index 4251b3f5..37c6ce3d 100644 --- a/public/translations/uk/default.json +++ b/public/translations/uk/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "Що таке 5chan?", "go": "йди", "5chan_description": "5chan — це повністю децентралізований та відкритий imageboard, де будь-хто може створювати та криптографічно володіти необмеженою кількістю дошок. Власники дошок хостують та модерують свою дошку і встановлюють її <1>Rules. Усі дані лише текстові і поширюються peer-to-peer, включно з посиланнями для вбудовування зображень. Перегляньте <2>FAQs, щоб дізнатися більше. Натисніть на будь-яку дошку нижче, що вас цікавить, і приєднуйтесь!", - "no_global_rules_info": "Ви завжди можете підключитися до будь-якого форуму, повністю P2P, ніхто не може вас зупинити. Просто вставте його адресу в поле пошуку вище.", + "no_global_rules_info": "5chan — це безсерверний додаток, цей сайт — лише один із способів його відкрити. Щоб не залежати від доступності сайту, <1>завантажте реліз.", "boards": "форуми", "popular_threads": "Популярні теми", "options": "Опції", diff --git a/public/translations/ur/default.json b/public/translations/ur/default.json index bf8fe960..73ef545a 100644 --- a/public/translations/ur/default.json +++ b/public/translations/ur/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "5chan کیا ہے؟", "go": "جا", "5chan_description": "5chan ایک مکمل طور پر غیر مرکزیت اور اوپن سورس imageboard ہے، جہاں کوئی بھی لامحدود بورڈز بنا سکتا ہے اور cryptographically ان کا مالک ہو سکتا ہے۔ بورڈ مالکان اپنے بورڈ کو ہوسٹ اور moderate کرتے ہیں اور اس کے <1>Rules سیٹ کرتے ہیں۔ تمام ڈیٹا صرف ٹیکسٹ ہے اور peer-to-peer شیئر کیا جاتا ہے، بشمول embed images کے لنکس۔ مزید جاننے کے لیے <2>FAQs دیکھیں۔ نیچے کسی بھی بورڈ پر کلک کریں جو آپ کو دلچسپ لگے اور شروع کریں!", - "no_global_rules_info": "آپ ہمیشہ کسی بھی بورڈ سے کنیکٹ ہو سکتے ہیں، مکمل طور پر P2P، کوئی آپ کو روک نہیں سکتا۔ بس اس کا پتہ اوپر والے سرچ فیلڈ میں پیسٹ کریں۔", + "no_global_rules_info": "5chan ایک سرور لیس ایپ ہے، یہ ویب سائٹ اسے کھولنے کا صرف ایک طریقہ ہے۔ ویب سائٹ کی اپ ٹائم پر انحصار سے بچنے کے لیے <1>ایک ریلیز ڈاؤن لوڈ کریں۔", "boards": "پیغام بورڈز", "popular_threads": "مقبول تھریڈز", "options": "اختیارات", diff --git a/public/translations/vi/default.json b/public/translations/vi/default.json index c8327b13..4e81d225 100644 --- a/public/translations/vi/default.json +++ b/public/translations/vi/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "5chan là gì?", "go": "đi", "5chan_description": "5chan là một imageboard hoàn toàn phi tập trung và mã nguồn mở, nơi bất kỳ ai cũng có thể tạo và sở hữu mật mã vô hạn các bảng. Chủ sở hữu bảng lưu trữ và kiểm duyệt bảng của họ và cài đặt <1>Rules. Tất cả dữ liệu chỉ là văn bản và được chia sẻ peer-to-peer, bao gồm cả liên kết nhúng hình ảnh. Xem <2>FAQs để tìm hiểu thêm. Hãy nhấp vào bất kỳ bảng nào bên dưới mà bạn quan tâm và bắt đầu!", - "no_global_rules_info": "Bạn luôn có thể kết nối với bất kỳ diễn đàn nào, hoàn toàn P2P, không ai có thể ngăn cản bạn. Chỉ cần dán địa chỉ của nó vào trường tìm kiếm ở trên.", + "no_global_rules_info": "5chan là ứng dụng không cần máy chủ, trang web này chỉ là một cách để mở nó. Để không phụ thuộc vào thời gian hoạt động của trang web, <1>tải xuống phiên bản.", "boards": "diễn đàn", "popular_threads": "Chủ đề phổ biến", "options": "Tùy chọn", diff --git a/public/translations/zh/default.json b/public/translations/zh/default.json index c167878b..b8f8c2ca 100644 --- a/public/translations/zh/default.json +++ b/public/translations/zh/default.json @@ -2,7 +2,7 @@ "what_is_5chan": "5chan是什么?", "go": "去", "5chan_description": "5chan 是一个完全去中心化和开源的图像板,任何人都可以创建并以加密方式拥有无限数量的板块。板块所有者托管和审核其板块,并设置其 <1>Rules。所有数据均为纯文本,且以点对点方式共享,包括嵌入图片的链接。查看 <2>FAQs 了解更多。欢迎点击下方任意感兴趣的板块,立即开始!", - "no_global_rules_info": "您始终可以连接到任何论坛,完全P2P,没有人能阻止您。只需将其地址粘贴到上面的搜索字段中即可。", + "no_global_rules_info": "5chan 是无服务器应用,本网站只是打开它的方式之一。为避免依赖网站可用性,请<1>下载发行版。", "boards": "论坛", "popular_threads": "热门主题", "options": "选项", diff --git a/scripts/start-dev.js b/scripts/start-dev.js new file mode 100644 index 00000000..7e582853 --- /dev/null +++ b/scripts/start-dev.js @@ -0,0 +1,35 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { spawn } from 'node:child_process'; + +const isWindows = process.platform === 'win32'; +const usePortless = process.env.PORTLESS !== '0' && !isWindows; +const binDir = join(process.cwd(), 'node_modules', '.bin'); +const executableSuffix = isWindows ? '.cmd' : ''; +const portlessBin = join(binDir, `portless${executableSuffix}`); +const viteBin = join(binDir, `vite${executableSuffix}`); + +const command = usePortless && existsSync(portlessBin) ? portlessBin : viteBin; +const args = + command === portlessBin + ? ['5chan', 'vite'] + : ['--host', '5chan.localhost', '--port', '1355', '--strictPort']; + +if (command !== portlessBin && process.env.PORTLESS !== '0') { + console.warn('portless unavailable on this platform, using vite directly on http://5chan.localhost:1355'); +} + +const child = spawn(command, args, { + stdio: 'inherit', + env: process.env, +}); + +child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + + process.exit(code ?? 0); +}); + diff --git a/src/components/comment-content/comment-content.tsx b/src/components/comment-content/comment-content.tsx index e0456521..760d3245 100644 --- a/src/components/comment-content/comment-content.tsx +++ b/src/components/comment-content/comment-content.tsx @@ -104,11 +104,13 @@ const CommentContent = ({ comment: post }: { comment: Comment }) => { if (!quotedCids?.length) return []; return quotedCids.filter((cid: string) => { const num = cidToNumber[cid]; - return num === undefined || !contentNumbers.has(num); + if (num === undefined) return false; + return !contentNumbers.has(num); }); }, [quotedCids, cidToNumber, contentNumbers]); - const shouldShowReplyingToReply = isReplyingToReply && (parentCid ? !contentNumbers.has(cidToNumber[parentCid] ?? -1) : true); + const parentNumber = parentCid ? cidToNumber[parentCid] : undefined; + const shouldShowReplyingToReply = isReplyingToReply && parentNumber !== undefined && !contentNumbers.has(parentNumber); const stateString = useStateString(post); const hasFailedState = state === 'failed'; diff --git a/src/components/comment-media/comment-media.tsx b/src/components/comment-media/comment-media.tsx index 604c4910..5ec52d13 100644 --- a/src/components/comment-media/comment-media.tsx +++ b/src/components/comment-media/comment-media.tsx @@ -391,7 +391,7 @@ const CommentMedia = ({ const isMobile = useIsMobile(); const { thumbnailHeight, thumbnailWidth, url } = commentMediaInfo || {}; let type = commentMediaInfo?.type; - const gifFrameUrl = useFetchGifFirstFrame(url); + const gifFrameUrl = useFetchGifFirstFrame(type === 'gif' ? url : undefined); if (type === 'gif' && gifFrameUrl) { type = 'animated gif'; diff --git a/src/components/markdown/markdown.module.css b/src/components/markdown/markdown.module.css index 9b61e010..8b62e126 100644 --- a/src/components/markdown/markdown.module.css +++ b/src/components/markdown/markdown.module.css @@ -18,25 +18,6 @@ text-decoration: var(--post-quotelink-text-decoration-hover); } -.hrWrapper { - padding: 0.5em 0; -} - -.hrWrapper hr { - margin: 0; -} - -.markdown hr { - all: revert; - border: none; - border-top: 1px solid var(--body-font-color); -} - -.markdown ol, .markdown ul { - padding-left: 1em; - display: inline-block; -} - .embedButton { color: var(--button-desktop-text-color); text-transform: capitalize; diff --git a/src/components/markdown/markdown.tsx b/src/components/markdown/markdown.tsx index 3a920426..809563d1 100644 --- a/src/components/markdown/markdown.tsx +++ b/src/components/markdown/markdown.tsx @@ -1,11 +1,6 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import type { Placement } from '@floating-ui/react'; import { useTranslation } from 'react-i18next'; -import ReactMarkdown, { Components } from 'react-markdown'; -import remarkGfm from 'remark-gfm'; -import supersub from 'remark-supersub'; -import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; -import rehypeRaw from 'rehype-raw'; import { useDismiss, useFloating, useFocus, useHover, useInteractions, offset, shift, size, autoUpdate, FloatingPortal } from '@floating-ui/react'; import { getLinkMediaInfo, getHasThumbnail } from '../../lib/utils/media-utils'; import { isCatalogView } from '../../lib/utils/view-utils'; @@ -14,7 +9,7 @@ import CommentMedia from '../comment-media'; import styles from './markdown.module.css'; import { Link, useLocation, useParams } from 'react-router-dom'; import { canEmbed } from '../embed'; -import { is5chanLink, transform5chanLinkToInternal, preprocess5chanPatterns } from '../../lib/utils/url-utils'; +import { is5chanLink, transform5chanLinkToInternal, isValidCrossboardPattern } from '../../lib/utils/url-utils'; import usePostNumberStore from '../../stores/use-post-number-store'; import useSubplebbitsPagesStore from '@bitsocialhq/pkc-react-hooks/dist/stores/subplebbits-pages'; import { useComment } from '@bitsocialhq/pkc-react-hooks'; @@ -34,10 +29,6 @@ interface ContentLinkEmbedProps { linkMediaInfo: any; } -interface ExtendedComponents extends Components { - spoiler: React.ComponentType<{ children: React.ReactNode }>; -} - const ContentLinkEmbed = ({ children, href, linkMediaInfo }: ContentLinkEmbedProps) => { const { t } = useTranslation(); const isMobile = useIsMobile(); @@ -139,48 +130,137 @@ const ContentLinkEmbed = ({ children, href, linkMediaInfo }: ContentLinkEmbedPro ); }; -const MAX_LENGTH_FOR_GFM = 10000; // remarkGfm lags with large content +const normalizeContent = (content: string): string => { + if (!content) return ''; + let normalized = content.replace(/\n \n/g, '\n\n'); + normalized = normalized.replace(/\n{3,}/g, '\n\n'); + return normalized; +}; -const blockquoteToGreentext = () => (tree: any) => { - tree.children.forEach((node: any) => { - if (node.type === 'blockquote') { - node.children.forEach((child: any) => { - if (child.type === 'paragraph' && child.children.length > 0) { - const prefix = { - type: 'text', - value: '>', - }; - child.children.unshift(prefix); +type Token = + | { type: 'text'; value: string } + | { type: 'url'; href: string } + | { type: 'quoteLink'; number: number } + | { type: 'crossBoardLink'; display: string; route: string } + | { type: 'spoiler'; tokens: Token[] }; + +const SPOILER_REGEX = /([\s\S]*?)<\/spoiler>/; +const CROSSBOARD_REGEX = />>>\/((?:[a-zA-Z0-9]{1,10}\/(?:[a-zA-Z0-9]{46})?|[a-zA-Z0-9\-.]+(?:\/[a-zA-Z0-9]{46})?))[.,:;!?]*/; +const QUOTE_LINK_REGEX = /(?/\w])>>(\d+)(?![\d/])/; +const URL_REGEX = /https?:\/\/[^\s<\[\]]*[^\s<\[\].,;:!?\"'\)\]>]/; + +const COMBINED_REGEX = new RegExp(`(${SPOILER_REGEX.source})|(${CROSSBOARD_REGEX.source})|(${QUOTE_LINK_REGEX.source})|(${URL_REGEX.source})`, 'g'); + +function getCrossboardRoute(fullPattern: string): string | null { + const pathPart = fullPattern.replace(/^>>>\//, '').replace(/[.,:;!?]+$/, ''); + if (!isValidCrossboardPattern(`>>>/${pathPart}`)) { + return null; + } + if (/^[a-zA-Z0-9]{1,10}\/$/.test(pathPart)) { + return `/${pathPart.slice(0, -1)}`; + } + if (/^[a-zA-Z0-9]{1,10}\/[a-zA-Z0-9]{46}$/.test(pathPart)) { + const [code, cid] = pathPart.split('/'); + return `/${code}/thread/${cid}`; + } + if (/^[^/]+\/[a-zA-Z0-9]{46}$/.test(pathPart)) { + const [address, cid] = pathPart.split('/'); + return `/${address}/thread/${cid}`; + } + return `/${pathPart}`; +} + +function tokenize(text: string): Token[] { + const tokens: Token[] = []; + let lastIndex = 0; + + const regex = new RegExp(COMBINED_REGEX.source, 'g'); + let match: RegExpExecArray | null; + + while ((match = regex.exec(text)) !== null) { + const fullMatch = match[0]; + const matchStart = match.index; + + if (matchStart > lastIndex) { + tokens.push({ type: 'text', value: text.slice(lastIndex, matchStart) }); + } + + if (match[1] !== undefined) { + const innerContent = match[2]; + tokens.push({ type: 'spoiler', tokens: tokenize(innerContent) }); + } else if (match[3] !== undefined) { + const pathPart = match[4]; + const fullPattern = `>>>/${pathPart}`; + const route = getCrossboardRoute(fullPattern); + if (route) { + tokens.push({ type: 'crossBoardLink', display: fullPattern, route }); + } else { + tokens.push({ type: 'text', value: fullMatch }); + } + } else if (match[5] !== undefined) { + const number = parseInt(match[6], 10); + tokens.push({ type: 'quoteLink', number }); + } else if (match[7] !== undefined) { + tokens.push({ type: 'url', href: fullMatch }); + } + + lastIndex = regex.lastIndex; + } + + if (lastIndex < text.length) { + tokens.push({ type: 'text', value: text.slice(lastIndex) }); + } + + return tokens; +} + +interface RenderContext { + isInCatalogView: boolean; + postCid?: string; + subplebbitAddress?: string; +} + +function renderTokens(tokens: Token[], context: RenderContext): React.ReactNode[] { + const { isInCatalogView, postCid, subplebbitAddress } = context; + + return tokens.map((token, i) => { + switch (token.type) { + case 'text': + return {token.value}; + case 'url': { + const href = token.href; + const linkMediaInfo = getLinkMediaInfo(href); + const embedUrl = safeParseUrl(href); + if (!isInCatalogView && ((embedUrl && canEmbed(embedUrl)) || getHasThumbnail(linkMediaInfo, href))) { + return ( + + {href} + + ); } - }); - node.type = 'div'; - node.data = { - hName: 'div', - hProperties: { - className: 'greentext', - }, - }; + return {renderAnchorLink(href, href, postCid, subplebbitAddress)}; + } + case 'quoteLink': + return ( + + + + ); + case 'crossBoardLink': + return ( + + {token.display} + + ); + case 'spoiler': + return ( + + {renderTokens(token.tokens, context)} + + ); } }); -}; - -const spoilerTransform = () => (tree: any) => { - const visit = (node: any) => { - if (node.tagName === 'spoiler') { - node.tagName = 'span'; - node.properties = node.properties || {}; - node.properties.className = 'spoilertext'; - } - - if (node.children) { - node.children.forEach(visit); - } - }; - - if (tree.children) { - tree.children.forEach(visit); - } -}; +} interface MarkdownProps { content: string; @@ -189,8 +269,6 @@ interface MarkdownProps { subplebbitAddress?: string; } -const NUMBER_QUOTE_HREF_REGEX = /^#q-(\d+)$/; - const NumberQuoteLink = ({ number, threadPostCid, subplebbitAddress }: { number: number; threadPostCid?: string; subplebbitAddress?: string }) => { const cid = usePostNumberStore((state) => (subplebbitAddress ? state.numberToCid[subplebbitAddress]?.[number] : undefined)); const commentFromStore = useSubplebbitsPagesStore((state) => (cid ? state.comments[cid] : undefined)); @@ -210,21 +288,9 @@ const renderAnchorLink = (children: React.ReactNode, href: string, threadPostCid return {children}; } - const numberQuoteMatch = href.match(NUMBER_QUOTE_HREF_REGEX); - if (numberQuoteMatch) { - const number = parseInt(numberQuoteMatch[1], 10); - return ( - - - - ); - } - - // Check if this is a valid 5chan link that should be handled internally if (is5chanLink(href)) { const internalPath = transform5chanLinkToInternal(href); if (internalPath) { - // Check if the link text should be replaced with the internal path let shouldReplaceText = false; if (typeof children === 'string') { @@ -238,7 +304,7 @@ const renderAnchorLink = (children: React.ReactNode, href: string, threadPostCid const isAutolinkedUrl = shouldReplaceText && typeof childrenText === 'string' && childrenText.startsWith('http'); if (isAutolinkedUrl) { - // Keep the full URL as display text for autolinked URLs (e.g. https://5chan.app/pass → "https://5chan.app/pass") + displayText = children; } else if (shouldReplaceText && internalPath.match(/^\/[^/]+$/)) { displayText = internalPath.substring(1); } else if (shouldReplaceText) { @@ -252,8 +318,6 @@ const renderAnchorLink = (children: React.ReactNode, href: string, threadPostCid } } - // Handle hash routes and internal patterns (including routes that start with /#/) - // Support both old format (/p/...) for backward compatibility and new format (/{boardIdentifier}/...) if ( href.startsWith('#/') || href.startsWith('/#/') || @@ -265,7 +329,6 @@ const renderAnchorLink = (children: React.ReactNode, href: string, threadPostCid return {children}; } - // External links return ( {children} @@ -274,83 +337,40 @@ const renderAnchorLink = (children: React.ReactNode, href: string, threadPostCid }; const Markdown = ({ content, title, postCid, subplebbitAddress }: MarkdownProps) => { - const remarkPlugins = useMemo(() => { - const plugins: any[] = [[supersub]]; - - if (content && content.length <= MAX_LENGTH_FOR_GFM) { - plugins.push([remarkGfm, { singleTilde: false }]); - } - - plugins.push([blockquoteToGreentext]); - plugins.push([spoilerTransform]); - - return plugins; - }, [content]); - - const customSchema = useMemo( - () => ({ - ...defaultSchema, - tagNames: [...(defaultSchema.tagNames || []), 'div', 'span', 'spoiler'], - attributes: { - ...defaultSchema.attributes, - div: ['className'], - span: ['className'], - spoiler: [], - }, - }), - [], - ); - const location = useLocation(); const params = useParams(); const isInCatalogView = isCatalogView(location.pathname, params); - const rehypePlugins = useMemo(() => [[rehypeRaw as any], [rehypeSanitize, customSchema]] as any[], [customSchema]); + const rendered = useMemo(() => { + const normalized = normalizeContent(content || ''); + const lines = normalized.split('\n'); + const elements: React.ReactNode[] = []; - // Preprocess content to convert plain text 5chan patterns to markdown links - const processedContent = useMemo(() => preprocess5chanPatterns(content || ''), [content]); + lines.forEach((line, lineIndex) => { + if (lineIndex > 0) { + elements.push(
); + } - const components = useMemo( - () => - ({ - p: ({ children }) =>

{children}

, - h1: ({ children }) =>

{children}

, - h2: ({ children }) =>

{children}

, - h3: ({ children }) =>

{children}

, - h4: ({ children }) =>

{children}

, - h5: ({ children }) =>

{children}

, - h6: ({ children }) =>

{children}

, - img: ({ src, alt }) => { - const displayText = src || alt || 'image'; - return {displayText}; - }, - video: ({ src }) => {src}, - iframe: ({ src }) => {src}, - source: ({ src }) => {src}, - spoiler: ({ children }) => {children}, - a: ({ href, children }) => { - if (href && !isInCatalogView) { - const linkMediaInfo = getLinkMediaInfo(href); - const embedUrl = safeParseUrl(href); - if ((embedUrl && canEmbed(embedUrl)) || getHasThumbnail(linkMediaInfo, href)) { - return ( - - {children} - - ); - } - if (!embedUrl && href.startsWith('http')) { - console.debug('Invalid URL:', href); - } + if (line.length === 0) return; - return renderAnchorLink(children, href, postCid, subplebbitAddress); - } + const isGreentext = /^>[^>]/.test(line) || line === '>'; - return renderAnchorLink(children, href || '', postCid, subplebbitAddress); - }, - }) as ExtendedComponents, - [isInCatalogView, postCid, subplebbitAddress], - ); + const tokens = tokenize(line); + const lineElements = renderTokens(tokens, { isInCatalogView, postCid, subplebbitAddress }); + + if (isGreentext) { + elements.push( + + {lineElements} + , + ); + } else { + elements.push({lineElements}); + } + }); + + return elements; + }, [content, isInCatalogView, postCid, subplebbitAddress]); return ( @@ -360,9 +380,7 @@ const Markdown = ({ content, title, postCid, subplebbitAddress }: MarkdownProps) {content ? ': ' : ''} )} - - {processedContent} - + {rendered} ); }; diff --git a/src/components/post-desktop/post-desktop.tsx b/src/components/post-desktop/post-desktop.tsx index 2b3cee36..2169aafe 100644 --- a/src/components/post-desktop/post-desktop.tsx +++ b/src/components/post-desktop/post-desktop.tsx @@ -543,7 +543,7 @@ const PostMedia = ({ const { t } = useTranslation(); const { url } = commentMediaInfo || {}; let type = commentMediaInfo?.type; - const gifFrameUrl = useFetchGifFirstFrame(url); + const gifFrameUrl = useFetchGifFirstFrame(type === 'gif' ? url : undefined); const directories = useDirectories(); if (type === 'gif' && gifFrameUrl !== null) { diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx index 493c8b1a..4dea216f 100644 --- a/src/components/post-form/post-form.tsx +++ b/src/components/post-form/post-form.tsx @@ -6,7 +6,6 @@ import getShortAddress from '../../lib/get-short-address'; import useSubplebbitsStore from '@bitsocialhq/pkc-react-hooks/dist/stores/subplebbits'; import useSubplebbitsPagesStore from '@bitsocialhq/pkc-react-hooks/dist/stores/subplebbits-pages'; import { getLinkMediaInfo } from '../../lib/utils/media-utils'; -import { formatMarkdown } from '../../lib/utils/post-utils'; import { isValidURL } from '../../lib/utils/url-utils'; import { isAllView, isCatalogView, isModQueueView, isModView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils'; import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories'; @@ -39,7 +38,7 @@ export const LinkTypePreviewer = ({ link }: { link: string }) => { const { t } = useTranslation(); const mediaInfo = getLinkMediaInfo(link); let type = mediaInfo?.type; - const gifFrameUrl = useFetchGifFirstFrame(mediaInfo?.url); + const gifFrameUrl = useFetchGifFirstFrame(type === 'gif' ? mediaInfo?.url : undefined); if (type === 'gif' && gifFrameUrl !== null) { type = t('animated_gif'); @@ -401,9 +400,9 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: const { setPublishReplyOptions, resetPublishReplyOptions, replyIndex, publishReply } = usePublishReply({ cid, subplebbitAddress }); const handleContentChange = (e: React.ChangeEvent) => { - const formattedContent = formatMarkdown(e.target.value); - isInPostView ? setPublishReplyOptions({ content: formattedContent }) : setPublishPostOptions({ content: formattedContent }); - checkContentLength(formattedContent, t); + const content = e.target.value; + isInPostView ? setPublishReplyOptions({ content }) : setPublishPostOptions({ content }); + checkContentLength(content, t); }; const onPublishReply = () => { diff --git a/src/components/reply-modal/reply-modal.tsx b/src/components/reply-modal/reply-modal.tsx index 34057c90..3a0ca880 100644 --- a/src/components/reply-modal/reply-modal.tsx +++ b/src/components/reply-modal/reply-modal.tsx @@ -3,7 +3,6 @@ import { useLocation, useParams } from 'react-router-dom'; import { Trans, useTranslation } from 'react-i18next'; import { setAccount, useAccount } from '@bitsocialhq/pkc-react-hooks'; import { useSubplebbitField } from '../../hooks/use-stable-subplebbit'; -import { formatMarkdown } from '../../lib/utils/post-utils'; import { getFormattedTimeAgo } from '../../lib/utils/time-utils'; import { isValidURL } from '../../lib/utils/url-utils'; import { isAllView, isSubscriptionsView } from '../../lib/utils/view-utils'; @@ -203,9 +202,9 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa const len = textRef.current.value.length; lastSelectionStartRef.current = len; lastSelectionEndRef.current = len; - const formattedContent = formatMarkdown(textRef.current.value); - setPublishReplyOptions({ content: formattedContent }); - checkContentLengthRef.current(formattedContent, t); + const content = textRef.current.value; + setPublishReplyOptions({ content }); + checkContentLengthRef.current(content, t); setTimeout(() => { if (textRef.current) { @@ -221,9 +220,9 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa }; const handleContentChange = (e: React.ChangeEvent) => { - const formattedContent = formatMarkdown(e.target.value); - setPublishReplyOptions({ content: formattedContent }); - checkContentLengthRef.current(formattedContent, t); + const content = e.target.value; + setPublishReplyOptions({ content }); + checkContentLengthRef.current(content, t); }; useEffect(() => { @@ -265,9 +264,8 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa lastSelectionStartRef.current = nextCursor; lastSelectionEndRef.current = nextCursor; - const formattedContent = formatMarkdown(nextValue); - setPublishReplyOptions({ content: formattedContent }); - checkContentLengthRef.current(formattedContent, t); + setPublishReplyOptions({ content: nextValue }); + checkContentLengthRef.current(nextValue, t); }, [showReplyModal, quoteInsertRequestId, quoteInsertNumber, quoteInsertSelectedText, setPublishReplyOptions, t]); const { isUploading, uploadedFileName, handleUpload } = useFileUpload({ diff --git a/src/components/settings-modal/account-settings/account-settings.tsx b/src/components/settings-modal/account-settings/account-settings.tsx index 11cd787e..558443b1 100644 --- a/src/components/settings-modal/account-settings/account-settings.tsx +++ b/src/components/settings-modal/account-settings/account-settings.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { deleteAccount, exportAccount, importAccount, setActiveAccount, useAccount, useAccounts } from '@bitsocialhq/pkc-react-hooks'; import styles from './account-settings.module.css'; @@ -189,4 +190,4 @@ const AccountSettings = () => { return ; }; -export default AccountSettings; +export default memo(AccountSettings); diff --git a/src/components/settings-modal/advanced-settings/advanced-settings.tsx b/src/components/settings-modal/advanced-settings/advanced-settings.tsx index dd2068ec..c225b6f0 100644 --- a/src/components/settings-modal/advanced-settings/advanced-settings.tsx +++ b/src/components/settings-modal/advanced-settings/advanced-settings.tsx @@ -1,4 +1,4 @@ -import { RefObject, useRef, useState } from 'react'; +import { memo, RefObject, useRef, useState } from 'react'; import { setAccount, useAccount, usePlebbitRpcSettings } from '@bitsocialhq/pkc-react-hooks'; import { useTranslation } from 'react-i18next'; import styles from './advanced-settings.module.css'; @@ -309,4 +309,4 @@ const AdvancedSettings = () => { ); }; -export default AdvancedSettings; +export default memo(AdvancedSettings); diff --git a/src/components/settings-modal/crypto-address-setting/crypto-address-setting.tsx b/src/components/settings-modal/crypto-address-setting/crypto-address-setting.tsx index 61d48f9f..c32b1a41 100644 --- a/src/components/settings-modal/crypto-address-setting/crypto-address-setting.tsx +++ b/src/components/settings-modal/crypto-address-setting/crypto-address-setting.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { memo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useAccount, setAccount, useResolvedAuthorAddress } from '@bitsocialhq/pkc-react-hooks'; import styles from './crypto-address-setting.module.css'; @@ -25,13 +25,15 @@ const CryptoAddressSetting = () => { }); const [savedCryptoAddress, setSavedCryptoAddress] = useState(false); + const [shouldResolve, setShouldResolve] = useState(false); - const author = { ...account?.author, address: cryptoState.cryptoAddress }; - const { resolvedAddress, state, error, chainProvider } = useResolvedAuthorAddress({ author, cache: false }); + const authorToResolve = shouldResolve ? { ...account?.author, address: cryptoState.cryptoAddress } : undefined; + const { resolvedAddress, state, error, chainProvider } = useResolvedAuthorAddress({ author: authorToResolve, cache: false }); const [inputValue, setInputValue] = useState(account?.author?.shortAddress.includes('.') ? account.author.shortAddress : ''); const checkCryptoAddress = () => { + setShouldResolve(true); const addressToCheck = inputValue || cryptoState.cryptoAddress; if (!addressToCheck || !addressToCheck.includes('.')) { alert(t('enter_crypto_address')); @@ -96,6 +98,7 @@ const CryptoAddressSetting = () => { }, ); if (result !== undefined) { + setShouldResolve(false); setSavedCryptoAddress(true); setTimeout(() => setSavedCryptoAddress(false), 2000); setCryptoState((prevState) => ({ @@ -179,4 +182,4 @@ const CryptoAddressSetting = () => { ); }; -export default CryptoAddressSetting; +export default memo(CryptoAddressSetting); diff --git a/src/components/settings-modal/crypto-wallets-setting/crypto-wallets-setting.tsx b/src/components/settings-modal/crypto-wallets-setting/crypto-wallets-setting.tsx index 6cf4baa4..5c9fbc52 100644 --- a/src/components/settings-modal/crypto-wallets-setting/crypto-wallets-setting.tsx +++ b/src/components/settings-modal/crypto-wallets-setting/crypto-wallets-setting.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { memo, useState } from 'react'; import { Account, setAccount, useAccount } from '@bitsocialhq/pkc-react-hooks'; import styles from './crypto-wallets-setting.module.css'; import { Trans, useTranslation } from 'react-i18next'; @@ -200,4 +200,4 @@ const CryptoWalletsSetting = () => { ); }; -export default CryptoWalletsSetting; +export default memo(CryptoWalletsSetting); diff --git a/src/components/settings-modal/interface-settings/interface-settings.tsx b/src/components/settings-modal/interface-settings/interface-settings.tsx index e497a364..43022e4d 100644 --- a/src/components/settings-modal/interface-settings/interface-settings.tsx +++ b/src/components/settings-modal/interface-settings/interface-settings.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { memo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import packageJson from '../../../../package.json'; import styles from './interface-settings.module.css'; @@ -41,7 +41,7 @@ const fetchLatestVersionInfo = async (t: (key: string, opts?: Record { ); }; -export default InterfaceSettings; +export default memo(InterfaceSettings); diff --git a/src/components/settings-modal/media-hosting-settings/media-hosting-settings.tsx b/src/components/settings-modal/media-hosting-settings/media-hosting-settings.tsx index b7d22e71..13bbdab0 100644 --- a/src/components/settings-modal/media-hosting-settings/media-hosting-settings.tsx +++ b/src/components/settings-modal/media-hosting-settings/media-hosting-settings.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import useMediaHostingStore, { MEDIA_HOSTING_PROVIDERS } from '../../../stores/use-media-hosting-store'; import type { ProviderId } from '../../../lib/media-hosting/types'; @@ -73,4 +74,4 @@ const MediaHostingSettings = () => { ); }; -export default MediaHostingSettings; +export default memo(MediaHostingSettings); diff --git a/src/components/settings-modal/settings-modal.tsx b/src/components/settings-modal/settings-modal.tsx index 0ea59a8e..17c176cc 100644 --- a/src/components/settings-modal/settings-modal.tsx +++ b/src/components/settings-modal/settings-modal.tsx @@ -65,24 +65,23 @@ const SettingsModal = () => { }, [hash]); // eslint-disable-line react-hooks/exhaustive-deps const handleCategoryClick = (categoryId: string) => { - setExpandedSections((prev) => { - const next = new Set(prev); - const isOpening = !next.has(categoryId); - if (isOpening) { - next.add(categoryId); - } else { - next.delete(categoryId); - } - if (isOpening) { - navigate(`${basePath}#${categoryId}`, { replace: true }); - } else if (next.size === 1) { - const remaining = next.values().next().value; - navigate(`${basePath}#${remaining}`, { replace: true }); - } else { - navigate(basePath, { replace: true }); - } - return next; - }); + const isOpening = !expandedSections.has(categoryId); + const next = new Set(expandedSections); + if (isOpening) { + next.add(categoryId); + } else { + next.delete(categoryId); + } + setExpandedSections(next); + + if (isOpening) { + navigate(`${basePath}#${categoryId}`, { replace: true }); + } else if (next.size === 1) { + const remaining = next.values().next().value; + navigate(`${basePath}#${remaining}`, { replace: true }); + } else { + navigate(basePath, { replace: true }); + } }; const handleExpandAll = () => { diff --git a/src/components/settings-modal/subscriptions-setting/subscriptions-setting.tsx b/src/components/settings-modal/subscriptions-setting/subscriptions-setting.tsx index 7729dae7..399d6d4a 100644 --- a/src/components/settings-modal/subscriptions-setting/subscriptions-setting.tsx +++ b/src/components/settings-modal/subscriptions-setting/subscriptions-setting.tsx @@ -2,7 +2,7 @@ import { setAccount, useAccount, useSubscribe } from '@bitsocialhq/pkc-react-hoo import getShortAddress from '../../../lib/get-short-address'; import styles from './subscriptions-setting.module.css'; import { useTranslation } from 'react-i18next'; -import { useState } from 'react'; +import { memo, useState } from 'react'; const SubscriptionButton = ({ address }: { address: string }) => { const { t } = useTranslation(); @@ -91,4 +91,4 @@ const SubscriptionsSetting = () => { ); }; -export default SubscriptionsSetting; +export default memo(SubscriptionsSetting); diff --git a/src/lib/utils/post-utils.ts b/src/lib/utils/post-utils.ts index 2fdf2e1c..c075d06f 100644 --- a/src/lib/utils/post-utils.ts +++ b/src/lib/utils/post-utils.ts @@ -21,36 +21,14 @@ export function getTextColorForBackground(rgb: string): string { return brightness > 125 ? 'black' : 'white'; } -export const formatMarkdown = (content: string): string => { - let md = content; - if (md) { - // Check if the content already contains valid Markdown patterns - const alreadyFormattedPattern = /\n \n/; - - // help the user by formatting the content if it's not already formatted - if (!alreadyFormattedPattern.test(md)) { - // Replace single newline with "/n /n" if followed by a newline - md = md.replace(/\n(?=\n)/g, '\n \n'); - // Replace single newline with double newline if between two characters - md = md.replace(/\n(?=\S)/g, '\n\n'); - } - } - return md; -}; - export function removeMarkdown(md: string): string { return md - .replace(/\[([^\]]*?)][[(].*?[)\]]/g, '$1') // Remove links - .replace(/[*_~`]/g, '') // Remove emphasis and code markers - .replace(/^#+\s*(.+)$/gm, '$1') // Remove headers - .replace(/^\s*[-*+]\s+/gm, '') // Remove list markers - .replace(/^\s*>\s+/gm, '') // Remove blockquotes - .replace(/^\s{1,2}\[(.*?)\]: (\S+)( ".*?")?\s*$/g, '') // Remove reference-style links - .replace(/^(\n)?\s{0,}#{1,6}\s*( (.+))? +#+$|^(\n)?\s{0,}#{1,6}\s*( (.+))?$/gm, '$1$3$4$6') // Remove atx-style headers - .replace(/([*]+)(\S)(.*?\S)?\1/g, '$2$3') // Remove * emphasis - .replace(/(^|\W)([_]+)(\S)(.*?\S)??\2($|\W)/g, '$1$3$4$5') // Remove _ emphasis - .replace(/(`{3,})(.*?)\1/gm, '$2') // Remove code blocks - .replace(/`(.+?)`/g, '$1') // Remove inline code - .replace(/~(.*?)~/g, '$1') // Remove strike through + .replace(/(.*?)<\/spoiler>/gs, '$1') // spoiler tags - keep inner text + .replace(/\[([^\]]*?)\]\([^)]*\)/g, '$1') // [text](url) -> text + .replace(/ /g, ' ') //   -> space + .replace(/^>\s*/gm, '') // greentext at line start + .replace(/[*_]/g, '') // bold/italic markers + .replace(/```[\s\S]*?```/g, (m) => m.slice(3, -3)) // code blocks - keep content + .replace(/`([^`]*)`/g, '$1') // inline code - keep content .trim(); } diff --git a/src/lib/utils/url-utils.ts b/src/lib/utils/url-utils.ts index feda7ca1..c817e166 100644 --- a/src/lib/utils/url-utils.ts +++ b/src/lib/utils/url-utils.ts @@ -19,6 +19,17 @@ export const isValidURL = (url: string) => { } }; +const CHAN_5_HOSTNAMES = ['5chan.app', '5chan.eth.limo', '5chan.eth.link', '5chan.eth.sucks', '5chan.netlify.app']; + +function getShareBaseUrl(): string { + const { protocol, hostname, origin } = window.location; + if ((protocol === 'https:' || protocol === 'http:') && hostname !== 'localhost' && hostname !== '127.0.0.1') { + return origin; + } + // Electron / Capacitor / local dev fallback + return `https://${CHAN_5_HOSTNAMES[0]}`; +} + export type ShareLinkType = 'thread' | 'catalog'; // Copies a share link to clipboard for a board, thread, description, or rules page @@ -29,17 +40,15 @@ export async function copyShareLinkToClipboard(boardIdentifier: string, linkType if (!cid) { throw new Error('copyShareLinkToClipboard: thread links require a cid'); } - const shareLink = `https://5chan.app/#/${boardIdentifier}/thread/${cid}`; + const shareLink = `${getShareBaseUrl()}/#/${boardIdentifier}/thread/${cid}`; await copyToClipboard(shareLink); return; } - const shareLink = `https://5chan.app/#/${boardIdentifier}/${linkType}`; + const shareLink = `${getShareBaseUrl()}/#/${boardIdentifier}/${linkType}`; await copyToClipboard(shareLink); } -const CHAN_5_HOSTNAMES = ['pleb.bz', '5chan.app', '5chan.eth.limo', '5chan.eth.link', '5chan.eth.sucks', '5chan.netlify.app']; - // Check if a URL is a valid 5chan link that should be handled internally export const is5chanLink = (url: string): boolean => { try { @@ -181,54 +190,3 @@ export const isValidCrossboardPattern = (pattern: string): boolean => { // Check if it's just a full address pattern: >>>/board.eth return isValidDomain(pathPart) || isValidIPNSKey(pathPart); }; - -// Transform >>{number} post number patterns to markdown links with special anchor -const preprocessPostNumberPatterns = (content: string): string => { - // Match >> followed by digits, avoid overlap with greentext (>>>), cross-board (>>>/), URLs, CID-like patterns - return content.replace(QUOTE_NUMBER_REGEX, (_, num) => `[>>${num}](#q-${num})`); -}; - -// Preprocess content to convert plain text 5chan cross-board patterns to markdown links -export const preprocess5chanPatterns = (content: string): string => { - const withPostNumbers = preprocessPostNumberPatterns(content); - // Pattern to match ">>>/something" or ">>>/something/cid" - // Negative lookbehind prevents matching patterns that are already part of URLs - // Matches: >>>/directory/, >>>/directory/cid (46 chars), >>>/address, >>>/address/cid (46 chars) - const pattern = /(?>>\/([a-zA-Z0-9]{1,10}\/(?:[a-zA-Z0-9]{46})?|[a-zA-Z0-9\-.]+(?:\/[a-zA-Z0-9]{46})?)[.,:;!?]*/g; - - return withPostNumbers.replace(pattern, (match, capturedPath) => { - // Remove any trailing punctuation from the captured path - const cleanPath = capturedPath.replace(/[.,:;!?]+$/, ''); - const fullPattern = `>>>/${cleanPath}`; - - if (isValidCrossboardPattern(fullPattern)) { - // Generate internal route based on pattern type - let internalRoute: string; - - // Directory with trailing slash: >>>/biz/ → /biz - if (/^[a-zA-Z0-9]{1,10}\/$/.test(cleanPath)) { - internalRoute = `/${cleanPath.slice(0, -1)}`; - } - // Directory + CID: >>>/biz/cid → /biz/thread/cid (CID is 46 chars) - else if (/^[a-zA-Z0-9]{1,10}\/[a-zA-Z0-9]{46}$/.test(cleanPath)) { - const [code, cid] = cleanPath.split('/'); - internalRoute = `/${code}/thread/${cid}`; - } - // Full address + CID: >>>/board.eth/cid → /board.eth/thread/cid (CID is 46 chars) - else if (/^[^/]+\/[a-zA-Z0-9]{46}$/.test(cleanPath)) { - const [address, cid] = cleanPath.split('/'); - internalRoute = `/${address}/thread/${cid}`; - } - // Just a full address: >>>/board.eth → /board.eth - else { - internalRoute = `/${cleanPath}`; - } - - // Preserve trailing punctuation outside the link - const trailingPunctuation = match.slice(fullPattern.length); - return `[${fullPattern}](${internalRoute})${trailingPunctuation}`; - } - - return match; - }); -}; diff --git a/src/views/faq/faq.tsx b/src/views/faq/faq.tsx index 2f8fc939..4660cc83 100644 --- a/src/views/faq/faq.tsx +++ b/src/views/faq/faq.tsx @@ -37,6 +37,9 @@ const FAQ = () => {
  • What is 5chan?
  • +
  • + Is 5chan just this website? +