feat(settings): add media hosting settings and provider-based upload gating

This commit is contained in:
plebeius
2026-02-17 18:57:54 +08:00
parent 45a6ff0bab
commit 96f11b3e26
46 changed files with 448 additions and 55 deletions
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "تصبح /{{boardIdentifier}}/ الأكثر تصويتاً هي /{{boardIdentifier}}/ الرسمية.",
"upload_failed": "فشل التحميل",
"upload_not_supported_web": "رفع الوسائط غير مدعوم من متصفحك بسبب قيود تقنية (CORS). إنه مدعوم في تطبيق 5chan لـ Android والتطبيق المكتبي لـ Mac/Linux/Windows. التحميل من صفحة الإصدار الرسمية: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "ملف"
"file": "ملف",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "সর্বাধিক ভোটপ্রাপ্ত /{{boardIdentifier}}/ আনুষ্ঠানিক /{{boardIdentifier}}/ হয়ে ওঠে।",
"upload_failed": "আপলোড ব্যর্থ",
"upload_not_supported_web": "প্রযুক্তিগত সীমাবদ্ধতার কারণে (CORS) আপনার ব্রাউজার মিডিয়া আপলোড সমর্থন করে না। এটি 5chan অ্যান্ড্রয়েড অ্যাপ এবং Mac/Linux/Windows ডেস্কটপ অ্যাপে সমর্থিত। অফিসিয়াল রিলিজ পৃষ্ঠা থেকে ডাউনলোড করুন: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "ফাইল"
"file": "ফাইল",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "Nejvíce hlasovaná /{{boardIdentifier}}/ se stane oficiální /{{boardIdentifier}}/.",
"upload_failed": "Nahrávání se nezdařilo",
"upload_not_supported_web": "Nahrávání médií není vaším prohlížečem podporováno z důvodu technických omezení (CORS). Je podporováno v aplikaci 5chan pro Android a desktopové aplikaci pro Mac/Linux/Windows. Stáhněte si z oficiální stránky vydání: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "Soubor"
"file": "Soubor",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "Det /{{boardIdentifier}}/ med flest stemmer bliver den officielle /{{boardIdentifier}}/.",
"upload_failed": "Upload mislykkedes",
"upload_not_supported_web": "Upload af medier understøttes ikke af din browser på grund af tekniske begrænsninger (CORS). Det understøttes i 5chan Android-appen og Mac/Linux/Windows desktop-appen. Download fra den officielle udgivelsesside: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "Fil"
"file": "Fil",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "Das am meisten abgestimmte /{{boardIdentifier}}/ wird zum offiziellen /{{boardIdentifier}}/.",
"upload_failed": "Upload fehlgeschlagen",
"upload_not_supported_web": "Das Hochladen von Medien wird von Ihrem Browser aufgrund technischer Einschränkungen (CORS) nicht unterstützt. Es wird in der 5chan-Android-App und der Mac/Linux/Windows-Desktop-App unterstützt. Download von der offiziellen Release-Seite: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "Datei"
"file": "Datei",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "Η /{{boardIdentifier}}/ με τους περισσότερους ψήφους γίνεται η επίσημη /{{boardIdentifier}}/.",
"upload_failed": "Αποτυχία μεταφόρτωσης",
"upload_not_supported_web": "Η μεταφόρτωση πολυμέσων δεν υποστηρίζεται από το πρόγραμμα περιήγησής σας λόγω τεχνικών περιορισμών (CORS). Υποστηρίζεται στην εφαρμογή 5chan για Android και την εφαρμογή επιφάνειας εργασίας Mac/Linux/Windows. Λήψη από τη σελίδα επίσημης έκδοσης: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "Αρχείο"
"file": "Αρχείο",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "The highest-voted /{{boardIdentifier}}/ becomes the official /{{boardIdentifier}}/.",
"upload_failed": "Upload failed",
"upload_not_supported_web": "Uploading media isn't supported by your browser due to technical limitations (CORS). It is supported in the 5chan Android app and Mac/Linux/Windows desktop app. Download from the official release page: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "File"
"file": "File",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "El tablero /{{boardIdentifier}}/ más votado se convierte en el oficial /{{boardIdentifier}}/.",
"upload_failed": "Error al subir",
"upload_not_supported_web": "La subida de medios no está soportada por tu navegador debido a limitaciones técnicas (CORS). Sí está soportada en la app 5chan para Android y en la app de escritorio para Mac/Linux/Windows. Descarga desde la página oficial de releases: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "Archivo"
"file": "Archivo",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "بیشترین رای‌شده /{{boardIdentifier}}/ به /{{boardIdentifier}}/ رسمی تبدیل می‌شود.",
"upload_failed": "آپلود ناموفق بود",
"upload_not_supported_web": "آپلود رسانه توسط مرورگر شما به دلیل محدودیت‌های فنی (CORS) پشتیبانی نمی‌شود. در اپلیکیشن 5chan اندروید و اپلیکیشن دسکتاپ Mac/Linux/Windows پشتیبانی می‌شود. از صفحه انتشار رسمی دانلود کنید: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "فایل"
"file": "فایل",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "Eniten ääniä saanut /{{boardIdentifier}}/ tulee viralliseksi /{{boardIdentifier}}/.",
"upload_failed": "Lataus epäonnistui",
"upload_not_supported_web": "Medioiden latausta ei tueta selaimessasi teknisistä rajoituksista (CORS) johtuen. Se on tuettu 5chan-sovelluksessa Androidille ja Mac/Linux/Windows-työpöytäsovelluksessa. Lataa viralliselta julkaisusivulta: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "Tiedosto"
"file": "Tiedosto",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "Ang /{{boardIdentifier}}/ na may pinakamataas na boto ay nagiging ang opisyal na /{{boardIdentifier}}/.",
"upload_failed": "Nabigo ang pag-upload",
"upload_not_supported_web": "Ang pag-upload ng media ay hindi sinusuportahan ng iyong browser dahil sa mga limitasyong teknikal (CORS). Sinusuportahan ito sa 5chan Android app at Mac/Linux/Windows desktop app. I-download mula sa opisyal na release page: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "Talaksan"
"file": "Talaksan",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "Le tableau /{{boardIdentifier}}/ le plus voté devient le tableau officiel /{{boardIdentifier}}/.",
"upload_failed": "Échec du téléchargement",
"upload_not_supported_web": "Le téléchargement de médias n'est pas pris en charge par votre navigateur en raison de limitations techniques (CORS). Il est pris en charge dans l'application 5chan pour Android et l'application de bureau Mac/Linux/Windows. Téléchargez depuis la page officielle des versions : https://github.com/bitsocialhq/5chan/releases/latest",
"file": "Fichier"
"file": "Fichier",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "/{{boardIdentifier}}/ בעל ההצבעות הרבות ביותר הופך ל- /{{boardIdentifier}}/ הרשמי.",
"upload_failed": "ההעלאה נכשלה",
"upload_not_supported_web": "העלאת מדיה אינה נתמכת בדפדפן שלך בשל מגבלות טכניות (CORS). היא נתמכת באפליקציית 5chan ל-Android ובאפליקציית הדסקטופ ל-Mac/Linux/Windows. הורד מדף השחרור הרשמי: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "קובץ"
"file": "קובץ",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "सर्वाधिक मतदान प्राप्त /{{boardIdentifier}}/ आधिकारिक /{{boardIdentifier}}/ बन जाता है।",
"upload_failed": "अपलोड विफल",
"upload_not_supported_web": "तकनीकी सीमाओं (CORS) के कारण आपका ब्राउज़र मीडिया अपलोड का समर्थन नहीं करता। यह 5chan एंड्रॉइड ऐप और Mac/Linux/Windows डेस्कटॉप ऐप में समर्थित है। आधिकारिक रिलीज़ पेज से डाउनलोड करें: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "फ़ाइल"
"file": "फ़ाइल",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "A legtöbb szavazatot kapott /{{boardIdentifier}}/ lesz a hivatalos /{{boardIdentifier}}/.",
"upload_failed": "A feltöltés sikertelen",
"upload_not_supported_web": "A média feltöltése nem támogatott a böngésződben technikai korlátozások miatt (CORS). A 5chan Android alkalmazásban és a Mac/Linux/Windows asztali alkalmazásban támogatott. Töltse le a hivatalos kiadási oldalról: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "Fájl"
"file": "Fájl",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "/{{boardIdentifier}}/ dengan suara terbanyak menjadi /{{boardIdentifier}}/ resmi.",
"upload_failed": "Unggahan gagal",
"upload_not_supported_web": "Mengunggah media tidak didukung oleh browser Anda karena batasan teknis (CORS). Ini didukung di aplikasi 5chan Android dan aplikasi desktop Mac/Linux/Windows. Unduh dari halaman rilis resmi: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "Berkas"
"file": "Berkas",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "Il /{{boardIdentifier}}/ più votato diventa il /{{boardIdentifier}}/ ufficiale.",
"upload_failed": "Caricamento fallito",
"upload_not_supported_web": "Il caricamento di file multimediali non è supportato dal tuo browser a causa di limitazioni tecniche (CORS). È supportato nell'app 5chan per Android e nell'app desktop per Mac/Linux/Windows. Scarica dalla pagina ufficiale delle release: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "File"
"file": "File",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "最も投票された /{{boardIdentifier}}/ が公式の /{{boardIdentifier}}/ になります。",
"upload_failed": "アップロードに失敗しました",
"upload_not_supported_web": "技術的な制限(CORS)により、お使いのブラウザではメディアのアップロードに対応していません。5chan AndroidアプリとMac/Linux/Windowsデスクトップアプリでは対応しています。公式リリースページからダウンロードしてください: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "ファイル"
"file": "ファイル",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "가장 많은 투표를 받은 /{{boardIdentifier}}/이 공식 /{{boardIdentifier}}/이 됩니다.",
"upload_failed": "업로드 실패",
"upload_not_supported_web": "기술적 제한(CORS)으로 인해 브라우저에서 미디어 업로드를 지원하지 않습니다. 5chan Android 앱과 Mac/Linux/Windows 데스크톱 앱에서는 지원됩니다. 공식 릴리스 페이지에서 다운로드하세요: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "파일"
"file": "파일",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "सर्वाधिक मतदान केलेले /{{boardIdentifier}}/ अधिकृत /{{boardIdentifier}}/ बन जाते.",
"upload_failed": "अपलोड अयशस्वी",
"upload_not_supported_web": "तांत्रिक मर्यादांमुळे (CORS) तुमचा ब्राउझर मीडिया अपलोडला समर्थन देत नाही. 5chan Android अॅप आणि Mac/Linux/Windows डेस्कटॉप अॅपमध्ये ते समर्थित आहे. अधिकृत रिलीझ पेजवरून डाउनलोड करा: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "फाइल"
"file": "फाइल",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "Het /{{boardIdentifier}}/ met de meeste stemmen wordt het officiële /{{boardIdentifier}}/.",
"upload_failed": "Upload mislukt",
"upload_not_supported_web": "Het uploaden van media wordt niet ondersteund door je browser vanwege technische beperkingen (CORS). Het wordt wel ondersteund in de 5chan Android-app en de Mac/Linux/Windows desktop-app. Download van de officiële releasespagina: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "Bestand"
"file": "Bestand",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "Det /{{boardIdentifier}}/ med flest stemmer blir det offisielle /{{boardIdentifier}}/.",
"upload_failed": "Opplasting mislyktes",
"upload_not_supported_web": "Opplasting av media støttes ikke av nettleseren din på grunn av tekniske begrensninger (CORS). Det støttes i 5chan Android-appen og Mac/Linux/Windows desktop-appen. Last ned fra den offisielle utgivelsessiden: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "Fil"
"file": "Fil",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "Tablica /{{boardIdentifier}}/ z największą liczbą głosów staje się oficjalną /{{boardIdentifier}}/.",
"upload_failed": "Przesyłanie nie powiodło się",
"upload_not_supported_web": "Przesyłanie multimediów nie jest obsługiwane przez twoją przeglądarkę ze względu na ograniczenia techniczne (CORS). Jest obsługiwane w aplikacji 5chan na Androida i w aplikacji desktopowej Mac/Linux/Windows. Pobierz ze strony oficjalnych wydań: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "Plik"
"file": "Plik",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "O /{{boardIdentifier}}/ mais votado se torna o /{{boardIdentifier}}/ oficial.",
"upload_failed": "Falha no envio",
"upload_not_supported_web": "O envio de mídia não é suportado pelo seu navegador devido a limitações técnicas (CORS). É suportado no app 5chan para Android e no app de desktop para Mac/Linux/Windows. Baixe na página oficial de releases: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "Arquivo"
"file": "Arquivo",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "Tabloul /{{boardIdentifier}}/ cu cele mai multe voturi devine /{{boardIdentifier}}/ oficial.",
"upload_failed": "Încărcarea a eșuat",
"upload_not_supported_web": "Încărcarea mediilor nu este suportată de browserul tău din cauza limitărilor tehnice (CORS). Este suportată în aplicația 5chan pentru Android și aplicația de desktop pentru Mac/Linux/Windows. Descarcă de pe pagina oficială de release: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "Fișier"
"file": "Fișier",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "Набравший наибольшее количество голосов /{{boardIdentifier}}/ становится официальным /{{boardIdentifier}}/.",
"upload_failed": "Ошибка загрузки",
"upload_not_supported_web": "Загрузка медиафайлов не поддерживается вашим браузером из-за технических ограничений (CORS). Поддерживается в приложении 5chan для Android и в десктопном приложении для Mac/Linux/Windows. Скачайте с официальной страницы релизов: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "Файл"
"file": "Файл",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "/{{boardIdentifier}}/ me më shumë vota bëhet /{{boardIdentifier}}/ zyrtare.",
"upload_failed": "Ngarkimi dështoi",
"upload_not_supported_web": "Ngarkimi i mediave nuk mbështetet nga shfletuesi juaj për shkak të kufizimeve teknike (CORS). Mbështetet në aplikacionin 5chan për Android dhe aplikacionin desktop për Mac/Linux/Windows. Shkarkoni nga faqja zyrtare e publikimeve: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "Skedar"
"file": "Skedar",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "Det /{{boardIdentifier}}/ med flest röster blir det officiella /{{boardIdentifier}}/.",
"upload_failed": "Uppladdning misslyckades",
"upload_not_supported_web": "Uppladdning av media stöds inte av din webbläsare på grund av tekniska begränsningar (CORS). Det stöds i 5chan Android-appen och Mac/Linux/Windows desktop-appen. Ladda ner från den officiella utgivningssidan: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "Fil"
"file": "Fil",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "సర్వాధిక ఓట్లను పొందిన /{{boardIdentifier}}/ అధికారిక /{{boardIdentifier}}/ అవుతుంది.",
"upload_failed": "అప్‌లోడ్ విఫలమైంది",
"upload_not_supported_web": "సాంకేతిక పరిమితుల (CORS) కారణంగా మీ బ్రౌజర్ మీడియా అప్‌లోడ్‌కు మద్దతు ఇవ్వదు. ఇది 5chan Android అనువర్తనం మరియు Mac/Linux/Windows డెస్క్‌టాప్ అనువర్తనంలో మద్దతివ్వబడుతుంది. అధికారిక రిలీజ్ పేజీ నుండి డౌన్‌లోడ్ చేయండి: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "ఫైల్"
"file": "ఫైల్",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "/{{boardIdentifier}}/ ที่ได้โหวตมากที่สุดจะกลายเป็น /{{boardIdentifier}}/ อย่างเป็นทางการ",
"upload_failed": "อัปโหลดล้มเหลว",
"upload_not_supported_web": "การอัปโหลดสื่อไม่รองรับโดยเบราว์เซอร์ของคุณเนื่องจากข้อจำกัดทางเทคนิค (CORS) รองรับในแอป 5chan สำหรับ Android และแอปเดสก์ท็อปสำหรับ Mac/Linux/Windows ดาวน์โหลดจากหน้าปล่อยอย่างเป็นทางการ: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "ไฟล์"
"file": "ไฟล์",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "En çok oy alan /{{boardIdentifier}}/ resmi /{{boardIdentifier}}/ olur.",
"upload_failed": "Yükleme başarısız",
"upload_not_supported_web": "Teknik kısıtlamalar (CORS) nedeniyle tarayıcınız medya yüklemeyi desteklemiyor. 5chan Android uygulamasında ve Mac/Linux/Windows masaüstü uygulamasında destekleniyor. Resmi sürüm sayfasından indirin: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "Dosya"
"file": "Dosya",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "Найбільш проголосована /{{boardIdentifier}}/ стає офіційною /{{boardIdentifier}}/.",
"upload_failed": "Помилка завантаження",
"upload_not_supported_web": "Завантаження медіа не підтримується вашим браузером через технічні обмеження (CORS). Підтримується в додатку 5chan для Android та десктопному додатку для Mac/Linux/Windows. Завантажте з офіційної сторінки релізів: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "Файл"
"file": "Файл",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "سب سے زیادہ ووٹ والا /{{boardIdentifier}}/ سرکاری /{{boardIdentifier}}/ بن جاتا ہے۔",
"upload_failed": "اپ لوڈ ناکام",
"upload_not_supported_web": "تکنیکی حدود (CORS) کی وجہ سے آپ کے براؤزر میں میڈیا اپ لوڈ کی حمایت نہیں ہے۔ یہ 5chan Android ایپ اور Mac/Linux/Windows ڈیسک ٹاپ ایپ میں دستیاب ہے۔ سرکاری ریلیز صفحہ سے ڈاؤن لوڈ کریں: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "فائل"
"file": "فائل",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "/{{boardIdentifier}}/ được bình chọn nhiều nhất trở thành /{{boardIdentifier}}/ chính thức.",
"upload_failed": "Tải lên thất bại",
"upload_not_supported_web": "Trình duyệt của bạn không hỗ trợ tải lên media do giới hạn kỹ thuật (CORS). Tính năng này được hỗ trợ trong ứng dụng 5chan cho Android và ứng dụng desktop cho Mac/Linux/Windows. Tải xuống từ trang phát hành chính thức: https://github.com/bitsocialhq/5chan/releases/latest",
"file": "Tệp"
"file": "Tệp",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+4 -1
View File
@@ -284,5 +284,8 @@
"vote_button_unavailable_outro": "投票最多的 /{{boardIdentifier}}/ 将成为官方 /{{boardIdentifier}}/。",
"upload_failed": "上传失败",
"upload_not_supported_web": "由于技术限制(CORS),您的浏览器不支持上传媒体。5chan Android 应用和 Mac/Linux/Windows 桌面应用支持此功能。请从官方发布页面下载:https://github.com/bitsocialhq/5chan/releases/latest",
"file": "文件"
"file": "文件",
"media_hosting_none": "None",
"media_hosting_none_tip": "Hides the file upload button from post and reply forms",
"media_hosting": "Media Hosting"
}
+14 -9
View File
@@ -16,6 +16,7 @@ import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline';
import usePublishPost from '../../hooks/use-publish-post';
import usePublishReply from '../../hooks/use-publish-reply';
import { useFileUpload } from '../../hooks/use-file-upload';
import useMediaHostingStore from '../../stores/use-media-hosting-store';
import styles from './post-form.module.css';
import { capitalize, debounce } from 'lodash';
@@ -209,6 +210,8 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
}
},
});
const selectedProvider = useMediaHostingStore((state) => state.selectedProvider);
const showUploadControls = selectedProvider !== 'none';
const hasInitializedDisplayName = useRef(false);
useEffect(() => {
@@ -289,15 +292,17 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
<span className={styles.linkType}> {url && <LinkTypePreviewer link={url} />}</span>
</td>
</tr>
<tr className={styles.uploadButton}>
<td>{t('file')}</td>
<td>
<button onClick={handleUpload} disabled={isUploading}>
{t('choose_file')}
</button>
<span>{isUploading ? t('uploading') : uploadedFileName || t('no_file_chosen')}</span>
</td>
</tr>
{showUploadControls && (
<tr className={styles.uploadButton}>
<td>{t('file')}</td>
<td>
<button onClick={handleUpload} disabled={isUploading}>
{t('choose_file')}
</button>
<span>{isUploading ? t('uploading') : uploadedFileName || t('no_file_chosen')}</span>
</td>
</tr>
)}
{((isInPostView && showSpoilerForReply) || (!isInPostView && showSpoilerForPost)) && (
<tr className={styles.spoilerButton}>
<td>{t('options')}</td>
+14 -9
View File
@@ -9,6 +9,7 @@ import { isValidURL } from '../../lib/utils/url-utils';
import { isAllView, isSubscriptionsView } from '../../lib/utils/view-utils';
import useSelectedTextStore from '../../stores/use-selected-text-store';
import useReplyModalStore from '../../stores/use-reply-modal-store';
import useMediaHostingStore from '../../stores/use-media-hosting-store';
import { useDirectoryByAddress } from '../../hooks/use-directories';
import usePublishReply from '../../hooks/use-publish-reply';
import useIsMobile from '../../hooks/use-is-mobile';
@@ -271,6 +272,8 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
}
},
});
const selectedProvider = useMediaHostingStore((state) => state.selectedProvider);
const showUploadControls = selectedProvider !== 'none';
const hasInitializedDisplayName = useRef(false);
useEffect(() => {
@@ -336,16 +339,18 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
/>
</div>
<div className={styles.footer}>
<span className={styles.uploadContainer}>
<span className={styles.uploadButton}>
<button onClick={handleUpload} disabled={isUploading}>
{t('choose_file')}
</button>
{showUploadControls && (
<span className={styles.uploadContainer}>
<span className={styles.uploadButton}>
<button onClick={handleUpload} disabled={isUploading}>
{t('choose_file')}
</button>
</span>
<span className={styles.uploadFileName} title={uploadedFileName || t('no_file_chosen')}>
{isUploading ? t('uploading') : uploadedFileName || t('no_file_chosen')}
</span>
</span>
<span className={styles.uploadFileName} title={uploadedFileName || t('no_file_chosen')}>
{isUploading ? t('uploading') : uploadedFileName || t('no_file_chosen')}
</span>
</span>
)}
{showSpoilerForReply && (
<span className={styles.spoilerButton}>
[
@@ -22,6 +22,17 @@
text-decoration: var(--post-content-link-text-decoration-hover);
}
.setting a {
color: var(--post-link-text-color);
text-decoration: var(--post-content-link-text-decoration);
text-transform: lowercase;
}
.setting a:hover {
color: var(--post-link-text-color-hover);
text-decoration: var(--post-content-link-text-decoration-hover);
}
.interfaceSettings {
margin-bottom: 10px;
}
@@ -30,6 +41,10 @@
margin-right: 2px;
}
.setting input[type="radio"] {
margin-right: 5px;
}
.settingTip {
font-size: 0.85em;
margin: 2px 0 5px 0;
@@ -0,0 +1,92 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import MediaHostingSettings from '../media-hosting-settings';
import { MEDIA_HOSTING_PROVIDERS } from '../../../../stores/use-media-hosting-store';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
const mockSetSelectedProvider = vi.fn();
const selectedProviderRef = vi.hoisted(() => ({ value: 'catbox' as string }));
vi.mock('../../../../stores/use-media-hosting-store', async (importOriginal) => {
const mod = await importOriginal<typeof import('../../../../stores/use-media-hosting-store')>();
return {
...mod,
default: (selector: (state: { selectedProvider: string; setSelectedProvider: (provider: string) => void }) => unknown) =>
selector({
selectedProvider: selectedProviderRef.value,
setSelectedProvider: mockSetSelectedProvider,
}),
};
});
let root: Root;
let container: HTMLDivElement;
const render = () => {
act(() => {
root.render(createElement(MediaHostingSettings));
});
};
describe('MediaHostingSettings', () => {
beforeEach(() => {
vi.clearAllMocks();
selectedProviderRef.value = 'catbox';
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('renders None option and all providers', () => {
render();
const radios = container.querySelectorAll('input[type="radio"]');
expect(radios.length).toBe(MEDIA_HOSTING_PROVIDERS.length + 1);
expect(container.textContent).toContain('media_hosting_none');
for (const provider of MEDIA_HOSTING_PROVIDERS) {
expect(container.textContent).toContain(provider.name);
expect(container.textContent).toContain(provider.url);
}
});
it('default selected is catbox', () => {
render();
const catboxRadio = container.querySelector<HTMLInputElement>('input[value="catbox"]');
expect(catboxRadio).not.toBeNull();
expect(catboxRadio?.checked).toBe(true);
const noneRadio = container.querySelector<HTMLInputElement>('input[value="none"]');
expect(noneRadio?.checked).toBe(false);
});
it('clicking a radio updates store selection', async () => {
render();
const noneRadio = container.querySelector<HTMLInputElement>('input[value="none"]');
expect(noneRadio).not.toBeNull();
await act(async () => {
noneRadio?.click();
});
expect(mockSetSelectedProvider).toHaveBeenCalledWith('none');
mockSetSelectedProvider.mockClear();
selectedProviderRef.value = 'none';
render();
const providerRadio = container.querySelector<HTMLInputElement>(`input[value="${MEDIA_HOSTING_PROVIDERS[0].id}"]`);
expect(providerRadio).not.toBeNull();
await act(async () => {
providerRadio?.click();
});
expect(mockSetSelectedProvider).toHaveBeenCalledWith(MEDIA_HOSTING_PROVIDERS[0].id);
});
});
@@ -0,0 +1 @@
export { default } from './media-hosting-settings';
@@ -0,0 +1,39 @@
import { useTranslation } from 'react-i18next';
import useMediaHostingStore, { MEDIA_HOSTING_PROVIDERS } from '../../../stores/use-media-hosting-store';
import styles from '../interface-settings/interface-settings.module.css';
const RADIO_NAME = 'media-hosting-provider';
const MediaHostingSettings = () => {
const { t } = useTranslation();
const selectedProvider = useMediaHostingStore((state) => state.selectedProvider);
const setSelectedProvider = useMediaHostingStore((state) => state.setSelectedProvider);
return (
<div className={styles.interfaceSettings}>
<div role='radiogroup' aria-label={t('media_hosting')}>
{MEDIA_HOSTING_PROVIDERS.map((provider) => (
<div key={provider.id} className={styles.setting}>
<label>
<input type='radio' name={RADIO_NAME} value={provider.id} checked={selectedProvider === provider.id} onChange={() => setSelectedProvider(provider.id)} />
{provider.name} (
<a href={provider.url} target='_blank' rel='noopener noreferrer'>
{provider.url}
</a>
)
</label>
</div>
))}
<div className={styles.setting}>
<label>
<input type='radio' name={RADIO_NAME} value='none' checked={selectedProvider === 'none'} onChange={() => setSelectedProvider('none')} />
{t('media_hosting_none')}
</label>
<div className={styles.settingTip}>{t('media_hosting_none_tip')}</div>
</div>
</div>
</div>
);
};
export default MediaHostingSettings;
@@ -8,6 +8,7 @@ import BlockedAddressesSetting from './blocked-addresses-setting';
import CryptoAddressSetting from './crypto-address-setting';
import CryptoWalletsSetting from './crypto-wallets-setting';
import InterfaceSettings from './interface-settings';
import MediaHostingSettings from './media-hosting-settings';
import P2pOptions from './p2p-options';
import SubscriptionsSetting from './subscriptions-setting';
@@ -36,6 +37,7 @@ const SettingsModal = () => {
}, [closeModal]);
const [showInterfaceSettings, setShowInterfaceSettings] = useState(false);
const [showMediaHostingSettings, setShowMediaHostingSettings] = useState(false);
const [showAccountSettings, setShowAccountSettings] = useState(false);
const [showAvatarSettings, setShowAvatarSettings] = useState(false);
const [showCryptoAddressSetting, setShowCryptoAddressSetting] = useState(false);
@@ -48,6 +50,7 @@ const SettingsModal = () => {
const getExpandedCount = () => {
return (
Number(showInterfaceSettings) +
Number(showMediaHostingSettings) +
Number(showAccountSettings) +
Number(showAvatarSettings) +
Number(showCryptoAddressSetting) +
@@ -60,6 +63,7 @@ const SettingsModal = () => {
const getExpandedCategoryId = (excludeCategoryId?: string) => {
if (showInterfaceSettings && 'interface-settings' !== excludeCategoryId) return 'interface-settings';
if (showMediaHostingSettings && 'media-hosting-settings' !== excludeCategoryId) return 'media-hosting-settings';
if (showAccountSettings && 'account-settings' !== excludeCategoryId) return 'account-settings';
if (showAvatarSettings && 'avatar-settings' !== excludeCategoryId) return 'avatar-settings';
if (showCryptoAddressSetting && 'crypto-address-settings' !== excludeCategoryId) return 'crypto-address-settings';
@@ -100,6 +104,7 @@ const SettingsModal = () => {
useEffect(() => {
if (hash) {
setShowInterfaceSettings(hash === 'interface-settings');
setShowMediaHostingSettings(hash === 'media-hosting-settings');
setShowAccountSettings(hash === 'account-settings');
setShowAvatarSettings(hash === 'avatar-settings');
setShowCryptoAddressSetting(hash === 'crypto-address-settings');
@@ -114,6 +119,7 @@ const SettingsModal = () => {
const newExpandState = !expandAll;
setExpandAll(newExpandState);
setShowInterfaceSettings(newExpandState);
setShowMediaHostingSettings(newExpandState);
setShowAccountSettings(newExpandState);
setShowAvatarSettings(newExpandState);
setShowCryptoAddressSetting(newExpandState);
@@ -144,6 +150,13 @@ const SettingsModal = () => {
</label>
</div>
{showInterfaceSettings && <InterfaceSettings />}
<div id='media-hosting-settings' className={`${styles.setting} ${styles.category}`}>
<label onClick={() => handleCategoryClick('media-hosting-settings', showMediaHostingSettings, setShowMediaHostingSettings)}>
<span className={showMediaHostingSettings ? styles.hideButton : styles.showButton} />
{t('media_hosting')}
</label>
</div>
{showMediaHostingSettings && <MediaHostingSettings />}
<div id='account-settings' className={`${styles.setting} ${styles.category}`}>
<label onClick={() => handleCategoryClick('account-settings', showAccountSettings, setShowAccountSettings)}>
<span className={showAccountSettings ? styles.hideButton : styles.showButton} />
@@ -27,6 +27,11 @@ vi.mock('../../lib/utils/catbox-utils', () => ({
uploadToCatbox: vi.fn(),
}));
const selectedProviderRef = vi.hoisted(() => ({ value: 'catbox' as string }));
vi.mock('../../stores/use-media-hosting-store', () => ({
default: (selector: (s: { selectedProvider: string }) => unknown) => selector({ selectedProvider: selectedProviderRef.value }),
}));
type HookSnapshot = ReturnType<typeof useFileUpload>;
let latestHook: HookSnapshot | null = null;
@@ -68,6 +73,7 @@ const selectFileFromHiddenInput = async (file: File | null) => {
describe('useFileUpload', () => {
beforeEach(() => {
vi.clearAllMocks();
selectedProviderRef.value = 'catbox';
latestHook = null;
container = document.createElement('div');
document.body.append(container);
@@ -177,4 +183,39 @@ describe('useFileUpload', () => {
expect(onUploadComplete).not.toHaveBeenCalled();
expect(hook().isUploading).toBe(false);
});
it('returns early when selectedProvider is none (no upload, no alert)', async () => {
selectedProviderRef.value = 'none';
vi.mocked(Capacitor.getPlatform).mockReturnValue('android');
vi.mocked(FileUploader.pickAndUploadMedia).mockResolvedValue({
url: 'https://files.catbox.moe/skip.jpg',
fileName: 'skip.jpg',
});
const { onUploadComplete, hook } = mountHook();
await act(async () => {
await hook().handleUpload();
});
expect(FileUploader.pickAndUploadMedia).not.toHaveBeenCalled();
expect(window.alert).not.toHaveBeenCalled();
expect(onUploadComplete).not.toHaveBeenCalled();
expect(hook().isUploading).toBe(false);
});
it('returns early when selectedProvider is unknown (no crash, no upload)', async () => {
selectedProviderRef.value = 'unknown-provider';
vi.mocked(Capacitor.getPlatform).mockReturnValue('ios');
window.electronApi = { isElectron: true } as any;
const { onUploadComplete, hook } = mountHook();
await act(async () => {
await hook().handleUpload();
});
expect(uploadToCatbox).not.toHaveBeenCalled();
expect(window.alert).not.toHaveBeenCalled();
expect(onUploadComplete).not.toHaveBeenCalled();
expect(hook().isUploading).toBe(false);
});
});
+12 -2
View File
@@ -3,9 +3,15 @@ import { Capacitor } from '@capacitor/core';
import { useTranslation } from 'react-i18next';
import FileUploader from '../plugins/file-uploader';
import { uploadToCatbox } from '../lib/utils/catbox-utils';
import useMediaHostingStore from '../stores/use-media-hosting-store';
const FILE_SELECTION_CANCELLED_ERROR = 'File selection cancelled';
async function uploadByProvider(provider: string, file: File): Promise<string> {
if (provider === 'catbox') return uploadToCatbox(file);
throw new Error(`Unsupported media provider: ${provider}`);
}
export interface UseFileUploadOptions {
onUploadComplete: (url: string, fileName: string) => void;
}
@@ -54,10 +60,14 @@ function selectFileViaInput(): Promise<File | null> {
export function useFileUpload(options: UseFileUploadOptions) {
const { t } = useTranslation();
const { onUploadComplete } = options;
const selectedProvider = useMediaHostingStore((s) => s.selectedProvider);
const [isUploading, setIsUploading] = useState(false);
const [uploadedFileName, setUploadedFileName] = useState<string | null>(null);
const handleUpload = useCallback(async () => {
if (selectedProvider === 'none') return;
if (selectedProvider !== 'catbox') return;
try {
setIsUploading(true);
setUploadedFileName(null);
@@ -79,7 +89,7 @@ export function useFileUpload(options: UseFileUploadOptions) {
throw new Error(FILE_SELECTION_CANCELLED_ERROR);
}
const url = await uploadToCatbox(file);
const url = await uploadByProvider(selectedProvider, file);
setUploadedFileName(file.name);
onUploadComplete(url, file.name);
return;
@@ -94,7 +104,7 @@ export function useFileUpload(options: UseFileUploadOptions) {
} finally {
setIsUploading(false);
}
}, [onUploadComplete, t]);
}, [onUploadComplete, t, selectedProvider]);
return {
isUploading,
@@ -0,0 +1,42 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import useMediaHostingStore, { MEDIA_HOSTING_PROVIDERS } from '../use-media-hosting-store';
const STORAGE_KEY = 'media-hosting-storage';
describe('useMediaHostingStore', () => {
let setItemSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
localStorage.removeItem(STORAGE_KEY);
setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
});
afterEach(() => {
setItemSpy.mockRestore();
useMediaHostingStore.getState().setSelectedProvider('catbox');
});
it('exports MEDIA_HOSTING_PROVIDERS with at least Catbox provider', () => {
expect(MEDIA_HOSTING_PROVIDERS).toBeDefined();
expect(MEDIA_HOSTING_PROVIDERS.length).toBeGreaterThanOrEqual(1);
const catbox = MEDIA_HOSTING_PROVIDERS.find((p) => p.id === 'catbox');
expect(catbox).toEqual({ id: 'catbox', name: 'Catbox', url: 'https://catbox.moe' });
});
it('defaults selectedProvider to catbox', () => {
const { selectedProvider } = useMediaHostingStore.getState();
expect(selectedProvider).toBe('catbox');
});
it('sets selectedProvider to none when setSelectedProvider is called with none', () => {
useMediaHostingStore.getState().setSelectedProvider('none');
const { selectedProvider } = useMediaHostingStore.getState();
expect(selectedProvider).toBe('none');
});
it('persists state to localStorage when setSelectedProvider is called', () => {
useMediaHostingStore.getState().setSelectedProvider('none');
expect(setItemSpy).toHaveBeenCalledWith(STORAGE_KEY, expect.stringContaining('"selectedProvider":"none"'));
});
});
+25
View File
@@ -0,0 +1,25 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export const MEDIA_HOSTING_PROVIDERS = [{ id: 'catbox', name: 'Catbox', url: 'https://catbox.moe' }] as const;
export type MediaHostingSelection = (typeof MEDIA_HOSTING_PROVIDERS)[number]['id'] | 'none' | string;
interface MediaHostingStore {
selectedProvider: MediaHostingSelection;
setSelectedProvider: (provider: string) => void;
}
const useMediaHostingStore = create<MediaHostingStore>()(
persist(
(set) => ({
selectedProvider: 'catbox',
setSelectedProvider: (provider) => set({ selectedProvider: provider }),
}),
{
name: 'media-hosting-storage',
},
),
);
export default useMediaHostingStore;