feat(home): redesign boards list to match 4chan with filtering and catalog support

This commit is contained in:
plebeius
2025-11-05 19:50:31 +01:00
parent e40eb5312b
commit a9690ac0be
7 changed files with 464 additions and 173 deletions
+1 -1
View File
@@ -190,7 +190,7 @@
"show_all_boards": "Show All Boards",
"show_nsfw_content_only": "Show NSFW Content Only",
"show_nsfw_boards_only": "Show NSFW Boards Only",
"use_catalog": "use catalog",
"use_catalog": "Use Catalog",
"show_worksafe_boards_only": "Show Worksafe Boards Only",
"show_all_content": "Show All Content",
"link_type": "Link type",
+51
View File
@@ -0,0 +1,51 @@
import { create } from 'zustand';
export type BoardFilter = 'all' | 'nsfw' | 'worksafe';
interface BoardsFilterStore {
useCatalogLinks: boolean;
setUseCatalogLinks: (value: boolean) => void;
boardFilter: BoardFilter;
setBoardFilter: (value: BoardFilter) => void;
}
const getStoredUseCatalogLinks = (): boolean => {
try {
const stored = localStorage.getItem('5chan-boards-use-catalog');
return stored === 'true';
} catch {
return false;
}
};
const getStoredBoardFilter = (): BoardFilter => {
try {
const stored = localStorage.getItem('5chan-boards-filter') as BoardFilter;
return stored === 'nsfw' || stored === 'worksafe' ? stored : 'all';
} catch {
return 'all';
}
};
const useBoardsFilterStore = create<BoardsFilterStore>((set) => ({
useCatalogLinks: getStoredUseCatalogLinks(),
setUseCatalogLinks: (value: boolean) => {
set({ useCatalogLinks: value });
try {
localStorage.setItem('5chan-boards-use-catalog', value.toString());
} catch (error) {
console.warn('Failed to save useCatalogLinks to localStorage:', error);
}
},
boardFilter: getStoredBoardFilter(),
setBoardFilter: (value: BoardFilter) => {
set({ boardFilter: value });
try {
localStorage.setItem('5chan-boards-filter', value);
} catch (error) {
console.warn('Failed to save boardFilter to localStorage:', error);
}
},
}));
export default useBoardsFilterStore;
+1 -1
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { NavigateFunction } from 'react-router-dom';
const DISCLAIMER_ACCEPTED_KEY = '5chan-disclaimer-accepted';
export const DISCLAIMER_ACCEPTED_KEY = '5chan-disclaimer-accepted';
interface DisclaimerModalState {
showModal: boolean;
@@ -0,0 +1,99 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import useBoardsFilterStore from '../../../stores/use-boards-filter-store';
import { DISCLAIMER_ACCEPTED_KEY } from '../../../stores/use-disclaimer-modal-store';
import styles from '../home.module.css';
const BoardsFilterModal = () => {
const { t } = useTranslation();
const [showFilterModal, setShowFilterModal] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLSpanElement>(null);
const { useCatalogLinks, setUseCatalogLinks, boardFilter, setBoardFilter } = useBoardsFilterStore();
// Check if disclaimer has been accepted
const hasAcceptedDisclaimer = (): boolean => {
try {
return localStorage.getItem(DISCLAIMER_ACCEPTED_KEY) === 'true';
} catch {
return false;
}
};
const disclaimerAccepted = hasAcceptedDisclaimer();
const handleClickOutside = useCallback(
(event: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(event.target as Node) && buttonRef.current && !buttonRef.current.contains(event.target as Node)) {
setShowFilterModal(false);
}
},
[modalRef, buttonRef, setShowFilterModal],
);
useEffect(() => {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [handleClickOutside]);
return (
<>
<span ref={buttonRef} onClick={() => !showFilterModal && setShowFilterModal(true)}>
{t('filter')}
</span>
{showFilterModal && (
<div ref={modalRef} className={styles.filterModal}>
{/* Always shown: Use Catalog */}
<div
className={`${styles.option} ${useCatalogLinks && styles.selected}`}
onClick={() => {
setUseCatalogLinks(!useCatalogLinks);
setShowFilterModal(false);
}}
>
{t('use_catalog')}
</div>
{/* Conditionally shown: Filtering options (only after disclaimer accepted) */}
{disclaimerAccepted && (
<>
<div className={styles.separator} />
<div
className={`${styles.option} ${boardFilter === 'all' && styles.selected}`}
onClick={() => {
setBoardFilter('all');
setShowFilterModal(false);
}}
>
{t('show_all_boards')}
</div>
<div
className={`${styles.option} ${boardFilter === 'nsfw' && styles.selected}`}
onClick={() => {
setBoardFilter('nsfw');
setShowFilterModal(false);
}}
>
{t('show_nsfw_boards_only')}
</div>
<div
className={`${styles.option} ${boardFilter === 'worksafe' && styles.selected}`}
onClick={() => {
setBoardFilter('worksafe');
setShowFilterModal(false);
}}
>
{t('show_worksafe_boards_only')}
</div>
</>
)}
</div>
)}
</>
);
};
export default BoardsFilterModal;
@@ -1,104 +0,0 @@
.boardsBox {
overflow: hidden;
min-height: 150px;
}
.boardsBox table {
display: table;
margin-bottom: 10px;
overflow: hidden;
table-layout: fixed;
width: calc(100% + 4px);
margin-left: -2px;
border-collapse: separate;
border-spacing: 2px 0;
}
.boardsBox table thead th {
background: #fca;
color: #800;
border: 1px solid #800;
padding: 4px 15px 5px 5px;
text-align: left;
white-space: nowrap;
text-transform: capitalize;
}
.boardsBox table tbody td {
margin: 0;
padding: 4px 15px 4px 4px;
text-align: left;
}
.boardsBox table tbody tr:nth-of-type( even ) {
background: #ede2d4;
}
.boardCell {
position: static;
margin: 0;
padding: 0;
box-sizing: border-box;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.offlineIconContainer {
float: right;
}
.offlineIcon {
vertical-align: text-top;
width: 13px;
height: 13px;
background-repeat: no-repeat;
background-size: contain;
image-rendering: pixelated;
display: inline-block;
position: relative;
}
.boardCell a {
color: inherit;
}
.loadingBoardCellValue {
color: var(--topbar-separator-color);
}
.loadMoreButton {
text-align: center;
margin-bottom: 14px;
color: var(--topbar-separator-color);
}
.displayCount {
font-size: 12px;
text-align: center;
margin-bottom: 10px;
text-transform: lowercase;
}
.loadMoreButton span {
color: var(--topbar-desktop-link-text-color);
text-transform: capitalize;
}
.loadMoreButton span:hover {
cursor: pointer;
color: var(--button-desktop-text-color-hover);
text-decoration: underline;
}
.nsfw {
color: red;
font-weight: 700;
text-transform: uppercase;
font-size: smaller;
}
.boardPPH {
width: 33px;
}
+239 -66
View File
@@ -1,100 +1,273 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import Plebbit from '@plebbit/plebbit-js';
import { useDefaultSubplebbitsState, MultisubSubplebbit } from '../../../hooks/use-default-subplebbits';
import useIsMobile from '../../../hooks/use-is-mobile';
import LoadingEllipsis from '../../../components/loading-ellipsis';
import useDisclaimerModalStore from '../../../stores/use-disclaimer-modal-store';
import styles from './boards-list.module.css';
import useBoardsFilterStore from '../../../stores/use-boards-filter-store';
import BoardsFilterModal from './boards-filter-modal';
import styles from '../home.module.css';
const Board = ({ subplebbit, isMobile }: { subplebbit: MultisubSubplebbit; isMobile: boolean }) => {
const { t } = useTranslation();
const { address, title, nsfw } = subplebbit || {};
const boardTitle = title?.replace(/^\/[^/]+\/\s*-\s*/, '') || '';
const displayAddress = address && Plebbit.getShortAddress(address);
const navigate = useNavigate();
const { showDisclaimerModal } = useDisclaimerModalStore();
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
if (address) {
showDisclaimerModal(address, navigate);
}
};
// Helper function to find board address by matching title pattern
const findBoardAddress = (multisub: MultisubSubplebbit[], titlePattern: string): string | null => {
const entry = multisub.find((ms) => ms.title === titlePattern);
return entry?.address || null;
};
const NSFWBadge = () => {
return (
<tr key={address}>
<td>
<p className={styles.boardCell}>
<Link to={`/p/${address}`} onClick={handleLinkClick}>
{displayAddress}
</Link>
{nsfw && <span className={styles.nsfw}> ({t('nsfw')})</span>}
</p>
</td>
<td>
<p className={styles.boardCell}>{boardTitle || displayAddress}</p>
</td>
</tr>
<>
&nbsp;
<h3 className={styles.nsfwBadge}>
<span title='Not Safe For Work'>
<sup>(NSFW)</sup>
</span>
</h3>
</>
);
};
const BoardsList = ({ multisub }: { multisub: MultisubSubplebbit[] }) => {
const { t } = useTranslation();
const [displayCount, setDisplayCount] = useState(15);
const isMobile = useIsMobile();
const navigate = useNavigate();
const { loading, error } = useDefaultSubplebbitsState();
const { showDisclaimerModal } = useDisclaimerModalStore();
const { useCatalogLinks, boardFilter } = useBoardsFilterStore();
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>, address: string) => {
e.preventDefault();
showDisclaimerModal(address, navigate);
};
// Helper to generate link URL with optional catalog suffix
const getBoardLink = (address: string | null): string => {
if (!address) return '#';
return `/p/${address}${useCatalogLinks ? '/catalog' : ''}`;
};
if (loading) {
return (
<div className={styles.boardsBox}>
<span className={styles.loading}>
<div className={styles.box}>
<div className={`${styles.boxBar} ${styles.color2ColorBar}`}>
<h2 className='capitalize'>{t('boards')}</h2>
<BoardsFilterModal />
</div>
<div className={styles.boxContent}>
<LoadingEllipsis string={t('loading_default_boards')} />
</span>
</div>
</div>
);
}
if (error) {
return (
<div className={styles.boardsBox}>
<div className='red'>{error.message}</div>
<div className={styles.box}>
<div className={`${styles.boxBar} ${styles.color2ColorBar}`}>
<h2 className='capitalize'>{t('boards')}</h2>
<BoardsFilterModal />
</div>
<div className={styles.boxContent}>
<div className='red'>{error.message}</div>
</div>
</div>
);
}
const filteredBoards = multisub.slice(0, displayCount);
const totalBoardCount = multisub.length;
const hasMoreBoards = displayCount < multisub.length;
// Find active boards
const bizAddress = findBoardAddress(multisub, '/biz/ - Business & Finance');
const polAddress = findBoardAddress(multisub, '/pol/ - Politically Incorrect');
// Filtering logic: determine which categories to show
const showAll = boardFilter === 'all';
const showNsfwOnly = boardFilter === 'nsfw';
const showWorksafeOnly = boardFilter === 'worksafe';
// Category visibility based on filter
const showJapaneseCulture = showAll || showWorksafeOnly;
const showVideoGames = showAll || showWorksafeOnly;
const showInterests = showAll || showWorksafeOnly;
const showCreative = showAll || showWorksafeOnly;
const showOther = showAll || showWorksafeOnly;
const showMisc = showAll || showNsfwOnly;
const showAdult = showAll || showNsfwOnly;
return (
<div className={styles.boardsBox}>
<table className={styles.boardsList}>
<colgroup>
<col className={styles.boardAddress} />
<col className={styles.boardTitle} />
</colgroup>
<thead>
<tr>
<th>{t('board')}</th>
<th>{t('title')}</th>
</tr>
</thead>
<tbody>
{filteredBoards.map((sub) => (
<Board key={sub.address} subplebbit={sub} isMobile={isMobile} />
))}
</tbody>
</table>
<div className={styles.displayCount}>
{t('displaying_boards', { filteredBoardsCount: filteredBoards.length, totalBoardCount: totalBoardCount, interpolation: { escapeValue: false } })}
<div className={styles.box}>
<div className={`${styles.boxBar} ${styles.color2ColorBar}`}>
<h2 className='capitalize'>{t('boards')}</h2>
<BoardsFilterModal />
</div>
<div className={`${styles.boxContent} ${styles.boardsContent}`}>
{/* Japanese Culture */}
{showJapaneseCulture && (
<div className={styles.boardsColumn}>
<h3>Japanese Culture</h3>
<ul>
{/* Placeholder boards */}
<li>
<Link to='#' onClick={(e) => e.preventDefault()}>
Anime & Manga
</Link>
</li>
<li>
<Link to='#' onClick={(e) => e.preventDefault()}>
Anime/Cute
</Link>
</li>
<li>
<Link to='#' onClick={(e) => e.preventDefault()}>
Anime/Wallpapers
</Link>
</li>
</ul>
</div>
)}
{/* Video Games */}
{showVideoGames && (
<div className={styles.boardsColumn}>
<h3>Video Games</h3>
<ul>
{/* Placeholder boards */}
<li>
<Link to='#' onClick={(e) => e.preventDefault()}>
Video Games
</Link>
</li>
<li>
<Link to='#' onClick={(e) => e.preventDefault()}>
Video Game Generals
</Link>
</li>
</ul>
</div>
)}
{/* Interests */}
{showInterests && (
<div className={styles.boardsColumn}>
<h3>Interests</h3>
<ul>
{/* Placeholder boards */}
<li>
<Link to='#' onClick={(e) => e.preventDefault()}>
Technology
</Link>
</li>
<li>
<Link to='#' onClick={(e) => e.preventDefault()}>
Comics & Cartoons
</Link>
</li>
<li>
<Link to='#' onClick={(e) => e.preventDefault()}>
Television & Film
</Link>
</li>
</ul>
</div>
)}
{/* Creative */}
{showCreative && (
<div className={styles.boardsColumn}>
<h3>Creative</h3>
<ul>
{/* Placeholder boards */}
<li>
<Link to='#' onClick={(e) => e.preventDefault()}>
Photography
</Link>
</li>
<li>
<Link to='#' onClick={(e) => e.preventDefault()}>
Literature
</Link>
</li>
<li>
<Link to='#' onClick={(e) => e.preventDefault()}>
Music
</Link>
</li>
</ul>
</div>
)}
{/* Other - ACTIVE: biz */}
{showOther && (
<div className={styles.boardsColumn}>
<h3>Other</h3>
<ul>
{bizAddress && (
<li>
<Link to={getBoardLink(bizAddress)} onClick={(e) => handleLinkClick(e, bizAddress)}>
Business & Finance
</Link>
</li>
)}
{/* Placeholder boards */}
<li>
<Link to='#' onClick={(e) => e.preventDefault()}>
Travel
</Link>
</li>
<li>
<Link to='#' onClick={(e) => e.preventDefault()}>
Fitness
</Link>
</li>
</ul>
</div>
)}
{/* Misc. (NSFW) - ACTIVE: pol */}
{showMisc && (
<div className={styles.boardsColumn}>
<h3>Misc.</h3>
<NSFWBadge />
<ul>
{/* Placeholder boards */}
<li>
<Link to='#' onClick={(e) => e.preventDefault()}>
Random
</Link>
</li>
<li>
<Link to='#' onClick={(e) => e.preventDefault()}>
ROBOT9001
</Link>
</li>
{polAddress && (
<li>
<Link to={getBoardLink(polAddress)} onClick={(e) => handleLinkClick(e, polAddress)}>
Politically Incorrect
</Link>
</li>
)}
</ul>
</div>
)}
{/* Adult (NSFW) */}
{showAdult && (
<div className={styles.boardsColumn}>
<h3>Adult</h3>
<NSFWBadge />
<ul>
{/* Placeholder boards */}
<li>
<Link to='#' onClick={(e) => e.preventDefault()}>
Sexy Beautiful Women
</Link>
</li>
<li>
<Link to='#' onClick={(e) => e.preventDefault()}>
Hardcore
</Link>
</li>
</ul>
</div>
)}
</div>
{hasMoreBoards && (
<div className={styles.loadMoreButton}>
[<span onClick={() => setDisplayCount((prevCount) => prevCount + 15)}>{t('load_more')}</span>]
</div>
)}
</div>
);
};
+73 -1
View File
@@ -157,7 +157,13 @@
}
.filterModal .selected::before {
content: ' ';
content: ' ';
}
.filterModal .separator {
border-bottom: 1px solid #ccc;
margin: 6px 0 4px;
padding: 0;
}
.footer a {
@@ -246,6 +252,72 @@
text-transform: capitalize;
}
.boardsContent {
display: flex;
flex-wrap: wrap;
gap: 20px;
padding: 0.5em;
padding-top: 0.25em;
padding-bottom: 0;
}
.boardsColumn {
flex: 1;
min-width: 150px;
vertical-align: top;
}
.boardsColumn h3:not(.nsfwBadge) {
display: inline;
font-weight: 700;
font-size: 100%;
margin: 0;
margin-bottom: 5px;
text-transform: capitalize;
color: #800;
text-decoration: underline;
}
.nsfwBadge {
display: inline;
color: #e00;
font-size: 100% !important;
font-weight: 700;
text-transform: uppercase;
font-size: smaller;
vertical-align: baseline;
}
.nsfwBadge sup {
vertical-align: baseline;
position: relative;
top: -0.2em;
font-size: 85%;
}
.boardsColumn ul {
list-style: none;
padding: 0;
margin: 0;
margin-bottom: 15px;
}
.boardsColumn li {
margin: 0;
padding: 0;
line-height: 1.5em;
}
.boardsColumn a {
color: var(--homepage-box-link-text-color) !important;
text-decoration: none;
}
.boardsColumn a:hover {
color: var(--homepage-box-link-text-color-hover) !important;
text-decoration: underline;
}
@media (max-width: 640px) {
.content {
min-width: 0;