diff --git a/README.md b/README.md index 522a649f..1f32cff5 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/public/translations/en/default.json b/public/translations/en/default.json index 15c8f5a0..1cd2de9f 100644 --- a/public/translations/en/default.json +++ b/public/translations/en/default.json @@ -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", diff --git a/src/app.tsx b/src/app.tsx index 5ae3aea6..d4e83417 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -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 ( <> + {activeCid && threadCid && subplebbitAddress && ( { 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); diff --git a/src/components/directory-modal/directory-modal.module.css b/src/components/directory-modal/directory-modal.module.css new file mode 100644 index 00000000..c220b20f --- /dev/null +++ b/src/components/directory-modal/directory-modal.module.css @@ -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; +} + + diff --git a/src/components/directory-modal/directory-modal.tsx b/src/components/directory-modal/directory-modal.tsx new file mode 100644 index 00000000..2239877f --- /dev/null +++ b/src/components/directory-modal/directory-modal.tsx @@ -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) => { + if (e.target === e.currentTarget) { + closeDirectoryModal(); + } + }; + + const isPlaceholderContext = modalContext === 'placeholder'; + + return ( +
+
+
+

{isPlaceholderContext ? 'Submit a Board to a Directory' : 'Create a Board'}

+
+
+ {isPlaceholderContext && ( +

+ The board you clicked on doesn't exist yet, but it can be yours! +

+ )} + + {isPlaceholderContext && ( +
+

Directory vs Regular Board

+

+ 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.{' '} + Anyone can create a board and users can access it anytime using its address—use the search bar, direct links, or the + "[Subscribe]" button to access boards regardless of directory assignment. +

+
+ )} + +
+

Creating Your Board

+

+ Create a board using the{' '} + + Seedit GUI client + {' '} + or{' '} + + plebbit-cli + + . Build a following: 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. +

+
+ + {isPlaceholderContext && ( +
+

Directory Requirements

+

+ 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. +

+
+ )} + +
+

Submitting to a Directory

+

+ Open a PR editing{' '} + + 5chan-multisub.json + {' '} + with your board's title, address, and NSFW status. Devs will review and merge if approved. 99% uptime is required. +

+
+ +
+

Future: DAO Voting

+

+ Directory assignments will use gasless pubsub voting—communities vote, highest-voted board wins the slot. Voting pages = discovery: Each + directory's voting page lists all competing boards (even low-voted ones), giving visibility without winning or dev approval. See{' '} + + design draft + + . +

+
+ +
+

Decentralization

+

+ 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. +

