mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(react): address Doctor findings (#1143)
Accessibility/semantics pass aligned with React Doctor: role-based spans/divs become real buttons, role=status becomes <output>, modals use native <dialog>, author/peer flags render as <img>, and embed/challenge iframes are sandboxed. Includes review follow-ups: correct button chrome/centering/themed dialog colors and focus rings, the missing aria-label i18n keys (all languages), the Yandex image-search URL, clearer .bso setup steps, a keyboard-accessible image-search dropdown, and removal of the post-edit ('comment edited') display (mod edits unchanged).
This commit is contained in:
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "عرض لوحات آمنة للعمل فقط",
|
||||
"show_all_content": "عرض جميع المحتويات",
|
||||
"link_type": "نوع الرابط",
|
||||
"comment_edited_at_timestamp": "تم تحرير التعليق في <1>{{timestamp}}</1>",
|
||||
"reason_reason": "السبب: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>انقر هنا</1> لعرض الأصل.",
|
||||
"click_here_to_hide_original": "<1>انقر هنا</1> لإخفاء الأصل.",
|
||||
"loading": "جار التحميل",
|
||||
"posted_by": "نشر بواسطة",
|
||||
"by": "بواسطة",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "انتقل إلى <1>الإعدادات</1> لاستيراد حساب المشرف الخاص بك",
|
||||
"post_form_rules_faq_prompt": "يُرجى قراءة <rules>القواعد</rules> و<faq>الأسئلة الشائعة</faq> قبل النشر.",
|
||||
"pending_mod_approval_reason": "أرسل الإشراف بالذكاء الاصطناعي هذا المنشور إلى قائمة انتظار المشرفين. السبب: \"{{reason}}\".",
|
||||
"flag": "علم"
|
||||
"flag": "علم",
|
||||
"show_replies": "عرض الردود",
|
||||
"hide_replies": "إخفاء الردود",
|
||||
"unhide": "إظهار",
|
||||
"account_data": "بيانات الحساب",
|
||||
"ban_duration_days": "مدة الحظر بالأيام",
|
||||
"challenge_answer": "إجابة التحدي",
|
||||
"ban": "حظر",
|
||||
"timestamp": "الطابع الزمني"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "শুধুমাত্র কাজ-সম্পূর্ণ বোর্ড দেখান",
|
||||
"show_all_content": "সমস্ত কন্টেন্ট দেখান",
|
||||
"link_type": "লিংক প্রকার",
|
||||
"comment_edited_at_timestamp": "<1>{{timestamp}}</1> এ মন্তব্য সম্পাদিত হয়েছে",
|
||||
"reason_reason": "কারণ: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>এখানে ক্লিক করুন</1> অসল দেখানোর জন্য।",
|
||||
"click_here_to_hide_original": "<1>এখানে ক্লিক করুন</1> অসল লুকাতে।",
|
||||
"loading": "লোড হচ্ছে",
|
||||
"posted_by": "পোস্ট করেছেন",
|
||||
"by": "দ্বারা",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "আপনার মড অ্যাকাউন্ট আমদানি করতে <1>সেটিংস</1>-এ যান",
|
||||
"post_form_rules_faq_prompt": "পোস্ট করার আগে <rules>নিয়ম</rules> এবং <faq>প্রশ্নোত্তর</faq> পড়ুন।",
|
||||
"pending_mod_approval_reason": "AI মডারেশন এই পোস্টটি মডারেটর কিউতে পাঠিয়েছে। কারণ: \"{{reason}}\".",
|
||||
"flag": "পতাকা"
|
||||
"flag": "পতাকা",
|
||||
"show_replies": "উত্তরগুলি দেখান",
|
||||
"hide_replies": "উত্তরগুলি লুকান",
|
||||
"unhide": "লুকানো সরান",
|
||||
"account_data": "অ্যাকাউন্ট ডেটা",
|
||||
"ban_duration_days": "ব্যান মেয়াদ দিনে",
|
||||
"challenge_answer": "চ্যালেঞ্জ উত্তর",
|
||||
"ban": "নিষেধ করুন",
|
||||
"timestamp": "টাইমস্ট্যাম্প"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "Zobrazit pouze bezpečné pracovní desky",
|
||||
"show_all_content": "Zobrazit veškerý obsah",
|
||||
"link_type": "Typ odkazu",
|
||||
"comment_edited_at_timestamp": "Komentář byl upraven dne <1>{{timestamp}}</1>",
|
||||
"reason_reason": "Důvod: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "Klikněte <1>zde</1> pro zobrazení původního.",
|
||||
"click_here_to_hide_original": "Klikněte <1>zde</1> pro skrytí původního.",
|
||||
"loading": "Načítání",
|
||||
"posted_by": "Publikoval(a)",
|
||||
"by": "od",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "Přejděte do <1>nastavení</1> a importujte svůj moderátorský účet",
|
||||
"post_form_rules_faq_prompt": "Před odesláním si přečtěte <rules>Pravidla</rules> a <faq>FAQ</faq>.",
|
||||
"pending_mod_approval_reason": "AI moderace odeslala tento příspěvek do fronty moderátorů. Důvod: \"{{reason}}\".",
|
||||
"flag": "Vlajka"
|
||||
"flag": "Vlajka",
|
||||
"show_replies": "Zobrazit odpovědi",
|
||||
"hide_replies": "skrýt odpovědi",
|
||||
"unhide": "odkrýt",
|
||||
"account_data": "data účtu",
|
||||
"ban_duration_days": "doba zákazu v dnech",
|
||||
"challenge_answer": "odpověď na výzvu",
|
||||
"ban": "zakázat",
|
||||
"timestamp": "časové razítko"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "Vis kun Worksafe boards",
|
||||
"show_all_content": "Vis alt indhold",
|
||||
"link_type": "Link type",
|
||||
"comment_edited_at_timestamp": "Kommentaren blev redigeret den <1>{{timestamp}}</1>",
|
||||
"reason_reason": "Grund: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>Klik her</1> for at vise originalen.",
|
||||
"click_here_to_hide_original": "<1>Klik her</1> for at skjule originalen.",
|
||||
"loading": "Indlæser",
|
||||
"posted_by": "Skrevet af",
|
||||
"by": "af",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "Gå til <1>indstillinger</1> for at importere din mod-konto",
|
||||
"post_form_rules_faq_prompt": "Læs venligst <rules>Reglerne</rules> og <faq>FAQ</faq>, før du poster.",
|
||||
"pending_mod_approval_reason": "AI-moderering sendte dette opslag til moderatorkøen. Grund: \"{{reason}}\".",
|
||||
"flag": "Flag"
|
||||
"flag": "Flag",
|
||||
"show_replies": "Vis svar",
|
||||
"hide_replies": "skjul svar",
|
||||
"unhide": "unhide",
|
||||
"account_data": "kontodata",
|
||||
"ban_duration_days": "banvarighed i dage",
|
||||
"challenge_answer": "svar på udfordring",
|
||||
"ban": "forbyd",
|
||||
"timestamp": "tidsstempel"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "Nur sichere Boards anzeigen",
|
||||
"show_all_content": "Alle Inhalte anzeigen",
|
||||
"link_type": "Linktyp",
|
||||
"comment_edited_at_timestamp": "Kommentar bearbeitet am <1>{{timestamp}}</1>",
|
||||
"reason_reason": "Grund: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>Hier klicken</1>, um das Original anzuzeigen.",
|
||||
"click_here_to_hide_original": "<1>Hier klicken</1>, um das Original zu verstecken.",
|
||||
"loading": "Laden",
|
||||
"posted_by": "Geschrieben von",
|
||||
"by": "von",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "Gehe zu <1>Einstellungen</1>, um dein Moderatorkonto zu importieren",
|
||||
"post_form_rules_faq_prompt": "Bitte lesen Sie die <rules>Regeln</rules> und die <faq>FAQ</faq>, bevor Sie posten.",
|
||||
"pending_mod_approval_reason": "Die KI-Moderation hat diesen Beitrag in die Moderationswarteschlange verschoben. Grund: \"{{reason}}\".",
|
||||
"flag": "Flagge"
|
||||
"flag": "Flagge",
|
||||
"show_replies": "Antworten anzeigen",
|
||||
"hide_replies": "Antworten ausblenden",
|
||||
"unhide": "einblenden",
|
||||
"account_data": "kontodaten",
|
||||
"ban_duration_days": "bannierungsdauer in tagen",
|
||||
"challenge_answer": "herausforderungsantwort",
|
||||
"ban": "sperren",
|
||||
"timestamp": "Zeitstempel"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "Εμφάνιση μόνο ασφαλών πινάκων εργασίας",
|
||||
"show_all_content": "Εμφάνιση όλων των περιεχομένων",
|
||||
"link_type": "Τύπος συνδέσμου",
|
||||
"comment_edited_at_timestamp": "Σχόλιο επεξεργασμένο στις <1>{{timestamp}}</1>",
|
||||
"reason_reason": "Λόγος: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>Κάντε κλικ εδώ</1> για να εμφανιστεί το αρχικό.",
|
||||
"click_here_to_hide_original": "<1>Κάντε κλικ εδώ</1> για να κρύψετε το αρχικό.",
|
||||
"loading": "Φόρτωση",
|
||||
"posted_by": "Δημοσιεύτηκε από",
|
||||
"by": "από",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "Μεταβείτε στις <1>ρυθμίσεις</1> για να εισαγάγετε τον λογαριασμό διαχειριστή σας",
|
||||
"post_form_rules_faq_prompt": "Παρακαλώ διαβάστε τους <rules>Κανόνες</rules> και τις <faq>Συχνές ερωτήσεις</faq> πριν δημοσιεύσετε.",
|
||||
"pending_mod_approval_reason": "Η εποπτεία AI έστειλε αυτή την ανάρτηση στην ουρά των συντονιστών. Λόγος: \"{{reason}}\".",
|
||||
"flag": "Σημαία"
|
||||
"flag": "Σημαία",
|
||||
"show_replies": "Εμφάνιση απαντήσεων",
|
||||
"hide_replies": "Απόκρυψη απαντήσεων",
|
||||
"unhide": "εμφάνιση",
|
||||
"account_data": "δεδομένα λογαριασμού",
|
||||
"ban_duration_days": "διάρκεια απαγόρευσης σε ημέρες",
|
||||
"challenge_answer": "απάντηση πρόκλησης",
|
||||
"ban": "απαγόρευση",
|
||||
"timestamp": "χρονοσήμανση"
|
||||
}
|
||||
|
||||
@@ -162,10 +162,6 @@
|
||||
"nsfw_boards_only": "NSFW Boards Only",
|
||||
"worksafe_boards_only": "Worksafe Boards Only",
|
||||
"link_type": "Link type",
|
||||
"comment_edited_at_timestamp": "Comment edited at <1>{{timestamp}}</1>",
|
||||
"reason_reason": "Reason: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>Click here</1> to show the original.",
|
||||
"click_here_to_hide_original": "<1>Click here</1> to hide the original.",
|
||||
"loading": "Loading",
|
||||
"posted_by": "Posted by",
|
||||
"by": "by",
|
||||
@@ -412,5 +408,13 @@
|
||||
"board_address_unverified_warning": "This board address is not verified. The board owner might not own this address.",
|
||||
"post_form_rules_faq_prompt": "Please read the <rules>Rules</rules> and <faq>FAQ</faq> before posting.",
|
||||
"pending_mod_approval_reason": "AI moderation sent this post to the mod queue. Reason: \"{{reason}}\".",
|
||||
"flag": "Flag"
|
||||
"flag": "Flag",
|
||||
"show_replies": "show replies",
|
||||
"hide_replies": "hide replies",
|
||||
"unhide": "unhide",
|
||||
"account_data": "account data",
|
||||
"ban_duration_days": "ban duration in days",
|
||||
"challenge_answer": "challenge answer",
|
||||
"ban": "ban",
|
||||
"timestamp": "timestamp"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "Mostrar solo tableros seguros para el trabajo",
|
||||
"show_all_content": "Mostrar todo el contenido",
|
||||
"link_type": "Tipo de enlace",
|
||||
"comment_edited_at_timestamp": "Comentario editado el <1>{{timestamp}}</1>",
|
||||
"reason_reason": "Motivo: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>Haga clic aquí</1> para mostrar el original.",
|
||||
"click_here_to_hide_original": "<1>Haga clic aquí</1> para ocultar el original.",
|
||||
"loading": "Cargando",
|
||||
"posted_by": "Publicado por",
|
||||
"by": "por",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "Ve a <1>configuración</1> para importar tu cuenta de moderador",
|
||||
"post_form_rules_faq_prompt": "Por favor, lee las <rules>Reglas</rules> y las <faq>FAQ</faq> antes de publicar.",
|
||||
"pending_mod_approval_reason": "La moderación con IA envió esta publicación a la cola de moderación. Motivo: \"{{reason}}\".",
|
||||
"flag": "Bandera"
|
||||
"flag": "Bandera",
|
||||
"show_replies": "mostrar respuestas",
|
||||
"hide_replies": "ocultar respuestas",
|
||||
"unhide": "mostrar",
|
||||
"account_data": "datos de cuenta",
|
||||
"ban_duration_days": "duración del baneo en días",
|
||||
"challenge_answer": "respuesta del desafío",
|
||||
"ban": "prohibir",
|
||||
"timestamp": "marca de tiempo"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "نمایش تنها بوردهای ایمن برای کار",
|
||||
"show_all_content": "نمایش تمام محتوا",
|
||||
"link_type": "نوع پیوند",
|
||||
"comment_edited_at_timestamp": "نظر ویرایش شده در <1>{{timestamp}}</1>",
|
||||
"reason_reason": "دلیل: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>برای نمایش اصلی اینجا کلیک کنید</1>.",
|
||||
"click_here_to_hide_original": "<1>برای پنهان کردن اصل اینجا کلیک کنید</1>.",
|
||||
"loading": "در حال بارگذاری",
|
||||
"posted_by": "ارسال شده توسط",
|
||||
"by": "توسط",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "برای وارد کردن حساب مدیر خود به <1>تنظیمات</1> بروید",
|
||||
"post_form_rules_faq_prompt": "لطفاً قبل از ارسال <rules>قوانین</rules> و <faq>پرسشهای متداول</faq> را بخوانید.",
|
||||
"pending_mod_approval_reason": "مدیریت با هوش مصنوعی این پست را به صف مدیران فرستاد. دلیل: \"{{reason}}\".",
|
||||
"flag": "پرچم"
|
||||
"flag": "پرچم",
|
||||
"show_replies": "نمایش پاسخها",
|
||||
"hide_replies": "پنهان کردن پاسخها",
|
||||
"unhide": "نمایش دادن",
|
||||
"account_data": "دادههای حساب",
|
||||
"ban_duration_days": "مدت بن به روز",
|
||||
"challenge_answer": "پاسخ چالش",
|
||||
"ban": "مسدود کردن",
|
||||
"timestamp": "مهر زمان"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "Näytä vain työturvalliset hallit",
|
||||
"show_all_content": "Näytä kaikki sisällöt",
|
||||
"link_type": "Linkin tyyppi",
|
||||
"comment_edited_at_timestamp": "Kommentti muokattu <1>{{timestamp}}</1>",
|
||||
"reason_reason": "Syy: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>Klikkaa tästä</1> näyttääksesi alkuperäisen.",
|
||||
"click_here_to_hide_original": "<1>Klikkaa tästä</1> piilottaaksesi alkuperäisen.",
|
||||
"loading": "Lataa",
|
||||
"posted_by": "Lähettäjä",
|
||||
"by": "kirjoittanut",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "Siirry <1>asetuksiin</1> tuodaksesi moderaattoritilisi",
|
||||
"post_form_rules_faq_prompt": "Lue <rules>Säännöt</rules> ja <faq>UKK</faq> ennen julkaisemista.",
|
||||
"pending_mod_approval_reason": "Tekoälymoderointi lähetti tämän viestin moderointijonoon. Syy: \"{{reason}}\".",
|
||||
"flag": "Lippu"
|
||||
"flag": "Lippu",
|
||||
"show_replies": "Näytä vastaukset",
|
||||
"hide_replies": "piilota vastaukset",
|
||||
"unhide": "paljasta",
|
||||
"account_data": "tilin tiedot",
|
||||
"ban_duration_days": "kiellon kesto päivissä",
|
||||
"challenge_answer": "haasteen vastaus",
|
||||
"ban": "kielto",
|
||||
"timestamp": "aikaleimaustiedosto"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "Ipakita lamang ang mga Worksafe boards",
|
||||
"show_all_content": "Ipakita lahat ng nilalaman",
|
||||
"link_type": "Uri ng Link",
|
||||
"comment_edited_at_timestamp": "Na-edit ang komento sa <1>{{timestamp}}</1>",
|
||||
"reason_reason": "Dahilan: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>I-click dito</1> upang ipakita ang orihinal.",
|
||||
"click_here_to_hide_original": "<1>I-click dito</1> upang itago ang orihinal.",
|
||||
"loading": "Naglo-load",
|
||||
"posted_by": "Nai-post ni",
|
||||
"by": "ni",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "Pumunta sa <1>mga setting</1> para i-import ang iyong mod account",
|
||||
"post_form_rules_faq_prompt": "Pakibasa ang <rules>Rules</rules> at <faq>FAQ</faq> bago mag-post.",
|
||||
"pending_mod_approval_reason": "Ipinadala ng AI moderation ang post na ito sa mod queue. Dahilan: \"{{reason}}\".",
|
||||
"flag": "Bandila"
|
||||
"flag": "Bandila",
|
||||
"show_replies": "Ipakita ang mga sagot",
|
||||
"hide_replies": "itago ang mga sagot",
|
||||
"unhide": "ipakita",
|
||||
"account_data": "data ng account",
|
||||
"ban_duration_days": "tagal ng ban sa araw",
|
||||
"challenge_answer": "sagot sa hamon",
|
||||
"ban": "ipagbawal",
|
||||
"timestamp": "timestamp"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "Afficher uniquement les tableaux Worksafe",
|
||||
"show_all_content": "Afficher tout le contenu",
|
||||
"link_type": "Type de lien",
|
||||
"comment_edited_at_timestamp": "Commentaire modifié le <1>{{timestamp}}</1>",
|
||||
"reason_reason": "Raison: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>Cliquez ici</1> pour afficher l'original.",
|
||||
"click_here_to_hide_original": "<1>Cliquez ici</1> pour masquer l'original.",
|
||||
"loading": "Chargement",
|
||||
"posted_by": "Posté par",
|
||||
"by": "par",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "Allez dans les <1>paramètres</1> pour importer votre compte de modération",
|
||||
"post_form_rules_faq_prompt": "Veuillez lire le <rules>Règlement</rules> et la <faq>FAQ</faq> avant de publier.",
|
||||
"pending_mod_approval_reason": "La modération par IA a envoyé ce post dans la file de modération. Raison: \"{{reason}}\".",
|
||||
"flag": "Drapeau"
|
||||
"flag": "Drapeau",
|
||||
"show_replies": "afficher les réponses",
|
||||
"hide_replies": "masquer les réponses",
|
||||
"unhide": "afficher",
|
||||
"account_data": "données du compte",
|
||||
"ban_duration_days": "durée du bannissement en jours",
|
||||
"challenge_answer": "réponse au défi",
|
||||
"ban": "interdire",
|
||||
"timestamp": "horodatage"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "הצג לוחות עבודה בטוחים בלבד",
|
||||
"show_all_content": "הצג את כל התוכן",
|
||||
"link_type": "סוג קישור",
|
||||
"comment_edited_at_timestamp": "התגובה נערכה ב-<1>{{timestamp}}</1>",
|
||||
"reason_reason": "סיבה: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>לחץ כאן</1> כדי להציג את המקור.",
|
||||
"click_here_to_hide_original": "<1>לחץ כאן</1> כדי להסתיר את המקור.",
|
||||
"loading": "טוען",
|
||||
"posted_by": "פורסם על ידי",
|
||||
"by": "על ידי",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "עבור אל <1>הגדרות</1> כדי לייבא את חשבון המוד שלך",
|
||||
"post_form_rules_faq_prompt": "אנא קרא את <rules>הכללים</rules> ואת <faq>שאלות נפוצות</faq> לפני פרסום.",
|
||||
"pending_mod_approval_reason": "סינון ה-AI שלח את הפוסט הזה לתור המודרציה. סיבה: \"{{reason}}\".",
|
||||
"flag": "דגל"
|
||||
"flag": "דגל",
|
||||
"show_replies": "הצג תגובות",
|
||||
"hide_replies": "הסתרת תגובות",
|
||||
"unhide": "הצג",
|
||||
"account_data": "נתוני חשבון",
|
||||
"ban_duration_days": "משך הבן בימים",
|
||||
"challenge_answer": "תשובת אתגר",
|
||||
"ban": "חסימה",
|
||||
"timestamp": "חותמת זמן"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "केवल कामसुरक्षित बोर्ड्स दिखाएं",
|
||||
"show_all_content": "सभी सामग्री दिखाएं",
|
||||
"link_type": "लिंक प्रकार",
|
||||
"comment_edited_at_timestamp": "<1>{{timestamp}}</1> पर टिप्पणी संपादित की गई",
|
||||
"reason_reason": "कारण: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>यहां क्लिक करें</1> मूल दिखाने के लिए।",
|
||||
"click_here_to_hide_original": "<1>यहां क्लिक करें</1> मूल छुपाने के लिए।",
|
||||
"loading": "लोड हो रहा है",
|
||||
"posted_by": "द्वारा लिखित",
|
||||
"by": "द्वारा",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "अपना मॉड खाता इम्पोर्ट करने के लिए <1>सेटिंग्स</1> पर जाएं",
|
||||
"post_form_rules_faq_prompt": "पोस्ट करने से पहले कृपया <rules>नियम</rules> और <faq>अक्सर पूछे जाने वाले सवाल</faq> पढ़ें।",
|
||||
"pending_mod_approval_reason": "AI मॉडरेशन ने इस पोस्ट को मॉड कतार में भेजा। कारण: \"{{reason}}\".",
|
||||
"flag": "ध्वज"
|
||||
"flag": "ध्वज",
|
||||
"show_replies": "उत्तर दिखाएं",
|
||||
"hide_replies": "जवाब छुपाएं",
|
||||
"unhide": "दिखाएं",
|
||||
"account_data": "खाता डेटा",
|
||||
"ban_duration_days": "प्रतिबंध की अवधि दिनों में",
|
||||
"challenge_answer": "चुनौती उत्तर",
|
||||
"ban": "प्रतिबंध",
|
||||
"timestamp": "टाइमस्टैंप"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "Csak munkabiztos táblák mutatása",
|
||||
"show_all_content": "Mutasd az összes tartalmat",
|
||||
"link_type": "Link típusa",
|
||||
"comment_edited_at_timestamp": "Megjegyzés szerkesztve ekkor: <1>{{timestamp}}</1>",
|
||||
"reason_reason": "Ok: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>Kattints ide</1> az eredeti megjelenítéséhez.",
|
||||
"click_here_to_hide_original": "<1>Kattints ide</1> az eredeti elrejtéséhez.",
|
||||
"loading": "Betöltés",
|
||||
"posted_by": "Írta",
|
||||
"by": "által",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "Lépj a <1>beállítások</1> menübe a moderátori fiókod importálásához",
|
||||
"post_form_rules_faq_prompt": "Kérjük, olvasd el a <rules>Szabályokat</rules> és a <faq>GYIK</faq>-et posztolás előtt.",
|
||||
"pending_mod_approval_reason": "Az AI-moderáció ezt a bejegyzést a moderátori várólistára küldte. Ok: \"{{reason}}\".",
|
||||
"flag": "Zászló"
|
||||
"flag": "Zászló",
|
||||
"show_replies": "Válaszok megjelenítése",
|
||||
"hide_replies": "válaszok elrejtése",
|
||||
"unhide": "mutat",
|
||||
"account_data": "fiókadat",
|
||||
"ban_duration_days": "kitiltás időtartama napokban",
|
||||
"challenge_answer": "kihívás válasza",
|
||||
"ban": "tiltás",
|
||||
"timestamp": "időbélyeg"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "Tampilkan Hanya Board Worksafe",
|
||||
"show_all_content": "Tampilkan Semua Konten",
|
||||
"link_type": "Tipe tautan",
|
||||
"comment_edited_at_timestamp": "Komentar diedit pada <1>{{timestamp}}</1>",
|
||||
"reason_reason": "Alasan: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>Klik di sini</1> untuk menampilkan aslinya.",
|
||||
"click_here_to_hide_original": "<1>Klik di sini</1> untuk menyembunyikan aslinya.",
|
||||
"loading": "Memuat",
|
||||
"posted_by": "Diposting oleh",
|
||||
"by": "oleh",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "Buka <1>pengaturan</1> untuk mengimpor akun mod Anda",
|
||||
"post_form_rules_faq_prompt": "Silakan baca <rules>Aturan</rules> dan <faq>FAQ</faq> sebelum memposting.",
|
||||
"pending_mod_approval_reason": "Moderasi AI mengirim postingan ini ke antrean moderator. Alasan: \"{{reason}}\".",
|
||||
"flag": "Bendera"
|
||||
"flag": "Bendera",
|
||||
"show_replies": "Tampilkan balasan",
|
||||
"hide_replies": "sembunyikan balasan",
|
||||
"unhide": "tampilkan",
|
||||
"account_data": "data akun",
|
||||
"ban_duration_days": "durasi larangan dalam hari",
|
||||
"challenge_answer": "jawaban tantangan",
|
||||
"ban": "larangan",
|
||||
"timestamp": "stempel waktu"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "Mostra solo le board Worksafe",
|
||||
"show_all_content": "Mostra tutti i contenuti",
|
||||
"link_type": "Tipo di link",
|
||||
"comment_edited_at_timestamp": "Commento modificato il <1>{{timestamp}}</1>",
|
||||
"reason_reason": "Motivo: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>Clicca qui</1> per mostrare l'originale.",
|
||||
"click_here_to_hide_original": "<1>Clicca qui</1> per nascondere l'originale.",
|
||||
"loading": "Caricamento",
|
||||
"posted_by": "Pubblicato da",
|
||||
"by": "da",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "Vai alle <1>impostazioni</1> per importare il tuo account mod",
|
||||
"post_form_rules_faq_prompt": "Leggi le <rules>Regole</rules> e le <faq>FAQ</faq> prima di pubblicare.",
|
||||
"pending_mod_approval_reason": "La moderazione AI ha inviato questo post alla coda di moderazione. Motivo: \"{{reason}}\".",
|
||||
"flag": "Bandiera"
|
||||
"flag": "Bandiera",
|
||||
"show_replies": "mostra risposte",
|
||||
"hide_replies": "nascondi risposte",
|
||||
"unhide": "mostra",
|
||||
"account_data": "dati account",
|
||||
"ban_duration_days": "durata del ban in giorni",
|
||||
"challenge_answer": "risposta alla sfida",
|
||||
"ban": "vietare",
|
||||
"timestamp": "marca temporale"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "Worksafeボードのみ表示",
|
||||
"show_all_content": "すべてのコンテンツを表示",
|
||||
"link_type": "リンクの種類",
|
||||
"comment_edited_at_timestamp": "<1>{{timestamp}}</1> にコメントを編集しました",
|
||||
"reason_reason": "理由: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>ここをクリック</1> して元の表示。",
|
||||
"click_here_to_hide_original": "<1>ここをクリック</1> して元を非表示にする。",
|
||||
"loading": "読み込み中",
|
||||
"posted_by": "投稿者",
|
||||
"by": "によって",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "モデレーターアカウントをインポートするには<1>設定</1>に移動してください",
|
||||
"post_form_rules_faq_prompt": "投稿する前に<rules>ルール</rules>と<faq>FAQ</faq>をお読みください。",
|
||||
"pending_mod_approval_reason": "AIモデレーションがこの投稿をモデレーターキューに送りました。理由: \"{{reason}}\".",
|
||||
"flag": "旗"
|
||||
"flag": "旗",
|
||||
"show_replies": "返信を表示",
|
||||
"hide_replies": "返信を非表示",
|
||||
"unhide": "表示",
|
||||
"account_data": "アカウントデータ",
|
||||
"ban_duration_days": "バン期間(日数)",
|
||||
"challenge_answer": "チャレンジ回答",
|
||||
"ban": "ブロック",
|
||||
"timestamp": "タイムスタンプ"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "작업 안전 보드만 표시",
|
||||
"show_all_content": "모든 콘텐츠 보기",
|
||||
"link_type": "링크 유형",
|
||||
"comment_edited_at_timestamp": "댓글 편집 시간: <1>{{timestamp}}</1>",
|
||||
"reason_reason": "이유: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>여기를 클릭</1> 하여 원본 표시.",
|
||||
"click_here_to_hide_original": "<1>여기를 클릭</1> 하여 원본 숨기기.",
|
||||
"loading": "로딩 중",
|
||||
"posted_by": "작성자",
|
||||
"by": "작성자:",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "관리자 계정을 가져오려면 <1>설정</1>으로 이동하세요",
|
||||
"post_form_rules_faq_prompt": "게시하기 전에 <rules>규칙</rules>과 <faq>FAQ</faq>을 읽어 주세요.",
|
||||
"pending_mod_approval_reason": "AI 모더레이션이 이 게시물을 모더레이터 대기열로 보냈습니다. 이유: \"{{reason}}\".",
|
||||
"flag": "깃발"
|
||||
"flag": "깃발",
|
||||
"show_replies": "답글 표시",
|
||||
"hide_replies": "답글 숨기기",
|
||||
"unhide": "표시",
|
||||
"account_data": "계정 데이터",
|
||||
"ban_duration_days": "차단 기간(일)",
|
||||
"challenge_answer": "챌린지 답변",
|
||||
"ban": "차단",
|
||||
"timestamp": "타임스탬프"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "केवळ कामसुरक्षित बोर्ड्स दाखवा",
|
||||
"show_all_content": "सर्व सामग्री दाखवा",
|
||||
"link_type": "लिंक प्रकार",
|
||||
"comment_edited_at_timestamp": "<1>{{timestamp}}</1> ला संपादित केला",
|
||||
"reason_reason": "कारण: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>येथे क्लिक करा</1> मूळ पाहण्यासाठी।",
|
||||
"click_here_to_hide_original": "<1>येथे क्लिक करा</1> मूळ लपविण्यासाठी।",
|
||||
"loading": "लोड होत आहे",
|
||||
"posted_by": "ने टप्पल केला",
|
||||
"by": "व्यक्ती",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "तुमचे मॉड खाते आयात करण्यासाठी <1>सेटिंग्ज</1> वर जा",
|
||||
"post_form_rules_faq_prompt": "पोस्ट करण्यापूर्वी <rules>नियम</rules> आणि <faq>वारंवार विचारले जाणारे प्रश्न</faq> वाचा.",
|
||||
"pending_mod_approval_reason": "AI मॉडरेशनने ही पोस्ट मॉड कतारमध्ये पाठवली. कारण: \"{{reason}}\".",
|
||||
"flag": "ध्वज"
|
||||
"flag": "ध्वज",
|
||||
"show_replies": "उत्तरे दर्शवा",
|
||||
"hide_replies": "उत्तरे लपवा",
|
||||
"unhide": "दाखवा",
|
||||
"account_data": "खाता डेटा",
|
||||
"ban_duration_days": "बंद मुदत दिवसांमध्ये",
|
||||
"challenge_answer": "आव्हान उत्तर",
|
||||
"ban": "प्रतिबंध",
|
||||
"timestamp": "टाइमस्टॅम्प"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "Toon alleen Worksafe boards",
|
||||
"show_all_content": "Toon alle inhoud",
|
||||
"link_type": "Link type",
|
||||
"comment_edited_at_timestamp": "Reactie bewerkt op <1>{{timestamp}}</1>",
|
||||
"reason_reason": "Reden: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>Klik hier</1> om de originele te tonen.",
|
||||
"click_here_to_hide_original": "<1>Klik hier</1> om de originele te verbergen.",
|
||||
"loading": "Laden",
|
||||
"posted_by": "Geplaatst door",
|
||||
"by": "door",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "Ga naar <1>instellingen</1> om je mod-account te importeren",
|
||||
"post_form_rules_faq_prompt": "Lees de <rules>Regels</rules> en de <faq>FAQ</faq> voordat je plaatst.",
|
||||
"pending_mod_approval_reason": "AI-moderatie heeft dit bericht naar de moderatiewachtrij gestuurd. Reden: \"{{reason}}\".",
|
||||
"flag": "Vlag"
|
||||
"flag": "Vlag",
|
||||
"show_replies": "antwoorden weergeven",
|
||||
"hide_replies": "reacties verbergen",
|
||||
"unhide": "weergeven",
|
||||
"account_data": "accountgegevens",
|
||||
"ban_duration_days": "banningsduur in dagen",
|
||||
"challenge_answer": "antwoord op uitdaging",
|
||||
"ban": "verbannen",
|
||||
"timestamp": "tijdstempel"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "Vis kun Worksafe boards",
|
||||
"show_all_content": "Vis allt innhold",
|
||||
"link_type": "Lenketype",
|
||||
"comment_edited_at_timestamp": "Kommentaren ble redigert på <1>{{timestamp}}</1>",
|
||||
"reason_reason": "Årsak: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>Klikk her</1> for å vise originalen.",
|
||||
"click_here_to_hide_original": "<1>Klikk her</1> for å skjule originalen.",
|
||||
"loading": "Laster",
|
||||
"posted_by": "Skrevet av",
|
||||
"by": "av",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "Gå til <1>innstillinger</1> for å importere modkontoen din",
|
||||
"post_form_rules_faq_prompt": "Vennligst les <rules>Reglene</rules> og <faq>FAQ</faq> før du poster.",
|
||||
"pending_mod_approval_reason": "AI-moderering sendte dette innlegget til moderatorkøen. Årsak: \"{{reason}}\".",
|
||||
"flag": "Flagg"
|
||||
"flag": "Flagg",
|
||||
"show_replies": "vis svar",
|
||||
"hide_replies": "skjul svar",
|
||||
"unhide": "vis",
|
||||
"account_data": "kontodata",
|
||||
"ban_duration_days": "bannvarighet i dager",
|
||||
"challenge_answer": "svar på utfordring",
|
||||
"ban": "forbud",
|
||||
"timestamp": "tidsstempel"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "Pokaż tylko bezpieczne tablice",
|
||||
"show_all_content": "Pokaż całą zawartość",
|
||||
"link_type": "Typ łącza",
|
||||
"comment_edited_at_timestamp": "Komentarz edytowany <1>{{timestamp}}</1>",
|
||||
"reason_reason": "Powód: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>Kliknij tutaj</1>, aby pokazać oryginał.",
|
||||
"click_here_to_hide_original": "<1>Kliknij tutaj</1>, aby ukryć oryginał.",
|
||||
"loading": "Ładowanie",
|
||||
"posted_by": "Opublikowane przez",
|
||||
"by": "przez",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "Przejdź do <1>ustawień</1>, aby zaimportować swoje konto moderatora",
|
||||
"post_form_rules_faq_prompt": "Przed opublikowaniem przeczytaj <rules>Zasady</rules> i <faq>FAQ</faq>.",
|
||||
"pending_mod_approval_reason": "Moderacja AI wysłała ten post do kolejki moderatorów. Powód: \"{{reason}}\".",
|
||||
"flag": "Flaga"
|
||||
"flag": "Flaga",
|
||||
"show_replies": "Pokaż odpowiedzi",
|
||||
"hide_replies": "ukryj odpowiedzi",
|
||||
"unhide": "pokaż",
|
||||
"account_data": "dane konta",
|
||||
"ban_duration_days": "czas trwania banu w dniach",
|
||||
"challenge_answer": "odpowiedź na wyzwanie",
|
||||
"ban": "zbanować",
|
||||
"timestamp": "znacznik czasu"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "Mostrar apenas boards Worksafe",
|
||||
"show_all_content": "Mostrar Todo o Conteúdo",
|
||||
"link_type": "Tipo de link",
|
||||
"comment_edited_at_timestamp": "Comentário editado em <1>{{timestamp}}</1>",
|
||||
"reason_reason": "Razão: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>Clique aqui</1> para mostrar o original.",
|
||||
"click_here_to_hide_original": "<1>Clique aqui</1> para ocultar o original.",
|
||||
"loading": "Carregando",
|
||||
"posted_by": "Postado por",
|
||||
"by": "por",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "Vá para <1>configurações</1> para importar sua conta de mod",
|
||||
"post_form_rules_faq_prompt": "Leia as <rules>Regras</rules> e as <faq>FAQ</faq> antes de publicar.",
|
||||
"pending_mod_approval_reason": "A moderação por IA enviou esta postagem para a fila de moderação. Razão: \"{{reason}}\".",
|
||||
"flag": "Bandeira"
|
||||
"flag": "Bandeira",
|
||||
"show_replies": "mostrar respostas",
|
||||
"hide_replies": "ocultar respostas",
|
||||
"unhide": "mostrar",
|
||||
"account_data": "dados da conta",
|
||||
"ban_duration_days": "duração do banimento em dias",
|
||||
"challenge_answer": "resposta do desafio",
|
||||
"ban": "banir",
|
||||
"timestamp": "marca de tempo"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "Afișați doar tablourile Worksafe",
|
||||
"show_all_content": "Afișați tot conținutul",
|
||||
"link_type": "Tipul linkului",
|
||||
"comment_edited_at_timestamp": "Comentariu editat la <1>{{timestamp}}</1>",
|
||||
"reason_reason": "Motiv: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>Click aici</1> pentru a arăta originalul.",
|
||||
"click_here_to_hide_original": "<1>Click aici</1> pentru a ascunde originalul.",
|
||||
"loading": "Încărcare",
|
||||
"posted_by": "Postat de",
|
||||
"by": "de către",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "Accesează <1>setările</1> pentru a importa contul tău de moderator",
|
||||
"post_form_rules_faq_prompt": "Citește <rules>Regulile</rules> și <faq>Întrebările frecvente</faq> înainte de a publica.",
|
||||
"pending_mod_approval_reason": "Moderarea AI a trimis această postare în coada moderatorilor. Motiv: \"{{reason}}\".",
|
||||
"flag": "Steag"
|
||||
"flag": "Steag",
|
||||
"show_replies": "arată răspunsuri",
|
||||
"hide_replies": "ascunde răspunsuri",
|
||||
"unhide": "afișează",
|
||||
"account_data": "date cont",
|
||||
"ban_duration_days": "durata banului în zile",
|
||||
"challenge_answer": "răspunsul la provocare",
|
||||
"ban": "interzice",
|
||||
"timestamp": "marcă de timp"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "Показывать только безопасные доски",
|
||||
"show_all_content": "Показать весь контент",
|
||||
"link_type": "Тип ссылки",
|
||||
"comment_edited_at_timestamp": "Комментарий изменен в <1>{{timestamp}}</1>",
|
||||
"reason_reason": "Причина: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>Нажмите здесь</1> для отображения оригинала.",
|
||||
"click_here_to_hide_original": "<1>Нажмите здесь</1> чтобы скрыть оригинал.",
|
||||
"loading": "Загрузка",
|
||||
"posted_by": "Опубликовано",
|
||||
"by": "от",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "Перейдите в <1>настройки</1>, чтобы импортировать свой аккаунт модератора",
|
||||
"post_form_rules_faq_prompt": "Пожалуйста, прочитайте <rules>Правила</rules> и <faq>FAQ</faq> перед публикацией.",
|
||||
"pending_mod_approval_reason": "ИИ-модерация отправила этот пост в очередь модераторов. Причина: \"{{reason}}\".",
|
||||
"flag": "Флаг"
|
||||
"flag": "Флаг",
|
||||
"show_replies": "показать ответы",
|
||||
"hide_replies": "скрыть ответы",
|
||||
"unhide": "показать",
|
||||
"account_data": "данные аккаунта",
|
||||
"ban_duration_days": "продолжительность бана в днях",
|
||||
"challenge_answer": "ответ на задачу",
|
||||
"ban": "блокировка",
|
||||
"timestamp": "временная метка"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "Shfaq vetëm taulat e sigurta të punës",
|
||||
"show_all_content": "Shfaq të gjitha përmbajtjet",
|
||||
"link_type": "Lloji i lidhjes",
|
||||
"comment_edited_at_timestamp": "Komenti u redaktua në <1>{{timestamp}}</1>",
|
||||
"reason_reason": "Arsye: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>Kliko këtu</1> për të treguar origjinalin.",
|
||||
"click_here_to_hide_original": "<1>Kliko këtu</1> për të fshehur origjinalin.",
|
||||
"loading": "Ngarkimi",
|
||||
"posted_by": "Postuar nga",
|
||||
"by": "nga",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "Shkoni te <1>parametrat</1> për të importuar llogarinë tuaj të modit",
|
||||
"post_form_rules_faq_prompt": "Ju lutemi lexoni <rules>Rregullat</rules> dhe <faq>Pyetjet e shpeshta</faq> para se të postoni.",
|
||||
"pending_mod_approval_reason": "Moderimi me AI e dërgoi këtë postim në radhën e moderatorëve. Arsye: \"{{reason}}\".",
|
||||
"flag": "Flamur"
|
||||
"flag": "Flamur",
|
||||
"show_replies": "Shfaq përgjigjet",
|
||||
"hide_replies": "fsheh përgjigjet",
|
||||
"unhide": "shfaq",
|
||||
"account_data": "të dhënat e llogarisë",
|
||||
"ban_duration_days": "kohëzgjatja e ndalimit në ditë",
|
||||
"challenge_answer": "përgjigje ndaj sfidës",
|
||||
"ban": "ndalim",
|
||||
"timestamp": "vulë kohe"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "Visa endast Worksafe-styrelser",
|
||||
"show_all_content": "Visa allt innehåll",
|
||||
"link_type": "Länktyp",
|
||||
"comment_edited_at_timestamp": "Kommentar redigerad vid <1>{{timestamp}}</1>",
|
||||
"reason_reason": "Anledning: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>Klicka här</1> för att visa original.",
|
||||
"click_here_to_hide_original": "<1>Klicka här</1> för att dölja original.",
|
||||
"loading": "Laddar",
|
||||
"posted_by": "Postat av",
|
||||
"by": "av",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "Gå till <1>inställningar</1> för att importera ditt modkonto",
|
||||
"post_form_rules_faq_prompt": "Läs <rules>Reglerna</rules> och <faq>FAQ</faq> innan du postar.",
|
||||
"pending_mod_approval_reason": "AI-moderering skickade det här inlägget till moderatorkön. Anledning: \"{{reason}}\".",
|
||||
"flag": "Flagga"
|
||||
"flag": "Flagga",
|
||||
"show_replies": "visa svar",
|
||||
"hide_replies": "dölj svar",
|
||||
"unhide": "visa",
|
||||
"account_data": "kontoupplysningar",
|
||||
"ban_duration_days": "bannlängd i dagar",
|
||||
"challenge_answer": "svar på utmaning",
|
||||
"ban": "förbjud",
|
||||
"timestamp": "tidsstämpel"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "పనిమాడు భద్రపరిచిన బోర్డులను మాత్రమే చూపించు",
|
||||
"show_all_content": "అన్ని కాంటెంట్ చూపించు",
|
||||
"link_type": "లింక్ రకం",
|
||||
"comment_edited_at_timestamp": "<1>{{timestamp}}</1> లో సమీక్ష సవరించబడింది",
|
||||
"reason_reason": "కారణం: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>ఇక్కడ క్లిక్ చేయండి</1> అసలు చూపించడానికి.",
|
||||
"click_here_to_hide_original": "<1>ఇక్కడ క్లిక్ చేయండి</1> అసలు దాచడానికి.",
|
||||
"loading": "లోడ్ అవుతోంది",
|
||||
"posted_by": "పోస్ట్ చేసినవాడు",
|
||||
"by": "ద్వారా",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "మీ మోడ్ ఖాతాను దిగుమతి చేయడానికి <1>అమరికలు</1>కి వెళ్లండి",
|
||||
"post_form_rules_faq_prompt": "పోస్ట్ చేయడానికి ముందు <rules>నియమాలు</rules> మరియు <faq>తరచుగా అడిగే ప్రశ్నలు</faq> చదవండి.",
|
||||
"pending_mod_approval_reason": "AI మోడరేషన్ ఈ పోస్ట్ను మోడరేటర్ క్యూకు పంపింది. కారణం: \"{{reason}}\".",
|
||||
"flag": "జెండా"
|
||||
"flag": "జెండా",
|
||||
"show_replies": "సమాధానాలను చూపించు",
|
||||
"hide_replies": "సమాధానలను దాచు",
|
||||
"unhide": "చూపించు",
|
||||
"account_data": "ఖాతా డేటా",
|
||||
"ban_duration_days": "నిషేధ వ్యవధి రోజుల్లో",
|
||||
"challenge_answer": "సవాలు సమాధానం",
|
||||
"ban": "నిషేధం",
|
||||
"timestamp": "టైమ్స్టాంప్"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "แสดงบอร์ด Worksafe เท่านั้น",
|
||||
"show_all_content": "แสดงเนื้อหาทั้งหมด",
|
||||
"link_type": "ประเภทลิงก์",
|
||||
"comment_edited_at_timestamp": "แก้ไขความคิดเห็นที่ <1>{{timestamp}}</1>",
|
||||
"reason_reason": "เหตุผล: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>คลิกที่นี่</1> เพื่อแสดงข้อมูลต้นฉบับ",
|
||||
"click_here_to_hide_original": "<1>คลิกที่นี่</1> เพื่อซ่อนข้อมูลต้นฉบับ",
|
||||
"loading": "กำลังโหลด",
|
||||
"posted_by": "โพสต์โดย",
|
||||
"by": "โดย",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "ไปที่ <1>การตั้งค่า</1> เพื่อนำเข้าบัญชีผู้ดูแลของคุณ",
|
||||
"post_form_rules_faq_prompt": "โปรดอ่าน<rules>กฎ</rules>และ<faq>คำถามที่พบบ่อย</faq>ก่อนโพสต์",
|
||||
"pending_mod_approval_reason": "การกลั่นกรองด้วย AI ส่งโพสต์นี้ไปยังคิวผู้ดูแล เหตุผล: \"{{reason}}\".",
|
||||
"flag": "ธง"
|
||||
"flag": "ธง",
|
||||
"show_replies": "แสดงคำตอบ",
|
||||
"hide_replies": "ซ่อนคำตอบ",
|
||||
"unhide": "แสดง",
|
||||
"account_data": "ข้อมูลบัญชี",
|
||||
"ban_duration_days": "ระยะเวลาแบนในวัน",
|
||||
"challenge_answer": "คำตอบความท้าทาย",
|
||||
"ban": "แบน",
|
||||
"timestamp": "แสตมป์เวลา"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "Sadece Worksafe Panoları Göster",
|
||||
"show_all_content": "Tüm İçeriği Göster",
|
||||
"link_type": "Bağlantı türü",
|
||||
"comment_edited_at_timestamp": "<1>{{timestamp}}</1> tarihinde yorum düzenlendi",
|
||||
"reason_reason": "Sebep: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>Buraya tıklayın</1> orijinali göstermek için.",
|
||||
"click_here_to_hide_original": "<1>Buraya tıklayın</1> orijinali gizlemek için.",
|
||||
"loading": "Yükleniyor",
|
||||
"posted_by": "Tarafından gönderildi",
|
||||
"by": "tarafından",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "Mod hesabınızı içe aktarmak için <1>ayarlar</1>a gidin",
|
||||
"post_form_rules_faq_prompt": "Lütfen göndermeden önce <rules>Kuralları</rules> ve <faq>SSS</faq>'yi okuyun.",
|
||||
"pending_mod_approval_reason": "AI moderasyonu bu gönderiyi moderatör kuyruğuna gönderdi. Neden: \"{{reason}}\".",
|
||||
"flag": "Bayrak"
|
||||
"flag": "Bayrak",
|
||||
"show_replies": "yanıtları göster",
|
||||
"hide_replies": "yanıtları gizle",
|
||||
"unhide": "göster",
|
||||
"account_data": "hesap verileri",
|
||||
"ban_duration_days": "ban süresi gün cinsinden",
|
||||
"challenge_answer": "zorluk yanıtı",
|
||||
"ban": "yasakla",
|
||||
"timestamp": "zaman damgası"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "Показувати лише безпечні дошки",
|
||||
"show_all_content": "Показати весь вміст",
|
||||
"link_type": "Тип посилання",
|
||||
"comment_edited_at_timestamp": "Коментар відредаговано в <1>{{timestamp}}</1>",
|
||||
"reason_reason": "Причина: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>Натисніть тут</1> для відображення оригіналу.",
|
||||
"click_here_to_hide_original": "<1>Натисніть тут</1> для приховання оригіналу.",
|
||||
"loading": "Завантаження",
|
||||
"posted_by": "Опубліковано",
|
||||
"by": "від",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "Перейдіть до <1>налаштувань</1>, щоб імпортувати свій обліковий запис модератора",
|
||||
"post_form_rules_faq_prompt": "Будь ласка, прочитайте <rules>Правила</rules> і <faq>Часті запитання</faq> перед публікацією.",
|
||||
"pending_mod_approval_reason": "AI-модерація надіслала цей допис до черги модераторів. Причина: \"{{reason}}\".",
|
||||
"flag": "Прапор"
|
||||
"flag": "Прапор",
|
||||
"show_replies": "показати відповіді",
|
||||
"hide_replies": "приховати відповіді",
|
||||
"unhide": "показати",
|
||||
"account_data": "дані аккаунту",
|
||||
"ban_duration_days": "тривалість бану в днях",
|
||||
"challenge_answer": "відповідь на виклик",
|
||||
"ban": "заблокувати",
|
||||
"timestamp": "часова мітка"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "صرف کام سے بچائے گئے بورڈز دکھائیں",
|
||||
"show_all_content": "تمام مواد دکھائیں",
|
||||
"link_type": "لنک کی قسم",
|
||||
"comment_edited_at_timestamp": "تبدیل کیا گیا تبصرہ <1>{{timestamp}}</1> پر",
|
||||
"reason_reason": "وجہ: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>یہاں کلک کریں</1> تاکہ اصل دکھائی دے۔",
|
||||
"click_here_to_hide_original": "<1>یہاں کلک کریں</1> تاکہ اصل چھپ جائے۔",
|
||||
"loading": "لوڈ ہو رہا ہے",
|
||||
"posted_by": "پوسٹ کیا گیا",
|
||||
"by": "کے ذریعے",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "اپنا موڈ اکاؤنٹ درآمد کرنے کے لیے <1>ترتیبات</1> پر جائیں",
|
||||
"post_form_rules_faq_prompt": "براہ کرم پوسٹ کرنے سے پہلے <rules>اصول</rules> اور <faq>اکثر پوچھے گئے سوالات</faq> پڑھیں۔",
|
||||
"pending_mod_approval_reason": "AI موڈریشن نے اس پوسٹ کو موڈریٹر قطار میں بھیج دیا۔ وجہ: \"{{reason}}\".",
|
||||
"flag": "پرچم"
|
||||
"flag": "پرچم",
|
||||
"show_replies": "جوابات دکھائیں",
|
||||
"hide_replies": "جوابات چھپائیں",
|
||||
"unhide": "دکھائیں",
|
||||
"account_data": "اکاؤنٹ ڈیٹا",
|
||||
"ban_duration_days": "بین کی مدت دنوں میں",
|
||||
"challenge_answer": "چ یلنج کا جواب",
|
||||
"ban": "روک",
|
||||
"timestamp": "ٹائم سٹیمپ"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "Chỉ hiển thị bảng Worksafe",
|
||||
"show_all_content": "Hiển thị tất cả nội dung",
|
||||
"link_type": "Loại liên kết",
|
||||
"comment_edited_at_timestamp": "Bình luận được chỉnh sửa lúc <1>{{timestamp}}</1>",
|
||||
"reason_reason": "Lý do: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>Nhấp vào đây</1> để hiển thị nguyên bản.",
|
||||
"click_here_to_hide_original": "<1>Nhấp vào đây</1> để ẩn nguyên bản.",
|
||||
"loading": "Đang tải",
|
||||
"posted_by": "Được đăng bởi",
|
||||
"by": "bởi",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "Vào <1>cài đặt</1> để nhập tài khoản mod của bạn",
|
||||
"post_form_rules_faq_prompt": "Vui lòng đọc <rules>Quy tắc</rules> và <faq>Câu hỏi thường gặp</faq> trước khi đăng.",
|
||||
"pending_mod_approval_reason": "Kiểm duyệt AI đã gửi bài viết này vào hàng đợi kiểm duyệt. Lý do: \"{{reason}}\".",
|
||||
"flag": "Cờ"
|
||||
"flag": "Cờ",
|
||||
"show_replies": "hiển thị câu trả lời",
|
||||
"hide_replies": "ẩn các trả lời",
|
||||
"unhide": "hiển thị",
|
||||
"account_data": "dữ liệu tài khoản",
|
||||
"ban_duration_days": "thời gian cấm theo ngày",
|
||||
"challenge_answer": "câu trả lời thử thách",
|
||||
"ban": "cấm",
|
||||
"timestamp": "dấu thời gian"
|
||||
}
|
||||
|
||||
@@ -144,10 +144,6 @@
|
||||
"show_worksafe_boards_only": "仅显示安全工作板",
|
||||
"show_all_content": "显示所有内容",
|
||||
"link_type": "链接类型",
|
||||
"comment_edited_at_timestamp": "评论于 <1>{{timestamp}}</1> 被编辑",
|
||||
"reason_reason": "原因: \"{{reason}}\".",
|
||||
"click_here_to_show_original": "<1>点击这里</1> 显示原文。",
|
||||
"click_here_to_hide_original": "<1>点击这里</1> 隐藏原文。",
|
||||
"loading": "加载中",
|
||||
"posted_by": "由...发布",
|
||||
"by": "由...发布",
|
||||
@@ -393,5 +389,13 @@
|
||||
"go_to_settings_to_import_mod_account": "前往<1>设置</1>以导入您的版主账户",
|
||||
"post_form_rules_faq_prompt": "发帖前请阅读<rules>规则</rules>和<faq>常见问题</faq>。",
|
||||
"pending_mod_approval_reason": "AI 审核已将此帖子发送到版主队列。原因:\"{{reason}}\"。",
|
||||
"flag": "旗帜"
|
||||
"flag": "旗帜",
|
||||
"show_replies": "显示回复",
|
||||
"hide_replies": "隐藏回复",
|
||||
"unhide": "显示",
|
||||
"account_data": "账户数据",
|
||||
"ban_duration_days": "封禁时长(天数)",
|
||||
"challenge_answer": "挑战答案",
|
||||
"ban": "封禁",
|
||||
"timestamp": "时间戳"
|
||||
}
|
||||
|
||||
+2
-2
@@ -33,7 +33,7 @@ import {
|
||||
import styles from './app.module.css';
|
||||
import { DesktopBoardButtons, MobileAllFeedFilter, MobileBoardButtons } from './components/board-buttons';
|
||||
import Blotter from './views/blotter';
|
||||
import FAQ from './views/faq';
|
||||
import Faq from './views/faq';
|
||||
import Home from './views/home';
|
||||
import Archive from './views/archive/archive';
|
||||
import Directory from './views/directory/directory';
|
||||
@@ -282,7 +282,7 @@ const App = () => {
|
||||
<Routes>
|
||||
<Route element={<GlobalLayout />}>
|
||||
<Route path='/' element={<Home />} />
|
||||
<Route path='/faq' element={<FAQ />} />
|
||||
<Route path='/faq' element={<Faq />} />
|
||||
<Route path='/pass' element={<Pass />} />
|
||||
<Route path='/rules/:boardIdentifier?' element={<Rules />} />
|
||||
<Route path='/blotter' element={<Blotter />} />
|
||||
|
||||
@@ -34,6 +34,22 @@
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.hideButton {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
display: inline;
|
||||
font: inherit;
|
||||
line-height: inherit;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.hideButton:focus-visible {
|
||||
outline: 1px dotted currentcolor;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.hideButton:hover,
|
||||
.actionLink:hover {
|
||||
cursor: pointer;
|
||||
|
||||
@@ -19,8 +19,8 @@ const BoardBlotter = () => {
|
||||
<div className={`${styles.content} ${styles.show}`}>
|
||||
<table className={styles.blotter}>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>
|
||||
<tr aria-label='Blotter separator'>
|
||||
<td aria-label='Blotter divider'>
|
||||
<hr />
|
||||
</td>
|
||||
</tr>
|
||||
@@ -40,9 +40,9 @@ const BoardBlotter = () => {
|
||||
<tr>
|
||||
<td>
|
||||
[
|
||||
<span
|
||||
<button
|
||||
type='button'
|
||||
className={styles.hideButton}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -53,7 +53,7 @@ const BoardBlotter = () => {
|
||||
onClick={() => toggleVisibility()}
|
||||
>
|
||||
{isHidden ? t('show_blotter') : t('hide')}
|
||||
</span>
|
||||
</button>
|
||||
]
|
||||
{!isHidden && (
|
||||
<>
|
||||
|
||||
@@ -110,7 +110,7 @@ export const ArchiveButton = ({ address, isInAllView, isInSubscriptionsView, isI
|
||||
}
|
||||
|
||||
return (
|
||||
<button className='button' onClick={() => navigate(archivePath)}>
|
||||
<button type='button' className='button' onClick={() => navigate(archivePath)}>
|
||||
{t('archive')}
|
||||
</button>
|
||||
);
|
||||
@@ -121,7 +121,7 @@ const SubscribeButton = ({ address }: BoardButtonsProps) => {
|
||||
const { subscribed, subscribe, unsubscribe } = useSubscribe({ communityAddress: address });
|
||||
|
||||
return (
|
||||
<button className='button' onClick={subscribed ? unsubscribe : subscribe}>
|
||||
<button type='button' className='button' onClick={subscribed ? unsubscribe : subscribe}>
|
||||
{subscribed ? t('unsubscribe') : t('subscribe')}
|
||||
</button>
|
||||
);
|
||||
@@ -186,7 +186,7 @@ export const RefreshButton = () => {
|
||||
const { t } = useTranslation();
|
||||
const reset = useFeedResetStore((state) => state.reset);
|
||||
return (
|
||||
<button className='button' onClick={() => reset && reset()}>
|
||||
<button type='button' className='button' onClick={() => reset && reset()}>
|
||||
{t('refresh')}
|
||||
</button>
|
||||
);
|
||||
@@ -250,7 +250,7 @@ export const UpdateButton = () => {
|
||||
const { t } = useTranslation();
|
||||
const requestUpdate = useThreadLiveUpdatesStore((state) => state.requestUpdate);
|
||||
return (
|
||||
<button className='button' onClick={() => requestUpdate()}>
|
||||
<button type='button' className='button' onClick={() => requestUpdate()}>
|
||||
{t('update')}
|
||||
</button>
|
||||
);
|
||||
@@ -281,7 +281,7 @@ export const BottomButton = () => {
|
||||
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'instant' });
|
||||
};
|
||||
return (
|
||||
<button className='button' onClick={scrollToBottom}>
|
||||
<button type='button' className='button' onClick={scrollToBottom}>
|
||||
{t('bottom')}
|
||||
</button>
|
||||
);
|
||||
@@ -293,7 +293,7 @@ export const TopButton = () => {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
|
||||
};
|
||||
return (
|
||||
<button className='button' onClick={scrollToTop}>
|
||||
<button type='button' className='button' onClick={scrollToTop}>
|
||||
{t('top')}
|
||||
</button>
|
||||
);
|
||||
@@ -381,6 +381,7 @@ const ModQueueAlertThreshold = () => {
|
||||
{t('alert_threshold')}:
|
||||
<input
|
||||
type='number'
|
||||
aria-label={t('alert_threshold')}
|
||||
min='1'
|
||||
step='1'
|
||||
value={alertThresholdValue}
|
||||
@@ -670,7 +671,7 @@ export const CatalogSearchResultsLabel = () => {
|
||||
return (
|
||||
<span className={styles.filteredThreadsCount}>
|
||||
{' '}
|
||||
— {t('search_results_for')}: <strong>{searchText}</strong>
|
||||
({t('search_results_for')}: <strong>{searchText}</strong>)
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -679,7 +680,7 @@ export const CatalogSearchResultsLabel = () => {
|
||||
return (
|
||||
<span className={styles.filteredThreadsCount}>
|
||||
{' '}
|
||||
— {t('filtered_threads')}: <strong>{filteredCount}</strong>
|
||||
({t('filtered_threads')}: <strong>{filteredCount}</strong>)
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -865,5 +866,13 @@ const SearchOPsBar = () => {
|
||||
}
|
||||
};
|
||||
|
||||
return <input type='text' placeholder={t('search_ops_placeholder', 'Search OPs...')} onKeyDown={handleSearch} className={styles.searchOPsInput} />;
|
||||
return (
|
||||
<input
|
||||
type='text'
|
||||
aria-label={t('search_ops_placeholder', 'Search OPs...')}
|
||||
placeholder={t('search_ops_placeholder', 'Search OPs...')}
|
||||
onKeyDown={handleSearch}
|
||||
className={styles.searchOPsInput}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -177,7 +177,7 @@ describe('BoardHeader', () => {
|
||||
expect(container.textContent).toContain('/subs/ - Subscriptions');
|
||||
expect(container.textContent).toContain('subscriptions_subtitle:{"count":2}');
|
||||
|
||||
const clickableSubtitle = container.querySelector('[role="button"]');
|
||||
const clickableSubtitle = Array.from(container.querySelectorAll('button')).find((button) => button.textContent?.includes('subscriptions_subtitle'));
|
||||
await act(async () => {
|
||||
clickableSubtitle?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
@@ -25,14 +25,27 @@
|
||||
}
|
||||
|
||||
.clickableSubtitle {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
font: inherit;
|
||||
line-height: inherit;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.clickableSubtitle:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.clickableSubtitle:focus-visible {
|
||||
outline: 1px dotted currentcolor;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.content hr {
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
@@ -110,9 +110,9 @@ const BoardHeader = () => {
|
||||
</div>
|
||||
<div className={styles.boardSubtitle}>
|
||||
{isInSubscriptionsView ? (
|
||||
<span
|
||||
<button
|
||||
type='button'
|
||||
className={styles.clickableSubtitle}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -123,7 +123,7 @@ const BoardHeader = () => {
|
||||
onClick={() => navigate('/subs/settings#subscriptions-settings')}
|
||||
>
|
||||
{subtitle}
|
||||
</span>
|
||||
</button>
|
||||
) : isInDirectoryListView ? (
|
||||
<span>{subtitle}</span>
|
||||
) : !isInAllView && !isInModView && subtitle ? (
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('BoardPagination', () => {
|
||||
expect(container.textContent).toContain('catalog');
|
||||
expect(container.textContent).toContain('archive');
|
||||
|
||||
const allLink = Array.from(container.querySelectorAll<HTMLElement>('[role="button"]')).find((element) => element.textContent === 'all');
|
||||
const allLink = Array.from(container.querySelectorAll<HTMLButtonElement>('button')).find((element) => element.textContent === 'all');
|
||||
expect(allLink).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -45,9 +45,9 @@ const BoardPagination = ({ basePath, currentPage, search = '', totalPages, foote
|
||||
{currentPage === 1 && (
|
||||
<span className={styles.footerPageItem}>
|
||||
<span className={styles.footerPageBracket}>[</span>
|
||||
<span
|
||||
<button
|
||||
type='button'
|
||||
className={styles.footerPageLink}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={() => setEnableInfiniteScroll(true)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -58,7 +58,7 @@ const BoardPagination = ({ basePath, currentPage, search = '', totalPages, foote
|
||||
}}
|
||||
>
|
||||
{t('all')}
|
||||
</span>
|
||||
</button>
|
||||
<span className={styles.footerPageBracket}>]</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -56,14 +56,14 @@ describe('BoardsBarEditModal', () => {
|
||||
input?.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeTruthy();
|
||||
expect(container.querySelector('dialog')).toBeTruthy();
|
||||
expect(useBoardsBarEditModalStore.getState().showModal).toBe(true);
|
||||
});
|
||||
|
||||
it('still closes when the backdrop itself handles keyboard activation', async () => {
|
||||
await renderModal();
|
||||
|
||||
const backdrop = container.querySelector<HTMLElement>('[role="button"]');
|
||||
const backdrop = container.querySelector<HTMLElement>('button[aria-label="Close board list modal"]');
|
||||
expect(backdrop).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.backdropButton {
|
||||
all: unset;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
cursor: default;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.boardsbarEditDialog {
|
||||
position: absolute;
|
||||
width: 400px;
|
||||
@@ -25,9 +33,12 @@
|
||||
box-shadow: rgba(0, 0, 0, 0.25) 0px 0px 5px 0px;
|
||||
font-size: var(--body-font-size);
|
||||
line-height: normal;
|
||||
z-index: 1000;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: inherit;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
|
||||
@@ -71,7 +71,13 @@ const BoardsBarEditModalForm = ({
|
||||
{subscriptions.length > 0 && (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.checkboxItem}>
|
||||
<input type='checkbox' id='show-subscriptions' checked={showSubscriptions} onChange={(e) => setShowSubscriptions(e.target.checked)} />
|
||||
<input
|
||||
type='checkbox'
|
||||
id='show-subscriptions'
|
||||
aria-label='show subscriptions'
|
||||
checked={showSubscriptions}
|
||||
onChange={(e) => setShowSubscriptions(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor='show-subscriptions'>show subscriptions</label>
|
||||
<span className={styles.editSubscriptionsWrapper}>
|
||||
(
|
||||
@@ -91,7 +97,9 @@ const BoardsBarEditModalForm = ({
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.boardsbarEditFooter}>
|
||||
<button onClick={handleSave}>Save</button>
|
||||
<button type='button' onClick={handleSave}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -108,28 +116,23 @@ const BoardsBarEditModal = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
closeBoardsBarEditModal();
|
||||
}
|
||||
};
|
||||
|
||||
const formKey = `${directoriesToString(visibleDirectories)}-${showSubscriptionsInBoardsBar}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.backdrop}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
<div className={styles.backdrop}>
|
||||
<button
|
||||
type='button'
|
||||
className={styles.backdropButton}
|
||||
aria-label='Close board list modal'
|
||||
onClick={closeBoardsBarEditModal}
|
||||
onKeyDown={(e) => {
|
||||
if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
closeBoardsBarEditModal();
|
||||
}
|
||||
}}
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className={styles.boardsbarEditDialog} role='dialog' aria-modal='true' aria-labelledby='boards-bar-edit-modal-title'>
|
||||
/>
|
||||
<dialog open className={styles.boardsbarEditDialog} aria-modal='true' aria-labelledby='boards-bar-edit-modal-title'>
|
||||
<div className={styles.hd}>
|
||||
<h2 id='boards-bar-edit-modal-title'>Custom Board List</h2>
|
||||
<button type='button' className={styles.closeButton} onClick={closeBoardsBarEditModal} title='Close' aria-label='Close' />
|
||||
@@ -146,7 +149,7 @@ const BoardsBarEditModal = () => {
|
||||
location={location}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -220,7 +220,7 @@ describe('BoardsBar', () => {
|
||||
await renderBoardsBar('/mu');
|
||||
|
||||
await act(async () => {
|
||||
findExactText('search')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
findExactText('Search')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
const inputs = container.querySelectorAll<HTMLInputElement>('input[type="text"]');
|
||||
|
||||
@@ -5,13 +5,30 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.boardNavDesktop button, .boardNavMobile button {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
display: inline;
|
||||
font: inherit;
|
||||
line-height: inherit;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.boardNavDesktop button:focus-visible, .boardNavMobile button:focus-visible {
|
||||
outline: 1px dotted currentcolor;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.checkingSubscriptions {
|
||||
font-size: var(--boardsbar-font-size);
|
||||
color: var(--boardsbar-desktop-link-text-color);
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.boardNavDesktop a, .navTopRight span {
|
||||
.boardNavDesktop a, .navTopRight span, .navTopRight button {
|
||||
color: var(--boardsbar-desktop-link-text-color);
|
||||
text-decoration: var(--boardsbar-desktop-link-text-decoration);
|
||||
}
|
||||
@@ -20,13 +37,13 @@
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
.boardNavDesktop a:hover, .navTopRight span:hover {
|
||||
.boardNavDesktop a:hover, .navTopRight span:hover, .navTopRight button:hover {
|
||||
color: var(--boardsbar-desktop-link-text-color-hover);
|
||||
text-decoration: var(--boardsbar-desktop-link-text-decoration-hover);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
.boardNavDesktop .placeholder {
|
||||
color: var(--boardsbar-separator-color);
|
||||
}
|
||||
|
||||
@@ -45,13 +62,17 @@
|
||||
margin-right: -7px;
|
||||
}
|
||||
|
||||
.temporaryButton {
|
||||
.boardNavDesktop .temporaryButton {
|
||||
color: var(--boardsbar-desktop-link-text-color);
|
||||
text-decoration: var(--boardsbar-desktop-link-text-decoration);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.temporaryButton:hover {
|
||||
.navTopRight button {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.boardNavDesktop .temporaryButton:hover {
|
||||
color: var(--boardsbar-desktop-link-text-color-hover);
|
||||
text-decoration: var(--boardsbar-desktop-link-text-decoration-hover);
|
||||
cursor: pointer;
|
||||
@@ -80,7 +101,7 @@
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.boardNavMobile .pageJump a, .boardNavMobile .pageJump span {
|
||||
.boardNavMobile .pageJump a, .boardNavMobile .pageJump span, .boardNavMobile .pageJump button {
|
||||
color: var(--boardsbar-mobile-button-text-color);
|
||||
text-decoration: var(--button-text-decoration-mobile);
|
||||
}
|
||||
@@ -108,7 +129,7 @@
|
||||
padding: 3px 5px 0 0;
|
||||
}
|
||||
|
||||
.pageJump a, .pageJump span {
|
||||
.pageJump a, .pageJump span, .pageJump button {
|
||||
padding-right: 5px;
|
||||
text-decoration: var(--homepage-box-link-text-decoration);
|
||||
cursor: pointer;
|
||||
|
||||
@@ -70,7 +70,16 @@ const SearchBar = ({ setShowSearchBar }: { setShowSearchBar: (show: boolean) =>
|
||||
return (
|
||||
<div className={styles.searchBar} ref={searchBarRef}>
|
||||
<form onSubmit={handleSearchSubmit}>
|
||||
<input type='text' autoCorrect='off' autoComplete='off' spellCheck='false' autoCapitalize='off' placeholder={placeholder} ref={searchInputRef} />
|
||||
<input
|
||||
type='text'
|
||||
aria-label={placeholder}
|
||||
autoCorrect='off'
|
||||
autoComplete='off'
|
||||
spellCheck='false'
|
||||
autoCapitalize='off'
|
||||
placeholder={placeholder}
|
||||
ref={searchInputRef}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
@@ -154,9 +163,9 @@ const BoardsBarDesktop = () => {
|
||||
const linkContent = (
|
||||
<>
|
||||
{isPlaceholder ? (
|
||||
<span
|
||||
<button
|
||||
type='button'
|
||||
className={styles.placeholder}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -168,7 +177,7 @@ const BoardsBarDesktop = () => {
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{code}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<Link to={`/${code}${catalogSuffix}`} onClick={openDirectoryForPlaceholder}>
|
||||
{code}
|
||||
@@ -209,19 +218,19 @@ const BoardsBarDesktop = () => {
|
||||
</>
|
||||
)}
|
||||
]{' '}
|
||||
{BOARD_CODE_GROUPS.map((group, groupIndex) => {
|
||||
{BOARD_CODE_GROUPS.map((group) => {
|
||||
const visibleCodes = group.filter((code) => directoriesToShow.has(code));
|
||||
if (visibleCodes.length === 0) return null;
|
||||
|
||||
return <span key={groupIndex}>[{visibleCodes.map((code, codeIndex) => renderBoardCode(code, codeIndex === visibleCodes.length - 1))}] </span>;
|
||||
return <span key={group.join('|')}>[{visibleCodes.map((code, codeIndex) => renderBoardCode(code, codeIndex === visibleCodes.length - 1))}] </span>;
|
||||
})}
|
||||
{hasHiddenDirectories && !showAllTemporarily && (
|
||||
<>
|
||||
{' '}
|
||||
[
|
||||
<span
|
||||
<button
|
||||
type='button'
|
||||
className={styles.temporaryButton}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -234,7 +243,7 @@ const BoardsBarDesktop = () => {
|
||||
title='Show all'
|
||||
>
|
||||
...
|
||||
</span>
|
||||
</button>
|
||||
]{' '}
|
||||
</>
|
||||
)}
|
||||
@@ -242,9 +251,9 @@ const BoardsBarDesktop = () => {
|
||||
<>[{visibleSubscriptionAddresses.map((address: string, index: number) => renderSubscription(address, index, visibleSubscriptionAddresses.length))}] </>
|
||||
)}
|
||||
[
|
||||
<span
|
||||
<button
|
||||
type='button'
|
||||
className={styles.temporaryButton}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -256,11 +265,11 @@ const BoardsBarDesktop = () => {
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{capitalize(t('edit'))}
|
||||
</span>
|
||||
</button>
|
||||
] [
|
||||
<span
|
||||
<button
|
||||
type='button'
|
||||
className={styles.temporaryButton}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -272,13 +281,13 @@ const BoardsBarDesktop = () => {
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{t('create_board')}
|
||||
</span>
|
||||
</button>
|
||||
]
|
||||
</span>
|
||||
<span className={styles.navTopRight}>
|
||||
[<Link to={!location.pathname.endsWith('settings') ? location.pathname.replace(/\/$/, '') + '/settings' : location.pathname}>{t('settings')}</Link>] [
|
||||
<span
|
||||
role='button'
|
||||
<button
|
||||
type='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -288,8 +297,8 @@ const BoardsBarDesktop = () => {
|
||||
}}
|
||||
onClick={() => setShowSearchBar(!showSearchBar)}
|
||||
>
|
||||
{t('search')}
|
||||
</span>
|
||||
{capitalize(t('search'))}
|
||||
</button>
|
||||
] [<Link to='/'>{t('home')}</Link>]
|
||||
</span>
|
||||
{showSearchBar && <SearchBar setShowSearchBar={setShowSearchBar} />}
|
||||
@@ -387,8 +396,8 @@ const BoardsBarMobile = ({ communityAddress }: { communityAddress?: string }) =>
|
||||
</div>
|
||||
<div className={styles.pageJump}>
|
||||
<Link to={location.pathname.replace(/\/$/, '') + '/settings'}>{t('settings')}</Link>
|
||||
<span
|
||||
role='button'
|
||||
<button
|
||||
type='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -399,7 +408,7 @@ const BoardsBarMobile = ({ communityAddress }: { communityAddress?: string }) =>
|
||||
onClick={() => setShowSearchBar(!showSearchBar)}
|
||||
>
|
||||
{capitalize(t('search'))}
|
||||
</span>
|
||||
</button>
|
||||
<Link to='/'>{t('home')}</Link>
|
||||
{showSearchBar && <SearchBar setShowSearchBar={setShowSearchBar} />}
|
||||
</div>
|
||||
|
||||
@@ -122,7 +122,7 @@ const renderCatalogFilters = () => {
|
||||
};
|
||||
|
||||
const openModal = async () => {
|
||||
const button = Array.from(container.querySelectorAll('[role="button"]')).find((candidate) => candidate.textContent === 'filters');
|
||||
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'filters');
|
||||
await click(button ?? null);
|
||||
};
|
||||
|
||||
@@ -176,7 +176,7 @@ describe('CatalogFilters', () => {
|
||||
expect(container.querySelector('[data-testid="filters-protip"]')?.textContent).toBe('filters-protip');
|
||||
expect(container.textContent).toContain('filter_and_highlights_help');
|
||||
|
||||
const overlay = Array.from(container.querySelectorAll('[role="button"]')).find((candidate) => candidate.tagName === 'DIV');
|
||||
const overlay = container.querySelector<HTMLButtonElement>('button[class*="overlay"]');
|
||||
await click(overlay ?? null);
|
||||
|
||||
expect(container.querySelector('[data-testid="filters-protip"]')).toBeNull();
|
||||
@@ -220,14 +220,15 @@ describe('CatalogFilters', () => {
|
||||
expect(container.textContent).toContain('x4');
|
||||
|
||||
const initialRows = getRows();
|
||||
const moveUpButton = initialRows[1]?.querySelector('[role="button"]');
|
||||
const moveUpButton = initialRows[1]?.querySelector('button');
|
||||
await click(moveUpButton ?? null);
|
||||
|
||||
const reorderedInputs = getTextInputs();
|
||||
expect(reorderedInputs[0]?.value).toBe('beta');
|
||||
expect(reorderedInputs[1]?.value).toBe('alpha');
|
||||
|
||||
const deleteButton = getRows()[1]?.querySelectorAll('[role="button"]')[1];
|
||||
const rowButtons = Array.from(getRows()[1]?.querySelectorAll('button') ?? []);
|
||||
const deleteButton = rowButtons.at(-1);
|
||||
await click(deleteButton ?? null);
|
||||
|
||||
expect(getTextInputs()).toHaveLength(1);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
.overlay {
|
||||
all: unset;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -88,6 +89,11 @@
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
appearance: none;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
right: 5px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
@@ -101,6 +107,11 @@
|
||||
}
|
||||
|
||||
.openHelpButton {
|
||||
appearance: none;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
right: 23px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
@@ -113,6 +124,11 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.closeButton:focus-visible, .openHelpButton:focus-visible {
|
||||
outline: 1px dotted currentcolor;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
padding: 5px;
|
||||
text-align: left;
|
||||
@@ -190,6 +206,16 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filtersTable button {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filtersTable tfoot td {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
@@ -152,9 +152,9 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
|
||||
{localFilterItems.map((item, index) => (
|
||||
<tr key={item.id ?? index}>
|
||||
<td>
|
||||
<span
|
||||
<button
|
||||
type='button'
|
||||
className={styles.orderButton}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -165,11 +165,12 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
|
||||
onClick={() => moveLocalFilterItemUp(index)}
|
||||
>
|
||||
↑
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type='checkbox'
|
||||
aria-label={`Enable filter ${index + 1}`}
|
||||
className={styles.onCheckbox}
|
||||
checked={item.enabled}
|
||||
onChange={(e) => updateLocalFilterItem(index, { ...item, enabled: e.target.checked })}
|
||||
@@ -178,6 +179,7 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
|
||||
<td>
|
||||
<input
|
||||
type='text'
|
||||
aria-label={`Filter text ${index + 1}`}
|
||||
autoCorrect='off'
|
||||
autoComplete='off'
|
||||
spellCheck='false'
|
||||
@@ -192,15 +194,25 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
|
||||
<HighlightColorPicker item={item} index={index} updateLocalFilterItem={updateLocalFilterItem} localFilterItems={localFilterItems} />
|
||||
</td>
|
||||
<td>
|
||||
<input type='checkbox' checked={item.hide} onChange={(e) => updateLocalFilterItem(index, { ...item, hide: e.target.checked })} />
|
||||
<input
|
||||
type='checkbox'
|
||||
aria-label={`Hide matches for filter ${index + 1}`}
|
||||
checked={item.hide}
|
||||
onChange={(e) => updateLocalFilterItem(index, { ...item, hide: e.target.checked })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input type='checkbox' checked={item.top} onChange={(e) => updateLocalFilterItem(index, { ...item, top: e.target.checked })} />
|
||||
<input
|
||||
type='checkbox'
|
||||
aria-label={`Pin matches for filter ${index + 1}`}
|
||||
checked={item.top}
|
||||
onChange={(e) => updateLocalFilterItem(index, { ...item, top: e.target.checked })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
<button
|
||||
type='button'
|
||||
className={styles.deleteButton}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -211,7 +223,7 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => {
|
||||
onClick={() => removeLocalFilterItem(index)}
|
||||
>
|
||||
×
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
<td className={styles.filterHits}>
|
||||
{currentCommunityAddress && item.communityFilteredCids?.has(currentCommunityAddress) && `x${item.communityCounts?.get(currentCommunityAddress) ?? 0}`}
|
||||
@@ -259,9 +271,10 @@ const FiltersModal = ({ closeModal }: { closeModal: () => void }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
<button
|
||||
type='button'
|
||||
aria-label={t('close')}
|
||||
className={styles.overlay}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -279,10 +292,11 @@ const FiltersModal = ({ closeModal }: { closeModal: () => void }) => {
|
||||
<div className={styles.header}>
|
||||
<span className={styles.title}>{showHelp ? t('filter_and_highlights_help') : t('filter_and_highlights')}</span>
|
||||
{!showHelp && (
|
||||
<span
|
||||
<button
|
||||
type='button'
|
||||
className={styles.openHelpButton}
|
||||
aria-label={t('help')}
|
||||
title={t('help')}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -293,10 +307,11 @@ const FiltersModal = ({ closeModal }: { closeModal: () => void }) => {
|
||||
onClick={openHelp}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
<button
|
||||
type='button'
|
||||
className={styles.closeButton}
|
||||
aria-label={t('close')}
|
||||
title={t('close')}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -323,9 +338,9 @@ const CatalogFilters = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
<button
|
||||
type='button'
|
||||
className={`${styles.filtersButton} button`}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -336,7 +351,7 @@ const CatalogFilters = () => {
|
||||
onClick={() => setShowModal(true)}
|
||||
>
|
||||
{t('filters')}
|
||||
</span>
|
||||
</button>
|
||||
{showModal && <FiltersModal closeModal={closeModal} />}
|
||||
</>
|
||||
);
|
||||
|
||||
+31
@@ -1,4 +1,16 @@
|
||||
.colorPickerBackdrop {
|
||||
all: unset;
|
||||
cursor: default;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.colorClickbox {
|
||||
appearance: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
@@ -35,6 +47,9 @@
|
||||
}
|
||||
|
||||
.colorOption {
|
||||
appearance: none;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 0 2px rgba(0, 0, 0, 0.2);
|
||||
margin: auto;
|
||||
width: 16px;
|
||||
@@ -52,6 +67,9 @@
|
||||
}
|
||||
|
||||
.colorPreview {
|
||||
appearance: none;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
@@ -100,6 +118,11 @@
|
||||
}
|
||||
|
||||
.colorPickerModal tfoot .button {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
color: var(--button-desktop-text-color);
|
||||
cursor: pointer;
|
||||
margin: 0 2px;
|
||||
@@ -118,3 +141,11 @@
|
||||
content: ']';
|
||||
color: var(--body-font-color);
|
||||
}
|
||||
|
||||
.colorClickbox:focus-visible,
|
||||
.colorOption:focus-visible,
|
||||
.colorPreview:focus-visible,
|
||||
.colorPickerModal tfoot .button:focus-visible {
|
||||
outline: 1px dotted currentcolor;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@@ -57,10 +57,11 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
<button
|
||||
type='button'
|
||||
aria-label={`Select filter color ${index + 1}`}
|
||||
className={styles.colorClickbox}
|
||||
style={{ backgroundColor: item.color || '#fff' }}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -72,20 +73,14 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
|
||||
{...getReferenceProps()}
|
||||
>
|
||||
{!item.color && '∕'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isColorPickerOpen && (
|
||||
<FloatingPortal>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 30,
|
||||
}}
|
||||
role='button'
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Close color picker'
|
||||
className={styles.colorPickerBackdrop}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -111,14 +106,15 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
|
||||
>
|
||||
<table className={styles.colorGrid}>
|
||||
<tbody>
|
||||
{colorRows.map((row, rowIndex) => (
|
||||
<tr key={rowIndex}>
|
||||
{row.map((color, colorIndex) => (
|
||||
<td key={colorIndex}>
|
||||
<span
|
||||
{colorRows.map((row) => (
|
||||
<tr key={row.join('|')}>
|
||||
{row.map((color) => (
|
||||
<td key={color}>
|
||||
<button
|
||||
type='button'
|
||||
aria-label={`Use color ${color}`}
|
||||
className={styles.colorOption}
|
||||
style={{ backgroundColor: color }}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -139,13 +135,14 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
|
||||
</tr>
|
||||
<tr>
|
||||
<td className={styles.middleTxt} colSpan={4}>
|
||||
<input type='text' ref={inputRef} value={customColor} onChange={handleCustomColorChange} />
|
||||
<span
|
||||
<input type='text' aria-label='Custom highlight color' ref={inputRef} value={customColor} onChange={handleCustomColorChange} />
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Apply custom highlight color'
|
||||
className={styles.colorPreview}
|
||||
style={{
|
||||
backgroundColor: customColor || '#fff',
|
||||
}}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -159,9 +156,9 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={4}>
|
||||
<span
|
||||
<button
|
||||
type='button'
|
||||
className={styles.button}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -172,10 +169,10 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
|
||||
onClick={() => setIsColorPickerOpen(false)}
|
||||
>
|
||||
<span className={styles.buttonText}>Close</span>
|
||||
</span>
|
||||
<span
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
className={styles.button}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -186,7 +183,7 @@ const HighlightColorPicker = ({ item, index, updateLocalFilterItem, localFilterI
|
||||
onClick={clearColor}
|
||||
>
|
||||
<span className={styles.buttonText}>Clear</span>
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
|
||||
@@ -92,14 +92,14 @@ export const CatalogPostMedia = ({ cid, commentMediaInfo, linkWidth, linkHeight,
|
||||
<img src={thumbnail} alt='' onLoad={handleLoad} onError={handleError} style={loadingStyle} width={numericWidth} height={numericHeight} />
|
||||
) : (
|
||||
// show first frame of the video, as a workaround for Safari not loading thumbnails
|
||||
<video src={`${url}#t=0.001`} onError={handleError} />
|
||||
<video src={`${url}#t=0.001`} aria-label='Video thumbnail' onError={handleError} />
|
||||
);
|
||||
} else if (type === 'webpage' && !hasError) {
|
||||
thumbnailComponent = <img src={thumbnail} alt='' onLoad={handleLoad} onError={handleError} style={loadingStyle} width={numericWidth} height={numericHeight} />;
|
||||
} else if (type === 'iframe' && iframeThumbnail && !hasError) {
|
||||
thumbnailComponent = <img src={iframeThumbnail} alt='' onLoad={handleLoad} onError={handleError} style={loadingStyle} width={numericWidth} height={numericHeight} />;
|
||||
} else if (type === 'audio') {
|
||||
thumbnailComponent = <audio src={url} controls />;
|
||||
thumbnailComponent = <audio src={url} aria-label='Audio preview' controls />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -238,7 +238,7 @@ const CatalogPost = memo(
|
||||
return (
|
||||
<>
|
||||
<div className={`${styles.post} ${imageSize === 'Large' ? styles.large : ''}`} style={CSSProperties}>
|
||||
<div onMouseOver={() => setHoveredCid(cid)} onMouseLeave={() => setHoveredCid(null)}>
|
||||
<div onMouseOver={() => setHoveredCid(cid)} onFocus={() => setHoveredCid(cid)} onMouseLeave={() => setHoveredCid(null)} onBlur={() => setHoveredCid(null)}>
|
||||
{shouldMaskPost ? (
|
||||
<Link to={postLink}>
|
||||
<span className={styles.hiddenThumbnail} />
|
||||
@@ -246,11 +246,10 @@ const CatalogPost = memo(
|
||||
) : hasThumbnail ? (
|
||||
<>
|
||||
{shouldShowSnow() && hasThumbnail && <img src='assets/xmashat.gif' className={styles.xmasHat} alt='' />}
|
||||
<Link to={postLink}>
|
||||
<div
|
||||
className={`${styles.mediaPaddingWrapper} ${shouldMaskPost && styles.hidden}`}
|
||||
ref={refs.setReference}
|
||||
<Link
|
||||
to={postLink}
|
||||
onMouseOver={() => (timeoutRef.current = setTimeout(() => setShowPortal(true), 250))}
|
||||
onFocus={() => (timeoutRef.current = setTimeout(() => setShowPortal(true), 250))}
|
||||
onMouseLeave={() => {
|
||||
setShowPortal(false);
|
||||
if (timeoutRef.current) {
|
||||
@@ -258,7 +257,15 @@ const CatalogPost = memo(
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setShowPortal(false);
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={`${styles.mediaPaddingWrapper} ${shouldMaskPost && styles.hidden}`} ref={refs.setReference}>
|
||||
{threadIcons}
|
||||
{spoiler ? (
|
||||
<img src='assets/spoiler.png' alt='' />
|
||||
|
||||
@@ -72,8 +72,8 @@ const clickElement = async (element: Element | null) => {
|
||||
});
|
||||
};
|
||||
|
||||
const querySearchButton = () => Array.from(container.querySelectorAll('span')).find((node) => node.textContent === 'search') ?? null;
|
||||
const queryCloseButton = () => Array.from(container.querySelectorAll('span')).find((node) => node.textContent === '✖') ?? null;
|
||||
const querySearchButton = () => Array.from(container.querySelectorAll('button')).find((node) => node.textContent === 'search') ?? null;
|
||||
const queryCloseButton = () => Array.from(container.querySelectorAll('button')).find((node) => node.textContent === '✖') ?? null;
|
||||
const queryInput = () => container.querySelector('input');
|
||||
|
||||
const dispatchInput = async (element: HTMLInputElement, value: string) => {
|
||||
|
||||
@@ -31,8 +31,13 @@
|
||||
}
|
||||
|
||||
.closeSearch {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
font: inherit;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
white-space: nowrap;
|
||||
color: var(--button-desktop-text-color, revert);
|
||||
}
|
||||
@@ -41,6 +46,11 @@
|
||||
color: var(--button-desktop-text-color-hover, revert);
|
||||
}
|
||||
|
||||
.closeSearch:focus-visible {
|
||||
outline: 1px dotted currentcolor;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.searchContainer input {
|
||||
width: 75px;
|
||||
|
||||
@@ -85,9 +85,9 @@ const CatalogSearch = () => {
|
||||
return (
|
||||
<>
|
||||
{!isMobile && '['}
|
||||
<span
|
||||
<button
|
||||
type='button'
|
||||
className={`${styles.filtersButton} button`}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -98,13 +98,14 @@ const CatalogSearch = () => {
|
||||
onClick={handleToggleSearch}
|
||||
>
|
||||
{t('search')}
|
||||
</span>
|
||||
</button>
|
||||
{!isMobile && ']'}
|
||||
{openSearch && (
|
||||
<div className={styles.searchContainer}>
|
||||
<input
|
||||
ref={(el) => el?.focus()}
|
||||
type='text'
|
||||
aria-label={t('search')}
|
||||
value={inputValue}
|
||||
onChange={handleSearchChange}
|
||||
onKeyDown={(e) => {
|
||||
@@ -113,9 +114,9 @@ const CatalogSearch = () => {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
<button
|
||||
type='button'
|
||||
className={styles.closeSearch}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -126,7 +127,7 @@ const CatalogSearch = () => {
|
||||
onClick={handleCloseSearch}
|
||||
>
|
||||
✖
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -318,10 +318,10 @@ describe('ChallengeModal', () => {
|
||||
'https://mintpass.org',
|
||||
);
|
||||
|
||||
const doneButton = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'Done');
|
||||
const doneButton = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'Finish Challenge');
|
||||
expect(doneButton?.getAttribute('aria-label')).toBe('Finish challenge');
|
||||
|
||||
await clickButton('Done');
|
||||
await clickButton('Finish Challenge');
|
||||
expect(publication.publishChallengeAnswers).toHaveBeenCalledWith(['']);
|
||||
expect(testState.removeChallengeMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -105,6 +105,15 @@ interface IframeChallengeProps {
|
||||
}
|
||||
|
||||
type IframeOpenMode = 'trusted' | 'manual';
|
||||
type IframeFrameState = { url: string; origin: string };
|
||||
|
||||
const getIframeSandbox = (iframeOrigin: string) => {
|
||||
const permissions = ['allow-scripts', 'allow-forms', 'allow-popups', 'allow-top-navigation-by-user-activation'];
|
||||
if (iframeOrigin !== window.location.origin) {
|
||||
permissions.splice(3, 0, 'allow-same-origin');
|
||||
}
|
||||
return permissions.join(' ');
|
||||
};
|
||||
|
||||
const IframeChallenge = ({
|
||||
challenge,
|
||||
@@ -121,8 +130,7 @@ const IframeChallenge = ({
|
||||
const account = useAccount();
|
||||
const [theme] = useTheme();
|
||||
const trustOrigin = useTrustedBoardUrlPermissionsStore((state) => state.trustOrigin);
|
||||
const [iframeUrlState, setIframeUrl] = useState('');
|
||||
const [iframeOrigin, setIframeOrigin] = useState('');
|
||||
const [iframeFrame, setIframeFrame] = useState<IframeFrameState | null>(null);
|
||||
const [inlineErrorMessage, setInlineErrorMessage] = useState('');
|
||||
const [rememberPermission, setRememberPermission] = useState(false);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
@@ -144,8 +152,7 @@ const IframeChallenge = ({
|
||||
(validatedUrl: { finalUrl: string; origin: string }) => {
|
||||
queueMicrotask(() => {
|
||||
if (!mountedRef.current) return;
|
||||
setIframeUrl(validatedUrl.finalUrl);
|
||||
setIframeOrigin(validatedUrl.origin);
|
||||
setIframeFrame({ url: validatedUrl.finalUrl, origin: validatedUrl.origin });
|
||||
onReady();
|
||||
});
|
||||
},
|
||||
@@ -211,20 +218,21 @@ const IframeChallenge = ({
|
||||
}, [onCancel]);
|
||||
|
||||
const sendThemeToIframe = useCallback(() => {
|
||||
postThemeToIframe(iframeRef.current, iframeOrigin, theme);
|
||||
}, [iframeOrigin, theme]);
|
||||
postThemeToIframe(iframeRef.current, iframeFrame?.origin ?? '', theme);
|
||||
}, [iframeFrame?.origin, theme]);
|
||||
|
||||
const handleIframeLoad = () => {
|
||||
sendThemeToIframe();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (iframeRef.current && iframeUrlState && iframeOrigin) {
|
||||
if (iframeRef.current && iframeFrame) {
|
||||
sendThemeToIframe();
|
||||
}
|
||||
}, [iframeOrigin, iframeUrlState, sendThemeToIframe]);
|
||||
}, [iframeFrame, sendThemeToIframe]);
|
||||
|
||||
useEffect(() => {
|
||||
const iframeOrigin = iframeFrame?.origin ?? '';
|
||||
if (!iframeOrigin || !expectedSessionId) {
|
||||
handledAutoCompleteRef.current = false;
|
||||
return;
|
||||
@@ -246,9 +254,9 @@ const IframeChallenge = ({
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, [expectedSessionId, iframeOrigin]);
|
||||
}, [expectedSessionId, iframeFrame?.origin]);
|
||||
|
||||
if (!iframeUrlState) {
|
||||
if (!iframeFrame) {
|
||||
return (
|
||||
<>
|
||||
{publicationDetails}
|
||||
@@ -256,7 +264,12 @@ const IframeChallenge = ({
|
||||
<div className={styles.iframeConsentMessage}>{inlineErrorMessage || confirmMessage}</div>
|
||||
{!inlineErrorMessage && (
|
||||
<label className={styles.iframeTrustCheckbox}>
|
||||
<input type='checkbox' checked={rememberPermission} onChange={(event) => setRememberPermission(event.target.checked)} />
|
||||
<input
|
||||
type='checkbox'
|
||||
aria-label={rememberPermissionLabel}
|
||||
checked={rememberPermission}
|
||||
onChange={(event) => setRememberPermission(event.target.checked)}
|
||||
/>
|
||||
{rememberPermissionLabel}
|
||||
</label>
|
||||
)}
|
||||
@@ -282,8 +295,8 @@ const IframeChallenge = ({
|
||||
<div className={`${styles.challengeMediaWrapper} ${styles.iframeWrapper}`}>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={iframeUrlState}
|
||||
sandbox='allow-scripts allow-forms allow-popups allow-same-origin allow-top-navigation-by-user-activation'
|
||||
src={iframeFrame.url}
|
||||
sandbox={getIframeSandbox(iframeFrame.origin)}
|
||||
onLoad={handleIframeLoad}
|
||||
className={styles.iframe}
|
||||
title='Challenge authentication'
|
||||
@@ -291,8 +304,8 @@ const IframeChallenge = ({
|
||||
</div>
|
||||
<div className={`${styles.challengeFooter} ${styles.iframeFooter}`}>
|
||||
<div className={styles.iframeCloseButton}>
|
||||
<button aria-label='Finish challenge' onClick={onDone}>
|
||||
Done
|
||||
<button type='button' aria-label='Finish challenge' onClick={onDone}>
|
||||
Finish Challenge
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -473,21 +486,21 @@ const Challenge = ({ challenge, closeModal, abandonModal }: ChallengeProps) => {
|
||||
const publicationDetails = (
|
||||
<>
|
||||
<div className={styles.name}>
|
||||
<input type='text' value={displayName || capitalize(t('anonymous'))} disabled readOnly />
|
||||
<input type='text' aria-label={capitalize(t('name'))} value={displayName || capitalize(t('anonymous'))} disabled readOnly />
|
||||
</div>
|
||||
{title && (
|
||||
<div className={styles.subject}>
|
||||
<input type='text' value={title} disabled readOnly />
|
||||
<input type='text' aria-label={capitalize(t('subject'))} value={title} disabled readOnly />
|
||||
</div>
|
||||
)}
|
||||
{content && (
|
||||
<div className={styles.content}>
|
||||
<textarea value={content} disabled readOnly cols={48} rows={4} wrap='soft' />
|
||||
<textarea aria-label={capitalize(t('comment'))} value={content} disabled readOnly cols={48} rows={4} wrap='soft' />
|
||||
</div>
|
||||
)}
|
||||
{link && (
|
||||
<div className={styles.link}>
|
||||
<input type='text' value={link} disabled readOnly />
|
||||
<input type='text' aria-label={capitalize(t('link'))} value={link} disabled readOnly />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -533,6 +546,7 @@ const Challenge = ({ challenge, closeModal, abandonModal }: ChallengeProps) => {
|
||||
ref={inputRef}
|
||||
className={styles.challengeAnswer}
|
||||
type='text'
|
||||
aria-label={t('challenge_answer')}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
spellCheck='false'
|
||||
@@ -550,17 +564,23 @@ const Challenge = ({ challenge, closeModal, abandonModal }: ChallengeProps) => {
|
||||
<div className={styles.counter}>{t('challenge_counter', { index: currentChallengeIndex + 1, total: challenges?.length })}</div>
|
||||
<span className={styles.buttons}>
|
||||
{!challenges?.[currentChallengeIndex + 1] && (
|
||||
<button onClick={onSubmit} disabled={!isValidAnswer(currentChallengeIndex)}>
|
||||
<button type='button' onClick={onSubmit} disabled={!isValidAnswer(currentChallengeIndex)}>
|
||||
{t('submit')}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={abandonModal}>Cancel</button>
|
||||
<button type='button' onClick={abandonModal}>
|
||||
Cancel
|
||||
</button>
|
||||
{challenges?.length > 1 && (
|
||||
<button disabled={!challenges?.[currentChallengeIndex - 1]} onClick={() => setCurrentChallengeIndex((prev) => prev - 1)}>
|
||||
<button type='button' disabled={!challenges?.[currentChallengeIndex - 1]} onClick={() => setCurrentChallengeIndex((prev) => prev - 1)}>
|
||||
{t('previous')}
|
||||
</button>
|
||||
)}
|
||||
{challenges?.[currentChallengeIndex + 1] && <button onClick={() => setCurrentChallengeIndex((prev) => prev + 1)}>{t('next')}</button>}
|
||||
{challenges?.[currentChallengeIndex + 1] && (
|
||||
<button type='button' onClick={() => setCurrentChallengeIndex((prev) => prev + 1)}>
|
||||
{t('next')}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -393,41 +393,6 @@ describe('CommentContent', () => {
|
||||
expect(container.querySelector('[data-testid="tooltip"]')?.getAttribute('title')).toContain('ban:short:music-posting.eth:2024-01-01 12:00:00');
|
||||
});
|
||||
|
||||
it('shows and hides the original content for edited comments', async () => {
|
||||
await renderContent({
|
||||
cid: 'post-1',
|
||||
content: 'edited body',
|
||||
edit: {
|
||||
timestamp: 1_704_067_200,
|
||||
},
|
||||
original: {
|
||||
content: 'original body',
|
||||
},
|
||||
postCid: 'post-1',
|
||||
reason: 'typo',
|
||||
});
|
||||
|
||||
expect(queryMarkdownText()).toEqual(['edited body']);
|
||||
expect(container.textContent).toContain('comment_edited_at_timestamp:2024-01-01 12:00:00');
|
||||
expect(container.textContent).toContain('reason:typo');
|
||||
|
||||
const showOriginal = container.querySelector('[data-testid="trans-action-click_here_to_show_original"]');
|
||||
expect(showOriginal).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
showOriginal?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(queryMarkdownText()).toEqual(['original body']);
|
||||
|
||||
const hideOriginal = container.querySelector('[data-testid="trans-action-click_here_to_hide_original"]');
|
||||
await act(async () => {
|
||||
hideOriginal?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(queryMarkdownText()).toEqual(['edited body']);
|
||||
});
|
||||
|
||||
it('renders moderation and deletion states with the expected messaging', async () => {
|
||||
await renderContent({
|
||||
cid: 'post-1',
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { Fragment, type ReactNode, useMemo, useState } from 'react';
|
||||
import { type ReactNode, useMemo, useState } from 'react';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Comment, useComment } from '@bitsocial/bitsocial-react-hooks';
|
||||
import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
|
||||
import usePostNumberStore from '../../stores/use-post-number-store';
|
||||
import getShortAddress from '../../lib/get-short-address';
|
||||
import { getFormattedDate, getFormattedTimeAgo } from '../../lib/utils/time-utils';
|
||||
import { getFormattedDate } from '../../lib/utils/time-utils';
|
||||
import { isUnavailableQuoteTarget } from '../../lib/utils/quote-link-utils';
|
||||
import { isPostPageView } from '../../lib/utils/view-utils';
|
||||
import useIsMobile from '../../hooks/use-is-mobile';
|
||||
import useStateString from '../../hooks/use-state-string';
|
||||
import LoadingEllipsis from '../../components/loading-ellipsis';
|
||||
import LoadingEllipsis from '../loading-ellipsis';
|
||||
import BbcodeContent from '../../components/bbcode-content/bbcode-content';
|
||||
import ErrorDisplay from '../../components/error-display/error-display';
|
||||
import ReplyQuotePreview from '../../components/reply-quote-preview';
|
||||
import Markdown from '../../components/markdown';
|
||||
import Tooltip from '../../components/tooltip';
|
||||
import ReplyQuotePreview from '../reply-quote-preview';
|
||||
import Markdown from '../markdown';
|
||||
import Tooltip from '../tooltip';
|
||||
import styles from '../../views/post/post.module.css';
|
||||
import capitalize from 'lodash/capitalize';
|
||||
import { getCommentCommunityAddress, withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils';
|
||||
@@ -95,15 +95,15 @@ const CommentContent = ({
|
||||
const params = useParams();
|
||||
const location = useLocation();
|
||||
const isInPostView = isPostPageView(location.pathname, params);
|
||||
const [showOriginal, setShowOriginal] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const resolvedPost = withResolvedCommentCommunityAddress(post);
|
||||
|
||||
const { cid, content, deleted, edit, original, parentCid, postCid, pendingApproval, quotedCids, reason, removed, state } = resolvedPost || {};
|
||||
const { cid, content, deleted, parentCid, postCid, pendingApproval, quotedCids, reason, removed, state } = resolvedPost || {};
|
||||
const communityAddress = getCommentCommunityAddress(resolvedPost);
|
||||
const authorAddress = resolvedPost?.author?.address;
|
||||
const authorRole = getRoleByAddress(roles, authorAddress);
|
||||
const shouldRenderBbcode = hasModQueueAccessRole(authorRole);
|
||||
const isPrivilegedAuthor = hasModQueueAccessRole(authorRole);
|
||||
const shouldRenderBbcode = isPrivilegedAuthor;
|
||||
const purged = resolvedPost?.commentModeration?.purged;
|
||||
const banExpiresAt = resolvedPost?.author?.community?.banExpiresAt;
|
||||
const banned = !!banExpiresAt;
|
||||
@@ -111,9 +111,9 @@ const CommentContent = ({
|
||||
const [showFullComment, setShowFullComment] = useState(false);
|
||||
const displayContent =
|
||||
content &&
|
||||
(!isInPostView && content.length > 1000 && !showFullComment
|
||||
(!isInPostView && content.length > 1000 && !showFullComment && !isPrivilegedAuthor
|
||||
? content.slice(0, 1000)
|
||||
: isInPostView && content.length > 2000 && !showFullComment
|
||||
: isInPostView && content.length > 2000 && !showFullComment && !isPrivilegedAuthor
|
||||
? content.slice(0, 2000)
|
||||
: content);
|
||||
|
||||
@@ -214,7 +214,7 @@ const CommentContent = ({
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{!showOriginal && renderContent(displayContent)}
|
||||
{displayContent && renderContent(displayContent)}
|
||||
{pendingApproval && (
|
||||
<>
|
||||
<br />
|
||||
@@ -234,7 +234,7 @@ const CommentContent = ({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{((!isInPostView && content?.length > 1000 && !showFullComment) || (isInPostView && content?.length > 2000 && !showFullComment)) && (
|
||||
{((!isInPostView && content?.length > 1000 && !showFullComment) || (isInPostView && content?.length > 2000 && !showFullComment)) && !isPrivilegedAuthor && (
|
||||
<span className={styles.abbr}>
|
||||
<br />
|
||||
<br />
|
||||
@@ -243,9 +243,10 @@ const CommentContent = ({
|
||||
shouldUnescape={true}
|
||||
components={{
|
||||
1: (
|
||||
<span
|
||||
<button
|
||||
type='button'
|
||||
key={cid}
|
||||
role='button'
|
||||
aria-label={t('view')}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -260,71 +261,6 @@ const CommentContent = ({
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{edit && original?.content !== content && (
|
||||
<span className={styles.editedInfo}>
|
||||
{showOriginal && renderContent(original?.content)}
|
||||
<br />
|
||||
<br />
|
||||
<Trans
|
||||
i18nKey={'comment_edited_at_timestamp'}
|
||||
values={{ timestamp: getFormattedDate(edit?.timestamp) }}
|
||||
shouldUnescape={true}
|
||||
components={{
|
||||
1: (
|
||||
<Tooltip key={edit?.timestamp} content={getFormattedTimeAgo(edit?.timestamp)}>
|
||||
<Fragment key={edit?.timestamp}></Fragment>
|
||||
</Tooltip>
|
||||
),
|
||||
}}
|
||||
/>{' '}
|
||||
{reason && <>{t('reason_reason', { reason: reason, interpolation: { escapeValue: false } })} </>}
|
||||
{showOriginal ? (
|
||||
<Trans
|
||||
i18nKey={'click_here_to_hide_original'}
|
||||
shouldUnescape={true}
|
||||
components={{
|
||||
1: (
|
||||
<span
|
||||
key={cid}
|
||||
className={styles.showOriginal}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowOriginal(!showOriginal);
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowOriginal(!showOriginal)}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey={'click_here_to_show_original'}
|
||||
shouldUnescape={true}
|
||||
components={{
|
||||
1: (
|
||||
<span
|
||||
key={cid}
|
||||
className={styles.showOriginal}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowOriginal(!showOriginal);
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowOriginal(!showOriginal)}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{banned && (
|
||||
|
||||
@@ -174,7 +174,7 @@ describe('CommentMedia', () => {
|
||||
|
||||
expect(container.querySelector('img[src="assets/filedeleted-res.gif"]')).toBeTruthy();
|
||||
expect(onMediaLoadFailureChange).toHaveBeenCalledWith('https://files.catbox.moe/missing.jpg');
|
||||
const status = container.querySelector('[role="status"]');
|
||||
const status = container.querySelector('output');
|
||||
expect(status?.textContent).toContain('media_failed_to_load_inline:files.catbox.moe');
|
||||
expect(status?.textContent).toContain('media_failed_to_load_open_source');
|
||||
expect(status?.textContent).not.toContain('media_failed_to_load_hint');
|
||||
@@ -209,7 +209,7 @@ describe('CommentMedia', () => {
|
||||
image?.dispatchEvent(new Event('error', { bubbles: true }));
|
||||
});
|
||||
|
||||
const status = container.querySelector('[role="status"]');
|
||||
const status = container.querySelector('output');
|
||||
expect(status?.textContent).toBe('');
|
||||
expect(status?.getAttribute('aria-label')).toBe(
|
||||
'media_failed_to_load. media_failed_to_load_source:files.catbox.moe. media_failed_to_load_hint. media_failed_to_load_open_source.',
|
||||
@@ -273,7 +273,7 @@ describe('CommentMedia', () => {
|
||||
|
||||
expect(container.textContent).toContain('example.com');
|
||||
|
||||
const fallbackButton = Array.from(container.querySelectorAll('span')).find((node) => node.textContent === 'example.com' && node.getAttribute('role') === 'button');
|
||||
const fallbackButton = Array.from(container.querySelectorAll('button')).find((node) => node.textContent === 'example.com');
|
||||
await act(async () => {
|
||||
fallbackButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
@@ -296,7 +296,7 @@ describe('CommentMedia', () => {
|
||||
|
||||
expect(container.querySelector('[data-testid="embed"]')?.textContent).toContain('https://youtu.be/test');
|
||||
|
||||
const closeButton = Array.from(container.querySelectorAll('span')).find((node) => node.textContent === 'close');
|
||||
const closeButton = Array.from(container.querySelectorAll('button')).find((node) => node.textContent === 'close');
|
||||
expect(closeButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -55,14 +55,31 @@
|
||||
}
|
||||
|
||||
.thumbnailSmall span,
|
||||
.thumbnailSmall a {
|
||||
.thumbnailSmall a,
|
||||
.thumbnailSmall .noThumbnailButton {
|
||||
width: 100%;
|
||||
display: inline-block;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.noThumbnailButton {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.noThumbnailButton:focus-visible {
|
||||
outline: 1px dotted var(--post-link-text-color);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
float: left;
|
||||
}
|
||||
@@ -75,6 +92,33 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mediaToggleButton {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.mediaToggleButton:focus-visible {
|
||||
outline: 1px dotted var(--post-link-text-color);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.mediaToggleButton img,
|
||||
.mediaToggleButton video {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.thumbnailBig > .mediaToggleButton,
|
||||
.thumbnailSmall > .mediaToggleButton {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.hide {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -50,13 +50,13 @@ const MediaLoadFailure = ({ compact = false, url }: { compact?: boolean; url?: s
|
||||
return (
|
||||
<>
|
||||
<img className={styles.fileDeleted} src='assets/filedeleted-res.gif' alt='' aria-hidden='true' />
|
||||
<span className={styles.mediaLoadFailureStatus} role='status' aria-label={statusLabel} title={statusLabel} />
|
||||
<output className={styles.mediaLoadFailureStatus} aria-label={statusLabel} title={statusLabel} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={styles.mediaLoadFailure} role='status' aria-label={statusLabel} title={statusLabel}>
|
||||
<output className={styles.mediaLoadFailure} aria-label={statusLabel} title={statusLabel}>
|
||||
<img className={styles.fileDeleted} src='assets/filedeleted-res.gif' alt='' aria-hidden='true' />
|
||||
<span className={styles.mediaLoadFailureSource}>{inline}</span>
|
||||
{url && (
|
||||
@@ -64,7 +64,7 @@ const MediaLoadFailure = ({ compact = false, url }: { compact?: boolean; url?: s
|
||||
{openSourceLabel}
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
</output>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -109,92 +109,40 @@ const Thumbnail = ({
|
||||
const iframeThumbnail = patternThumbnailUrl || thumbnail;
|
||||
const { frameUrl: gifFrameUrl, status: gifFrameStatus } = gifFrameState;
|
||||
const hasThumbnail = getHasThumbnail(commentMediaInfo, url);
|
||||
const gifThumbnailButtonProps =
|
||||
gifFrameStatus === 'loading'
|
||||
? {
|
||||
role: 'button' as const,
|
||||
tabIndex: 0,
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLSpanElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowThumbnail(false);
|
||||
}
|
||||
},
|
||||
onClick: () => setShowThumbnail(false),
|
||||
}
|
||||
: {};
|
||||
const handleOpenMedia = () => setShowThumbnail(false);
|
||||
|
||||
if (type === 'gif') {
|
||||
thumbnailComponent =
|
||||
gifFrameStatus === 'loading' ? (
|
||||
<span className={styles.gifPlaceholder} aria-label='Loading GIF thumbnail' {...gifThumbnailButtonProps} />
|
||||
<button type='button' className={`${styles.gifPlaceholder} ${styles.mediaToggleButton}`} aria-label='Loading GIF thumbnail' onClick={handleOpenMedia} />
|
||||
) : (
|
||||
<img
|
||||
src={gifFrameUrl || url}
|
||||
alt=''
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowThumbnail(false);
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowThumbnail(false)}
|
||||
/>
|
||||
<button type='button' className={styles.mediaToggleButton} aria-label='Open GIF' onClick={handleOpenMedia}>
|
||||
<img src={gifFrameUrl || url} alt='' />
|
||||
</button>
|
||||
);
|
||||
} else if (type === 'video') {
|
||||
thumbnailComponent = thumbnail ? (
|
||||
<img src={thumbnail} alt='' />
|
||||
<img src={thumbnail} alt='Video thumbnail' />
|
||||
) : (
|
||||
// show first frame of the video, as a workaround for Safari not loading thumbnails
|
||||
<video
|
||||
src={`${url}#t=0.001`}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowThumbnail(false);
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowThumbnail(false)}
|
||||
/>
|
||||
<button type='button' className={styles.mediaToggleButton} aria-label='Open video' onClick={handleOpenMedia}>
|
||||
<video src={`${url}#t=0.001`} aria-label='Video thumbnail' />
|
||||
</button>
|
||||
);
|
||||
} else if (type === 'webpage') {
|
||||
thumbnailComponent = (
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt=''
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowThumbnail(false);
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowThumbnail(false)}
|
||||
/>
|
||||
<button type='button' className={styles.mediaToggleButton} aria-label='Open webpage preview' onClick={handleOpenMedia}>
|
||||
<img src={thumbnail} alt='' />
|
||||
</button>
|
||||
);
|
||||
} else if (type === 'iframe') {
|
||||
thumbnailComponent = iframeThumbnail ? (
|
||||
<img
|
||||
src={iframeThumbnail}
|
||||
alt=''
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowThumbnail(false);
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowThumbnail(false)}
|
||||
/>
|
||||
<button type='button' className={styles.mediaToggleButton} aria-label='Open embedded media preview' onClick={handleOpenMedia}>
|
||||
<img src={iframeThumbnail} alt='' />
|
||||
</button>
|
||||
) : null;
|
||||
} else if (type === 'audio') {
|
||||
thumbnailComponent = <audio src={url} controls />;
|
||||
thumbnailComponent = <audio src={url} aria-label='Audio preview' controls />;
|
||||
}
|
||||
|
||||
const thumbnailSmallPadding = isMobile ? styles.thumbnailMobile : styles.thumbnailReplyDesktop;
|
||||
@@ -204,8 +152,9 @@ const Thumbnail = ({
|
||||
const noThumbnailLink =
|
||||
!hasThumbnail && linkWithoutThumbnail ? (
|
||||
canEmbed(linkWithoutThumbnail) ? (
|
||||
<span
|
||||
role='button'
|
||||
<button
|
||||
type='button'
|
||||
className={styles.noThumbnailButton}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -216,7 +165,7 @@ const Thumbnail = ({
|
||||
onClick={() => setShowThumbnail(false)}
|
||||
>
|
||||
{fallbackLinkLabel}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<a href={url} target='_blank' rel='noopener noreferrer'>
|
||||
{fallbackLinkLabel}
|
||||
@@ -227,20 +176,9 @@ const Thumbnail = ({
|
||||
return deleted || removed || purged ? (
|
||||
<img className={styles.fileDeleted} src='assets/filedeleted-res.gif' alt='File deleted' />
|
||||
) : spoiler ? (
|
||||
<img
|
||||
className={styles.spoiler}
|
||||
src='assets/spoiler.png'
|
||||
alt=''
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowThumbnail(false);
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowThumbnail(false)}
|
||||
/>
|
||||
<button type='button' className={styles.mediaToggleButton} aria-label='Open spoiler media' onClick={handleOpenMedia}>
|
||||
<img className={styles.spoiler} src='assets/spoiler.png' alt='' />
|
||||
</button>
|
||||
) : isOutOfFeed ? (
|
||||
<span className={`${isFloatingEmbed ? styles.floatingEmbed : styles.communityAvatar}`}>{thumbnailComponent}</span>
|
||||
) : isMobile || isReply ? (
|
||||
@@ -272,43 +210,23 @@ const Media = ({ commentMediaInfo, disableToggle, isReply, setShowThumbnail }: M
|
||||
{type === 'iframe' && url ? (
|
||||
<Embed url={url} />
|
||||
) : type === 'gif' ? (
|
||||
<img
|
||||
src={url}
|
||||
alt=''
|
||||
role={disableToggle ? undefined : 'button'}
|
||||
tabIndex={disableToggle ? undefined : 0}
|
||||
onKeyDown={
|
||||
disableToggle
|
||||
? undefined
|
||||
: (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowThumbnail(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
onClick={disableToggle ? undefined : () => setShowThumbnail(true)}
|
||||
/>
|
||||
disableToggle ? (
|
||||
<img src={url} alt='' />
|
||||
) : (
|
||||
<button type='button' className={styles.mediaToggleButton} aria-label='Collapse GIF' onClick={() => setShowThumbnail(true)}>
|
||||
<img src={url} alt='' />
|
||||
</button>
|
||||
)
|
||||
) : type === 'video' ? (
|
||||
<video src={url} controls autoPlay loop muted={!unmuteExpandedVideoSound} />
|
||||
<video src={url} aria-label={t('video')} controls autoPlay loop muted={!unmuteExpandedVideoSound} />
|
||||
) : type === 'webpage' ? (
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt=''
|
||||
role={disableToggle ? undefined : 'button'}
|
||||
tabIndex={disableToggle ? undefined : 0}
|
||||
onKeyDown={
|
||||
disableToggle
|
||||
? undefined
|
||||
: (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setShowThumbnail(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
onClick={disableToggle ? undefined : () => setShowThumbnail(true)}
|
||||
/>
|
||||
disableToggle ? (
|
||||
<img src={thumbnail} alt='' />
|
||||
) : (
|
||||
<button type='button' className={styles.mediaToggleButton} aria-label='Collapse webpage preview' onClick={() => setShowThumbnail(true)}>
|
||||
<img src={thumbnail} alt='' />
|
||||
</button>
|
||||
)
|
||||
) : null}
|
||||
{isMobile && type && (
|
||||
<div className={styles.fileInfo}>
|
||||
@@ -321,9 +239,9 @@ const Media = ({ commentMediaInfo, disableToggle, isReply, setShowThumbnail }: M
|
||||
)}
|
||||
{isMobile && (type === 'iframe' || type === 'video' || type === 'audio') && (
|
||||
<div className={styles.closeButton}>
|
||||
<span
|
||||
<button
|
||||
type='button'
|
||||
className='button'
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -334,7 +252,7 @@ const Media = ({ commentMediaInfo, disableToggle, isReply, setShowThumbnail }: M
|
||||
onClick={() => setShowThumbnail(true)}
|
||||
>
|
||||
{t('close')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
@@ -397,20 +315,9 @@ const Image = ({
|
||||
className={`${isOutOfFeed ? styles.communityAvatar : styles.thumbnailBig} ${styles.thumbnail} ${isImageExpanded && isMobile ? styles.removeFloat : ''}`}
|
||||
style={spoilerDimensions}
|
||||
>
|
||||
<img
|
||||
className={styles.spoiler}
|
||||
src='assets/spoiler.png'
|
||||
alt=''
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setIsImageExpanded(true);
|
||||
}
|
||||
}}
|
||||
onClick={() => setIsImageExpanded(true)}
|
||||
/>
|
||||
<button type='button' className={styles.mediaToggleButton} aria-label='Open spoiler media' onClick={() => setIsImageExpanded(true)}>
|
||||
<img className={styles.spoiler} src='assets/spoiler.png' alt='' />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -424,26 +331,17 @@ const Image = ({
|
||||
>
|
||||
{hasError ? (
|
||||
<MediaLoadFailure compact url={url} />
|
||||
) : disableToggle ? (
|
||||
<img src={url} onError={handleError} onLoad={handleLoad} alt='' />
|
||||
) : (
|
||||
<img
|
||||
src={url}
|
||||
onError={handleError}
|
||||
onLoad={handleLoad}
|
||||
alt=''
|
||||
role={disableToggle ? undefined : 'button'}
|
||||
tabIndex={disableToggle ? undefined : 0}
|
||||
onKeyDown={
|
||||
disableToggle
|
||||
? undefined
|
||||
: (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setIsImageExpanded(!isImageExpanded);
|
||||
}
|
||||
}
|
||||
}
|
||||
onClick={disableToggle ? undefined : () => setIsImageExpanded(!isImageExpanded)}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
className={styles.mediaToggleButton}
|
||||
aria-label={isImageExpanded ? 'Collapse image' : 'Expand image'}
|
||||
onClick={() => setIsImageExpanded(!isImageExpanded)}
|
||||
>
|
||||
<img src={url} onError={handleError} onLoad={handleLoad} alt='' />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
{isImageExpanded && type && (
|
||||
@@ -465,26 +363,17 @@ const Image = ({
|
||||
>
|
||||
{hasError ? (
|
||||
<MediaLoadFailure url={url} />
|
||||
) : disableToggle ? (
|
||||
<img src={url} onError={handleError} onLoad={handleLoad} alt='' />
|
||||
) : (
|
||||
<img
|
||||
src={url}
|
||||
onError={handleError}
|
||||
onLoad={handleLoad}
|
||||
alt=''
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={
|
||||
disableToggle
|
||||
? undefined
|
||||
: (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setIsImageExpanded(!isImageExpanded);
|
||||
}
|
||||
}
|
||||
}
|
||||
onClick={disableToggle ? undefined : () => setIsImageExpanded(!isImageExpanded)}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
className={styles.mediaToggleButton}
|
||||
aria-label={isImageExpanded ? 'Collapse image' : 'Expand image'}
|
||||
onClick={() => setIsImageExpanded(!isImageExpanded)}
|
||||
>
|
||||
<img src={url} onError={handleError} onLoad={handleLoad} alt='' />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.backdropButton {
|
||||
all: unset;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
cursor: default;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.createBoardDialog {
|
||||
position: absolute;
|
||||
width: 500px;
|
||||
@@ -22,7 +30,9 @@
|
||||
box-shadow: rgba(0, 0, 0, 0.1) 2px 2px 0px 1px;
|
||||
font-size: var(--body-font-size);
|
||||
line-height: 16.003px;
|
||||
z-index: 1000;
|
||||
z-index: 1;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
@@ -122,4 +132,3 @@
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,29 +8,13 @@ const CreateBoardModal = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
closeCreateBoardModal();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.backdrop}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
closeCreateBoardModal();
|
||||
}
|
||||
}}
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className={styles.createBoardDialog} role='dialog' aria-modal='true' aria-labelledby='create-board-modal-title'>
|
||||
<div className={styles.backdrop}>
|
||||
<button type='button' className={styles.backdropButton} aria-label='Close create board modal' onClick={closeCreateBoardModal} />
|
||||
<dialog open className={styles.createBoardDialog} aria-modal='true' aria-labelledby='create-board-modal-title'>
|
||||
<div className={styles.hd}>
|
||||
<h2 id='create-board-modal-title'>Create a Board</h2>
|
||||
<button className={styles.closeButton} onClick={closeCreateBoardModal} title='Close' aria-label='Close' />
|
||||
<button type='button' className={styles.closeButton} onClick={closeCreateBoardModal} title='Close' aria-label='Close' />
|
||||
</div>
|
||||
<div className={styles.bd}>
|
||||
<div className={styles.section}>
|
||||
@@ -73,9 +57,11 @@ const CreateBoardModal = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.createBoardFooter}>
|
||||
<button onClick={closeCreateBoardModal}>Close</button>
|
||||
</div>
|
||||
<button type='button' onClick={closeCreateBoardModal}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,12 +12,23 @@
|
||||
background-color: rgba(128, 128, 128, 0.5);
|
||||
}
|
||||
|
||||
.directoryDialog {
|
||||
.backdropButton {
|
||||
all: unset;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
cursor: default;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.directoryDialog {
|
||||
position: fixed;
|
||||
width: 500px;
|
||||
top: 50px;
|
||||
max-width: calc(100% - 20px);
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow-y: auto;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin-left: -251px;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: var(--directory-modal-background-color, #f0e0d6);
|
||||
border: var(--directory-modal-border, 1px solid #d9bfb7);
|
||||
color: var(--directory-modal-text-color, #800);
|
||||
@@ -25,7 +36,9 @@
|
||||
font-family: arial, helvetica, clean, sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
z-index: 1000;
|
||||
z-index: 1;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
@@ -34,10 +47,7 @@
|
||||
max-width: 500px;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
margin-left: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,5 +142,3 @@
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -11,29 +11,19 @@ const DirectoryModal = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
closeDirectoryModal();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.backdrop} ${isHomeView ? styles.backdropHome : ''}`}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
closeDirectoryModal();
|
||||
}
|
||||
}}
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className={styles.directoryDialog} role='dialog' aria-modal='true' aria-labelledby='directory-modal-title'>
|
||||
<div className={`${styles.backdrop} ${isHomeView ? styles.backdropHome : ''}`}>
|
||||
<button type='button' className={styles.backdropButton} aria-label='Close directory modal' onClick={closeDirectoryModal} />
|
||||
<dialog open className={styles.directoryDialog} aria-modal='true' aria-labelledby='directory-modal-title'>
|
||||
<div className={styles.hd}>
|
||||
<h2 id='directory-modal-title'>Submit a Board to a Directory</h2>
|
||||
<button className={`${styles.closeButton} ${isHomeView ? styles.closeButtonHome : ''}`} onClick={closeDirectoryModal} title='Close' aria-label='Close' />
|
||||
<button
|
||||
type='button'
|
||||
className={`${styles.closeButton} ${isHomeView ? styles.closeButtonHome : ''}`}
|
||||
onClick={closeDirectoryModal}
|
||||
title='Close'
|
||||
aria-label='Close'
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.bd}>
|
||||
<p className={styles.introMessage}>
|
||||
@@ -74,9 +64,11 @@ const DirectoryModal = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.directoryFooter}>
|
||||
<button onClick={closeDirectoryModal}>Close</button>
|
||||
</div>
|
||||
<button type='button' onClick={closeDirectoryModal}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,12 +8,23 @@
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.disclaimerDialog {
|
||||
.backdropButton {
|
||||
all: unset;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
cursor: default;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.disclaimerDialog {
|
||||
position: fixed;
|
||||
width: 300px;
|
||||
top: 50px;
|
||||
max-width: calc(100% - 20px);
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow-y: auto;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin-left: -151px;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: var(--disclaimer-modal-background-color, rgb(255, 255, 255));
|
||||
border: var(--disclaimer-modal-border, 1px solid rgb(136, 0, 0));
|
||||
color: var(--disclaimer-modal-text-color, rgb(136, 0, 0));
|
||||
@@ -21,7 +32,9 @@
|
||||
font-family: arial, helvetica, clean, sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 16.003px;
|
||||
z-index: 1000;
|
||||
z-index: 1;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.hd {
|
||||
|
||||
@@ -14,12 +14,6 @@ const DisclaimerModal = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
closeDisclaimerModal();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccept = () => {
|
||||
// If there's no board path (placeholder), show directory modal instead
|
||||
if (!targetBoardPath) {
|
||||
@@ -31,22 +25,18 @@ const DisclaimerModal = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.backdrop}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
closeDisclaimerModal();
|
||||
}
|
||||
}}
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className={styles.disclaimerDialog} role='dialog' aria-modal='true' aria-labelledby='disclaimer-modal-title'>
|
||||
<div className={styles.backdrop}>
|
||||
<button type='button' className={styles.backdropButton} aria-label='Close disclaimer modal' onClick={closeDisclaimerModal} />
|
||||
<dialog open className={styles.disclaimerDialog} aria-modal='true' aria-labelledby='disclaimer-modal-title'>
|
||||
<div className={styles.hd}>
|
||||
<h2 id='disclaimer-modal-title'>Disclaimer</h2>
|
||||
<button className={`${styles.closeButton} ${isHomeView ? styles.closeButtonHome : ''}`} onClick={closeDisclaimerModal} title='Close' aria-label='Close' />
|
||||
<button
|
||||
type='button'
|
||||
className={`${styles.closeButton} ${isHomeView ? styles.closeButtonHome : ''}`}
|
||||
onClick={closeDisclaimerModal}
|
||||
title='Close'
|
||||
aria-label='Close'
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.bd}>
|
||||
<p>This web application may contain content for mature audiences only. By clicking "Accept," you confirm that:</p>
|
||||
@@ -57,10 +47,14 @@ const DisclaimerModal = () => {
|
||||
</ol>
|
||||
</div>
|
||||
<div className={styles.disclaimerFooter}>
|
||||
<button onClick={handleAccept}>Accept</button>
|
||||
<button onClick={closeDisclaimerModal}>Cancel</button>
|
||||
</div>
|
||||
<button type='button' onClick={handleAccept}>
|
||||
Accept
|
||||
</button>
|
||||
<button type='button' onClick={closeDisclaimerModal}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -19,8 +19,6 @@ import useAuthorPrivileges from '../../hooks/use-author-privileges';
|
||||
import { useBoardPseudonymityMode } from '../../hooks/use-board-pseudonymity-mode';
|
||||
import { getCommentCommunityAddress, withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils';
|
||||
|
||||
const { addChallenge } = useChallengesStore.getState();
|
||||
|
||||
const daysToTimestampInSeconds = (days: number) => {
|
||||
const now = new Date();
|
||||
now.setDate(now.getDate() + days);
|
||||
@@ -60,7 +58,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
||||
useEffect(() => {
|
||||
latestPostRef.current = resolvedPost;
|
||||
}, [resolvedPost]);
|
||||
const onChallenge = useCallback((...args: any) => addChallenge([...args, latestPostRef.current]), []);
|
||||
const onChallenge = useCallback((...args: any) => useChallengesStore.getState().addChallenge([...args, latestPostRef.current]), []);
|
||||
|
||||
const defaultPublishEditOptions = useMemo(() => {
|
||||
return {
|
||||
@@ -102,6 +100,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
||||
pinned,
|
||||
reason,
|
||||
removed,
|
||||
parentCid,
|
||||
purged,
|
||||
spoiler,
|
||||
communityAddress,
|
||||
@@ -289,6 +288,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
||||
<span className={`${styles.checkbox} ${parentCid && styles.replyCheckbox}`} ref={refs.setReference} {...(cid && getReferenceProps())}>
|
||||
<input
|
||||
type='checkbox'
|
||||
aria-label={t('edit')}
|
||||
onChange={() => {
|
||||
if (cid && canOpenEditMenu) {
|
||||
if (!isEditMenuOpen) {
|
||||
@@ -315,7 +315,13 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
||||
<div className={styles.menuItem}>
|
||||
<label>
|
||||
[
|
||||
<input onChange={onCheckbox} checked={publishCommentEditOptions.deleted ?? false} type='checkbox' id='deleted' />
|
||||
<input
|
||||
aria-label={capitalize(t('delete'))}
|
||||
onChange={onCheckbox}
|
||||
checked={publishCommentEditOptions.deleted ?? false}
|
||||
type='checkbox'
|
||||
id='deleted'
|
||||
/>
|
||||
{capitalize(t('delete'))}?]
|
||||
</label>
|
||||
</div>
|
||||
@@ -326,13 +332,19 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
||||
<div className={styles.menuItem}>
|
||||
<label>
|
||||
[
|
||||
<input type='checkbox' onChange={() => setIsContentEditorOpen(!isContentEditorOpen)} checked={isContentEditorOpen} />
|
||||
<input
|
||||
type='checkbox'
|
||||
aria-label={capitalize(t('edit'))}
|
||||
onChange={() => setIsContentEditorOpen(!isContentEditorOpen)}
|
||||
checked={isContentEditorOpen}
|
||||
/>
|
||||
{capitalize(t('edit'))}?]
|
||||
</label>
|
||||
</div>
|
||||
{isContentEditorOpen && (
|
||||
<div>
|
||||
<textarea
|
||||
aria-label={capitalize(t('edit'))}
|
||||
className={styles.editTextarea}
|
||||
value={publishCommentEditOptions.content || ''}
|
||||
onChange={(e) => {
|
||||
@@ -349,13 +361,25 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
||||
<div className={styles.menuItem}>
|
||||
<label>
|
||||
[
|
||||
<input onChange={onCheckbox} checked={publishCommentEditOptions.commentModeration?.removed ?? false} type='checkbox' id='removed' />
|
||||
<input
|
||||
aria-label={capitalize(t('remove'))}
|
||||
onChange={onCheckbox}
|
||||
checked={publishCommentEditOptions.commentModeration?.removed ?? false}
|
||||
type='checkbox'
|
||||
id='removed'
|
||||
/>
|
||||
{capitalize(t('remove'))}?]
|
||||
</label>{' '}
|
||||
<span className={styles.purgeItem}>
|
||||
<label>
|
||||
[
|
||||
<input onChange={onPurgeCheckbox} checked={publishCommentEditOptions.commentModeration?.purged ?? false} type='checkbox' id='purged' />
|
||||
<input
|
||||
aria-label={capitalize(t('purge'))}
|
||||
onChange={onPurgeCheckbox}
|
||||
checked={publishCommentEditOptions.commentModeration?.purged ?? false}
|
||||
type='checkbox'
|
||||
id='purged'
|
||||
/>
|
||||
{capitalize(t('purge'))}?]
|
||||
</label>
|
||||
</span>
|
||||
@@ -364,7 +388,13 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
||||
<div className={styles.menuItem}>
|
||||
[
|
||||
<label>
|
||||
<input onChange={onCheckbox} checked={publishCommentEditOptions.commentModeration?.locked ?? false} type='checkbox' id='locked' />
|
||||
<input
|
||||
aria-label={capitalize(t('close_thread'))}
|
||||
onChange={onCheckbox}
|
||||
checked={publishCommentEditOptions.commentModeration?.locked ?? false}
|
||||
type='checkbox'
|
||||
id='locked'
|
||||
/>
|
||||
{capitalize(t('close_thread'))}?
|
||||
</label>
|
||||
]
|
||||
@@ -373,7 +403,13 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
||||
<div className={styles.menuItem}>
|
||||
[
|
||||
<label>
|
||||
<input onChange={onCheckbox} checked={publishCommentEditOptions.commentModeration?.spoiler ?? false} type='checkbox' id='spoiler' />
|
||||
<input
|
||||
aria-label={capitalize(t('spoiler'))}
|
||||
onChange={onCheckbox}
|
||||
checked={publishCommentEditOptions.commentModeration?.spoiler ?? false}
|
||||
type='checkbox'
|
||||
id='spoiler'
|
||||
/>
|
||||
{capitalize(t('spoiler'))}?
|
||||
</label>
|
||||
]
|
||||
@@ -382,7 +418,13 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
||||
<div className={styles.menuItem}>
|
||||
[
|
||||
<label>
|
||||
<input onChange={onCheckbox} checked={publishCommentEditOptions.commentModeration?.archived ?? false} type='checkbox' id='archived' />
|
||||
<input
|
||||
aria-label={capitalize(t('archived'))}
|
||||
onChange={onCheckbox}
|
||||
checked={publishCommentEditOptions.commentModeration?.archived ?? false}
|
||||
type='checkbox'
|
||||
id='archived'
|
||||
/>
|
||||
{capitalize(t('archived'))}?
|
||||
</label>
|
||||
]
|
||||
@@ -391,7 +433,13 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
||||
<div className={styles.menuItem}>
|
||||
[
|
||||
<label>
|
||||
<input onChange={onCheckbox} checked={publishCommentEditOptions.commentModeration?.pinned ?? false} type='checkbox' id='pinned' />
|
||||
<input
|
||||
aria-label={capitalize(t('sticky'))}
|
||||
onChange={onCheckbox}
|
||||
checked={publishCommentEditOptions.commentModeration?.pinned ?? false}
|
||||
type='checkbox'
|
||||
id='pinned'
|
||||
/>
|
||||
{capitalize(t('sticky'))}?
|
||||
</label>
|
||||
]
|
||||
@@ -402,6 +450,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
||||
<label>
|
||||
<input
|
||||
onChange={onCheckbox}
|
||||
aria-label={capitalize(t('ban'))}
|
||||
checked={publishCommentEditOptions.commentModeration?.author?.banExpiresAt !== undefined}
|
||||
type='checkbox'
|
||||
id='banUser'
|
||||
@@ -414,6 +463,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
||||
<input
|
||||
key='ban-duration-input'
|
||||
className={styles.banInput}
|
||||
aria-label={t('ban_duration_days')}
|
||||
onChange={onBanDurationChange}
|
||||
type='number'
|
||||
min={1}
|
||||
@@ -433,6 +483,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
||||
{capitalize(t('reason'))}? ({t('optional')})
|
||||
<input
|
||||
type='text'
|
||||
aria-label={capitalize(t('reason'))}
|
||||
value={publishCommentEditOptions.commentModeration?.reason || ''}
|
||||
onChange={(e) => {
|
||||
const newReason = e.target.value;
|
||||
@@ -451,7 +502,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
||||
</>
|
||||
)}
|
||||
<div className={styles.bottom}>
|
||||
<button className={isMobile ? 'button' : ''} onClick={_publishCommentEdit}>
|
||||
<button type='button' className={isMobile ? 'button' : ''} onClick={_publishCommentEdit}>
|
||||
{t('save')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,8 @@ import * as React from 'react';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import Embed, { canEmbed } from '../embed';
|
||||
import Embed from '../embed';
|
||||
import { canEmbed } from '../embed-utils';
|
||||
|
||||
(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>;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
export const youtubeHosts = new Set<string>([
|
||||
'youtube.com',
|
||||
'www.youtube.com',
|
||||
'youtu.be',
|
||||
'www.youtu.be',
|
||||
'm.youtube.com',
|
||||
'music.youtube.com',
|
||||
// working Invidious instances - https://docs.invidious.io/instances/ - https://uptime.invidious.io/
|
||||
'yewtu.be',
|
||||
'inv.nadeko.net',
|
||||
'yt.artemislena.eu',
|
||||
'invidious.nerdvpn.de',
|
||||
]);
|
||||
|
||||
export const xHosts = new Set<string>(['twitter.com', 'www.twitter.com', 'x.com', 'www.x.com']);
|
||||
|
||||
export const getXTweetId = (parsedUrl: URL): string | undefined => {
|
||||
const pathParts = parsedUrl.pathname.split('/').filter(Boolean);
|
||||
const statusIndex = pathParts.findIndex((part) => part === 'status' || part === 'statuses');
|
||||
const statusId = statusIndex === -1 ? undefined : pathParts[statusIndex + 1];
|
||||
|
||||
return statusId && /^\d+$/.test(statusId) ? statusId : undefined;
|
||||
};
|
||||
|
||||
export const redditHosts = new Set<string>(['reddit.com', 'www.reddit.com', 'old.reddit.com']);
|
||||
export const twitchHosts = new Set<string>(['twitch.tv', 'www.twitch.tv']);
|
||||
export const tiktokHosts = new Set<string>(['tiktok.com', 'www.tiktok.com']);
|
||||
export const instagramHosts = new Set<string>(['instagram.com', 'www.instagram.com']);
|
||||
export const odyseeHosts = new Set<string>(['odysee.com', 'www.odysee.com']);
|
||||
export const bitchuteHosts = new Set<string>(['bitchute.com', 'www.bitchute.com']);
|
||||
export const streamableHosts = new Set<string>(['streamable.com', 'www.streamable.com']);
|
||||
export const spotifyHosts = new Set<string>(['spotify.com', 'www.spotify.com', 'open.spotify.com']);
|
||||
export const soundcloudHosts = new Set<string>(['soundcloud.com', 'www.soundcloud.com', 'on.soundcloud.com', 'api.soundcloud.com', 'w.soundcloud.com']);
|
||||
|
||||
const canEmbedHosts = new Set<string>([
|
||||
...youtubeHosts,
|
||||
...xHosts,
|
||||
...redditHosts,
|
||||
...twitchHosts,
|
||||
...tiktokHosts,
|
||||
...instagramHosts,
|
||||
...odyseeHosts,
|
||||
...bitchuteHosts,
|
||||
...soundcloudHosts,
|
||||
...streamableHosts,
|
||||
...spotifyHosts,
|
||||
]);
|
||||
|
||||
export const canEmbed = (parsedUrl: URL): boolean => {
|
||||
if (xHosts.has(parsedUrl.host)) {
|
||||
return Boolean(getXTweetId(parsedUrl));
|
||||
}
|
||||
|
||||
if (redditHosts.has(parsedUrl.host)) {
|
||||
// Reddit posts are not embeddable if the URL does not include '/comments/'
|
||||
return parsedUrl.pathname.includes('/comments/');
|
||||
}
|
||||
|
||||
return canEmbedHosts.has(parsedUrl.host) || (parsedUrl.host.startsWith('yt.') && parsedUrl.searchParams.has('v'));
|
||||
};
|
||||
@@ -1,4 +1,18 @@
|
||||
import styles from './embed.module.css';
|
||||
import {
|
||||
bitchuteHosts,
|
||||
getXTweetId,
|
||||
instagramHosts,
|
||||
odyseeHosts,
|
||||
redditHosts,
|
||||
soundcloudHosts,
|
||||
spotifyHosts,
|
||||
streamableHosts,
|
||||
tiktokHosts,
|
||||
twitchHosts,
|
||||
xHosts,
|
||||
youtubeHosts,
|
||||
} from './embed-utils';
|
||||
|
||||
interface EmbedProps {
|
||||
url: string;
|
||||
@@ -47,20 +61,7 @@ interface EmbedComponentProps {
|
||||
}
|
||||
|
||||
const srcDocSandbox = 'allow-scripts allow-popups allow-popups-to-escape-sandbox';
|
||||
|
||||
const youtubeHosts = new Set<string>([
|
||||
'youtube.com',
|
||||
'www.youtube.com',
|
||||
'youtu.be',
|
||||
'www.youtu.be',
|
||||
'm.youtube.com',
|
||||
'music.youtube.com',
|
||||
// working Invidious instances - https://docs.invidious.io/instances/ - https://uptime.invidious.io/
|
||||
'yewtu.be',
|
||||
'inv.nadeko.net',
|
||||
'yt.artemislena.eu',
|
||||
'invidious.nerdvpn.de',
|
||||
]);
|
||||
const remoteEmbedSandbox = `${srcDocSandbox} allow-forms allow-presentation allow-same-origin`;
|
||||
|
||||
const YoutubeEmbed = ({ parsedUrl }: EmbedComponentProps) => {
|
||||
let embedSrc = '';
|
||||
@@ -100,6 +101,7 @@ const YoutubeEmbed = ({ parsedUrl }: EmbedComponentProps) => {
|
||||
referrerPolicy='origin'
|
||||
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
|
||||
allowFullScreen
|
||||
sandbox={remoteEmbedSandbox}
|
||||
title={parsedUrl.href}
|
||||
src={embedSrc}
|
||||
/>
|
||||
@@ -108,16 +110,6 @@ const YoutubeEmbed = ({ parsedUrl }: EmbedComponentProps) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const xHosts = new Set<string>(['twitter.com', 'www.twitter.com', 'x.com', 'www.x.com']);
|
||||
|
||||
const getXTweetId = (parsedUrl: URL): string | undefined => {
|
||||
const pathParts = parsedUrl.pathname.split('/').filter(Boolean);
|
||||
const statusIndex = pathParts.findIndex((part) => part === 'status' || part === 'statuses');
|
||||
const statusId = statusIndex === -1 ? undefined : pathParts[statusIndex + 1];
|
||||
|
||||
return statusId && /^\d+$/.test(statusId) ? statusId : undefined;
|
||||
};
|
||||
|
||||
const XEmbed = ({ parsedUrl }: EmbedComponentProps) => {
|
||||
const tweetId = getXTweetId(parsedUrl);
|
||||
if (!tweetId) {
|
||||
@@ -147,8 +139,6 @@ const XEmbed = ({ parsedUrl }: EmbedComponentProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const redditHosts = new Set<string>(['reddit.com', 'www.reddit.com', 'old.reddit.com']);
|
||||
|
||||
const RedditEmbed = ({ parsedUrl }: EmbedComponentProps) => {
|
||||
return (
|
||||
<iframe
|
||||
@@ -175,8 +165,6 @@ const RedditEmbed = ({ parsedUrl }: EmbedComponentProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const twitchHosts = new Set<string>(['twitch.tv', 'www.twitch.tv']);
|
||||
|
||||
const TwitchEmbed = ({ parsedUrl }: EmbedComponentProps) => {
|
||||
let iframeUrl;
|
||||
if (parsedUrl.pathname.startsWith('/videos/')) {
|
||||
@@ -194,14 +182,13 @@ const TwitchEmbed = ({ parsedUrl }: EmbedComponentProps) => {
|
||||
referrerPolicy='no-referrer'
|
||||
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
|
||||
allowFullScreen
|
||||
sandbox={remoteEmbedSandbox}
|
||||
title={parsedUrl.href}
|
||||
src={iframeUrl}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const tiktokHosts = new Set<string>(['tiktok.com', 'www.tiktok.com']);
|
||||
|
||||
const TiktokEmbed = ({ parsedUrl }: EmbedComponentProps) => {
|
||||
const videoId = parsedUrl.pathname.replace(/.+\/video\//, '').replaceAll('/', '');
|
||||
return (
|
||||
@@ -223,8 +210,6 @@ const TiktokEmbed = ({ parsedUrl }: EmbedComponentProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const instagramHosts = new Set<string>(['instagram.com', 'www.instagram.com']);
|
||||
|
||||
const InstagramEmbed = ({ parsedUrl }: EmbedComponentProps) => {
|
||||
const pathNames = parsedUrl.pathname.replace(/\/+$/, '').split('/');
|
||||
const id = pathNames[pathNames.length - 1];
|
||||
@@ -247,8 +232,6 @@ const InstagramEmbed = ({ parsedUrl }: EmbedComponentProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const odyseeHosts = new Set<string>(['odysee.com', 'www.odysee.com']);
|
||||
|
||||
const OdyseeEmbed = ({ parsedUrl }: EmbedComponentProps) => {
|
||||
const iframeUrl = `https://odysee.com/$/embed${parsedUrl.pathname}`;
|
||||
return (
|
||||
@@ -259,14 +242,13 @@ const OdyseeEmbed = ({ parsedUrl }: EmbedComponentProps) => {
|
||||
referrerPolicy='no-referrer'
|
||||
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
|
||||
allowFullScreen
|
||||
sandbox={remoteEmbedSandbox}
|
||||
title={parsedUrl.href}
|
||||
src={iframeUrl}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const bitchuteHosts = new Set<string>(['bitchute.com', 'www.bitchute.com']);
|
||||
|
||||
const BitchuteEmbed = ({ parsedUrl }: EmbedComponentProps) => {
|
||||
const videoId = parsedUrl.pathname.replace(/\/video\//, '').replaceAll('/', '');
|
||||
return (
|
||||
@@ -277,14 +259,13 @@ const BitchuteEmbed = ({ parsedUrl }: EmbedComponentProps) => {
|
||||
referrerPolicy='no-referrer'
|
||||
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
|
||||
allowFullScreen
|
||||
sandbox={remoteEmbedSandbox}
|
||||
title={parsedUrl.href}
|
||||
src={`https://www.bitchute.com/embed/${videoId}/`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const streamableHosts = new Set<string>(['streamable.com', 'www.streamable.com']);
|
||||
|
||||
const StreamableEmbed = ({ parsedUrl }: EmbedComponentProps) => {
|
||||
const videoId = parsedUrl.pathname.replaceAll('/', '');
|
||||
return (
|
||||
@@ -295,14 +276,13 @@ const StreamableEmbed = ({ parsedUrl }: EmbedComponentProps) => {
|
||||
referrerPolicy='no-referrer'
|
||||
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
|
||||
allowFullScreen
|
||||
sandbox={remoteEmbedSandbox}
|
||||
title={parsedUrl.href}
|
||||
src={`https://streamable.com/e/${videoId}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const spotifyHosts = new Set<string>(['spotify.com', 'www.spotify.com', 'open.spotify.com']);
|
||||
|
||||
const SpotifyEmbed = ({ parsedUrl }: EmbedComponentProps) => {
|
||||
const iframeUrl = `https://open.spotify.com/embed${parsedUrl.pathname}?theme=0`;
|
||||
return (
|
||||
@@ -313,14 +293,13 @@ const SpotifyEmbed = ({ parsedUrl }: EmbedComponentProps) => {
|
||||
referrerPolicy='no-referrer'
|
||||
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
|
||||
allowFullScreen
|
||||
sandbox={remoteEmbedSandbox}
|
||||
title={parsedUrl.href}
|
||||
src={iframeUrl}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const soundcloudHosts = new Set(['soundcloud.com', 'www.soundcloud.com', 'on.soundcloud.com', 'api.soundcloud.com', 'w.soundcloud.com']);
|
||||
|
||||
// not officially documented https://stackoverflow.com/questions/20870270/how-to-get-soundcloud-embed-code-by-soundcloud-com-url
|
||||
const SoundcloudEmbed = ({ parsedUrl }: EmbedComponentProps) => {
|
||||
return (
|
||||
@@ -331,37 +310,11 @@ const SoundcloudEmbed = ({ parsedUrl }: EmbedComponentProps) => {
|
||||
referrerPolicy='no-referrer'
|
||||
allow='accelerometer; encrypted-media; gyroscope; picture-in-picture; web-share'
|
||||
allowFullScreen
|
||||
sandbox={remoteEmbedSandbox}
|
||||
title={parsedUrl.href}
|
||||
src={`https://w.soundcloud.com/player/?url=${parsedUrl.href}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const canEmbedHosts = new Set<string>([
|
||||
...youtubeHosts,
|
||||
...xHosts,
|
||||
...redditHosts,
|
||||
...twitchHosts,
|
||||
...tiktokHosts,
|
||||
...instagramHosts,
|
||||
...odyseeHosts,
|
||||
...bitchuteHosts,
|
||||
...soundcloudHosts,
|
||||
...streamableHosts,
|
||||
...spotifyHosts,
|
||||
]);
|
||||
|
||||
export const canEmbed = (parsedUrl: URL): boolean => {
|
||||
if (xHosts.has(parsedUrl.host)) {
|
||||
return Boolean(getXTweetId(parsedUrl));
|
||||
}
|
||||
|
||||
if (redditHosts.has(parsedUrl.host)) {
|
||||
// Reddit posts are not embeddable if the URL does not include '/comments/'
|
||||
return parsedUrl.pathname.includes('/comments/');
|
||||
}
|
||||
|
||||
return canEmbedHosts.has(parsedUrl.host) || (parsedUrl.host.startsWith('yt.') && parsedUrl.searchParams.has('v'));
|
||||
};
|
||||
|
||||
export default Embed;
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { default, canEmbed } from './embed';
|
||||
export { default } from './embed';
|
||||
export { canEmbed } from './embed-utils';
|
||||
|
||||
@@ -252,7 +252,7 @@ export const ThreadFooterMobile = ({ postCid, threadNumber, communityAddress, is
|
||||
<PageFooterMobile>
|
||||
<div className={styles.threadMobileFooterContent}>
|
||||
<div className={styles.mobileFooterButtons}>
|
||||
<button className='button' onClick={handlePostReplyClick} disabled={isThreadClosed}>
|
||||
<button type='button' className='button' onClick={handlePostReplyClick} disabled={isThreadClosed}>
|
||||
{t('post_a_reply')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -460,7 +460,7 @@ describe('Markdown', () => {
|
||||
content: 'https://cdn.example/image.png',
|
||||
});
|
||||
|
||||
const toggle = Array.from(container.querySelectorAll('span')).find((node) => node.textContent === 'embed');
|
||||
const toggle = Array.from(container.querySelectorAll('button')).find((node) => node.textContent === 'embed');
|
||||
expect(toggle).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="comment-media"]')).toBeNull();
|
||||
|
||||
@@ -470,7 +470,7 @@ describe('Markdown', () => {
|
||||
|
||||
expect(container.querySelector('[data-testid="comment-media"]')?.textContent).toBe('image:https://cdn.example/image.png');
|
||||
|
||||
const removeToggle = Array.from(container.querySelectorAll('span')).find((node) => node.textContent === 'remove');
|
||||
const removeToggle = Array.from(container.querySelectorAll('button')).find((node) => node.textContent === 'remove');
|
||||
await act(async () => {
|
||||
removeToggle?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MouseEvent } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -64,7 +64,8 @@ const ExternalNumberQuoteLink = ({ isOP = false, reference }: ExternalNumberQuot
|
||||
const boardLabel = getExternalQuoteBoardLabel(reference, directories);
|
||||
const linkLabel = isOP ? `${reference.raw} (OP)` : reference.raw;
|
||||
|
||||
const updatePreviewPosition = (anchor: HTMLElement | null) => {
|
||||
const updatePreviewPosition = useCallback(
|
||||
(anchor: HTMLElement | null) => {
|
||||
if (!anchor || isMobile) {
|
||||
return;
|
||||
}
|
||||
@@ -78,7 +79,9 @@ const ExternalNumberQuoteLink = ({ isOP = false, reference }: ExternalNumberQuot
|
||||
const top = Math.min(Math.max(padding, rect.top - 8), Math.max(padding, window.innerHeight - previewHeight - padding));
|
||||
|
||||
setPreviewPosition({ left, top });
|
||||
};
|
||||
},
|
||||
[isMobile],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPreviewOpen || isMobile) {
|
||||
@@ -95,7 +98,7 @@ const ExternalNumberQuoteLink = ({ isOP = false, reference }: ExternalNumberQuot
|
||||
window.removeEventListener('resize', reposition);
|
||||
window.removeEventListener('scroll', reposition, true);
|
||||
};
|
||||
}, [isMobile, isPreviewOpen, previewState.kind]);
|
||||
}, [isMobile, isPreviewOpen, previewState.kind, updatePreviewPosition]);
|
||||
|
||||
const getInitialSearchMessage = () =>
|
||||
getExternalQuoteStatusMessage(t, {
|
||||
|
||||
@@ -34,10 +34,23 @@
|
||||
}
|
||||
|
||||
.embedButton {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--button-desktop-text-color);
|
||||
display: inline;
|
||||
font: inherit;
|
||||
line-height: inherit;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.embedButton:focus-visible {
|
||||
outline: 1px dotted currentcolor;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.embedButton:hover {
|
||||
cursor: pointer;
|
||||
color: var(--button-desktop-text-color-hover);
|
||||
|
||||
@@ -91,9 +91,9 @@ const ContentLinkEmbed = ({ children, href, linkMediaInfo }: ContentLinkEmbedPro
|
||||
{children}
|
||||
</a>{' '}
|
||||
[
|
||||
<span
|
||||
<button
|
||||
type='button'
|
||||
className={styles.embedButton}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -106,7 +106,7 @@ const ContentLinkEmbed = ({ children, href, linkMediaInfo }: ContentLinkEmbedPro
|
||||
{...getReferenceProps()}
|
||||
>
|
||||
{showMedia ? t('remove') : isMobile ? t('open') : t('embed')}
|
||||
</span>
|
||||
</button>
|
||||
]
|
||||
{showMedia && (
|
||||
<>
|
||||
@@ -489,7 +489,7 @@ const renderQstBbcodeElement = (tag: QstBbcodeTag, key: string, children: React.
|
||||
);
|
||||
};
|
||||
|
||||
const renderQstBbcodeText = (text: string, keyPrefix: string): React.ReactNode[] => {
|
||||
const getQstBbcodeTextNodes = (text: string, keyPrefix: string): React.ReactNode[] => {
|
||||
const elements: React.ReactNode[] = [];
|
||||
const regex = new RegExp(QST_BBCODE_OPEN_REGEX.source, 'g');
|
||||
let lastIndex = 0;
|
||||
@@ -511,7 +511,9 @@ const renderQstBbcodeText = (text: string, keyPrefix: string): React.ReactNode[]
|
||||
|
||||
const closeEnd = closeStart + tag.length + 3;
|
||||
const childKeyPrefix = `${keyPrefix}${tag}-${matchStart}-`;
|
||||
elements.push(renderQstBbcodeElement(tag, `${keyPrefix}${tag}-${matchStart}-${closeEnd}`, renderQstBbcodeText(text.slice(contentStart, closeStart), childKeyPrefix)));
|
||||
elements.push(
|
||||
renderQstBbcodeElement(tag, `${keyPrefix}${tag}-${matchStart}-${closeEnd}`, getQstBbcodeTextNodes(text.slice(contentStart, closeStart), childKeyPrefix)),
|
||||
);
|
||||
lastIndex = closeEnd;
|
||||
regex.lastIndex = closeEnd;
|
||||
}
|
||||
@@ -523,7 +525,7 @@ const renderQstBbcodeText = (text: string, keyPrefix: string): React.ReactNode[]
|
||||
return elements;
|
||||
};
|
||||
|
||||
const QstBbcodeText = ({ text, tokenKey }: { text: string; tokenKey: string }) => <>{renderQstBbcodeText(text, `${tokenKey}/`)}</>;
|
||||
const QstBbcodeText = ({ text, tokenKey }: { text: string; tokenKey: string }) => <>{getQstBbcodeTextNodes(text, `${tokenKey}/`)}</>;
|
||||
|
||||
const Fortune = ({ color, text }: { color: string; text: string }) => (
|
||||
<span className='fortune' style={{ color }}>
|
||||
@@ -608,12 +610,16 @@ const Markdown = ({ content, title, postCid, communityAddress }: MarkdownProps)
|
||||
const normalized = normalizeContent(content || '');
|
||||
const lines = normalized.split('\n');
|
||||
const elements: React.ReactNode[] = [];
|
||||
let lineOffset = 0;
|
||||
|
||||
const context = { isInCatalogView, postCid, communityAddress, enableQstBbcode };
|
||||
|
||||
lines.forEach((line, lineIndex) => {
|
||||
const lineKey = `line-${lineOffset}`;
|
||||
lineOffset += line.length + 1;
|
||||
|
||||
if (lineIndex > 0) {
|
||||
elements.push(<br key={`br-${lineIndex}`} />);
|
||||
elements.push(<br key={`br-${lineKey}`} />);
|
||||
}
|
||||
|
||||
if (line.length === 0) return;
|
||||
@@ -624,12 +630,12 @@ const Markdown = ({ content, title, postCid, communityAddress }: MarkdownProps)
|
||||
|
||||
if (isGreentext) {
|
||||
elements.push(
|
||||
<span key={`line-${lineIndex}`} className='greentext'>
|
||||
<span key={lineKey} className='greentext'>
|
||||
{lineElements}
|
||||
</span>,
|
||||
);
|
||||
} else {
|
||||
elements.push(<React.Fragment key={`line-${lineIndex}`}>{lineElements}</React.Fragment>);
|
||||
elements.push(<React.Fragment key={lineKey}>{lineElements}</React.Fragment>);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
.modEmptyState {
|
||||
display: block;
|
||||
padding: 1em 0;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
|
||||
@@ -10,12 +10,12 @@ const ModEmptyState = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={styles.modEmptyState} role='status'>
|
||||
<output className={styles.modEmptyState}>
|
||||
<div className={styles.modEmptyStateTitle}>{t('not_mod_of_any_board')}</div>
|
||||
<div className={styles.modEmptyStateAction}>
|
||||
<Trans i18nKey='go_to_settings_to_import_mod_account' components={MOD_ACCOUNT_SETTINGS_LINK_COMPONENTS} />
|
||||
</div>
|
||||
</div>
|
||||
</output>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ interface PostAuthorFlagsProps {
|
||||
}
|
||||
|
||||
const getBackgroundPosition = (x: number, y: number) => `${x === 0 ? 0 : -x}px ${y === 0 ? 0 : -y}px`;
|
||||
const transparentPixelSrc = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="1" height="1"%3E%3C/svg%3E';
|
||||
|
||||
const PostAuthorFlags = ({ author, comment, enabled }: PostAuthorFlagsProps) => {
|
||||
if (!enabled) return null;
|
||||
@@ -18,11 +19,11 @@ const PostAuthorFlags = ({ author, comment, enabled }: PostAuthorFlagsProps) =>
|
||||
return (
|
||||
<span className={styles.authorFlags}>
|
||||
{flags.map((flag) => (
|
||||
<span
|
||||
<img
|
||||
key={flag.key}
|
||||
aria-label={flag.label}
|
||||
alt={flag.label}
|
||||
className={styles.authorFlag}
|
||||
role='img'
|
||||
src={transparentPixelSrc}
|
||||
style={{
|
||||
backgroundImage: `url("${flag.spritePath}")`,
|
||||
backgroundPosition: getBackgroundPosition(flag.x, flag.y),
|
||||
|
||||
@@ -72,8 +72,6 @@ import { getFeedPostHeightEstimate, getReplyHeightEstimates, reportReplyHeightAu
|
||||
import { getAuthorBadge } from '../../lib/utils/author-display-utils';
|
||||
import { hasCommentFlagsForDirectory } from '../../lib/comment-flag-selection';
|
||||
|
||||
const { addChallenge } = useChallengesStore.getState();
|
||||
|
||||
const RepliesFooter = ({ hasMore, loadingString }: { hasMore: boolean; loadingString: string }) =>
|
||||
hasMore ? (
|
||||
<div className={styles.stateString}>
|
||||
@@ -112,7 +110,7 @@ const PendingModerationActions = ({ cid, communityAddress, post }: { cid: string
|
||||
communityAddress,
|
||||
commentModeration: approvePendingCommentModeration,
|
||||
onChallenge: async (...args: any) => {
|
||||
addChallenge([...args, post]);
|
||||
useChallengesStore.getState().addChallenge([...args, post]);
|
||||
},
|
||||
onChallengeVerification: async (challengeVerification, comment) => {
|
||||
alertChallengeVerificationFailed(challengeVerification, comment);
|
||||
@@ -131,7 +129,7 @@ const PendingModerationActions = ({ cid, communityAddress, post }: { cid: string
|
||||
communityAddress,
|
||||
commentModeration: rejectPendingCommentModeration,
|
||||
onChallenge: async (...args: any) => {
|
||||
addChallenge([...args, post]);
|
||||
useChallengesStore.getState().addChallenge([...args, post]);
|
||||
},
|
||||
onChallengeVerification: async (challengeVerification, comment) => {
|
||||
alertChallengeVerificationFailed(challengeVerification, comment);
|
||||
@@ -194,14 +192,14 @@ const PendingModerationActions = ({ cid, communityAddress, post }: { cid: string
|
||||
<>
|
||||
<span className={styles.modQueueButtonWrapper}>
|
||||
[
|
||||
<button className={styles.modQueueActionButton} onClick={handlePendingApprove} disabled={isPublishingPending}>
|
||||
<button type='button' className={styles.modQueueActionButton} onClick={handlePendingApprove} disabled={isPublishingPending}>
|
||||
{t('approve')}
|
||||
</button>
|
||||
]
|
||||
</span>
|
||||
<span className={styles.modQueueButtonWrapper}>
|
||||
[
|
||||
<button className={styles.modQueueActionButton} onClick={handlePendingReject} disabled={isPublishingPending}>
|
||||
<button type='button' className={styles.modQueueActionButton} onClick={handlePendingReject} disabled={isPublishingPending}>
|
||||
{t('reject')}
|
||||
</button>
|
||||
]
|
||||
@@ -318,7 +316,7 @@ const PostInfo = ({
|
||||
const modQueueRemoveButton = onRemoveFromModQueue ? (
|
||||
<span className={styles.modQueueButtonWrapper}>
|
||||
[
|
||||
<button className={styles.modQueueActionButton} onClick={onRemoveFromModQueue} disabled={isPublishing}>
|
||||
<button type='button' className={styles.modQueueActionButton} onClick={onRemoveFromModQueue} disabled={isPublishing}>
|
||||
{t('modQueue.dismiss')}
|
||||
</button>
|
||||
]
|
||||
@@ -397,10 +395,10 @@ const PostInfo = ({
|
||||
content={`${numberOfPostsByAuthor === 1 ? t('1_post_by_this_id') : t('x_posts_by_this_id', { number: numberOfPostsByAuthor })}`}
|
||||
showTooltip={isInPostPageView || showOmittedReplies[postCid] || (postReplyCount < 6 && !pinned)}
|
||||
>
|
||||
<span
|
||||
<button
|
||||
type='button'
|
||||
title={t('highlight_posts')}
|
||||
className={styles.userAddress}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={() => handleUserAddressClick(userID, postCid)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -412,7 +410,7 @@ const PostInfo = ({
|
||||
style={{ backgroundColor: userIDBackgroundColor, color: userIDTextColor }}
|
||||
>
|
||||
{formatUserIDForDisplay(userID)}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
){' '}
|
||||
@@ -440,10 +438,10 @@ const PostInfo = ({
|
||||
<Link to={threadRoute || '#'} state={threadTopNavigationState} className={styles.linkToPost} title={t('link_to_post')} onClick={onLinkToPostClick}>
|
||||
No.
|
||||
</Link>
|
||||
<span
|
||||
<button
|
||||
type='button'
|
||||
className={styles.replyToPost}
|
||||
title={t('reply_to_post')}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onMouseDown={onReplyModalClick}
|
||||
onKeyDown={(e) => {
|
||||
@@ -454,7 +452,7 @@ const PostInfo = ({
|
||||
}}
|
||||
>
|
||||
{post?.number || '?'}
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
@@ -523,14 +521,14 @@ const PostInfo = ({
|
||||
)}
|
||||
<span className={styles.modQueueButtonWrapper}>
|
||||
[
|
||||
<button className={styles.modQueueActionButton} onClick={onApprove} disabled={isPublishing}>
|
||||
<button type='button' className={styles.modQueueActionButton} onClick={onApprove} disabled={isPublishing}>
|
||||
{t('approve')}
|
||||
</button>
|
||||
]
|
||||
</span>
|
||||
<span className={styles.modQueueButtonWrapper}>
|
||||
[
|
||||
<button className={styles.modQueueActionButton} onClick={onReject} disabled={isPublishing}>
|
||||
<button type='button' className={styles.modQueueActionButton} onClick={onReject} disabled={isPublishing}>
|
||||
{t('reject')}
|
||||
</button>
|
||||
]
|
||||
@@ -685,9 +683,9 @@ const PostMedia = ({
|
||||
{!showThumbnail && (type === 'iframe' || type === 'video' || type === 'audio') && (
|
||||
<span>
|
||||
-[
|
||||
<span
|
||||
<button
|
||||
type='button'
|
||||
className={styles.closeMedia}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={() => setShowThumbnail(true)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -698,16 +696,16 @@ const PostMedia = ({
|
||||
}}
|
||||
>
|
||||
{t('close')}
|
||||
</span>
|
||||
</button>
|
||||
]
|
||||
</span>
|
||||
)}
|
||||
{showThumbnail && !hasThumbnail && embedUrl && canEmbed(embedUrl) && (
|
||||
<span>
|
||||
-[
|
||||
<span
|
||||
<button
|
||||
type='button'
|
||||
className={styles.closeMedia}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={() => setShowThumbnail(false)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -718,7 +716,7 @@ const PostMedia = ({
|
||||
}}
|
||||
>
|
||||
{t('open')}
|
||||
</span>
|
||||
</button>
|
||||
]
|
||||
</span>
|
||||
)}
|
||||
@@ -1139,9 +1137,10 @@ const PostDesktop = ({
|
||||
<div className={isHidden ? styles.postDesktopHidden : ''}>
|
||||
{!isInPostPageView && showReplies && (
|
||||
<span className={`${styles.hideButtonWrapper} ${!hasThumbnail ? styles.hideButtonWrapperNoImage : ''}`}>
|
||||
<span
|
||||
<button
|
||||
type='button'
|
||||
aria-label={hidden ? t('unhide') : t('hide')}
|
||||
className={`${styles.hideButton} ${hidden ? styles.unhideThread : styles.hideThread}`}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={hidden ? unhide : hide}
|
||||
onKeyDown={(e) => {
|
||||
@@ -1207,9 +1206,10 @@ const PostDesktop = ({
|
||||
</div>
|
||||
{!isHidden && !isInPendingPostView && showReplies && repliesCount > 0 && !isInPostPageView && (
|
||||
<span className={styles.summary}>
|
||||
<span
|
||||
<button
|
||||
type='button'
|
||||
aria-label={showOmittedReplies[cid] ? t('hide_replies') : t('show_replies')}
|
||||
className={`${showOmittedReplies[cid] ? styles.hideOmittedReplies : styles.showOmittedReplies} ${styles.omittedRepliesButtonWrapper}`}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={() => setShowOmittedReplies(cid, !showOmittedReplies[cid])}
|
||||
onKeyDown={(e) => {
|
||||
|
||||
@@ -144,21 +144,21 @@ describe('PostMenuDesktop', () => {
|
||||
await renderMenu();
|
||||
|
||||
await openMenu();
|
||||
const copyLink = Array.from(document.body.querySelectorAll('div')).find((node) => node.textContent === 'copy_direct_link');
|
||||
const copyLink = Array.from(document.body.querySelectorAll('button')).find((node) => node.textContent === 'copy_direct_link');
|
||||
await act(async () => {
|
||||
copyLink?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
expect(testState.copyShareLinkMock).toHaveBeenCalledWith('mu', 'thread', 'cid-1');
|
||||
|
||||
await openMenu();
|
||||
const copyContentId = Array.from(document.body.querySelectorAll('div')).find((node) => node.textContent === 'copy_content_id');
|
||||
const copyContentId = Array.from(document.body.querySelectorAll('button')).find((node) => node.textContent === 'copy_content_id');
|
||||
await act(async () => {
|
||||
copyContentId?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
expect(testState.copyToClipboardMock).toHaveBeenCalledWith('cid-1');
|
||||
|
||||
await openMenu();
|
||||
const copyUserId = Array.from(document.body.querySelectorAll('div')).find((node) => node.textContent === 'copy_user_id');
|
||||
const copyUserId = Array.from(document.body.querySelectorAll('button')).find((node) => node.textContent === 'copy_user_id');
|
||||
await act(async () => {
|
||||
copyUserId?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
@@ -169,7 +169,7 @@ describe('PostMenuDesktop', () => {
|
||||
await renderMenu('/mu');
|
||||
|
||||
await openMenu();
|
||||
const hideThread = Array.from(document.body.querySelectorAll('div')).find((node) => node.textContent === 'hide_thread');
|
||||
const hideThread = Array.from(document.body.querySelectorAll('button')).find((node) => node.textContent === 'hide_thread');
|
||||
expect(hideThread).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
@@ -181,7 +181,7 @@ describe('PostMenuDesktop', () => {
|
||||
await renderMenu('/mu');
|
||||
await openMenu();
|
||||
|
||||
const unhideThread = Array.from(document.body.querySelectorAll('div')).find((node) => node.textContent === 'unhide_thread');
|
||||
const unhideThread = Array.from(document.body.querySelectorAll('button')).find((node) => node.textContent === 'unhide_thread');
|
||||
expect(unhideThread).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
@@ -205,7 +205,7 @@ describe('PostMenuDesktop', () => {
|
||||
|
||||
expect(Array.from(document.body.querySelectorAll('div')).find((node) => node.textContent === 'hide_thread')).toBeUndefined();
|
||||
|
||||
const imageSearch = Array.from(document.body.querySelectorAll('[role="button"]')).find((node) => node.textContent?.includes('Image_search'));
|
||||
const imageSearch = Array.from(document.body.querySelectorAll('[class*="dropdown"]')).find((node) => node.textContent?.includes('Image_search'));
|
||||
expect(imageSearch).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
@@ -216,7 +216,7 @@ describe('PostMenuDesktop', () => {
|
||||
expect(hrefs).toEqual(
|
||||
expect.arrayContaining([
|
||||
'https://lens.google.com/uploadbyurl?url=https%3A%2F%2Fcdn.example%2Fimage.png',
|
||||
'https://www.yandex.com/images/search?img_url=https%3A%2F%2Fcdn.example%2Fimage.png&rpt=imageview',
|
||||
'https://www.yandex.com/images/search?url=https%3A%2F%2Fcdn.example%2Fimage.png&rpt=imageview',
|
||||
'https://saucenao.com/search.php?url=https%3A%2F%2Fcdn.example%2Fimage.png',
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -7,6 +7,28 @@
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.postMenu button {
|
||||
appearance: none;
|
||||
font: inherit;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.postMenuBtn, .postMenuBtnCatalog {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.postMenu button:focus-visible, .postMenuBtn:focus-visible, .postMenuBtnCatalog:focus-visible {
|
||||
outline: 1px dotted currentcolor;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.postMenuBtnWrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
@@ -28,10 +50,13 @@
|
||||
|
||||
.postMenuItem {
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid var(--post-menu-desktop-item-border-color);
|
||||
background-color: var(--post-menu-desktop-item-background-color);
|
||||
border-bottom: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.postMenuItem:hover {
|
||||
@@ -46,7 +71,7 @@
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
top: -1px;
|
||||
display: none;
|
||||
display: block;
|
||||
border-bottom: var(--post-menu-desktop-border-bottom);
|
||||
border-right: var(--post-menu-desktop-border-right);
|
||||
}
|
||||
@@ -56,10 +81,6 @@
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.dropdown:hover .dropdownMenu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.postMenuBtnCatalogWrapper {
|
||||
left: 5px;
|
||||
top: 5px;
|
||||
|
||||
@@ -51,9 +51,9 @@ const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkBu
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
type='button'
|
||||
className={styles.postMenuItem}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={copyDirectLink}
|
||||
onKeyDown={(e) => {
|
||||
@@ -64,7 +64,7 @@ const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkBu
|
||||
}}
|
||||
>
|
||||
{t('copy_direct_link')}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -75,9 +75,9 @@ const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => voi
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
type='button'
|
||||
className={styles.postMenuItem}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={copyContentId}
|
||||
onKeyDown={(e) => {
|
||||
@@ -88,7 +88,7 @@ const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => voi
|
||||
}}
|
||||
>
|
||||
{t('copy_content_id')}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -99,9 +99,9 @@ const CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () =
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
type='button'
|
||||
className={styles.postMenuItem}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={copyUserId}
|
||||
onKeyDown={(e) => {
|
||||
@@ -112,7 +112,7 @@ const CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () =
|
||||
}}
|
||||
>
|
||||
{t('copy_user_id')}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -128,30 +128,31 @@ const ImageSearchButton = ({ url, onClose }: { url: string; onClose: () => void
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.postMenuItem} ${styles.dropdown}`}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
className={styles.dropdown}
|
||||
onMouseOver={() => setIsImageSearchMenuOpen(true)}
|
||||
onFocus={() => setIsImageSearchMenuOpen(true)}
|
||||
onMouseLeave={() => setIsImageSearchMenuOpen(false)}
|
||||
onBlur={() => setIsImageSearchMenuOpen(false)}
|
||||
ref={refs.setReference}
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
className={styles.postMenuItem}
|
||||
aria-haspopup='true'
|
||||
aria-expanded={isImageSearchMenuOpen}
|
||||
onClick={() => setIsImageSearchMenuOpen((open) => !open)}
|
||||
>
|
||||
{capitalize(t('image_search'))} »
|
||||
</button>
|
||||
{isImageSearchMenuOpen && (
|
||||
<div ref={refs.setFloating} style={floatingStyles} className={styles.dropdownMenu}>
|
||||
<a href={`https://lens.google.com/uploadbyurl?url=${encodedUrl}`} target='_blank' rel='noopener noreferrer'>
|
||||
<a href={`https://lens.google.com/uploadbyurl?url=${encodedUrl}`} target='_blank' rel='noopener noreferrer' onClick={onClose}>
|
||||
<div className={styles.postMenuItem}>Google</div>
|
||||
</a>
|
||||
<a href={`https://www.yandex.com/images/search?img_url=${encodedUrl}&rpt=imageview`} target='_blank' rel='noopener noreferrer'>
|
||||
<a href={`https://www.yandex.com/images/search?url=${encodedUrl}&rpt=imageview`} target='_blank' rel='noopener noreferrer' onClick={onClose}>
|
||||
<div className={styles.postMenuItem}>Yandex</div>
|
||||
</a>
|
||||
<a href={`https://saucenao.com/search.php?url=${encodedUrl}`} target='_blank' rel='noopener noreferrer'>
|
||||
<a href={`https://saucenao.com/search.php?url=${encodedUrl}`} target='_blank' rel='noopener noreferrer' onClick={onClose}>
|
||||
<div className={styles.postMenuItem}>SauceNAO</div>
|
||||
</a>
|
||||
</div>
|
||||
@@ -202,10 +203,10 @@ const PostMenuDesktop = ({ postMenu }: PostMenuDesktopProps) => {
|
||||
return (
|
||||
<>
|
||||
<span className={isInCatalogView ? styles.postMenuBtnCatalogWrapper : styles.postMenuBtnWrapper} ref={refs.setReference} {...getReferenceProps()}>
|
||||
<span
|
||||
<button
|
||||
type='button'
|
||||
className={isInCatalogView ? styles.postMenuBtnCatalog : styles.postMenuBtn}
|
||||
title='Post menu'
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={handleMenuClick}
|
||||
onKeyDown={(e) => {
|
||||
@@ -217,16 +218,16 @@ const PostMenuDesktop = ({ postMenu }: PostMenuDesktopProps) => {
|
||||
style={{ transform: menuBtnRotated && cid ? 'rotate(90deg)' : 'rotate(0deg)' }}
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
{menuBtnRotated &&
|
||||
cid &&
|
||||
createPortal(
|
||||
<FloatingFocusManager context={context} modal={false}>
|
||||
<div className={styles.postMenu} ref={refs.setFloating} style={floatingStyles} aria-labelledby={headingId} {...getFloatingProps()}>
|
||||
<div
|
||||
<button
|
||||
type='button'
|
||||
className={styles.postMenuItem}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
alert("Reporting isn't available yet.");
|
||||
@@ -241,11 +242,11 @@ const PostMenuDesktop = ({ postMenu }: PostMenuDesktopProps) => {
|
||||
}}
|
||||
>
|
||||
{t('report_post')}
|
||||
</div>
|
||||
</button>
|
||||
{!(isInPostPageView && postCid === cid) && (
|
||||
<div
|
||||
<button
|
||||
type='button'
|
||||
className={styles.postMenuItem}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
if (hidden) {
|
||||
@@ -268,7 +269,7 @@ const PostMenuDesktop = ({ postMenu }: PostMenuDesktopProps) => {
|
||||
}}
|
||||
>
|
||||
{hidden ? (postCid === cid ? t('unhide_thread') : t('unhide_post')) : postCid === cid ? t('hide_thread') : t('hide_post')}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
{cid && communityAddress && <CopyLinkButton cid={cid} communityAddress={communityAddress} linkType='thread' onClose={handleClose} />}
|
||||
{cid && <CopyContentIdButton cid={cid} onClose={handleClose} />}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
@@ -373,6 +373,7 @@ const PostFormFields = ({
|
||||
<label>
|
||||
<input
|
||||
type='checkbox'
|
||||
aria-label={capitalize(t('spoiler'))}
|
||||
onChange={(e) => (isInPostView ? setPublishReplyOptions({ spoiler: e.target.checked }) : setPublishPostOptions({ spoiler: e.target.checked }))}
|
||||
/>
|
||||
{capitalize(t('spoiler'))}?
|
||||
@@ -500,7 +501,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
}, POST_OPTIONS_VALIDATION_DELAY_MS),
|
||||
).current;
|
||||
|
||||
const resetFields = () => {
|
||||
const resetFields = useCallback(() => {
|
||||
if (textRef.current) {
|
||||
textRef.current.value = '';
|
||||
}
|
||||
@@ -523,7 +524,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
setFormError(null);
|
||||
setIsBbcodePreviewing(false);
|
||||
setBbcodePreviewContent('');
|
||||
};
|
||||
}, [checkContentLength, checkPostOptions]);
|
||||
|
||||
const getBoardIndexPath = () => {
|
||||
if (effectiveBoardAddress) {
|
||||
@@ -596,7 +597,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
navigate(`/pending/${postIndex}`, pendingPostBoardPath ? { state: { boardPath: pendingPostBoardPath } } : undefined);
|
||||
}
|
||||
}
|
||||
}, [postIndex, pendingPostBoardPath, resetPublishPostOptions, navigate]);
|
||||
}, [postIndex, pendingPostBoardPath, resetFields, resetPublishPostOptions, navigate]);
|
||||
|
||||
// in post page, publish a reply to the post
|
||||
const isInPostView = isPostPageView(location.pathname, params);
|
||||
@@ -704,7 +705,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
navigate(nonokoRedirectPath);
|
||||
}
|
||||
}
|
||||
}, [replyIndex, closeForm, navigate]);
|
||||
}, [replyIndex, closeForm, navigate, resetFields]);
|
||||
|
||||
const { isUploading, uploadedFileName, handleUpload } = useFileUpload({
|
||||
onUploadComplete: (uploadedUrl: string) => {
|
||||
@@ -846,7 +847,7 @@ const PostForm = () => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button className={`${styles.showFormButton} button`} onClick={() => setShowForm(showForm ? false : true)}>
|
||||
<button type='button' className={`${styles.showFormButton} button`} onClick={() => setShowForm(showForm ? false : true)}>
|
||||
{showForm ? t('close_post_form') : isInPostView ? t('post_a_reply') : t('start_new_thread')}
|
||||
</button>
|
||||
{showForm && <PostFormTable closeForm={() => setShowForm(false)} postCid={postCid} />}
|
||||
@@ -871,7 +872,7 @@ const PostForm = () => {
|
||||
) : !showForm ? (
|
||||
<div>
|
||||
[
|
||||
<button className='button' onClick={() => setShowForm(true)}>
|
||||
<button type='button' className='button' onClick={() => setShowForm(true)}>
|
||||
{isInPostView ? t('post_a_reply') : t('start_new_thread')}
|
||||
</button>
|
||||
]
|
||||
|
||||
@@ -256,7 +256,7 @@ describe('PostMenuMobile', () => {
|
||||
expect(hrefs).toEqual(
|
||||
expect.arrayContaining([
|
||||
'https://lens.google.com/uploadbyurl?url=https%3A%2F%2Fcdn.example%2Fimage.png',
|
||||
'https://www.yandex.com/images/search?img_url=https%3A%2F%2Fcdn.example%2Fimage.png&rpt=imageview',
|
||||
'https://www.yandex.com/images/search?url=https%3A%2F%2Fcdn.example%2Fimage.png&rpt=imageview',
|
||||
'https://saucenao.com/search.php?url=https%3A%2F%2Fcdn.example%2Fimage.png',
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
.postMenuBtn {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--post-mobile-menu-button-color);
|
||||
line-height: 1em;
|
||||
width: 1em;
|
||||
@@ -11,6 +14,13 @@
|
||||
font-size: 16px;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.postMenuBtn:focus-visible {
|
||||
outline: 1px dotted currentcolor;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
@@ -35,6 +45,20 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.postMenu button {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
color: inherit;
|
||||
display: block;
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.postMenu button:focus-visible {
|
||||
outline: 1px dotted currentcolor;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.postMenuItem {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
@@ -70,8 +70,8 @@ const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkBu
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
role='button'
|
||||
<button
|
||||
type='button'
|
||||
tabIndex={0}
|
||||
onClick={copyDirectLink}
|
||||
onKeyDown={(e) => {
|
||||
@@ -82,7 +82,7 @@ const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkBu
|
||||
}}
|
||||
>
|
||||
<div className={styles.postMenuItem}>{t('copy_direct_link')}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -93,8 +93,8 @@ const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => voi
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
role='button'
|
||||
<button
|
||||
type='button'
|
||||
tabIndex={0}
|
||||
onClick={copyContentId}
|
||||
onKeyDown={(e) => {
|
||||
@@ -105,7 +105,7 @@ const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => voi
|
||||
}}
|
||||
>
|
||||
<div className={styles.postMenuItem}>{t('copy_content_id')}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -116,8 +116,8 @@ const CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () =
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
role='button'
|
||||
<button
|
||||
type='button'
|
||||
tabIndex={0}
|
||||
onClick={copyUserId}
|
||||
onKeyDown={(e) => {
|
||||
@@ -128,7 +128,7 @@ const CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () =
|
||||
}}
|
||||
>
|
||||
<div className={styles.postMenuItem}>{t('copy_user_id')}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -136,32 +136,20 @@ const ImageSearchButtons = ({ url, onClose }: { url: string; onClose: () => void
|
||||
const { t } = useTranslation();
|
||||
const encodedUrl = encodeURIComponent(url);
|
||||
return (
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<a href={`https://lens.google.com/uploadbyurl?url=${encodedUrl}`} target='_blank' rel='noopener noreferrer'>
|
||||
<div>
|
||||
<a href={`https://lens.google.com/uploadbyurl?url=${encodedUrl}`} target='_blank' rel='noopener noreferrer' onClick={onClose}>
|
||||
<div className={styles.postMenuItem}>{t('search_image_on_google')}</div>
|
||||
</a>
|
||||
<a href={`https://www.yandex.com/images/search?img_url=${encodedUrl}&rpt=imageview`} target='_blank' rel='noopener noreferrer'>
|
||||
<a href={`https://www.yandex.com/images/search?url=${encodedUrl}&rpt=imageview`} target='_blank' rel='noopener noreferrer' onClick={onClose}>
|
||||
<div className={styles.postMenuItem}>{t('search_image_on_yandex')}</div>
|
||||
</a>
|
||||
<a href={`https://saucenao.com/search.php?url=${encodedUrl}`} target='_blank' rel='noopener noreferrer'>
|
||||
<a href={`https://saucenao.com/search.php?url=${encodedUrl}`} target='_blank' rel='noopener noreferrer' onClick={onClose}>
|
||||
<div className={styles.postMenuItem}>{t('search_image_on_saucenao')}</div>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const { addChallenge } = useChallengesStore.getState();
|
||||
|
||||
const ReportPostButton = ({ onClose }: { onClose: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
const reportPost = () => {
|
||||
@@ -169,8 +157,8 @@ const ReportPostButton = ({ onClose }: { onClose: () => void }) => {
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
role='button'
|
||||
<button
|
||||
type='button'
|
||||
tabIndex={0}
|
||||
onClick={reportPost}
|
||||
onKeyDown={(e) => {
|
||||
@@ -181,7 +169,7 @@ const ReportPostButton = ({ onClose }: { onClose: () => void }) => {
|
||||
}}
|
||||
>
|
||||
<div className={styles.postMenuItem}>{t('report_post')}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -208,7 +196,7 @@ const DeletePostButton = ({ post, onClose }: DeletePostButtonProps) => {
|
||||
latestPostRef.current = resolvedPost;
|
||||
}, [resolvedPost]);
|
||||
const onChallenge = useCallback(async (...args: unknown[]) => {
|
||||
addChallenge([...args, latestPostRef.current]);
|
||||
useChallengesStore.getState().addChallenge([...args, latestPostRef.current]);
|
||||
}, []);
|
||||
|
||||
const deleteOptions = useMemo(
|
||||
@@ -252,8 +240,8 @@ const DeletePostButton = ({ post, onClose }: DeletePostButtonProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role='button'
|
||||
<button
|
||||
type='button'
|
||||
tabIndex={0}
|
||||
onClick={deletePost}
|
||||
onKeyDown={(e) => {
|
||||
@@ -264,7 +252,7 @@ const DeletePostButton = ({ post, onClose }: DeletePostButtonProps) => {
|
||||
}}
|
||||
>
|
||||
<div className={styles.postMenuItem}>{t('delete_post')}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -283,8 +271,8 @@ const HidePostButton = ({ cid, comment, isReply, onClose, postCid }: HideButtonP
|
||||
};
|
||||
return (
|
||||
(!isInPostView || isReply) && (
|
||||
<div
|
||||
role='button'
|
||||
<button
|
||||
type='button'
|
||||
tabIndex={0}
|
||||
onClick={togglePostHidden}
|
||||
onKeyDown={(e) => {
|
||||
@@ -297,7 +285,7 @@ const HidePostButton = ({ cid, comment, isReply, onClose, postCid }: HideButtonP
|
||||
<div className={styles.postMenuItem}>
|
||||
{hidden ? (postCid === cid ? t('unhide_thread') : t('unhide_post')) : postCid === cid ? t('hide_thread') : t('hide_post')}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -343,10 +331,10 @@ const PostMenuMobile = ({ postMenu, editMenuPost }: PostMenuMobileProps) => {
|
||||
<>
|
||||
{!(deleted || removed) && (
|
||||
<>
|
||||
<span
|
||||
<button
|
||||
type='button'
|
||||
className={styles.postMenuBtn}
|
||||
title='Post menu'
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={handleMenuClick}
|
||||
onKeyDown={(e) => {
|
||||
@@ -359,7 +347,7 @@ const PostMenuMobile = ({ postMenu, editMenuPost }: PostMenuMobileProps) => {
|
||||
{...getReferenceProps()}
|
||||
>
|
||||
...
|
||||
</span>
|
||||
</button>
|
||||
{isMenuOpen &&
|
||||
cid &&
|
||||
createPortal(
|
||||
|
||||
@@ -61,8 +61,6 @@ import { getFeedPostHeightEstimate, getReplyHeightEstimates, reportReplyHeightAu
|
||||
import { getAuthorBadge } from '../../lib/utils/author-display-utils';
|
||||
import { hasCommentFlagsForDirectory } from '../../lib/comment-flag-selection';
|
||||
|
||||
const { addChallenge } = useChallengesStore.getState();
|
||||
|
||||
const RepliesFooter = ({ hasMore, loadingString }: { hasMore: boolean; loadingString: string }) =>
|
||||
hasMore ? (
|
||||
<div className={styles.stateString}>
|
||||
@@ -135,7 +133,7 @@ const PostInfoAndMedia = ({
|
||||
communityAddress: shouldShowPendingApprovalButtons ? communityAddress : undefined,
|
||||
commentModeration: approvePendingCommentModeration,
|
||||
onChallenge: async (...args: any) => {
|
||||
addChallenge([...args, resolvedPost]);
|
||||
useChallengesStore.getState().addChallenge([...args, resolvedPost]);
|
||||
},
|
||||
onChallengeVerification: async (challengeVerification, comment) => {
|
||||
alertChallengeVerificationFailed(challengeVerification, comment);
|
||||
@@ -154,7 +152,7 @@ const PostInfoAndMedia = ({
|
||||
communityAddress: shouldShowPendingApprovalButtons ? communityAddress : undefined,
|
||||
commentModeration: rejectPendingCommentModeration,
|
||||
onChallenge: async (...args: any) => {
|
||||
addChallenge([...args, resolvedPost]);
|
||||
useChallengesStore.getState().addChallenge([...args, resolvedPost]);
|
||||
},
|
||||
onChallengeVerification: async (challengeVerification, comment) => {
|
||||
alertChallengeVerificationFailed(challengeVerification, comment);
|
||||
@@ -331,10 +329,10 @@ const PostInfoAndMedia = ({
|
||||
content={`${numberOfPostsByAuthor === 1 ? t('1_post_by_this_id') : t('x_posts_by_this_id', { number: numberOfPostsByAuthor })}`}
|
||||
showTooltip={isInPostPageView || postReplyCount < 6}
|
||||
>
|
||||
<span
|
||||
<button
|
||||
type='button'
|
||||
title={t('highlight_posts')}
|
||||
className={styles.userAddress}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={() => handleUserAddressClick(userID, postCid)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -346,7 +344,7 @@ const PostInfoAndMedia = ({
|
||||
style={{ backgroundColor: userIDBackgroundColor, color: userIDTextColor }}
|
||||
>
|
||||
{formatUserIDForDisplay(userID)}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
){' '}
|
||||
@@ -406,10 +404,10 @@ const PostInfoAndMedia = ({
|
||||
<Link to={threadRoute || '#'} state={threadTopNavigationState} className={styles.linkToPost} title={t('link_to_post')} onClick={onLinkToPostClick}>
|
||||
No.
|
||||
</Link>
|
||||
<span
|
||||
<button
|
||||
type='button'
|
||||
className={styles.replyToPost}
|
||||
title={t('reply_to_post')}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onMouseDown={onReplyModalClick}
|
||||
onKeyDown={(e) => {
|
||||
@@ -420,7 +418,7 @@ const PostInfoAndMedia = ({
|
||||
}}
|
||||
>
|
||||
{resolvedPost?.number || '?'}
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
@@ -443,10 +441,10 @@ const PostInfoAndMedia = ({
|
||||
<LoadingEllipsis string={t('publishing')} />
|
||||
) : (
|
||||
<>
|
||||
<button className={`button ${styles.approveButton}`} onClick={handlePendingApprove} disabled={isPublishingPending}>
|
||||
<button type='button' className={`button ${styles.approveButton}`} onClick={handlePendingApprove} disabled={isPublishingPending}>
|
||||
{t('approve')}
|
||||
</button>
|
||||
<button className={`button ${styles.rejectButton}`} onClick={handlePendingReject} disabled={isPublishingPending}>
|
||||
<button type='button' className={`button ${styles.rejectButton}`} onClick={handlePendingReject} disabled={isPublishingPending}>
|
||||
{t('reject')}
|
||||
</button>
|
||||
</>
|
||||
@@ -625,7 +623,7 @@ const PostMobile = ({
|
||||
const modQueueThreadRouteState = getQueuedCommentRouteState(resolvedPost);
|
||||
const modQueueErrorMessage = formatErrorForDisplay(modQueueError);
|
||||
const modQueueRemoveButton = onRemoveFromModQueue ? (
|
||||
<button className='button' onClick={onRemoveFromModQueue} disabled={isPublishing}>
|
||||
<button type='button' className='button' onClick={onRemoveFromModQueue} disabled={isPublishing}>
|
||||
{capitalize(t('modQueue.dismiss'))}
|
||||
</button>
|
||||
) : null;
|
||||
@@ -840,9 +838,9 @@ const PostMobile = ({
|
||||
<>
|
||||
<hr className={styles.unhideButtonHr} />
|
||||
<span className={styles.mobileUnhideButton}>
|
||||
<span
|
||||
<button
|
||||
type='button'
|
||||
className='button'
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={unhide}
|
||||
onKeyDown={(e) => {
|
||||
@@ -853,7 +851,7 @@ const PostMobile = ({
|
||||
}}
|
||||
>
|
||||
Show Hidden Thread
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
@@ -921,10 +919,10 @@ const PostMobile = ({
|
||||
{t('view_thread')}
|
||||
</Link>
|
||||
)}
|
||||
<button className={`button ${styles.approveButton}`} onClick={onApprove} disabled={isPublishing}>
|
||||
<button type='button' className={`button ${styles.approveButton}`} onClick={onApprove} disabled={isPublishing}>
|
||||
{t('approve')}
|
||||
</button>
|
||||
<button className={`button ${styles.rejectButton}`} onClick={onReject} disabled={isPublishing}>
|
||||
<button type='button' className={`button ${styles.rejectButton}`} onClick={onReject} disabled={isPublishing}>
|
||||
{t('reject')}
|
||||
</button>
|
||||
</>
|
||||
|
||||
@@ -97,6 +97,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
const nonokoRedirectPathRef = useRef<string | null>(null);
|
||||
const lastSelectionStartRef = useRef(0);
|
||||
const lastSelectionEndRef = useRef(0);
|
||||
const initializedReplyContentKeyRef = useRef('');
|
||||
const lastProcessedQuoteInsertRequestIdRef = useRef(0);
|
||||
const { selectedText } = useSelectedTextStore();
|
||||
const openEmpty = useReplyModalStore((state) => state.openEmpty);
|
||||
@@ -243,9 +244,12 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const checkContentLength = checkContentLengthRef.current;
|
||||
const checkPostOptions = checkPostOptionsRef.current;
|
||||
|
||||
return () => {
|
||||
checkContentLengthRef.current.cancel();
|
||||
checkPostOptionsRef.current.cancel();
|
||||
checkContentLength.cancel();
|
||||
checkPostOptions.cancel();
|
||||
restoreBodyTextSelection();
|
||||
};
|
||||
}, []);
|
||||
@@ -293,14 +297,24 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
|
||||
// Enable spellcheck after initial content is injected into the textarea.
|
||||
useEffect(() => {
|
||||
if (showReplyModal && textRef.current) {
|
||||
if (!showReplyModal || !textRef.current) {
|
||||
initializedReplyContentKeyRef.current = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const initialContent = openEmpty ? selectedText || '' : `${defaultParentQuote}${selectedText || ''}`;
|
||||
const initialContentKey = `${parentCid}:${openEmpty ? 'empty' : 'quoted'}:${initialContent}`;
|
||||
if (initializedReplyContentKeyRef.current === initialContentKey) {
|
||||
return;
|
||||
}
|
||||
initializedReplyContentKeyRef.current = initialContentKey;
|
||||
|
||||
textRef.current.spellcheck = false;
|
||||
textRef.current.value = openEmpty ? selectedText || '' : `${defaultParentQuote}${selectedText || ''}`;
|
||||
textRef.current.value = initialContent;
|
||||
const len = textRef.current.value.length;
|
||||
lastSelectionStartRef.current = len;
|
||||
lastSelectionEndRef.current = len;
|
||||
const content = textRef.current.value;
|
||||
const publishContent = getContentWithOptions(content, optionsRef.current?.value || '', fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
|
||||
const publishContent = getContentWithOptions(initialContent, optionsRef.current?.value || '', fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
|
||||
setPublishReplyOptions({ content: publishContent });
|
||||
checkContentLengthRef.current(publishContent, t);
|
||||
|
||||
@@ -313,8 +327,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
return () => {
|
||||
window.clearTimeout(spellcheckTimeout);
|
||||
};
|
||||
}
|
||||
}, [showReplyModal, openEmpty, defaultParentQuote, selectedText]);
|
||||
}, [showReplyModal, parentCid, openEmpty, defaultParentQuote, selectedText, postOptionsDirectoryCode, setPublishReplyOptions, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showReplyModal) {
|
||||
@@ -414,7 +427,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
const publishContent = getContentWithOptions(nextValue, optionsRef.current?.value || '', fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
|
||||
setPublishReplyOptions({ content: publishContent });
|
||||
checkContentLengthRef.current(publishContent, t);
|
||||
}, [showReplyModal, quoteInsertRequestId, quoteInsertNumber, quoteInsertSelectedText, setPublishReplyOptions, t]);
|
||||
}, [showReplyModal, quoteInsertRequestId, quoteInsertNumber, quoteInsertSelectedText, postOptionsDirectoryCode, setPublishReplyOptions, t]);
|
||||
|
||||
const { isUploading, uploadedFileName, handleUpload } = useFileUpload({
|
||||
onUploadComplete: (uploadedUrl: string) => {
|
||||
@@ -568,7 +581,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
<span className={styles.spoilerButton}>
|
||||
[
|
||||
<label>
|
||||
<input type='checkbox' onChange={(e) => setPublishReplyOptions({ spoiler: e.target.checked })} />
|
||||
<input type='checkbox' aria-label={capitalize(t('spoiler'))} onChange={(e) => setPublishReplyOptions({ spoiler: e.target.checked })} />
|
||||
{capitalize(t('spoiler'))}?
|
||||
</label>
|
||||
]
|
||||
|
||||
@@ -353,7 +353,9 @@ const MobileQuotePreview = ({
|
||||
className={styles.backlink}
|
||||
ref={refs.setReference}
|
||||
onMouseOver={() => handleMouseOver(normalizedBacklinkReply?.cid)}
|
||||
onFocus={() => handleMouseOver(normalizedBacklinkReply?.cid)}
|
||||
onMouseLeave={() => handleMouseLeave(normalizedBacklinkReply?.cid)}
|
||||
onBlur={() => handleMouseLeave(normalizedBacklinkReply?.cid)}
|
||||
>
|
||||
{`>>${normalizedBacklinkReply?.number ?? '?'}`}
|
||||
</span>
|
||||
@@ -404,7 +406,9 @@ const MobileQuotePreview = ({
|
||||
ref={quotelinkUnavailable || quotelinkPendingResolution ? undefined : refs.setReference}
|
||||
className={quotelinkPendingResolution ? styles.quoteLink : quotelinkClassName}
|
||||
onMouseOver={quotelinkUnavailable || quotelinkPendingResolution ? undefined : () => handleMouseOver(resolvedQuotelinkCid)}
|
||||
onFocus={quotelinkUnavailable || quotelinkPendingResolution ? undefined : () => handleMouseOver(resolvedQuotelinkCid)}
|
||||
onMouseLeave={quotelinkUnavailable || quotelinkPendingResolution ? undefined : () => handleMouseLeave(resolvedQuotelinkCid)}
|
||||
onBlur={quotelinkUnavailable || quotelinkPendingResolution ? undefined : () => handleMouseLeave(resolvedQuotelinkCid)}
|
||||
>
|
||||
{formatQuoteNumber(resolvedQuotelinkNumber)}
|
||||
{isOP && ' (OP)'}
|
||||
|
||||
@@ -80,12 +80,12 @@ const render = (initialEntry = '/all/settings') => {
|
||||
|
||||
const getLocationText = () => container.querySelector('[data-testid="location"]')?.textContent ?? '';
|
||||
|
||||
const getLabelByText = (text: string) => {
|
||||
const label = Array.from(container.querySelectorAll('label')).find((candidate) => (candidate.textContent ?? '').includes(text));
|
||||
if (!label) {
|
||||
throw new Error(`Label containing "${text}" not found`);
|
||||
const getSectionToggleByText = (text: string) => {
|
||||
const control = Array.from(container.querySelectorAll<HTMLElement>('label, button')).find((candidate) => (candidate.textContent ?? '').includes(text));
|
||||
if (!control) {
|
||||
throw new Error(`Section toggle containing "${text}" not found`);
|
||||
}
|
||||
return label;
|
||||
return control;
|
||||
};
|
||||
|
||||
describe('SettingsModal', () => {
|
||||
@@ -115,7 +115,7 @@ describe('SettingsModal', () => {
|
||||
expect(getLocationText()).toBe('/all/settings#account-settings');
|
||||
|
||||
await act(async () => {
|
||||
getLabelByText('interface').click();
|
||||
getSectionToggleByText('interface').click();
|
||||
});
|
||||
|
||||
expect(getLocationText()).toBe('/all/settings#interface-settings');
|
||||
@@ -123,14 +123,14 @@ describe('SettingsModal', () => {
|
||||
expect(container.querySelector('[data-testid="account-settings"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
getLabelByText('interface').click();
|
||||
getSectionToggleByText('interface').click();
|
||||
});
|
||||
|
||||
expect(getLocationText()).toBe('/all/settings#account-settings');
|
||||
expect(container.querySelector('[data-testid="interface-settings-panel"]')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
getLabelByText('bitsocial_account').click();
|
||||
getSectionToggleByText('bitsocial_account').click();
|
||||
});
|
||||
|
||||
expect(getLocationText()).toBe('/all/settings');
|
||||
@@ -140,7 +140,7 @@ describe('SettingsModal', () => {
|
||||
it('expands and collapses all settings sections', async () => {
|
||||
render('/all/settings');
|
||||
|
||||
const expandAllControl = Array.from(container.querySelectorAll('[role="button"]')).find((candidate) => (candidate.textContent ?? '').includes('expand_all_settings'));
|
||||
const expandAllControl = Array.from(container.querySelectorAll('button')).find((candidate) => (candidate.textContent ?? '').includes('expand_all_settings'));
|
||||
if (!expandAllControl) {
|
||||
throw new Error('expand_all_settings control not found');
|
||||
}
|
||||
@@ -157,9 +157,7 @@ describe('SettingsModal', () => {
|
||||
expect(container.querySelector('[data-testid="advanced-settings-panel"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="p2p-stats-settings-panel"]')).not.toBeNull();
|
||||
|
||||
const collapseAllControl = Array.from(container.querySelectorAll('[role="button"]')).find((candidate) =>
|
||||
(candidate.textContent ?? '').includes('collapse_all_settings'),
|
||||
);
|
||||
const collapseAllControl = Array.from(container.querySelectorAll('button')).find((candidate) => (candidate.textContent ?? '').includes('collapse_all_settings'));
|
||||
if (!collapseAllControl) {
|
||||
throw new Error('collapse_all_settings control not found');
|
||||
}
|
||||
@@ -186,7 +184,7 @@ describe('SettingsModal', () => {
|
||||
it('closes the modal when the overlay is clicked', async () => {
|
||||
render('/all/settings#interface-settings');
|
||||
|
||||
const overlay = container.querySelector('[role="button"]');
|
||||
const overlay = container.querySelector('button');
|
||||
if (!overlay) {
|
||||
throw new Error('overlay not found');
|
||||
}
|
||||
|
||||
@@ -217,13 +217,19 @@ const AccountSettingsEditor = ({
|
||||
<select value={account?.name} onChange={(e) => setActiveAccount(e.target.value)}>
|
||||
{accountsOptions}
|
||||
</select>{' '}
|
||||
<button onClick={() => navigate('/settings/account-data', { state: { returnTo: location.pathname + location.hash } })}>{t('edit')}</button>{' '}
|
||||
<button onClick={handleExportAccount}>{t('download_backup')}</button>
|
||||
<button type='button' onClick={() => navigate('/settings/account-data', { state: { returnTo: location.pathname + location.hash } })}>
|
||||
{t('edit')}
|
||||
</button>{' '}
|
||||
<button type='button' onClick={handleExportAccount}>
|
||||
{t('download_backup')}
|
||||
</button>
|
||||
<div className={styles.info}>{accountStorageInfo}</div>
|
||||
</div>
|
||||
<div>
|
||||
<button onClick={handleImportAccount}>{t('import_account_backup')}</button>
|
||||
<button className={styles.deleteAccount} onClick={() => _deleteAccount(account?.name ?? '')}>
|
||||
<button type='button' onClick={handleImportAccount}>
|
||||
{t('import_account_backup')}
|
||||
</button>
|
||||
<button type='button' className={styles.deleteAccount} onClick={() => _deleteAccount(account?.name ?? '')}>
|
||||
{t('delete_account')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -64,6 +64,7 @@ const IPFSGatewaysSettings = ({ ipfsGatewayUrlsRef, mediaIpfsGatewayUrlRef }: Se
|
||||
<div className={styles.ipfsGatewaysSettings}>
|
||||
<div className={styles.ipfsGatewaysSetting}>
|
||||
<textarea
|
||||
aria-label='IPFS gateway URLs'
|
||||
defaultValue={ipfsGatewayUrlsDefaultValue}
|
||||
ref={ipfsGatewayUrlsRef}
|
||||
disabled={isConnectedToRpc}
|
||||
@@ -77,6 +78,7 @@ const IPFSGatewaysSettings = ({ ipfsGatewayUrlsRef, mediaIpfsGatewayUrlRef }: Se
|
||||
<div>
|
||||
<input
|
||||
type='text'
|
||||
aria-label='Media IPFS gateway URL'
|
||||
defaultValue={mediaIpfsGatewayUrl}
|
||||
ref={mediaIpfsGatewayUrlRef}
|
||||
disabled={isConnectedToRpc}
|
||||
@@ -100,6 +102,7 @@ const PubsubProvidersSettings = ({ pubsubProvidersRef }: SettingsProps) => {
|
||||
return (
|
||||
<div className={styles.pubsubProvidersSettings}>
|
||||
<textarea
|
||||
aria-label='Pubsub providers'
|
||||
defaultValue={pubsubProvidersDefaultValue}
|
||||
ref={pubsubProvidersRef}
|
||||
disabled={isConnectedToRpc}
|
||||
@@ -124,6 +127,7 @@ const HttpRoutersSettings = ({ httpRoutersRef }: SettingsProps) => {
|
||||
return (
|
||||
<div className={styles.httpRoutersSettings}>
|
||||
<textarea
|
||||
aria-label='HTTP routers'
|
||||
defaultValue={httpRoutersDefaultValue}
|
||||
ref={httpRoutersRef}
|
||||
disabled={isConnectedToRpc}
|
||||
@@ -147,6 +151,7 @@ const BlockchainProvidersSettings = ({ ethRpcRef }: SettingsProps) => {
|
||||
<span className={styles.settingTip}>Ethereum RPC, for .eth domains</span>
|
||||
<div>
|
||||
<textarea
|
||||
aria-label='Ethereum RPC URLs'
|
||||
defaultValue={ethRpcDefaultValue}
|
||||
ref={ethRpcRef}
|
||||
autoCorrect='off'
|
||||
@@ -168,6 +173,7 @@ const P2pRPCSettings = ({ p2pRpcRef }: SettingsProps) => {
|
||||
<div className={styles.p2pRPCSettings}>
|
||||
<input
|
||||
type='text'
|
||||
aria-label='P2P RPC clients'
|
||||
defaultValue={pkcRpcClientsOptions}
|
||||
placeholder='ws://<IP>:<port>/<secretAuthKey>'
|
||||
ref={p2pRpcRef}
|
||||
@@ -188,7 +194,16 @@ const P2pDataPathSettings = ({ p2pDataPathRef }: SettingsProps) => {
|
||||
return (
|
||||
<div className={styles.p2pDataPathSettings}>
|
||||
<div>
|
||||
<input autoCorrect='off' autoCapitalize='off' spellCheck='false' type='text' defaultValue={path} disabled={!isConnectedToRpc} ref={p2pDataPathRef} />
|
||||
<input
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
type='text'
|
||||
aria-label='P2P data path'
|
||||
defaultValue={path}
|
||||
disabled={!isConnectedToRpc}
|
||||
ref={p2pDataPathRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -205,6 +220,7 @@ const PureP2PBrowserSettings = ({ onPureP2PBrowserChange, pureP2PBrowserEnabled,
|
||||
<input
|
||||
className={styles.pureP2PCheckbox}
|
||||
type='checkbox'
|
||||
aria-label='Pure P2P browser mode'
|
||||
checked={isChecked}
|
||||
ref={pureP2PBrowserRef}
|
||||
onChange={(event) => onPureP2PBrowserChange?.(event.currentTarget.checked)}
|
||||
@@ -363,7 +379,7 @@ const AdvancedSettings = () => {
|
||||
{canConfigurePureP2PBrowser ? (
|
||||
<PureP2PBrowserSettings pureP2PBrowserEnabled={browserPureP2PEnabled} pureP2PBrowserRef={pureP2PBrowserRef} onPureP2PBrowserChange={setBrowserPureP2PSelection} />
|
||||
) : null}
|
||||
<button className={styles.saveOptions} onClick={handleSave}>
|
||||
<button type='button' className={styles.saveOptions} onClick={handleSave}>
|
||||
{t('save_advanced_settings')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -178,6 +178,7 @@ const CryptoAddressSettingContent = ({ account }: { account: ReturnType<typeof u
|
||||
<div className={styles.cryptoAddressInput}>
|
||||
<input
|
||||
type='text'
|
||||
aria-label='Crypto address'
|
||||
placeholder='myaddress.bso'
|
||||
value={cryptoAddress}
|
||||
onChange={(e) => {
|
||||
@@ -186,33 +187,48 @@ const CryptoAddressSettingContent = ({ account }: { account: ReturnType<typeof u
|
||||
setCryptoAddress(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<button className={styles.saveButton} onClick={saveCryptoAddress}>
|
||||
<button type='button' className={styles.saveButton} onClick={saveCryptoAddress}>
|
||||
{t('save')}
|
||||
</button>
|
||||
<button className={styles.infoButton} onClick={() => setShowCryptoAddressInfo(!showCryptoAddressInfo)}>
|
||||
<button
|
||||
type='button'
|
||||
className={styles.infoButton}
|
||||
aria-label={showCryptoAddressInfo ? 'Hide crypto address help' : 'Show crypto address help'}
|
||||
onClick={() => setShowCryptoAddressInfo(!showCryptoAddressInfo)}
|
||||
>
|
||||
{showCryptoAddressInfo ? 'x' : '?'}
|
||||
</button>
|
||||
{showCryptoAddressInfo && (
|
||||
<div className={styles.cryptoAddressInfo}>
|
||||
steps to set a .bso account address:
|
||||
<br />
|
||||
A <code>.bso</code> address is just an ENS name you own, shown by 5chan with a <code>.bso</code> ending instead of <code>.eth</code>. To use one as your
|
||||
account name:
|
||||
<ol>
|
||||
<li>
|
||||
buy your desired .bso address as .eth on{' '}
|
||||
Register a name (e.g. <code>yourname.eth</code>) at{' '}
|
||||
<a href='https://app.ens.domains/' target='_blank' rel='noopener noreferrer'>
|
||||
app.ens.domains
|
||||
</a>{' '}
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
<li>
|
||||
Open that name on ENS and go to <strong>Records</strong> → <strong>Edit Records</strong>.
|
||||
</li>
|
||||
<li>
|
||||
Add a <strong>text record</strong> named <code>bitsocial</code> and paste this account's public key as its value:
|
||||
<br />
|
||||
<code>{account?.signer?.address}</code>
|
||||
</li>
|
||||
<li>
|
||||
Come back here, type your name ending in <code>.bso</code> (e.g. <code>yourname.bso</code>) in the field above, press <strong>Check</strong> to confirm it
|
||||
points to this account, then press <strong>Save</strong>.
|
||||
</li>
|
||||
<li>once you own the .eth address, go to its page on ENS, click on "records", then "edit records"</li>
|
||||
<li>add a new text record with name "bitsocial" and value: {account?.signer?.address}</li>
|
||||
<li>enter your .bso address in the input field above, click "check" to verify it's yours, then click "save"</li>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
{savedCryptoAddress && <span className={styles.saved}>{t('saved')}</span>}
|
||||
</div>
|
||||
<div className={styles.checkCryptoAddress}>
|
||||
<button className={styles.button} onClick={checkCryptoAddress}>
|
||||
<button type='button' className={styles.button} onClick={checkCryptoAddress}>
|
||||
{t('check')}
|
||||
</button>{' '}
|
||||
<span className={(transientResolutionStatus ?? resolutionStatus).resolveClass}>{(transientResolutionStatus ?? resolutionStatus).resolveString}</span>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user