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
package/README.md ADDED
@@ -0,0 +1,208 @@
1
+ # komado
2
+
3
+ A terminal manga reader. Browse and read from **MangaDex** (online) or your **local
4
+ library** (CBZ/ZIP archives and image folders), rendered straight into the terminal
5
+ with [Ink](https://github.com/vadimdemedes/ink).
6
+
7
+ ```
8
+ komado
9
+ a terminal manga reader · MangaDex + local files
10
+
11
+ › Search MangaDex online catalog
12
+ Popular on MangaDex most followed
13
+ Local library your CBZ / folders
14
+ Continue reading resume
15
+ Settings config & library paths
16
+ Quit
17
+ ```
18
+
19
+ ## Features
20
+
21
+ - **Two sources, one reader.** MangaDex and local files are normalized into a single
22
+ shape, so search / details / reader / progress treat them identically.
23
+ - **Image rendering with graceful upgrade.** A pure-JS Unicode half-block renderer works
24
+ in any 24-bit terminal; if [`chafa`](https://hpjansson.org/chafa/) is installed it’s
25
+ auto-used for sharper output. Falls back automatically if anything goes wrong.
26
+ - **Reader UX.** Vertical scroll for tall pages, page/chapter navigation, fit-to-screen
27
+ toggle, runtime renderer switching, and next-page prefetch.
28
+ - **Reading progress.** Where you left off is saved per manga and resumable from
29
+ *Continue reading*.
30
+ - **Sign in to MangaDex (optional).** Log in to browse your **followed library**, see
31
+ which chapters you've already read, and **sync progress back** — finishing a chapter
32
+ marks it read on MangaDex. Reading itself needs no account.
33
+ - **Offline-friendly.** Point it at folders of `.cbz`/`.zip` or loose images.
34
+
35
+ ## Requirements
36
+
37
+ - Node.js **≥ 20** (developed on Node 22).
38
+ - A 24-bit-color terminal (most modern ones).
39
+ - Optional but recommended: **`chafa`** for higher-fidelity rendering.
40
+ - Debian/Ubuntu: `sudo apt install chafa` · macOS: `brew install chafa`
41
+
42
+ ## Install
43
+
44
+ One line — fetches the latest, builds it, and drops a `komado` launcher on your PATH:
45
+
46
+ ```bash
47
+ curl -fsSL https://raw.githubusercontent.com/RyuPrad/komado/main/install.sh | bash
48
+ ```
49
+
50
+ Then just type **`komado`**. Re-run that same command any time to update. It needs
51
+ `git`, **Node ≥ 20**, and `npm`; it installs to `~/.local/share/komado` with the
52
+ launcher in `~/.local/bin` (override via `KOMADO_APP_DIR` / `KOMADO_BIN_DIR`).
53
+
54
+ Uninstall: `rm -rf ~/.local/share/komado ~/.local/bin/komado` (add `~/.komado` to also
55
+ drop your saved progress and login).
56
+
57
+ ### From source (for development)
58
+
59
+ ```bash
60
+ npm install # also builds dist/ via the prepare script
61
+ npm start # rebuilds, then launches the reader
62
+ ```
63
+
64
+ The UI is JSX, transpiled to `dist/` by esbuild (`npm run build`). Check what your
65
+ terminal supports and where state is stored:
66
+
67
+ ```bash
68
+ npm run doctor
69
+ ```
70
+
71
+ Render a single image (path or URL) at the best fidelity your terminal allows — handy
72
+ for testing sixel/kitty terminals:
73
+
74
+ ```bash
75
+ node dist/cli.js render ./cover.jpg
76
+ node dist/cli.js render https://example.com/page.png 100
77
+ ```
78
+
79
+ ## Keys
80
+
81
+ | Context | Keys | Action |
82
+ |---|---|---|
83
+ | Lists | `↑`/`↓` or `j`/`k`, `g`/`G`, `PgUp`/`PgDn` | move / jump / page |
84
+ | Lists | `enter` | open · `/` focus search · `esc` back |
85
+ | Reader | `↑`/`↓` or `j`/`k` | scroll within a page |
86
+ | Reader | `←`/`→` or `a`/`d`, `space` | previous / next page |
87
+ | Reader | `N` / `P` | next / previous chapter |
88
+ | Reader | `f` | toggle fit-to-screen |
89
+ | Reader | `r` | cycle renderer (`auto` → `halfblock` → `chafa`) |
90
+ | Global | `q` | quit · `esc` back |
91
+
92
+ ## Rendering quality
93
+
94
+ Readable text needs real pixels. At startup the app probes your terminal (run
95
+ `npm run doctor` to see the result):
96
+
97
+ - **sixel or kitty graphics supported** → opening a chapter launches a
98
+ full-resolution **pixel viewer** (chafa straight to the terminal). It renders
99
+ full-width with vertical pan by default; press `f` to toggle whole-page fit.
100
+ Keys: `←`/`→` or `a`/`d` page · `↑`/`↓` pan · `N`/`P` chapter · `f` fit · `q` back.
101
+ - **neither** → the in-Ink **cell reader** is used (Unicode half-blocks, or
102
+ chafa symbols when available). Fine for art, coarse for small lettering —
103
+ that's the hard ceiling of character-cell rendering.
104
+
105
+ Terminals with pixel support include kitty, WezTerm, Ghostty, foot, recent
106
+ Windows Terminal (≥ 1.22), and VS Code's terminal (with image support enabled).
107
+
108
+ ## MangaDex account (optional)
109
+
110
+ Reading from MangaDex needs **no account**. Signing in only adds personalization:
111
+
112
+ - **My Library** — browse the manga you follow on MangaDex.
113
+ - **Read-markers** — chapters you've already read are marked in the chapter list.
114
+ - **Progress sync** — finishing a chapter pushes a read-marker back to MangaDex.
115
+
116
+ MangaDex uses OAuth2 **personal clients**, so logging in is a one-time setup:
117
+
118
+ 1. On [mangadex.org](https://mangadex.org) → **Settings → API Clients**, create a
119
+ personal client and note its **client ID** and **client secret**. (A new client may
120
+ need staff approval before it works.)
121
+ 2. In the app: **Settings → Log in to MangaDex…**, then enter the client id/secret plus
122
+ your MangaDex username and password.
123
+
124
+ The session is **durable** — it requests an `offline_access` token and persists it, so
125
+ you stay logged in across restarts until you explicitly **log out** (Settings). Only the
126
+ client id/secret + refresh token are stored, in `~/.komado/credentials.json` (mode
127
+ `600`); your password is never written to disk. Toggle write-back any time with
128
+ **Settings → Sync reading progress**.
129
+
130
+ ## Local library layout
131
+
132
+ Add library folders in **Settings → Add library path…**. Within a library folder:
133
+
134
+ ```
135
+ My Library/
136
+ Berserk/ # folder = a manga
137
+ Chapter 1/ # image subfolder = a chapter
138
+ 001.jpg 002.jpg …
139
+ Chapter 2.cbz # …or a .cbz/.zip = a chapter
140
+ One Shot.cbz # a standalone .cbz = a single-chapter manga
141
+ ```
142
+
143
+ Pages are ordered naturally (`2` before `10`). Both `.cbz`/`.zip` and `.cbr`/`.rar`
144
+ archives are supported (RAR via the WASM `node-unrar-js`, no system binary needed).
145
+
146
+ ## Where state lives
147
+
148
+ Everything is self-contained under `~/.komado/` (override with `KOMADO_HOME`):
149
+
150
+ - `config.json` — preferences + library paths
151
+ - `progress.json` — reading progress
152
+ - `credentials.json` — MangaDex login (client id/secret + refresh token; mode `600`)
153
+ - `cache/` — scratch space for the renderer
154
+
155
+ ## Architecture
156
+
157
+ The layering mirrors a route→controller→service→db backend, adapted for a TUI:
158
+
159
+ ```
160
+ cli.js → app.js (screen stack) → screens → hooks/state → sources/* → HTTP | filesystem
161
+ └──────────────→ render/* (image → terminal)
162
+ ```
163
+
164
+ - **`src/sources/*`** — each source (`mangadex`, `local`) implements the same interface
165
+ (`search`, `getManga`, `listChapters`, `getPages`, `loadPageBuffer`) and returns the
166
+ unified `{ data, pagination, meta }` envelope. The only source-specific seam is
167
+ `loadPageBuffer`, which resolves raw bytes.
168
+ - **`src/domain/shape.js`** — the unified `Manga`/`Chapter` contract.
169
+ - **`src/render/*`** — capability detection + half-block (`sharp`) and `chafa` backends
170
+ behind one `renderInline()` dispatcher, with half-block as the always-works fallback.
171
+ - **`src/lib/*`** — cross-cutting utilities ported from a server toolkit:
172
+ `fetchWithBackoff` (retries 429/5xx + timeout), `createCache` (TTL + negative caching
173
+ + stampede protection), `AppError`/typed errors, the response `envelope`.
174
+ - **`src/components/*` + `src/hooks/*`** — Ink UI. Effects use a cancelled-flag/abort
175
+ guard against out-of-order responses, and reading progress writes are debounced.
176
+
177
+ ## Development
178
+
179
+ UI is plain JSX in `.js` files, transpiled to `dist/` by esbuild (a small
180
+ `scripts/build.mjs` does a transpile-only, structure-preserving build — no bundling,
181
+ so package imports stay external).
182
+
183
+ ```bash
184
+ npm run build # src/ → dist/ (esbuild, JSX automatic runtime)
185
+ npm run lint # ESLint 9 (flat config, eslint-plugin-react + react-hooks)
186
+ npm test # Vitest: lib/source unit tests + an ink-testing-library UI test
187
+ ```
188
+
189
+ `npm test` covers the cache, backoff, envelope, natural sort, MangaDex normalization,
190
+ MangaDex auth (token refresh, offline-scope fallback, session handling) and the authed
191
+ source methods, the local source (folder + `.cbz` + `.cbr`), and an end-to-end UI walk
192
+ (home → local manga → reader) via ink-testing-library. The `.cbr` test is skipped
193
+ automatically where the `rar` binary isn't available.
194
+
195
+ ## Roadmap
196
+
197
+ - True sixel/kitty fullscreen page view (beyond the standalone `render` command)
198
+ - On-disk page cache for offline re-reads of MangaDex chapters
199
+
200
+ ## Legal
201
+
202
+ Uses the public MangaDex API for personal reading. Respect MangaDex’s
203
+ [terms](https://api.mangadex.org/docs/) and rate limits, and support official releases
204
+ where available.
205
+
206
+ ## License
207
+
208
+ MIT
package/dist/app.js ADDED
@@ -0,0 +1,59 @@
1
+ import { jsx } from "react/jsx-runtime";
2
+ import { useState, useCallback } from "react";
3
+ import { Box, useApp, useInput } from "ink";
4
+ import { UIContext } from "./ui-context.js";
5
+ import { useStdoutDimensions } from "./hooks/useStdoutDimensions.js";
6
+ import { flushProgress } from "./state/store.js";
7
+ import { HomeScreen } from "./components/screens/HomeScreen.js";
8
+ import { SearchScreen } from "./components/screens/SearchScreen.js";
9
+ import { MangaScreen } from "./components/screens/MangaScreen.js";
10
+ import { ReaderScreen } from "./components/screens/ReaderScreen.js";
11
+ import { SettingsScreen } from "./components/screens/SettingsScreen.js";
12
+ import { ContinueScreen } from "./components/screens/ContinueScreen.js";
13
+ import { LoginScreen } from "./components/screens/LoginScreen.js";
14
+ import { LibraryScreen } from "./components/screens/LibraryScreen.js";
15
+ const SCREENS = {
16
+ home: HomeScreen,
17
+ search: SearchScreen,
18
+ manga: MangaScreen,
19
+ reader: ReaderScreen,
20
+ settings: SettingsScreen,
21
+ continue: ContinueScreen,
22
+ login: LoginScreen,
23
+ library: LibraryScreen
24
+ };
25
+ function App({ caps = {}, onViewer, initialRoute = null }) {
26
+ const { exit } = useApp();
27
+ const dimensions = useStdoutDimensions();
28
+ const [stack, setStack] = useState(
29
+ initialRoute ? [{ name: "home", params: {} }, initialRoute] : [{ name: "home", params: {} }]
30
+ );
31
+ const [typing, setTyping] = useState(false);
32
+ const navigate = useCallback((name, params = {}) => setStack((s) => [...s, { name, params }]), []);
33
+ const goBack = useCallback(() => setStack((s) => s.length > 1 ? s.slice(0, -1) : s), []);
34
+ const replace = useCallback((name, params = {}) => setStack((s) => [...s.slice(0, -1), { name, params }]), []);
35
+ const quit = useCallback(() => {
36
+ flushProgress();
37
+ exit();
38
+ }, [exit]);
39
+ useInput((input, key) => {
40
+ if (typing) return;
41
+ if (input === "q") quit();
42
+ else if (key.escape) goBack();
43
+ });
44
+ const current = stack[stack.length - 1];
45
+ const Screen = SCREENS[current.name] || HomeScreen;
46
+ const openReader = useCallback(
47
+ (payload) => {
48
+ const pixel = onViewer && (caps.sixel || caps.kitty) && caps.chafa && !process.env.KOMADO_NO_PIXEL;
49
+ if (pixel) onViewer(payload);
50
+ else navigate("reader", payload);
51
+ },
52
+ [onViewer, caps, navigate]
53
+ );
54
+ const ctx = { navigate, goBack, replace, exit: quit, setTyping, dimensions, caps, openReader };
55
+ return /* @__PURE__ */ jsx(UIContext.Provider, { value: ctx, children: /* @__PURE__ */ jsx(Box, { flexDirection: "column", paddingX: 1, children: /* @__PURE__ */ jsx(Screen, { params: current.params }, `${stack.length}:${current.name}`) }) });
56
+ }
57
+ export {
58
+ App
59
+ };
package/dist/cli.js ADDED
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env node
2
+ import { jsx } from "react/jsx-runtime";
3
+ import process from "node:process";
4
+ function printHelp() {
5
+ console.log(`komado \u2014 a terminal manga reader (MangaDex + local files)
6
+
7
+ Usage:
8
+ komado launch the interactive reader
9
+ komado doctor print terminal/image capabilities and config
10
+ komado render <img> render one image (path or URL) at best fidelity
11
+ komado --version print version
12
+ komado --help show this help
13
+
14
+ On a sixel/kitty terminal, opening a chapter launches a full-resolution pixel
15
+ viewer: \u2190/\u2192 or a/d page \xB7 \u2191/\u2193 pan \xB7 N/P chapter \xB7 f fit-width/whole-page \xB7 q back
16
+
17
+ Otherwise the in-terminal cell reader is used:
18
+ \u2191/\u2193 or j/k scroll \u2190/\u2192 or h/l prev/next page
19
+ space page down N / P next/prev chapter
20
+ f fit-to-screen r cycle renderer
21
+ g / G top / bottom esc back q quit
22
+ `);
23
+ }
24
+ async function doctor() {
25
+ const { detectCapabilities, probeTerminal } = await import("./render/detect.js");
26
+ const { paths, ensureDirs } = await import("./config.js");
27
+ const { getConfig } = await import("./state/store.js");
28
+ ensureDirs();
29
+ const caps = detectCapabilities();
30
+ const cfg = getConfig();
31
+ const probe = await probeTerminal();
32
+ console.log("komado doctor\n");
33
+ console.log("Terminal:");
34
+ console.log(` TERM=${caps.term} TERM_PROGRAM=${caps.termProgram || "(none)"}`);
35
+ console.log(` truecolor: ${caps.truecolor}`);
36
+ if (probe.queried) {
37
+ console.log(` kitty graphics: ${probe.kitty} (probed)`);
38
+ console.log(` sixel: ${probe.sixel} (probed)`);
39
+ if (probe.cellW || probe.cellH) {
40
+ console.log(` cell size: ${probe.cellW ?? "?"}x${probe.cellH ?? "?"} px`);
41
+ }
42
+ } else {
43
+ console.log(` kitty graphics: ${caps.kitty} (env guess \u2014 run in a real terminal to probe)`);
44
+ console.log(` sixel: ${caps.sixel} (env guess \u2014 run in a real terminal to probe)`);
45
+ }
46
+ console.log(` chafa: ${caps.chafa ? caps.chafaVersion : "not installed"}`);
47
+ console.log(` inline backend: ${caps.chafa ? "chafa-symbols" : "half-block"} (config.renderer=${cfg.renderer})`);
48
+ if (probe.queried) {
49
+ const protos = [probe.kitty && "kitty", probe.sixel && "sixel"].filter(Boolean);
50
+ console.log(
51
+ protos.length ? `
52
+ \u2192 Pixel graphics available (${protos.join(", ")}). Crisp rendering is possible:
53
+ test it with node dist/cli.js render <some-image>` : "\n \u2192 No pixel protocol detected \u2014 rendering is limited to character cells."
54
+ );
55
+ }
56
+ console.log("\nPaths:");
57
+ console.log(` home: ${paths.home}`);
58
+ console.log(` config: ${paths.configFile}`);
59
+ console.log(` progress: ${paths.progressFile}`);
60
+ console.log(` cache: ${paths.cacheDir}`);
61
+ console.log("\nConfig:");
62
+ console.log(` language: ${cfg.language}`);
63
+ console.log(` dataSaver: ${cfg.dataSaver}`);
64
+ console.log(` renderer: ${cfg.renderer}`);
65
+ console.log(` contentRating: ${cfg.contentRating.join(", ")}`);
66
+ console.log(` localLibraryPaths: ${cfg.localLibraryPaths.length ? cfg.localLibraryPaths.join(", ") : "(none \u2014 add in Settings)"}`);
67
+ }
68
+ async function renderCmd(target, rest) {
69
+ if (!target) {
70
+ console.error("usage: komado render <image-path-or-url> [width]");
71
+ process.exit(1);
72
+ }
73
+ const width = Number(rest[0]) || process.stdout.columns || 80;
74
+ let buf;
75
+ if (/^https?:/.test(target)) {
76
+ const { fetchWithBackoff } = await import("./lib/fetchWithBackoff.js");
77
+ const res = await fetchWithBackoff(target);
78
+ buf = Buffer.from(await res.arrayBuffer());
79
+ } else {
80
+ buf = await (await import("node:fs/promises")).readFile(target);
81
+ }
82
+ const { detectCapabilities } = await import("./render/detect.js");
83
+ const caps = detectCapabilities();
84
+ if (caps.chafa && process.stdout.isTTY) {
85
+ const os = await import("node:os");
86
+ const path = await import("node:path");
87
+ const { writeFile, unlink } = await import("node:fs/promises");
88
+ const sharp = (await import("sharp")).default;
89
+ const { imageSize } = await import("./render/image.js");
90
+ const { spawnChafaToTerminal } = await import("./render/chafa.js");
91
+ const { width: iw, height: ih } = await imageSize(buf);
92
+ const rows = Math.max(1, Math.round(ih / iw * width / 2));
93
+ const tmp = path.join(os.tmpdir(), `komado-render-${Date.now()}.png`);
94
+ await writeFile(tmp, await sharp(buf).png().toBuffer());
95
+ spawnChafaToTerminal(tmp, { cols: width, rows });
96
+ await unlink(tmp).catch(() => {
97
+ });
98
+ } else {
99
+ const { renderHalfBlock } = await import("./render/halfblock.js");
100
+ const out = await renderHalfBlock(buf, { cols: width });
101
+ process.stdout.write(out.lines.join("\n") + "\n");
102
+ }
103
+ }
104
+ async function runApp() {
105
+ const { ensureDirs, paths } = await import("./config.js");
106
+ const { appendFileSync } = await import("node:fs");
107
+ ensureDirs();
108
+ if (!process.stdout.isTTY) {
109
+ console.error("komado needs an interactive terminal (TTY). Run it directly in your terminal.");
110
+ process.exit(1);
111
+ }
112
+ const logCrash = (label, err) => {
113
+ try {
114
+ appendFileSync(paths.logFile, `[${(/* @__PURE__ */ new Date()).toISOString()}] ${label}: ${err?.stack || err}
115
+ `);
116
+ } catch {
117
+ }
118
+ };
119
+ const { detectCapabilities, probeTerminal } = await import("./render/detect.js");
120
+ const probed = process.env.KOMADO_FORCE_PIXEL ? { queried: true, sixel: true, kitty: false } : await probeTerminal();
121
+ const caps = { ...detectCapabilities(), ...probed };
122
+ const { render } = await import("ink");
123
+ const { App } = await import("./app.js");
124
+ const { runViewer } = await import("./sixel-reader.js");
125
+ const restore = () => process.stdout.write("\x1B[?25h\x1B[?1049l");
126
+ process.stdout.write("\x1B[?1049h\x1B[?25l");
127
+ process.on("exit", restore);
128
+ try {
129
+ let resumeRoute = null;
130
+ for (; ; ) {
131
+ let viewerRequest = null;
132
+ let instance;
133
+ const onViewer = (payload) => {
134
+ viewerRequest = payload;
135
+ setImmediate(() => {
136
+ try {
137
+ instance.unmount();
138
+ } catch (err) {
139
+ logCrash("unmount failed", err);
140
+ }
141
+ });
142
+ };
143
+ instance = render(/* @__PURE__ */ jsx(App, { caps, onViewer, initialRoute: resumeRoute }), {
144
+ exitOnCtrlC: true
145
+ });
146
+ try {
147
+ await instance.waitUntilExit();
148
+ } catch (err) {
149
+ logCrash("ink exited with error", err);
150
+ }
151
+ if (!viewerRequest) break;
152
+ process.stdin.resume();
153
+ try {
154
+ resumeRoute = await runViewer({ ...viewerRequest, caps });
155
+ } catch (err) {
156
+ logCrash("viewer crashed", err);
157
+ resumeRoute = { name: "manga", params: { sourceId: viewerRequest.sourceId, manga: viewerRequest.manga } };
158
+ }
159
+ }
160
+ } finally {
161
+ restore();
162
+ process.removeListener("exit", restore);
163
+ }
164
+ }
165
+ async function main() {
166
+ const [cmd, ...rest] = process.argv.slice(2);
167
+ switch (cmd) {
168
+ case "--help":
169
+ case "-h":
170
+ return printHelp();
171
+ case "--version":
172
+ case "-v": {
173
+ const { readFileSync } = await import("node:fs");
174
+ const url = new URL("../package.json", import.meta.url);
175
+ return console.log(JSON.parse(readFileSync(url, "utf8")).version);
176
+ }
177
+ case "doctor":
178
+ return doctor();
179
+ case "render":
180
+ return renderCmd(rest[0], rest.slice(1));
181
+ default:
182
+ return runApp();
183
+ }
184
+ }
185
+ main().catch(async (err) => {
186
+ process.stdout.write("\x1B[?25h\x1B[?1049l");
187
+ try {
188
+ const { appendFileSync } = await import("node:fs");
189
+ const { paths } = await import("./config.js");
190
+ appendFileSync(paths.logFile, `[${(/* @__PURE__ */ new Date()).toISOString()}] FATAL: ${err?.stack || err}
191
+ `);
192
+ } catch {
193
+ }
194
+ console.error(err);
195
+ process.exit(1);
196
+ });
@@ -0,0 +1,41 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ function List({
5
+ items = [],
6
+ onSelect,
7
+ onHighlight,
8
+ renderItem,
9
+ height = 12,
10
+ isActive = true,
11
+ emptyText = "Nothing here yet."
12
+ }) {
13
+ const [index, setIndex] = useState(0);
14
+ const count = items.length;
15
+ const selected = count ? Math.min(index, count - 1) : 0;
16
+ useEffect(() => {
17
+ if (count) onHighlight?.(items[selected], selected);
18
+ }, [selected, count]);
19
+ useInput((input, key) => {
20
+ if (!count) return;
21
+ if (key.downArrow || input === "j") setIndex(Math.min(count - 1, selected + 1));
22
+ else if (key.upArrow || input === "k") setIndex(Math.max(0, selected - 1));
23
+ else if (key.pageDown) setIndex(Math.min(count - 1, selected + height));
24
+ else if (key.pageUp) setIndex(Math.max(0, selected - height));
25
+ else if (input === "g") setIndex(0);
26
+ else if (input === "G") setIndex(count - 1);
27
+ else if (key.return) onSelect?.(items[selected], selected);
28
+ }, { isActive });
29
+ if (!count) {
30
+ return /* @__PURE__ */ jsx(Text, { dimColor: true, children: emptyText });
31
+ }
32
+ const start = Math.max(0, Math.min(selected - Math.floor(height / 2), Math.max(0, count - height)));
33
+ const slice = items.slice(start, start + height);
34
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
35
+ slice.map((item, i) => renderItem(item, start + i === selected, start + i)),
36
+ count > height ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: ` \xB7 ${selected + 1}/${count} \xB7` }, "more") : null
37
+ ] });
38
+ }
39
+ export {
40
+ List
41
+ };
@@ -0,0 +1,66 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Box, Text } from "ink";
4
+ import { useUI } from "../../ui-context.js";
5
+ import { getSource } from "../../sources/index.js";
6
+ import { getAllProgress } from "../../state/store.js";
7
+ import { makeManga } from "../../domain/shape.js";
8
+ import { List } from "../List.js";
9
+ import { Header, Spinner, ErrorView, KeyHints } from "../ui.js";
10
+ import { truncate, relativeTime } from "../../lib/text.js";
11
+ function ContinueScreen() {
12
+ const ui = useUI();
13
+ const entries = getAllProgress();
14
+ const [loading, setLoading] = useState(false);
15
+ const [error, setError] = useState(null);
16
+ const open = async (entry) => {
17
+ const source = getSource(entry.source);
18
+ setLoading(true);
19
+ setError(null);
20
+ try {
21
+ const [manga, chRes] = await Promise.all([
22
+ source.getManga(entry.mangaId).catch(() => makeManga({ source: entry.source, id: entry.mangaId, title: entry.mangaTitle })),
23
+ source.listChapters(entry.mangaId, { limit: 500 })
24
+ ]);
25
+ const idx = chRes.data.findIndex((c) => c.id === entry.chapterId);
26
+ ui.openReader({
27
+ sourceId: entry.source,
28
+ manga,
29
+ chapters: chRes.data,
30
+ chapterIndex: Math.max(0, idx),
31
+ startPage: entry.page || 0
32
+ });
33
+ } catch (err) {
34
+ setError(err);
35
+ } finally {
36
+ setLoading(false);
37
+ }
38
+ };
39
+ if (loading) {
40
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
41
+ /* @__PURE__ */ jsx(Header, { title: "Continue reading" }),
42
+ /* @__PURE__ */ jsx(Spinner, { label: "Opening" })
43
+ ] });
44
+ }
45
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
46
+ /* @__PURE__ */ jsx(Header, { title: "Continue reading", subtitle: "pick up where you left off" }),
47
+ error ? /* @__PURE__ */ jsx(ErrorView, { error }) : null,
48
+ /* @__PURE__ */ jsx(
49
+ List,
50
+ {
51
+ items: entries,
52
+ height: Math.max(5, (ui.dimensions.rows || 24) - 7),
53
+ onSelect: open,
54
+ emptyText: "No reading history yet.",
55
+ renderItem: (e, active) => /* @__PURE__ */ jsxs(Box, { justifyContent: "space-between", children: [
56
+ /* @__PURE__ */ jsx(Text, { inverse: active, color: active ? "cyanBright" : void 0, children: ` ${truncate(e.mangaTitle || e.mangaId, 40)} \xB7 ${e.chapterNumber != null ? `Ch.${e.chapterNumber}` : "Oneshot"} p.${(e.page || 0) + 1} ` }),
57
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: relativeTime(e.updatedAt) })
58
+ ] }, `${e.source}:${e.mangaId}`)
59
+ }
60
+ ),
61
+ /* @__PURE__ */ jsx(KeyHints, { hints: [["\u2191\u2193", "move"], ["enter", "resume"], ["esc", "back"]] })
62
+ ] });
63
+ }
64
+ export {
65
+ ContinueScreen
66
+ };
@@ -0,0 +1,59 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { useUI } from "../../ui-context.js";
4
+ import { List } from "../List.js";
5
+ import { Header, KeyHints } from "../ui.js";
6
+ import { getAllProgress } from "../../state/store.js";
7
+ import { isLoggedIn } from "../../sources/mangadex/auth.js";
8
+ function HomeScreen() {
9
+ const ui = useUI();
10
+ const hasProgress = getAllProgress().length > 0;
11
+ const items = [
12
+ { id: "search", label: "Search MangaDex", hint: "online catalog" },
13
+ { id: "browse", label: "Popular on MangaDex", hint: "most followed" },
14
+ ...isLoggedIn() ? [{ id: "library", label: "My Library (MangaDex)", hint: "your follows" }] : [],
15
+ { id: "local", label: "Local library", hint: "your CBZ / folders" },
16
+ ...hasProgress ? [{ id: "continue", label: "Continue reading", hint: "resume" }] : [],
17
+ { id: "settings", label: "Settings", hint: "config & library paths" },
18
+ { id: "quit", label: "Quit", hint: "" }
19
+ ];
20
+ const onSelect = (item) => {
21
+ switch (item.id) {
22
+ case "search":
23
+ return ui.navigate("search", { sourceId: "mangadex", mode: "search" });
24
+ case "browse":
25
+ return ui.navigate("search", { sourceId: "mangadex", mode: "browse" });
26
+ case "library":
27
+ return ui.navigate("library", { sourceId: "mangadex" });
28
+ case "local":
29
+ return ui.navigate("search", { sourceId: "local", mode: "browse" });
30
+ case "continue":
31
+ return ui.navigate("continue");
32
+ case "settings":
33
+ return ui.navigate("settings");
34
+ case "quit":
35
+ return ui.exit();
36
+ default:
37
+ return void 0;
38
+ }
39
+ };
40
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
41
+ /* @__PURE__ */ jsx(Header, { title: "komado", subtitle: "a terminal manga reader \xB7 MangaDex + local files" }),
42
+ /* @__PURE__ */ jsx(
43
+ List,
44
+ {
45
+ items,
46
+ height: items.length,
47
+ onSelect,
48
+ renderItem: (item, active) => /* @__PURE__ */ jsxs(Box, { children: [
49
+ /* @__PURE__ */ jsx(Text, { inverse: active, color: active ? "cyanBright" : void 0, children: ` ${active ? "\u203A" : " "} ${item.label} ` }),
50
+ item.hint ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: ` ${item.hint}` }) : null
51
+ ] }, item.id)
52
+ }
53
+ ),
54
+ /* @__PURE__ */ jsx(KeyHints, { hints: [["\u2191\u2193", "move"], ["enter", "select"], ["q", "quit"]] })
55
+ ] });
56
+ }
57
+ export {
58
+ HomeScreen
59
+ };