feat(mod-queue): add view mode selection and integrate with post components

This commit is contained in:
plebeius
2026-01-25 16:29:15 +08:00
parent 3cac7810ae
commit 14e8c92fa0
12 changed files with 703 additions and 145 deletions
@@ -66,6 +66,30 @@
align-items: center;
}
.modQueueControls {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: 8px;
font-size: 12px;
}
.modQueueControls label {
display: inline-flex;
align-items: center;
gap: 6px;
}
.alertThresholdInput {
width: 50px;
}
.mobileBoardButtons .modQueueControls {
display: flex;
justify-content: center;
margin: 6px 0;
}
.desktopBoardButtons .rightSideButtons select {
margin-right: 5px;
}
@@ -11,6 +11,7 @@ import useCatalogStyleStore from '../../stores/use-catalog-style-store';
import useFeedResetStore from '../../stores/use-feed-reset-store';
import useSortingStore from '../../stores/use-sorting-store';
import useAllFeedFilterStore from '../../stores/use-all-feed-filter-store';
import useModQueueStore from '../../stores/use-mod-queue-store';
import useCountLinksInReplies from '../../hooks/use-count-links-in-replies';
import useIsMobile from '../../hooks/use-is-mobile';
import useTimeFilter from '../../hooks/use-time-filter';
@@ -229,6 +230,58 @@ const ImageSizeOptions = () => {
);
};
const ModQueueAlertThreshold = () => {
const { t } = useTranslation();
const { alertThresholdValue, alertThresholdUnit, setAlertThreshold } = useModQueueStore();
return (
<div className={styles.modQueueControls}>
<label>
{t('alert_threshold')}:
<input
type='number'
min='1'
step='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>
);
};
const ModQueueViewSelector = () => {
const { viewMode, setViewMode } = useModQueueStore();
return (
<div className={styles.modQueueControls}>
<label>
View:
<select value={viewMode} onChange={(e) => setViewMode(e.target.value as 'compact' | 'feed')}>
<option value='compact'>Compact</option>
<option value='feed'>Feed</option>
</select>
</label>
</div>
);
};
const ShowOPCommentOption = () => {
const { t } = useTranslation();
const { showOPComment, setShowOPComment } = useCatalogStyleStore();
@@ -353,6 +406,8 @@ export const MobileBoardButtons = () => {
isInModQueueView={isInModQueueView}
/>
<RefreshButton />
<ModQueueAlertThreshold />
<ModQueueViewSelector />
</>
) : (
<>
@@ -476,6 +531,10 @@ export const DesktopBoardButtons = () => {
/>
] [
<RefreshButton />]
<span className={styles.rightSideButtons}>
<ModQueueAlertThreshold />
<ModQueueViewSelector />
</span>
</>
) : (
<>
+96 -8
View File
@@ -9,7 +9,8 @@ import { CommentMediaInfo, getDisplayMediaInfoType, getHasThumbnail, getMediaDim
import { hashStringToColor, getTextColorForBackground } from '../../lib/utils/post-utils';
import { getFormattedDate, getFormattedTimeAgo } from '../../lib/utils/time-utils';
import { isValidURL } from '../../lib/utils/url-utils';
import { isAllView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
import { isAllView, isModQueueView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
import useModQueueStore from '../../stores/use-mod-queue-store';
import { useDefaultSubplebbits } from '../../hooks/use-default-subplebbits';
import { getBoardPath } from '../../lib/utils/route-utils';
import useAvatarVisibilityStore from '../../stores/use-avatar-visibility-store';
@@ -54,7 +55,19 @@ const useShowOmittedReplies = create<ShowOmittedRepliesState>((set) => ({
})),
}));
const PostInfo = ({ post, postReplyCount = 0, roles, isHidden, threadNumber }: PostProps) => {
const PostInfo = ({
post,
postReplyCount = 0,
roles,
isHidden,
threadNumber,
isModQueue,
modQueueStatus,
modQueueError,
isPublishing,
onApprove,
onReject,
}: PostProps) => {
const { t } = useTranslation();
const { author, cid, deleted, locked, pinned, parentCid, postCid, reason, removed, state, subplebbitAddress, timestamp } = post || {};
const title = post?.title?.trim();
@@ -74,6 +87,17 @@ const PostInfo = ({ post, postReplyCount = 0, roles, isHidden, threadNumber }: P
const params = useParams();
const location = useLocation();
const isInPostPageView = isPostPageView(location.pathname, params);
const isInModQueueView = isModQueueView(location.pathname);
const { getAlertThresholdSeconds } = useModQueueStore();
// Check if post is awaiting approval and over threshold (for mod queue view)
const approved = post?.approved;
const alreadyApproved = approved === true;
const alreadyRejected = removed === true;
const isAwaitingApproval = isInModQueueView && !alreadyApproved && !alreadyRejected;
const timeWaiting = timestamp ? Date.now() / 1000 - timestamp : 0;
const alertThresholdSeconds = getAlertThresholdSeconds();
const isOverThreshold = isAwaitingApproval && timeWaiting > alertThresholdSeconds;
const userID = address && Plebbit.getShortAddress({ address }); // should not be shortened to less than 12 characters, because users can create unlimited addresses/IDs before authenticating or passing challenges, so if the ID is short enough they can spoof it to troll users with the same ID
const userIDBackgroundColor = hashStringToColor(userID);
@@ -169,7 +193,14 @@ const PostInfo = ({ post, postReplyCount = 0, roles, isHidden, threadNumber }: P
){' '}
</span>
<span className={styles.dateTime}>
<Tooltip children={<span>{getFormattedDate(timestamp)}</span>} content={getFormattedTimeAgo(timestamp)} />{' '}
{isInModQueueView && isOverThreshold ? (
<>
<Tooltip children={<span>{getFormattedDate(timestamp)}</span>} content={getFormattedTimeAgo(timestamp)} /> (
<span className={styles.alert}>{getFormattedTimeAgo(timestamp)}</span>)
</>
) : (
<Tooltip children={<span>{getFormattedDate(timestamp)}</span>} content={getFormattedTimeAgo(timestamp)} />
)}{' '}
</span>
<span className={styles.postNum}>
{cid ? (
@@ -204,7 +235,7 @@ const PostInfo = ({ post, postReplyCount = 0, roles, isHidden, threadNumber }: P
<img src='assets/icons/closed.gif' alt='' className={styles.closedIcon} title={t('closed')} />
</span>
)}
{!isInPostPageView && !isReply && !isHidden && (
{!isInPostPageView && !isReply && !isHidden && !isModQueue && (
<span className={styles.replyButton}>
[
<Link to={boardPath ? `/${boardPath}/thread/${postCid}` : `/thread/${postCid}`} onClick={(e) => !cid && e.preventDefault()}>
@@ -213,8 +244,41 @@ const PostInfo = ({ post, postReplyCount = 0, roles, isHidden, threadNumber }: P
]
</span>
)}
{isModQueue && (
<span className={styles.modQueueActions}>
{modQueueStatus === 'approved' ? (
<span className={styles.modQueueStatusApproved}>{t('approved')}</span>
) : modQueueStatus === 'rejected' ? (
<span className={styles.modQueueStatusRejected}>{t('rejected')}</span>
) : modQueueStatus === 'failed' ? (
<span className={styles.modQueueStatusRejected}>
{t('failed')}
{modQueueError ? `: ${modQueueError}` : ''}
</span>
) : isPublishing ? (
<LoadingEllipsis string={t('publishing')} />
) : (
<>
<span className={styles.modQueueButtonWrapper}>
[
<button className={styles.modQueueActionButton} onClick={onApprove} disabled={isPublishing}>
{t('approve')}
</button>
]
</span>
<span className={styles.modQueueButtonWrapper}>
[
<button className={styles.modQueueActionButton} onClick={onReject} disabled={isPublishing}>
{t('reject')}
</button>
]
</span>
</>
)}
</span>
)}
</span>
{!(removed || deleted) && <PostMenuDesktop postMenu={postMenuProps} />}
{!(removed || deleted) && !isModQueue && <PostMenuDesktop postMenu={postMenuProps} />}
{cid &&
parentCid &&
replies &&
@@ -376,7 +440,19 @@ const Reply = ({ postReplyCount, reply, roles, threadNumber }: PostProps) => {
);
};
const PostDesktop = ({ post, roles, showAllReplies, showReplies = true, targetReplyCid }: PostProps) => {
const PostDesktop = ({
post,
roles,
showAllReplies,
showReplies = true,
targetReplyCid,
isModQueue,
modQueueStatus,
modQueueError,
isPublishing,
onApprove,
onReject,
}: PostProps) => {
const { t } = useTranslation();
const { author, cid, content, deleted, link, linkHeight, linkWidth, pinned, postCid, removed, spoiler, state, subplebbitAddress, thumbnailUrl, parentCid } = post || {};
const params = useParams();
@@ -450,7 +526,7 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true, targetRe
return (
<div className={styles.postDesktop}>
{showReplies ? (
{showReplies || isModQueue ? (
<div className={styles.hrWrapper}>
<hr />
</div>
@@ -485,7 +561,19 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true, targetRe
isInSubscriptionsView={isInSubscriptionsView}
/>
)}
<PostInfo isHidden={hidden} post={post} postReplyCount={replyCount} roles={roles} threadNumber={post?.number} />
<PostInfo
isHidden={hidden}
post={post}
postReplyCount={replyCount}
roles={roles}
threadNumber={post?.number}
isModQueue={isModQueue}
modQueueStatus={modQueueStatus}
modQueueError={modQueueError}
isPublishing={isPublishing}
onApprove={onApprove}
onReject={onReject}
/>
{!isHidden && !content && !(deleted || removed) && <div className={styles.spacer} />}
{!isHidden && <CommentContent comment={post} />}
</div>
@@ -18,6 +18,18 @@
margin-top: -3px;
}
.modQueueTitle {
font-size: 1.5em;
font-weight: bold;
padding: 10px 0;
}
@media (min-width: 640px) {
.modQueueTitle {
font-size: 1em;
}
}
.closed {
font-size: x-large;
text-align: center;
+8 -3
View File
@@ -8,7 +8,7 @@ import useSubplebbitsPagesStore from '@plebbit/plebbit-react-hooks/dist/stores/s
import { getHasThumbnail, getLinkMediaInfo } from '../../lib/utils/media-utils';
import { formatMarkdown } from '../../lib/utils/post-utils';
import { isValidURL } from '../../lib/utils/url-utils';
import { isAllView, isCatalogView, isModView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
import { isAllView, isCatalogView, isModQueueView, isModView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
import { useDefaultSubplebbits } from '../../hooks/use-default-subplebbits';
import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address';
import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
@@ -373,6 +373,7 @@ const PostForm = () => {
const isInPostView = isPostPageView(location.pathname, params);
const isInAllView = isAllView(location.pathname);
const isInModView = isModView(location.pathname);
const isInModQueueView = isModQueueView(location.pathname);
const isInSubscriptionsView = isSubscriptionsView(location.pathname, params);
const isInCatalogView = isCatalogView(location.pathname, params);
@@ -398,7 +399,9 @@ const PostForm = () => {
<>
<div className={styles.postFormDesktop}>
{!(isInAllView || isInSubscriptionsView || isInModView) && showForm && <OfflineAlert subplebbitAddress={subplebbitAddress} />}
{isThreadClosed ? (
{isInModQueueView ? (
<div className={styles.modQueueTitle}>{t('moderation_queue')}</div>
) : isThreadClosed ? (
<div className={styles.closed}>
{t('thread_closed')}
<br />
@@ -418,7 +421,9 @@ const PostForm = () => {
</div>
<div className={styles.postFormMobile}>
{!(isInAllView || isInSubscriptionsView || isInModView) && showForm && <OfflineAlert subplebbitAddress={subplebbitAddress} />}
{isThreadClosed ? (
{isInModQueueView ? (
<div className={styles.modQueueTitle}>{t('moderation_queue')}</div>
) : isThreadClosed ? (
<div className={styles.closed}>
{t('thread_closed')}
<br />
+66 -9
View File
@@ -9,7 +9,8 @@ import { shouldShowSnow } from '../../lib/snow';
import { getHasThumbnail } from '../../lib/utils/media-utils';
import { getTextColorForBackground, hashStringToColor } from '../../lib/utils/post-utils';
import { getFormattedDate, getFormattedTimeAgo } from '../../lib/utils/time-utils';
import { isAllView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
import { isAllView, isModQueueView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
import useModQueueStore from '../../stores/use-mod-queue-store';
import { useDefaultSubplebbits } from '../../hooks/use-default-subplebbits';
import { getBoardPath } from '../../lib/utils/route-utils';
import useAvatarVisibilityStore from '../../stores/use-avatar-visibility-store';
@@ -52,10 +53,21 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos
const isInAllView = isAllView(location.pathname);
const isInPostPageView = isPostPageView(location.pathname, params);
const isInSubscriptionsView = isSubscriptionsView(location.pathname, params);
const isInModQueueView = isModQueueView(location.pathname);
const { getAlertThresholdSeconds } = useModQueueStore();
const commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
// Check if post is awaiting approval and over threshold (for mod queue view)
const approved = post?.approved;
const alreadyApproved = approved === true;
const alreadyRejected = removed === true;
const isAwaitingApproval = isInModQueueView && !alreadyApproved && !alreadyRejected;
const timeWaiting = timestamp ? Date.now() / 1000 - timestamp : 0;
const alertThresholdSeconds = getAlertThresholdSeconds();
const isOverThreshold = isAwaitingApproval && timeWaiting > alertThresholdSeconds;
const stateString = useStateString(post);
const postMenuProps = useMemo(() => selectPostMenuProps(post), [post]);
@@ -176,7 +188,14 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos
<Link to={`/${boardPath}`}>Board: {boardPath}</Link>
</div>
)}
<Tooltip children={<span>{getFormattedDate(timestamp)}</span>} content={getFormattedTimeAgo(timestamp)} />{' '}
{isInModQueueView && isOverThreshold ? (
<>
<Tooltip children={<span>{getFormattedDate(timestamp)}</span>} content={getFormattedTimeAgo(timestamp)} /> (
<span className={styles.alert}>{getFormattedTimeAgo(timestamp)}</span>)
</>
) : (
<Tooltip children={<span>{getFormattedDate(timestamp)}</span>} content={getFormattedTimeAgo(timestamp)} />
)}{' '}
{cid ? (
<span className={styles.postNumLink}>
<Link
@@ -283,7 +302,19 @@ const Reply = ({ postReplyCount, reply, roles, threadNumber }: PostProps) => {
);
};
const PostMobile = ({ post, roles, showAllReplies, showReplies = true, targetReplyCid }: PostProps) => {
const PostMobile = ({
post,
roles,
showAllReplies,
showReplies = true,
targetReplyCid,
isModQueue,
modQueueStatus,
modQueueError,
isPublishing,
onApprove,
onReject,
}: PostProps) => {
const { t } = useTranslation();
const { author, cid, pinned, postCid, replyCount, state, subplebbitAddress } = post || {};
const params = useParams();
@@ -356,12 +387,12 @@ const PostMobile = ({ post, roles, showAllReplies, showReplies = true, targetRep
</>
) : (
<div className={styles.postMobile}>
{showReplies && (
{(showReplies || isModQueue) && (
<div className={styles.hrWrapper}>
<hr />
</div>
)}
<div className={showReplies ? styles.thread : styles.quotePreview}>
<div className={showReplies || isModQueue ? styles.thread : styles.quotePreview}>
<div className={styles.postContainer}>
<div
className={`${styles.postOp} ${shouldShowSnow() ? styles.xmasHatWrapper : ''}`}
@@ -373,15 +404,41 @@ const PostMobile = ({ post, roles, showAllReplies, showReplies = true, targetRep
<PostInfoAndMedia post={post} postReplyCount={replyCount} roles={roles} threadNumber={post?.number} />
<CommentContent comment={post} />
</div>
{!isInPostView && !isInPendingPostView && showReplies && (
{!isInPostView && !isInPendingPostView && (showReplies || isModQueue) && (
<div className={styles.postLink}>
<span className={styles.info}>
{replyCount > 0 && `${replyCount} Replies`}
{linksCount > 0 && ` / ${linksCount} Links`}
</span>
<Link to={boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`} className='button'>
{t('view_thread')}
</Link>
{isModQueue ? (
<div className={styles.modQueueActions}>
{modQueueStatus === 'approved' ? (
<span className={styles.modQueueStatusApproved}>{t('approved')}</span>
) : modQueueStatus === 'rejected' ? (
<span className={styles.modQueueStatusRejected}>{t('rejected')}</span>
) : modQueueStatus === 'failed' ? (
<span className={styles.modQueueStatusRejected}>
{t('failed')}
{modQueueError ? `: ${modQueueError}` : ''}
</span>
) : isPublishing ? (
<LoadingEllipsis string={t('publishing')} />
) : (
<>
<button className={`button ${styles.approveButton}`} onClick={onApprove} disabled={isPublishing}>
{t('approve')}
</button>
<button className={`button ${styles.rejectButton}`} onClick={onReject} disabled={isPublishing}>
{t('reject')}
</button>
</>
)}
</div>
) : (
<Link to={boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`} className='button'>
{t('view_thread')}
</Link>
)}
</div>
)}
</div>