komado 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +208 -0
  2. package/dist/app.js +59 -0
  3. package/dist/cli.js +196 -0
  4. package/dist/components/List.js +41 -0
  5. package/dist/components/screens/ContinueScreen.js +66 -0
  6. package/dist/components/screens/HomeScreen.js +59 -0
  7. package/dist/components/screens/LibraryScreen.js +69 -0
  8. package/dist/components/screens/LoginScreen.js +85 -0
  9. package/dist/components/screens/MangaScreen.js +107 -0
  10. package/dist/components/screens/ReaderScreen.js +195 -0
  11. package/dist/components/screens/SearchScreen.js +111 -0
  12. package/dist/components/screens/SettingsScreen.js +144 -0
  13. package/dist/components/ui.js +33 -0
  14. package/dist/config.js +51 -0
  15. package/dist/domain/shape.js +44 -0
  16. package/dist/hooks/useStdoutDimensions.js +17 -0
  17. package/dist/lib/AppError.js +37 -0
  18. package/dist/lib/cache.js +54 -0
  19. package/dist/lib/catchAsync.js +12 -0
  20. package/dist/lib/envelope.js +15 -0
  21. package/dist/lib/fetchWithBackoff.js +65 -0
  22. package/dist/lib/logger.js +41 -0
  23. package/dist/lib/natsort.js +7 -0
  24. package/dist/lib/text.js +20 -0
  25. package/dist/render/chafa.js +56 -0
  26. package/dist/render/detect.js +86 -0
  27. package/dist/render/halfblock.js +42 -0
  28. package/dist/render/image.js +23 -0
  29. package/dist/render/sixel.js +88 -0
  30. package/dist/sixel-reader.js +309 -0
  31. package/dist/sources/index.js +17 -0
  32. package/dist/sources/local/archive.js +68 -0
  33. package/dist/sources/local/index.js +147 -0
  34. package/dist/sources/mangadex/auth.js +102 -0
  35. package/dist/sources/mangadex/client.js +76 -0
  36. package/dist/sources/mangadex/index.js +156 -0
  37. package/dist/sources/mangadex/normalize.js +54 -0
  38. package/dist/state/store.js +91 -0
  39. package/dist/ui-context.js +11 -0
  40. package/package.json +50 -0
  41. package/src/app.js +73 -0
  42. package/src/cli.js +218 -0
  43. package/src/components/List.js +60 -0
  44. package/src/components/screens/ContinueScreen.js +73 -0
  45. package/src/components/screens/HomeScreen.js +54 -0
  46. package/src/components/screens/LibraryScreen.js +79 -0
  47. package/src/components/screens/LoginScreen.js +92 -0
  48. package/src/components/screens/MangaScreen.js +125 -0
  49. package/src/components/screens/ReaderScreen.js +230 -0
  50. package/src/components/screens/SearchScreen.js +123 -0
  51. package/src/components/screens/SettingsScreen.js +146 -0
  52. package/src/components/ui.js +42 -0
  53. package/src/config.js +49 -0
  54. package/src/domain/shape.js +47 -0
  55. package/src/hooks/useStdoutDimensions.js +19 -0
  56. package/src/lib/AppError.js +26 -0
  57. package/src/lib/cache.js +57 -0
  58. package/src/lib/catchAsync.js +12 -0
  59. package/src/lib/envelope.js +14 -0
  60. package/src/lib/fetchWithBackoff.js +74 -0
  61. package/src/lib/logger.js +41 -0
  62. package/src/lib/natsort.js +7 -0
  63. package/src/lib/text.js +18 -0
  64. package/src/render/chafa.js +64 -0
  65. package/src/render/detect.js +112 -0
  66. package/src/render/halfblock.js +46 -0
  67. package/src/render/image.js +24 -0
  68. package/src/render/sixel.js +141 -0
  69. package/src/sixel-reader.js +359 -0
  70. package/src/sources/index.js +17 -0
  71. package/src/sources/local/archive.js +74 -0
  72. package/src/sources/local/index.js +155 -0
  73. package/src/sources/mangadex/auth.js +125 -0
  74. package/src/sources/mangadex/client.js +83 -0
  75. package/src/sources/mangadex/index.js +166 -0
  76. package/src/sources/mangadex/normalize.js +70 -0
  77. package/src/state/store.js +90 -0
  78. package/src/ui-context.js +12 -0
