feat(rules page): rebuild directory rules page with vendored lists (#1147)

* feat(rules page): rebuild directory rules page with vendored lists

Mirror per-directory JSON from lists into src/data/5chan-directories, rework /rules layout to match 4chan (sidebar nav, category boxes, P2P load), and keep spoiler markup visible in rule text via parseSpoilers.

* fix(rules): keep directory defaults cache atomic

* fix(rules): derive defaults from shared directory refresh

* fix(rules): address directory refresh edge cases
This commit is contained in:
Tommaso Casaburi
2026-05-31 11:39:40 +07:00
committed by GitHub
parent e4638ac8c7
commit b442e05191
75 changed files with 2853 additions and 1333 deletions
+148 -23
View File
@@ -1,6 +1,7 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Rules from '../rules';
@@ -13,8 +14,15 @@ const testState = vi.hoisted(() => ({
directories: [
{ address: 'anime-posting.eth', title: '/a/ - Anime & Manga' },
{ address: 'random-posting.eth', title: '/b/ - Random' },
] as Array<{ address: string; title?: string }>,
navigateMock: vi.fn(),
{ address: 'flash-posting.eth', title: '/f/ - Flash' },
] as Array<{ address: string; title?: string; directoryCode?: string }>,
directoryDefaults: {
directories: {
a: { directoryCode: 'a', title: '/a/ - Anime & Manga', rules: ['All anime discussion welcome.'] },
b: { directoryCode: 'b', title: '/b/ - Random', rules: ['Be excellent to each other.'] },
f: { directoryCode: 'f', title: '/f/ - Flash', features: { postFlairs: true }, rules: ['Tag your uploads.'] },
},
} as { directories: Record<string, { directoryCode?: string; title?: string; rules?: string[]; features?: Record<string, unknown> }> },
}));
vi.mock('react-i18next', () => ({
@@ -27,7 +35,6 @@ vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return {
...actual,
useNavigate: () => testState.navigateMock,
useParams: () => ({
boardIdentifier: testState.boardIdentifier,
}),
@@ -49,6 +56,7 @@ vi.mock('../../../hooks/use-directories', async () => {
return {
...actual,
useDirectories: () => testState.directories,
useDirectoryDefaults: () => testState.directoryDefaults,
};
});
@@ -71,10 +79,31 @@ vi.mock('lodash/debounce', () => ({
let container: HTMLDivElement;
let root: Root;
let scrollIntoViewMock: ReturnType<typeof vi.fn>;
const renderRules = async () => {
await act(async () => {
root.render(createElement(Rules));
root.render(createElement(MemoryRouter, null, createElement(Rules)));
});
};
// Set a controlled input's value via the native setter so React's value tracker still fires onChange.
const setInputValue = (input: HTMLInputElement, value: string) => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
setter?.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
};
const submitBoardAddress = async (address: string) => {
const input = container.querySelector('input[type="text"]') as HTMLInputElement;
expect(input).toBeTruthy();
const form = input.closest('form') as HTMLFormElement;
await act(async () => {
setInputValue(input, address);
});
await act(async () => {
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
});
};
@@ -86,8 +115,18 @@ describe('Rules', () => {
testState.directories = [
{ address: 'anime-posting.eth', title: '/a/ - Anime & Manga' },
{ address: 'random-posting.eth', title: '/b/ - Random' },
{ address: 'flash-posting.eth', title: '/f/ - Flash' },
];
testState.directoryDefaults = {
directories: {
a: { directoryCode: 'a', title: '/a/ - Anime & Manga', rules: ['All anime discussion welcome.'] },
b: { directoryCode: 'b', title: '/b/ - Random', rules: ['Be excellent to each other.'] },
f: { directoryCode: 'f', title: '/f/ - Flash', features: { postFlairs: true }, rules: ['Tag your uploads.'] },
},
};
window.scrollTo = vi.fn();
scrollIntoViewMock = vi.fn();
Element.prototype.scrollIntoView = scrollIntoViewMock as unknown as typeof Element.prototype.scrollIntoView;
container = document.createElement('div');
document.body.appendChild(container);
@@ -99,54 +138,140 @@ describe('Rules', () => {
container.remove();
});
it('keeps custom-address routes out of the default board select', async () => {
testState.boardIdentifier = 'custom-board.eth';
it('renders a quick-jump nav link and a rules section for every directory', async () => {
await renderRules();
// Quick-jump nav links use the directory name (like 4chan's board list) and point at the per-directory route.
expect(container.querySelector('a[href="/rules/a"]')?.textContent).toBe('Anime & Manga');
expect(container.querySelector('a[href="/rules/b"]')?.textContent).toBe('Random');
// One anchored rules section per directory.
expect(container.querySelector('#a')).toBeTruthy();
expect(container.querySelector('#b')).toBeTruthy();
expect(container.textContent).toContain('/a/ - Anime & Manga');
expect(container.textContent).toContain('/b/ - Random');
});
it('renders directory rules from the directories JSON without loading any board over P2P', async () => {
// communities (the P2P source) is empty, yet the rules still render because they come from the defaults JSON.
await renderRules();
expect(container.textContent).toContain('All anime discussion welcome.');
expect(container.textContent).toContain('Be excellent to each other.');
// The directory rules are not framed as a P2P "Rules for:" board fetch.
expect(container.textContent).not.toContain('Rules for:');
});
it('insta-scrolls to a directory section when deep-linked via /rules/:code', async () => {
testState.boardIdentifier = 'a';
await renderRules();
expect(scrollIntoViewMock).toHaveBeenCalled();
});
it('loads a board over P2P when an address is submitted in the loader', async () => {
testState.communities = {
'custom-board.eth': {
rules: ['No custom options in the select.'],
rules: ['No spamming.'],
shortAddress: 'custom-board.eth',
state: 'succeeded',
},
};
await renderRules();
await submitBoardAddress('custom-board.eth');
const select = container.querySelector('select');
expect(select).toBeTruthy();
expect(select?.value).toBe('');
expect(Array.from(select?.options ?? []).map((option) => option.value)).toEqual(['', 'anime-posting.eth', 'random-posting.eth']);
expect(container.textContent).toContain('Rules for: custom-board.eth');
expect(container.textContent).toContain('No spamming.');
});
it('keeps the canonical default board selected for known directories', async () => {
testState.boardIdentifier = 'a';
it('clears a loaded P2P rules box when navigating to a directory route', async () => {
testState.communities = {
'anime-posting.eth': {
rules: ['Stay on topic.'],
'custom-board.eth': {
rules: ['No spamming.'],
shortAddress: 'custom-board.eth',
state: 'succeeded',
},
};
await renderRules();
await submitBoardAddress('custom-board.eth');
expect(container.textContent).toContain('Rules for: custom-board.eth');
const select = container.querySelector('select');
expect(select).toBeTruthy();
expect(select?.value).toBe('anime-posting.eth');
expect(Array.from(select?.options ?? []).map((option) => option.value)).toEqual(['', 'anime-posting.eth', 'random-posting.eth']);
expect(container.textContent).toContain('Rules for: /a/ - Anime & Manga');
testState.boardIdentifier = 'a';
await renderRules();
expect(container.textContent).not.toContain('Rules for: custom-board.eth');
expect(scrollIntoViewMock).toHaveBeenCalled();
});
it('shows a friendly loading state string while board rules are downloading', async () => {
testState.boardIdentifier = 'a';
it('shows a friendly loading state string while a board over P2P is downloading', async () => {
testState.communities = {
'anime-posting.eth': {
'custom-board.eth': {
state: 'fetching-ipns',
},
};
await renderRules();
await submitBoardAddress('custom-board.eth');
expect(container.textContent).toContain('Downloading board from peers');
expect(container.textContent).not.toContain('loading...');
});
it('groups directories into Image Boards and Upload Boards with an h3 per directory', async () => {
await renderRules();
expect(container.textContent).toContain('Image Boards');
expect(container.textContent).toContain('Upload Boards');
const h3Titles = Array.from(container.querySelectorAll('h3')).map((h3) => h3.textContent);
expect(h3Titles).toContain('/a/ - Anime & Manga');
expect(h3Titles).toContain('/b/ - Random');
expect(h3Titles).toContain('/f/ - Flash');
expect(container.textContent).toContain('Tag your uploads.');
});
it('does not scroll bare /rules back to the top when directories refresh', async () => {
await renderRules();
expect(window.scrollTo).toHaveBeenCalled();
vi.mocked(window.scrollTo).mockClear();
testState.directories = [...testState.directories, { address: 'travel-posting.eth', title: '/trv/ - Travel', directoryCode: 'trv' }];
testState.directoryDefaults = {
directories: {
...testState.directoryDefaults.directories,
trv: { directoryCode: 'trv', title: '/trv/ - Travel', rules: ['Stay on topic.'] },
},
};
await renderRules();
expect(window.scrollTo).not.toHaveBeenCalled();
});
it('toggles the loader action to Clear, which removes the loaded rules and empties the input', async () => {
testState.communities = {
'custom-board.eth': {
rules: ['No spamming.'],
shortAddress: 'custom-board.eth',
state: 'succeeded',
},
};
await renderRules();
await submitBoardAddress('custom-board.eth');
expect(container.textContent).toContain('Rules for: custom-board.eth');
const clearButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Clear');
expect(clearButton).toBeTruthy();
await act(async () => {
clearButton?.click();
});
expect(container.textContent).not.toContain('Rules for: custom-board.eth');
expect((container.querySelector('input[type="text"]') as HTMLInputElement).value).toBe('');
});
});
+134 -31
View File
@@ -66,22 +66,6 @@
box-sizing: border-box;
}
.boardSelect {
padding: 5px 10px;
font-size: 14px;
border: 1px solid #aaa;
border-radius: 0;
background: #fff;
cursor: pointer;
min-width: 200px;
box-sizing: border-box;
}
.orSeparator {
color: #666;
font-style: italic;
}
.customAddressForm {
display: flex;
align-items: center;
@@ -103,13 +87,103 @@
outline: none;
}
/* Match homepage search "Go" button (home.module.css .searchButton). */
.goButton {
padding: 5px 10px;
padding: 3px 5px;
font-size: 14px;
display: inline-block;
text-transform: capitalize;
white-space: nowrap;
flex-shrink: 0;
cursor: pointer;
box-sizing: border-box;
}
.columns {
display: flex;
align-items: flex-start;
gap: 0.5em;
}
.leftColumn {
flex: 0 0 29.9%;
width: 29.9%;
min-width: 0;
}
.rightColumn {
flex: 1 1 auto;
min-width: 0;
}
/*
* Green sidebar (4chan .left-box) — rules.2.css + global .boxcontent.
* Same 93% font, 1.5em line-height, and .5em padding as other boxes (.boxContent).
*/
.selectorBox .boxContent > .directoryNav > ul {
color: #060;
}
.selectorBox .directoryNav ul {
margin: 1em;
padding-left: 0;
list-style: disc outside;
}
/* 4chan: div ul { margin-left: 2em } — top list under boxcontent */
.selectorBox .directoryNav > ul {
margin-left: 2em;
}
/* 4chan: li ul { margin-left: 1em; margin-top: 0 } */
.selectorBox .directoryNav li ul {
margin-left: 1em;
margin-top: 0;
}
/* 4chan: li li ul { margin-bottom: .5em } */
.selectorBox .directoryNav li li ul {
margin-bottom: 0.5em;
}
.selectorBox .directoryNav li {
margin: 0;
line-height: inherit;
}
.selectorBox .directoryNav a {
color: #00e;
text-decoration: underline;
overflow-wrap: anywhere;
}
.selectorBox .directoryNavHeader {
appearance: none;
background: transparent;
border: 0;
padding: 0;
font: inherit;
font-weight: 700;
color: #00e;
text-decoration: underline;
cursor: pointer;
}
.directoryTitle {
font-size: 100%;
font-weight: 700;
color: #006;
margin: 0 0 5px;
}
.directoryDivider {
border: 0;
height: 1px;
/* Match the rules box border color (#006, the rulesBox currentColor), like 4chan's <hr>. */
background: #006;
margin: 0 0 1em;
}
.rulesBox {
background: #eff;
color: #006;
@@ -120,14 +194,15 @@
color: #fff;
}
/* 4chan's .right-box ol overrides only top + left of the base 1em margin (bottom/right stay 1em). */
.box ol {
margin: 1em;
margin-left: 2em;
margin: 0.5em 1em 1em 2.5em;
}
/* No per-item spacing: line height drives the gap. 1.5 matches 4chan's rule list exactly. */
.box ol li {
list-style: decimal outside;
margin-bottom: 0.5em;
line-height: 1.5;
}
@media (max-width: 640px) {
@@ -142,28 +217,56 @@
align-items: stretch;
}
.boardSelect,
.addressInput {
width: 100%;
flex: 1 1 auto;
width: auto;
min-width: 0;
box-sizing: border-box;
}
.orSeparator {
text-align: center;
}
/* Keep the input and button on one row on mobile instead of stacking them. */
.customAddressForm {
flex-direction: column;
width: 100%;
box-sizing: border-box;
}
.goButton {
box-sizing: border-box;
margin-top: 5px;
padding: 5px 10px;
cursor: pointer;
flex: 0 0 auto;
}
.columns {
flex-direction: column;
}
.leftColumn {
flex: none;
width: 100%;
}
/* Mobile: tighter list margins + left inset (font size stays same as desktop .boxContent) */
.leftColumn .selectorBox .boxContent {
line-height: 130%;
padding: 0.25em 0.5em 0 10px;
}
.leftColumn .selectorBox .directoryNav ul {
margin: 0;
padding-left: 1.2em;
list-style: disc outside;
}
.leftColumn .selectorBox .directoryNav > ul {
margin-left: 0;
}
.leftColumn .selectorBox .directoryNav li ul {
margin-left: 0;
margin-top: 0;
padding-left: 1em;
}
.leftColumn .selectorBox .directoryNav li {
padding: 5px 10px 5px 0;
}
}
+191 -75
View File
@@ -1,10 +1,10 @@
import { useEffect, useState, FormEvent } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Fragment, useEffect, useRef, useState, FormEvent } from 'react';
import { Link, useParams } from 'react-router-dom';
import { useCommunity } from '@bitsocial/bitsocial-react-hooks';
import { Footer, HomeLogo } from '../home';
import { useDirectories, DirectoryCommunity, findDirectoryByAddress } from '../../hooks/use-directories';
import { useDirectories, useDirectoryDefaults, DirectoryCommunity, DirectoryDefaultsData } from '../../hooks/use-directories';
import { useCommunityIdentifier } from '../../hooks/use-community-identifiers';
import { getCommunityAddress, getBoardPath } from '../../lib/utils/route-utils';
import { getCommunityAddress, getBoardPath, isDirectoryRoute } from '../../lib/utils/route-utils';
import Markdown from '../../components/markdown';
import LoadingEllipsis from '../../components/loading-ellipsis';
import useStateString from '../../hooks/use-state-string';
@@ -12,6 +12,12 @@ import styles from './rules.module.css';
import { useTranslation } from 'react-i18next';
import lowerCase from 'lodash/lowerCase';
interface CategoryGroup {
key: string;
label: string;
communities: DirectoryCommunity[];
}
const getBoardShortCode = (title?: string): string => {
if (!title) return '';
const match = title.match(/^\/([^/]+)\//);
@@ -24,31 +30,128 @@ const getBoardName = (title?: string): string => {
return match ? match[1] : title;
};
const BoardRulesDisplay = ({ communityAddress, directories }: { communityAddress: string; directories: DirectoryCommunity[] }) => {
const getDirectoryCode = (community: DirectoryCommunity): string => community.directoryCode ?? getBoardShortCode(community.title);
const getDirectoryDisplayTitle = (community: DirectoryCommunity): string => {
const shortCode = getDirectoryCode(community);
const boardName = getBoardName(community.title);
if (shortCode && boardName) {
return `/${shortCode}/ - ${boardName}`;
}
return community.title ?? community.address;
};
const getDirectoryRules = (defaults: DirectoryDefaultsData, code: string): string[] => (code ? (defaults.directories[code]?.rules ?? []) : []);
// Upload boards (e.g. /f/ - Flash) require post flairs/tagging on uploads; everything else is an image board, mirroring 4chan's split.
const isUploadDirectory = (defaults: DirectoryDefaultsData, code: string): boolean => !!defaults.directories[code]?.features?.postFlairs;
// Split the directories into ordered category groups (image boards first, then upload boards), dropping empty ones.
const groupDirectoriesByCategory = (directories: DirectoryCommunity[], defaults: DirectoryDefaultsData): CategoryGroup[] =>
[
{ key: 'image', label: 'Image Boards' },
{ key: 'upload', label: 'Upload Boards' },
]
.map(({ key, label }) => ({
key,
label,
communities: directories.filter((community) => (key === 'upload') === isUploadDirectory(defaults, getDirectoryCode(community))),
}))
.filter((group) => group.communities.length > 0);
// Resolve a /rules/:boardIdentifier segment (directory code or board address) to a directory code.
const resolveDirectoryCode = (identifier: string, directories: DirectoryCommunity[]): string | null => {
if (isDirectoryRoute(identifier, directories)) {
return identifier;
}
const code = getBoardPath(getCommunityAddress(identifier, directories), directories);
return isDirectoryRoute(code, directories) ? code : null;
};
// A single directory's rules (h3 title + ordered rules), anchored by code for deep-link scrolling.
const DirectorySection = ({ community, rules }: { community: DirectoryCommunity; rules: string[] }) => {
const code = getDirectoryCode(community);
return (
<div id={code || undefined}>
<h3 className={styles.directoryTitle}>{getDirectoryDisplayTitle(community)}</h3>
{rules.length > 0 ? (
<ol>
{rules.map((rule, index) => (
<li key={`${index}-${rule}`}>
<Markdown content={rule} parseSpoilers={false} />
</li>
))}
</ol>
) : (
<p>
<em>This directory has no specific rules.</em>
</p>
)}
</div>
);
};
// Directory rules come straight from the directories JSON (defaults), so a whole category renders at once without a P2P fetch.
const CategoryRulesBox = ({ group, defaults }: { group: CategoryGroup; defaults: DirectoryDefaultsData }) => (
<div className={`${styles.box} ${styles.rulesBox}`} id={`category-${group.key}`}>
<div className={styles.boxBar}>
<h2 className={styles.rulesBoxTitle}>{group.label}</h2>
</div>
<div className={styles.boxContent}>
{group.communities.map((community, index) => (
<Fragment key={community.address}>
<DirectorySection community={community} rules={getDirectoryRules(defaults, getDirectoryCode(community))} />
{/* Separator below each entry except the last (matches 4chan's <hr> between board rules). */}
{index < group.communities.length - 1 && <hr className={styles.directoryDivider} />}
</Fragment>
))}
</div>
</div>
);
// Quick-jump nav (left column) grouped by category; clicking a directory insta-scrolls to its rules via /rules/:code.
const DirectoryNav = ({ groups }: { groups: CategoryGroup[] }) => (
<div className={`${styles.box} ${styles.selectorBox}`}>
<div className={styles.boxBar}>
<h2 className={styles.selectorBoxTitle}>Directories</h2>
</div>
<div className={styles.boxContent}>
<nav className={styles.directoryNav}>
<ul>
{groups.map((group) => (
<li key={group.key}>
{/* Button (not an anchor) so the HashRouter route hash is preserved while still scrolling to the category. */}
<button type='button' className={styles.directoryNavHeader} onClick={() => document.getElementById(`category-${group.key}`)?.scrollIntoView()}>
{group.label}
</button>
<ul>
{group.communities.map((community) => {
const code = getDirectoryCode(community);
return (
<li key={community.address}>
<Link to={`/rules/${code}`}>{getBoardName(community.title) || getDirectoryDisplayTitle(community)}</Link>
</li>
);
})}
</ul>
</li>
))}
</ul>
</nav>
</div>
</div>
);
// P2P board rules: fetched live from peers for an arbitrary board address.
const BoardRulesDisplay = ({ communityAddress }: { communityAddress: string }) => {
const { t } = useTranslation();
const communityIdentifier = useCommunityIdentifier(communityAddress);
const community = useCommunity(communityIdentifier ? { community: communityIdentifier } : undefined);
const { rules, state, title, shortAddress } = community || {};
const { rules, state, shortAddress } = community || {};
const stateString = useStateString(community) || t('downloading_board');
const isLoaded = state === 'succeeded';
const defaultSub = directories.find((sub) => sub.address === communityAddress);
let displayTitle: string;
if (defaultSub?.title) {
const shortCode = getBoardShortCode(defaultSub.title);
const boardName = getBoardName(defaultSub.title);
displayTitle = `Rules for: /${shortCode}/ - ${boardName}`;
} else if (title) {
const shortCode = getBoardShortCode(title);
const boardName = getBoardName(title);
if (shortCode && boardName && boardName !== title) {
displayTitle = `Rules for: /${shortCode}/ - ${boardName}`;
} else {
displayTitle = `Rules for: ${shortAddress || communityAddress}`;
}
} else {
displayTitle = `Rules for: ${shortAddress || communityAddress}`;
}
const displayTitle = `Rules for: ${shortAddress || communityAddress}`;
return (
<div className={`${styles.box} ${styles.rulesBox}`}>
@@ -64,7 +167,7 @@ const BoardRulesDisplay = ({ communityAddress, directories }: { communityAddress
<ol>
{rules.map((rule: string, index: number) => (
<li key={`${index}-${rule}`}>
<Markdown content={rule} />
<Markdown content={rule} parseSpoilers={false} />
</li>
))}
</ol>
@@ -78,60 +181,33 @@ const BoardRulesDisplay = ({ communityAddress, directories }: { communityAddress
);
};
const BoardSelector = ({
directories,
selectedAddress,
onSelect,
}: {
directories: DirectoryCommunity[];
selectedAddress: string;
onSelect: (address: string) => void;
}) => {
// Load any board's own rules live from peers (P2P), separate from the static directory rules below.
// Once a board is loaded the action toggles to "Clear", which drops the result box and empties the input.
const LoadBoardRules = ({ onLoad, onClear, isLoaded }: { onLoad: (address: string) => void; onClear: () => void; isLoaded: boolean }) => {
const { t } = useTranslation();
const [customAddress, setCustomAddress] = useState('');
const selectedDefaultBoard = findDirectoryByAddress(directories, selectedAddress);
const selectedBoardValue = selectedDefaultBoard?.address ?? '';
const handleSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
if (value) {
onSelect(value);
setCustomAddress('');
}
};
const handleCustomSubmit = (e: FormEvent) => {
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const trimmed = customAddress.trim();
if (trimmed) {
onSelect(trimmed);
onLoad(trimmed);
}
};
const { t } = useTranslation();
const handleClear = () => {
setCustomAddress('');
onClear();
};
return (
<div className={`${styles.box} ${styles.selectorBox}`}>
<div className={styles.boxBar}>
<h2 className={styles.selectorBoxTitle}>Load rules from a board</h2>
<h2 className={styles.selectorBoxTitle}>Load rules P2P from any board</h2>
</div>
<div className={styles.boxContent}>
<div className={styles.selectorRow}>
<select value={selectedBoardValue} onChange={handleSelectChange} className={styles.boardSelect}>
<option value=''>Select board&hellip;</option>
{directories
.toSorted((a, b) => getBoardShortCode(a.title).localeCompare(getBoardShortCode(b.title)))
.map((sub) => {
const shortCode = getBoardShortCode(sub.title);
const boardName = getBoardName(sub.title);
return (
<option key={sub.address} value={sub.address}>
/{shortCode}/ - {boardName}
</option>
);
})}
</select>
<span className={styles.orSeparator}>or</span>
<form onSubmit={handleCustomSubmit} className={styles.customAddressForm}>
<form onSubmit={handleSubmit} className={styles.customAddressForm}>
<input
type='text'
aria-label={lowerCase(t('enter_board_address'))}
@@ -140,9 +216,15 @@ const BoardSelector = ({
onChange={(e) => setCustomAddress(e.target.value)}
className={styles.addressInput}
/>
<button type='submit' className={styles.goButton}>
Open Board
</button>
{isLoaded ? (
<button type='button' className={styles.goButton} onClick={handleClear}>
Clear
</button>
) : (
<button type='submit' className={styles.goButton}>
Load
</button>
)}
</form>
</div>
</div>
@@ -152,21 +234,44 @@ const BoardSelector = ({
const Rules = () => {
const { boardIdentifier } = useParams();
const navigate = useNavigate();
const directories = useDirectories();
const directoryDefaults = useDirectoryDefaults();
const [loadedAddress, setLoadedAddress] = useState('');
const scrolledForRef = useRef<string | null>(null);
const selectedAddress = boardIdentifier ? getCommunityAddress(boardIdentifier, directories) : '';
// Order directories alphabetically by directory code (e.g. /3/, /a/, /aco/...), like 4chan, not by title.
const directoriesWithCode = directories.filter((community) => getDirectoryCode(community)).toSorted((a, b) => getDirectoryCode(a).localeCompare(getDirectoryCode(b)));
const categoryGroups = groupDirectoriesByCategory(directoriesWithCode, directoryDefaults);
const handleBoardSelect = (address: string) => {
const path = getBoardPath(address, directories);
navigate(`/rules/${path}`, { replace: true });
const handleLoad = (address: string) => {
setLoadedAddress(getCommunityAddress(address, directories));
};
useEffect(() => {
window.scrollTo(0, 0);
document.title = 'Rules - 5chan';
}, []);
useEffect(() => {
setLoadedAddress('');
if (!boardIdentifier) {
scrolledForRef.current = null;
window.scrollTo(0, 0);
}
}, [boardIdentifier]);
// Deep-link: /rules/:code insta-scrolls to that directory's rules once the matching section is rendered.
useEffect(() => {
if (!boardIdentifier || scrolledForRef.current === boardIdentifier) {
return;
}
const code = resolveDirectoryCode(boardIdentifier, directories);
const element = code ? document.getElementById(code) : null;
if (element) {
element.scrollIntoView();
scrolledForRef.current = boardIdentifier;
}
}, [boardIdentifier, directories]);
return (
<div className={styles.wrapper}>
<div className={styles.content}>
@@ -177,14 +282,25 @@ const Rules = () => {
</div>
<div className={styles.boxContent}>
5chan does <i>not</i> have global rules or moderators. It is a serverless, adminless, static tool for browsing and posting to decentralized imageboards.{' '}
<strong>Each board sets its own rules independently</strong>, determined by the board owner and board admins, and enforced by the board moderators.
<strong>Each directory sets its own rules</strong>, listed below and expected of the boards that host it; individual board owners and admins may add their
own.
<br />
<br />
Please read and respect the rules of whatever board you decide to post to.
</div>
</div>
<BoardSelector directories={directories} selectedAddress={selectedAddress} onSelect={handleBoardSelect} />
{selectedAddress && <BoardRulesDisplay communityAddress={selectedAddress} directories={directories} />}
<LoadBoardRules onLoad={handleLoad} onClear={() => setLoadedAddress('')} isLoaded={!!loadedAddress} />
{loadedAddress && <BoardRulesDisplay communityAddress={loadedAddress} />}
<div className={styles.columns}>
<div className={styles.leftColumn}>
<DirectoryNav groups={categoryGroups} />
</div>
<div className={styles.rightColumn}>
{categoryGroups.map((group) => (
<CategoryRulesBox key={group.key} group={group} defaults={directoryDefaults} />
))}
</div>
</div>
<Footer />
</div>
</div>