mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(directory): load per-directory list files (#1134)
Merge board directories from the new bitsocialnet/lists per-directory layout on the client side. Update vendored fallback data, sync tooling, directory references, and tests. Treat failed board stat fetches as complete on the home page so stats do not stay loading forever.
This commit is contained in:
@@ -1,25 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { DirectoryCommunity, useDirectories } from './use-directories';
|
||||
import { type DirectoryList, type DirectoryListBoard, normalizeDirectoryList, sortDirectoryBoardsByRank } from '../lib/utils/directory-list-utils';
|
||||
|
||||
export interface DirectoryListBoard {
|
||||
address: string;
|
||||
publicKey?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
owner?: string;
|
||||
score: number;
|
||||
managedByDevs: boolean;
|
||||
addedAt?: number;
|
||||
}
|
||||
|
||||
interface DirectoryList {
|
||||
directoryCode: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
boards: DirectoryListBoard[];
|
||||
}
|
||||
export type { DirectoryListBoard } from '../lib/utils/directory-list-utils';
|
||||
|
||||
interface DirectoryListState {
|
||||
list: DirectoryList | null;
|
||||
@@ -33,7 +16,7 @@ interface DirectoryListsState {
|
||||
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-directories/5chan-{code}-directory.json';
|
||||
const LOCALSTORAGE_KEY_PREFIX = '5chan-directory-list-cache:';
|
||||
const LOCALSTORAGE_TIMESTAMP_KEY_PREFIX = '5chan-directory-list-cache-timestamp:';
|
||||
const CACHE_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
|
||||
@@ -46,49 +29,6 @@ const inFlightFetches = new Map<string, Promise<DirectoryList | null>>();
|
||||
const lastFetchSuccessAt = new Map<string, number>();
|
||||
const lastFetchAttemptAt = new Map<string, number>();
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;
|
||||
|
||||
const toNumber = (value: unknown): number | undefined => (typeof value === 'number' && Number.isFinite(value) ? value : undefined);
|
||||
|
||||
const toString = (value: unknown): string | undefined => (typeof value === 'string' && value.length > 0 ? value : undefined);
|
||||
|
||||
const toBool = (value: unknown): boolean => value === true;
|
||||
|
||||
const normalizeBoard = (raw: unknown): DirectoryListBoard | null => {
|
||||
if (!isRecord(raw)) return null;
|
||||
const address = toString(raw.address) ?? toString(raw.name);
|
||||
if (!address) return null;
|
||||
|
||||
return {
|
||||
address,
|
||||
...(toString(raw.publicKey) ? { publicKey: toString(raw.publicKey)! } : {}),
|
||||
...(toString(raw.title) ? { title: toString(raw.title)! } : {}),
|
||||
...(toString(raw.description) ? { description: toString(raw.description)! } : {}),
|
||||
...(toString(raw.owner) ? { owner: toString(raw.owner)! } : {}),
|
||||
score: toNumber(raw.score) ?? 0,
|
||||
managedByDevs: toBool(raw.managedByDevs),
|
||||
...(toNumber(raw.addedAt) !== undefined ? { addedAt: toNumber(raw.addedAt) } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeDirectoryList = (raw: unknown, fallbackCode: string): DirectoryList | null => {
|
||||
if (!isRecord(raw)) return null;
|
||||
const boardsRaw = Array.isArray(raw.boards) ? raw.boards : Array.isArray(raw.communities) ? raw.communities : null;
|
||||
if (!boardsRaw) return null;
|
||||
|
||||
const boards = boardsRaw.map(normalizeBoard).filter((board): board is DirectoryListBoard => board !== null);
|
||||
if (boards.length === 0) return null;
|
||||
|
||||
return {
|
||||
directoryCode: toString(raw.directoryCode) ?? fallbackCode,
|
||||
...(toString(raw.title) ? { title: toString(raw.title)! } : {}),
|
||||
...(toString(raw.description) ? { description: toString(raw.description)! } : {}),
|
||||
...(toNumber(raw.createdAt) !== undefined ? { createdAt: toNumber(raw.createdAt) } : {}),
|
||||
...(toNumber(raw.updatedAt) !== undefined ? { updatedAt: toNumber(raw.updatedAt) } : {}),
|
||||
boards,
|
||||
};
|
||||
};
|
||||
|
||||
const synthesizeFromMainDirectory = (directoryCode: string, directories: DirectoryCommunity[]): DirectoryList | null => {
|
||||
const match = directories.find((community) => community.directoryCode === directoryCode);
|
||||
if (!match || !match.address) return null;
|
||||
@@ -96,18 +36,24 @@ const synthesizeFromMainDirectory = (directoryCode: string, directories: Directo
|
||||
const board: DirectoryListBoard = {
|
||||
address: match.address,
|
||||
...(match.publicKey ? { publicKey: match.publicKey } : {}),
|
||||
...(match.title ? { title: match.title } : {}),
|
||||
score: 0,
|
||||
managedByDevs: true,
|
||||
...(match.nsfw !== undefined ? { nsfw: match.nsfw } : {}),
|
||||
...(match.features ? { features: match.features } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
directoryCode,
|
||||
...(match.title ? { title: match.title } : {}),
|
||||
...(match.features ? { features: match.features } : {}),
|
||||
boards: [board],
|
||||
};
|
||||
};
|
||||
|
||||
const mergeDirectoryListDefaults = (list: DirectoryList, fallback: DirectoryList | null): DirectoryList => ({
|
||||
...list,
|
||||
...(list.title || !fallback?.title ? {} : { title: fallback.title }),
|
||||
...(list.features || !fallback?.features ? {} : { features: fallback.features }),
|
||||
});
|
||||
|
||||
const getLocalStorageKey = (code: string) => `${LOCALSTORAGE_KEY_PREFIX}${code}`;
|
||||
const getLocalStorageTimestampKey = (code: string) => `${LOCALSTORAGE_TIMESTAMP_KEY_PREFIX}${code}`;
|
||||
|
||||
@@ -199,9 +145,9 @@ const fetchDirectoryListDeduped = (code: string): Promise<DirectoryList | null>
|
||||
/**
|
||||
* Fetch the candidate boards for a single directory code (e.g. 'biz').
|
||||
*
|
||||
* Source: `bitsocialnet/lists/5chan-{code}-directory.json`. When the network is unavailable
|
||||
* Source: `bitsocialnet/lists/5chan-directories/5chan-{code}-directory.json`. When the network is unavailable
|
||||
* or the file is not yet published, falls back to a synthesized single-entry list derived
|
||||
* from the main 5chan-directories.json (the dev-managed default).
|
||||
* from the merged directory assignments.
|
||||
*/
|
||||
export const useDirectoryList = (directoryCode: string | undefined): DirectoryListState => {
|
||||
const directories = useDirectories();
|
||||
@@ -227,16 +173,17 @@ export const useDirectoryList = (directoryCode: string | undefined): DirectoryLi
|
||||
let isMounted = true;
|
||||
|
||||
const hydrate = (list: DirectoryList) => {
|
||||
moduleCaches.set(directoryCode, list);
|
||||
const mergedList = mergeDirectoryListDefaults(list, fallback);
|
||||
moduleCaches.set(directoryCode, mergedList);
|
||||
if (isMounted) {
|
||||
setState({ list, loading: false, error: null });
|
||||
setState({ list: mergedList, loading: false, error: null });
|
||||
}
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const cached = moduleCaches.get(directoryCode);
|
||||
if (cached) {
|
||||
setState({ list: cached, loading: false, error: null });
|
||||
setState({ list: mergeDirectoryListDefaults(cached, fallback), loading: false, error: null });
|
||||
} else {
|
||||
const local = getFromLocalStorage(directoryCode);
|
||||
if (local) {
|
||||
@@ -365,16 +312,6 @@ export const useDirectoryLists = (directoryCodes: string[] | undefined): Directo
|
||||
return state;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sort boards by score (desc). Ties break in favor of `managedByDevs`, then `addedAt` asc.
|
||||
*/
|
||||
export const sortDirectoryBoardsByRank = (boards: DirectoryListBoard[]): DirectoryListBoard[] =>
|
||||
[...boards].sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
if (a.managedByDevs !== b.managedByDevs) return a.managedByDevs ? -1 : 1;
|
||||
return (a.addedAt ?? 0) - (b.addedAt ?? 0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Pick the winning board for a directory, skipping any boards reported offline.
|
||||
* Returns the highest-ranked online board, or — if every candidate looks offline —
|
||||
@@ -384,3 +321,5 @@ export const pickDirectoryWinner = (boards: DirectoryListBoard[], isOffline: (ad
|
||||
const ranked = sortDirectoryBoardsByRank(boards);
|
||||
return ranked.find((board) => !isOffline(board.address)) ?? ranked[0];
|
||||
};
|
||||
|
||||
export { sortDirectoryBoardsByRank };
|
||||
|
||||
Reference in New Issue
Block a user