mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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:
+4
-2
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './blotter-message';
|
||||
+5
-12
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './board-blotter';
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './blotter';
|
||||
Reference in New Issue
Block a user