@@ -0,0 +1,69 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect, useRef } from "react";
3
+ import { Box, Text } from "ink";
4
+ import { useUI } from "../../ui-context.js";
5
+ import { getSource } from "../../sources/index.js";
6
+ import { List } from "../List.js";
7
+ import { Header, Spinner, ErrorView, KeyHints } from "../ui.js";
8
+ import { truncate } from "../../lib/text.js";
9
+ const PAGE = 32;
10
+ function LibraryScreen({ params }) {
11
+ const sourceId = params?.sourceId || "mangadex";
12
+ const ui = useUI();
13
+ const source = getSource(sourceId);
14
+ const [results, setResults] = useState([]);
15
+ const [pagination, setPagination] = useState(null);
16
+ const [loading, setLoading] = useState(true);
17
+ const [error, setError] = useState(null);
18
+ const reqId = useRef(0);
19
+ const fetchPage = async (offset, append) => {
20
+ const rid = ++reqId.current;
21
+ const ctrl = new AbortController();
22
+ setLoading(true);
23
+ setError(null);
24
+ try {
25
+ const res = await source.getFollows({ offset, limit: PAGE, signal: ctrl.signal });
26
+ if (rid !== reqId.current) return;
27
+ setResults((prev) => append ? [...prev, ...res.data] : res.data);
28
+ setPagination(res.pagination);
29
+ } catch (err) {
30
+ if (rid === reqId.current) setError(err);
31
+ } finally {
32
+ if (rid === reqId.current) setLoading(false);
33
+ }
34
+ };
35
+ useEffect(() => {
36
+ fetchPage(0, false);
37
+ }, []);
38
+ const loadMore = () => {
39
+ if (loading || !pagination?.hasMore) return;
40
+ fetchPage(pagination.offset + pagination.limit, true);
41
+ };
42
+ const onHighlight = (_item, index) => {
43
+ if (index >= results.length - 2) loadMore();
44
+ };
45
+ const listHeight = Math.max(4, (ui.dimensions.rows || 24) - 8);
46
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
47
+ /* @__PURE__ */ jsx(Header, { title: "My Library", subtitle: "manga you follow on MangaDex" }),
48
+ loading && !results.length ? /* @__PURE__ */ jsx(Spinner, { label: "Loading your follows" }) : null,
49
+ error ? /* @__PURE__ */ jsx(ErrorView, { error }) : null,
50
+ !error ? /* @__PURE__ */ jsx(
51
+ List,
52
+ {
53
+ items: results,
54
+ height: listHeight,
55
+ onSelect: (m) => ui.navigate("manga", { sourceId, manga: m }),
56
+ onHighlight,
57
+ emptyText: loading ? " " : "You are not following any manga yet.",
58
+ renderItem: (m, active) => /* @__PURE__ */ jsxs(Box, { children: [
59
+ /* @__PURE__ */ jsx(Text, { inverse: active, color: active ? "cyanBright" : void 0, children: ` ${truncate(m.title, Math.max(20, (ui.dimensions.cols || 80) - 24))} ` }),
60
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: ` ${m.status || ""}` })
61
+ ] }, m.key)
62
+ }
63
+ ) : null,
64
+ /* @__PURE__ */ jsx(KeyHints, { hints: [["\u2191\u2193", "move"], ["enter", "open"], ["esc", "back"]] })
65
+ ] });
66
+ }
67
+ export {
68
+ LibraryScreen
69
+ };
@@ -0,0 +1,85 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import TextInput from "ink-text-input";
5
+ import { useUI } from "../../ui-context.js";
6
+ import { login } from "../../sources/mangadex/auth.js";
7
+ import { Header, Spinner, ErrorView, KeyHints } from "../ui.js";
8
+ const FIELDS = [
9
+ { key: "clientId", label: "Client ID", mask: false },
10
+ { key: "clientSecret", label: "Client Secret", mask: true },
11
+ { key: "username", label: "Username / email", mask: false },
12
+ { key: "password", label: "Password", mask: true }
13
+ ];
14
+ function LoginScreen() {
15
+ const ui = useUI();
16
+ const [vals, setVals] = useState({ clientId: "", clientSecret: "", username: "", password: "" });
17
+ const [idx, setIdx] = useState(0);
18
+ const [busy, setBusy] = useState(false);
19
+ const [error, setError] = useState(null);
20
+ useEffect(() => {
21
+ ui.setTyping(true);
22
+ return () => ui.setTyping(false);
23
+ }, []);
24
+ const setField = (key) => (v) => setVals((s) => ({ ...s, [key]: v }));
25
+ const submit = async () => {
26
+ if (busy) return;
27
+ if (idx < FIELDS.length - 1) {
28
+ setIdx(idx + 1);
29
+ return;
30
+ }
31
+ if (FIELDS.some((f) => !vals[f.key].trim())) {
32
+ setError(new Error("All four fields are required."));
33
+ return;
34
+ }
35
+ setBusy(true);
36
+ setError(null);
37
+ try {
38
+ await login({
39
+ clientId: vals.clientId.trim(),
40
+ clientSecret: vals.clientSecret.trim(),
41
+ username: vals.username.trim(),
42
+ password: vals.password
43
+ });
44
+ ui.goBack();
45
+ } catch (err) {
46
+ setError(err);
47
+ setBusy(false);
48
+ }
49
+ };
50
+ useInput((input, key) => {
51
+ if (busy) return;
52
+ if (key.escape) ui.goBack();
53
+ else if (key.tab || key.downArrow) setIdx((i) => (i + 1) % FIELDS.length);
54
+ else if (key.upArrow) setIdx((i) => (i - 1 + FIELDS.length) % FIELDS.length);
55
+ });
56
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
57
+ /* @__PURE__ */ jsx(Header, { title: "Log in to MangaDex", subtitle: "OAuth2 personal client" }),
58
+ /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [
59
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Create a personal client at mangadex.org/settings \u2192 API Clients," }),
60
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "then enter its id + secret with your MangaDex login below." }),
61
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "(A new client may need staff approval before it works.)" })
62
+ ] }),
63
+ FIELDS.map((f, i) => /* @__PURE__ */ jsxs(Box, { children: [
64
+ /* @__PURE__ */ jsx(Box, { width: 16, children: /* @__PURE__ */ jsx(Text, { color: i === idx ? "cyanBright" : void 0, children: `${i === idx ? "\u203A " : " "}${f.label}` }) }),
65
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: ": " }),
66
+ /* @__PURE__ */ jsx(
67
+ TextInput,
68
+ {
69
+ value: vals[f.key],
70
+ onChange: setField(f.key),
71
+ onSubmit: submit,
72
+ focus: i === idx && !busy,
73
+ mask: f.mask ? "*" : void 0,
74
+ placeholder: i === idx ? "type\u2026" : ""
75
+ }
76
+ )
77
+ ] }, f.key)),
78
+ busy ? /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Spinner, { label: "Signing in" }) }) : null,
79
+ error ? /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(ErrorView, { error }) }) : null,
80
+ /* @__PURE__ */ jsx(KeyHints, { hints: [["enter", "next / submit"], ["tab \u2191\u2193", "fields"], ["esc", "cancel"]] })
81
+ ] });
82
+ }
83
+ export {
84
+ LoginScreen
85
+ };
@@ -0,0 +1,107 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { useUI } from "../../ui-context.js";
5
+ import { getSource } from "../../sources/index.js";
6
+ import { getProgress } from "../../state/store.js";
7
+ import { isLoggedIn } from "../../sources/mangadex/auth.js";
8
+ import { chapterLabel } from "../../domain/shape.js";
9
+ import { List } from "../List.js";
10
+ import { Header, Spinner, ErrorView, KeyHints } from "../ui.js";
11
+ import { truncate } from "../../lib/text.js";
12
+ function MangaScreen({ params }) {
13
+ const { sourceId, manga: initial } = params;
14
+ const ui = useUI();
15
+ const source = getSource(sourceId);
16
+ const [manga, setManga] = useState(initial);
17
+ const [chapters, setChapters] = useState([]);
18
+ const [loading, setLoading] = useState(true);
19
+ const [error, setError] = useState(null);
20
+ const [readSet, setReadSet] = useState(null);
21
+ const progress = getProgress(initial.key);
22
+ useEffect(() => {
23
+ let cancelled = false;
24
+ const ctrl = new AbortController();
25
+ setLoading(true);
26
+ setError(null);
27
+ (async () => {
28
+ try {
29
+ const [full, chRes] = await Promise.all([
30
+ source.getManga(initial.id, { signal: ctrl.signal }).catch(() => initial),
31
+ source.listChapters(initial.id, { signal: ctrl.signal, limit: 500 })
32
+ ]);
33
+ if (cancelled) return;
34
+ setManga(full);
35
+ setChapters(chRes.data);
36
+ if (isLoggedIn() && source.getReadMarkers) {
37
+ source.getReadMarkers(initial.id, { signal: ctrl.signal }).then((ids) => {
38
+ if (!cancelled) setReadSet(new Set(ids));
39
+ }).catch(() => {
40
+ });
41
+ }
42
+ } catch (err) {
43
+ if (!cancelled) setError(err);
44
+ } finally {
45
+ if (!cancelled) setLoading(false);
46
+ }
47
+ })();
48
+ return () => {
49
+ cancelled = true;
50
+ ctrl.abort();
51
+ };
52
+ }, [initial.key]);
53
+ const openAt = (index, startPage = 0) => ui.openReader({ sourceId, manga, chapters, chapterIndex: index, startPage });
54
+ const resume = () => {
55
+ if (!progress) return;
56
+ const idx = chapters.findIndex((c) => c.id === progress.chapterId);
57
+ if (idx >= 0) openAt(idx, progress.page || 0);
58
+ };
59
+ useInput((input) => {
60
+ if (input === "r" && progress && chapters.length) resume();
61
+ });
62
+ const cols = ui.dimensions.cols || 80;
63
+ const resumeChapterId = progress?.chapterId;
64
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
65
+ /* @__PURE__ */ jsx(
66
+ Header,
67
+ {
68
+ title: truncate(manga.title, cols - 4),
69
+ subtitle: [manga.authors?.join(", "), manga.status].filter(Boolean).join(" \xB7 ")
70
+ }
71
+ ),
72
+ manga.tags?.length ? /* @__PURE__ */ jsx(Text, { color: "blue", children: truncate(manga.tags.join(" \xB7 "), cols - 4) }) : null,
73
+ manga.description ? /* @__PURE__ */ jsx(Box, { marginTop: 1, width: cols - 4, children: /* @__PURE__ */ jsx(Text, { dimColor: true, wrap: "truncate-end", children: truncate(manga.description.replace(/\s+/g, " "), (cols - 4) * 3) }) }) : null,
74
+ progress ? /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { color: "green", children: `\u25B6 Resume Ch.${progress.chapterNumber ?? "?"} p.${(progress.page || 0) + 1} (press r)` }) }) : null,
75
+ /* @__PURE__ */ jsxs(Box, { marginTop: 1, flexDirection: "column", children: [
76
+ /* @__PURE__ */ jsx(Text, { bold: true, children: `Chapters ${chapters.length ? `(${chapters.length})` : ""}` }),
77
+ loading ? /* @__PURE__ */ jsx(Spinner, { label: "Loading chapters" }) : null,
78
+ error ? /* @__PURE__ */ jsx(ErrorView, { error }) : null,
79
+ !loading && !error ? /* @__PURE__ */ jsx(
80
+ List,
81
+ {
82
+ items: chapters,
83
+ height: Math.max(4, (ui.dimensions.rows || 24) - 12),
84
+ onSelect: (_c, index) => openAt(index),
85
+ emptyText: "No chapters available in this language (try changing language in Settings).",
86
+ renderItem: (ch, active) => {
87
+ const isResume = ch.id === resumeChapterId;
88
+ const read = readSet?.has(ch.id);
89
+ return /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(
90
+ Text,
91
+ {
92
+ inverse: active,
93
+ color: active ? "cyanBright" : isResume ? "green" : void 0,
94
+ dimColor: !active && read && !isResume,
95
+ children: ` ${isResume ? "\u25B6 " : read ? "\u2713 " : " "}${truncate(chapterLabel(ch), cols - 8)} `
96
+ }
97
+ ) }, ch.id);
98
+ }
99
+ }
100
+ ) : null
101
+ ] }),
102
+ /* @__PURE__ */ jsx(KeyHints, { hints: [["\u2191\u2193", "move"], ["enter", "read"], ...progress ? [["r", "resume"]] : [], ["esc", "back"]] })
103
+ ] });
104
+ }
105
+ export {
106
+ MangaScreen
107
+ };
@@ -0,0 +1,195 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect, useRef, useMemo } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { useUI } from "../../ui-context.js";
5
+ import { getSource } from "../../sources/index.js";
6
+ import { setProgress, getConfig } from "../../state/store.js";
7
+ import { chapterLabel } from "../../domain/shape.js";
8
+ import { renderInline, imageSize } from "../../render/image.js";
9
+ import { pickInlineBackend, RENDERER_CYCLE } from "../../render/detect.js";
10
+ import { Spinner, ErrorView, KeyHints } from "../ui.js";
11
+ import { truncate } from "../../lib/text.js";
12
+ function ReaderScreen({ params }) {
13
+ const { sourceId, manga, chapters, chapterIndex: startChapter, startPage = 0 } = params;
14
+ const ui = useUI();
15
+ const source = getSource(sourceId);
16
+ const { cols, rows } = ui.dimensions;
17
+ const [chapterIndex, setChapterIndex] = useState(startChapter);
18
+ const [pages, setPages] = useState(null);
19
+ const [pageIndex, setPageIndex] = useState(startPage);
20
+ const [scroll, setScroll] = useState(0);
21
+ const [rendered, setRendered] = useState(null);
22
+ const [status, setStatus] = useState("loading");
23
+ const [error, setError] = useState(null);
24
+ const [fitMode, setFitMode] = useState(false);
25
+ const [rendererPref, setRendererPref] = useState(getConfig().renderer || "auto");
26
+ const chapter = chapters[chapterIndex];
27
+ const viewportRows = Math.max(3, rows - 3);
28
+ const renderCache = useRef(/* @__PURE__ */ new Map());
29
+ const backend = useMemo(() => pickInlineBackend({ renderer: rendererPref }), [rendererPref]);
30
+ useEffect(() => {
31
+ let cancelled = false;
32
+ const ctrl = new AbortController();
33
+ setStatus("loading");
34
+ setPages(null);
35
+ setError(null);
36
+ (async () => {
37
+ try {
38
+ const pgs = await source.getPages(chapter.id, { signal: ctrl.signal });
39
+ if (cancelled) return;
40
+ setPages(pgs);
41
+ setPageIndex((p) => Math.max(0, Math.min(p, pgs.length - 1)));
42
+ } catch (err) {
43
+ if (!cancelled) {
44
+ setError(err);
45
+ setStatus("error");
46
+ }
47
+ }
48
+ })();
49
+ return () => {
50
+ cancelled = true;
51
+ ctrl.abort();
52
+ };
53
+ }, [chapter?.id]);
54
+ const cacheKeyFor = (idx) => `${chapter?.id}:${idx}:${cols}:${viewportRows}:${backend}:${fitMode ? "fit" : "scroll"}`;
55
+ const renderPage = async (idx, signal) => {
56
+ const key = cacheKeyFor(idx);
57
+ if (renderCache.current.has(key)) return renderCache.current.get(key);
58
+ const buf = await source.loadPageBuffer(pages[idx], { signal });
59
+ let renderCols = cols;
60
+ if (fitMode) {
61
+ const { width, height } = await imageSize(buf);
62
+ renderCols = Math.max(8, Math.min(cols, Math.floor(viewportRows * 2 * (width / height))));
63
+ }
64
+ const out = await renderInline(buf, { cols: renderCols, backend });
65
+ renderCache.current.set(key, out);
66
+ return out;
67
+ };
68
+ const prefetchers = useRef(/* @__PURE__ */ new Set());
69
+ useEffect(() => () => {
70
+ for (const c of prefetchers.current) c.abort();
71
+ prefetchers.current.clear();
72
+ }, []);
73
+ const prefetch = (idx) => {
74
+ if (!pages?.[idx] || renderCache.current.has(cacheKeyFor(idx))) return;
75
+ const c = new AbortController();
76
+ prefetchers.current.add(c);
77
+ renderPage(idx, c.signal).catch(() => {
78
+ }).finally(() => prefetchers.current.delete(c));
79
+ };
80
+ useEffect(() => {
81
+ if (!pages || !pages[pageIndex]) return;
82
+ let cancelled = false;
83
+ const ctrl = new AbortController();
84
+ if (renderCache.current.has(cacheKeyFor(pageIndex))) {
85
+ setRendered(renderCache.current.get(cacheKeyFor(pageIndex)));
86
+ setScroll(0);
87
+ setStatus("ready");
88
+ } else {
89
+ setStatus("loading");
90
+ renderPage(pageIndex, ctrl.signal).then((out) => {
91
+ if (cancelled) return;
92
+ setRendered(out);
93
+ setScroll(0);
94
+ setStatus("ready");
95
+ }).catch((err) => {
96
+ if (!cancelled && !ctrl.signal.aborted) {
97
+ setError(err);
98
+ setStatus("error");
99
+ }
100
+ });
101
+ }
102
+ prefetch(pageIndex + 1);
103
+ return () => {
104
+ cancelled = true;
105
+ ctrl.abort();
106
+ };
107
+ }, [pages, pageIndex, cols, viewportRows, backend, fitMode]);
108
+ useEffect(() => {
109
+ if (status !== "ready" || !pages) return;
110
+ setProgress(manga.key, {
111
+ source: sourceId,
112
+ mangaId: manga.id,
113
+ mangaTitle: manga.title,
114
+ chapterId: chapter.id,
115
+ chapterNumber: chapter.number,
116
+ page: pageIndex
117
+ });
118
+ if (pageIndex === pages.length - 1 && source.syncChapterRead) {
119
+ source.syncChapterRead(manga.id, chapter.id);
120
+ }
121
+ }, [pageIndex, chapter?.id, status]);
122
+ const lines = rendered?.lines || [];
123
+ const maxScroll = Math.max(0, lines.length - viewportRows);
124
+ const changeChapter = (delta) => {
125
+ const next = chapterIndex + delta;
126
+ if (next < 0 || next >= chapters.length) return;
127
+ setChapterIndex(next);
128
+ setPageIndex(0);
129
+ setScroll(0);
130
+ };
131
+ const nextPage = () => {
132
+ if (pages && pageIndex < pages.length - 1) {
133
+ setPageIndex(pageIndex + 1);
134
+ setScroll(0);
135
+ } else {
136
+ changeChapter(1);
137
+ }
138
+ };
139
+ const prevPage = () => {
140
+ if (pageIndex > 0) {
141
+ setPageIndex(pageIndex - 1);
142
+ setScroll(0);
143
+ } else {
144
+ changeChapter(-1);
145
+ }
146
+ };
147
+ const cycleRenderer = () => {
148
+ const i = RENDERER_CYCLE.indexOf(rendererPref);
149
+ setRendererPref(RENDERER_CYCLE[(i + 1) % RENDERER_CYCLE.length]);
150
+ };
151
+ useInput((input, key) => {
152
+ if (key.downArrow || input === "j") setScroll((s) => Math.min(maxScroll, s + 1));
153
+ else if (key.upArrow || input === "k") setScroll((s) => Math.max(0, s - 1));
154
+ else if (input === " " || key.pageDown) {
155
+ if (scroll >= maxScroll) nextPage();
156
+ else setScroll((s) => Math.min(maxScroll, s + viewportRows - 1));
157
+ } else if (key.pageUp) setScroll((s) => Math.max(0, s - (viewportRows - 1)));
158
+ else if (key.rightArrow || input === "l" || input === "n") nextPage();
159
+ else if (key.leftArrow || input === "h" || input === "p") prevPage();
160
+ else if (input === "g") setScroll(0);
161
+ else if (input === "G") setScroll(maxScroll);
162
+ else if (input === "N") changeChapter(1);
163
+ else if (input === "P") changeChapter(-1);
164
+ else if (input === "f") setFitMode((f) => !f);
165
+ else if (input === "r") cycleRenderer();
166
+ });
167
+ const pageLabel = pages ? `${pageIndex + 1}/${pages.length}` : "\u2026";
168
+ const slice = lines.slice(scroll, scroll + viewportRows);
169
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
170
+ /* @__PURE__ */ jsxs(Box, { justifyContent: "space-between", children: [
171
+ /* @__PURE__ */ jsx(Text, { color: "magentaBright", bold: true, children: truncate(manga.title, Math.max(10, cols - 34)) }),
172
+ /* @__PURE__ */ jsx(Text, { children: `${truncate(chapterLabel(chapter), 24)} \xB7 ${pageLabel}${fitMode ? " \xB7 fit" : ""}` })
173
+ ] }),
174
+ /* @__PURE__ */ jsx(Box, { height: viewportRows, flexDirection: "column", children: status === "loading" ? /* @__PURE__ */ jsx(Spinner, { label: pages ? `Rendering page ${pageIndex + 1}` : "Loading chapter" }) : status === "error" ? /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
175
+ /* @__PURE__ */ jsx(ErrorView, { error }),
176
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Press N / P to skip to another chapter, or Esc to go back." })
177
+ ] }) : slice.map((ln, i) => /* @__PURE__ */ jsx(Text, { wrap: "truncate-end", children: ln }, scroll + i)) }),
178
+ /* @__PURE__ */ jsx(
179
+ KeyHints,
180
+ {
181
+ hints: [
182
+ ["\u2190\u2192", "page"],
183
+ ["\u2191\u2193", "scroll"],
184
+ ["N/P", "chapter"],
185
+ ["f", fitMode ? "scroll" : "fit"],
186
+ ["r", `render:${rendererPref}`],
187
+ ["esc", "back"]
188
+ ]
189
+ }
190
+ )
191
+ ] });
192
+ }
193
+ export {
194
+ ReaderScreen
195
+ };
@@ -0,0 +1,111 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect, useRef } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import TextInput from "ink-text-input";
5
+ import { useUI } from "../../ui-context.js";
6
+ import { getSource } from "../../sources/index.js";
7
+ import { List } from "../List.js";
8
+ import { Header, Spinner, ErrorView, KeyHints } from "../ui.js";
9
+ import { truncate } from "../../lib/text.js";
10
+ const PAGE = 20;
11
+ function SearchScreen({ params }) {
12
+ const { sourceId, mode } = params;
13
+ const ui = useUI();
14
+ const source = getSource(sourceId);
15
+ const [query, setQuery] = useState("");
16
+ const [submitted, setSubmitted] = useState(mode === "browse" ? "" : null);
17
+ const [results, setResults] = useState([]);
18
+ const [pagination, setPagination] = useState(null);
19
+ const [loading, setLoading] = useState(mode === "browse");
20
+ const [error, setError] = useState(null);
21
+ const [focusInput, setFocusInput] = useState(mode !== "browse");
22
+ useEffect(() => {
23
+ ui.setTyping(focusInput);
24
+ return () => ui.setTyping(false);
25
+ }, [focusInput]);
26
+ const reqId = useRef(0);
27
+ const fetchPage = async (q, offset, append) => {
28
+ const id = ++reqId.current;
29
+ const ctrl = new AbortController();
30
+ setLoading(true);
31
+ setError(null);
32
+ try {
33
+ const res = await source.search(q, { offset, limit: PAGE, signal: ctrl.signal });
34
+ if (id !== reqId.current) return;
35
+ setResults((prev) => append ? [...prev, ...res.data] : res.data);
36
+ setPagination(res.pagination);
37
+ } catch (err) {
38
+ if (id === reqId.current) setError(err);
39
+ } finally {
40
+ if (id === reqId.current) setLoading(false);
41
+ }
42
+ };
43
+ useEffect(() => {
44
+ if (mode === "browse") fetchPage("", 0, false);
45
+ }, []);
46
+ const onSubmit = () => {
47
+ setSubmitted(query);
48
+ setFocusInput(false);
49
+ fetchPage(query, 0, false);
50
+ };
51
+ const loadMore = () => {
52
+ if (loading || !pagination?.hasMore) return;
53
+ fetchPage(submitted ?? "", pagination.offset + pagination.limit, true);
54
+ };
55
+ const onHighlight = (_item, index) => {
56
+ if (index >= results.length - 2) loadMore();
57
+ };
58
+ useInput((input, key) => {
59
+ if (!focusInput && input === "/") setFocusInput(true);
60
+ else if (focusInput && key.escape) setFocusInput(false);
61
+ });
62
+ const listHeight = Math.max(4, (ui.dimensions.rows || 24) - 9);
63
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
64
+ /* @__PURE__ */ jsx(
65
+ Header,
66
+ {
67
+ title: source.label,
68
+ subtitle: sourceId === "local" ? "filter your local library" : "search the online catalog"
69
+ }
70
+ ),
71
+ /* @__PURE__ */ jsxs(Box, { children: [
72
+ /* @__PURE__ */ jsx(Text, { color: focusInput ? "cyanBright" : "gray", children: focusInput ? "\u203A " : " " }),
73
+ /* @__PURE__ */ jsx(
74
+ TextInput,
75
+ {
76
+ value: query,
77
+ onChange: setQuery,
78
+ onSubmit,
79
+ focus: focusInput,
80
+ placeholder: sourceId === "local" ? "type to filter\u2026" : "type a title, enter to search\u2026"
81
+ }
82
+ )
83
+ ] }),
84
+ loading ? /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Spinner, { label: "Loading" }) }) : null,
85
+ error ? /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(ErrorView, { error }) }) : null,
86
+ !error && submitted !== null ? /* @__PURE__ */ jsx(Box, { flexDirection: "column", marginTop: 1, children: /* @__PURE__ */ jsx(
87
+ List,
88
+ {
89
+ items: results,
90
+ isActive: !focusInput,
91
+ height: listHeight,
92
+ onSelect: (m) => ui.navigate("manga", { sourceId, manga: m }),
93
+ onHighlight,
94
+ emptyText: loading ? " " : submitted === "" ? "Nothing found." : `No results for "${submitted}".`,
95
+ renderItem: (m, active) => /* @__PURE__ */ jsxs(Box, { children: [
96
+ /* @__PURE__ */ jsx(Text, { inverse: active, color: active ? "cyanBright" : void 0, children: ` ${truncate(m.title, Math.max(20, (ui.dimensions.cols || 80) - 24))} ` }),
97
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: ` ${m.source === "local" ? `${m.chaptersCount ?? "?"} ch` : m.status || ""}` })
98
+ ] }, m.key)
99
+ }
100
+ ) }) : null,
101
+ /* @__PURE__ */ jsx(
102
+ KeyHints,
103
+ {
104
+ hints: focusInput ? [["enter", "search"], ["esc", "to results"]] : [["\u2191\u2193", "move"], ["enter", "open"], ["/", "search"], ["esc", "back"]]
105
+ }
106
+ )
107
+ ] });
108
+ }
109
+ export {
110
+ SearchScreen
111
+ };