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.
- package/README.md +208 -0
- package/dist/app.js +59 -0
- package/dist/cli.js +196 -0
- package/dist/components/List.js +41 -0
- package/dist/components/screens/ContinueScreen.js +66 -0
- package/dist/components/screens/HomeScreen.js +59 -0
- package/dist/components/screens/LibraryScreen.js +69 -0
- package/dist/components/screens/LoginScreen.js +85 -0
- package/dist/components/screens/MangaScreen.js +107 -0
- package/dist/components/screens/ReaderScreen.js +195 -0
- package/dist/components/screens/SearchScreen.js +111 -0
- package/dist/components/screens/SettingsScreen.js +144 -0
- package/dist/components/ui.js +33 -0
- package/dist/config.js +51 -0
- package/dist/domain/shape.js +44 -0
- package/dist/hooks/useStdoutDimensions.js +17 -0
- package/dist/lib/AppError.js +37 -0
- package/dist/lib/cache.js +54 -0
- package/dist/lib/catchAsync.js +12 -0
- package/dist/lib/envelope.js +15 -0
- package/dist/lib/fetchWithBackoff.js +65 -0
- package/dist/lib/logger.js +41 -0
- package/dist/lib/natsort.js +7 -0
- package/dist/lib/text.js +20 -0
- package/dist/render/chafa.js +56 -0
- package/dist/render/detect.js +86 -0
- package/dist/render/halfblock.js +42 -0
- package/dist/render/image.js +23 -0
- package/dist/render/sixel.js +88 -0
- package/dist/sixel-reader.js +309 -0
- package/dist/sources/index.js +17 -0
- package/dist/sources/local/archive.js +68 -0
- package/dist/sources/local/index.js +147 -0
- package/dist/sources/mangadex/auth.js +102 -0
- package/dist/sources/mangadex/client.js +76 -0
- package/dist/sources/mangadex/index.js +156 -0
- package/dist/sources/mangadex/normalize.js +54 -0
- package/dist/state/store.js +91 -0
- package/dist/ui-context.js +11 -0
- package/package.json +50 -0
- package/src/app.js +73 -0
- package/src/cli.js +218 -0
- package/src/components/List.js +60 -0
- package/src/components/screens/ContinueScreen.js +73 -0
- package/src/components/screens/HomeScreen.js +54 -0
- package/src/components/screens/LibraryScreen.js +79 -0
- package/src/components/screens/LoginScreen.js +92 -0
- package/src/components/screens/MangaScreen.js +125 -0
- package/src/components/screens/ReaderScreen.js +230 -0
- package/src/components/screens/SearchScreen.js +123 -0
- package/src/components/screens/SettingsScreen.js +146 -0
- package/src/components/ui.js +42 -0
- package/src/config.js +49 -0
- package/src/domain/shape.js +47 -0
- package/src/hooks/useStdoutDimensions.js +19 -0
- package/src/lib/AppError.js +26 -0
- package/src/lib/cache.js +57 -0
- package/src/lib/catchAsync.js +12 -0
- package/src/lib/envelope.js +14 -0
- package/src/lib/fetchWithBackoff.js +74 -0
- package/src/lib/logger.js +41 -0
- package/src/lib/natsort.js +7 -0
- package/src/lib/text.js +18 -0
- package/src/render/chafa.js +64 -0
- package/src/render/detect.js +112 -0
- package/src/render/halfblock.js +46 -0
- package/src/render/image.js +24 -0
- package/src/render/sixel.js +141 -0
- package/src/sixel-reader.js +359 -0
- package/src/sources/index.js +17 -0
- package/src/sources/local/archive.js +74 -0
- package/src/sources/local/index.js +155 -0
- package/src/sources/mangadex/auth.js +125 -0
- package/src/sources/mangadex/client.js +83 -0
- package/src/sources/mangadex/index.js +166 -0
- package/src/sources/mangadex/normalize.js +70 -0
- package/src/state/store.js +90 -0
- 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
|
+
};
|