Add board directory view (#1132)

* feat(directory): add board directory view

* fix(directory): populate board status

* style(directory): tighten board table

* docs(board manager): point board owners to manager

* fix(directory): show loading status

* style(directory): center board column in directory table

* perf(directory): cap board status checks

* style(directory): simplify board row links

* test(ci): stabilize coverage run

* test(ci): stabilize coverage harness

* test(ci): avoid async app flush act

* test(app): narrow layout harness coverage

* test(ci): stabilize app update distribution mock

* test(ci): preload app harness before route tests

* fix(directory): address final review findings
This commit is contained in:
Tommaso Casaburi
2026-05-19 23:34:33 +07:00
committed by GitHub
parent 4370e89174
commit cda1f7519f
62 changed files with 1572 additions and 171 deletions
@@ -0,0 +1,295 @@
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 Directory from '../directory';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const testState = vi.hoisted(() => ({
boardIdentifier: 'a' as string | undefined,
communities: {} as Record<string, { address: string; name?: string; state?: string; updatedAt?: number }>,
communityIdentifierRequests: [] as Array<string | undefined>,
directoryListLoading: false,
directoryBoards: [
{
address: 'anime-and-manga.bso',
score: 12,
managedByDevs: false,
},
],
directories: [
{
address: 'anime-and-manga.bso',
directoryCode: 'a',
title: '/a/ - Anime & Manga',
},
],
offlineHookRequests: [] as Array<{ address?: string; communityAddressHint?: string }>,
offlineHookValue: {
isOffline: false,
isOnlineStatusLoading: false,
offlineIconClass: '',
offlineTitle: false as string | false,
},
offlineStates: {} as Record<string, { state?: string; updatedAt?: number }>,
nowSeconds: 1_704_067_210,
}));
vi.mock('react-i18next', () => ({
Trans: ({ i18nKey }: { i18nKey: string }) => createElement(React.Fragment, null, i18nKey),
useTranslation: () => ({
t: (key: string, values?: Record<string, unknown>) => {
if (key === 'directory_status_online') return 'online';
if (key === 'directory_status_offline') return 'offline';
if (key === 'directory_heading') return `${values?.boardIdentifier} directory`;
if (key === 'view') return 'View';
return key;
},
}),
}));
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return {
...actual,
useParams: () => ({
boardIdentifier: testState.boardIdentifier,
}),
};
});
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
useCommunity: (options?: { community?: { name?: string; publicKey?: string } }) => {
const communityAddress = options?.community?.name ?? options?.community?.publicKey;
return communityAddress ? testState.communities[communityAddress] : undefined;
},
}));
vi.mock('../../../components/board-buttons/board-buttons', () => ({
BottomButton: () => createElement('button', { type: 'button' }, 'bottom'),
CatalogButton: () => createElement('a', null, 'catalog'),
ReturnButton: () => createElement('a', null, 'return'),
TopButton: () => createElement('button', { type: 'button' }, 'top'),
}));
vi.mock('../../../components/footer', () => ({
PageFooterDesktop: ({ firstRow, styleRow }: { firstRow: React.ReactNode; styleRow: React.ReactNode }) =>
createElement('footer', { 'data-testid': 'desktop-footer' }, firstRow, styleRow),
PageFooterMobile: ({ children }: { children: React.ReactNode }) => createElement('footer', { 'data-testid': 'mobile-footer' }, children),
ThreadFooterStyleRow: () => createElement('div', null, 'style'),
}));
vi.mock('../../../components/loading-ellipsis', () => ({
default: ({ string }: { string: string }) => createElement('span', null, string),
}));
vi.mock('../../../components/tooltip', () => ({
default: ({ content, children }: { content: React.ReactNode; children: React.ReactNode }) =>
createElement('span', { title: typeof content === 'string' ? content : undefined }, children),
}));
vi.mock('../../../hooks/use-directories', () => ({
useDirectories: () => testState.directories,
}));
vi.mock('../../../hooks/use-directory-list', async () => {
const actual = await vi.importActual<typeof import('../../../hooks/use-directory-list')>('../../../hooks/use-directory-list');
return {
...actual,
useDirectoryList: () => ({
list: {
directoryCode: testState.boardIdentifier,
title: '/a/ - Anime & Manga',
boards: testState.directoryBoards,
},
loading: testState.directoryListLoading,
error: null,
}),
};
});
vi.mock('../../../hooks/use-resolved-community-address', () => ({
useResolvedCommunityAddress: () => undefined,
}));
vi.mock('../../../hooks/use-community-identifiers', () => ({
useCommunityIdentifier: (address?: string) => {
testState.communityIdentifierRequests.push(address);
return address ? { name: address } : undefined;
},
}));
vi.mock('../../../hooks/use-is-community-offline', () => ({
default: (community?: { address?: string }, communityAddressHint?: string) => {
testState.offlineHookRequests.push({ address: community?.address, communityAddressHint });
return testState.offlineHookValue;
},
}));
vi.mock('../../../hooks/use-now-seconds', () => ({
useNowSeconds: () => testState.nowSeconds,
}));
vi.mock('../../../stores/use-community-offline-store', () => ({
default: <T,>(selector: (state: { communityOfflineState: typeof testState.offlineStates }) => T) =>
selector({
communityOfflineState: testState.offlineStates,
}),
}));
vi.mock('../../../lib/snow', () => ({
shouldShowSnow: () => false,
}));
let container: HTMLDivElement;
let originalAlert: typeof window.alert;
let root: Root;
const renderDirectory = async () => {
await act(async () => {
root.render(createElement(MemoryRouter, {}, createElement(Directory)));
});
};
const createDirectoryBoard = (address: string, score = 12) => ({
address,
score,
managedByDevs: false,
});
const createCommunity = (address: string, updatedAt = testState.nowSeconds - 60) => ({
address,
name: address,
state: 'started',
updatedAt,
});
const getDirectoryRow = (address = 'anime-and-manga.bso') => Array.from(container.querySelectorAll('tbody tr')).find((row) => row.textContent?.includes(address));
describe('Directory', () => {
beforeEach(() => {
vi.clearAllMocks();
testState.boardIdentifier = 'a';
testState.communities = {
'anime-and-manga.bso': {
address: 'anime-and-manga.bso',
name: 'anime-and-manga.bso',
state: 'started',
updatedAt: testState.nowSeconds - 60,
},
};
testState.communityIdentifierRequests = [];
testState.directoryListLoading = false;
testState.directoryBoards = [createDirectoryBoard('anime-and-manga.bso')];
testState.directories = [
{
address: 'anime-and-manga.bso',
directoryCode: 'a',
title: '/a/ - Anime & Manga',
},
];
testState.offlineHookRequests = [];
testState.offlineHookValue = {
isOffline: false,
isOnlineStatusLoading: false,
offlineIconClass: '',
offlineTitle: false,
};
testState.offlineStates = {};
testState.nowSeconds = 1_704_067_210;
originalAlert = window.alert;
window.alert = vi.fn();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
window.alert = originalAlert;
container.remove();
});
it('shows online status for a listed board after loading its community', async () => {
await renderDirectory();
const cells = Array.from(getDirectoryRow()?.querySelectorAll('td') ?? []).map((cell) => cell.textContent?.replace(/\s+/g, ' ').trim());
expect(cells.slice(0, 5)).toEqual(['1', 'anime-and-manga.bso', 'directory_owner_anonymous', 'online', '12']);
expect(cells[5]).toContain('+1');
expect(cells[5]).toContain('-1');
expect(cells[5]).toContain('View');
expect(getDirectoryRow()?.querySelector('td:nth-child(2) a')).toBeNull();
expect(getDirectoryRow()?.querySelector('td:nth-child(2) span')).toBeNull();
expect(testState.communityIdentifierRequests).toContain('anime-and-manga.bso');
expect(testState.offlineHookRequests).toContainEqual({
address: 'anime-and-manga.bso',
communityAddressHint: 'anime-and-manga.bso',
});
});
it('shows loading status while the listed board status is loading', async () => {
testState.communities = {};
testState.offlineHookValue = {
isOffline: false,
isOnlineStatusLoading: true,
offlineIconClass: 'yellowOfflineIcon',
offlineTitle: 'downloading board...',
};
await renderDirectory();
expect(getDirectoryRow()?.textContent).toContain('loading');
});
it('shows a placeholder when listed board status is unknown', async () => {
testState.communities = {};
await renderDirectory();
const cells = Array.from(getDirectoryRow()?.querySelectorAll('td') ?? []).map((cell) => cell.textContent?.replace(/\s+/g, ' ').trim());
expect(cells[3]).toBe('—');
});
it('shows offline status when the listed board community is stale', async () => {
testState.communities['anime-and-manga.bso'] = {
address: 'anime-and-manga.bso',
name: 'anime-and-manga.bso',
state: 'started',
updatedAt: testState.nowSeconds - 31 * 60,
};
await renderDirectory();
expect(getDirectoryRow()?.textContent).toContain('offline');
});
it('does not request status checks after the top five boards', async () => {
const boards = Array.from({ length: 6 }, (_, index) => createDirectoryBoard(`board-${index + 1}.bso`, 100 - index));
testState.directoryBoards = boards;
testState.communities = Object.fromEntries(boards.map((board) => [board.address, createCommunity(board.address)]));
await renderDirectory();
for (const board of boards.slice(0, 5)) {
expect(testState.communityIdentifierRequests).toContain(board.address);
expect(testState.offlineHookRequests).toContainEqual({
address: board.address,
communityAddressHint: board.address,
});
}
expect(testState.communityIdentifierRequests).not.toContain('board-6.bso');
expect(testState.offlineHookRequests).not.toContainEqual({
address: 'board-6.bso',
communityAddressHint: 'board-6.bso',
});
const cells = Array.from(getDirectoryRow('board-6.bso')?.querySelectorAll('td') ?? []).map((cell) => cell.textContent?.replace(/\s+/g, ' ').trim());
expect(cells[3]).toBe('—?');
expect(getDirectoryRow('board-6.bso')?.querySelector('sup')?.closest('span')?.getAttribute('title')).toBe('directory_status_unavailable_reason');
});
});
+306
View File
@@ -0,0 +1,306 @@
.page {
max-width: none;
margin: 0;
padding: 0 0 24px;
color: var(--body-font-color);
font-family: var(--body-font-family);
font-size: var(--body-font-size);
}
.desktopDivider {
display: block;
}
.divider {
display: block;
}
.desktopNavLinks {
display: flex;
align-items: center;
gap: 4px;
text-align: left;
font-size: 13px;
text-transform: capitalize;
}
.desktopNavLinks a:not([class~='button']),
.desktopFooterButtons a:not([class~='button']) {
all: unset;
}
.mobileNavLinks {
display: none;
text-align: center;
text-transform: capitalize;
margin-top: 5px;
}
.mobileNavLinks button {
text-transform: capitalize;
margin: 5px 2px;
}
.mobileNavLinks a:not([class~='button']),
.mobileFooterButtons a:not([class~='button']) {
all: unset;
}
.desktopFooterButtons {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
text-transform: capitalize;
}
.mobileFooterButtons {
text-align: center;
text-transform: capitalize;
}
.mobileFooterButtons button {
text-transform: capitalize;
margin: 5px 2px;
}
.directorySummary {
margin: 0 0 12px;
text-align: center;
font-size: 10pt;
font-weight: 700;
}
.directoryIntro {
width: 80%;
max-width: 810px;
margin: 0 auto 8px;
text-align: center;
font-size: 12px;
line-height: 1.4;
}
.directoryIntro a {
color: var(--post-link-text-color);
text-decoration: var(--post-link-text-decoration);
}
.directoryIntro a:hover {
color: var(--post-link-text-color-hover);
}
.error {
margin-bottom: 8px;
}
.flashListing {
width: 80%;
max-width: 810px;
margin: 10px auto 0;
border-collapse: separate;
border-spacing: 1px;
table-layout: auto;
}
.flashListing td {
padding: 2px;
font-size: 12px;
text-align: center;
}
.flashListing thead td {
background: #98e;
border: 1px solid #000;
font-weight: 700;
}
.dirRow {
background: transparent;
}
.numberCell,
.scoreCell,
.ownerCell,
.statusCell,
.actionsCell {
white-space: nowrap;
}
.numberCell,
.scoreCell,
.actionsCell {
width: 1%;
}
.postblock {
padding: 5px !important;
text-align: center;
}
.boardCol {
text-align: center;
vertical-align: middle;
word-break: break-all;
}
.ownerCell {
text-align: center;
}
.ownerName {
font-weight: var(--post-name-font-weight, 700);
}
.viewLink {
color: var(--post-link-text-color);
text-decoration: var(--post-link-text-decoration);
}
.viewLink:hover {
color: var(--post-link-text-color-hover);
}
.actionButton {
all: unset;
cursor: pointer;
color: var(--post-link-text-color);
text-decoration: var(--post-link-text-decoration);
}
.actionButton:hover,
.actionButton:focus-visible {
color: var(--post-link-text-color-hover);
}
.actionButton:focus-visible {
outline: 1px dotted currentcolor;
outline-offset: 1px;
}
.scoreValue {
font-weight: 700;
}
.statusOnline {
color: green;
}
.statusOffline {
color: red;
}
.statusUnavailable {
color: var(--body-font-color);
}
.statusUnavailableHelp {
margin-left: 2px;
font-weight: 700;
cursor: help;
}
.directoryFootnote {
width: 80%;
max-width: 810px;
margin: 14px auto 0;
text-align: center;
font-size: 11px;
line-height: 1.4;
opacity: 0.85;
}
.directoryFootnote a {
color: var(--post-link-text-color);
text-decoration: var(--post-link-text-decoration);
}
.directoryFootnote a:hover {
color: var(--post-link-text-color-hover);
}
.footerState {
margin: 8px 0;
text-align: center;
}
.garland {
border-image-slice: 50 0 50 0;
border-image-width: 40px 0px 0px 0px;
border-image-outset: 0px 0px 0px 0px;
border-image-repeat: repeat repeat;
border-image-source: url('/assets/garland.png');
border-style: solid;
padding-top: 50px;
}
@media (max-width: 640px) {
.desktopDivider,
.desktopNavLinks {
display: none;
}
.mobileNavLinks {
display: block;
}
.flashListing,
.directoryIntro,
.directoryFootnote {
width: calc(100% - 10px);
max-width: calc(100% - 10px);
margin-left: auto;
margin-right: auto;
}
.flashListing {
margin-top: 10px;
}
.actionsCell {
white-space: nowrap;
}
}
:global(body.yotsuba) .rowOdd td {
background: #ede2d4;
}
:global(body.yotsuba) .flashListing thead td {
background: #ea8;
}
:global(body.yotsuba-b) .rowOdd td {
background: #e0e5f6;
}
:global(body.futaba) .rowOdd td {
background: #ede2d4;
}
:global(body.futaba) .flashListing thead td {
background: #f0e0d6;
}
:global(body.burichan) .rowOdd td {
background: #e0e5f6;
}
:global(body.burichan) .flashListing thead td {
background: #c3c9e9;
}
:global(body.tomorrow) .rowOdd td {
background: rgba(255, 255, 255, 0.1);
}
:global(body.tomorrow) .flashListing thead td {
background: #b294bb;
}
:global(body.photon) .rowOdd td {
background: #888;
}
:global(body.photon) .flashListing thead td {
background: #ddd;
}
+270
View File
@@ -0,0 +1,270 @@
import { useEffect, useMemo } from 'react';
import { Link, Navigate, useParams } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next';
import { useCommunity } from '@bitsocial/bitsocial-react-hooks';
import { shouldShowSnow } from '../../lib/snow';
import { BottomButton, CatalogButton, ReturnButton, TopButton } from '../../components/board-buttons/board-buttons';
import { PageFooterDesktop, PageFooterMobile, ThreadFooterStyleRow } from '../../components/footer';
import LoadingEllipsis from '../../components/loading-ellipsis';
import Tooltip from '../../components/tooltip';
import { useDirectories } from '../../hooks/use-directories';
import { useCommunityIdentifier } from '../../hooks/use-community-identifiers';
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
import { isDirectoryRoute } from '../../lib/utils/route-utils';
import { DirectoryListBoard, sortDirectoryBoardsByRank, useDirectoryList } from '../../hooks/use-directory-list';
import { type CommunityFreshnessState, isCommunityKnownOffline } from '../../lib/utils/community-freshness-utils';
import getShortAddress from '../../lib/get-short-address';
import { get5chanDeveloperBadge } from '../../lib/utils/author-display-utils';
import useCommunityOfflineStore from '../../stores/use-community-offline-store';
import useIsCommunityOffline from '../../hooks/use-is-community-offline';
import { useNowSeconds } from '../../hooks/use-now-seconds';
import postStyles from '../post/post.module.css';
import styles from './directory.module.css';
const DIRECTORY_STATUS_CHECK_LIMIT = 5;
const DIRECTORY_STATUS_UNAVAILABLE_MARKER = '\u2014';
const computeBoardStatus = (
communityState: CommunityFreshnessState | undefined,
offlineState: CommunityFreshnessState | undefined,
nowSeconds: number,
isOffline: boolean,
isOnlineStatusLoading: boolean,
): 'online' | 'offline' | 'loading' | 'unknown' => {
const freshnessState = {
state: communityState?.state ?? offlineState?.state,
updatedAt: communityState?.updatedAt ?? offlineState?.updatedAt,
};
if (isOffline || isCommunityKnownOffline(freshnessState, nowSeconds)) return 'offline';
if (isOnlineStatusLoading) return 'loading';
if (!freshnessState.updatedAt) return 'unknown';
return 'online';
};
const PASS_LINK = '/pass';
const DirectoryDesktopTopControls = ({ communityAddress }: { communityAddress: string | undefined }) => (
<div className={styles.desktopNavLinks}>
<span>
[<ReturnButton address={communityAddress} />]
</span>
<span>
[<CatalogButton address={communityAddress} />]
</span>
<span>
[<BottomButton />]
</span>
</div>
);
const DirectoryDesktopFooterControls = ({ communityAddress }: { communityAddress: string | undefined }) => (
<div className={styles.desktopFooterButtons}>
<span>
[<ReturnButton address={communityAddress} />]
</span>
<span>
[<CatalogButton address={communityAddress} />]
</span>
<span>
[<TopButton />]
</span>
</div>
);
const DirectoryMobileTopControls = ({ communityAddress }: { communityAddress: string | undefined }) => (
<div className={styles.mobileNavLinks}>
<ReturnButton address={communityAddress} />
<CatalogButton address={communityAddress} />
<BottomButton />
</div>
);
const DirectoryMobileFooterControls = ({ communityAddress }: { communityAddress: string | undefined }) => (
<div className={styles.mobileFooterButtons}>
<ReturnButton address={communityAddress} />
<CatalogButton address={communityAddress} />
<TopButton />
</div>
);
interface DirectoryRowProps {
board: DirectoryListBoard;
nowSeconds: number;
rank: number;
onVote: () => void;
}
const DirectoryRow = ({ board, nowSeconds, rank, onVote }: DirectoryRowProps) => {
const { t } = useTranslation();
const statusUnavailableReason = t('directory_status_unavailable_reason');
const ownerAddress = board.owner;
const ownerDisplay = ownerAddress ? getShortAddress(ownerAddress) || ownerAddress : undefined;
const developerBadge = get5chanDeveloperBadge(ownerAddress);
const shouldCheckStatus = rank <= DIRECTORY_STATUS_CHECK_LIMIT;
const communityIdentifier = useCommunityIdentifier(shouldCheckStatus ? board.address : undefined);
const community = useCommunity(shouldCheckStatus && communityIdentifier ? { community: communityIdentifier } : undefined);
const { isOffline, isOnlineStatusLoading } = useIsCommunityOffline(community, shouldCheckStatus ? board.address : undefined);
const offlineState = useCommunityOfflineStore((state) => (shouldCheckStatus ? state.communityOfflineState[board.address] : undefined));
const status = shouldCheckStatus ? computeBoardStatus(community, offlineState, nowSeconds, isOffline, isOnlineStatusLoading) : 'unavailable';
const boardLink = `/${board.address}`;
return (
<tr className={`${styles.dirRow} ${rank % 2 === 1 ? styles.rowOdd : ''}`}>
<td className={styles.numberCell}>{rank}</td>
<td className={styles.boardCol}>{board.address}</td>
<td className={styles.ownerCell}>
<span className={developerBadge ? `${styles.ownerName} ${postStyles.capcodeAdmin}` : undefined}>
{ownerDisplay ?? t('directory_owner_anonymous')}
{developerBadge && (
<>
{' ## '}
{developerBadge.label} <span className={`${postStyles.capcodeIcon} ${postStyles.capcodeAdminIcon}`} title={developerBadge.title} />
</>
)}
</span>
</td>
<td className={styles.statusCell}>
{status === 'unavailable' ? (
<span className={styles.statusUnavailable}>
{DIRECTORY_STATUS_UNAVAILABLE_MARKER}
<Tooltip content={statusUnavailableReason}>
<sup className={styles.statusUnavailableHelp} aria-label={statusUnavailableReason} tabIndex={0}>
?
</sup>
</Tooltip>
</span>
) : status === 'loading' ? (
<LoadingEllipsis string={t('loading')} />
) : status === 'unknown' ? (
<span className={styles.statusUnavailable}>{DIRECTORY_STATUS_UNAVAILABLE_MARKER}</span>
) : (
<span className={status === 'offline' ? styles.statusOffline : styles.statusOnline}>
{t(status === 'offline' ? 'directory_status_offline' : 'directory_status_online')}
</span>
)}
</td>
<td className={styles.scoreCell}>
<span className={styles.scoreValue}>{board.score}</span>
</td>
<td className={styles.actionsCell}>
[
<button type='button' className={styles.actionButton} onClick={onVote} aria-label={t('upvote')} title={t('upvote')}>
+1
</button>
] [
<button type='button' className={styles.actionButton} onClick={onVote} aria-label={t('downvote')} title={t('downvote')}>
-1
</button>
] [
<Link to={boardLink} className={styles.viewLink}>
{t('view')}
</Link>
]
</td>
</tr>
);
};
const getRepoEditUrl = (directoryCode: string) => `https://github.com/bitsocialnet/lists/edit/master/5chan-${directoryCode}-directory.json`;
const Directory = () => {
const { t } = useTranslation();
const params = useParams();
const boardIdentifier = params.boardIdentifier;
const directories = useDirectories();
const isValidDirectoryCode = !!boardIdentifier && isDirectoryRoute(boardIdentifier, directories);
const { list, loading } = useDirectoryList(isValidDirectoryCode ? boardIdentifier : undefined);
const communityAddress = useResolvedCommunityAddress();
const nowSeconds = useNowSeconds();
const ranked = useMemo(() => (list ? sortDirectoryBoardsByRank(list.boards) : []), [list]);
const directoryTitle = list?.title || (boardIdentifier ? `/${boardIdentifier}/ - ${t('directory')}` : t('directory'));
useEffect(() => {
if (!isValidDirectoryCode) return;
document.title = `${directoryTitle} - 5chan`;
}, [directoryTitle, isValidDirectoryCode]);
if (!isValidDirectoryCode) {
return <Navigate to='/not-found' replace />;
}
const handleVoteUnavailable = () => {
const values = { boardIdentifier };
window.alert(`${t('directory_voting_unavailable_intro', values)}\n\n${t('directory_voting_unavailable_outro', values)}`);
};
const isLoadingShell = loading && ranked.length === 0;
const boardCount = ranked.length;
const repoEditUrl = getRepoEditUrl(boardIdentifier!);
return (
<div id='top' className={`${styles.page} ${shouldShowSnow() ? styles.garland : ''}`}>
<DirectoryMobileTopControls communityAddress={communityAddress} />
<hr className={styles.desktopDivider} />
<DirectoryDesktopTopControls communityAddress={communityAddress} />
<hr className={styles.divider} />
{isLoadingShell ? (
<h4 className={styles.directorySummary}>
<LoadingEllipsis string={t('loading_directory')} />
</h4>
) : ranked.length === 0 ? (
<h4 className={styles.directorySummary}>{t('directory_empty')}</h4>
) : (
<h4 className={styles.directorySummary}>{t('directory_heading', { boardIdentifier, count: boardCount })}</h4>
)}
{!isLoadingShell && ranked.length > 0 && (
<>
<table className={styles.flashListing}>
<thead>
<tr>
<th className={styles.postblock} scope='col'>
No.
</th>
<th className={styles.postblock} scope='col'>
{t('directory_board')}
</th>
<th className={styles.postblock} scope='col'>
{t('directory_owner')}
</th>
<th className={styles.postblock} scope='col'>
{t('directory_status')}
</th>
<th className={styles.postblock} scope='col'>
{t('directory_score')}
</th>
<th className={styles.postblock} scope='col'>
{t('directory_vote')}
</th>
</tr>
</thead>
<tbody>
{ranked.map((board, index) => (
<DirectoryRow key={board.address} board={board} nowSeconds={nowSeconds} rank={index + 1} onVote={handleVoteUnavailable} />
))}
</tbody>
</table>
<div className={styles.directoryFootnote}>
<Trans
i18nKey='directory_footnote'
values={{ boardIdentifier }}
components={{
passLink: <Link to={PASS_LINK} />,
repoLink: <a href={repoEditUrl} target='_blank' rel='noreferrer noopener' />,
}}
/>
</div>
</>
)}
<PageFooterDesktop firstRow={<DirectoryDesktopFooterControls communityAddress={communityAddress} />} styleRow={<ThreadFooterStyleRow />} />
<PageFooterMobile>
<DirectoryMobileFooterControls communityAddress={communityAddress} />
</PageFooterMobile>
</div>
);
};
export default Directory;
+4 -11
View File
@@ -316,15 +316,12 @@ const FAQ_SECTIONS: FAQSection[] = [
question: 'Can I create my own board?',
answer: (
<>
Yes. A 5chan board is a Bitsocial community. Today the practical route is to run{' '}
<a href='https://github.com/bitsocialnet/bitsocial-cli' {...externalLinkProps}>
bitsocial-cli
</a>{' '}
as a node and use{' '}
Yes. A 5chan board is a Bitsocial community. Today the practical route is to use{' '}
<a href='https://github.com/bitsocialnet/5chan-board-manager' {...externalLinkProps}>
5chan Board Manager
</a>{' '}
for imageboard lifecycle rules such as thread limits, bump limits, archived-thread retention, and purging of author-deleted content.
to run the board with imageboard lifecycle rules such as thread limits, bump limits, archived-thread retention, and purging of author-deleted content. Its
Docker setup can start the required Bitsocial node for you or connect to one you already operate.
</>
),
},
@@ -557,14 +554,10 @@ const FAQ_SECTIONS: FAQSection[] = [
answer: (
<>
The 5chan client is free and open-source software under GPL-3.0-or-later. Boards are Bitsocial communities, typically run with{' '}
<a href='https://github.com/bitsocialnet/bitsocial-cli' {...externalLinkProps}>
bitsocial-cli
</a>{' '}
and, for imageboard-specific behavior,{' '}
<a href='https://github.com/bitsocialnet/5chan-board-manager' {...externalLinkProps}>
5chan Board Manager
</a>
.
, which connects to the Bitsocial network and applies 5chan-specific board behavior.
</>
),
},