feat(mod-queue): improve compact view and add pending approval features

- Update excerpt format to show both title and content (title: content)
- Add Type and Image columns to compact view (desktop and mobile)
- Show parent post above replies in feed view
- Add approve/reject buttons to post page for pending posts when user is mod
- Translate pending approval string across all languages
- Reorder columns: No., Excerpt, Submitted, Type, Image, Actions
- Right-align Type and Image columns in desktop view
This commit is contained in:
plebeius
2026-01-26 16:31:15 +08:00
parent 28873478a2
commit fa4ec6da40
40 changed files with 525 additions and 57 deletions
+52
View File
@@ -66,6 +66,8 @@
.numberHeader,
.excerptHeader,
.timeHeader,
.typeHeader,
.imageHeader,
.actionsHeader {
background: var(--mod-queue-header-background-color);
padding: 5px;
@@ -80,6 +82,18 @@
flex-grow: 1;
}
.typeHeader {
width: 70px;
flex-shrink: 0;
text-align: right;
}
.imageHeader {
width: 60px;
flex-shrink: 0;
text-align: right;
}
.timeHeader {
width: 210px;
text-align: right;
@@ -124,6 +138,8 @@
.number,
.excerpt,
.time,
.type,
.image,
.actions {
padding: 2px 5px;
}
@@ -135,6 +151,8 @@
:global(body.yotsuba) .rowOdd .number,
:global(body.yotsuba) .rowOdd .excerpt,
:global(body.yotsuba) .rowOdd .time,
:global(body.yotsuba) .rowOdd .type,
:global(body.yotsuba) .rowOdd .image,
:global(body.yotsuba) .rowOdd .actions {
background-color: #ede2d4;
}
@@ -142,6 +160,8 @@
:global(body.yotsuba-b) .rowOdd .number,
:global(body.yotsuba-b) .rowOdd .excerpt,
:global(body.yotsuba-b) .rowOdd .time,
:global(body.yotsuba-b) .rowOdd .type,
:global(body.yotsuba-b) .rowOdd .image,
:global(body.yotsuba-b) .rowOdd .actions {
background-color: #e0e5f6;
}
@@ -149,6 +169,8 @@
:global(body.futaba) .rowOdd .number,
:global(body.futaba) .rowOdd .excerpt,
:global(body.futaba) .rowOdd .time,
:global(body.futaba) .rowOdd .type,
:global(body.futaba) .rowOdd .image,
:global(body.futaba) .rowOdd .actions {
background-color: #ede2d4;
}
@@ -156,6 +178,8 @@
:global(body.burichan) .rowOdd .number,
:global(body.burichan) .rowOdd .excerpt,
:global(body.burichan) .rowOdd .time,
:global(body.burichan) .rowOdd .type,
:global(body.burichan) .rowOdd .image,
:global(body.burichan) .rowOdd .actions {
background-color: #e0e5f6;
}
@@ -163,6 +187,8 @@
:global(body.tomorrow) .rowOdd .number,
:global(body.tomorrow) .rowOdd .excerpt,
:global(body.tomorrow) .rowOdd .time,
:global(body.tomorrow) .rowOdd .type,
:global(body.tomorrow) .rowOdd .image,
:global(body.tomorrow) .rowOdd .actions {
background-color: rgba(255, 255, 255, 0.1);
}
@@ -170,6 +196,8 @@
:global(body.photon) .rowOdd .number,
:global(body.photon) .rowOdd .excerpt,
:global(body.photon) .rowOdd .time,
:global(body.photon) .rowOdd .type,
:global(body.photon) .rowOdd .image,
:global(body.photon) .rowOdd .actions {
background-color: #888;
}
@@ -187,6 +215,18 @@
min-width: 0;
}
.type {
width: 70px;
flex-shrink: 0;
text-align: right;
}
.image {
width: 60px;
flex-shrink: 0;
text-align: right;
}
.excerpt a {
text-decoration: var(--post-link-text-decoration);
color: var(--post-link-text-color);
@@ -397,26 +437,38 @@ span.reject:hover {
:global(body.yotsuba) .rowOdd .number,
:global(body.yotsuba) .rowOdd .excerpt,
:global(body.yotsuba) .rowOdd .time,
:global(body.yotsuba) .rowOdd .type,
:global(body.yotsuba) .rowOdd .image,
: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 .type,
:global(body.yotsuba-b) .rowOdd .image,
: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 .type,
:global(body.futaba) .rowOdd .image,
:global(body.futaba) .rowOdd .actions,
:global(body.burichan) .rowOdd .number,
:global(body.burichan) .rowOdd .excerpt,
:global(body.burichan) .rowOdd .time,
:global(body.burichan) .rowOdd .type,
:global(body.burichan) .rowOdd .image,
:global(body.burichan) .rowOdd .actions,
:global(body.tomorrow) .rowOdd .number,
:global(body.tomorrow) .rowOdd .excerpt,
:global(body.tomorrow) .rowOdd .time,
:global(body.tomorrow) .rowOdd .type,
:global(body.tomorrow) .rowOdd .image,
:global(body.tomorrow) .rowOdd .actions,
:global(body.photon) .rowOdd .number,
:global(body.photon) .rowOdd .excerpt,
:global(body.photon) .rowOdd .time,
:global(body.photon) .rowOdd .type,
:global(body.photon) .rowOdd .image,
:global(body.photon) .rowOdd .actions {
background-color: transparent;
}
+44 -17
View File
@@ -1,7 +1,7 @@
import React, { useMemo, useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams, Link } from 'react-router-dom';
import { useFeed, Comment, usePublishCommentModeration, useEditedComment, useSubplebbit } from '@plebbit/plebbit-react-hooks';
import { useFeed, Comment, usePublishCommentModeration, useEditedComment, useSubplebbit, useComment } 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';
@@ -22,6 +22,7 @@ import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-util
import Tooltip from '../../components/tooltip';
import useIsMobile from '../../hooks/use-is-mobile';
import { Post } from '../post/post';
import _ from 'lodash';
const { addChallenge } = useChallengesStore.getState();
@@ -162,7 +163,8 @@ const ModQueueRow = ({ comment, isOdd = false }: ModQueueRowProps) => {
const { editedComment } = useEditedComment({ comment });
const displayComment = editedComment || comment;
const { content, title, timestamp, subplebbitAddress, cid, threadCid, link, thumbnailUrl, linkWidth, linkHeight, removed, approved, number } = displayComment;
const { content, title, timestamp, subplebbitAddress, cid, threadCid, link, thumbnailUrl, linkWidth, linkHeight, removed, approved, number, parentCid } =
displayComment;
// Check if already moderated (from previous session or API update)
// Note: `approved` and `removed` are direct fields on the comment from CommentUpdate,
@@ -183,11 +185,15 @@ const ModQueueRow = ({ comment, isOdd = false }: ModQueueRowProps) => {
const hasTitle = title && title.trim().length > 0;
const hasContent = content && content.trim().length > 0;
const hasLink = link && link.length > 0;
const isReply = !!parentCid;
const commentMediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
const rawExcerpt =
(hasTitle && hasContent ? `${title}: ${content}` : null) ||
(hasTitle ? title : null) ||
(hasContent ? content : null) ||
(hasLink ? link : null) ||
(getHasThumbnail(getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight), link) ? t('image') : null) ||
(hasThumbnail ? 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;
@@ -268,6 +274,8 @@ const ModQueueRow = ({ comment, isOdd = false }: ModQueueRowProps) => {
<Tooltip children={<span>{getFormattedDate(timestamp)}</span>} content={getFormattedTimeAgo(timestamp)} />
)}
</div>
<div className={styles.type}>{isReply ? _.capitalize(t('reply')) : _.capitalize(t('post'))}</div>
<div className={styles.image}>{hasThumbnail ? t('yes') : t('no')}</div>
<div className={styles.actions}>{renderActions()}</div>
</div>
);
@@ -284,7 +292,8 @@ const ModQueueCard = ({ comment }: ModQueueCardProps) => {
const { editedComment } = useEditedComment({ comment });
const displayComment = editedComment || comment;
const { content, title, timestamp, subplebbitAddress, cid, threadCid, link, thumbnailUrl, linkWidth, linkHeight, removed, approved, number } = displayComment;
const { content, title, timestamp, subplebbitAddress, cid, threadCid, link, thumbnailUrl, linkWidth, linkHeight, removed, approved, number, parentCid } =
displayComment;
const alreadyApproved = approved === true;
const alreadyRejected = removed === true;
@@ -300,11 +309,15 @@ const ModQueueCard = ({ comment }: ModQueueCardProps) => {
const hasTitle = title && title.trim().length > 0;
const hasContent = content && content.trim().length > 0;
const hasLink = link && link.length > 0;
const isReply = !!parentCid;
const commentMediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
const rawExcerpt =
(hasTitle && hasContent ? `${title}: ${content}` : null) ||
(hasTitle ? title : null) ||
(hasContent ? content : null) ||
(hasLink ? link : null) ||
(getHasThumbnail(getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight), link) ? t('image') : null) ||
(hasThumbnail ? t('image') : null) ||
t('no_content');
const excerpt = rawExcerpt.length > 140 ? rawExcerpt.slice(0, 137) + '...' : rawExcerpt;
const threadTargetCid = threadCid || cid;
@@ -377,7 +390,8 @@ const ModQueueCard = ({ comment }: ModQueueCardProps) => {
</Link>
) : (
<span title={excerpt}>{excerpt}</span>
)}
)}{' '}
/ {t('type')}: {isReply ? t('reply') : t('post')} / {_.capitalize(t('image'))}: {hasThumbnail ? _.lowerCase(t('yes')) : _.lowerCase(t('no'))}
</div>
{renderActions()}
</div>
@@ -388,19 +402,30 @@ const ModQueueFeedPost = ({ comment }: { comment: Comment }) => {
const { editedComment } = useEditedComment({ comment });
const displayComment = editedComment || comment;
const { status, errorMessage, isPublishing, handleApprove, handleReject } = useModQueueActions(displayComment);
const { parentCid } = displayComment;
// Fetch parent comment if this is a reply
const parentComment = useComment({ commentCid: parentCid });
return (
<Post
post={displayComment}
showAllReplies={false}
showReplies={false}
isModQueue={true}
modQueueStatus={status}
modQueueError={errorMessage}
isPublishing={isPublishing}
onApprove={handleApprove}
onReject={handleReject}
/>
<>
{parentCid && parentComment && (
<div style={{ marginBottom: '10px', paddingLeft: '20px', borderLeft: '2px solid var(--mod-queue-alert-color, #ccc)' }}>
<Post post={parentComment} showAllReplies={false} showReplies={false} isModQueue={false} />
</div>
)}
<Post
post={displayComment}
showAllReplies={false}
showReplies={false}
isModQueue={true}
modQueueStatus={status}
modQueueError={errorMessage}
isPublishing={isPublishing}
onApprove={handleApprove}
onReject={handleReject}
/>
</>
);
};
@@ -730,6 +755,8 @@ export const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueV
<div className={styles.numberHeader}>No.</div>
<div className={styles.excerptHeader}>{t('excerpt')}</div>
<div className={styles.timeHeader}>{t('submitted')}</div>
<div className={styles.typeHeader}>{t('type')}</div>
<div className={styles.imageHeader}>{t('image')}</div>
<div className={styles.actionsHeader}>{t('actions')}</div>
</div>