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]}
+
+
+
+ );
+}
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 (
- {p.name}
+ {p.name}
+ {reportCount > 0 && (
+
+ {reportCount}
+
+ )}
);
})}
@@ -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.',
+ },
+ });
+ }
+ },
+});