feat(blotter): replace board stats with changelog-backed blotter

Add static blotter snapshot, route /blotter, compact board blotter (Hide/Show All).
Include yarn blotter tooling and AGENTS rules so every changelog version has a
release line. 4chan-style table UI, subplebbit→board and plebchan→5chan in messages.
This commit is contained in:
plebeius
2026-02-22 19:37:49 +08:00
parent a13c72f888
commit 0cb7b1a970
54 changed files with 1180 additions and 430 deletions
+4 -2
View File
@@ -16,6 +16,7 @@ import { useDirectories } from './hooks/use-directories';
import { useResolvedSubplebbitAddress } from './hooks/use-resolved-subplebbit-address';
import { getSubplebbitAddress, isPostRoute, isPendingPostRoute, isModQueueRoute } from './lib/utils/route-utils';
import styles from './app.module.css';
import Blotter from './views/blotter';
import FAQ from './views/faq';
import Home from './views/home';
import Rules from './views/rules';
@@ -33,7 +34,7 @@ import CreateBoardModal from './components/create-board-modal';
import FeedCacheContainer from './components/feed-cache-container';
import ReplyModal from './components/reply-modal';
import PostForm from './components/post-form';
import SubplebbitStats from './components/subplebbit-stats';
import BoardBlotter from './components/board-blotter';
import TopBar from './components/topbar';
import TopbarEditModal from './components/topbar-edit-modal';
import DirectoryModal from './components/directory-modal';
@@ -111,7 +112,7 @@ const BoardLayout = () => {
: (subplebbitAddress || isInAllView || isInModView || isInSubscriptionsView || pendingPost?.subplebbitAddress || isOnModQueueRoute) && (
<>
<PostForm key={key} />
{!(isInAllView || isInSubscriptionsView || isInModView) && !isOnModQueueRoute && <SubplebbitStats />}
{!(isInAllView || isInSubscriptionsView || isInModView) && !isOnModQueueRoute && <BoardBlotter />}
<DesktopBoardButtons />
</>
)}
@@ -242,6 +243,7 @@ const App = () => {
<Route path='/' element={<Home />} />
<Route path='/faq' element={<FAQ />} />
<Route path='/rules/:boardIdentifier?' element={<Rules />} />
<Route path='/blotter' element={<Blotter />} />
<Route element={<BoardLayout />}>
{/* Canonical multiboard routes (no time filter) */}
<Route path='/all' element={boardFeedElement} />
@@ -0,0 +1,9 @@
.versionLink {
color: var(--button-desktop-text-color);
text-decoration: var(--button-text-decoration);
text-transform: none;
}
.versionLink:hover {
color: var(--button-desktop-text-color-hover);
}
@@ -0,0 +1,26 @@
import type { BlotterEntry } from '../../lib/utils/blotter-utils';
import styles from './blotter-message.module.css';
const RELEASES_BASE = 'https://github.com/bitsocialhq/5chan/releases/tag/v';
function normalizeMessage(text: string): string {
return text.replace(/subplebbit/gi, 'board').replace(/plebchan/gi, '5chan');
}
const BlotterMessage = ({ entry }: { entry: BlotterEntry }) => {
if (entry.kind === 'release' && entry.version) {
const idx = entry.message.indexOf(': ');
const oneLiner = idx >= 0 ? entry.message.slice(idx + 2) : entry.message;
return (
<>
<a href={`${RELEASES_BASE}${entry.version}`} className={styles.versionLink} target='_blank' rel='noopener noreferrer'>
v{entry.version}
</a>
: {normalizeMessage(oneLiner)}
</>
);
}
return <>{normalizeMessage(entry.message)}</>;
};
export default BlotterMessage;
+1
View File
@@ -0,0 +1 @@
export { default } from './blotter-message';
@@ -27,28 +27,21 @@
text-align: right;
}
.hideButton {
.hideButton,
.actionLink {
color: var(--button-desktop-text-color);
text-decoration: var(--button-text-decoration);
text-transform: capitalize;
}
.hideButton:hover {
.hideButton:hover,
.actionLink:hover {
cursor: pointer;
color: var(--button-desktop-text-color-hover);
}
.statValue {
color: var(--button-desktop-text-color);
}
.lowercase {
text-transform: lowercase;
}
@media (max-width: 640px) {
.content {
display: none;
}
}
}
@@ -0,0 +1,65 @@
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import blotterData from '../../data/5chan-blotter.json';
import BlotterMessage from '../blotter-message';
import { formatBlotterDate, getBlotterPreview, isBlotterEntry, sortBlotterEntries } from '../../lib/utils/blotter-utils';
import useBlotterVisibilityStore from '../../stores/use-blotter-visibility-store';
import styles from './board-blotter.module.css';
const BoardBlotter = () => {
const { t } = useTranslation();
const { isHidden, toggleVisibility } = useBlotterVisibilityStore();
const entries = (blotterData as { entries?: unknown[] }).entries ?? [];
const validEntries = entries.filter(isBlotterEntry);
const sorted = sortBlotterEntries(validEntries);
const preview = getBlotterPreview(sorted);
return (
<div className={`${styles.content} ${styles.show}`}>
<table className={styles.blotter}>
<thead>
<tr>
<td>
<hr />
</td>
</tr>
</thead>
{!isHidden && (
<tbody>
{preview.map((entry) => (
<tr key={entry.id}>
<td>
{formatBlotterDate(entry.timestamp)} <BlotterMessage entry={entry} />
</td>
</tr>
))}
</tbody>
)}
<tfoot>
<tr>
<td colSpan={2}>
[
<span className={styles.hideButton} onClick={() => toggleVisibility()}>
{isHidden ? t('show_blotter') : t('hide')}
</span>
]
{!isHidden && (
<>
{' '}
[
<Link to='/blotter' className={styles.actionLink}>
{t('show_all')}
</Link>
]
</>
)}
</td>
</tr>
</tfoot>
</table>
</div>
);
};
export default BoardBlotter;
+1
View File
@@ -0,0 +1 @@
export { default } from './board-blotter';
-1
View File
@@ -1 +0,0 @@
export { default } from './subplebbit-stats';
@@ -1,108 +0,0 @@
import { useParams } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next';
import { useAccountComment, useSubplebbit, useSubplebbitStats } from '@plebbit/plebbit-react-hooks';
import useSubplebbitsPagesStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits-pages';
import useSubplebbitStatsVisibilityStore from '../../stores/use-subplebbit-stats-visibility-store';
import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address';
import styles from './subplebbit-stats.module.css';
const SubplebbitStats = () => {
const { t } = useTranslation();
const params = useParams();
const accountComment = useAccountComment({ commentIndex: params?.accountCommentIndex as any });
const resolvedAddress = useResolvedSubplebbitAddress();
const subplebbitAddress = resolvedAddress || accountComment?.subplebbitAddress;
const subplebbit = useSubplebbit({ subplebbitAddress });
const { address, createdAt } = subplebbit || {};
const stats = useSubplebbitStats({ subplebbitAddress: address });
const { hiddenStats, toggleVisibility } = useSubplebbitStatsVisibilityStore();
const isHidden = hiddenStats[address];
const comment = useSubplebbitsPagesStore((state) => state.comments[params?.commentCid as string]);
const { deleted, locked, removed } = comment || {};
const hideStats = deleted || locked || removed;
const unixToMMDDYYYY = (timestamp: number) => {
const date = new Date(timestamp * 1000);
const month = ('0' + (date.getMonth() + 1)).slice(-2);
const day = ('0' + date.getDate()).slice(-2);
const year = date.getFullYear().toString().slice(-2);
return month + '/' + day + '/' + year;
};
return (
<div className={`${styles.content} ${hideStats ? styles.hide : styles.show}`}>
<table className={styles.blotter}>
<thead>
<tr>
<td>
<hr />
</td>
</tr>
</thead>
{!isHidden && (
<tbody>
<tr>
<td>
<Trans
i18nKey='board_stats_hour'
values={{ userCount: stats.hourActiveUserCount ?? '?', postCount: stats.hourPostCount ?? '?' }}
components={{ 1: <span key='hour-stat-value' className={styles.statValue} /> }}
/>
{' / '}
<Trans
i18nKey='board_stats_day'
values={{ userCount: stats.dayActiveUserCount ?? '?', postCount: stats.dayPostCount ?? '?' }}
components={{ 1: <span key='day-stat-value' className={styles.statValue} /> }}
/>
</td>
</tr>
<tr>
<td>
<Trans
i18nKey='board_stats_week'
values={{ userCount: stats.weekActiveUserCount ?? '?', postCount: stats.weekPostCount ?? '?' }}
components={{ 1: <span key='week-stat-value' className={styles.statValue} /> }}
/>
{' / '}
<Trans
i18nKey='board_stats_month'
values={{ userCount: stats.monthActiveUserCount ?? '?', postCount: stats.monthPostCount ?? '?' }}
components={{ 1: <span key='month-stat-value' className={styles.statValue} /> }}
/>
</td>
</tr>
<tr>
<td className={styles.lowercase}>
{createdAt && unixToMMDDYYYY(createdAt)} {t('board_created')}
{' / '}
<Trans
i18nKey='board_stats_all'
values={{ userCount: stats.allActiveUserCount ?? '?', postCount: stats.allPostCount ?? '?' }}
components={{ 1: <span key='all-stat-value' className={styles.statValue} /> }}
/>
</td>
</tr>
</tbody>
)}
<tfoot>
<tr>
<td colSpan={2}>
[
<span className={styles.hideButton} onClick={() => toggleVisibility(address)}>
{isHidden ? t('show_stats') : t('hide')}
</span>
]
</td>
</tr>
</tfoot>
</table>
</div>
);
};
export default SubplebbitStats;
+298
View File
@@ -0,0 +1,298 @@
{
"entries": [
{
"id": "release-0.6.6",
"kind": "release",
"timestamp": 1771243200,
"message": "v0.6.6: Syncs board dirs from GitHub, macOS icon, reply perf",
"version": "0.6.6"
},
{
"id": "release-0.6.5",
"kind": "release",
"timestamp": 1770811200,
"message": "v0.6.5: quotedCids in replies, OP backlinks, dependabot fix",
"version": "0.6.5"
},
{
"id": "release-0.6.4",
"kind": "release",
"timestamp": 1770724800,
"message": "v0.6.4: Quote links, backlinks, pseudonymityMode, release artifacts",
"version": "0.6.4"
},
{
"id": "release-0.6.3",
"kind": "release",
"timestamp": 1769774400,
"message": "v0.6.3: Electron icon config, production build fixes",
"version": "0.6.3"
},
{
"id": "release-0.6.2",
"kind": "release",
"timestamp": 1769428800,
"message": "v0.6.2: Mod-queue compact view, pending approval features",
"version": "0.6.2"
},
{
"id": "release-0.6.1",
"kind": "release",
"timestamp": 1769342400,
"message": "v0.6.1: Major CI, Android, mod-queue, and routing fixes",
"version": "0.6.1"
},
{
"id": "release-0.5.3",
"kind": "release",
"timestamp": 1753790400,
"message": "v0.5.3: Pending post redirect, challenge verification, libp2p deps",
"version": "0.5.3"
},
{
"id": "release-0.5.2",
"kind": "release",
"timestamp": 1749038400,
"message": "v0.5.2: Board search, board address length, markdown 5chan links",
"version": "0.5.2"
},
{
"id": "release-0.5.1",
"kind": "release",
"timestamp": 1748952000,
"message": "v0.5.1: Android fullscreen fix, electron clipboard, reply filtering",
"version": "0.5.1"
},
{
"id": "release-0.5.0",
"kind": "release",
"timestamp": 1747915200,
"message": "v0.5.0: Electron IPFS retry, markdown spoiler, pending post redirect",
"version": "0.5.0"
},
{
"id": "release-0.4.0",
"kind": "release",
"timestamp": 1741348800,
"message": "v0.4.0: Catalog filters, p/mod feed, catalog search, schema adapters",
"version": "0.4.0"
},
{
"id": "release-0.3.6",
"kind": "release",
"timestamp": 1740312000,
"message": "v0.3.6: Catalog instant posts, feed no-posts fix, spoiler visibility",
"version": "0.3.6"
},
{
"id": "release-0.3.5",
"kind": "release",
"timestamp": 1740052800,
"message": "v0.3.5: Auto-subscribe new accounts, markdown list fix, topbar links",
"version": "0.3.5"
},
{
"id": "release-0.3.4",
"kind": "release",
"timestamp": 1738756800,
"message": "v0.3.4: Boards list vichan-style, spoiler text, challenge modal, settings hash routing",
"version": "0.3.4"
},
{
"id": "release-0.3.3",
"kind": "release",
"timestamp": 1735128000,
"message": "v0.3.3: Snow effect mobile fix",
"version": "0.3.3"
},
{
"id": "release-0.3.2",
"kind": "release",
"timestamp": 1735041600,
"message": "v0.3.2: Christmas theme, avatar timestamp, electron routers",
"version": "0.3.2"
},
{
"id": "release-0.3.1",
"kind": "release",
"timestamp": 1731240000,
"message": "v0.3.1: Moderation API schema, post quote hover fix",
"version": "0.3.1"
},
{
"id": "release-0.3.0",
"kind": "release",
"timestamp": 1731067200,
"message": "v0.3.0: Android choose file, interface settings, reply modal errors",
"version": "0.3.0"
},
{
"id": "release-0.2.9",
"kind": "release",
"timestamp": 1730203200,
"message": "v0.2.9: Catalog bump order, Reddit embed, p/all time filter, Java APK fix",
"version": "0.2.9"
},
{
"id": "release-0.2.8",
"kind": "release",
"timestamp": 1729339200,
"message": "v0.2.8: Account storage alert, catalog thumbnails, embed support",
"version": "0.2.8"
},
{
"id": "release-0.2.7",
"kind": "release",
"timestamp": 1726920000,
"message": "v0.2.7: Deleted posts collapse styling, edit reason display",
"version": "0.2.7"
},
{
"id": "release-0.2.6",
"kind": "release",
"timestamp": 1726833600,
"message": "v0.2.6: Anon mode refresh fix, catalog filters save, feed instant posts",
"version": "0.2.6"
},
{
"id": "release-0.2.5",
"kind": "release",
"timestamp": 1725624000,
"message": "v0.2.5: Board buttons, catalog, edit menu, markdown fixes",
"version": "0.2.5"
},
{
"id": "release-0.2.4",
"kind": "release",
"timestamp": 1721736000,
"message": "v0.2.4: Avatars, board header offline icon, post loading states",
"version": "0.2.4"
},
{
"id": "release-0.2.3",
"kind": "release",
"timestamp": 1720008000,
"message": "v0.2.3: Feed description, mod menu fixes, catalog filters",
"version": "0.2.3"
},
{
"id": "release-0.2.2",
"kind": "release",
"timestamp": 1719057600,
"message": "v0.2.2: Catalog filter label, large image size fix",
"version": "0.2.2"
},
{
"id": "release-0.2.1",
"kind": "release",
"timestamp": 1718884800,
"message": "v0.2.1: Incorrect assets path fix",
"version": "0.2.1"
},
{
"id": "release-0.2.0",
"kind": "release",
"timestamp": 1718884800,
"message": "v0.2.0: Major rewrite with p/all, catalog, multiboards, mod menu",
"version": "0.2.0"
},
{
"id": "release-0.1.17",
"kind": "release",
"timestamp": 1703073600,
"message": "v0.1.17: Electron IPFS errors, SettingsModal signer fix",
"version": "0.1.17"
},
{
"id": "release-0.1.16",
"kind": "release",
"timestamp": 1702900800,
"message": "v0.1.16: SettingsModal signer preview fix",
"version": "0.1.16"
},
{
"id": "release-0.1.15",
"kind": "release",
"timestamp": 1702641600,
"message": "v0.1.15: multisub.json, share link copy, view on seedit",
"version": "0.1.15"
},
{
"id": "release-0.1.14",
"kind": "release",
"timestamp": 1697976000,
"message": "v0.1.14: Anon mode per-thread, home redesign, popular threads",
"version": "0.1.14"
},
{
"id": "release-0.1.12",
"kind": "release",
"timestamp": 1695038400,
"message": "v0.1.12: New version toast once fix",
"version": "0.1.12"
},
{
"id": "release-0.1.10",
"kind": "release",
"timestamp": 1691668800,
"message": "v0.1.10: Revert refresh button",
"version": "0.1.10"
},
{
"id": "release-0.1.8",
"kind": "release",
"timestamp": 1686312000,
"message": "v0.1.8: Maintenance release",
"version": "0.1.8"
},
{
"id": "release-0.1.7",
"kind": "release",
"timestamp": 1686225600,
"message": "v0.1.7: Revert toasts refactor",
"version": "0.1.7"
},
{
"id": "release-0.1.5",
"kind": "release",
"timestamp": 1683892800,
"message": "v0.1.5: Maintenance release",
"version": "0.1.5"
},
{
"id": "release-0.1.4",
"kind": "release",
"timestamp": 1683806400,
"message": "v0.1.4: Maintenance release",
"version": "0.1.4"
},
{
"id": "release-0.1.3",
"kind": "release",
"timestamp": 1683374400,
"message": "v0.1.3: Maintenance release",
"version": "0.1.3"
},
{
"id": "release-0.1.2",
"kind": "release",
"timestamp": 1682769600,
"message": "v0.1.2: Maintenance release",
"version": "0.1.2"
},
{
"id": "release-0.1.1",
"kind": "release",
"timestamp": 1682596800,
"message": "v0.1.1: Maintenance release",
"version": "0.1.1"
},
{
"id": "release-0.1.0",
"kind": "release",
"timestamp": 1682337600,
"message": "v0.1.0: Initial release",
"version": "0.1.0"
}
]
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Blotter utilities: sorting, preview slicing, date formatting, and entry guards.
* Board preview count is fixed at 3 entries to preserve compact block height.
*/
export type BlotterEntryKind = 'release' | 'manual';
export interface BlotterEntry {
id: string;
kind: BlotterEntryKind;
timestamp: number;
message: string;
version?: string;
}
/** Number of entries shown in the board blotter preview block. */
export const BLOTTER_PREVIEW_COUNT = 3;
/**
* Guards that an entry satisfies the BlotterEntry contract.
* Returns false for invalid entries (missing id, kind, timestamp, or message;
* kind=release without version).
*/
export function isBlotterEntry(entry: unknown): entry is BlotterEntry {
if (!entry || typeof entry !== 'object') return false;
const e = entry as Record<string, unknown>;
if (typeof e.id !== 'string' || !e.id.trim()) return false;
if (e.kind !== 'release' && e.kind !== 'manual') return false;
const ts = e.timestamp;
if (typeof ts !== 'number' || !Number.isFinite(ts) || ts < 0) return false;
if (typeof e.message !== 'string' || !e.message.trim()) return false;
if (e.kind === 'release' && (typeof e.version !== 'string' || !e.version.trim())) return false;
return true;
}
/**
* Filters and sorts entries by timestamp descending.
* Invalid entries are skipped. Ensures stable descending order.
*/
export function sortBlotterEntries<T extends { timestamp: number }>(entries: T[]): T[] {
return [...entries].sort((a, b) => b.timestamp - a.timestamp);
}
/**
* Returns the first N entries for board preview.
* Use BLOTTER_PREVIEW_COUNT (3) to preserve compact block height.
*/
export function getBlotterPreview<T>(entries: T[], count: number = BLOTTER_PREVIEW_COUNT): T[] {
return entries.slice(0, count);
}
/**
* Formats a unix timestamp (seconds) as MM/DD/YY.
*/
export function formatBlotterDate(timestamp: number): string {
if (!Number.isFinite(timestamp) || timestamp < 0) return '';
const date = new Date(timestamp * 1000);
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const day = String(date.getUTCDate()).padStart(2, '0');
const year = String(date.getUTCFullYear()).slice(-2);
return `${month}/${day}/${year}`;
}
@@ -0,0 +1,24 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface BlotterVisibilityState {
isHidden: boolean;
toggleVisibility: () => void;
}
const useBlotterVisibilityStore = create<BlotterVisibilityState>()(
persist(
(set) => ({
isHidden: false,
toggleVisibility: () =>
set((state) => ({
isHidden: !state.isHidden,
})),
}),
{
name: 'blotter-visibility',
},
),
);
export default useBlotterVisibilityStore;
@@ -1,27 +0,0 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface SubplebbitStatsVisibilityState {
hiddenStats: { [subplebbitAddress: string]: boolean };
toggleVisibility: (subplebbitAddress: string) => void;
}
const useSubplebbitStatsVisibilityStore = create<SubplebbitStatsVisibilityState>()(
persist(
(set) => ({
hiddenStats: {},
toggleVisibility: (subplebbitAddress) =>
set((state) => ({
hiddenStats: {
...state.hiddenStats,
[subplebbitAddress]: !state.hiddenStats[subplebbitAddress],
},
})),
}),
{
name: 'subplebbit-stats-visibility',
},
),
);
export default useSubplebbitStatsVisibilityStore;
+72
View File
@@ -0,0 +1,72 @@
.wrapper {
width: 100%;
}
.content {
margin: auto;
text-align: center;
padding: 0 10px;
}
.content h1 {
font-size: 28px;
font-weight: 700;
text-align: center;
margin: 20px 0 0;
font-family: 'Helvetica Neue', arial, sans-serif;
letter-spacing: -2px;
}
.table {
width: 80%;
max-width: 1000px;
margin: 25px auto;
border-collapse: collapse;
border: 1px solid #606060;
background-color: #fff;
color: #000;
}
.table th {
padding: 5px;
font-weight: 700;
background-color: #ffccaa;
vertical-align: middle;
}
.table .dateHeader {
text-align: center;
white-space: nowrap;
width: 90px;
}
.table .messageHeader {
text-align: left;
border-left: 1px solid #606060;
}
.table .dateCell {
white-space: nowrap;
width: 90px;
text-align: center;
}
.table .messageCell {
text-align: left;
border-left: 1px solid #606060;
}
.table td {
padding: 5px;
vertical-align: top;
}
.table tbody tr {
border-bottom: 1px solid #606060;
}
@media (max-width: 640px) {
.table {
width: calc(100% - 20px);
}
}
+48
View File
@@ -0,0 +1,48 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import blotterData from '../../data/5chan-blotter.json';
import BlotterMessage from '../../components/blotter-message';
import { formatBlotterDate, isBlotterEntry, sortBlotterEntries } from '../../lib/utils/blotter-utils';
import { Footer } from '../home';
import styles from './blotter.module.css';
const Blotter = () => {
const { t } = useTranslation();
const entries = (blotterData as { entries?: unknown[] }).entries ?? [];
const validEntries = entries.filter(isBlotterEntry);
const sorted = sortBlotterEntries(validEntries);
useEffect(() => {
window.scrollTo(0, 0);
document.title = 'Blotter - 5chan';
}, []);
return (
<div className={styles.wrapper}>
<div className={styles.content}>
<h1>5chan Blotter</h1>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.dateHeader}>{t('date')}</th>
<th className={styles.messageHeader}>{t('message')}</th>
</tr>
</thead>
<tbody>
{sorted.map((entry) => (
<tr key={entry.id}>
<td className={styles.dateCell}>{formatBlotterDate(entry.timestamp)}</td>
<td className={styles.messageCell}>
<BlotterMessage entry={entry} />
</td>
</tr>
))}
</tbody>
</table>
<Footer />
</div>
</div>
);
};
export default Blotter;
+1
View File
@@ -0,0 +1 @@
export { default } from './blotter';