diff --git a/.github/scripts/generate-issue-index.cjs b/.github/scripts/generate-issue-index.cjs new file mode 100644 index 000000000..9edd11ae4 --- /dev/null +++ b/.github/scripts/generate-issue-index.cjs @@ -0,0 +1,298 @@ +'use strict'; + +const fs = require('node:fs'); + +const CATEGORY_CODES = new Map([ + ['wrong formatting', 1], + ['wrong content', 2], + ['missing chapter', 3], + ['missing images', 4], + ["can't load novels", 5], + ['domain changed', 6], +]); +const DIRECTIONAL_CONTROLS = /[\u200e\u200f\u202a-\u202e\u2066-\u2069]/g; + +function comparisonValue(value) { + return value + .replace(DIRECTIONAL_CONTROLS, '') + .trim() + .toLocaleLowerCase('en-US'); +} + +function extractSection(body, name) { + const lines = (body || '').replace(/\r\n?/g, '\n').split('\n'); + const heading = new RegExp(`^\\s{0,3}###\\s+${name}\\s*#*\\s*$`, 'i'); + const start = lines.findIndex(line => + heading.test(line.replace(DIRECTIONAL_CONTROLS, '')), + ); + if (start < 0) return { present: false, lines: [] }; + let end = start + 1; + while ( + end < lines.length && + !/^\s{0,3}#{1,6}(?:\s|$)/.test(lines[end].replace(DIRECTIONAL_CONTROLS, '')) + ) + end += 1; + return { present: true, lines: lines.slice(start + 1, end) }; +} + +function createLookups(pluginKeys) { + const prototype = + pluginKeys && typeof pluginKeys === 'object' + ? Object.getPrototypeOf(pluginKeys) + : undefined; + if ( + !pluginKeys || + typeof pluginKeys !== 'object' || + Array.isArray(pluginKeys) || + (prototype !== Object.prototype && prototype !== null) + ) { + throw new TypeError( + 'Plugin dictionary must be an object mapping display keys to ids.', + ); + } + const idsByCandidate = new Map(); + const canonicalIds = new Map(); + for (const [displayKey, id] of Object.entries(pluginKeys)) { + if ( + typeof id !== 'string' || + !comparisonValue(displayKey) || + !comparisonValue(id) + ) { + throw new TypeError( + 'Plugin dictionary entries must have nonempty string keys and ids.', + ); + } + const canonicalId = id; + const normalizedId = comparisonValue(id); + if (!canonicalIds.has(normalizedId)) + canonicalIds.set(normalizedId, new Set()); + canonicalIds.get(normalizedId).add(canonicalId); + const candidates = [displayKey, id]; + const prefix = displayKey.indexOf(': '); + if (prefix >= 0) candidates.push(displayKey.slice(prefix + 2)); + for (const candidate of candidates) { + const normalized = comparisonValue(candidate); + if (!normalized) continue; + if (!idsByCandidate.has(normalized)) + idsByCandidate.set(normalized, new Set()); + idsByCandidate.get(normalized).add(canonicalId); + } + } + const candidates = [...idsByCandidate.entries()] + .map(([value, ids]) => ({ value, ids })) + .sort( + (a, b) => + b.value.length - a.value.length || + (a.value < b.value ? -1 : a.value > b.value ? 1 : 0), + ); + return { idsByCandidate, canonicalIds, candidates }; +} + +function resolveSelection(lines, lookups, issueNumber, warn) { + const selection = lines + .map(line => line.trim()) + .filter(Boolean) + .join(', '); + const normalized = comparisonValue(selection); + const alternatives = lookups.candidates + .map(candidate => candidate.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('|'); + if (!alternatives) return { ids: new Set(), unresolved: selection }; + const regex = new RegExp(`(?:^|, )(${alternatives})(?=, |$)`, 'g'); + const ids = new Set(); + const spans = []; + let match; + while ((match = regex.exec(normalized)) !== null) { + const start = match.index + (match[0].startsWith(', ') ? 2 : 0); + const found = lookups.idsByCandidate.get(match[1]); + if (found && found.size === 1) { + ids.add(found.values().next().value); + spans.push([start, start + match[1].length]); + } + } + let unresolved = ''; + let cursor = 0; + for (const [start, end] of spans) { + unresolved += normalized.slice(cursor, start); + cursor = end; + } + unresolved += normalized.slice(cursor); + unresolved = unresolved + .split(',') + .map(part => part.trim()) + .filter(Boolean) + .join(', '); + if (unresolved) + warn(`Issue #${issueNumber}: unresolved plugin selection: ${unresolved}`); + return { ids, unresolved }; +} + +function validateIssue(issue) { + if ( + !issue || + typeof issue !== 'object' || + Array.isArray(issue) || + !Number.isInteger(issue.number) || + issue.number <= 0 + ) { + throw new TypeError('Every issue must have a positive integer number.'); + } + if (issue.state !== 'open' && issue.state !== 'closed') + throw new TypeError('Every issue must have state "open" or "closed".'); +} + +function buildIssueIndex(issues, pluginKeys, warn = console.warn) { + const lookups = createLookups(pluginKeys); + if (!Array.isArray(issues)) throw new TypeError('Issues must be an array.'); + const index = new Map(); + for (const issue of issues) { + validateIssue(issue); + if (issue.state !== 'open' || Object.hasOwn(issue, 'pull_request')) + continue; + const labels = Array.isArray(issue.labels) + ? issue.labels + .map(label => + typeof label === 'string' ? label : label && label.name, + ) + .filter(Boolean) + : []; + const pluginSection = extractSection(issue.body, 'Plugin'); + const sourceSection = extractSection(issue.body, 'Source information'); + let pluginIds = new Set(); + let pluginBearing = false; + if (pluginSection.present) { + const resolved = resolveSelection( + pluginSection.lines, + lookups, + issue.number, + warn, + ); + pluginIds = resolved.ids; + pluginBearing = + pluginSection.lines.some(line => line.trim()) || + Boolean(resolved.unresolved); + } else { + if (sourceSection.present) { + const resolved = resolveSelection( + sourceSection.lines, + lookups, + issue.number, + warn, + ); + pluginIds = resolved.ids; + pluginBearing = + sourceSection.lines.some(line => line.trim()) || + Boolean(resolved.unresolved); + } + if (pluginIds.size === 0) { + for (const label of labels) { + const match = /^plugin: .*\[([^\]]+)\]$/i.exec(label.trim()); + if (!match) continue; + pluginBearing = true; + const ids = lookups.canonicalIds.get(comparisonValue(match[1])); + if (ids && ids.size === 1) pluginIds.add(ids.values().next().value); + else + warn(`Issue #${issue.number}: unresolved plugin label: ${label}`); + } + } + } + if (pluginBearing && pluginIds.size === 0) { + warn(`Issue #${issue.number}: plugin association could not be resolved.`); + } + if (pluginIds.size === 0) continue; + + const severitySection = extractSection(issue.body, 'Severity'); + let categoryCode = 0; + if (severitySection.present) { + const severity = severitySection.lines + .map(line => line.trim()) + .find(Boolean); + categoryCode = severity + ? CATEGORY_CODES.get(comparisonValue(severity)) || 0 + : 0; + } else { + const severityLabels = labels.filter(label => + /^severity:\s*/i.test(label.trim()), + ); + const values = severityLabels.map(label => + comparisonValue(label.trim().replace(/^severity:\s*/i, '')), + ); + const codes = values.map(value => CATEGORY_CODES.get(value)); + if (values.length === 1 && codes[0] !== undefined) + categoryCode = codes[0]; + } + for (const id of pluginIds) { + if (!index.has(id)) index.set(id, new Map()); + const issueNumbers = index.get(id); + const previousCode = issueNumbers.get(issue.number); + if (previousCode === undefined) { + issueNumbers.set(issue.number, categoryCode); + } else if (previousCode !== categoryCode) { + warn( + `Issue #${issue.number}: conflicting category codes for plugin ${id}; using Other (0).`, + ); + issueNumbers.set(issue.number, 0); + } + } + } + const result = {}; + const pluginIds = [...index.keys()].sort((a, b) => + a < b ? -1 : a > b ? 1 : 0, + ); + for (const id of pluginIds) { + const groups = new Map(); + for (const [number, code] of index.get(id)) { + if (!groups.has(code)) groups.set(code, []); + groups.get(code).push(number); + } + const value = [...groups.entries()] + .sort(([a], [b]) => b - a) + .map(([code, numbers]) => [code, ...numbers.sort((a, b) => a - b)]); + Object.defineProperty(result, id, { + value, + enumerable: true, + configurable: true, + writable: true, + }); + } + return result; +} + +function flattenIssuePages(value) { + if (!Array.isArray(value)) + throw new TypeError('Issue JSON must be an array or an array of pages.'); + if (value.every(Array.isArray)) return value.flat(); + if (value.some(Array.isArray)) + throw new TypeError('Issue JSON cannot mix issue objects and pages.'); + return value; +} + +function runCli(args) { + if (args.length !== 2) + throw new Error( + 'Usage: node .github/scripts/generate-issue-index.cjs ', + ); + const [issuesPath, outputPath] = args; + const raw = JSON.parse(fs.readFileSync(issuesPath, 'utf8')); + const issues = flattenIssuePages(raw); + for (const issue of issues) validateIssue(issue); + const pluginKeys = JSON.parse( + fs.readFileSync('.github/scripts/keys.json', 'utf8'), + ); + const index = buildIssueIndex(issues, pluginKeys, message => + process.stderr.write(`${message}\n`), + ); + const serialized = JSON.stringify(index); + fs.writeFileSync(outputPath, serialized); +} + +if (require.main === module) { + try { + runCli(process.argv.slice(2)); + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + } +} + +module.exports = { buildIssueIndex }; diff --git a/.github/workflows/generate-issue-index.yml b/.github/workflows/generate-issue-index.yml new file mode 100644 index 000000000..cbcea9151 --- /dev/null +++ b/.github/workflows/generate-issue-index.yml @@ -0,0 +1,79 @@ +name: Generate Issue Index + +on: + issues: + types: [opened, closed, reopened, edited, labeled, unlabeled, deleted] + workflow_dispatch: + push: + branches: [master] + paths: + - .github/scripts/generate-issue-index.cjs + - .github/scripts/keys.json + - .github/workflows/generate-issue-index.yml + workflow_run: + workflows: ['Update Issue Template', 'Issue Moderator'] + types: [completed] + +permissions: {} + +concurrency: + group: plugin-issue-index + cancel-in-progress: false + +jobs: + generate-and-publish: + runs-on: ubuntu-latest + permissions: + contents: write + issues: read + steps: + - name: Checkout default branch + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.repository.default_branch }} + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '24' + + - name: Generate full open issue index + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + shell: bash + run: | + set -euo pipefail + gh api --paginate --slurp "repos/$REPOSITORY/issues?state=open&per_page=100" > "$RUNNER_TEMP/open-plugin-issues.json" + node .github/scripts/generate-issue-index.cjs "$RUNNER_TEMP/open-plugin-issues.json" "$RUNNER_TEMP/issue-index.min.json" + + - name: Publish issue index branch + shell: bash + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + + set +e + git ls-remote --exit-code --heads origin refs/heads/issue-index >/dev/null + branch_status=$? + set -e + if [ "$branch_status" -eq 0 ]; then + git fetch origin refs/heads/issue-index + git checkout --force -B issue-index FETCH_HEAD + elif [ "$branch_status" -eq 2 ]; then + git checkout --orphan issue-index + git rm -rf --ignore-unmatch . + else + echo "Unable to determine whether refs/heads/issue-index exists (git ls-remote exited $branch_status)." >&2 + exit "$branch_status" + fi + + cp "$RUNNER_TEMP/issue-index.min.json" issue-index.min.json + git add -- issue-index.min.json + if git diff --cached --quiet; then + echo "Issue index is unchanged." + exit 0 + fi + git commit -m "chore(issues): update plugin issue index" + git push origin HEAD:refs/heads/issue-index diff --git a/README.md b/README.md index 44f8de914..7f88fb01a 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,14 @@ npm run dev:start Open [localhost:3000](http://localhost:3000) to test plugins interactively. See the [testing guide](./docs/website-tutorial.md) for details. +The issue-report panel uses `VITE_ISSUE_INDEX_URL` when set. Add it to `.env.local` to use another index URL: + +```env +VITE_ISSUE_INDEX_URL=https://example.com/issue-index.min.json +``` + +If unset, the playground uses `https://raw.githubusercontent.com/LNReader/lnreader-plugins/issue-index/issue-index.min.json`. + ### Mobile App **From GitHub (Automated):** diff --git a/src/components/plugin-issues.tsx b/src/components/plugin-issues.tsx new file mode 100644 index 000000000..1b9eafc95 --- /dev/null +++ b/src/components/plugin-issues.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { + CATEGORY_LABELS, + getIssueGroups, + type IssueGroup, +} from '@/lib/issue-index'; +import { useAppStore } from '@/store'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; + +export function PluginIssues({ pluginId }: { pluginId?: string }) { + const state = useAppStore(store => store.issueIndexState); + const loadIssueIndex = useAppStore(store => store.loadIssueIndex); + const groups = + state.status === 'ready' && pluginId + ? getIssueGroups(state.index, pluginId) + : []; + const reportCount = groups.reduce( + (total, group) => total + group.length - 1, + 0, + ); + + return ( + + +
+ Open issue reports + + Indexed GitHub reports for this plugin. The snapshot may lag recent + changes. + +
+ {state.status === 'ready' && ( + + )} + {state.status === 'error' && ( + + )} +
+ + {!pluginId ? ( +

+ Select a plugin to see its issue reports. +

+ ) : state.status === 'idle' || state.status === 'loading' ? ( +
+

+ Loading issue reports... +

+ +
+ ) : state.status === 'error' ? ( +
+

Issue reports are unavailable.

+

{state.message}

+
+ ) : groups.length === 0 ? ( +

+ No indexed open reports for this plugin. +

+ ) : ( +
+

+ {reportCount} indexed open{' '} + {reportCount === 1 ? 'report' : 'reports'} +

+ {groups.map(group => ( + + ))} +
+ )} +
+
+ ); +} + +function IssueCategory({ group }: { group: IssueGroup }) { + const [code, ...issueNumbers] = group; + return ( +
+ + {CATEGORY_LABELS[code]} + +
+ {issueNumbers.map(number => ( + + #{number} + + ))} +
+
+ ); +} diff --git a/src/lib/issue-index.min.json b/src/lib/issue-index.min.json new file mode 100644 index 000000000..b062988ad --- /dev/null +++ b/src/lib/issue-index.min.json @@ -0,0 +1 @@ +{"1stkissnovel":[[0,1150]],"AnimesHoy12":[[5,1784]],"DDL.com":[[5,2381]],"FWN.com":[[5,1932],[3,2004],[2,2335]],"IDWN.id":[[6,2133],[5,2438,2449,2512],[3,2591],[2,2590],[1,2412,2589]],"LightNovelUpdates":[[3,2046]],"RLIB":[[4,1735,2390],[0,1352,2389]],"RNBH.org":[[5,2425],[0,1387]],"WTRLAB":[[5,2075,2188],[2,2228],[0,2227]],"agit.xyz":[[0,1091]],"allnovel":[[5,2179],[3,2422],[1,2359]],"anf.net":[[6,2652],[3,1975],[1,2376]],"archiveofourown":[[3,2195]],"arnovel":[[5,2609]],"asuralightnovel":[[0,1316]],"bacalightnovel":[[3,2457],[1,1964],[0,1230]],"bailiantales":[[5,2660]],"blumeverse":[[5,2531]],"boxnovel":[[5,2075],[3,2265]],"cherrymistcafe":[[5,2563,2603]],"citrusaurora":[[0,1300]],"coralboutique":[[0,1301]],"crimsonscrolls":[[5,1992]],"daotranslate":[[2,2207],[0,1232]],"dilartube":[[0,1558]],"dragonholic":[[6,2362]],"duskblossoms":[[0,1339]],"ellotl":[[5,1845]],"fannovel":[[5,2264],[0,1325]],"fanstranslations":[[1,2357]],"fenrir":[[5,1990]],"ficbook":[[0,2118]],"fictionzone":[[3,1554,1997],[2,1993]],"foxaholic":[[4,2500],[3,2291],[0,2368]],"freekolnovel":[[3,1920]],"galaxynovels":[[5,2490,2522],[3,2667],[0,2442]],"galaxytranslations":[[5,1665]],"genesistudio":[[5,2286],[1,1675],[0,1212]],"guavaread":[[0,1300]],"hizomanga":[[6,2003],[0,1455]],"inoveltranslation":[[0,1382]],"jaomix.ru":[[5,2037],[3,2052],[2,2245]],"kdtnovels":[[5,2492]],"knoxt":[[5,2533]],"kolnovel":[[5,2576],[3,2486],[1,2578,2623]],"lightnovelcave":[[0,2178]],"lightnovelfr":[[6,2289]],"lilyonthevalley":[[3,1966],[0,1718]],"linovelib":[[5,2374]],"lnmtl":[[0,1305]],"lnori":[[4,2413],[3,2334]],"ltnovel":[[0,947,1053,1356,1380]],"mangatr":[[5,1732]],"markazriwayat":[[5,2522]],"massnovel":[[6,2062]],"moonlightnovel":[[0,1458]],"morenovel":[[3,2263]],"mtlnovel":[[3,1626],[0,1094,1432]],"mtlnovel-id":[[0,1328]],"mvlempyr.com":[[5,2377,2437],[3,1890]],"novelarrow":[[5,2630]],"novelbin":[[5,1954,2070,2153,2242,2549],[3,2150,2226],[0,1326,1347,1902,2237,2243,2267]],"novelbookid":[[1,1722],[0,1328]],"novelcool":[[5,2241]],"novelfire":[[5,1932,2012,2410,2491],[3,2312,2350,2373,2406,2420,2436,2456],[1,2309],[0,2033,2268,2297,2419]],"novelib":[[4,1717],[0,1533,1718]],"novelight":[[1,2526]],"novelki.pl":[[5,2055]],"noveloku":[[0,1525,2240]],"novelsparadise":[[3,1734]],"novelupdates":[[5,1505,1676,1754],[4,1772],[3,1613,1677,1840,1957,2128,2187],[2,1511,1544,1744],[0,729,736,997,1185,1349,1480,2060,2213,2385]],"olaoe":[[1,1881]],"panchotranslations":[[5,1664]],"pasteltales":[[0,1301]],"rainofsnow":[[0,1301,1355]],"ranobes":[[1,1953,2345]],"ranovel":[[5,2106]],"readfrom":[[5,2261]],"requiemtls":[[0,1361]],"rewayatclub":[[3,2181],[0,1817]],"riwyat":[[5,2301,2465,2501,2505],[1,1741,2370],[0,1096,1566,2054]],"royalroad":[[5,2659],[3,2015],[1,2364],[0,1879,2246]],"sakura.id":[[5,2513,2571],[3,2481,2619],[0,891]],"scribblehub":[[5,1792,2344,2475],[0,1150,1196]],"thnovels":[[1,1526]],"traducciones":[[0,1678]],"wbnovel":[[0,1328]],"worldnovel":[[0,1692,2594]],"wuxiacity":[[0,947,1325,1356]],"wuxiamtl":[[5,2264],[0,947,1325,1356]],"wuxiap":[[0,947,1356]],"wuxiaspace":[[0,1053]],"wuxiav":[[0,1055]],"wuxiaworld":[[5,1679],[0,983]],"yuukitls":[[5,1664]]} \ No newline at end of file diff --git a/src/lib/issue-index.ts b/src/lib/issue-index.ts new file mode 100644 index 000000000..1541a4d36 --- /dev/null +++ b/src/lib/issue-index.ts @@ -0,0 +1,99 @@ +import localIssueIndex from './issue-index.min.json'; +export type CategoryCode = 0 | 1 | 2 | 3 | 4 | 5 | 6; +export type IssueGroup = [CategoryCode, number, ...number[]]; +export type IssueIndex = Record; + +export const ISSUE_INDEX_URL = + import.meta.env?.VITE_ISSUE_INDEX_URL || + 'https://raw.githubusercontent.com/LNReader/lnreader-plugins/issue-index/issue-index.min.json'; + +export const CATEGORY_LABELS = [ + 'Other', + 'Wrong Formatting', + 'Wrong Content', + 'Missing Chapter', + 'Missing Images', + "Can't Load Novels", + 'Domain Changed', +] as const; + +const EMPTY_ISSUE_GROUPS: readonly IssueGroup[] = []; +const INVALID_FORMAT = 'Issue index format is invalid.'; + +export function parseIssueIndex(value: unknown): IssueIndex { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(INVALID_FORMAT); + } + + for (const groups of Object.values(value)) { + if (!Array.isArray(groups)) throw new Error(INVALID_FORMAT); + + let previousCode = Infinity; + const issueNumbers = new Set(); + for (const group of groups) { + if (!Array.isArray(group) || group.length < 2) { + throw new Error(INVALID_FORMAT); + } + + const [code, ...numbers] = group; + if ( + !Number.isInteger(code) || + code < 0 || + code > 6 || + code >= previousCode || + numbers.length === 0 + ) { + throw new Error(INVALID_FORMAT); + } + previousCode = code; + + let previousNumber = 0; + for (const number of numbers) { + if ( + !Number.isSafeInteger(number) || + number <= 0 || + number <= previousNumber || + issueNumbers.has(number) + ) { + throw new Error(INVALID_FORMAT); + } + previousNumber = number; + issueNumbers.add(number); + } + } + } + + return value as IssueIndex; +} + +async function fetchAndParse(url: string): Promise { + const response = await fetch(url, { cache: 'no-store' }); + if (!response.ok) { + throw new Error(`Issue index request failed (HTTP ${response.status}).`); + } + + let value: unknown; + try { + value = await response.json(); + } catch { + throw new Error(INVALID_FORMAT); + } + return parseIssueIndex(value); +} + +export async function fetchIssueIndex(): Promise { + try { + return await fetchAndParse(ISSUE_INDEX_URL); + } catch { + return parseIssueIndex(localIssueIndex); + } +} + +export function getIssueGroups( + index: IssueIndex, + pluginId: string, +): readonly IssueGroup[] { + return Object.prototype.hasOwnProperty.call(index, pluginId) + ? index[pluginId] + : EMPTY_ISSUE_GROUPS; +} diff --git a/src/pages/home.tsx b/src/pages/home.tsx index a94f8baeb..75686e172 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState, useCallback } from 'react'; +import React, { useMemo, useState, useCallback, useEffect } from 'react'; import { BookOpen, Search, Settings, Zap } from 'lucide-react'; import PluginHeader from '../components/plugin-header'; @@ -13,9 +13,14 @@ import SearchNovelsSection from '@/components/search-novels'; import ParseNovelSection from '@/components/parse-novel'; import SettingsSection from '@/components/settings'; import ParseChapterSection from '@/components/parse-chapter'; +import { Badge } from '@/components/ui/badge'; +import { getIssueGroups } from '@/lib/issue-index'; +import { PluginIssues } from '@/components/plugin-issues'; function PluginSidebar() { - const { plugin, selectPlugin } = useAppStore(state => state); + const plugin = useAppStore(state => state.plugin); + const selectPlugin = useAppStore(state => state.selectPlugin); + const issueIndexState = useAppStore(state => state.issueIndexState); const [searchQuery, setSearchQuery] = useState(''); const filteredPlugins = useMemo( @@ -56,6 +61,13 @@ function PluginSidebar() { const isVisible = p.name .toLowerCase() .includes(searchQuery.toLowerCase()); + const reportCount = + issueIndexState.status === 'ready' + ? getIssueGroups(issueIndexState.index, p.id).reduce( + (total, group) => total + group.length - 1, + 0, + ) + : 0; return ( ); })} @@ -87,9 +108,13 @@ function PluginSidebar() { } function Home() { - const { plugin } = useAppStore(state => state); + const plugin = useAppStore(state => state.plugin); + const loadIssueIndex = useAppStore(state => state.loadIssueIndex); const [activeTab, setActiveTab] = useState('popular'); + useEffect(() => { + void loadIssueIndex(); + }, [loadIssueIndex]); const handleNavigateToParseNovel = useCallback(() => { setActiveTab('parse-novel'); @@ -116,6 +141,7 @@ function Home() { Explore and test {plugin?.name || 'plugin'} features

+ {/* Tabs */} { export const useAppStore = create((set: SetStore, get: GetStore) => ({ ...PluginStore(set, get), ...NavigationStore(set, get), + ...IssueStore(set, get), theme: getInitialTheme(), setTheme(theme: 'light' | 'dark') { set(state => ({ diff --git a/src/store/issueStore.ts b/src/store/issueStore.ts new file mode 100644 index 000000000..70cedd02f --- /dev/null +++ b/src/store/issueStore.ts @@ -0,0 +1,37 @@ +import { fetchIssueIndex, type IssueIndex } from '@/lib/issue-index'; +import { StoreCreator } from '.'; + +export type IssueIndexState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'ready'; index: IssueIndex } + | { status: 'error'; message: string }; + +export type IssueStore = { + issueIndexState: IssueIndexState; + loadIssueIndex(): Promise; +}; + +export const IssueStore: StoreCreator = (set, get) => ({ + issueIndexState: { status: 'idle' }, + + async loadIssueIndex() { + if (get().issueIndexState.status === 'loading') return; + + set({ issueIndexState: { status: 'loading' } }); + try { + const index = await fetchIssueIndex(); + set({ issueIndexState: { status: 'ready', index } }); + } catch (error) { + set({ + issueIndexState: { + status: 'error', + message: + error instanceof Error + ? error.message + : 'Issue index request failed.', + }, + }); + } + }, +});