+
+
+
+ +
+
+
+ ); +}; + +export default DirectoryModal; diff --git a/src/components/directory-modal/index.ts b/src/components/directory-modal/index.ts new file mode 100644 index 00000000..08c007a5 --- /dev/null +++ b/src/components/directory-modal/index.ts @@ -0,0 +1 @@ +export { default } from './directory-modal'; diff --git a/src/components/reply-quote-preview/reply-quote-preview.tsx b/src/components/reply-quote-preview/reply-quote-preview.tsx index c0fa61d1..2d2a71a0 100644 --- a/src/components/reply-quote-preview/reply-quote-preview.tsx +++ b/src/components/reply-quote-preview/reply-quote-preview.tsx @@ -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]); diff --git a/src/components/topbar/topbar.tsx b/src/components/topbar/topbar.tsx index 246f508c..5842ce4d 100644 --- a/src/components/topbar/topbar.tsx +++ b/src/components/topbar/topbar.tsx @@ -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 = () => { )} [ - - - {t('create_board')} - - - ] [ - - - {t('vote')} - + openDirectoryModal('create-button')} style={{ cursor: 'pointer' }}> + {t('create_board')} ] diff --git a/src/hooks/use-time-filter.ts b/src/hooks/use-time-filter.ts index f8a03ba1..ccfda5b5 100644 --- a/src/hooks/use-time-filter.ts +++ b/src/hooks/use-time-filter.ts @@ -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 = { '1h': 60 * 60, diff --git a/src/stores/use-boards-filter-store.ts b/src/stores/use-boards-filter-store.ts new file mode 100644 index 00000000..b7c23b60 --- /dev/null +++ b/src/stores/use-boards-filter-store.ts @@ -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((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; diff --git a/src/stores/use-directory-modal-store.ts b/src/stores/use-directory-modal-store.ts new file mode 100644 index 00000000..3ceed28a --- /dev/null +++ b/src/stores/use-directory-modal-store.ts @@ -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((set) => ({ + showModal: false, + modalContext: 'placeholder', + + openDirectoryModal: (context: ModalContext = 'placeholder') => { + set({ + showModal: true, + modalContext: context, + }); + }, + + closeDirectoryModal: () => { + set({ + showModal: false, + }); + }, +})); + +export default useDirectoryModalStore; diff --git a/src/stores/use-disclaimer-modal-store.ts b/src/stores/use-disclaimer-modal-store.ts index 89bc3240..7815964d 100644 --- a/src/stores/use-disclaimer-modal-store.ts +++ b/src/stores/use-disclaimer-modal-store.ts @@ -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; diff --git a/src/themes.css b/src/themes.css index 3da8c039..a9a20642 100644 --- a/src/themes.css +++ b/src/themes.css @@ -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; diff --git a/src/views/home/boards-list/boards-filter-modal.tsx b/src/views/home/boards-list/boards-filter-modal.tsx new file mode 100644 index 00000000..37b3da66 --- /dev/null +++ b/src/views/home/boards-list/boards-filter-modal.tsx @@ -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(null); + const buttonRef = useRef(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 ( + <> + !showFilterModal && setShowFilterModal(true)}> + {t('filter')} ▼ + + {showFilterModal && ( +
+ {/* Always shown: Use Catalog */} +
{ + setUseCatalogLinks(!useCatalogLinks); + setShowFilterModal(false); + }} + > + {t('use_catalog')} +
+ + {/* Conditionally shown: Filtering options (only after disclaimer accepted) */} + {disclaimerAccepted && ( + <> +
+
{ + setBoardFilter('all'); + setShowFilterModal(false); + }} + > + {t('show_all_boards')} +
+
{ + setBoardFilter('nsfw'); + setShowFilterModal(false); + }} + > + {t('show_nsfw_boards_only')} +
+
{ + setBoardFilter('worksafe'); + setShowFilterModal(false); + }} + > + {t('show_worksafe_boards_only')} +
+ + )} +
+ )} + + ); +}; + +export default BoardsFilterModal; diff --git a/src/views/home/boards-list/boards-list.module.css b/src/views/home/boards-list/boards-list.module.css deleted file mode 100644 index 19b0daaa..00000000 --- a/src/views/home/boards-list/boards-list.module.css +++ /dev/null @@ -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; -} diff --git a/src/views/home/boards-list/boards-list.tsx b/src/views/home/boards-list/boards-list.tsx index 7ea7b2c2..3c3ecb2b 100644 --- a/src/views/home/boards-list/boards-list.tsx +++ b/src/views/home/boards-list/boards-list.tsx @@ -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) => { - 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 ( - - -

- - {displayAddress} - - {nsfw && ({t('nsfw')})} -

- - -

{boardTitle || displayAddress}

- - + <> +   +

+ + (NSFW) + +

+ ); }; 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, 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) => { + e.preventDefault(); + openDirectoryModal('placeholder'); + }; if (loading) { return ( -
- +
+
+

{t('boards')}

+ +
+
- +
); } if (error) { return ( -
-
{error.message}
+
+
+

{t('boards')}

+ +
+
+
{error.message}
+
); } - 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 ( -
- - - - - - - - - - - - - {filteredBoards.map((sub) => ( - - ))} - -
{t('board')}{t('title')}
-
- {t('displaying_boards', { filteredBoardsCount: filteredBoards.length, totalBoardCount: totalBoardCount, interpolation: { escapeValue: false } })} +
+
+

{t('boards')}

+ +
+
+ {/* Column 1: Japanese Culture + Video Games */} + {(showJapaneseCulture || showVideoGames) && ( +
+ {/* Japanese Culture */} + {showJapaneseCulture && ( + <> +

Japanese Culture

+
    + {(showAll || showWorksafeOnly) && ( + <> +
  • + + Anime & Manga + +
  • +
  • + + Anime/Cute + +
  • +
  • + + Anime/Wallpapers + +
  • +
  • + + Mecha + +
  • +
  • + + Cosplay & EGL + +
  • +
  • + + Cute/Male + +
  • + + )} + {(showAll || showNsfwOnly) && ( +
  • + + Flash + +
  • + )} + {(showAll || showWorksafeOnly) && ( + <> +
  • + + Transportation + +
  • +
  • + + Otaku Culture + +
  • +
  • + + Virtual YouTubers + +
  • + + )} +
+ + )} + + {/* Video Games */} + {showVideoGames && ( + <> +

Video Games

+
    +
  • + + Video Games + +
  • +
  • + + Video Game Generals + +
  • +
  • + + Video Games/Multiplayer + +
  • +
  • + + Video Games/Mobile + +
  • +
  • + + Pokémon + +
  • +
  • + + Retro Games + +
  • +
  • + + Video Games/RPG + +
  • +
  • + + Video Games/Strategy + +
  • +
+ + )} +
+ )} + + {/* Column 2: Interests */} + {showInterests && ( +
+

Interests

+
    +
  • + + Comics & Cartoons + +
  • +
  • + + Technology + +
  • +
  • + + Television & Film + +
  • +
  • + + Weapons + +
  • +
  • + + Auto + +
  • +
  • + + Animals & Nature + +
  • +
  • + + Traditional Games + +
  • +
  • + + Sports + +
  • +
  • + + Extreme Sports + +
  • +
  • + + Professional Wrestling + +
  • +
  • + + Science & Math + +
  • +
  • + + History & Humanities + +
  • +
  • + + International + +
  • +
  • + + Outdoors + +
  • +
  • + + Toys + +
  • +
+
+ )} + + {/* Column 3: Creative */} + {showCreative && ( +
+

Creative

+
    + {(showAll || showNsfwOnly) && ( +
  • + + Oekaki + +
  • + )} + {(showAll || showWorksafeOnly) && ( + <> +
  • + + Papercraft & Origami + +
  • +
  • + + Photography + +
  • +
  • + + Food & Cooking + +
  • + + )} + {(showAll || showNsfwOnly) && ( +
  • + + Artwork/Critique + +
  • + )} + {(showAll || showNsfwOnly) && ( +
  • + + Wallpapers/General + +
  • + )} + {(showAll || showWorksafeOnly) && ( + <> +
  • + + Literature + +
  • +
  • + + Music + +
  • +
  • + + Fashion + +
  • +
  • + + 3DCG + +
  • +
  • + + Graphic Design + +
  • +
  • + + Do-It-Yourself + +
  • +
  • + + Worksafe GIF + +
  • +
  • + + Quests + +
  • + + )} +
+
+ )} + + {/* Column 4: Other + Misc. */} + {(showOther || showMisc) && ( +
+ {/* Other */} + {showOther && ( + <> +

Other

+
    + {bizAddress ? ( +
  • + handleLinkClick(e, bizAddress)}> + Business & Finance + +
  • + ) : ( +
  • + + Business & Finance + +
  • + )} +
  • + + Travel + +
  • +
  • + + Fitness + +
  • +
  • + + Paranormal + +
  • +
  • + + Advice + +
  • +
  • + + LGBT + +
  • +
  • + + Pony + +
  • +
  • + + Current News + +
  • +
  • + + Worksafe Requests + +
  • +
  • + + Very Important Posts + +
  • +
+ + )} + + {/* Misc. (NSFW) */} + {showMisc && ( + <> +

Misc.

+ +
    +
  • + + Random + +
  • +
  • + + ROBOT9001 + +
  • + {polAddress ? ( +
  • + handleLinkClick(e, polAddress)}> + Politically Incorrect + +
  • + ) : ( +
  • + + Politically Incorrect + +
  • + )} +
  • + + International/Random + +
  • +
  • + + Cams & Meetups + +
  • +
  • + + Shit 4chan Says + +
  • +
+ + )} +
+ )} + + {/* Column 5: Adult (NSFW) */} + {showAdult && ( +
+

Adult

+ +
    +
  • + + Sexy Beautiful Women + +
  • +
  • + + Hardcore + +
  • +
  • + + Handsome Men + +
  • +
  • + + Hentai + +
  • +
  • + + Ecchi + +
  • +
  • + + Yuri + +
  • +
  • + + Hentai/Alternative + +
  • +
  • + + Yaoi + +
  • +
  • + + Torrents + +
  • +
  • + + High Resolution + +
  • +
  • + + Adult GIF + +
  • +
  • + + Adult Cartoons + +
  • +
  • + + Adult Requests + +
  • +
+
+ )}
- {hasMoreBoards && ( -
- [ setDisplayCount((prevCount) => prevCount + 15)}>{t('load_more')}] -
- )}
); }; diff --git a/src/views/home/home.module.css b/src/views/home/home.module.css index 5a06baa6..9ed7da21 100644 --- a/src/views/home/home.module.css +++ b/src/views/home/home.module.css @@ -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;