mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "قائمة الانتظار فارغة",
|
||||
"approve": "الموافقة",
|
||||
"reject": "الرفض",
|
||||
"waiting": "في الانتظار",
|
||||
"filter_by_board": "تصفية حسب اللوحة",
|
||||
"mod_queue_alert_threshold": "عتبة تنبيه قائمة انتظار الإشراف",
|
||||
"mod_queue_alert_threshold_tip": "إظهار مؤشر أحمر وامض عندما تكون العناصر في الانتظار لأكثر من هذا الوقت",
|
||||
"no_content": "لا يوجد محتوى",
|
||||
"view": "عرض",
|
||||
"excerpt": "مقتطف",
|
||||
"publishing": "نشر",
|
||||
"approved": "موافق عليه",
|
||||
"rejected": "مرفوض"
|
||||
"rejected": "مرفوض",
|
||||
"submitted": "تم الإرسال",
|
||||
"hours": "ساعات",
|
||||
"minutes": "دقائق",
|
||||
"alert_threshold": "عتبة التنبيه"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "সারি খালি",
|
||||
"approve": "অনুমোদন করুন",
|
||||
"reject": "প্রত্যাখ্যান করুন",
|
||||
"waiting": "অপেক্ষা করছে",
|
||||
"filter_by_board": "বোর্ড অনুযায়ী ফিল্টার করুন",
|
||||
"mod_queue_alert_threshold": "মডারেশন সারি সতর্কতা থ্রেশহোল্ড",
|
||||
"mod_queue_alert_threshold_tip": "আইটেমগুলি এই সময়ের চেয়ে বেশি অপেক্ষা করলে একটি ঝলমলে লাল সূচক দেখান",
|
||||
"no_content": "কোনো বিষয়বস্তু নেই",
|
||||
"view": "দেখুন",
|
||||
"excerpt": "উদ্ধৃতি",
|
||||
"publishing": "প্রকাশনা",
|
||||
"approved": "অনুমোদিত",
|
||||
"rejected": "প্রত্যাখ্যান"
|
||||
"rejected": "প্রত্যাখ্যান",
|
||||
"submitted": "জমা দেওয়া হয়েছে",
|
||||
"hours": "ঘণ্টা",
|
||||
"minutes": "মিনিট",
|
||||
"alert_threshold": "সতর্কতা থ্রেশহোল্ড"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "Fronta je prázdná",
|
||||
"approve": "Schválit",
|
||||
"reject": "Odmítnout",
|
||||
"waiting": "Čeká",
|
||||
"filter_by_board": "Filtrovat podle desky",
|
||||
"mod_queue_alert_threshold": "Práh upozornění fronty moderování",
|
||||
"mod_queue_alert_threshold_tip": "Zobrazit blikající červený indikátor, když položky čekají déle než tento čas",
|
||||
"no_content": "Žádný obsah",
|
||||
"view": "Zobrazit",
|
||||
"excerpt": "Výňatek",
|
||||
"publishing": "Publikování",
|
||||
"approved": "Schváleno",
|
||||
"rejected": "Zamítnuto"
|
||||
"rejected": "Zamítnuto",
|
||||
"submitted": "Odesláno",
|
||||
"hours": "hodin",
|
||||
"minutes": "minut",
|
||||
"alert_threshold": "Práh upozornění"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "Køen er tom",
|
||||
"approve": "Godkend",
|
||||
"reject": "Afvis",
|
||||
"waiting": "Venter",
|
||||
"filter_by_board": "Filtrer efter tavle",
|
||||
"mod_queue_alert_threshold": "Tærskel for moderationskø-alarm",
|
||||
"mod_queue_alert_threshold_tip": "Vis en blinkende rød indikator, når elementer venter længere end denne tid",
|
||||
"no_content": "Intet indhold",
|
||||
"view": "Vis",
|
||||
"excerpt": "Uddrag",
|
||||
"publishing": "Udgivelse",
|
||||
"approved": "Godkendt",
|
||||
"rejected": "Afvist"
|
||||
"rejected": "Afvist",
|
||||
"submitted": "Indsendt",
|
||||
"hours": "timer",
|
||||
"minutes": "minutter",
|
||||
"alert_threshold": "Alarmtærskel"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "Warteschlange ist leer",
|
||||
"approve": "Genehmigen",
|
||||
"reject": "Ablehnen",
|
||||
"waiting": "Wartend",
|
||||
"filter_by_board": "Nach Forum filtern",
|
||||
"mod_queue_alert_threshold": "Schwellenwert für Moderations-Warteschlangen-Alarm",
|
||||
"mod_queue_alert_threshold_tip": "Einen blinkenden roten Indikator anzeigen, wenn Elemente länger als diese Zeit warten",
|
||||
"no_content": "Kein Inhalt",
|
||||
"view": "Anzeigen",
|
||||
"excerpt": "Auszug",
|
||||
"publishing": "Veröffentlichung",
|
||||
"approved": "Genehmigt",
|
||||
"rejected": "Abgelehnt"
|
||||
"rejected": "Abgelehnt",
|
||||
"submitted": "Eingereicht",
|
||||
"hours": "Stunden",
|
||||
"minutes": "Minuten",
|
||||
"alert_threshold": "Alarmschwellenwert"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "Η ουρά είναι άδεια",
|
||||
"approve": "Έγκριση",
|
||||
"reject": "Απόρριψη",
|
||||
"waiting": "Αναμονή",
|
||||
"filter_by_board": "Φίλτρο ανά σανίδα",
|
||||
"mod_queue_alert_threshold": "Όριο ειδοποίησης ουράς συντονισμού",
|
||||
"mod_queue_alert_threshold_tip": "Εμφάνιση αναβοσβήνοντος κόκκινου δείκτη όταν τα στοιχεία περιμένουν περισσότερο από αυτόν τον χρόνο",
|
||||
"no_content": "Χωρίς περιεχόμενο",
|
||||
"view": "Προβολή",
|
||||
"excerpt": "Απόσπασμα",
|
||||
"publishing": "Δημοσίευση",
|
||||
"approved": "Εγκεκριμένο",
|
||||
"rejected": "Απορρίφθηκε"
|
||||
"rejected": "Απορρίφθηκε",
|
||||
"submitted": "Υποβλήθηκε",
|
||||
"hours": "ώρες",
|
||||
"minutes": "λεπτά",
|
||||
"alert_threshold": "Όριο ειδοποίησης"
|
||||
}
|
||||
|
||||
@@ -259,14 +259,15 @@
|
||||
"queue_is_empty": "Queue is empty",
|
||||
"approve": "Approve",
|
||||
"reject": "Reject",
|
||||
"waiting": "Waiting",
|
||||
"filter_by_board": "Filter by board",
|
||||
"mod_queue_alert_threshold": "Mod queue alert threshold",
|
||||
"mod_queue_alert_threshold_tip": "Show a blinking red indicator when items are waiting for longer than this time",
|
||||
"alert_threshold": "Alert threshold",
|
||||
"hours": "hours",
|
||||
"minutes": "minutes",
|
||||
"no_content": "No content",
|
||||
"view": "View",
|
||||
"excerpt": "Excerpt",
|
||||
"publishing": "Publishing",
|
||||
"approved": "Approved",
|
||||
"rejected": "Rejected"
|
||||
"rejected": "Rejected",
|
||||
"submitted": "Submitted",
|
||||
"downloading_board": "Downloading board"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "La cola está vacía",
|
||||
"approve": "Aprobar",
|
||||
"reject": "Rechazar",
|
||||
"waiting": "Esperando",
|
||||
"filter_by_board": "Filtrar por foro",
|
||||
"mod_queue_alert_threshold": "Umbral de alerta de cola de moderación",
|
||||
"mod_queue_alert_threshold_tip": "Mostrar un indicador rojo parpadeante cuando los elementos están esperando más de este tiempo",
|
||||
"no_content": "Sin contenido",
|
||||
"view": "Ver",
|
||||
"excerpt": "Extracto",
|
||||
"publishing": "Publicando",
|
||||
"approved": "Aprobado",
|
||||
"rejected": "Rechazado"
|
||||
"rejected": "Rechazado",
|
||||
"submitted": "Enviado",
|
||||
"hours": "horas",
|
||||
"minutes": "minutos",
|
||||
"alert_threshold": "Umbral de alerta"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "صف خالی است",
|
||||
"approve": "تایید",
|
||||
"reject": "رد",
|
||||
"waiting": "در انتظار",
|
||||
"filter_by_board": "فیلتر بر اساس تابلو",
|
||||
"mod_queue_alert_threshold": "آستانه هشدار صف انتظار نظارت",
|
||||
"mod_queue_alert_threshold_tip": "نمایش نشانگر قرمز چشمکزن زمانی که موارد بیش از این زمان در انتظار هستند",
|
||||
"no_content": "بدون محتوا",
|
||||
"view": "مشاهده",
|
||||
"excerpt": "گزیده",
|
||||
"publishing": "در حال انتشار",
|
||||
"approved": "تایید شده",
|
||||
"rejected": "رد شده"
|
||||
"rejected": "رد شده",
|
||||
"submitted": "ارسال شده",
|
||||
"hours": "ساعت",
|
||||
"minutes": "دقیقه",
|
||||
"alert_threshold": "آستانه هشدار"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "Jono on tyhjä",
|
||||
"approve": "Hyväksy",
|
||||
"reject": "Hylkää",
|
||||
"waiting": "Odottaa",
|
||||
"filter_by_board": "Suodata taulun mukaan",
|
||||
"mod_queue_alert_threshold": "Moderaatiojonon hälytyskynnys",
|
||||
"mod_queue_alert_threshold_tip": "Näytä vilkkuva punainen indikaattori, kun kohteet odottavat pidempään kuin tämä aika",
|
||||
"no_content": "Ei sisältöä",
|
||||
"view": "Näytä",
|
||||
"excerpt": "Ote",
|
||||
"publishing": "Julkaiseminen",
|
||||
"approved": "Hyväksytty",
|
||||
"rejected": "Hylätty"
|
||||
"rejected": "Hylätty",
|
||||
"submitted": "Lähetetty",
|
||||
"hours": "tuntia",
|
||||
"minutes": "minuuttia",
|
||||
"alert_threshold": "Hälytyskynnys"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "Walang laman ang queue",
|
||||
"approve": "Aprubahan",
|
||||
"reject": "Tanggihan",
|
||||
"waiting": "Naghihintay",
|
||||
"filter_by_board": "I-filter ayon sa board",
|
||||
"mod_queue_alert_threshold": "Moderasyon queue alert threshold",
|
||||
"mod_queue_alert_threshold_tip": "Magpakita ng kumikislap na pulang indicator kapag ang mga item ay naghihintay nang mas mahaba kaysa sa oras na ito",
|
||||
"no_content": "Walang nilalaman",
|
||||
"view": "Tingnan",
|
||||
"excerpt": "Sipi",
|
||||
"publishing": "Pag-publish",
|
||||
"approved": "Naaprubado",
|
||||
"rejected": "Tinanggihan"
|
||||
"rejected": "Tinanggihan",
|
||||
"submitted": "Isinumite",
|
||||
"hours": "oras",
|
||||
"minutes": "minuto",
|
||||
"alert_threshold": "Alert threshold"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "La file est vide",
|
||||
"approve": "Approuver",
|
||||
"reject": "Rejeter",
|
||||
"waiting": "En attente",
|
||||
"filter_by_board": "Filtrer par forum",
|
||||
"mod_queue_alert_threshold": "Seuil d'alerte de la file de modération",
|
||||
"mod_queue_alert_threshold_tip": "Afficher un indicateur rouge clignotant lorsque les éléments attendent plus longtemps que ce temps",
|
||||
"no_content": "Aucun contenu",
|
||||
"view": "Voir",
|
||||
"excerpt": "Extrait",
|
||||
"publishing": "Publication",
|
||||
"approved": "Approuvé",
|
||||
"rejected": "Rejeté"
|
||||
"rejected": "Rejeté",
|
||||
"submitted": "Soumis",
|
||||
"hours": "heures",
|
||||
"minutes": "minutes",
|
||||
"alert_threshold": "Seuil d'alerte"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "התור ריק",
|
||||
"approve": "אשר",
|
||||
"reject": "דחה",
|
||||
"waiting": "ממתין",
|
||||
"filter_by_board": "סנן לפי לוח",
|
||||
"mod_queue_alert_threshold": "סף התרעה של תור ניהול",
|
||||
"mod_queue_alert_threshold_tip": "הצג אינדיקטור אדום מהבהב כאשר פריטים ממתינים יותר מזמן זה",
|
||||
"no_content": "אין תוכן",
|
||||
"view": "הצג",
|
||||
"excerpt": "תקציר",
|
||||
"publishing": "מפרסם",
|
||||
"approved": "אושר",
|
||||
"rejected": "נדחה"
|
||||
"rejected": "נדחה",
|
||||
"submitted": "נשלח",
|
||||
"hours": "שעות",
|
||||
"minutes": "דקות",
|
||||
"alert_threshold": "סף התרעה"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "कतार खाली है",
|
||||
"approve": "अनुमोदन करें",
|
||||
"reject": "अस्वीकार करें",
|
||||
"waiting": "प्रतीक्षा कर रहा है",
|
||||
"filter_by_board": "बोर्ड के अनुसार फ़िल्टर करें",
|
||||
"mod_queue_alert_threshold": "मॉडरेटर कतार सतर्कता सीमा",
|
||||
"mod_queue_alert_threshold_tip": "जब आइटम इस समय से अधिक समय तक प्रतीक्षा कर रहे हों तो एक चमकता हुआ लाल संकेतक दिखाएं",
|
||||
"no_content": "कोई सामग्री नहीं",
|
||||
"view": "देखें",
|
||||
"excerpt": "अंश",
|
||||
"publishing": "प्रकाशन",
|
||||
"approved": "अनुमोदित",
|
||||
"rejected": "अस्वीकृत"
|
||||
"rejected": "अस्वीकृत",
|
||||
"submitted": "सबमिट किया गया",
|
||||
"hours": "घंटे",
|
||||
"minutes": "मिनट",
|
||||
"alert_threshold": "सतर्कता सीमा"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "A sor üres",
|
||||
"approve": "Jóváhagyás",
|
||||
"reject": "Elutasítás",
|
||||
"waiting": "Várakozás",
|
||||
"filter_by_board": "Szűrés tábla szerint",
|
||||
"mod_queue_alert_threshold": "Moderációs sor riasztási küszöb",
|
||||
"mod_queue_alert_threshold_tip": "Villogó piros jelző megjelenítése, amikor az elemek ennél hosszabb ideig várakoznak",
|
||||
"no_content": "Nincs tartalom",
|
||||
"view": "Megtekintés",
|
||||
"excerpt": "Kivonat",
|
||||
"publishing": "Közzététel",
|
||||
"approved": "Jóváhagyva",
|
||||
"rejected": "Elutasítva"
|
||||
"rejected": "Elutasítva",
|
||||
"submitted": "Beküldve",
|
||||
"hours": "óra",
|
||||
"minutes": "perc",
|
||||
"alert_threshold": "Riasztási küszöb"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "Antrian kosong",
|
||||
"approve": "Setujui",
|
||||
"reject": "Tolak",
|
||||
"waiting": "Menunggu",
|
||||
"filter_by_board": "Filter berdasarkan papan",
|
||||
"mod_queue_alert_threshold": "Ambang peringatan antrian moderasi",
|
||||
"mod_queue_alert_threshold_tip": "Tampilkan indikator merah berkedip ketika item menunggu lebih lama dari waktu ini",
|
||||
"no_content": "Tidak ada konten",
|
||||
"view": "Lihat",
|
||||
"excerpt": "Kutipan",
|
||||
"publishing": "Menerbitkan",
|
||||
"approved": "Disetujui",
|
||||
"rejected": "Ditolak"
|
||||
"rejected": "Ditolak",
|
||||
"submitted": "Dikirim",
|
||||
"hours": "jam",
|
||||
"minutes": "menit",
|
||||
"alert_threshold": "Ambang peringatan"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "La coda è vuota",
|
||||
"approve": "Approva",
|
||||
"reject": "Rifiuta",
|
||||
"waiting": "In attesa",
|
||||
"filter_by_board": "Filtra per board",
|
||||
"mod_queue_alert_threshold": "Soglia di allerta coda moderazione",
|
||||
"mod_queue_alert_threshold_tip": "Mostra un indicatore rosso lampeggiante quando gli elementi attendono più di questo tempo",
|
||||
"no_content": "Nessun contenuto",
|
||||
"view": "Visualizza",
|
||||
"excerpt": "Estratto",
|
||||
"publishing": "Pubblicazione",
|
||||
"approved": "Approvato",
|
||||
"rejected": "Rifiutato"
|
||||
"rejected": "Rifiutato",
|
||||
"submitted": "Inviato",
|
||||
"hours": "ore",
|
||||
"minutes": "minuti",
|
||||
"alert_threshold": "Soglia di allerta"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "キューは空です",
|
||||
"approve": "承認",
|
||||
"reject": "拒否",
|
||||
"waiting": "待機中",
|
||||
"filter_by_board": "ボードでフィルター",
|
||||
"mod_queue_alert_threshold": "モデレーションキューのアラート閾値",
|
||||
"mod_queue_alert_threshold_tip": "アイテムがこの時間より長く待機している場合、点滅する赤いインジケーターを表示",
|
||||
"no_content": "コンテンツなし",
|
||||
"view": "表示",
|
||||
"excerpt": "抜粋",
|
||||
"publishing": "公開中",
|
||||
"approved": "承認済み",
|
||||
"rejected": "却下"
|
||||
"rejected": "却下",
|
||||
"submitted": "送信済み",
|
||||
"hours": "時間",
|
||||
"minutes": "分",
|
||||
"alert_threshold": "アラート閾値"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "대기열이 비어 있습니다",
|
||||
"approve": "승인",
|
||||
"reject": "거부",
|
||||
"waiting": "대기 중",
|
||||
"filter_by_board": "게시판별 필터",
|
||||
"mod_queue_alert_threshold": "관리자 대기열 알림 임계값",
|
||||
"mod_queue_alert_threshold_tip": "항목이 이 시간보다 오래 대기 중일 때 깜빡이는 빨간색 표시기 표시",
|
||||
"no_content": "콘텐츠 없음",
|
||||
"view": "보기",
|
||||
"excerpt": "요약",
|
||||
"publishing": "게시 중",
|
||||
"approved": "승인됨",
|
||||
"rejected": "거부됨"
|
||||
"rejected": "거부됨",
|
||||
"submitted": "제출됨",
|
||||
"hours": "시간",
|
||||
"minutes": "분",
|
||||
"alert_threshold": "알림 임계값"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "रांग रिकामी आहे",
|
||||
"approve": "मंजूर करा",
|
||||
"reject": "नकार द्या",
|
||||
"waiting": "प्रतीक्षा करत आहे",
|
||||
"filter_by_board": "बोर्डनुसार फिल्टर करा",
|
||||
"mod_queue_alert_threshold": "मॉडरेशन रांग सतर्कता मर्यादा",
|
||||
"mod_queue_alert_threshold_tip": "जेव्हा आयटम या वेळेपेक्षा जास्त वेळ प्रतीक्षा करत असतात तेव्हा एक चमकणारा लाल सूचक दाखवा",
|
||||
"no_content": "सामग्री नाही",
|
||||
"view": "पहा",
|
||||
"excerpt": "उतारा",
|
||||
"publishing": "प्रकाशन करत आहे",
|
||||
"approved": "मंजूर",
|
||||
"rejected": "नाकारले"
|
||||
"rejected": "नाकारले",
|
||||
"submitted": "सबमिट केले",
|
||||
"hours": "तास",
|
||||
"minutes": "मिनिटे",
|
||||
"alert_threshold": "सतर्कता मर्यादा"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "Wachtrij is leeg",
|
||||
"approve": "Goedkeuren",
|
||||
"reject": "Afwijzen",
|
||||
"waiting": "Wachten",
|
||||
"filter_by_board": "Filteren op bord",
|
||||
"mod_queue_alert_threshold": "Drempelwaarde voor moderatiewachtrijwaarschuwing",
|
||||
"mod_queue_alert_threshold_tip": "Toon een knipperende rode indicator wanneer items langer dan deze tijd wachten",
|
||||
"no_content": "Geen inhoud",
|
||||
"view": "Bekijken",
|
||||
"excerpt": "Uittreksel",
|
||||
"publishing": "Publiceren",
|
||||
"approved": "Goedgekeurd",
|
||||
"rejected": "Afgewezen"
|
||||
"rejected": "Afgewezen",
|
||||
"submitted": "Ingediend",
|
||||
"hours": "uur",
|
||||
"minutes": "minuten",
|
||||
"alert_threshold": "Drempelwaarde voor waarschuwing"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "Køen er tom",
|
||||
"approve": "Godkjenn",
|
||||
"reject": "Avvis",
|
||||
"waiting": "Venter",
|
||||
"filter_by_board": "Filtrer etter tavle",
|
||||
"mod_queue_alert_threshold": "Terskel for modereringskø-varsel",
|
||||
"mod_queue_alert_threshold_tip": "Vis en blinkende rød indikator når elementer venter lenger enn denne tiden",
|
||||
"no_content": "Ingen innhold",
|
||||
"view": "Vis",
|
||||
"excerpt": "Utdrag",
|
||||
"publishing": "Publisering",
|
||||
"approved": "Godkjent",
|
||||
"rejected": "Avslått"
|
||||
"rejected": "Avslått",
|
||||
"submitted": "Sendt inn",
|
||||
"hours": "timer",
|
||||
"minutes": "minutter",
|
||||
"alert_threshold": "Terskel for varsel"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "Kolejka jest pusta",
|
||||
"approve": "Zatwierdź",
|
||||
"reject": "Odrzuć",
|
||||
"waiting": "Oczekiwanie",
|
||||
"filter_by_board": "Filtruj według tablicy",
|
||||
"mod_queue_alert_threshold": "Próg alertu kolejki moderacji",
|
||||
"mod_queue_alert_threshold_tip": "Pokaż migający czerwony wskaźnik, gdy elementy czekają dłużej niż ten czas",
|
||||
"no_content": "Brak zawartości",
|
||||
"view": "Zobacz",
|
||||
"excerpt": "Fragment",
|
||||
"publishing": "Publikowanie",
|
||||
"approved": "Zatwierdzone",
|
||||
"rejected": "Odrzucone"
|
||||
"rejected": "Odrzucone",
|
||||
"submitted": "Przesłane",
|
||||
"hours": "godzin",
|
||||
"minutes": "minut",
|
||||
"alert_threshold": "Próg alertu"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "A fila está vazia",
|
||||
"approve": "Aprovar",
|
||||
"reject": "Rejeitar",
|
||||
"waiting": "Aguardando",
|
||||
"filter_by_board": "Filtrar por quadro",
|
||||
"mod_queue_alert_threshold": "Limite de alerta da fila de moderação",
|
||||
"mod_queue_alert_threshold_tip": "Mostrar um indicador vermelho piscante quando os itens estão aguardando por mais tempo que este tempo",
|
||||
"no_content": "Sem conteúdo",
|
||||
"view": "Ver",
|
||||
"excerpt": "Trecho",
|
||||
"publishing": "Publicando",
|
||||
"approved": "Aprovado",
|
||||
"rejected": "Rejeitado"
|
||||
"rejected": "Rejeitado",
|
||||
"submitted": "Enviado",
|
||||
"hours": "horas",
|
||||
"minutes": "minutos",
|
||||
"alert_threshold": "Limite de alerta"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "Coada este goală",
|
||||
"approve": "Aprobă",
|
||||
"reject": "Respinge",
|
||||
"waiting": "Așteaptă",
|
||||
"filter_by_board": "Filtrează după panou",
|
||||
"mod_queue_alert_threshold": "Prag de alertă pentru coada de moderare",
|
||||
"mod_queue_alert_threshold_tip": "Afișează un indicator roșu intermitent când elementele așteaptă mai mult decât acest timp",
|
||||
"no_content": "Fără conținut",
|
||||
"view": "Vizualizează",
|
||||
"excerpt": "Fragment",
|
||||
"publishing": "Publicare",
|
||||
"approved": "Aprobat",
|
||||
"rejected": "Respins"
|
||||
"rejected": "Respins",
|
||||
"submitted": "Trimis",
|
||||
"hours": "ore",
|
||||
"minutes": "minute",
|
||||
"alert_threshold": "Prag de alertă"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "Очередь пуста",
|
||||
"approve": "Одобрить",
|
||||
"reject": "Отклонить",
|
||||
"waiting": "Ожидание",
|
||||
"filter_by_board": "Фильтр по доске",
|
||||
"mod_queue_alert_threshold": "Порог предупреждения очереди модерации",
|
||||
"mod_queue_alert_threshold_tip": "Показывать мигающий красный индикатор, когда элементы ждут дольше этого времени",
|
||||
"no_content": "Нет содержимого",
|
||||
"view": "Просмотр",
|
||||
"excerpt": "Выдержка",
|
||||
"publishing": "Публикация",
|
||||
"approved": "Одобрено",
|
||||
"rejected": "Отклонено"
|
||||
"rejected": "Отклонено",
|
||||
"submitted": "Отправлено",
|
||||
"hours": "часов",
|
||||
"minutes": "минут",
|
||||
"alert_threshold": "Порог предупреждения"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "Radha është e zbrazët",
|
||||
"approve": "Aprovo",
|
||||
"reject": "Refuzo",
|
||||
"waiting": "Duke pritur",
|
||||
"filter_by_board": "Filtro sipas tabelës",
|
||||
"mod_queue_alert_threshold": "Pragu i alarmit të radhës së moderimit",
|
||||
"mod_queue_alert_threshold_tip": "Shfaq një tregues të kuq që blinkon kur elementët po presin më shumë se kjo kohë",
|
||||
"no_content": "Pa përmbajtje",
|
||||
"view": "Shiko",
|
||||
"excerpt": "Fragment",
|
||||
"publishing": "Publikimi",
|
||||
"approved": "Aprovuar",
|
||||
"rejected": "Refuzuar"
|
||||
"rejected": "Refuzuar",
|
||||
"submitted": "Dërguar",
|
||||
"hours": "orë",
|
||||
"minutes": "minuta",
|
||||
"alert_threshold": "Pragu i alarmit"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "Kön är tom",
|
||||
"approve": "Godkänn",
|
||||
"reject": "Avvisa",
|
||||
"waiting": "Väntar",
|
||||
"filter_by_board": "Filtrera efter tavla",
|
||||
"mod_queue_alert_threshold": "Tröskelvärde för modereringskö-varning",
|
||||
"mod_queue_alert_threshold_tip": "Visa en blinkande röd indikator när objekt väntar längre än denna tid",
|
||||
"no_content": "Inget innehåll",
|
||||
"view": "Visa",
|
||||
"excerpt": "Utdrag",
|
||||
"publishing": "Publicerar",
|
||||
"approved": "Godkänd",
|
||||
"rejected": "Avvisad"
|
||||
"rejected": "Avvisad",
|
||||
"submitted": "Inskickat",
|
||||
"hours": "timmar",
|
||||
"minutes": "minuter",
|
||||
"alert_threshold": "Tröskelvärde för varning"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "క్యూ ఖాళీగా ఉంది",
|
||||
"approve": "ఆమోదించు",
|
||||
"reject": "తిరస్కరించు",
|
||||
"waiting": "వేచి ఉంది",
|
||||
"filter_by_board": "బోర్డ్ ద్వారా ఫిల్టర్ చేయి",
|
||||
"mod_queue_alert_threshold": "మోడరేషన్ క్యూ హెచ్చరిక థ్రెషోల్డ్",
|
||||
"mod_queue_alert_threshold_tip": "అంశాలు ఈ సమయం కంటే ఎక్కువ సమయం వేచి ఉన్నప్పుడు మెరిసే ఎరుపు సూచికను చూపించు",
|
||||
"no_content": "కంటెంట్ లేదు",
|
||||
"view": "చూడు",
|
||||
"excerpt": "ఎక్సెర్ప్ట్",
|
||||
"publishing": "ప్రచురించడం",
|
||||
"approved": "అనుమోదించబడింది",
|
||||
"rejected": "తిరస్కరించబడింది"
|
||||
"rejected": "తిరస్కరించబడింది",
|
||||
"submitted": "సమర్పించబడింది",
|
||||
"hours": "గంటలు",
|
||||
"minutes": "నిమిషాలు",
|
||||
"alert_threshold": "హెచ్చరిక థ్రెషోల్డ్"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "คิวว่างเปล่า",
|
||||
"approve": "อนุมัติ",
|
||||
"reject": "ปฏิเสธ",
|
||||
"waiting": "กำลังรอ",
|
||||
"filter_by_board": "กรองตามบอร์ด",
|
||||
"mod_queue_alert_threshold": "เกณฑ์การแจ้งเตือนคิวผู้ดูแล",
|
||||
"mod_queue_alert_threshold_tip": "แสดงตัวบ่งชี้สีแดงกะพริบเมื่อรายการรอเกินเวลานี้",
|
||||
"no_content": "ไม่มีเนื้อหา",
|
||||
"view": "ดู",
|
||||
"excerpt": "ข้อความย่อ",
|
||||
"publishing": "กำลังเผยแพร่",
|
||||
"approved": "อนุมัติแล้ว",
|
||||
"rejected": "ปฏิเสธ"
|
||||
"rejected": "ปฏิเสธ",
|
||||
"submitted": "ส่งแล้ว",
|
||||
"hours": "ชั่วโมง",
|
||||
"minutes": "นาที",
|
||||
"alert_threshold": "เกณฑ์การแจ้งเตือน"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "Kuyruk boş",
|
||||
"approve": "Onayla",
|
||||
"reject": "Reddet",
|
||||
"waiting": "Bekliyor",
|
||||
"filter_by_board": "Panoya göre filtrele",
|
||||
"mod_queue_alert_threshold": "Moderasyon kuyruğu uyarı eşiği",
|
||||
"mod_queue_alert_threshold_tip": "Öğeler bu süreden daha uzun süre beklediğinde yanıp sönen kırmızı bir gösterge göster",
|
||||
"no_content": "İçerik yok",
|
||||
"view": "Görüntüle",
|
||||
"excerpt": "Alıntı",
|
||||
"publishing": "Yayınlama",
|
||||
"approved": "Onaylandı",
|
||||
"rejected": "Reddedildi"
|
||||
"rejected": "Reddedildi",
|
||||
"submitted": "Gönderildi",
|
||||
"hours": "saat",
|
||||
"minutes": "dakika",
|
||||
"alert_threshold": "Uyarı eşiği"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "Черга порожня",
|
||||
"approve": "Схвалити",
|
||||
"reject": "Відхилити",
|
||||
"waiting": "Очікування",
|
||||
"filter_by_board": "Фільтр за дошкою",
|
||||
"mod_queue_alert_threshold": "Поріг попередження черги модерації",
|
||||
"mod_queue_alert_threshold_tip": "Показувати мигаючий червоний індикатор, коли елементи чекають довше цього часу",
|
||||
"no_content": "Немає вмісту",
|
||||
"view": "Перегляд",
|
||||
"excerpt": "Витяг",
|
||||
"publishing": "Публікація",
|
||||
"approved": "Схвалено",
|
||||
"rejected": "Відхилено"
|
||||
"rejected": "Відхилено",
|
||||
"submitted": "Відправлено",
|
||||
"hours": "годин",
|
||||
"minutes": "хвилин",
|
||||
"alert_threshold": "Поріг попередження"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "قطار خالی ہے",
|
||||
"approve": "منظور کریں",
|
||||
"reject": "مسترد کریں",
|
||||
"waiting": "انتظار کر رہا ہے",
|
||||
"filter_by_board": "بورڈ کے لحاظ سے فلٹر کریں",
|
||||
"mod_queue_alert_threshold": "نگرانی کی قطار کی انتباہ کی حد",
|
||||
"mod_queue_alert_threshold_tip": "جب آئٹمز اس وقت سے زیادہ انتظار کر رہے ہوں تو ایک ٹمٹمانے والا سرخ اشارہ دکھائیں",
|
||||
"no_content": "کوئی مواد نہیں",
|
||||
"view": "دیکھیں",
|
||||
"excerpt": "اقتباس",
|
||||
"publishing": "اشاعت",
|
||||
"approved": "منظور شدہ",
|
||||
"rejected": "مسترد"
|
||||
"rejected": "مسترد",
|
||||
"submitted": "جمع کرایا گیا",
|
||||
"hours": "گھنٹے",
|
||||
"minutes": "منٹ",
|
||||
"alert_threshold": "انتباہ کی حد"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "Hàng đợi trống",
|
||||
"approve": "Phê duyệt",
|
||||
"reject": "Từ chối",
|
||||
"waiting": "Đang chờ",
|
||||
"filter_by_board": "Lọc theo bảng",
|
||||
"mod_queue_alert_threshold": "Ngưỡng cảnh báo hàng đợi kiểm duyệt",
|
||||
"mod_queue_alert_threshold_tip": "Hiển thị chỉ báo màu đỏ nhấp nháy khi các mục đang chờ lâu hơn thời gian này",
|
||||
"no_content": "Không có nội dung",
|
||||
"view": "Xem",
|
||||
"excerpt": "Trích đoạn",
|
||||
"publishing": "Đang xuất bản",
|
||||
"approved": "Đã phê duyệt",
|
||||
"rejected": "Đã từ chối"
|
||||
"rejected": "Đã từ chối",
|
||||
"submitted": "Đã gửi",
|
||||
"hours": "giờ",
|
||||
"minutes": "phút",
|
||||
"alert_threshold": "Ngưỡng cảnh báo"
|
||||
}
|
||||
|
||||
@@ -260,14 +260,14 @@
|
||||
"queue_is_empty": "队列为空",
|
||||
"approve": "批准",
|
||||
"reject": "拒绝",
|
||||
"waiting": "等待中",
|
||||
"filter_by_board": "按板块筛选",
|
||||
"mod_queue_alert_threshold": "审核队列警报阈值",
|
||||
"mod_queue_alert_threshold_tip": "当项目等待时间超过此时长时显示闪烁的红色指示器",
|
||||
"no_content": "无内容",
|
||||
"view": "查看",
|
||||
"excerpt": "摘要",
|
||||
"publishing": "发布中",
|
||||
"approved": "已批准",
|
||||
"rejected": "已拒绝"
|
||||
"rejected": "已拒绝",
|
||||
"submitted": "已提交",
|
||||
"hours": "小时",
|
||||
"minutes": "分钟",
|
||||
"alert_threshold": "警报阈值"
|
||||
}
|
||||
|
||||
@@ -363,7 +363,7 @@ export const MobileBoardButtons = () => {
|
||||
)}
|
||||
{showVoteButton && <VoteButton />}
|
||||
{!(isInAllView || isInSubscriptionsView || isInModView) && <SubscribeButton address={subplebbitAddress} />}
|
||||
{!(isInAllView || isInSubscriptionsView || isInModView) && <ModQueueButton boardIdentifier={boardIdentifier} isMobile={true} />}
|
||||
{!(isInAllView || isInSubscriptionsView) && <ModQueueButton boardIdentifier={boardIdentifier} isMobile={true} />}
|
||||
<RefreshButton />
|
||||
{isInCatalogView && searchText ? (
|
||||
<span className={styles.filteredThreadsCount}>
|
||||
@@ -498,7 +498,7 @@ export const DesktopBoardButtons = () => {
|
||||
[<VoteButton />]
|
||||
</>
|
||||
)}
|
||||
{!(isInAllView || isInSubscriptionsView || isInModView) && (
|
||||
{!(isInAllView || isInSubscriptionsView) && (
|
||||
<>
|
||||
{' '}
|
||||
<ModQueueButton boardIdentifier={boardIdentifier} isMobile={false} />
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { useAccountSubplebbits } from '@plebbit/plebbit-react-hooks';
|
||||
import { useMemo } from 'react';
|
||||
import useAccountsStore from '@plebbit/plebbit-react-hooks/dist/stores/accounts';
|
||||
import { MultisubSubplebbit } from './use-default-subplebbits';
|
||||
|
||||
export const useAccountSubplebbitsWithMetadata = (): MultisubSubplebbit[] => {
|
||||
const { accountSubplebbits } = useAccountSubplebbits();
|
||||
const subplebbitsWithMetadata = useAccountsStore(
|
||||
(state) => {
|
||||
const activeAccountId = state.activeAccountId;
|
||||
const activeAccount = activeAccountId ? state.accounts[activeAccountId] : undefined;
|
||||
const accountSubplebbits = activeAccount?.subplebbits || {};
|
||||
return Object.entries(accountSubplebbits).map(([address, sub]) => ({
|
||||
address,
|
||||
title: (sub as any).title,
|
||||
}));
|
||||
},
|
||||
(prev, next) => {
|
||||
if (prev.length !== next.length) return false;
|
||||
return prev.every((item, idx) => item.address === next[idx].address && item.title === next[idx].title);
|
||||
},
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
return Object.entries(accountSubplebbits || {}).map(([address, sub]) => ({
|
||||
address,
|
||||
title: (sub as any).title,
|
||||
// We can map other fields if needed
|
||||
}));
|
||||
}, [accountSubplebbits]);
|
||||
return subplebbitsWithMetadata;
|
||||
};
|
||||
|
||||
@@ -1,23 +1,65 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export type AlertThresholdUnit = 'hours' | 'minutes';
|
||||
|
||||
interface ModQueueState {
|
||||
alertThresholdHours: number;
|
||||
alertThresholdValue: number;
|
||||
alertThresholdUnit: AlertThresholdUnit;
|
||||
selectedBoardFilter: string | null;
|
||||
setAlertThresholdHours: (hours: number) => void;
|
||||
setAlertThreshold: (value: number, unit: AlertThresholdUnit) => void;
|
||||
setSelectedBoardFilter: (boardAddress: string | null) => void;
|
||||
// Helper to get threshold in seconds for calculations
|
||||
getAlertThresholdSeconds: () => number;
|
||||
}
|
||||
|
||||
// Type for old persisted state format (before migration)
|
||||
interface OldPersistedState {
|
||||
alertThresholdHours?: number;
|
||||
alertThresholdValue?: number;
|
||||
alertThresholdUnit?: AlertThresholdUnit;
|
||||
selectedBoardFilter?: string | null;
|
||||
}
|
||||
|
||||
// Type for persisted data (without methods)
|
||||
type PersistedModQueueData = Pick<ModQueueState, 'alertThresholdValue' | 'alertThresholdUnit' | 'selectedBoardFilter'>;
|
||||
|
||||
const useModQueueStore = create<ModQueueState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
alertThresholdHours: 6,
|
||||
(set, get) => ({
|
||||
alertThresholdValue: 6,
|
||||
alertThresholdUnit: 'hours' as AlertThresholdUnit,
|
||||
selectedBoardFilter: null,
|
||||
setAlertThresholdHours: (hours) => set({ alertThresholdHours: hours }),
|
||||
setAlertThreshold: (value, unit) => set({ alertThresholdValue: value, alertThresholdUnit: unit }),
|
||||
setSelectedBoardFilter: (boardAddress) => set({ selectedBoardFilter: boardAddress }),
|
||||
getAlertThresholdSeconds: () => {
|
||||
const { alertThresholdValue, alertThresholdUnit } = get();
|
||||
return alertThresholdUnit === 'hours' ? alertThresholdValue * 3600 : alertThresholdValue * 60;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'mod-queue-storage',
|
||||
version: 1,
|
||||
// Migrate old alertThresholdHours format to new alertThresholdValue/alertThresholdUnit format
|
||||
migrate: (persistedState, version): ModQueueState => {
|
||||
const state = persistedState as OldPersistedState;
|
||||
if (version === 0 && state.alertThresholdHours !== undefined) {
|
||||
const migrated: PersistedModQueueData = {
|
||||
alertThresholdValue: state.alertThresholdHours,
|
||||
alertThresholdUnit: 'hours' as AlertThresholdUnit,
|
||||
selectedBoardFilter: state.selectedBoardFilter ?? null,
|
||||
};
|
||||
// Zustand will merge this with the store definition (which includes methods)
|
||||
return migrated as ModQueueState;
|
||||
}
|
||||
// Ensure we return a valid persisted state shape
|
||||
const current: PersistedModQueueData = {
|
||||
alertThresholdValue: state.alertThresholdValue ?? 6,
|
||||
alertThresholdUnit: state.alertThresholdUnit ?? 'hours',
|
||||
selectedBoardFilter: state.selectedBoardFilter ?? null,
|
||||
};
|
||||
return current as ModQueueState;
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useLocation, useNavigationType, useParams } from 'react-router-dom';
|
||||
import { Comment, useAccount, useAccountComments, useAccountSubplebbits, useBlock, useFeed } from '@plebbit/plebbit-react-hooks';
|
||||
import { Comment, useAccount, useAccountComments, useAccountSubplebbits, useBlock, useFeed, useSubplebbit } from '@plebbit/plebbit-react-hooks';
|
||||
import { useSubplebbitField } from '../../hooks/use-stable-subplebbit';
|
||||
import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
@@ -157,9 +157,9 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
|
||||
// Use stable subplebbit fields to avoid rerenders from updatingState
|
||||
const subplebbitTitle = useSubplebbitField(subplebbitAddress, (sub) => sub?.title);
|
||||
const shortAddress = useSubplebbitField(subplebbitAddress, (sub) => sub?.shortAddress);
|
||||
// Subscribe to transient state/error separately from stable fields since useStableSubplebbit ignores them
|
||||
const subplebbitState = useSubplebbitField(subplebbitAddress, (sub) => sub?.state);
|
||||
const subplebbitError = useSubplebbitField(subplebbitAddress, (sub) => sub?.error);
|
||||
// useSubplebbitField only reads from store, doesn't trigger fetching
|
||||
const subplebbit = useSubplebbit({ subplebbitAddress });
|
||||
const { error: subplebbitError, state: subplebbitState } = subplebbit || {};
|
||||
const title = isInAllView ? t('all') : isInSubscriptionsView ? t('subscriptions') : isInModView ? t('mod') : subplebbitTitle;
|
||||
|
||||
const { blocked, unblock } = useBlock({ address: subplebbitAddress });
|
||||
|
||||
@@ -5,6 +5,15 @@
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
@@ -13,9 +22,14 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
.controlsLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.controlsRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.alertThresholdSetting {
|
||||
@@ -33,27 +47,32 @@
|
||||
|
||||
.alertThresholdInput {
|
||||
width: 50px;
|
||||
padding: 4px;
|
||||
border: var(--mod-queue-table-border);
|
||||
background: var(--mod-queue-header-background-color);
|
||||
color: var(--body-font-color);
|
||||
}
|
||||
|
||||
|
||||
|
||||
.tableWrapper {
|
||||
border: var(--mod-queue-table-border);
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
display: flex;
|
||||
padding: 8px;
|
||||
border-bottom: var(--mod-queue-header-border-bottom);
|
||||
background: var(--mod-queue-header-background-color);
|
||||
gap: 1px;
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
font-size: 9pt;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.boardHeader {
|
||||
width: 50px;
|
||||
.numberHeader,
|
||||
.excerptHeader,
|
||||
.timeHeader,
|
||||
.actionsHeader {
|
||||
background: var(--mod-queue-header-background-color);
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.numberHeader {
|
||||
min-width: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -62,9 +81,10 @@
|
||||
}
|
||||
|
||||
.timeHeader {
|
||||
width: 80px;
|
||||
width: 210px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actionsHeader {
|
||||
@@ -79,29 +99,84 @@
|
||||
color: var(--mod-queue-time-color);
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ModQueueRow styles */
|
||||
.row {
|
||||
display: flex;
|
||||
padding: 4px 8px;
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-bottom: var(--mod-queue-row-border-bottom);
|
||||
border: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background-color: var(--mod-queue-row-background-color);
|
||||
gap: 1px;
|
||||
margin-top: 1px;
|
||||
color: var(--body-font-color);
|
||||
font-size: 13px;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background-color: var(--mod-queue-row-hover-background-color);
|
||||
.number,
|
||||
.excerpt,
|
||||
.time,
|
||||
.actions {
|
||||
padding: 2px 5px;
|
||||
}
|
||||
|
||||
.board {
|
||||
width: 50px;
|
||||
/* Odd rows - standalone class needed for CSS Modules export */
|
||||
.rowOdd {}
|
||||
|
||||
/* Odd rows - theme-specific background colors applied to cells */
|
||||
:global(body.yotsuba) .rowOdd .number,
|
||||
:global(body.yotsuba) .rowOdd .excerpt,
|
||||
:global(body.yotsuba) .rowOdd .time,
|
||||
:global(body.yotsuba) .rowOdd .actions {
|
||||
background-color: #ede2d4;
|
||||
}
|
||||
|
||||
:global(body.yotsuba-b) .rowOdd .number,
|
||||
:global(body.yotsuba-b) .rowOdd .excerpt,
|
||||
:global(body.yotsuba-b) .rowOdd .time,
|
||||
:global(body.yotsuba-b) .rowOdd .actions {
|
||||
background-color: #e0e5f6;
|
||||
}
|
||||
|
||||
:global(body.futaba) .rowOdd .number,
|
||||
:global(body.futaba) .rowOdd .excerpt,
|
||||
:global(body.futaba) .rowOdd .time,
|
||||
:global(body.futaba) .rowOdd .actions {
|
||||
background-color: #ede2d4;
|
||||
}
|
||||
|
||||
:global(body.burichan) .rowOdd .number,
|
||||
:global(body.burichan) .rowOdd .excerpt,
|
||||
:global(body.burichan) .rowOdd .time,
|
||||
:global(body.burichan) .rowOdd .actions {
|
||||
background-color: #e0e5f6;
|
||||
}
|
||||
|
||||
:global(body.tomorrow) .rowOdd .number,
|
||||
:global(body.tomorrow) .rowOdd .excerpt,
|
||||
:global(body.tomorrow) .rowOdd .time,
|
||||
:global(body.tomorrow) .rowOdd .actions {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
:global(body.photon) .rowOdd .number,
|
||||
:global(body.photon) .rowOdd .excerpt,
|
||||
:global(body.photon) .rowOdd .time,
|
||||
:global(body.photon) .rowOdd .actions {
|
||||
background-color: #888;
|
||||
}
|
||||
|
||||
.number {
|
||||
min-width: 60px;
|
||||
flex-shrink: 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.excerpt {
|
||||
@@ -123,18 +198,20 @@
|
||||
}
|
||||
|
||||
.time {
|
||||
width: 80px;
|
||||
width: 210px;
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
color: var(--mod-queue-time-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.time.alert {
|
||||
.alert {
|
||||
color: var(--mod-queue-alert-color);
|
||||
font-weight: bold;
|
||||
animation: blink 2s infinite;
|
||||
/* Synchronize all animations by starting from the same phase */
|
||||
animation-delay: calc(var(--mod-queue-blink-phase, 0s) * -1);
|
||||
}
|
||||
|
||||
.alertWrapper {
|
||||
/* Container for the time ago indicator with parentheses, hidden on mobile */
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
@@ -152,6 +229,10 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Desktop: buttons styled as text links (same as board buttons) */
|
||||
@media (min-width: 640px) {
|
||||
.actions {
|
||||
@@ -203,18 +284,11 @@ span.reject:hover {
|
||||
|
||||
/* ModQueueBoardFilter styles */
|
||||
.filterContainer {
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
padding: 4px;
|
||||
border: var(--mod-queue-table-border);
|
||||
background: var(--mod-queue-header-background-color);
|
||||
color: var(--body-font-color);
|
||||
}
|
||||
|
||||
/* ModQueueButton styles */
|
||||
.modQueueButtonCount {
|
||||
@@ -238,62 +312,131 @@ span.reject:hover {
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile Layout */
|
||||
/* Mobile Layout - keeps table structure like desktop, just narrower columns */
|
||||
@media (max-width: 600px) {
|
||||
.container {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
display: none;
|
||||
font-size: 8pt;
|
||||
}
|
||||
|
||||
.numberHeader {
|
||||
min-width: 28px;
|
||||
}
|
||||
|
||||
.timeHeader {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.actionsHeader {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.row {
|
||||
flex-wrap: wrap;
|
||||
padding: 8px;
|
||||
font-size: 8pt;
|
||||
}
|
||||
|
||||
.board {
|
||||
width: auto;
|
||||
margin-right: 8px;
|
||||
|
||||
/* Remove background colors for rows on mobile */
|
||||
:global(body.yotsuba) .rowOdd .number,
|
||||
:global(body.yotsuba) .rowOdd .excerpt,
|
||||
:global(body.yotsuba) .rowOdd .time,
|
||||
:global(body.yotsuba) .rowOdd .actions,
|
||||
:global(body.yotsuba-b) .rowOdd .number,
|
||||
:global(body.yotsuba-b) .rowOdd .excerpt,
|
||||
:global(body.yotsuba-b) .rowOdd .time,
|
||||
:global(body.yotsuba-b) .rowOdd .actions,
|
||||
:global(body.futaba) .rowOdd .number,
|
||||
:global(body.futaba) .rowOdd .excerpt,
|
||||
:global(body.futaba) .rowOdd .time,
|
||||
:global(body.futaba) .rowOdd .actions,
|
||||
:global(body.burichan) .rowOdd .number,
|
||||
:global(body.burichan) .rowOdd .excerpt,
|
||||
:global(body.burichan) .rowOdd .time,
|
||||
:global(body.burichan) .rowOdd .actions,
|
||||
:global(body.tomorrow) .rowOdd .number,
|
||||
:global(body.tomorrow) .rowOdd .excerpt,
|
||||
:global(body.tomorrow) .rowOdd .time,
|
||||
:global(body.tomorrow) .rowOdd .actions,
|
||||
:global(body.photon) .rowOdd .number,
|
||||
:global(body.photon) .rowOdd .excerpt,
|
||||
:global(body.photon) .rowOdd .time,
|
||||
:global(body.photon) .rowOdd .actions {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.time {
|
||||
margin-left: auto;
|
||||
width: auto;
|
||||
|
||||
.number {
|
||||
min-width: 28px;
|
||||
}
|
||||
|
||||
|
||||
.excerpt {
|
||||
width: 100%;
|
||||
margin: 4px 0;
|
||||
white-space: normal;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
|
||||
.time {
|
||||
width: 90px;
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Hide the time ago indicator (including parentheses) on mobile to save space */
|
||||
.alertWrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
width: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
|
||||
.actionButtons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.buttonWrapper {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Keep desktop-style text buttons on mobile */
|
||||
.button {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
border: var(--button-border-mobile);
|
||||
background: var(--button-background-color-mobile);
|
||||
color: var(--button-text-color-mobile);
|
||||
text-decoration: none;
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
text-transform: capitalize;
|
||||
color: var(--button-desktop-text-color);
|
||||
text-decoration: var(--button-text-decoration);
|
||||
display: inline;
|
||||
font-family: inherit;
|
||||
font-size: 8pt;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: var(--button-background-color-mobile);
|
||||
color: var(--button-desktop-text-color-hover);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
+358
-149
@@ -1,21 +1,26 @@
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useAccountSubplebbits, useFeed, Comment, usePublishCommentModeration } from '@plebbit/plebbit-react-hooks';
|
||||
import { useFeed, Comment, usePublishCommentModeration, useEditedComment, useSubplebbit } from '@plebbit/plebbit-react-hooks';
|
||||
import useAccountsStore from '@plebbit/plebbit-react-hooks/dist/stores/accounts';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import styles from './mod-queue.module.css';
|
||||
import useModQueueStore from '../../stores/use-mod-queue-store';
|
||||
import { useAccountSubplebbitsWithMetadata } from '../../hooks/use-account-subplebbits-with-metadata';
|
||||
import LoadingEllipsis from '../../components/loading-ellipsis';
|
||||
import ErrorDisplay from '../../components/error-display/error-display';
|
||||
import { useFeedStateString } from '../../hooks/use-state-string';
|
||||
import { getSubplebbitAddress, getBoardPath } from '../../lib/utils/route-utils';
|
||||
import { useDefaultSubplebbits, MultisubSubplebbit } from '../../hooks/use-default-subplebbits';
|
||||
import { useBoardPath } from '../../hooks/use-resolved-subplebbit-address';
|
||||
import { getHasThumbnail, getCommentMediaInfo } from '../../lib/utils/media-utils';
|
||||
import { getFormattedDate, getFormattedTimeAgo } from '../../lib/utils/time-utils';
|
||||
import useFeedResetStore from '../../stores/use-feed-reset-store';
|
||||
import useChallengesStore from '../../stores/use-challenges-store';
|
||||
import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils';
|
||||
import Tooltip from '../../components/tooltip';
|
||||
import useIsMobile from '../../hooks/use-is-mobile';
|
||||
|
||||
const { addChallenge } = useChallengesStore.getState();
|
||||
|
||||
@@ -25,13 +30,18 @@ interface ModQueueViewProps {
|
||||
|
||||
interface ModQueueFooterProps {
|
||||
hasMore: boolean;
|
||||
loadingStateString: string;
|
||||
subplebbitAddresses: string[];
|
||||
}
|
||||
|
||||
// Defined outside ModQueueView to preserve component identity across renders (Virtuoso optimization)
|
||||
const ModQueueFooter = ({ hasMore, loadingStateString }: ModQueueFooterProps) => {
|
||||
// The useFeedStateString hook is called here instead of in ModQueueView to isolate re-renders
|
||||
// caused by backend IPFS state changes to just this footer component
|
||||
const ModQueueFooter = ({ hasMore, subplebbitAddresses }: ModQueueFooterProps) => {
|
||||
const { t } = useTranslation();
|
||||
const loadingStateString = useFeedStateString(subplebbitAddresses) || t('loading');
|
||||
|
||||
return hasMore ? (
|
||||
<div style={{ padding: '10px', textAlign: 'center' }}>
|
||||
<div className={styles.footer}>
|
||||
<LoadingEllipsis string={loadingStateString} />
|
||||
</div>
|
||||
) : null;
|
||||
@@ -39,18 +49,22 @@ const ModQueueFooter = ({ hasMore, loadingStateString }: ModQueueFooterProps) =>
|
||||
|
||||
interface ModQueueRowProps {
|
||||
comment: Comment;
|
||||
showBoardColumn?: boolean;
|
||||
isOdd?: boolean;
|
||||
}
|
||||
|
||||
// Track which action was initiated to show appropriate completion message
|
||||
type ModerationAction = 'approve' | 'reject' | null;
|
||||
|
||||
const ModQueueRow = ({ comment, showBoardColumn = false }: ModQueueRowProps) => {
|
||||
const ModQueueRow = ({ comment, isOdd = false }: ModQueueRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { alertThresholdHours } = useModQueueStore();
|
||||
const { getAlertThresholdSeconds } = useModQueueStore();
|
||||
const [initiatedAction, setInitiatedAction] = useState<ModerationAction>(null);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const { content, title, timestamp, subplebbitAddress, cid, threadCid, link, thumbnailUrl, linkWidth, linkHeight, removed, approved } = comment;
|
||||
const { editedComment } = useEditedComment({ comment });
|
||||
const displayComment = editedComment || comment;
|
||||
|
||||
const { content, title, timestamp, subplebbitAddress, cid, shortCid, threadCid, link, thumbnailUrl, linkWidth, linkHeight, removed, approved, number } = displayComment;
|
||||
|
||||
// Check if already moderated (from previous session or API update)
|
||||
// Note: `approved` and `removed` are direct fields on the comment from CommentUpdate,
|
||||
@@ -58,9 +72,12 @@ const ModQueueRow = ({ comment, showBoardColumn = false }: ModQueueRowProps) =>
|
||||
const alreadyApproved = approved === true;
|
||||
const alreadyRejected = removed === true;
|
||||
|
||||
const boardPath = useBoardPath(subplebbitAddress);
|
||||
const timeWaiting = Date.now() / 1000 - timestamp;
|
||||
const isOverThreshold = timeWaiting > alertThresholdHours * 3600;
|
||||
const alertThresholdSeconds = getAlertThresholdSeconds();
|
||||
const isOverThreshold = timeWaiting > alertThresholdSeconds;
|
||||
|
||||
// Only show alert animation for comments awaiting approval (not approved or rejected)
|
||||
const isAwaitingApproval = !alreadyApproved && !alreadyRejected;
|
||||
|
||||
const {
|
||||
publishCommentModeration: approve,
|
||||
@@ -101,6 +118,12 @@ const ModQueueRow = ({ comment, showBoardColumn = false }: ModQueueRowProps) =>
|
||||
});
|
||||
|
||||
const handleApprove = async () => {
|
||||
// Double confirmation for approve action
|
||||
const confirm = window.confirm(t('double_confirm'));
|
||||
if (!confirm) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInitiatedAction('approve');
|
||||
try {
|
||||
await approve();
|
||||
@@ -110,6 +133,12 @@ const ModQueueRow = ({ comment, showBoardColumn = false }: ModQueueRowProps) =>
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
// Double confirmation for reject action
|
||||
const confirm = window.confirm(t('double_confirm'));
|
||||
if (!confirm) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInitiatedAction('reject');
|
||||
try {
|
||||
await reject();
|
||||
@@ -129,7 +158,18 @@ const ModQueueRow = ({ comment, showBoardColumn = false }: ModQueueRowProps) =>
|
||||
const approveFailed = initiatedAction === 'approve' && approveState === 'failed';
|
||||
const rejectFailed = initiatedAction === 'reject' && rejectState === 'failed';
|
||||
|
||||
const excerpt = title || content || (getHasThumbnail(getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight), link) ? t('image') : t('no_content'));
|
||||
const boardPath = useBoardPath(subplebbitAddress);
|
||||
const hasTitle = title && title.trim().length > 0;
|
||||
const hasContent = content && content.trim().length > 0;
|
||||
const hasLink = link && link.length > 0;
|
||||
const rawExcerpt =
|
||||
(hasTitle ? title : null) ||
|
||||
(hasContent ? content : null) ||
|
||||
(hasLink ? link : null) ||
|
||||
(getHasThumbnail(getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight), link) ? t('image') : null) ||
|
||||
t('no_content');
|
||||
// Only truncate excerpt on desktop, allow wrapping on mobile
|
||||
const excerpt = !isMobile && rawExcerpt.length > 101 ? rawExcerpt.slice(0, 98) + '...' : rawExcerpt;
|
||||
const threadTargetCid = threadCid || cid;
|
||||
const postUrl = boardPath && threadTargetCid ? `/${boardPath}/thread/${threadTargetCid}` : undefined;
|
||||
|
||||
@@ -161,23 +201,28 @@ const ModQueueRow = ({ comment, showBoardColumn = false }: ModQueueRowProps) =>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
[
|
||||
<button className={styles.button} onClick={handleApprove} disabled={isPublishing}>
|
||||
{t('approve')}
|
||||
</button>
|
||||
] [
|
||||
<button className={styles.button} onClick={handleReject} disabled={isPublishing}>
|
||||
{t('reject')}
|
||||
</button>
|
||||
]
|
||||
</>
|
||||
<div className={styles.actionButtons}>
|
||||
<span className={styles.buttonWrapper}>
|
||||
[
|
||||
<button className={styles.button} onClick={handleApprove} disabled={isPublishing}>
|
||||
{t('approve')}
|
||||
</button>
|
||||
]
|
||||
</span>
|
||||
<span className={styles.buttonWrapper}>
|
||||
[
|
||||
<button className={styles.button} onClick={handleReject} disabled={isPublishing}>
|
||||
{t('reject')}
|
||||
</button>
|
||||
]
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
{showBoardColumn && <div className={styles.board}>{boardPath ? <Link to={`/${boardPath}`}>/{boardPath}/</Link> : <span>—</span>}</div>}
|
||||
<div className={`${styles.row} ${isOdd ? styles.rowOdd : ''}`}>
|
||||
<div className={styles.number}>{number ?? 'N/A'}</div>
|
||||
<div className={styles.excerpt}>
|
||||
{postUrl ? (
|
||||
<Link to={postUrl} title={excerpt}>
|
||||
@@ -187,7 +232,33 @@ const ModQueueRow = ({ comment, showBoardColumn = false }: ModQueueRowProps) =>
|
||||
<span title={excerpt}>{excerpt}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={`${styles.time} ${isOverThreshold ? styles.alert : ''}`}>{formatDistanceToNow(timestamp * 1000, { addSuffix: false })}</div>
|
||||
<div className={styles.time}>
|
||||
{isMobile ? (
|
||||
// On mobile, show shorter time ago format without tooltip
|
||||
isAwaitingApproval && isOverThreshold ? (
|
||||
<>
|
||||
<span>{getFormattedTimeAgo(timestamp)}</span>
|
||||
<span className={styles.alertWrapper}>
|
||||
{' '}
|
||||
(<span className={styles.alert}>{getFormattedTimeAgo(timestamp)}</span>)
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{getFormattedTimeAgo(timestamp)}</span>
|
||||
)
|
||||
) : // On desktop, show full date with tooltip
|
||||
isAwaitingApproval && isOverThreshold ? (
|
||||
<>
|
||||
<Tooltip children={<span>{getFormattedDate(timestamp)}</span>} content={getFormattedTimeAgo(timestamp)} />
|
||||
<span className={styles.alertWrapper}>
|
||||
{' '}
|
||||
(<span className={styles.alert}>{getFormattedTimeAgo(timestamp)}</span>)
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<Tooltip children={<span>{getFormattedDate(timestamp)}</span>} content={getFormattedTimeAgo(timestamp)} />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.actions}>{renderActions()}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -203,18 +274,28 @@ const ModQueueBoardFilter = ({ subplebbits }: ModQueueBoardFilterProps) => {
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = e.target.value;
|
||||
setSelectedBoardFilter(value === '' ? null : value);
|
||||
setSelectedBoardFilter(value);
|
||||
};
|
||||
|
||||
if (!subplebbits || subplebbits.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Default to first board if none selected
|
||||
const firstBoardAddress = subplebbits.find((sub) => sub.address)?.address;
|
||||
const currentFilter = selectedBoardFilter || firstBoardAddress || '';
|
||||
|
||||
// Auto-select first board if none is selected
|
||||
useEffect(() => {
|
||||
if (!selectedBoardFilter && firstBoardAddress) {
|
||||
setSelectedBoardFilter(firstBoardAddress);
|
||||
}
|
||||
}, [selectedBoardFilter, firstBoardAddress, setSelectedBoardFilter]);
|
||||
|
||||
return (
|
||||
<div className={styles.filterContainer}>
|
||||
<label>{t('filter_by_board')}:</label>
|
||||
<select className={styles.filterSelect} value={selectedBoardFilter || ''} onChange={handleChange}>
|
||||
<option value=''>{t('all_boards')}</option>
|
||||
<select value={currentFilter} onChange={handleChange}>
|
||||
{subplebbits.map((sub) => {
|
||||
const address = sub.address;
|
||||
if (!address) return null;
|
||||
@@ -234,73 +315,74 @@ interface ModQueueButtonProps {
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProps) => {
|
||||
interface ModQueueCountItemProps {
|
||||
comment: Comment;
|
||||
alertThresholdSeconds: number;
|
||||
onStatusChange: (cid: string, status: { awaiting: boolean; urgent: boolean }) => void;
|
||||
}
|
||||
|
||||
const ModQueueCountItem = ({ comment, alertThresholdSeconds, onStatusChange }: ModQueueCountItemProps) => {
|
||||
const { editedComment } = useEditedComment({ comment });
|
||||
const displayComment = editedComment || comment;
|
||||
|
||||
const { cid, approved, removed, timestamp } = displayComment;
|
||||
const isAwaiting = approved !== true && removed !== true;
|
||||
const timeWaiting = Date.now() / 1000 - timestamp;
|
||||
const isUrgent = isAwaiting && timeWaiting > alertThresholdSeconds;
|
||||
|
||||
useEffect(() => {
|
||||
onStatusChange(cid, { awaiting: isAwaiting, urgent: isUrgent });
|
||||
}, [cid, isAwaiting, isUrgent, onStatusChange]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
interface ModQueueButtonContentProps {
|
||||
feed: Comment[];
|
||||
alertThresholdSeconds: number;
|
||||
boardIdentifier?: string;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
const ModQueueButtonContent = ({ feed, alertThresholdSeconds, boardIdentifier, isMobile }: ModQueueButtonContentProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { alertThresholdHours } = useModQueueStore();
|
||||
const { accountSubplebbits } = useAccountSubplebbits();
|
||||
const accountSubplebbitAddresses = useMemo(() => Object.keys(accountSubplebbits || {}), [accountSubplebbits]);
|
||||
const defaultSubplebbits = useDefaultSubplebbits();
|
||||
const [statusMap, setStatusMap] = useState<Map<string, { awaiting: boolean; urgent: boolean }>>(new Map());
|
||||
|
||||
// Resolve boardIdentifier to address if it exists
|
||||
const resolvedAddress = useMemo(() => {
|
||||
if (boardIdentifier) {
|
||||
return getSubplebbitAddress(boardIdentifier, defaultSubplebbits);
|
||||
}
|
||||
return undefined;
|
||||
}, [boardIdentifier, defaultSubplebbits]);
|
||||
const handleStatusChange = React.useCallback((cid: string, status: { awaiting: boolean; urgent: boolean }) => {
|
||||
setStatusMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(cid, status);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const subplebbitAddresses = useMemo(() => {
|
||||
if (resolvedAddress) {
|
||||
return [resolvedAddress];
|
||||
}
|
||||
return accountSubplebbitAddresses;
|
||||
}, [resolvedAddress, accountSubplebbitAddresses]);
|
||||
// Clean up stale entries when comments leave the feed to prevent memory leaks
|
||||
const feedCids = useMemo(() => new Set(feed.map((item) => item.cid)), [feed]);
|
||||
useEffect(() => {
|
||||
setStatusMap((prev) => {
|
||||
const staleKeys = [...prev.keys()].filter((cid) => !feedCids.has(cid));
|
||||
if (staleKeys.length === 0) return prev;
|
||||
const next = new Map(prev);
|
||||
for (const key of staleKeys) {
|
||||
next.delete(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [feedCids]);
|
||||
|
||||
// If specific board, check if user is mod using resolved address
|
||||
const isModOfBoard = resolvedAddress ? accountSubplebbitAddresses.includes(resolvedAddress) : true;
|
||||
|
||||
// Only fetch if we have addresses to check and permissions
|
||||
const shouldFetch = subplebbitAddresses.length > 0 && isModOfBoard;
|
||||
|
||||
const { feed } = useFeed({
|
||||
subplebbitAddresses: shouldFetch ? subplebbitAddresses : [],
|
||||
modQueue: ['pendingApproval'],
|
||||
postsPerPage: 100, // Fetch enough to check timestamps
|
||||
});
|
||||
|
||||
if (!shouldFetch || subplebbitAddresses.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort feed by timestamp (oldest first) to match ModQueueView sorting
|
||||
const sortedFeed = useMemo(() => {
|
||||
return [...feed].sort((a, b) => a.timestamp - b.timestamp);
|
||||
}, [feed]);
|
||||
|
||||
// Separate comments into normal and urgent based on threshold
|
||||
// Match ModQueueRow logic: use > (strictly greater) to be consistent
|
||||
const { normalCount, urgentCount } = useMemo(() => {
|
||||
const thresholdSeconds = alertThresholdHours * 3600;
|
||||
const now = Date.now() / 1000;
|
||||
|
||||
let normal = 0;
|
||||
let urgent = 0;
|
||||
|
||||
for (const item of sortedFeed) {
|
||||
const timeWaiting = now - item.timestamp;
|
||||
if (timeWaiting > thresholdSeconds) {
|
||||
urgent++;
|
||||
} else {
|
||||
normal++;
|
||||
for (const { awaiting, urgent: isUrgent } of statusMap.values()) {
|
||||
if (awaiting) {
|
||||
if (isUrgent) urgent++;
|
||||
else normal++;
|
||||
}
|
||||
}
|
||||
|
||||
return { normalCount: normal, urgentCount: urgent };
|
||||
}, [sortedFeed, alertThresholdHours]);
|
||||
}, [statusMap]);
|
||||
|
||||
const totalCount = normalCount + urgentCount;
|
||||
|
||||
// Use boardIdentifier for the route, but resolvedAddress for mod check
|
||||
const to = boardIdentifier ? `/${boardIdentifier}/queue` : '/mod/queue';
|
||||
|
||||
const buttonContent = (
|
||||
@@ -330,20 +412,34 @@ export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProp
|
||||
</button>
|
||||
);
|
||||
|
||||
return isMobile ? buttonContent : <>[{buttonContent}]</>;
|
||||
return (
|
||||
<>
|
||||
{feed.map((item) => (
|
||||
<ModQueueCountItem key={item.cid} comment={item} alertThresholdSeconds={alertThresholdSeconds} onStatusChange={handleStatusChange} />
|
||||
))}
|
||||
{isMobile ? buttonContent : <>[{buttonContent}]</>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProps) => {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams();
|
||||
const { selectedBoardFilter, alertThresholdHours, setAlertThresholdHours } = useModQueueStore();
|
||||
const { accountSubplebbits } = useAccountSubplebbits();
|
||||
const accountSubplebbitAddresses = Object.keys(accountSubplebbits);
|
||||
export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProps) => {
|
||||
const { getAlertThresholdSeconds } = useModQueueStore();
|
||||
|
||||
const accountSubplebbitAddresses = useAccountsStore(
|
||||
(state) => {
|
||||
const activeAccountId = state.activeAccountId;
|
||||
const activeAccount = activeAccountId ? state.accounts[activeAccountId] : undefined;
|
||||
const accountSubplebbits = activeAccount?.subplebbits || {};
|
||||
return Object.keys(accountSubplebbits);
|
||||
},
|
||||
(prev, next) => {
|
||||
if (prev.length !== next.length) return false;
|
||||
return prev.every((val, idx) => val === next[idx]);
|
||||
},
|
||||
);
|
||||
|
||||
const defaultSubplebbits = useDefaultSubplebbits();
|
||||
|
||||
const boardIdentifier = propBoardIdentifier || params.boardIdentifier;
|
||||
|
||||
// Resolve boardIdentifier to address if it exists
|
||||
const resolvedAddress = useMemo(() => {
|
||||
if (boardIdentifier) {
|
||||
return getSubplebbitAddress(boardIdentifier, defaultSubplebbits);
|
||||
@@ -351,7 +447,65 @@ export const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueV
|
||||
return undefined;
|
||||
}, [boardIdentifier, defaultSubplebbits]);
|
||||
|
||||
// Get metadata for filter dropdown
|
||||
const subplebbitAddresses = useMemo(() => {
|
||||
if (resolvedAddress) {
|
||||
return [resolvedAddress];
|
||||
}
|
||||
return accountSubplebbitAddresses;
|
||||
}, [resolvedAddress, accountSubplebbitAddresses]);
|
||||
|
||||
// If specific board, check if user is mod using resolved address
|
||||
const isModOfBoard = resolvedAddress ? accountSubplebbitAddresses.includes(resolvedAddress) : true;
|
||||
|
||||
// Only fetch if we have addresses to check and permissions
|
||||
const shouldFetch = subplebbitAddresses.length > 0 && isModOfBoard;
|
||||
|
||||
const { feed } = useFeed({
|
||||
subplebbitAddresses: shouldFetch ? subplebbitAddresses : [],
|
||||
modQueue: ['pendingApproval'],
|
||||
sortType: 'new',
|
||||
postsPerPage: 200, // Fetch more items to get accurate pending count for the badge
|
||||
});
|
||||
|
||||
if (!shouldFetch || subplebbitAddresses.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const alertThresholdSeconds = getAlertThresholdSeconds();
|
||||
// Use key to reset statusMap state when switching boards (prevents stale counts from previous board)
|
||||
const contentKey = subplebbitAddresses.join(',');
|
||||
return <ModQueueButtonContent key={contentKey} feed={feed} alertThresholdSeconds={alertThresholdSeconds} boardIdentifier={boardIdentifier} isMobile={isMobile} />;
|
||||
};
|
||||
|
||||
export const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProps) => {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams();
|
||||
const { selectedBoardFilter, alertThresholdValue, alertThresholdUnit, setAlertThreshold } = useModQueueStore();
|
||||
|
||||
const accountSubplebbitAddresses = useAccountsStore(
|
||||
(state) => {
|
||||
const activeAccountId = state.activeAccountId;
|
||||
const activeAccount = activeAccountId ? state.accounts[activeAccountId] : undefined;
|
||||
const accountSubplebbits = activeAccount?.subplebbits || {};
|
||||
return Object.keys(accountSubplebbits);
|
||||
},
|
||||
(prev, next) => {
|
||||
if (prev.length !== next.length) return false;
|
||||
return prev.every((val, idx) => val === next[idx]);
|
||||
},
|
||||
);
|
||||
|
||||
const defaultSubplebbits = useDefaultSubplebbits();
|
||||
|
||||
const boardIdentifier = propBoardIdentifier || params.boardIdentifier;
|
||||
|
||||
const resolvedAddress = useMemo(() => {
|
||||
if (boardIdentifier) {
|
||||
return getSubplebbitAddress(boardIdentifier, defaultSubplebbits);
|
||||
}
|
||||
return undefined;
|
||||
}, [boardIdentifier, defaultSubplebbits]);
|
||||
|
||||
const subplebbitsWithMetadata = useAccountSubplebbitsWithMetadata();
|
||||
|
||||
const subplebbitAddresses = useMemo(() => {
|
||||
@@ -359,100 +513,155 @@ export const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueV
|
||||
return [resolvedAddress];
|
||||
}
|
||||
|
||||
// Always require a board filter when viewing /mod/queue (no boardIdentifier)
|
||||
if (selectedBoardFilter) {
|
||||
return [selectedBoardFilter];
|
||||
}
|
||||
|
||||
return accountSubplebbitAddresses;
|
||||
// Default to first board if none selected
|
||||
const firstBoardAddress = accountSubplebbitAddresses[0];
|
||||
if (firstBoardAddress) {
|
||||
return [firstBoardAddress];
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [resolvedAddress, selectedBoardFilter, accountSubplebbitAddresses]);
|
||||
|
||||
const subplebbitAddress = subplebbitAddresses[0];
|
||||
const subplebbit = useSubplebbit({ subplebbitAddress });
|
||||
const { error: subplebbitError } = subplebbit || {};
|
||||
|
||||
const { feed, hasMore, loadMore, reset } = useFeed({
|
||||
subplebbitAddresses,
|
||||
modQueue: ['pendingApproval'],
|
||||
postsPerPage: 50,
|
||||
});
|
||||
|
||||
// Sort feed by timestamp (oldest first) to show items waiting longest at the top
|
||||
const sortedFeed = useMemo(() => {
|
||||
return [...feed].sort((a, b) => a.timestamp - b.timestamp);
|
||||
}, [feed]);
|
||||
|
||||
// Register reset function with feed reset store so refresh button works
|
||||
const setResetFunction = useFeedResetStore((state) => state.setResetFunction);
|
||||
useEffect(() => {
|
||||
setResetFunction(reset);
|
||||
}, [reset, setResetFunction]);
|
||||
|
||||
// Synchronize blinking animation across all rows
|
||||
// Set a CSS variable with the current animation phase so all elements start from the same point
|
||||
// Update frequently to ensure elements rendered at different times stay synchronized
|
||||
// Auto-select first board if viewing /mod/queue without a boardIdentifier and no filter is set
|
||||
useEffect(() => {
|
||||
const ANIMATION_DURATION = 2000; // 2 seconds
|
||||
const UPDATE_INTERVAL = 100; // Update every 100ms for smooth synchronization
|
||||
|
||||
const updateAnimationPhase = () => {
|
||||
// Calculate current phase in the animation cycle (0 to 2 seconds)
|
||||
const phase = (Date.now() % ANIMATION_DURATION) / 1000;
|
||||
document.documentElement.style.setProperty('--mod-queue-blink-phase', `${phase}s`);
|
||||
};
|
||||
|
||||
// Update immediately and then at regular intervals
|
||||
updateAnimationPhase();
|
||||
const interval = setInterval(updateAnimationPhase, UPDATE_INTERVAL);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const loadingStateString = useFeedStateString(subplebbitAddresses) || t('loading');
|
||||
const showBoardColumn = !resolvedAddress && !selectedBoardFilter;
|
||||
if (!resolvedAddress && !selectedBoardFilter && accountSubplebbitAddresses.length > 0) {
|
||||
const { setSelectedBoardFilter } = useModQueueStore.getState();
|
||||
setSelectedBoardFilter(accountSubplebbitAddresses[0]);
|
||||
}
|
||||
}, [resolvedAddress, selectedBoardFilter, accountSubplebbitAddresses]);
|
||||
|
||||
// Memoize footer components object to preserve identity across renders (Virtuoso optimization)
|
||||
// Note: useFeedStateString is called inside ModQueueFooter to isolate re-renders from backend state changes
|
||||
const footerComponents = useMemo(
|
||||
() => ({
|
||||
Footer: () => <ModQueueFooter hasMore={hasMore} loadingStateString={loadingStateString} />,
|
||||
Footer: () => (
|
||||
<>
|
||||
{subplebbitError?.message && feed.length === 0 && (
|
||||
<div className={styles.error}>
|
||||
<ErrorDisplay error={subplebbitError} />
|
||||
</div>
|
||||
)}
|
||||
<ModQueueFooter hasMore={hasMore} subplebbitAddresses={subplebbitAddresses} />
|
||||
</>
|
||||
),
|
||||
}),
|
||||
[hasMore, loadingStateString],
|
||||
[hasMore, subplebbitAddresses, subplebbitError, feed.length],
|
||||
);
|
||||
|
||||
const alertThresholdControl = (
|
||||
<div className={styles.alertThresholdSetting}>
|
||||
<label>
|
||||
{t('alert_threshold')}:
|
||||
<input
|
||||
type='number'
|
||||
min='1'
|
||||
step={alertThresholdUnit === 'minutes' ? '1' : '1'}
|
||||
value={alertThresholdValue}
|
||||
onChange={(e) => setAlertThreshold(Number(e.target.value), alertThresholdUnit)}
|
||||
className={styles.alertThresholdInput}
|
||||
/>
|
||||
<select
|
||||
value={alertThresholdUnit}
|
||||
onChange={(e) => {
|
||||
const newUnit = e.target.value as 'hours' | 'minutes';
|
||||
const newValue =
|
||||
alertThresholdUnit === 'hours' && newUnit === 'minutes'
|
||||
? alertThresholdValue * 60
|
||||
: alertThresholdUnit === 'minutes' && newUnit === 'hours'
|
||||
? Math.round(alertThresholdValue / 60)
|
||||
: alertThresholdValue;
|
||||
setAlertThreshold(Math.max(1, newValue), newUnit);
|
||||
}}
|
||||
>
|
||||
<option value='minutes'>{t('minutes')}</option>
|
||||
<option value='hours'>{t('hours')}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>{t('moderation_queue')}</div>
|
||||
<div className={styles.alertThresholdSetting}>
|
||||
<label>
|
||||
{t('mod_queue_alert_threshold')}:
|
||||
<input
|
||||
type='number'
|
||||
min='1'
|
||||
value={alertThresholdHours}
|
||||
onChange={(e) => setAlertThresholdHours(Number(e.target.value))}
|
||||
className={styles.alertThresholdInput}
|
||||
/>{' '}
|
||||
{t('hours')}
|
||||
</label>
|
||||
{!resolvedAddress && (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>{t('moderation_queue')}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.controls}>
|
||||
{!resolvedAddress ? (
|
||||
<>
|
||||
<div className={styles.controlsLeft}>
|
||||
<ModQueueBoardFilter subplebbits={subplebbitsWithMetadata} />
|
||||
</div>
|
||||
<div className={styles.controlsRight}>{alertThresholdControl}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.controlsLeft}>
|
||||
<div className={styles.title}>{t('moderation_queue')}</div>
|
||||
</div>
|
||||
<div className={styles.controlsRight}>{alertThresholdControl}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!resolvedAddress && <ModQueueBoardFilter subplebbits={subplebbitsWithMetadata} />}
|
||||
|
||||
{sortedFeed.length === 0 && !hasMore ? (
|
||||
{feed.length === 0 && !hasMore ? (
|
||||
<div className={styles.empty}>{t('queue_is_empty')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.tableHeader}>
|
||||
{showBoardColumn && <div className={styles.boardHeader}>{t('board')}</div>}
|
||||
<div className={styles.numberHeader}>No.</div>
|
||||
<div className={styles.excerptHeader}>{t('excerpt')}</div>
|
||||
<div className={styles.timeHeader}>{t('waiting')}</div>
|
||||
<div className={styles.timeHeader}>{t('submitted')}</div>
|
||||
<div className={styles.actionsHeader}>{t('actions')}</div>
|
||||
</div>
|
||||
|
||||
<Virtuoso
|
||||
useWindowScroll
|
||||
data={sortedFeed}
|
||||
totalCount={sortedFeed.length}
|
||||
endReached={loadMore}
|
||||
itemContent={(index, comment) => <ModQueueRow key={comment.cid} comment={comment} showBoardColumn={showBoardColumn} />}
|
||||
components={footerComponents}
|
||||
/>
|
||||
{/* Use Virtuoso for infinite scroll only when feed is large enough to warrant it */}
|
||||
{feed.length > 25 ? (
|
||||
<Virtuoso
|
||||
useWindowScroll
|
||||
data={feed}
|
||||
totalCount={feed.length}
|
||||
endReached={loadMore}
|
||||
increaseViewportBy={{ bottom: 1200, top: 1200 }}
|
||||
itemContent={(index, comment) => <ModQueueRow key={comment.cid} comment={comment} isOdd={index % 2 === 0} />}
|
||||
components={footerComponents}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{feed.map((comment, index) => (
|
||||
<ModQueueRow key={comment.cid} comment={comment} isOdd={index % 2 === 0} />
|
||||
))}
|
||||
{subplebbitError?.message && feed.length === 0 && (
|
||||
<div className={styles.error}>
|
||||
<ErrorDisplay error={subplebbitError} />
|
||||
</div>
|
||||
)}
|
||||
<ModQueueFooter hasMore={hasMore} subplebbitAddresses={subplebbitAddresses} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+15
-4
@@ -70,7 +70,7 @@ const PostPage = () => {
|
||||
}, [comment?.subplebbitAddress, subplebbitAddress, navigate]);
|
||||
|
||||
const subplebbit = useSubplebbit({ subplebbitAddress });
|
||||
const { shortAddress, title } = subplebbit || {};
|
||||
const { error: subplebbitError, shortAddress, title } = subplebbit || {};
|
||||
const defaultSubplebbits = useDefaultSubplebbits();
|
||||
|
||||
// if the comment is a reply, return the post comment instead, then the reply will be highlighted in the thread
|
||||
@@ -106,17 +106,28 @@ const PostPage = () => {
|
||||
document.title = `${boardTitle}${postTitlePart} - 5chan`;
|
||||
}, [title, shortAddress, subplebbitAddress, post?.title, post?.content, isInAllView, t, params.boardIdentifier, defaultSubplebbits]);
|
||||
|
||||
// probably not necessary to show the error to the user if the post loaded successfully
|
||||
const shouldShowErrorToUser = post?.error && ((post?.replyCount > 0 && post?.replies?.length === 0) || (post?.state === 'failed' && post?.error));
|
||||
const shouldShowCommentError = comment?.error?.message && !comment?.cid;
|
||||
const shouldShowPostError = post?.error && post?.replyCount > 0 && post?.replies?.length === 0;
|
||||
const shouldShowSubplebbitError = subplebbitError?.message && !post?.cid;
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
{shouldShowErrorToUser && (
|
||||
{shouldShowPostError && (
|
||||
<div className={styles.error}>
|
||||
<ErrorDisplay error={error} />
|
||||
</div>
|
||||
)}
|
||||
<Post post={post} showAllReplies={true} />
|
||||
{shouldShowSubplebbitError && (
|
||||
<div className={styles.error}>
|
||||
<ErrorDisplay error={subplebbitError} />
|
||||
</div>
|
||||
)}
|
||||
{shouldShowCommentError && (
|
||||
<div className={styles.error}>
|
||||
<ErrorDisplay error={comment?.error} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user