mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat: implement mod queue
This commit is contained in:
+10
-2
@@ -10,7 +10,7 @@ import useSpecialThemeStore from './stores/use-special-theme-store';
|
||||
import useIsMobile from './hooks/use-is-mobile';
|
||||
import useTheme from './hooks/use-theme';
|
||||
import { useDefaultSubplebbits } from './hooks/use-default-subplebbits';
|
||||
import { getSubplebbitAddress, isPostRoute, isPendingPostRoute } from './lib/utils/route-utils';
|
||||
import { getSubplebbitAddress, isPostRoute, isPendingPostRoute, isModQueueRoute } from './lib/utils/route-utils';
|
||||
import styles from './app.module.css';
|
||||
import FAQ from './views/faq';
|
||||
import Home from './views/home';
|
||||
@@ -18,6 +18,7 @@ import Rules from './views/rules';
|
||||
import NotFound from './views/not-found';
|
||||
import PendingPost from './views/pending-post';
|
||||
import Post from './views/post';
|
||||
import ModQueueView from './views/mod-queue';
|
||||
import { DesktopBoardButtons, MobileBoardButtons } from './components/board-buttons';
|
||||
import BoardHeader from './components/board-header';
|
||||
import ChallengeModal from './components/challenge-modal';
|
||||
@@ -50,6 +51,7 @@ const BoardLayout = () => {
|
||||
|
||||
const isOnPostRoute = isPostRoute(location.pathname);
|
||||
const isOnPendingPostRoute = isPendingPostRoute(location.pathname);
|
||||
const isOnModQueueRoute = isModQueueRoute(location.pathname);
|
||||
|
||||
// Christmas theme
|
||||
const { isEnabled: isSpecialEnabled } = useSpecialThemeStore();
|
||||
@@ -95,7 +97,7 @@ const BoardLayout = () => {
|
||||
</>
|
||||
)}
|
||||
<FeedCacheContainer />
|
||||
{(isOnPostRoute || isOnPendingPostRoute) && <Outlet />}
|
||||
{(isOnPostRoute || isOnPendingPostRoute || isOnModQueueRoute) && <Outlet />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -168,11 +170,17 @@ const App = () => (
|
||||
<Route path='/mod/catalog/:timeFilterName?' element={null} />
|
||||
<Route path='/mod/catalog/:timeFilterName?/settings' element={null} />
|
||||
|
||||
<Route path='/mod/queue' element={<ModQueueView />} />
|
||||
<Route path='/mod/queue/settings' element={<ModQueueView />} />
|
||||
|
||||
<Route path='/:boardIdentifier' element={null} />
|
||||
<Route path='/:boardIdentifier/settings' element={null} />
|
||||
<Route path='/:boardIdentifier/catalog' element={null} />
|
||||
<Route path='/:boardIdentifier/catalog/settings' element={null} />
|
||||
|
||||
<Route path='/:boardIdentifier/queue' element={<ModQueueView />} />
|
||||
<Route path='/:boardIdentifier/queue/settings' element={<ModQueueView />} />
|
||||
|
||||
<Route path='/:boardIdentifier/thread/:commentCid' element={<Post />} />
|
||||
<Route path='/:boardIdentifier/thread/:commentCid/settings' element={<Post />} />
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useAccountComment, useSubscribe } from '@plebbit/plebbit-react-hooks';
|
||||
import useSubplebbitsPagesStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits-pages';
|
||||
import { isAllView, isCatalogView, isModView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
||||
import { isAllView, isCatalogView, isModView, isModQueueView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
||||
import { useDefaultSubplebbits } from '../../hooks/use-default-subplebbits';
|
||||
import { getBoardPath, isDirectoryBoard } from '../../lib/utils/route-utils';
|
||||
import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address';
|
||||
@@ -26,6 +26,7 @@ interface BoardButtonsProps {
|
||||
isInCatalogView?: boolean;
|
||||
isInSubscriptionsView?: boolean;
|
||||
isInModView?: boolean;
|
||||
isInModQueueView?: boolean;
|
||||
isTopbar?: boolean;
|
||||
}
|
||||
|
||||
@@ -72,7 +73,7 @@ const SubscribeButton = ({ address }: BoardButtonsProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const ReturnButton = ({ address, isInAllView, isInSubscriptionsView, isInModView }: BoardButtonsProps) => {
|
||||
const ReturnButton = ({ address, isInAllView, isInSubscriptionsView, isInModView, isInModQueueView }: BoardButtonsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams();
|
||||
const defaultSubplebbits = useDefaultSubplebbits();
|
||||
@@ -84,6 +85,12 @@ const ReturnButton = ({ address, isInAllView, isInSubscriptionsView, isInModView
|
||||
} else if (isInSubscriptionsView) {
|
||||
if (params?.timeFilterName) return `/subs/${params.timeFilterName}`;
|
||||
return `/subs`;
|
||||
} else if (isInModQueueView) {
|
||||
// If in mod queue view, return to /mod or /:boardIdentifier
|
||||
if (params?.boardIdentifier) {
|
||||
return `/${params.boardIdentifier}`;
|
||||
}
|
||||
return `/mod`;
|
||||
} else if (isInModView) {
|
||||
if (params?.timeFilterName) return `/mod/${params.timeFilterName}`;
|
||||
return `/mod`;
|
||||
@@ -309,6 +316,7 @@ export const MobileBoardButtons = () => {
|
||||
const isInPostView = isPostPageView(location.pathname, params);
|
||||
const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams());
|
||||
const isInModView = isModView(location.pathname);
|
||||
const isInModQueueView = isModQueueView(location.pathname);
|
||||
|
||||
const accountComment = useAccountComment({ commentIndex: params?.accountCommentIndex as any });
|
||||
const resolvedAddress = useResolvedSubplebbitAddress();
|
||||
@@ -334,6 +342,17 @@ export const MobileBoardButtons = () => {
|
||||
<AutoButton />
|
||||
</div>
|
||||
</>
|
||||
) : isInModQueueView ? (
|
||||
<>
|
||||
<ReturnButton
|
||||
address={subplebbitAddress}
|
||||
isInAllView={isInAllView}
|
||||
isInSubscriptionsView={isInSubscriptionsView}
|
||||
isInModView={isInModView}
|
||||
isInModQueueView={isInModQueueView}
|
||||
/>
|
||||
<RefreshButton />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isInCatalogView ? (
|
||||
@@ -419,6 +438,7 @@ export const DesktopBoardButtons = () => {
|
||||
const isInPostView = isPostPageView(location.pathname, params);
|
||||
const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams());
|
||||
const isInModView = isModView(location.pathname);
|
||||
const isInModQueueView = isModQueueView(location.pathname);
|
||||
|
||||
const { filteredCount, searchText } = useCatalogFiltersStore();
|
||||
|
||||
@@ -442,6 +462,19 @@ export const DesktopBoardButtons = () => {
|
||||
<PostPageStats />
|
||||
</span>
|
||||
</>
|
||||
) : isInModQueueView ? (
|
||||
<>
|
||||
[
|
||||
<ReturnButton
|
||||
address={subplebbitAddress}
|
||||
isInAllView={isInAllView}
|
||||
isInSubscriptionsView={isInSubscriptionsView}
|
||||
isInModView={isInModView}
|
||||
isInModQueueView={isInModQueueView}
|
||||
/>
|
||||
] [
|
||||
<RefreshButton />]
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isInCatalogView ? (
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useAccountSubplebbits } from '@plebbit/plebbit-react-hooks';
|
||||
import { useMemo } from 'react';
|
||||
import { MultisubSubplebbit } from './use-default-subplebbits';
|
||||
|
||||
export const useAccountSubplebbitsWithMetadata = (): MultisubSubplebbit[] => {
|
||||
const { accountSubplebbits } = useAccountSubplebbits();
|
||||
|
||||
return useMemo(() => {
|
||||
return Object.entries(accountSubplebbits || {}).map(([address, sub]) => ({
|
||||
address,
|
||||
title: (sub as any).title,
|
||||
// We can map other fields if needed
|
||||
}));
|
||||
}, [accountSubplebbits]);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useMemo } from 'react';
|
||||
import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits';
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
isAllView,
|
||||
isBoardView,
|
||||
isCatalogView,
|
||||
isHomeView,
|
||||
isModView,
|
||||
isModQueueView,
|
||||
isPendingPostView,
|
||||
isPostPageView,
|
||||
isSettingsView,
|
||||
isSubscriptionsView,
|
||||
isNotFoundView,
|
||||
ParamsType,
|
||||
} from './view-utils';
|
||||
|
||||
export {
|
||||
isAllView,
|
||||
isBoardView,
|
||||
isCatalogView,
|
||||
isHomeView,
|
||||
isModView,
|
||||
isModQueueView,
|
||||
isPendingPostView,
|
||||
isPostPageView,
|
||||
isSettingsView,
|
||||
isSubscriptionsView,
|
||||
isNotFoundView,
|
||||
};
|
||||
export type { ParamsType };
|
||||
@@ -106,6 +106,7 @@ export const isFeedRoute = (pathname: string): boolean => {
|
||||
|
||||
if (normalizedPath.includes('/thread/')) return false;
|
||||
if (normalizedPath.startsWith('/pending/')) return false;
|
||||
if (normalizedPath.includes('/queue')) return false;
|
||||
|
||||
const pathWithoutSettings = normalizedPath.replace(/\/settings$/, '');
|
||||
|
||||
@@ -137,6 +138,11 @@ export const isPendingPostRoute = (pathname: string): boolean => {
|
||||
return normalizedPath.startsWith('/pending/');
|
||||
};
|
||||
|
||||
export const isModQueueRoute = (pathname: string): boolean => {
|
||||
const normalizedPath = pathname.replace(/\/settings$/, '');
|
||||
return normalizedPath.includes('/queue');
|
||||
};
|
||||
|
||||
export const getFeedCacheKey = (pathname: string): string | null => {
|
||||
let normalizedPath = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||
normalizedPath = normalizedPath.replace(/\/settings$/, '');
|
||||
@@ -150,6 +156,10 @@ export const getFeedCacheKey = (pathname: string): string | null => {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (normalizedPath.includes('/queue')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isFeedRoute(pathname)) {
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,10 @@ export const isModView = (pathname: string): boolean => {
|
||||
return pathname === `/mod` || pathname.startsWith(`/mod/`);
|
||||
};
|
||||
|
||||
export const isModQueueView = (pathname: string): boolean => {
|
||||
return pathname.includes('/queue');
|
||||
};
|
||||
|
||||
export const isPendingPostView = (pathname: string, params: ParamsType): boolean => {
|
||||
return pathname === `/pending/${params.accountCommentIndex}` || pathname === `/pending/${params.accountCommentIndex}/settings`;
|
||||
};
|
||||
@@ -101,6 +105,7 @@ export const isNotFoundView = (pathname: string, params: ParamsType): boolean =>
|
||||
!isPendingPostView(pathname, params) &&
|
||||
!isPostPageView(pathname, params) &&
|
||||
!isSettingsView(pathname, params) &&
|
||||
!isSubscriptionsView(pathname, params)
|
||||
!isSubscriptionsView(pathname, params) &&
|
||||
!isModQueueView(pathname)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface ModQueueState {
|
||||
alertThresholdHours: number;
|
||||
selectedBoardFilter: string | null;
|
||||
setAlertThresholdHours: (hours: number) => void;
|
||||
setSelectedBoardFilter: (boardAddress: string | null) => void;
|
||||
}
|
||||
|
||||
const useModQueueStore = create<ModQueueState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
alertThresholdHours: 6,
|
||||
selectedBoardFilter: null,
|
||||
setAlertThresholdHours: (hours) => set({ alertThresholdHours: hours }),
|
||||
setSelectedBoardFilter: (boardAddress) => set({ selectedBoardFilter: boardAddress }),
|
||||
}),
|
||||
{
|
||||
name: 'mod-queue-storage',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export default useModQueueStore;
|
||||
@@ -0,0 +1,3 @@
|
||||
import ModQueueView from './mod-queue';
|
||||
|
||||
export default ModQueueView;
|
||||
@@ -0,0 +1,252 @@
|
||||
.container {
|
||||
padding: 10px;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.alertThresholdSetting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.alertThresholdSetting label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.alertThresholdInput {
|
||||
width: 50px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--input-background-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
display: flex;
|
||||
padding: 8px;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
background: var(--header-background-color);
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.boardHeader {
|
||||
width: 50px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.excerptHeader {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.timeHeader {
|
||||
width: 80px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.actionsHeader {
|
||||
width: 150px;
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
/* ModQueueRow styles */
|
||||
.row {
|
||||
display: flex;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background-color: var(--post-background-color);
|
||||
color: var(--text-color);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background-color: var(--post-highlight-color);
|
||||
}
|
||||
|
||||
.board {
|
||||
width: 50px;
|
||||
flex-shrink: 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.excerpt {
|
||||
flex-grow: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.excerpt a {
|
||||
text-decoration: none;
|
||||
color: var(--link-color);
|
||||
}
|
||||
|
||||
.excerpt a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.time {
|
||||
width: 80px;
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.time.alert {
|
||||
color: red;
|
||||
font-weight: bold;
|
||||
animation: blink 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.actions {
|
||||
width: 150px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.button {
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--button-background);
|
||||
color: var(--button-text);
|
||||
font-size: 11px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: var(--button-hover-background);
|
||||
}
|
||||
|
||||
.approve {
|
||||
color: green;
|
||||
}
|
||||
|
||||
.reject {
|
||||
color: red;
|
||||
}
|
||||
|
||||
/* ModQueueBoardFilter styles */
|
||||
.filterContainer {
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
padding: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--input-background-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* ModQueueButton styles */
|
||||
.modQueueButton {
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 8px;
|
||||
text-decoration: none;
|
||||
color: var(--text-color);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.modQueueButton:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.modQueueButtonCount {
|
||||
margin-left: 4px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.modQueueButtonAlert {
|
||||
color: red;
|
||||
animation: blink 2s infinite;
|
||||
}
|
||||
|
||||
/* Mobile Layout */
|
||||
@media (max-width: 600px) {
|
||||
.tableHeader {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
flex-wrap: wrap;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.board {
|
||||
width: auto;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.time {
|
||||
margin-left: auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.excerpt {
|
||||
width: 100%;
|
||||
margin: 4px 0;
|
||||
white-space: normal;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.button {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
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 { 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 { 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 useFeedResetStore from '../../stores/use-feed-reset-store';
|
||||
|
||||
interface ModQueueViewProps {
|
||||
boardIdentifier?: string; // If provided, shows queue for single board
|
||||
}
|
||||
|
||||
interface ModQueueRowProps {
|
||||
comment: Comment;
|
||||
showBoardColumn?: boolean;
|
||||
}
|
||||
|
||||
const ModQueueRow = ({ comment, showBoardColumn = false }: ModQueueRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { alertThresholdHours } = useModQueueStore();
|
||||
const [isModerating, setIsModerating] = useState(false);
|
||||
|
||||
const { content, title, timestamp, subplebbitAddress, cid, threadCid, link, thumbnailUrl, linkWidth, linkHeight } = comment;
|
||||
|
||||
const boardPath = useBoardPath(subplebbitAddress);
|
||||
const timeWaiting = Date.now() / 1000 - timestamp;
|
||||
const isOverThreshold = timeWaiting > alertThresholdHours * 3600;
|
||||
|
||||
const { publishCommentModeration: approve } = usePublishCommentModeration({
|
||||
commentCid: cid,
|
||||
subplebbitAddress,
|
||||
commentModeration: { approved: true },
|
||||
});
|
||||
|
||||
const { publishCommentModeration: reject } = usePublishCommentModeration({
|
||||
commentCid: cid,
|
||||
subplebbitAddress,
|
||||
commentModeration: { removed: true },
|
||||
});
|
||||
|
||||
const handleApprove = async () => {
|
||||
try {
|
||||
setIsModerating(true);
|
||||
await approve();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setIsModerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
try {
|
||||
setIsModerating(true);
|
||||
await reject();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setIsModerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isModerating) {
|
||||
return null; // Or show a loading state / fade out
|
||||
}
|
||||
|
||||
const excerpt = title || content || (getHasThumbnail(getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight), link) ? t('image') : t('no_content'));
|
||||
const postUrl = `/${boardPath}/thread/${threadCid || cid}`;
|
||||
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
{showBoardColumn && (
|
||||
<div className={styles.board}>
|
||||
<Link to={`/${boardPath}`}>/{boardPath}/</Link>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.excerpt}>
|
||||
<Link to={postUrl} title={excerpt}>
|
||||
{excerpt}
|
||||
</Link>
|
||||
</div>
|
||||
<div className={`${styles.time} ${isOverThreshold ? styles.alert : ''}`}>{formatDistanceToNow(timestamp * 1000, { addSuffix: false })}</div>
|
||||
<div className={styles.actions}>
|
||||
<button className={`${styles.button} ${styles.approve}`} onClick={handleApprove}>
|
||||
{t('approve')}
|
||||
</button>
|
||||
<button className={`${styles.button} ${styles.reject}`} onClick={handleReject}>
|
||||
{t('reject')}
|
||||
</button>
|
||||
<Link to={postUrl} className={styles.button}>
|
||||
{t('view')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ModQueueBoardFilterProps {
|
||||
subplebbits: MultisubSubplebbit[];
|
||||
}
|
||||
|
||||
const ModQueueBoardFilter = ({ subplebbits }: ModQueueBoardFilterProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { selectedBoardFilter, setSelectedBoardFilter } = useModQueueStore();
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = e.target.value;
|
||||
setSelectedBoardFilter(value === '' ? null : value);
|
||||
};
|
||||
|
||||
if (!subplebbits || subplebbits.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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>
|
||||
{subplebbits.map((sub) => {
|
||||
const address = sub.address;
|
||||
if (!address) return null;
|
||||
return (
|
||||
<option key={address} value={address}>
|
||||
/{getBoardPath(address, subplebbits)}/
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ModQueueButtonProps {
|
||||
boardIdentifier?: string;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { alertThresholdHours } = useModQueueStore();
|
||||
const { accountSubplebbits } = useAccountSubplebbits();
|
||||
const accountSubplebbitAddresses = useMemo(() => Object.keys(accountSubplebbits || {}), [accountSubplebbits]);
|
||||
|
||||
const subplebbitAddresses = useMemo(() => {
|
||||
if (boardIdentifier) {
|
||||
return [boardIdentifier];
|
||||
}
|
||||
return accountSubplebbitAddresses;
|
||||
}, [boardIdentifier, accountSubplebbitAddresses]);
|
||||
|
||||
// If specific board, check if user is mod
|
||||
const isModOfBoard = boardIdentifier ? accountSubplebbitAddresses.includes(boardIdentifier) : 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;
|
||||
}
|
||||
|
||||
const count = feed.length;
|
||||
|
||||
const hasAlert = feed.some((item) => {
|
||||
const timeWaiting = Date.now() / 1000 - item.timestamp;
|
||||
return timeWaiting > alertThresholdHours * 3600;
|
||||
});
|
||||
|
||||
const to = boardIdentifier ? `/${boardIdentifier}/queue` : '/mod/queue';
|
||||
|
||||
if (count === 0) {
|
||||
// If we are on a specific board and the user is a mod, we still show the button
|
||||
// If we are in global mod view, we show it too
|
||||
return (
|
||||
<Link to={to} className={styles.modQueueButton}>
|
||||
[{t('mod_queue')}]
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={to} className={`${styles.modQueueButton} ${hasAlert ? styles.modQueueButtonAlert : ''}`}>
|
||||
[{t('mod_queue')} <span className={styles.modQueueButtonCount}>{count}</span>]
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
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);
|
||||
const defaultSubplebbits = useDefaultSubplebbits();
|
||||
|
||||
const boardIdentifier = propBoardIdentifier || params.boardIdentifier;
|
||||
|
||||
// Resolve boardIdentifier to address if it exists
|
||||
const resolvedAddress = useMemo(() => {
|
||||
if (boardIdentifier) {
|
||||
return getSubplebbitAddress(boardIdentifier, defaultSubplebbits);
|
||||
}
|
||||
return undefined;
|
||||
}, [boardIdentifier, defaultSubplebbits]);
|
||||
|
||||
// Get metadata for filter dropdown
|
||||
const subplebbitsWithMetadata = useAccountSubplebbitsWithMetadata();
|
||||
|
||||
const subplebbitAddresses = useMemo(() => {
|
||||
if (resolvedAddress) {
|
||||
return [resolvedAddress];
|
||||
}
|
||||
|
||||
if (selectedBoardFilter) {
|
||||
return [selectedBoardFilter];
|
||||
}
|
||||
|
||||
return accountSubplebbitAddresses;
|
||||
}, [resolvedAddress, selectedBoardFilter, accountSubplebbitAddresses]);
|
||||
|
||||
const { feed, hasMore, loadMore, reset } = useFeed({
|
||||
subplebbitAddresses,
|
||||
modQueue: ['pendingApproval'],
|
||||
postsPerPage: 50,
|
||||
});
|
||||
|
||||
// Register reset function with feed reset store so refresh button works
|
||||
const setResetFunction = useFeedResetStore((state) => state.setResetFunction);
|
||||
useEffect(() => {
|
||||
setResetFunction(reset);
|
||||
}, [reset, setResetFunction]);
|
||||
|
||||
const loadingStateString = useFeedStateString(subplebbitAddresses) || t('loading');
|
||||
const showBoardColumn = !resolvedAddress && !selectedBoardFilter;
|
||||
|
||||
const Footer = () => {
|
||||
return hasMore ? (
|
||||
<div style={{ padding: '10px', textAlign: 'center' }}>
|
||||
<LoadingEllipsis string={loadingStateString} />
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!resolvedAddress && <ModQueueBoardFilter subplebbits={subplebbitsWithMetadata} />}
|
||||
|
||||
{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.excerptHeader}>{t('excerpt')}</div>
|
||||
<div className={styles.timeHeader}>{t('waiting')}</div>
|
||||
<div className={styles.actionsHeader}>{t('actions')}</div>
|
||||
</div>
|
||||
|
||||
<Virtuoso
|
||||
useWindowScroll
|
||||
data={feed}
|
||||
totalCount={feed.length}
|
||||
endReached={loadMore}
|
||||
itemContent={(index, comment) => <ModQueueRow key={comment.cid} comment={comment} showBoardColumn={showBoardColumn} />}
|
||||
components={{ Footer }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModQueueView;
|
||||
Reference in New Issue
Block a user