mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
@@ -4,40 +4,106 @@ _Telegram group for this repo https://t.me/fivechandev_
|
||||
|
||||
# 5chan
|
||||
|
||||
5chan is a serverless, adminless, decentralized and open-source 4chan alternative, where anyone can create and cryptographically own unlimited boards. All boards are selfhosted by their owners, who run [plebbit nodes](https://github.com/plebbit/plebbit-cli) that users connect to, peer-to-peer. All data is text-only, including links to load media/embeds.
|
||||
5chan is a serverless, adminless, decentralized and open-source 4chan alternative built on the [Plebbit protocol](https://plebbit.com). It features the same directory structure as 4chan, but with a crucial difference: **anyone can create and own boards, and multiple boards can compete for each directory slot**.
|
||||
|
||||
- web version: https://5chan.app, also available using Brave/IPFS Companion on https://5chan.eth
|
||||
## Key Features
|
||||
|
||||
### Downloads
|
||||
- desktop version (full p2p plebbit node, seeds automatically): available for Mac/Windows/Linux, [download link in the release page](https://github.com/plebbit/5chan/releases/latest)
|
||||
- mobile version: available for Android, [download link in the release page](https://github.com/plebbit/5chan/releases/latest)
|
||||
### Decentralized Board Ownership
|
||||
Unlike traditional imageboards, 5chan has no global admins or central authority. Anyone can create unlimited boards using their own [plebbit node](https://github.com/plebbit/plebbit-cli). Each board owner runs their own P2P node that users connect to peer-to-peer, giving them complete control over their board's content, moderation, and rules.
|
||||
|
||||
## How to create a board
|
||||
In the plebbit protocol, a 5chan board is called a _subplebbit_. To run a subplebbit, you can choose between two options:
|
||||
### Competitive Directory System
|
||||
5chan maintains the familiar 4chan directory structure (Japanese Culture, Video Games, Interests, Creative, etc.), but introduces competition: **multiple boards can compete for each directory slot**. For example, there can be unlimited "Business & Finance" boards, but only the highest-voted one appears in the directory on the homepage.
|
||||
|
||||
1. If you prefer to use a **GUI**, download the desktop version of the Seedit client, available for Windows, MacOS and Linux: [latest release](https://github.com/plebbit/seedit/releases/latest). Create a subplebbit using using the familiar old.reddit-like UI, and modify its settings to your liking. The app runs an IPFS node, meaning you have to keep it running to have your board online.
|
||||
2. If you prefer to use a **command line interface**, install plebbit-cli, available for Windows, MacOS and Linux: [latest release](https://github.com/plebbit/plebbit-cli/releases/latest). Follow the instructions in the readme of the repo. When running the daemon for the first time, it will output WebUI links you can use to manage your subplebbit with the ease of the GUI.
|
||||
Currently, directory assignments are temporarily handpicked by developers through GitHub pull requests. In the future, this will be fully automated through **gasless pubsub voting** (see [Future Roadmap](#future-roadmap) below), making the process completely decentralized and community-driven.
|
||||
|
||||
Peers can connect to your subplebbit using any plebbit client, such as 5chan or Seedit. They only need the subplebbit's address, which is not stored in any central database, as plebbit is a pure peer-to-peer protocol.
|
||||
### How It Works
|
||||
|
||||
### How to add a board to the boards list
|
||||
The boards list on 5chan is plebbit's [lists](https://github.com/plebbit/lists) repository, specifically the [5chan-multisub.json](https://github.com/plebbit/lists/blob/master/5chan-multisub.json) file. You can open a pull request in that repo to add your subplebbit to the list, or contact devs via telegram [@plebbit](https://t.me/plebbit). In the future, this process will be automated by submitting proposals to a plebbit DAO, using the [plebbit token](https://etherscan.io/token/0xea81dab2e0ecbc6b5c4172de4c22b6ef6e55bd8f).
|
||||
- **Current System**: Developers manually curate directory assignments by reviewing pull requests to the [5chan-multisub.json](https://github.com/plebbit/lists/blob/master/5chan-multisub.json) file. This is temporary until DAO curation is implemented.
|
||||
|
||||
## To run locally
|
||||
- **Future System**: Directory board assignments will be determined through gasless voting using pubsub. Community members will vote on which board should be assigned to each directory, and the highest-voted board will automatically become the directory board. This creates a competitive marketplace where board quality and community engagement determine directory placement.
|
||||
|
||||
1. Install Node v22 (Download from https://nodejs.org)
|
||||
2. Install Yarn: `npm install -g yarn`
|
||||
3. `yarn install --frozen-lockfile` to install 5chan dependencies
|
||||
4. `yarn start` to run the web client
|
||||
- **Accessing Boards**: Users can access any board at any time using its address, regardless of directory assignment. Boards can be accessed via the search bar, by subscribing to them (which adds them to the top bar), or by directly navigating to their address.
|
||||
|
||||
### Scripts:
|
||||
### Future Roadmap
|
||||
|
||||
- Web client: `yarn start`
|
||||
- Electron client (must start web client first): `yarn electron`
|
||||
- Electron client and don't delete data: `yarn electron:no-delete-data`
|
||||
- Web client and electron client: `yarn electron:start`
|
||||
- Web client and electron client and don't delete data: `yarn electron:start:no-delete-data`
|
||||
The protocol design for pubsub voting is already drafted in [plebbit-js issue #25](https://github.com/plebbit/plebbit-js/issues/25). This will enable:
|
||||
- Gasless voting using pubsub topics
|
||||
- Weighted voting based on token balances
|
||||
- Automatic directory resolution based on vote tallies
|
||||
- Full decentralization without any intermediaries
|
||||
|
||||
### Build:
|
||||
This feature is on the plebbit-js roadmap but hasn't been implemented yet.
|
||||
|
||||
The linux/windows/mac/android build scripts are in https://github.com/plebbit/5chan/blob/master/.github/workflows/release.yml
|
||||
## Downloads
|
||||
|
||||
- **Web version**: https://5chan.app (also available using Brave/IPFS Companion on https://5chan.eth)
|
||||
- **Desktop version** (full P2P plebbit node, seeds automatically): Available for Mac/Windows/Linux, [download from the release page](https://github.com/plebbit/5chan/releases/latest)
|
||||
- **Mobile version**: Available for Android, [download from the release page](https://github.com/plebbit/5chan/releases/latest)
|
||||
|
||||
## Creating a Board
|
||||
|
||||
In the plebbit protocol, a 5chan board is called a _subplebbit_. To create and run a subplebbit, you can choose between two options:
|
||||
|
||||
### Option 1: Seedit GUI Client (Recommended for beginners)
|
||||
|
||||
1. Download the desktop version of the Seedit client, available for Windows, macOS, and Linux: [latest release](https://github.com/plebbit/seedit/releases/latest)
|
||||
2. Create a subplebbit using the familiar old.reddit-like UI
|
||||
3. Modify its settings to your liking
|
||||
4. Keep the app running to keep your board online (it runs an IPFS node)
|
||||
|
||||
### Option 2: plebbit-cli Command Line Interface
|
||||
|
||||
1. Install plebbit-cli, available for Windows, macOS, and Linux: [latest release](https://github.com/plebbit/plebbit-cli/releases/latest)
|
||||
2. Follow the instructions in the repo's README
|
||||
3. When running the daemon for the first time, it will output WebUI links you can use to manage your subplebbit with a GUI
|
||||
|
||||
Once created, anyone can connect to your subplebbit using any plebbit client (such as 5chan or Seedit) by using the subplebbit's address. The address is not stored in any central database—plebbit is a pure peer-to-peer protocol.
|
||||
|
||||
## Submitting Your Board to a Directory
|
||||
|
||||
To have your board appear in a directory on the 5chan homepage:
|
||||
|
||||
1. Ensure your board meets these requirements:
|
||||
- Active and well-moderated
|
||||
- Relevant to the directory category
|
||||
- **99% uptime** (since a board acts like its own server—it's a P2P node)
|
||||
|
||||
2. Open a pull request on GitHub by editing the [5chan-multisub.json](https://github.com/plebbit/lists/blob/master/5chan-multisub.json) file
|
||||
|
||||
3. Add your board's entry with:
|
||||
- Title
|
||||
- Address
|
||||
- NSFW status (if applicable)
|
||||
|
||||
4. The developers will review your PR and merge it if approved
|
||||
|
||||
**Note**: Even if your board isn't assigned to a directory, users can still access it at any time using its address. Directory assignment only affects visibility on the homepage.
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js v22 (Download from https://nodejs.org)
|
||||
- Yarn: `npm install -g yarn`
|
||||
|
||||
### Setup
|
||||
|
||||
1. Clone the repository
|
||||
2. Install dependencies: `yarn install --frozen-lockfile`
|
||||
3. Start the web client: `yarn start`
|
||||
|
||||
### Scripts
|
||||
|
||||
- **Web client**: `yarn start`
|
||||
- **Electron client** (must start web client first): `yarn electron`
|
||||
- **Electron client** (don't delete data): `yarn electron:no-delete-data`
|
||||
- **Web client and electron client**: `yarn electron:start`
|
||||
- **Web client and electron client** (don't delete data): `yarn electron:start:no-delete-data`
|
||||
|
||||
### Build
|
||||
|
||||
The Linux/Windows/macOS/Android build scripts are in [.github/workflows/release.yml](https://github.com/plebbit/5chan/blob/master/.github/workflows/release.yml)
|
||||
|
||||
## License
|
||||
|
||||
5chan is open-source software (GPLv2 license) with no owner—anyone can host their own instance on any domain. The operator of any domain is merely hosting the web app and does not own, create, moderate, or control 5chan or any board content, which is stored peer-to-peer and generated by board owners and users.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useAccountComment } from '@plebbit/plebbit-react-hooks';
|
||||
import { initSnow, removeSnow } from './lib/snow';
|
||||
import { isAllView, isModView, isSubscriptionsView } from './lib/utils/view-utils';
|
||||
import useReplyModalStore from './stores/use-reply-modal-store';
|
||||
import useDirectoryModalStore from './stores/use-directory-modal-store';
|
||||
import useSpecialThemeStore from './stores/use-special-theme-store';
|
||||
import useIsMobile from './hooks/use-is-mobile';
|
||||
import useTheme from './hooks/use-theme';
|
||||
@@ -18,6 +19,7 @@ import Post from './views/post';
|
||||
import { DesktopBoardButtons, MobileBoardButtons } from './components/board-buttons';
|
||||
import BoardHeader from './components/board-header';
|
||||
import ChallengeModal from './components/challenge-modal';
|
||||
import DirectoryModal from './components/directory-modal';
|
||||
import ReplyModal from './components/reply-modal';
|
||||
import PostForm from './components/post-form';
|
||||
import SubplebbitStats from './components/subplebbit-stats';
|
||||
@@ -93,13 +95,20 @@ const GlobalLayout = () => {
|
||||
}, [theme]);
|
||||
|
||||
const { activeCid, threadCid, subplebbitAddress, closeModal, showReplyModal, scrollY } = useReplyModalStore();
|
||||
const { closeDirectoryModal } = useDirectoryModalStore();
|
||||
|
||||
const location = useLocation();
|
||||
const isInSettingsView = location.pathname.endsWith('/settings');
|
||||
|
||||
// Close directory modal when navigating to a different page
|
||||
useEffect(() => {
|
||||
closeDirectoryModal();
|
||||
}, [location.pathname, closeDirectoryModal]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChallengeModal />
|
||||
<DirectoryModal />
|
||||
{activeCid && threadCid && subplebbitAddress && (
|
||||
<ReplyModal
|
||||
closeModal={closeModal}
|
||||
|
||||
@@ -6,7 +6,7 @@ import Plebbit from '@plebbit/plebbit-js';
|
||||
import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits';
|
||||
import { isAllView, isSubscriptionsView, isModView } from '../../lib/utils/view-utils';
|
||||
import styles from './board-header.module.css';
|
||||
import { useMultisubMetadata } from '../../hooks/use-default-subplebbits';
|
||||
import { useMultisubMetadata, useDefaultSubplebbits } from '../../hooks/use-default-subplebbits';
|
||||
import useIsMobile from '../../hooks/use-is-mobile';
|
||||
import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline';
|
||||
import { shouldShowSnow } from '../../lib/snow';
|
||||
@@ -35,13 +35,18 @@ const BoardHeader = () => {
|
||||
const { address, shortAddress } = subplebbit || {};
|
||||
|
||||
const multisubMetadata = useMultisubMetadata();
|
||||
const defaultSubplebbits = useDefaultSubplebbits();
|
||||
|
||||
// Find matching subplebbit from default list to get its title
|
||||
const defaultSubplebbit = subplebbitAddress ? defaultSubplebbits.find((s) => s.address === subplebbitAddress) : null;
|
||||
|
||||
const title = isInAllView
|
||||
? multisubMetadata?.title || 'all'
|
||||
: isInSubscriptionsView
|
||||
? 'Subscriptions'
|
||||
: isInModView
|
||||
? _.startCase(t('boards_you_moderate'))
|
||||
: subplebbit?.title;
|
||||
: defaultSubplebbit?.title || subplebbit?.title;
|
||||
const subtitle = isInAllView ? 'p/all' : isInSubscriptionsView ? 'p/subscriptions' : isInModView ? 'p/mod' : `p/${address}`;
|
||||
|
||||
const { isOffline, isOnlineStatusLoading, offlineIconClass, offlineTitle } = useIsSubplebbitOffline(subplebbit);
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(128, 128, 128, 0.5);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.directoryDialog {
|
||||
position: absolute;
|
||||
width: 500px;
|
||||
top: 50px;
|
||||
left: 50%;
|
||||
margin-left: -251px;
|
||||
background-color: rgb(255, 255, 255);
|
||||
border: 1px solid rgb(136, 0, 0);
|
||||
color: rgb(136, 0, 0);
|
||||
box-shadow: rgba(0, 0, 0, 0.1) 2px 2px 0px 1px;
|
||||
font-family: arial, helvetica, clean, sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 16.003px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.directoryDialog {
|
||||
width: calc(100% - 20px);
|
||||
margin-left: -50%;
|
||||
left: 50%;
|
||||
max-width: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
.hd {
|
||||
position: relative;
|
||||
background-color: rgb(136, 0, 0);
|
||||
color: rgb(255, 255, 255);
|
||||
padding: 4px 6px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hd h2 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 131%;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
background-image: var(--disclaimer-modal-close-button-background-image);
|
||||
background-size: 100%;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.bd {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.introMessage {
|
||||
font-size: 15px;
|
||||
margin: 10px 0 20px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.introMessage strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
margin: 0 0 8px 0;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: rgb(136, 0, 0);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.section p {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 13px;
|
||||
line-height: 16.003px;
|
||||
}
|
||||
|
||||
.section p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.bd a {
|
||||
color: rgb(136, 0, 0);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.directoryFooter {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import useDirectoryModalStore from '../../stores/use-directory-modal-store';
|
||||
import styles from './directory-modal.module.css';
|
||||
|
||||
const DirectoryModal = () => {
|
||||
const { showModal, modalContext, closeDirectoryModal } = useDirectoryModalStore();
|
||||
|
||||
if (!showModal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
closeDirectoryModal();
|
||||
}
|
||||
};
|
||||
|
||||
const isPlaceholderContext = modalContext === 'placeholder';
|
||||
|
||||
return (
|
||||
<div className={styles.backdrop} onClick={handleBackdropClick}>
|
||||
<div className={styles.directoryDialog}>
|
||||
<div className={styles.hd}>
|
||||
<h2>{isPlaceholderContext ? 'Submit a Board to a Directory' : 'Create a Board'}</h2>
|
||||
<button className={styles.closeButton} onClick={closeDirectoryModal} title='Close' />
|
||||
</div>
|
||||
<div className={styles.bd}>
|
||||
{isPlaceholderContext && (
|
||||
<p className={styles.introMessage}>
|
||||
<strong>The board you clicked on doesn't exist yet, but it can be yours!</strong>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isPlaceholderContext && (
|
||||
<div className={styles.section}>
|
||||
<h3>Directory vs Regular Board</h3>
|
||||
<p>
|
||||
A "directory board" is assigned to a category on the homepage (like "Anime & Manga" or "Video Games"). Directory assignments
|
||||
are temporary and handpicked by devs until DAO curation is implemented.{' '}
|
||||
<strong>Anyone can create a board and users can access it anytime using its address</strong>—use the search bar, direct links, or the
|
||||
"[Subscribe]" button to access boards regardless of directory assignment.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.section}>
|
||||
<h3>Creating Your Board</h3>
|
||||
<p>
|
||||
Create a board using the{' '}
|
||||
<a href='https://plebbit.github.io/docs/learn/clients/5chan/create-a-board' target='_blank' rel='noopener noreferrer'>
|
||||
Seedit GUI client
|
||||
</a>{' '}
|
||||
or{' '}
|
||||
<a href='https://github.com/plebbit/plebbit-cli' target='_blank' rel='noopener noreferrer'>
|
||||
plebbit-cli
|
||||
</a>
|
||||
. <strong>Build a following:</strong> Users can subscribe to your board via the "[Subscribe]" button, which adds it to their top bar. You can gain
|
||||
subscribers through direct links, word of mouth, or search—no directory assignment or dev approval needed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isPlaceholderContext && (
|
||||
<div className={styles.section}>
|
||||
<h3>Directory Requirements</h3>
|
||||
<p>
|
||||
Boards need 99% uptime (they're P2P nodes), plus be active, well-moderated, and relevant to the category. Devs review and approve/reject based on
|
||||
these criteria.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.section}>
|
||||
<h3>Submitting to a Directory</h3>
|
||||
<p>
|
||||
Open a PR editing{' '}
|
||||
<a href='https://github.com/plebbit/lists/blob/master/5chan-multisub.json' target='_blank' rel='noopener noreferrer'>
|
||||
5chan-multisub.json
|
||||
</a>{' '}
|
||||
with your board's title, address, and NSFW status. Devs will review and merge if approved. 99% uptime is required.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<h3>Future: DAO Voting</h3>
|
||||
<p>
|
||||
Directory assignments will use gasless pubsub voting—communities vote, highest-voted board wins the slot. <strong>Voting pages = discovery:</strong> Each
|
||||
directory's voting page lists all competing boards (even low-voted ones), giving visibility without winning or dev approval. See{' '}
|
||||
<a href='https://github.com/plebbit/plebbit-js/issues/25' target='_blank' rel='noopener noreferrer'>
|
||||
design draft
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<h3>Decentralization</h3>
|
||||
<p>
|
||||
Devs can change directories via commits in the open-source repo. No centralized servers—anyone can fork, modify, and redeploy to their own domain. 5chan is
|
||||
adminless with no central authority.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.directoryFooter}>
|
||||
<button onClick={closeDirectoryModal}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DirectoryModal;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './directory-modal';
|
||||
@@ -194,9 +194,11 @@ const MobileQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, is
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', () => update());
|
||||
// Create a stable function reference for proper cleanup
|
||||
const handleResize = () => update();
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', () => update());
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [update]);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAccount, useAccountComment, useAccountSubplebbits } from '@plebbit/p
|
||||
import { isAllView, isCatalogView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
||||
import { useDefaultSubplebbitAddresses } from '../../hooks/use-default-subplebbits';
|
||||
import { TimeFilter } from '../board-buttons';
|
||||
import useDirectoryModalStore from '../../stores/use-directory-modal-store';
|
||||
import styles from './topbar.module.css';
|
||||
import _, { debounce } from 'lodash';
|
||||
|
||||
@@ -75,6 +76,7 @@ const TopBarDesktop = () => {
|
||||
const params = useParams();
|
||||
const isInCatalogView = isCatalogView(location.pathname, params);
|
||||
const [showSearchBar, setShowSearchBar] = useState(false);
|
||||
const { openDirectoryModal } = useDirectoryModalStore();
|
||||
|
||||
const subscriptions = account?.subscriptions;
|
||||
|
||||
@@ -108,16 +110,8 @@ const TopBarDesktop = () => {
|
||||
</>
|
||||
)}
|
||||
[
|
||||
<span className={styles.temporaryButton}>
|
||||
<a href='https://plebbit.github.io/docs/learn/clients/5chan/create-a-board' target='_blank' rel='noopener noreferrer'>
|
||||
{t('create_board')}
|
||||
</a>
|
||||
</span>
|
||||
] [
|
||||
<span className={styles.temporaryButton}>
|
||||
<a href='https://seedit.app/#/communities/vote' target='_blank' rel='noopener noreferrer'>
|
||||
{t('vote')}
|
||||
</a>
|
||||
<span className={styles.temporaryButton} onClick={() => openDirectoryModal('create-button')} style={{ cursor: 'pointer' }}>
|
||||
{t('create_board')}
|
||||
</span>
|
||||
]
|
||||
</span>
|
||||
|
||||
@@ -4,10 +4,21 @@ import { useParams } from 'react-router-dom';
|
||||
// the timestamp the last time the user visited
|
||||
const lastVisitTimestamp = localStorage.getItem('5chanLastVisitTimestamp');
|
||||
|
||||
// update the last visited timestamp every n seconds
|
||||
setInterval(() => {
|
||||
localStorage.setItem('5chanLastVisitTimestamp', Date.now().toString());
|
||||
}, 60 * 1000);
|
||||
// Singleton pattern to ensure only one interval is ever created
|
||||
// This prevents memory leaks from multiple intervals being created during HMR or module re-evaluation
|
||||
// Using window object to persist across hot module reloads
|
||||
declare global {
|
||||
interface Window {
|
||||
_5chanLastVisitTimestampIntervalId?: number;
|
||||
}
|
||||
}
|
||||
|
||||
if (!window._5chanLastVisitTimestampIntervalId) {
|
||||
// update the last visited timestamp every n seconds
|
||||
window._5chanLastVisitTimestampIntervalId = window.setInterval(() => {
|
||||
localStorage.setItem('5chanLastVisitTimestamp', Date.now().toString());
|
||||
}, 60 * 1000);
|
||||
}
|
||||
|
||||
const timeFilterNamesToSeconds: Record<string, number | undefined> = {
|
||||
'1h': 60 * 60,
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
type ModalContext = 'placeholder' | 'create-button';
|
||||
|
||||
interface DirectoryModalState {
|
||||
showModal: boolean;
|
||||
modalContext: ModalContext;
|
||||
openDirectoryModal: (context?: ModalContext) => void;
|
||||
closeDirectoryModal: () => void;
|
||||
}
|
||||
|
||||
const useDirectoryModalStore = create<DirectoryModalState>((set) => ({
|
||||
showModal: false,
|
||||
modalContext: 'placeholder',
|
||||
|
||||
openDirectoryModal: (context: ModalContext = 'placeholder') => {
|
||||
set({
|
||||
showModal: true,
|
||||
modalContext: context,
|
||||
});
|
||||
},
|
||||
|
||||
closeDirectoryModal: () => {
|
||||
set({
|
||||
showModal: false,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
export default useDirectoryModalStore;
|
||||
@@ -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;
|
||||
|
||||
+4
-4
@@ -23,7 +23,7 @@
|
||||
--body-background-pattern-image: url("/assets/background-fade.png");
|
||||
--body-font-color: maroon;
|
||||
--body-font-family: arial, helvetica, sans-serif;
|
||||
--body-font-size: 10pt;
|
||||
--body-font-size: 13px;
|
||||
|
||||
/* catalog post */
|
||||
--catalog-post-menu-desktop-btn-color: navy;
|
||||
@@ -239,7 +239,7 @@
|
||||
--body-background-pattern-image: url("/assets/background-fade-blue.png");
|
||||
--body-font-color: #000;
|
||||
--body-font-family: arial, helvetica, sans-serif;
|
||||
--body-font-size: 10pt;
|
||||
--body-font-size: 13px;
|
||||
|
||||
/* catalog post */
|
||||
--catalog-post-menu-desktop-btn-color: navy;
|
||||
@@ -819,7 +819,7 @@
|
||||
--body-background-pattern-image: none;
|
||||
--body-font-color: #c5c8c6;
|
||||
--body-font-family: arial, helvetica, sans-serif;
|
||||
--body-font-size: 10pt;
|
||||
--body-font-size: 13px;
|
||||
|
||||
/* board banner */
|
||||
--banner-image-border: 1px solid #000;
|
||||
@@ -1040,7 +1040,7 @@
|
||||
--body-background-pattern-image: none;
|
||||
--body-font-color: #333;
|
||||
--body-font-family: arial, helvetica, sans-serif;
|
||||
--body-font-size: 10pt;
|
||||
--body-font-size: 13px;
|
||||
|
||||
/* board banner */
|
||||
--banner-image-border: 1px solid #000;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,100 +1,605 @@
|
||||
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 useDirectoryModalStore from '../../../stores/use-directory-modal-store';
|
||||
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>
|
||||
<>
|
||||
|
||||
<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 { openDirectoryModal } = useDirectoryModalStore();
|
||||
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' : ''}`;
|
||||
};
|
||||
|
||||
// Handler for placeholder board links
|
||||
const handlePlaceholderClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
openDirectoryModal('placeholder');
|
||||
};
|
||||
|
||||
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 || showNsfwOnly;
|
||||
const showVideoGames = showAll || showWorksafeOnly;
|
||||
const showInterests = showAll || showWorksafeOnly;
|
||||
const showCreative = showAll || showWorksafeOnly || showNsfwOnly;
|
||||
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}`}>
|
||||
{/* Column 1: Japanese Culture + Video Games */}
|
||||
{(showJapaneseCulture || showVideoGames) && (
|
||||
<div className={styles.boardsColumn}>
|
||||
{/* Japanese Culture */}
|
||||
{showJapaneseCulture && (
|
||||
<>
|
||||
<h3>Japanese Culture</h3>
|
||||
<ul>
|
||||
{(showAll || showWorksafeOnly) && (
|
||||
<>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Anime & Manga
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Anime/Cute
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Anime/Wallpapers
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Mecha
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Cosplay & EGL
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Cute/Male
|
||||
</Link>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
{(showAll || showNsfwOnly) && (
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Flash
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{(showAll || showWorksafeOnly) && (
|
||||
<>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Transportation
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Otaku Culture
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Virtual YouTubers
|
||||
</Link>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Video Games */}
|
||||
{showVideoGames && (
|
||||
<>
|
||||
<h3>Video Games</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Video Games
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Video Game Generals
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Video Games/Multiplayer
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Video Games/Mobile
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Pokémon
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Retro Games
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Video Games/RPG
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Video Games/Strategy
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Column 2: Interests */}
|
||||
{showInterests && (
|
||||
<div className={styles.boardsColumn}>
|
||||
<h3>Interests</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Comics & Cartoons
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Technology
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Television & Film
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Weapons
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Auto
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Animals & Nature
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Traditional Games
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Sports
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Extreme Sports
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Professional Wrestling
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Science & Math
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
History & Humanities
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
International
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Outdoors
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Toys
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Column 3: Creative */}
|
||||
{showCreative && (
|
||||
<div className={styles.boardsColumn}>
|
||||
<h3>Creative</h3>
|
||||
<ul>
|
||||
{(showAll || showNsfwOnly) && (
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Oekaki
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{(showAll || showWorksafeOnly) && (
|
||||
<>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Papercraft & Origami
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Photography
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Food & Cooking
|
||||
</Link>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
{(showAll || showNsfwOnly) && (
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Artwork/Critique
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{(showAll || showNsfwOnly) && (
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Wallpapers/General
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{(showAll || showWorksafeOnly) && (
|
||||
<>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Literature
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Music
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Fashion
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
3DCG
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Graphic Design
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Do-It-Yourself
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Worksafe GIF
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Quests
|
||||
</Link>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Column 4: Other + Misc. */}
|
||||
{(showOther || showMisc) && (
|
||||
<div className={styles.boardsColumn}>
|
||||
{/* Other */}
|
||||
{showOther && (
|
||||
<>
|
||||
<h3>Other</h3>
|
||||
<ul>
|
||||
{bizAddress ? (
|
||||
<li>
|
||||
<Link to={getBoardLink(bizAddress)} onClick={(e) => handleLinkClick(e, bizAddress)}>
|
||||
Business & Finance
|
||||
</Link>
|
||||
</li>
|
||||
) : (
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Business & Finance
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Travel
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Fitness
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Paranormal
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Advice
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
LGBT
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Pony
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Current News
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Worksafe Requests
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Very Important Posts
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Misc. (NSFW) */}
|
||||
{showMisc && (
|
||||
<>
|
||||
<h3>Misc.</h3>
|
||||
<NSFWBadge />
|
||||
<ul>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Random
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
ROBOT9001
|
||||
</Link>
|
||||
</li>
|
||||
{polAddress ? (
|
||||
<li>
|
||||
<Link to={getBoardLink(polAddress)} onClick={(e) => handleLinkClick(e, polAddress)}>
|
||||
Politically Incorrect
|
||||
</Link>
|
||||
</li>
|
||||
) : (
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Politically Incorrect
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
International/Random
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Cams & Meetups
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Shit 4chan Says
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Column 5: Adult (NSFW) */}
|
||||
{showAdult && (
|
||||
<div className={styles.boardsColumn}>
|
||||
<h3>Adult</h3>
|
||||
<NSFWBadge />
|
||||
<ul>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Sexy Beautiful Women
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Hardcore
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Handsome Men
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Hentai
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Ecchi
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Yuri
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Hentai/Alternative
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Yaoi
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Torrents
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
High Resolution
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Adult GIF
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Adult Cartoons
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to='#' onClick={handlePlaceholderClick} className={styles.placeholder}>
|
||||
Adult Requests
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hasMoreBoards && (
|
||||
<div className={styles.loadMoreButton}>
|
||||
[<span onClick={() => setDisplayCount((prevCount) => prevCount + 15)}>{t('load_more')}</span>]
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
height: 100%;
|
||||
margin: auto;
|
||||
text-align: left;
|
||||
width: 750px;
|
||||
min-width: 750px;
|
||||
width: 748px;
|
||||
min-width: 748px;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
@@ -157,7 +157,7 @@
|
||||
}
|
||||
|
||||
.filterModal .selected::before {
|
||||
content: '✔ ';
|
||||
content: '✓ ';
|
||||
}
|
||||
|
||||
.footer a {
|
||||
@@ -246,6 +246,77 @@
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.boardsContent {
|
||||
display: block;
|
||||
padding: 0.5em;
|
||||
padding-top: 0.25em;
|
||||
padding-bottom: 0;
|
||||
line-height: 130%;
|
||||
}
|
||||
|
||||
.boardsContent::after {
|
||||
content: "";
|
||||
display: table;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.boardsColumn {
|
||||
display: block;
|
||||
float: left;
|
||||
width: 145px;
|
||||
}
|
||||
|
||||
.boardsColumn h3:not(.nsfwBadge) {
|
||||
display: inline;
|
||||
font-weight: 700;
|
||||
font-size: 100%;
|
||||
margin: 0;
|
||||
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: text-bottom;
|
||||
font-size: smaller;
|
||||
}
|
||||
|
||||
.boardsColumn ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.boardsColumn li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
line-height: 130%;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.boardsColumn a.placeholder {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.content {
|
||||
min-width: 0;
|
||||
@@ -277,7 +348,7 @@
|
||||
@media (min-width: 640px) {
|
||||
.footer {
|
||||
border-top: 1px solid;
|
||||
width: 750px;
|
||||
width: 748px;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
|
||||
Reference in New Issue
Block a user