style(mod queue): add missing footer, improve mobile UI

This commit is contained in:
plebeius
2026-03-07 18:50:24 +08:00
parent 76b3e97a31
commit 38614e39e9
5 changed files with 268 additions and 150 deletions
+17
View File
@@ -39,6 +39,23 @@ export const PageFooterDesktop = ({ firstRow, styleRow }: PageFooterDesktopProps
</footer> </footer>
); );
/* -----------------------------------------------------------------------------
* StyleOnlyFooterFirstRow
* Footer first row with only the style selector (right-aligned). Used by mod queue.
* -------------------------------------------------------------------------- */
export const StyleOnlyFooterFirstRow = () => {
const { t } = useTranslation();
return (
<div className={`${styles.footerRow} ${styles.footerRowRightOnly}`}>
<div className={styles.footerRight}>
<span className={styles.styleLabel}>{t('style')}:</span>
<StyleSelector />
</div>
</div>
);
};
/* ----------------------------------------------------------------------------- /* -----------------------------------------------------------------------------
* CatalogFooterFirstRow * CatalogFooterFirstRow
* Catalog footer first row: Return, Archive, Top, Refresh on left; Style selector on right. * Catalog footer first row: Return, Archive, Top, Refresh on left; Style selector on right.
+9 -1
View File
@@ -1,2 +1,10 @@
export { PageFooterDesktop, PageFooterMobile, CatalogFooterFirstRow, ThreadFooterFirstRow, ThreadFooterStyleRow, ThreadFooterMobile } from './footer'; export {
PageFooterDesktop,
PageFooterMobile,
StyleOnlyFooterFirstRow,
CatalogFooterFirstRow,
ThreadFooterFirstRow,
ThreadFooterStyleRow,
ThreadFooterMobile,
} from './footer';
export type { PageFooterDesktopProps, CatalogFooterFirstRowProps, ThreadFooterFirstRowProps, ThreadFooterMobileProps } from './footer'; export type { PageFooterDesktopProps, CatalogFooterFirstRowProps, ThreadFooterFirstRowProps, ThreadFooterMobileProps } from './footer';
@@ -24,6 +24,12 @@
padding: 10px 0; padding: 10px 0;
} }
@media (max-width: 639px) {
.modQueueTitle {
padding-top: 24px;
}
}
@media (min-width: 640px) { @media (min-width: 640px) {
.modQueueTitle { .modQueueTitle {
font-size: 1em; font-size: 1em;
+28 -2
View File
@@ -292,16 +292,31 @@
.cardHeader { .cardHeader {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: baseline;
font-size: 9pt; font-size: 9pt;
font-weight: bold; font-weight: bold;
} }
.cardHeaderLeft {
display: flex;
align-items: baseline;
gap: 0;
min-width: 0;
}
.cardNumber { .cardNumber {
color: var(--body-font-color); color: var(--body-font-color);
flex-shrink: 0;
}
.cardBoardSeparator {
color: var(--body-font-color);
margin: 0 4px;
} }
.cardTime { .cardTime {
white-space: nowrap; white-space: nowrap;
flex-shrink: 0;
} }
.cardContent { .cardContent {
@@ -325,6 +340,17 @@
gap: 8px; gap: 8px;
} }
/* Mobile compact view: match feed view approve/reject button styling */
.cardApproveButton {
background-color: #3fa96b;
color: #fff;
}
.cardRejectButton {
background-color: #d9534f;
color: #fff;
}
/* Desktop: buttons styled as text links (same as board buttons) */ /* Desktop: buttons styled as text links (same as board buttons) */
@media (min-width: 640px) { @media (min-width: 640px) {
.actions { .actions {
@@ -438,7 +464,7 @@ span.reject:hover {
.cardBoard { .cardBoard {
font-size: 8pt; font-size: 8pt;
margin-right: 8px; flex-shrink: 0;
} }
.cardBoard a { .cardBoard a {
@@ -614,7 +640,7 @@ span.reject:hover {
text-decoration: var(--button-text-decoration); text-decoration: var(--button-text-decoration);
display: inline; display: inline;
font-family: inherit; font-family: inherit;
font-size: 8pt; font-size: var(--button-font-size-mobile);
} }
.button:hover { .button:hover {
+87 -26
View File
@@ -24,9 +24,18 @@ import { useCurrentTime } from '../../hooks/use-current-time';
import { Post } from '../post/post'; import { Post } from '../post/post';
import capitalize from 'lodash/capitalize'; import capitalize from 'lodash/capitalize';
import lowerCase from 'lodash/lowerCase'; import lowerCase from 'lodash/lowerCase';
import { PageFooterDesktop, PageFooterMobile, StyleOnlyFooterFirstRow } from '../../components/footer';
import footerStyles from '../../components/footer/footer.module.css';
const { addChallenge } = useChallengesStore.getState(); const { addChallenge } = useChallengesStore.getState();
/** Path for display: directory code, or full address if has TLD, or shortened for long IPNS keys (no dot) */
const getBoardDisplayPath = (address: string, path: string): string => {
if (path !== address) return path;
if (address.includes('.')) return address;
return getShortAddress(address) || address;
};
interface ModQueueViewProps { interface ModQueueViewProps {
boardIdentifier?: string; // If provided, shows queue for single board boardIdentifier?: string; // If provided, shows queue for single board
} }
@@ -54,8 +63,10 @@ interface ModQueueRowProps {
comment: Comment; comment: Comment;
isOdd?: boolean; isOdd?: boolean;
showBoard?: boolean; showBoard?: boolean;
/** Precomputed board path from parent (avoids per-row useBoardPath lookup) */ /** Board path for URLs (directory code or full address) */
boardPath: string | undefined; boardPath: string | undefined;
/** Board path for display (shortened when long IPNS key with no TLD) */
boardDisplayPath: string | undefined;
} }
// Track which action was initiated to show appropriate completion message // Track which action was initiated to show appropriate completion message
@@ -123,10 +134,10 @@ const ModQueueActions = ({ status, errorMessage, isPublishing, handleApprove, ha
</div> </div>
) : ( ) : (
<div className={styles.cardActions}> <div className={styles.cardActions}>
<button className={styles.button} onClick={handleApprove} disabled={isPublishing}> <button className={`button ${styles.cardApproveButton}`} onClick={handleApprove} disabled={isPublishing}>
{t('approve')} {t('approve')}
</button> </button>
<button className={styles.button} onClick={handleReject} disabled={isPublishing}> <button className={`button ${styles.cardRejectButton}`} onClick={handleReject} disabled={isPublishing}>
{t('reject')} {t('reject')}
</button> </button>
</div> </div>
@@ -225,7 +236,7 @@ const useModQueueActions = (comment: Comment): ModQueueActionState => {
return { status, errorMessage, isPublishing, handleApprove, handleReject }; return { status, errorMessage, isPublishing, handleApprove, handleReject };
}; };
const ModQueueRow = memo(({ comment, isOdd = false, showBoard = false, boardPath }: ModQueueRowProps) => { const ModQueueRow = memo(({ comment, isOdd = false, showBoard = false, boardPath, boardDisplayPath }: ModQueueRowProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { getAlertThresholdSeconds } = useModQueueStore(); const { getAlertThresholdSeconds } = useModQueueStore();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -257,13 +268,14 @@ const ModQueueRow = memo(({ comment, isOdd = false, showBoard = false, boardPath
const isReply = !!parentCid; const isReply = !!parentCid;
const commentMediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight); const commentMediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
const hasThumbnail = getHasThumbnail(commentMediaInfo, link); const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
const rawExcerpt = const rawExcerpt = (
(hasTitle && hasContent ? `${title}: ${content}` : null) || (hasTitle && hasContent ? `${title}: ${content}` : null) ||
(hasTitle ? title : null) || (hasTitle ? title : null) ||
(hasContent ? content : null) || (hasContent ? content : null) ||
(hasLink ? link : null) || (hasLink ? link : null) ||
(hasThumbnail ? t('image') : null) || (hasThumbnail ? t('image') : null) ||
t('no_content'); t('no_content')
).trim();
// Only truncate excerpt on desktop, allow wrapping on mobile // Only truncate excerpt on desktop, allow wrapping on mobile
const excerpt = !isMobile && rawExcerpt.length > 101 ? rawExcerpt.slice(0, 98) + '...' : rawExcerpt; const excerpt = !isMobile && rawExcerpt.length > 101 ? rawExcerpt.slice(0, 98) + '...' : rawExcerpt;
const threadTargetCid = threadCid || cid; const threadTargetCid = threadCid || cid;
@@ -274,7 +286,9 @@ const ModQueueRow = memo(({ comment, isOdd = false, showBoard = false, boardPath
return ( return (
<div className={`${styles.row} ${isOdd ? styles.rowOdd : ''}`}> <div className={`${styles.row} ${isOdd ? styles.rowOdd : ''}`}>
<div className={styles.number}>{number ?? 'N/A'}</div> <div className={styles.number}>{number ?? 'N/A'}</div>
{showBoard && <div className={styles.board}>{modQueueUrl ? <Link to={modQueueUrl}>/{boardPath}/</Link> : <span>/{boardPath ?? '—'}/</span>}</div>} {showBoard && (
<div className={styles.board}>{modQueueUrl ? <Link to={modQueueUrl}>/{boardDisplayPath ?? '—'}/</Link> : <span>/{boardDisplayPath ?? '—'}/</span>}</div>
)}
<div className={styles.excerpt}> <div className={styles.excerpt}>
{postUrl ? ( {postUrl ? (
<Link to={postUrl} title={excerpt}> <Link to={postUrl} title={excerpt}>
@@ -329,11 +343,13 @@ ModQueueRow.displayName = 'ModQueueRow';
interface ModQueueCardProps { interface ModQueueCardProps {
comment: Comment; comment: Comment;
showBoard?: boolean; showBoard?: boolean;
/** Precomputed board path from parent (avoids per-row useBoardPath lookup) */ /** Board path for URLs (directory code or full address) */
boardPath: string | undefined; boardPath: string | undefined;
/** Board path for display (shortened when long IPNS key with no TLD) */
boardDisplayPath: string | undefined;
} }
const ModQueueCard = memo(({ comment, showBoard = false, boardPath }: ModQueueCardProps) => { const ModQueueCard = memo(({ comment, showBoard = false, boardPath, boardDisplayPath }: ModQueueCardProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { getAlertThresholdSeconds } = useModQueueStore(); const { getAlertThresholdSeconds } = useModQueueStore();
const currentTime = useCurrentTime(); const currentTime = useCurrentTime();
@@ -359,13 +375,14 @@ const ModQueueCard = memo(({ comment, showBoard = false, boardPath }: ModQueueCa
const isReply = !!parentCid; const isReply = !!parentCid;
const commentMediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight); const commentMediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
const hasThumbnail = getHasThumbnail(commentMediaInfo, link); const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
const rawExcerpt = const rawExcerpt = (
(hasTitle && hasContent ? `${title}: ${content}` : null) || (hasTitle && hasContent ? `${title}: ${content}` : null) ||
(hasTitle ? title : null) || (hasTitle ? title : null) ||
(hasContent ? content : null) || (hasContent ? content : null) ||
(hasLink ? link : null) || (hasLink ? link : null) ||
(hasThumbnail ? t('image') : null) || (hasThumbnail ? t('image') : null) ||
t('no_content'); t('no_content')
).trim();
const excerpt = rawExcerpt.length > 140 ? rawExcerpt.slice(0, 137) + '...' : rawExcerpt; const excerpt = rawExcerpt.length > 140 ? rawExcerpt.slice(0, 137) + '...' : rawExcerpt;
const threadTargetCid = threadCid || cid; const threadTargetCid = threadCid || cid;
const postUrl = boardPath && threadTargetCid ? `/${boardPath}/thread/${threadTargetCid}` : undefined; const postUrl = boardPath && threadTargetCid ? `/${boardPath}/thread/${threadTargetCid}` : undefined;
@@ -375,8 +392,15 @@ const ModQueueCard = memo(({ comment, showBoard = false, boardPath }: ModQueueCa
return ( return (
<div className={styles.mobileCard}> <div className={styles.mobileCard}>
<div className={styles.cardHeader}> <div className={styles.cardHeader}>
<span className={styles.cardHeaderLeft}>
<span className={styles.cardNumber}>No. {number ?? 'N/A'}</span> <span className={styles.cardNumber}>No. {number ?? 'N/A'}</span>
{showBoard && boardPath && <span className={styles.cardBoard}>{modQueueUrl ? <Link to={modQueueUrl}>/{boardPath}/</Link> : <span>/{boardPath}/</span>}</span>} {showBoard && boardPath && (
<>
<span className={styles.cardBoardSeparator}> </span>
<span className={styles.cardBoard}>{modQueueUrl ? <Link to={modQueueUrl}>/{boardDisplayPath}/</Link> : <span>/{boardDisplayPath}/</span>}</span>
</>
)}
</span>
<span className={styles.cardTime}> <span className={styles.cardTime}>
{isAwaitingApproval && isOverThreshold ? ( {isAwaitingApproval && isOverThreshold ? (
<> <>
@@ -792,27 +816,35 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp
const showBoardColumn = !resolvedAddress; const showBoardColumn = !resolvedAddress;
const compactRowItemContent = useCallback( const compactRowItemContent = useCallback(
(index: number, comment: Comment) => ( (index: number, comment: Comment) => {
const path = addressToPathMap.get(comment.subplebbitAddress) ?? getBoardPath(comment.subplebbitAddress, directories);
return (
<ModQueueRow <ModQueueRow
key={comment.cid} key={comment.cid}
comment={comment} comment={comment}
isOdd={index % 2 === 0} isOdd={index % 2 === 0}
showBoard={showBoardColumn} showBoard={showBoardColumn}
boardPath={addressToPathMap.get(comment.subplebbitAddress) ?? comment.subplebbitAddress} boardPath={path}
boardDisplayPath={path ? getBoardDisplayPath(comment.subplebbitAddress, path) : undefined}
/> />
), );
[addressToPathMap, showBoardColumn], },
[addressToPathMap, showBoardColumn, directories],
); );
const compactCardItemContent = useCallback( const compactCardItemContent = useCallback(
(_index: number, comment: Comment) => ( (_index: number, comment: Comment) => {
const path = addressToPathMap.get(comment.subplebbitAddress) ?? getBoardPath(comment.subplebbitAddress, directories);
return (
<ModQueueCard <ModQueueCard
key={comment.cid} key={comment.cid}
comment={comment} comment={comment}
showBoard={showBoardColumn} showBoard={showBoardColumn}
boardPath={addressToPathMap.get(comment.subplebbitAddress) ?? comment.subplebbitAddress} boardPath={path}
boardDisplayPath={path ? getBoardDisplayPath(comment.subplebbitAddress, path) : undefined}
/> />
), );
[addressToPathMap, showBoardColumn], },
[addressToPathMap, showBoardColumn, directories],
); );
const setResetFunction = useFeedResetStore((state) => state.setResetFunction); const setResetFunction = useFeedResetStore((state) => state.setResetFunction);
@@ -838,7 +870,26 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp
[hasMore, subplebbitAddresses, subplebbitError, feed.length], [hasMore, subplebbitAddresses, subplebbitError, feed.length],
); );
const pageFooter = (
<>
<PageFooterDesktop firstRow={<StyleOnlyFooterFirstRow />} />
<PageFooterMobile>
<div>
<div className={footerStyles.mobileFooterButtons}>
<button type='button' className='button' onClick={() => window.scrollTo({ top: 0, left: 0, behavior: 'instant' })}>
{t('top')}
</button>
<button type='button' className='button' onClick={() => reset?.()}>
{t('refresh')}
</button>
</div>
</div>
</PageFooterMobile>
</>
);
return ( return (
<>
<div className={styles.container}> <div className={styles.container}>
{!resolvedAddress && ( {!resolvedAddress && (
<div className={styles.controls}> <div className={styles.controls}>
@@ -876,15 +927,19 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp
/> />
) : ( ) : (
<> <>
{filteredFeed.map((comment, index) => ( {filteredFeed.map((comment, index) => {
const path = addressToPathMap.get(comment.subplebbitAddress) ?? getBoardPath(comment.subplebbitAddress, directories);
return (
<ModQueueRow <ModQueueRow
key={comment.cid} key={comment.cid}
comment={comment} comment={comment}
isOdd={index % 2 === 0} isOdd={index % 2 === 0}
showBoard={showBoardColumn} showBoard={showBoardColumn}
boardPath={addressToPathMap.get(comment.subplebbitAddress) ?? comment.subplebbitAddress} boardPath={path}
boardDisplayPath={path ? getBoardDisplayPath(comment.subplebbitAddress, path) : undefined}
/> />
))} );
})}
{subplebbitError?.message && feed.length === 0 && ( {subplebbitError?.message && feed.length === 0 && (
<div className={styles.error}> <div className={styles.error}>
<ErrorDisplay error={subplebbitError} /> <ErrorDisplay error={subplebbitError} />
@@ -910,14 +965,18 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp
/> />
) : ( ) : (
<> <>
{filteredFeed.map((comment) => ( {filteredFeed.map((comment) => {
const path = addressToPathMap.get(comment.subplebbitAddress) ?? getBoardPath(comment.subplebbitAddress, directories);
return (
<ModQueueCard <ModQueueCard
key={comment.cid} key={comment.cid}
comment={comment} comment={comment}
showBoard={showBoardColumn} showBoard={showBoardColumn}
boardPath={addressToPathMap.get(comment.subplebbitAddress) ?? comment.subplebbitAddress} boardPath={path}
boardDisplayPath={path ? getBoardDisplayPath(comment.subplebbitAddress, path) : undefined}
/> />
))} );
})}
{subplebbitError?.message && feed.length === 0 && ( {subplebbitError?.message && feed.length === 0 && (
<div className={styles.error}> <div className={styles.error}>
<ErrorDisplay error={subplebbitError} /> <ErrorDisplay error={subplebbitError} />
@@ -959,6 +1018,8 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp
</> </>
)} )}
</div> </div>
{pageFooter}
</>
); );
}; };