mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(home): use directory fallbacks for stats (#1133)
This commit is contained in:
@@ -27,6 +27,12 @@ interface DirectoryListState {
|
|||||||
error: Error | null;
|
error: Error | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DirectoryListsState {
|
||||||
|
listsByCode: Record<string, DirectoryList | null>;
|
||||||
|
loadingByCode: Record<string, boolean>;
|
||||||
|
errorsByCode: Record<string, Error | null>;
|
||||||
|
}
|
||||||
|
|
||||||
const GITHUB_URL_TEMPLATE = 'https://raw.githubusercontent.com/bitsocialnet/lists/master/5chan-{code}-directory.json';
|
const GITHUB_URL_TEMPLATE = 'https://raw.githubusercontent.com/bitsocialnet/lists/master/5chan-{code}-directory.json';
|
||||||
const LOCALSTORAGE_KEY_PREFIX = '5chan-directory-list-cache:';
|
const LOCALSTORAGE_KEY_PREFIX = '5chan-directory-list-cache:';
|
||||||
const LOCALSTORAGE_TIMESTAMP_KEY_PREFIX = '5chan-directory-list-cache-timestamp:';
|
const LOCALSTORAGE_TIMESTAMP_KEY_PREFIX = '5chan-directory-list-cache-timestamp:';
|
||||||
@@ -274,6 +280,91 @@ export const useDirectoryList = (directoryCode: string | undefined): DirectoryLi
|
|||||||
return state;
|
return state;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useDirectoryLists = (directoryCodes: string[] | undefined): DirectoryListsState => {
|
||||||
|
const directories = useDirectories();
|
||||||
|
const directoryCodesKey = useMemo(() => [...new Set(directoryCodes ?? [])].join('\0'), [directoryCodes]);
|
||||||
|
|
||||||
|
const fallbackByCode = useMemo(() => {
|
||||||
|
const normalizedDirectoryCodes = directoryCodesKey ? directoryCodesKey.split('\0') : [];
|
||||||
|
return Object.fromEntries(normalizedDirectoryCodes.map((directoryCode) => [directoryCode, synthesizeFromMainDirectory(directoryCode, directories)])) as Record<
|
||||||
|
string,
|
||||||
|
DirectoryList | null
|
||||||
|
>;
|
||||||
|
}, [directories, directoryCodesKey]);
|
||||||
|
|
||||||
|
const [state, setState] = useState<DirectoryListsState>({
|
||||||
|
listsByCode: {},
|
||||||
|
loadingByCode: {},
|
||||||
|
errorsByCode: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const normalizedDirectoryCodes = directoryCodesKey ? directoryCodesKey.split('\0') : [];
|
||||||
|
if (normalizedDirectoryCodes.length === 0) {
|
||||||
|
setState({
|
||||||
|
listsByCode: {},
|
||||||
|
loadingByCode: {},
|
||||||
|
errorsByCode: {},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isMounted = true;
|
||||||
|
const initialState = normalizedDirectoryCodes.reduce<DirectoryListsState>(
|
||||||
|
(acc, directoryCode) => {
|
||||||
|
const cached = moduleCaches.get(directoryCode);
|
||||||
|
const local = cached ? null : getFromLocalStorage(directoryCode);
|
||||||
|
const list = cached ?? local ?? fallbackByCode[directoryCode] ?? null;
|
||||||
|
|
||||||
|
if (local) {
|
||||||
|
moduleCaches.set(directoryCode, local);
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.listsByCode[directoryCode] = list;
|
||||||
|
acc.loadingByCode[directoryCode] = !cached && !local;
|
||||||
|
acc.errorsByCode[directoryCode] = null;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
listsByCode: {},
|
||||||
|
loadingByCode: {},
|
||||||
|
errorsByCode: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
setState(initialState);
|
||||||
|
|
||||||
|
normalizedDirectoryCodes.forEach((directoryCode) => {
|
||||||
|
fetchDirectoryListDeduped(directoryCode)
|
||||||
|
.then((fetched) => {
|
||||||
|
if (!isMounted) return;
|
||||||
|
const list = fetched ?? moduleCaches.get(directoryCode) ?? fallbackByCode[directoryCode] ?? null;
|
||||||
|
setState((prev) => ({
|
||||||
|
listsByCode: { ...prev.listsByCode, [directoryCode]: list },
|
||||||
|
loadingByCode: { ...prev.loadingByCode, [directoryCode]: false },
|
||||||
|
errorsByCode: { ...prev.errorsByCode, [directoryCode]: null },
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn(`Failed to fetch directory list "${directoryCode}":`, error);
|
||||||
|
if (!isMounted) return;
|
||||||
|
const cachedAfter = moduleCaches.get(directoryCode);
|
||||||
|
setState((prev) => ({
|
||||||
|
listsByCode: { ...prev.listsByCode, [directoryCode]: cachedAfter ?? fallbackByCode[directoryCode] ?? null },
|
||||||
|
loadingByCode: { ...prev.loadingByCode, [directoryCode]: false },
|
||||||
|
errorsByCode: { ...prev.errorsByCode, [directoryCode]: error instanceof Error ? error : new Error(String(error)) },
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [directoryCodesKey, fallbackByCode]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sort boards by score (desc). Ties break in favor of `managedByDevs`, then `addedAt` asc.
|
* Sort boards by score (desc). Ties break in favor of `managedByDevs`, then `addedAt` asc.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -10,8 +10,12 @@ const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise
|
|||||||
|
|
||||||
const testState = vi.hoisted(() => ({
|
const testState = vi.hoisted(() => ({
|
||||||
closeDirectoryModalMock: vi.fn(),
|
closeDirectoryModalMock: vi.fn(),
|
||||||
directories: [] as Array<{ address: string; title?: string }>,
|
directories: [] as Array<{ address: string; title?: string; directoryCode?: string }>,
|
||||||
directoryAddresses: [] as string[],
|
directoryAddresses: [] as string[],
|
||||||
|
directoryListCodes: [] as string[],
|
||||||
|
directoryListsByCode: {} as Record<string, { boards: Array<{ address: string; score: number; managedByDevs: boolean; addedAt?: number }> }>,
|
||||||
|
loadingStartTimestamps: [] as number[],
|
||||||
|
nowSeconds: 1_704_067_210,
|
||||||
navigateMock: vi.fn(),
|
navigateMock: vi.fn(),
|
||||||
communities: {} as Record<string, unknown>,
|
communities: {} as Record<string, unknown>,
|
||||||
communityStats: {} as Record<string, { allPostCount?: number; weekActiveUserCount?: number }>,
|
communityStats: {} as Record<string, { allPostCount?: number; weekActiveUserCount?: number }>,
|
||||||
@@ -43,12 +47,35 @@ vi.mock('../../../hooks/use-directories', () => ({
|
|||||||
directories.find((entry) => entry.address === address || entry.directoryCode === address || entry.title === address),
|
directories.find((entry) => entry.address === address || entry.directoryCode === address || entry.title === address),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../hooks/use-directory-list', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('../../../hooks/use-directory-list')>('../../../hooks/use-directory-list');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useDirectoryLists: (directoryCodes: string[] | undefined) => {
|
||||||
|
testState.directoryListCodes = directoryCodes ?? [];
|
||||||
|
return {
|
||||||
|
listsByCode: testState.directoryListsByCode,
|
||||||
|
loadingByCode: {},
|
||||||
|
errorsByCode: {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('../../../hooks/use-communities-stats', () => ({
|
vi.mock('../../../hooks/use-communities-stats', () => ({
|
||||||
CommunityStatsCollector: ({ communityAddress }: { communityAddress: string }) =>
|
CommunityStatsCollector: ({ communityAddress }: { communityAddress: string }) =>
|
||||||
createElement('div', { 'data-testid': 'stats-collector', 'data-address': communityAddress }),
|
createElement('div', { 'data-testid': 'stats-collector', 'data-address': communityAddress }),
|
||||||
useCommunitiesStatsStore: (selector: (state: { communityStats: typeof testState.communityStats }) => unknown) => selector({ communityStats: testState.communityStats }),
|
useCommunitiesStatsStore: (selector: (state: { communityStats: typeof testState.communityStats }) => unknown) => selector({ communityStats: testState.communityStats }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../stores/use-communities-loading-start-timestamps-store', () => ({
|
||||||
|
default: () => testState.loadingStartTimestamps,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../hooks/use-now-seconds', () => ({
|
||||||
|
useNowSeconds: () => testState.nowSeconds,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../../../components/loading-ellipsis', () => ({
|
vi.mock('../../../components/loading-ellipsis', () => ({
|
||||||
default: ({ string }: { string: string }) => createElement('span', { 'data-testid': 'loading-ellipsis' }, string),
|
default: ({ string }: { string: string }) => createElement('span', { 'data-testid': 'loading-ellipsis' }, string),
|
||||||
}));
|
}));
|
||||||
@@ -96,10 +123,14 @@ describe('Home', () => {
|
|||||||
testState.closeDirectoryModalMock.mockReset();
|
testState.closeDirectoryModalMock.mockReset();
|
||||||
testState.navigateMock.mockReset();
|
testState.navigateMock.mockReset();
|
||||||
testState.directories = [
|
testState.directories = [
|
||||||
{ address: 'music-posting.eth', title: '/mu/ - Music' },
|
{ address: 'music-posting.eth', title: '/mu/ - Music', directoryCode: 'mu' },
|
||||||
{ address: 'tech-posting.eth', title: '/g/ - Technology' },
|
{ address: 'tech-posting.eth', title: '/g/ - Technology', directoryCode: 'g' },
|
||||||
];
|
];
|
||||||
testState.directoryAddresses = ['music-posting.eth', 'tech-posting.eth'];
|
testState.directoryAddresses = ['music-posting.eth', 'tech-posting.eth'];
|
||||||
|
testState.directoryListCodes = [];
|
||||||
|
testState.directoryListsByCode = {};
|
||||||
|
testState.loadingStartTimestamps = [];
|
||||||
|
testState.nowSeconds = 1_704_067_210;
|
||||||
testState.communities = {
|
testState.communities = {
|
||||||
'music-posting.eth': { address: 'music-posting.eth' },
|
'music-posting.eth': { address: 'music-posting.eth' },
|
||||||
'tech-posting.eth': { address: 'tech-posting.eth' },
|
'tech-posting.eth': { address: 'tech-posting.eth' },
|
||||||
@@ -151,6 +182,37 @@ describe('Home', () => {
|
|||||||
expect(container.textContent).not.toContain('boards_tracked 1');
|
expect(container.textContent).not.toContain('boards_tracked 1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses a ranked directory fallback board when the default board stats stay unresolved', () => {
|
||||||
|
testState.directories = [
|
||||||
|
{ address: 'business-and-finance.bso', title: '/biz/ - Business & Finance', directoryCode: 'biz' },
|
||||||
|
{ address: 'tech-posting.eth', title: '/g/ - Technology', directoryCode: 'g' },
|
||||||
|
];
|
||||||
|
testState.directoryAddresses = ['business-and-finance.bso', 'tech-posting.eth'];
|
||||||
|
testState.loadingStartTimestamps = [1_704_067_170, 1_704_067_170];
|
||||||
|
testState.directoryListsByCode = {
|
||||||
|
biz: {
|
||||||
|
boards: [
|
||||||
|
{ address: 'business-and-finance.bso', score: 100, managedByDevs: true },
|
||||||
|
{ address: 'backup-business.bso', score: 10, managedByDevs: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
testState.communityStats = {
|
||||||
|
'backup-business.bso': { allPostCount: 11, weekActiveUserCount: 3 },
|
||||||
|
'tech-posting.eth': { allPostCount: 7, weekActiveUserCount: 4 },
|
||||||
|
};
|
||||||
|
|
||||||
|
renderHome();
|
||||||
|
|
||||||
|
const collectorAddresses = Array.from(container.querySelectorAll('[data-testid="stats-collector"]')).map((collector) => collector.getAttribute('data-address'));
|
||||||
|
expect(testState.directoryListCodes).toEqual(['biz']);
|
||||||
|
expect(collectorAddresses).toEqual(['business-and-finance.bso', 'tech-posting.eth', 'backup-business.bso']);
|
||||||
|
expect(container.querySelectorAll('[data-testid="loading-ellipsis"]')).toHaveLength(0);
|
||||||
|
expect(container.textContent).toContain('total_posts 18');
|
||||||
|
expect(container.textContent).toContain('current_users 7');
|
||||||
|
expect(container.textContent).toContain('boards_tracked 2');
|
||||||
|
});
|
||||||
|
|
||||||
it('shows zero totals after every directory has loaded zero-count stats', () => {
|
it('shows zero totals after every directory has loaded zero-count stats', () => {
|
||||||
testState.communityStats = {
|
testState.communityStats = {
|
||||||
'music-posting.eth': { allPostCount: 0, weekActiveUserCount: 0 },
|
'music-posting.eth': { allPostCount: 0, weekActiveUserCount: 0 },
|
||||||
|
|||||||
+73
-12
@@ -2,7 +2,8 @@ import { useEffect, useMemo, useRef, FormEvent } from 'react';
|
|||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import styles from './home.module.css';
|
import styles from './home.module.css';
|
||||||
import { useDirectories, useDirectoryAddresses } from '../../hooks/use-directories';
|
import { type DirectoryCommunity, useDirectories, useDirectoryAddresses } from '../../hooks/use-directories';
|
||||||
|
import { sortDirectoryBoardsByRank, useDirectoryLists } from '../../hooks/use-directory-list';
|
||||||
import { CommunityStatsCollector, useCommunitiesStatsStore } from '../../hooks/use-communities-stats';
|
import { CommunityStatsCollector, useCommunitiesStatsStore } from '../../hooks/use-communities-stats';
|
||||||
import PopularThreadsBox from './popular-threads-box';
|
import PopularThreadsBox from './popular-threads-box';
|
||||||
import BoardsList from './boards-list';
|
import BoardsList from './boards-list';
|
||||||
@@ -11,9 +12,11 @@ import LoadingEllipsis from '../../components/loading-ellipsis';
|
|||||||
import useDirectoryModalStore from '../../stores/use-directory-modal-store';
|
import useDirectoryModalStore from '../../stores/use-directory-modal-store';
|
||||||
import DisclaimerModal from '../../components/disclaimer-modal';
|
import DisclaimerModal from '../../components/disclaimer-modal';
|
||||||
import DirectoryModal from '../../components/directory-modal';
|
import DirectoryModal from '../../components/directory-modal';
|
||||||
import { getBoardPath } from '../../lib/utils/route-utils';
|
import { extractDirectoryFromTitle, getBoardPath } from '../../lib/utils/route-utils';
|
||||||
import { isWebRuntime } from '../../lib/media-hosting/show-upload-controls';
|
import { isWebRuntime } from '../../lib/media-hosting/show-upload-controls';
|
||||||
import lowerCase from 'lodash/lowerCase';
|
import lowerCase from 'lodash/lowerCase';
|
||||||
|
import useCommunitiesLoadingStartTimestamps from '../../stores/use-communities-loading-start-timestamps-store';
|
||||||
|
import { useNowSeconds } from '../../hooks/use-now-seconds';
|
||||||
|
|
||||||
// https://github.com/bitsocialnet/lists/blob/master/5chan-directories.json
|
// https://github.com/bitsocialnet/lists/blob/master/5chan-directories.json
|
||||||
|
|
||||||
@@ -91,22 +94,74 @@ interface StatValueProps {
|
|||||||
value: number;
|
value: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HomepageStats = {
|
||||||
|
allPostCount?: number;
|
||||||
|
weekActiveUserCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
const StatValue = ({ isLoaded, loadingLabel, value }: StatValueProps) => (isLoaded ? <>{value}</> : <LoadingEllipsis string={loadingLabel} />);
|
const StatValue = ({ isLoaded, loadingLabel, value }: StatValueProps) => (isLoaded ? <>{value}</> : <LoadingEllipsis string={loadingLabel} />);
|
||||||
|
|
||||||
const Stats = ({ directoryAddresses }: { directoryAddresses: string[] }) => {
|
const STATS_DIRECTORY_FALLBACK_DELAY_SECONDS = 30;
|
||||||
|
|
||||||
|
const getDirectoryCode = (directory: DirectoryCommunity): string | null => directory.directoryCode ?? extractDirectoryFromTitle(directory.title ?? '');
|
||||||
|
|
||||||
|
const getUniqueAddresses = (addresses: string[]): string[] => [...new Set(addresses.filter((address) => address.length > 0))];
|
||||||
|
|
||||||
|
const hasLoadedStats = (stat: HomepageStats | undefined): stat is HomepageStats & { allPostCount: number } => stat?.allPostCount !== undefined;
|
||||||
|
|
||||||
|
const Stats = ({ directories }: { directories: DirectoryCommunity[] }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const communitiesStats = useCommunitiesStatsStore((state) => state.communityStats);
|
const communitiesStats = useCommunitiesStatsStore((state) => state.communityStats);
|
||||||
|
const defaultDirectoryAddresses = useMemo(() => directories.map((directory) => directory.address), [directories]);
|
||||||
|
const loadingStartTimestamps = useCommunitiesLoadingStartTimestamps(defaultDirectoryAddresses);
|
||||||
|
const nowSeconds = useNowSeconds(defaultDirectoryAddresses.length > 0);
|
||||||
|
|
||||||
const { totalPosts, currentUsers, boardsTracked, allDirectoryStatsLoaded } = useMemo(() => {
|
const fallbackDirectoryCodes = useMemo(
|
||||||
|
() =>
|
||||||
|
directories.flatMap((directory, index) => {
|
||||||
|
const defaultStats = communitiesStats[directory.address];
|
||||||
|
if (hasLoadedStats(defaultStats)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingStartTimestamp = loadingStartTimestamps[index];
|
||||||
|
if (!loadingStartTimestamp || nowSeconds - loadingStartTimestamp < STATS_DIRECTORY_FALLBACK_DELAY_SECONDS) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const directoryCode = getDirectoryCode(directory);
|
||||||
|
return directoryCode ? [directoryCode] : [];
|
||||||
|
}),
|
||||||
|
[communitiesStats, directories, loadingStartTimestamps, nowSeconds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { listsByCode } = useDirectoryLists(fallbackDirectoryCodes);
|
||||||
|
|
||||||
|
const { collectorAddresses, totalPosts, currentUsers, boardsTracked, allDirectoryStatsLoaded } = useMemo(() => {
|
||||||
|
const collectorAddressSet = new Set(defaultDirectoryAddresses);
|
||||||
let totalPosts = 0;
|
let totalPosts = 0;
|
||||||
let currentUsers = 0;
|
let currentUsers = 0;
|
||||||
let boardsTracked = 0;
|
let boardsTracked = 0;
|
||||||
let allDirectoryStatsLoaded = directoryAddresses.length > 0;
|
let allDirectoryStatsLoaded = directories.length > 0;
|
||||||
|
|
||||||
for (const address of directoryAddresses) {
|
for (const directory of directories) {
|
||||||
const stat = communitiesStats[address];
|
const directoryCode = getDirectoryCode(directory);
|
||||||
|
const directoryList = directoryCode ? listsByCode[directoryCode] : null;
|
||||||
|
const rankedAddresses = directoryList ? sortDirectoryBoardsByRank(directoryList.boards).map((board) => board.address) : [directory.address];
|
||||||
|
const candidateAddresses = getUniqueAddresses(rankedAddresses.length > 0 ? rankedAddresses : [directory.address]);
|
||||||
|
|
||||||
if (!stat || stat.allPostCount === undefined) {
|
candidateAddresses.forEach((address) => collectorAddressSet.add(address));
|
||||||
|
|
||||||
|
let stat: HomepageStats | undefined;
|
||||||
|
for (const address of candidateAddresses) {
|
||||||
|
const candidateStats = communitiesStats[address];
|
||||||
|
if (hasLoadedStats(candidateStats)) {
|
||||||
|
stat = candidateStats;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasLoadedStats(stat)) {
|
||||||
allDirectoryStatsLoaded = false;
|
allDirectoryStatsLoaded = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -116,15 +171,21 @@ const Stats = ({ directoryAddresses }: { directoryAddresses: string[] }) => {
|
|||||||
boardsTracked++;
|
boardsTracked++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { totalPosts, currentUsers, boardsTracked, allDirectoryStatsLoaded };
|
return {
|
||||||
}, [communitiesStats, directoryAddresses]);
|
collectorAddresses: [...collectorAddressSet],
|
||||||
|
totalPosts,
|
||||||
|
currentUsers,
|
||||||
|
boardsTracked,
|
||||||
|
allDirectoryStatsLoaded,
|
||||||
|
};
|
||||||
|
}, [communitiesStats, defaultDirectoryAddresses, directories, listsByCode]);
|
||||||
|
|
||||||
const loadingLabel = t('loading');
|
const loadingLabel = t('loading');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Render collectors to fetch stats for each community */}
|
{/* Render collectors to fetch stats for each community */}
|
||||||
{directoryAddresses.map((address) => (
|
{collectorAddresses.map((address) => (
|
||||||
<CommunityStatsCollector key={address} communityAddress={address} />
|
<CommunityStatsCollector key={address} communityAddress={address} />
|
||||||
))}
|
))}
|
||||||
<div className={styles.box}>
|
<div className={styles.box}>
|
||||||
@@ -225,7 +286,7 @@ const Home = () => {
|
|||||||
<InfoBox />
|
<InfoBox />
|
||||||
<BoardsList multisub={directories} />
|
<BoardsList multisub={directories} />
|
||||||
<PopularThreadsBox directories={directories} directoryAddresses={directoryAddresses} />
|
<PopularThreadsBox directories={directories} directoryAddresses={directoryAddresses} />
|
||||||
<Stats directoryAddresses={directoryAddresses} />
|
<Stats directories={directories} />
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user