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
package/src/app.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react';
|
|
2
|
+
import { Box, useApp, useInput } from 'ink';
|
|
3
|
+
import { UIContext } from './ui-context.js';
|
|
4
|
+
import { useStdoutDimensions } from './hooks/useStdoutDimensions.js';
|
|
5
|
+
import { flushProgress } from './state/store.js';
|
|
6
|
+
import { HomeScreen } from './components/screens/HomeScreen.js';
|
|
7
|
+
import { SearchScreen } from './components/screens/SearchScreen.js';
|
|
8
|
+
import { MangaScreen } from './components/screens/MangaScreen.js';
|
|
9
|
+
import { ReaderScreen } from './components/screens/ReaderScreen.js';
|
|
10
|
+
import { SettingsScreen } from './components/screens/SettingsScreen.js';
|
|
11
|
+
import { ContinueScreen } from './components/screens/ContinueScreen.js';
|
|
12
|
+
import { LoginScreen } from './components/screens/LoginScreen.js';
|
|
13
|
+
import { LibraryScreen } from './components/screens/LibraryScreen.js';
|
|
14
|
+
|
|
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
|
+
|
|
26
|
+
export function App({ caps = {}, onViewer, initialRoute = null }) {
|
|
27
|
+
const { exit } = useApp();
|
|
28
|
+
const dimensions = useStdoutDimensions();
|
|
29
|
+
const [stack, setStack] = useState(
|
|
30
|
+
initialRoute ? [{ name: 'home', params: {} }, initialRoute] : [{ name: 'home', params: {} }],
|
|
31
|
+
);
|
|
32
|
+
const [typing, setTyping] = useState(false);
|
|
33
|
+
|
|
34
|
+
const navigate = useCallback((name, params = {}) => setStack((s) => [...s, { name, params }]), []);
|
|
35
|
+
const goBack = useCallback(() => setStack((s) => (s.length > 1 ? s.slice(0, -1) : s)), []);
|
|
36
|
+
const replace = useCallback((name, params = {}) => setStack((s) => [...s.slice(0, -1), { name, params }]), []);
|
|
37
|
+
const quit = useCallback(() => {
|
|
38
|
+
flushProgress();
|
|
39
|
+
exit();
|
|
40
|
+
}, [exit]);
|
|
41
|
+
|
|
42
|
+
// Global keys — suppressed while a text input is focused (`typing`).
|
|
43
|
+
useInput((input, key) => {
|
|
44
|
+
if (typing) return;
|
|
45
|
+
if (input === 'q') quit();
|
|
46
|
+
else if (key.escape) goBack();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const current = stack[stack.length - 1];
|
|
50
|
+
const Screen = SCREENS[current.name] || HomeScreen;
|
|
51
|
+
// openReader picks the high-res sixel/kitty viewer when the terminal supports
|
|
52
|
+
// it (handled outside Ink), else the in-Ink cell reader.
|
|
53
|
+
const openReader = useCallback(
|
|
54
|
+
(payload) => {
|
|
55
|
+
const pixel = onViewer && (caps.sixel || caps.kitty) && caps.chafa
|
|
56
|
+
&& !process.env.KOMADO_NO_PIXEL; // escape hatch → force cell reader
|
|
57
|
+
if (pixel) onViewer(payload);
|
|
58
|
+
else navigate('reader', payload);
|
|
59
|
+
},
|
|
60
|
+
[onViewer, caps, navigate],
|
|
61
|
+
);
|
|
62
|
+
const ctx = { navigate, goBack, replace, exit: quit, setTyping, dimensions, caps, openReader };
|
|
63
|
+
|
|
64
|
+
// Remount on each push/pop so screens start with fresh state. The key includes
|
|
65
|
+
// the depth so navigating back rebuilds the previous screen.
|
|
66
|
+
return (
|
|
67
|
+
<UIContext.Provider value={ctx}>
|
|
68
|
+
<Box flexDirection="column" paddingX={1}>
|
|
69
|
+
<Screen key={`${stack.length}:${current.name}`} params={current.params} />
|
|
70
|
+
</Box>
|
|
71
|
+
</UIContext.Provider>
|
|
72
|
+
);
|
|
73
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
|
|
4
|
+
function printHelp() {
|
|
5
|
+
console.log(`komado — 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: ←/→ or a/d page · ↑/↓ pan · N/P chapter · f fit-width/whole-page · q back
|
|
16
|
+
|
|
17
|
+
Otherwise the in-terminal cell reader is used:
|
|
18
|
+
↑/↓ or j/k scroll ←/→ 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
|
+
|
|
25
|
+
async function doctor() {
|
|
26
|
+
const { detectCapabilities, probeTerminal } = await import('./render/detect.js');
|
|
27
|
+
const { paths, ensureDirs } = await import('./config.js');
|
|
28
|
+
const { getConfig } = await import('./state/store.js');
|
|
29
|
+
ensureDirs();
|
|
30
|
+
const caps = detectCapabilities();
|
|
31
|
+
const cfg = getConfig();
|
|
32
|
+
const probe = await probeTerminal();
|
|
33
|
+
|
|
34
|
+
console.log('komado doctor\n');
|
|
35
|
+
console.log('Terminal:');
|
|
36
|
+
console.log(` TERM=${caps.term} TERM_PROGRAM=${caps.termProgram || '(none)'}`);
|
|
37
|
+
console.log(` truecolor: ${caps.truecolor}`);
|
|
38
|
+
if (probe.queried) {
|
|
39
|
+
console.log(` kitty graphics: ${probe.kitty} (probed)`);
|
|
40
|
+
console.log(` sixel: ${probe.sixel} (probed)`);
|
|
41
|
+
if (probe.cellW || probe.cellH) {
|
|
42
|
+
console.log(` cell size: ${probe.cellW ?? '?'}x${probe.cellH ?? '?'} px`);
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
console.log(` kitty graphics: ${caps.kitty} (env guess — run in a real terminal to probe)`);
|
|
46
|
+
console.log(` sixel: ${caps.sixel} (env guess — run in a real terminal to probe)`);
|
|
47
|
+
}
|
|
48
|
+
console.log(` chafa: ${caps.chafa ? caps.chafaVersion : 'not installed'}`);
|
|
49
|
+
console.log(` inline backend: ${caps.chafa ? 'chafa-symbols' : 'half-block'} (config.renderer=${cfg.renderer})`);
|
|
50
|
+
if (probe.queried) {
|
|
51
|
+
const protos = [probe.kitty && 'kitty', probe.sixel && 'sixel'].filter(Boolean);
|
|
52
|
+
console.log(
|
|
53
|
+
protos.length
|
|
54
|
+
? `\n → Pixel graphics available (${protos.join(', ')}). Crisp rendering is possible:\n test it with node dist/cli.js render <some-image>`
|
|
55
|
+
: '\n → No pixel protocol detected — rendering is limited to character cells.',
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
console.log('\nPaths:');
|
|
59
|
+
console.log(` home: ${paths.home}`);
|
|
60
|
+
console.log(` config: ${paths.configFile}`);
|
|
61
|
+
console.log(` progress: ${paths.progressFile}`);
|
|
62
|
+
console.log(` cache: ${paths.cacheDir}`);
|
|
63
|
+
console.log('\nConfig:');
|
|
64
|
+
console.log(` language: ${cfg.language}`);
|
|
65
|
+
console.log(` dataSaver: ${cfg.dataSaver}`);
|
|
66
|
+
console.log(` renderer: ${cfg.renderer}`);
|
|
67
|
+
console.log(` contentRating: ${cfg.contentRating.join(', ')}`);
|
|
68
|
+
console.log(` localLibraryPaths: ${cfg.localLibraryPaths.length ? cfg.localLibraryPaths.join(', ') : '(none — add in Settings)'}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function renderCmd(target, rest) {
|
|
72
|
+
if (!target) {
|
|
73
|
+
console.error('usage: komado render <image-path-or-url> [width]');
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
const width = Number(rest[0]) || process.stdout.columns || 80;
|
|
77
|
+
|
|
78
|
+
let buf;
|
|
79
|
+
if (/^https?:/.test(target)) {
|
|
80
|
+
const { fetchWithBackoff } = await import('./lib/fetchWithBackoff.js');
|
|
81
|
+
const res = await fetchWithBackoff(target);
|
|
82
|
+
buf = Buffer.from(await res.arrayBuffer());
|
|
83
|
+
} else {
|
|
84
|
+
buf = await (await import('node:fs/promises')).readFile(target);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const { detectCapabilities } = await import('./render/detect.js');
|
|
88
|
+
const caps = detectCapabilities();
|
|
89
|
+
|
|
90
|
+
if (caps.chafa && process.stdout.isTTY) {
|
|
91
|
+
// Let chafa probe the real terminal and pick kitty > sixel > symbols.
|
|
92
|
+
const os = await import('node:os');
|
|
93
|
+
const path = await import('node:path');
|
|
94
|
+
const { writeFile, unlink } = await import('node:fs/promises');
|
|
95
|
+
const sharp = (await import('sharp')).default;
|
|
96
|
+
const { imageSize } = await import('./render/image.js');
|
|
97
|
+
const { spawnChafaToTerminal } = await import('./render/chafa.js');
|
|
98
|
+
|
|
99
|
+
const { width: iw, height: ih } = await imageSize(buf);
|
|
100
|
+
const rows = Math.max(1, Math.round(((ih / iw) * width) / 2));
|
|
101
|
+
const tmp = path.join(os.tmpdir(), `komado-render-${Date.now()}.png`);
|
|
102
|
+
await writeFile(tmp, await sharp(buf).png().toBuffer());
|
|
103
|
+
spawnChafaToTerminal(tmp, { cols: width, rows });
|
|
104
|
+
await unlink(tmp).catch(() => {});
|
|
105
|
+
} else {
|
|
106
|
+
const { renderHalfBlock } = await import('./render/halfblock.js');
|
|
107
|
+
const out = await renderHalfBlock(buf, { cols: width });
|
|
108
|
+
process.stdout.write(out.lines.join('\n') + '\n');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function runApp() {
|
|
113
|
+
const { ensureDirs, paths } = await import('./config.js');
|
|
114
|
+
const { appendFileSync } = await import('node:fs');
|
|
115
|
+
ensureDirs();
|
|
116
|
+
|
|
117
|
+
if (!process.stdout.isTTY) {
|
|
118
|
+
console.error('komado needs an interactive terminal (TTY). Run it directly in your terminal.');
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Always record crashes (not gated on KOMADO_DEBUG) so failures aren't lost
|
|
123
|
+
// when the alt-screen is torn down.
|
|
124
|
+
const logCrash = (label, err) => {
|
|
125
|
+
try {
|
|
126
|
+
appendFileSync(paths.logFile, `[${new Date().toISOString()}] ${label}: ${err?.stack || err}\n`);
|
|
127
|
+
} catch { /* ignore */ }
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Probe the terminal for pixel-protocol support before Ink grabs stdin.
|
|
131
|
+
// KOMADO_FORCE_PIXEL skips the probe (useful if detection is wrong, or for
|
|
132
|
+
// exercising the viewer in a dumb terminal).
|
|
133
|
+
const { detectCapabilities, probeTerminal } = await import('./render/detect.js');
|
|
134
|
+
const probed = process.env.KOMADO_FORCE_PIXEL
|
|
135
|
+
? { queried: true, sixel: true, kitty: false }
|
|
136
|
+
: await probeTerminal();
|
|
137
|
+
const caps = { ...detectCapabilities(), ...probed };
|
|
138
|
+
|
|
139
|
+
const { render } = await import('ink');
|
|
140
|
+
const { App } = await import('./app.js');
|
|
141
|
+
const { runViewer } = await import('./sixel-reader.js'); // pre-import (no mid-loop gap)
|
|
142
|
+
|
|
143
|
+
// Alternate screen + hidden cursor for a clean, scrollback-free experience.
|
|
144
|
+
const restore = () => process.stdout.write('\x1b[?25h\x1b[?1049l');
|
|
145
|
+
process.stdout.write('\x1b[?1049h\x1b[?25l');
|
|
146
|
+
process.on('exit', restore);
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
// Browse in Ink; when a chapter is opened on a pixel-capable terminal the
|
|
150
|
+
// app requests the sixel viewer: unmount Ink → run viewer → remount Ink.
|
|
151
|
+
let resumeRoute = null;
|
|
152
|
+
for (;;) {
|
|
153
|
+
let viewerRequest = null;
|
|
154
|
+
let instance;
|
|
155
|
+
const onViewer = (payload) => {
|
|
156
|
+
viewerRequest = payload;
|
|
157
|
+
// Defer so we don't unmount Ink in the middle of its input dispatch.
|
|
158
|
+
setImmediate(() => {
|
|
159
|
+
try { instance.unmount(); } catch (err) { logCrash('unmount failed', err); }
|
|
160
|
+
});
|
|
161
|
+
};
|
|
162
|
+
instance = render(<App caps={caps} onViewer={onViewer} initialRoute={resumeRoute} />, {
|
|
163
|
+
exitOnCtrlC: true,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
await instance.waitUntilExit();
|
|
168
|
+
} catch (err) {
|
|
169
|
+
logCrash('ink exited with error', err); // don't let an Ink reject kill us
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!viewerRequest) break; // normal quit
|
|
173
|
+
|
|
174
|
+
process.stdin.resume(); // keep the event loop alive across the handoff
|
|
175
|
+
try {
|
|
176
|
+
resumeRoute = await runViewer({ ...viewerRequest, caps });
|
|
177
|
+
} catch (err) {
|
|
178
|
+
logCrash('viewer crashed', err);
|
|
179
|
+
resumeRoute = { name: 'manga', params: { sourceId: viewerRequest.sourceId, manga: viewerRequest.manga } };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
} finally {
|
|
183
|
+
restore();
|
|
184
|
+
process.removeListener('exit', restore);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function main() {
|
|
189
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
190
|
+
switch (cmd) {
|
|
191
|
+
case '--help':
|
|
192
|
+
case '-h':
|
|
193
|
+
return printHelp();
|
|
194
|
+
case '--version':
|
|
195
|
+
case '-v': {
|
|
196
|
+
const { readFileSync } = await import('node:fs');
|
|
197
|
+
const url = new URL('../package.json', import.meta.url);
|
|
198
|
+
return console.log(JSON.parse(readFileSync(url, 'utf8')).version);
|
|
199
|
+
}
|
|
200
|
+
case 'doctor':
|
|
201
|
+
return doctor();
|
|
202
|
+
case 'render':
|
|
203
|
+
return renderCmd(rest[0], rest.slice(1));
|
|
204
|
+
default:
|
|
205
|
+
return runApp();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
main().catch(async (err) => {
|
|
210
|
+
process.stdout.write('\x1b[?25h\x1b[?1049l');
|
|
211
|
+
try {
|
|
212
|
+
const { appendFileSync } = await import('node:fs');
|
|
213
|
+
const { paths } = await import('./config.js');
|
|
214
|
+
appendFileSync(paths.logFile, `[${new Date().toISOString()}] FATAL: ${err?.stack || err}\n`);
|
|
215
|
+
} catch { /* ignore */ }
|
|
216
|
+
console.error(err);
|
|
217
|
+
process.exit(1);
|
|
218
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
|
|
4
|
+
// Reusable windowed, keyboard-driven list. The parent supplies `renderItem`
|
|
5
|
+
// (which must set a `key`) and gets `onSelect`/`onHighlight` callbacks.
|
|
6
|
+
// Only the active instance consumes input, so multiple lists can coexist.
|
|
7
|
+
export function List({
|
|
8
|
+
items = [],
|
|
9
|
+
onSelect,
|
|
10
|
+
onHighlight,
|
|
11
|
+
renderItem,
|
|
12
|
+
height = 12,
|
|
13
|
+
isActive = true,
|
|
14
|
+
emptyText = 'Nothing here yet.',
|
|
15
|
+
}) {
|
|
16
|
+
const [index, setIndex] = useState(0);
|
|
17
|
+
const count = items.length;
|
|
18
|
+
// Clamp on read instead of in an effect, so a shrinking list can't leave the
|
|
19
|
+
// selection out of range (and there's no cascading setState-in-effect).
|
|
20
|
+
const selected = count ? Math.min(index, count - 1) : 0;
|
|
21
|
+
|
|
22
|
+
// Notify the parent of the highlighted item when the selection (or list size)
|
|
23
|
+
// changes. Keyed on the index + count (primitives) — NOT the `items` array,
|
|
24
|
+
// whose reference changes every render in callers that rebuild it inline. If
|
|
25
|
+
// `items` were a dep, a parent whose onHighlight calls setState would loop:
|
|
26
|
+
// render → new items ref → effect → setState → render → … (max update depth).
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (count) onHighlight?.(items[selected], selected);
|
|
29
|
+
// items/onHighlight intentionally omitted; see above.
|
|
30
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
31
|
+
}, [selected, count]);
|
|
32
|
+
|
|
33
|
+
useInput((input, key) => {
|
|
34
|
+
if (!count) return;
|
|
35
|
+
if (key.downArrow || input === 'j') setIndex(Math.min(count - 1, selected + 1));
|
|
36
|
+
else if (key.upArrow || input === 'k') setIndex(Math.max(0, selected - 1));
|
|
37
|
+
else if (key.pageDown) setIndex(Math.min(count - 1, selected + height));
|
|
38
|
+
else if (key.pageUp) setIndex(Math.max(0, selected - height));
|
|
39
|
+
else if (input === 'g') setIndex(0);
|
|
40
|
+
else if (input === 'G') setIndex(count - 1);
|
|
41
|
+
else if (key.return) onSelect?.(items[selected], selected);
|
|
42
|
+
}, { isActive });
|
|
43
|
+
|
|
44
|
+
if (!count) {
|
|
45
|
+
return <Text dimColor>{emptyText}</Text>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Vertical window centred on the selection.
|
|
49
|
+
const start = Math.max(0, Math.min(selected - Math.floor(height / 2), Math.max(0, count - height)));
|
|
50
|
+
const slice = items.slice(start, start + height);
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<Box flexDirection="column">
|
|
54
|
+
{slice.map((item, i) => renderItem(item, start + i === selected, start + i))}
|
|
55
|
+
{count > height ? (
|
|
56
|
+
<Text key="more" dimColor>{` · ${selected + 1}/${count} ·`}</Text>
|
|
57
|
+
) : null}
|
|
58
|
+
</Box>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { useUI } from '../../ui-context.js';
|
|
4
|
+
import { getSource } from '../../sources/index.js';
|
|
5
|
+
import { getAllProgress } from '../../state/store.js';
|
|
6
|
+
import { makeManga } from '../../domain/shape.js';
|
|
7
|
+
import { List } from '../List.js';
|
|
8
|
+
import { Header, Spinner, ErrorView, KeyHints } from '../ui.js';
|
|
9
|
+
import { truncate, relativeTime } from '../../lib/text.js';
|
|
10
|
+
|
|
11
|
+
export function ContinueScreen() {
|
|
12
|
+
const ui = useUI();
|
|
13
|
+
const entries = getAllProgress();
|
|
14
|
+
const [loading, setLoading] = useState(false);
|
|
15
|
+
const [error, setError] = useState(null);
|
|
16
|
+
|
|
17
|
+
const open = async (entry) => {
|
|
18
|
+
const source = getSource(entry.source);
|
|
19
|
+
setLoading(true);
|
|
20
|
+
setError(null);
|
|
21
|
+
try {
|
|
22
|
+
const [manga, chRes] = await Promise.all([
|
|
23
|
+
source
|
|
24
|
+
.getManga(entry.mangaId)
|
|
25
|
+
.catch(() => makeManga({ source: entry.source, id: entry.mangaId, title: entry.mangaTitle })),
|
|
26
|
+
source.listChapters(entry.mangaId, { limit: 500 }),
|
|
27
|
+
]);
|
|
28
|
+
const idx = chRes.data.findIndex((c) => c.id === entry.chapterId);
|
|
29
|
+
ui.openReader({
|
|
30
|
+
sourceId: entry.source,
|
|
31
|
+
manga,
|
|
32
|
+
chapters: chRes.data,
|
|
33
|
+
chapterIndex: Math.max(0, idx),
|
|
34
|
+
startPage: entry.page || 0,
|
|
35
|
+
});
|
|
36
|
+
} catch (err) {
|
|
37
|
+
setError(err);
|
|
38
|
+
} finally {
|
|
39
|
+
setLoading(false);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
if (loading) {
|
|
44
|
+
return (
|
|
45
|
+
<Box flexDirection="column">
|
|
46
|
+
<Header title="Continue reading" />
|
|
47
|
+
<Spinner label="Opening" />
|
|
48
|
+
</Box>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<Box flexDirection="column">
|
|
54
|
+
<Header title="Continue reading" subtitle="pick up where you left off" />
|
|
55
|
+
{error ? <ErrorView error={error} /> : null}
|
|
56
|
+
<List
|
|
57
|
+
items={entries}
|
|
58
|
+
height={Math.max(5, (ui.dimensions.rows || 24) - 7)}
|
|
59
|
+
onSelect={open}
|
|
60
|
+
emptyText="No reading history yet."
|
|
61
|
+
renderItem={(e, active) => (
|
|
62
|
+
<Box key={`${e.source}:${e.mangaId}`} justifyContent="space-between">
|
|
63
|
+
<Text inverse={active} color={active ? 'cyanBright' : undefined}>
|
|
64
|
+
{` ${truncate(e.mangaTitle || e.mangaId, 40)} · ${e.chapterNumber != null ? `Ch.${e.chapterNumber}` : 'Oneshot'} p.${(e.page || 0) + 1} `}
|
|
65
|
+
</Text>
|
|
66
|
+
<Text dimColor>{relativeTime(e.updatedAt)}</Text>
|
|
67
|
+
</Box>
|
|
68
|
+
)}
|
|
69
|
+
/>
|
|
70
|
+
<KeyHints hints={[['↑↓', 'move'], ['enter', 'resume'], ['esc', 'back']]} />
|
|
71
|
+
</Box>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Box, Text } from 'ink';
|
|
2
|
+
import { useUI } from '../../ui-context.js';
|
|
3
|
+
import { List } from '../List.js';
|
|
4
|
+
import { Header, KeyHints } from '../ui.js';
|
|
5
|
+
import { getAllProgress } from '../../state/store.js';
|
|
6
|
+
import { isLoggedIn } from '../../sources/mangadex/auth.js';
|
|
7
|
+
|
|
8
|
+
export function HomeScreen() {
|
|
9
|
+
const ui = useUI();
|
|
10
|
+
const hasProgress = getAllProgress().length > 0;
|
|
11
|
+
|
|
12
|
+
const items = [
|
|
13
|
+
{ id: 'search', label: 'Search MangaDex', hint: 'online catalog' },
|
|
14
|
+
{ id: 'browse', label: 'Popular on MangaDex', hint: 'most followed' },
|
|
15
|
+
...(isLoggedIn() ? [{ id: 'library', label: 'My Library (MangaDex)', hint: 'your follows' }] : []),
|
|
16
|
+
{ id: 'local', label: 'Local library', hint: 'your CBZ / folders' },
|
|
17
|
+
...(hasProgress ? [{ id: 'continue', label: 'Continue reading', hint: 'resume' }] : []),
|
|
18
|
+
{ id: 'settings', label: 'Settings', hint: 'config & library paths' },
|
|
19
|
+
{ id: 'quit', label: 'Quit', hint: '' },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const onSelect = (item) => {
|
|
23
|
+
switch (item.id) {
|
|
24
|
+
case 'search': return ui.navigate('search', { sourceId: 'mangadex', mode: 'search' });
|
|
25
|
+
case 'browse': return ui.navigate('search', { sourceId: 'mangadex', mode: 'browse' });
|
|
26
|
+
case 'library': return ui.navigate('library', { sourceId: 'mangadex' });
|
|
27
|
+
case 'local': return ui.navigate('search', { sourceId: 'local', mode: 'browse' });
|
|
28
|
+
case 'continue': return ui.navigate('continue');
|
|
29
|
+
case 'settings': return ui.navigate('settings');
|
|
30
|
+
case 'quit': return ui.exit();
|
|
31
|
+
default: return undefined;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Box flexDirection="column">
|
|
37
|
+
<Header title="komado" subtitle="a terminal manga reader · MangaDex + local files" />
|
|
38
|
+
<List
|
|
39
|
+
items={items}
|
|
40
|
+
height={items.length}
|
|
41
|
+
onSelect={onSelect}
|
|
42
|
+
renderItem={(item, active) => (
|
|
43
|
+
<Box key={item.id}>
|
|
44
|
+
<Text inverse={active} color={active ? 'cyanBright' : undefined}>
|
|
45
|
+
{` ${active ? '›' : ' '} ${item.label} `}
|
|
46
|
+
</Text>
|
|
47
|
+
{item.hint ? <Text dimColor>{` ${item.hint}`}</Text> : null}
|
|
48
|
+
</Box>
|
|
49
|
+
)}
|
|
50
|
+
/>
|
|
51
|
+
<KeyHints hints={[['↑↓', 'move'], ['enter', 'select'], ['q', 'quit']]} />
|
|
52
|
+
</Box>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { useUI } from '../../ui-context.js';
|
|
4
|
+
import { getSource } from '../../sources/index.js';
|
|
5
|
+
import { List } from '../List.js';
|
|
6
|
+
import { Header, Spinner, ErrorView, KeyHints } from '../ui.js';
|
|
7
|
+
import { truncate } from '../../lib/text.js';
|
|
8
|
+
|
|
9
|
+
const PAGE = 32;
|
|
10
|
+
|
|
11
|
+
// The signed-in user's MangaDex follows. Same browse/paginate shape as the
|
|
12
|
+
// search screen, minus the query box — getFollows is just another envelope.
|
|
13
|
+
export function LibraryScreen({ params }) {
|
|
14
|
+
const sourceId = params?.sourceId || 'mangadex';
|
|
15
|
+
const ui = useUI();
|
|
16
|
+
const source = getSource(sourceId);
|
|
17
|
+
|
|
18
|
+
const [results, setResults] = useState([]);
|
|
19
|
+
const [pagination, setPagination] = useState(null);
|
|
20
|
+
const [loading, setLoading] = useState(true);
|
|
21
|
+
const [error, setError] = useState(null);
|
|
22
|
+
|
|
23
|
+
// Monotonic request id guards against out-of-order responses.
|
|
24
|
+
const reqId = useRef(0);
|
|
25
|
+
const fetchPage = async (offset, append) => {
|
|
26
|
+
const rid = ++reqId.current;
|
|
27
|
+
const ctrl = new AbortController();
|
|
28
|
+
setLoading(true);
|
|
29
|
+
setError(null);
|
|
30
|
+
try {
|
|
31
|
+
const res = await source.getFollows({ offset, limit: PAGE, signal: ctrl.signal });
|
|
32
|
+
if (rid !== reqId.current) return;
|
|
33
|
+
setResults((prev) => (append ? [...prev, ...res.data] : res.data));
|
|
34
|
+
setPagination(res.pagination);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (rid === reqId.current) setError(err);
|
|
37
|
+
} finally {
|
|
38
|
+
if (rid === reqId.current) setLoading(false);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
useEffect(() => { fetchPage(0, false); }, []);
|
|
43
|
+
|
|
44
|
+
const loadMore = () => {
|
|
45
|
+
if (loading || !pagination?.hasMore) return;
|
|
46
|
+
fetchPage(pagination.offset + pagination.limit, true);
|
|
47
|
+
};
|
|
48
|
+
const onHighlight = (_item, index) => {
|
|
49
|
+
if (index >= results.length - 2) loadMore(); // prefetch near the end
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const listHeight = Math.max(4, (ui.dimensions.rows || 24) - 8);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<Box flexDirection="column">
|
|
56
|
+
<Header title="My Library" subtitle="manga you follow on MangaDex" />
|
|
57
|
+
{loading && !results.length ? <Spinner label="Loading your follows" /> : null}
|
|
58
|
+
{error ? <ErrorView error={error} /> : null}
|
|
59
|
+
{!error ? (
|
|
60
|
+
<List
|
|
61
|
+
items={results}
|
|
62
|
+
height={listHeight}
|
|
63
|
+
onSelect={(m) => ui.navigate('manga', { sourceId, manga: m })}
|
|
64
|
+
onHighlight={onHighlight}
|
|
65
|
+
emptyText={loading ? ' ' : 'You are not following any manga yet.'}
|
|
66
|
+
renderItem={(m, active) => (
|
|
67
|
+
<Box key={m.key}>
|
|
68
|
+
<Text inverse={active} color={active ? 'cyanBright' : undefined}>
|
|
69
|
+
{` ${truncate(m.title, Math.max(20, (ui.dimensions.cols || 80) - 24))} `}
|
|
70
|
+
</Text>
|
|
71
|
+
<Text dimColor>{` ${m.status || ''}`}</Text>
|
|
72
|
+
</Box>
|
|
73
|
+
)}
|
|
74
|
+
/>
|
|
75
|
+
) : null}
|
|
76
|
+
<KeyHints hints={[['↑↓', 'move'], ['enter', 'open'], ['esc', 'back']]} />
|
|
77
|
+
</Box>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import TextInput from 'ink-text-input';
|
|
4
|
+
import { useUI } from '../../ui-context.js';
|
|
5
|
+
import { login } from '../../sources/mangadex/auth.js';
|
|
6
|
+
import { Header, Spinner, ErrorView, KeyHints } from '../ui.js';
|
|
7
|
+
|
|
8
|
+
// MangaDex needs an OAuth2 "personal client" (client id/secret registered at
|
|
9
|
+
// mangadex.org/settings) plus the account's username/password. Secret and
|
|
10
|
+
// password fields are masked.
|
|
11
|
+
const FIELDS = [
|
|
12
|
+
{ key: 'clientId', label: 'Client ID', mask: false },
|
|
13
|
+
{ key: 'clientSecret', label: 'Client Secret', mask: true },
|
|
14
|
+
{ key: 'username', label: 'Username / email', mask: false },
|
|
15
|
+
{ key: 'password', label: 'Password', mask: true },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export function LoginScreen() {
|
|
19
|
+
const ui = useUI();
|
|
20
|
+
const [vals, setVals] = useState({ clientId: '', clientSecret: '', username: '', password: '' });
|
|
21
|
+
const [idx, setIdx] = useState(0);
|
|
22
|
+
const [busy, setBusy] = useState(false);
|
|
23
|
+
const [error, setError] = useState(null);
|
|
24
|
+
|
|
25
|
+
// The whole screen is a form — keep global keys (q / Esc) suppressed.
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
ui.setTyping(true);
|
|
28
|
+
return () => ui.setTyping(false);
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
const setField = (key) => (v) => setVals((s) => ({ ...s, [key]: v }));
|
|
32
|
+
|
|
33
|
+
const submit = async () => {
|
|
34
|
+
if (busy) return;
|
|
35
|
+
if (idx < FIELDS.length - 1) { setIdx(idx + 1); return; }
|
|
36
|
+
if (FIELDS.some((f) => !vals[f.key].trim())) {
|
|
37
|
+
setError(new Error('All four fields are required.'));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
setBusy(true);
|
|
41
|
+
setError(null);
|
|
42
|
+
try {
|
|
43
|
+
await login({
|
|
44
|
+
clientId: vals.clientId.trim(),
|
|
45
|
+
clientSecret: vals.clientSecret.trim(),
|
|
46
|
+
username: vals.username.trim(),
|
|
47
|
+
password: vals.password,
|
|
48
|
+
});
|
|
49
|
+
ui.goBack();
|
|
50
|
+
} catch (err) {
|
|
51
|
+
setError(err);
|
|
52
|
+
setBusy(false);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
useInput((input, key) => {
|
|
57
|
+
if (busy) return;
|
|
58
|
+
if (key.escape) ui.goBack();
|
|
59
|
+
else if (key.tab || key.downArrow) setIdx((i) => (i + 1) % FIELDS.length);
|
|
60
|
+
else if (key.upArrow) setIdx((i) => (i - 1 + FIELDS.length) % FIELDS.length);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<Box flexDirection="column">
|
|
65
|
+
<Header title="Log in to MangaDex" subtitle="OAuth2 personal client" />
|
|
66
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
67
|
+
<Text dimColor>Create a personal client at mangadex.org/settings → API Clients,</Text>
|
|
68
|
+
<Text dimColor>then enter its id + secret with your MangaDex login below.</Text>
|
|
69
|
+
<Text dimColor>(A new client may need staff approval before it works.)</Text>
|
|
70
|
+
</Box>
|
|
71
|
+
{FIELDS.map((f, i) => (
|
|
72
|
+
<Box key={f.key}>
|
|
73
|
+
<Box width={16}>
|
|
74
|
+
<Text color={i === idx ? 'cyanBright' : undefined}>{`${i === idx ? '› ' : ' '}${f.label}`}</Text>
|
|
75
|
+
</Box>
|
|
76
|
+
<Text dimColor>: </Text>
|
|
77
|
+
<TextInput
|
|
78
|
+
value={vals[f.key]}
|
|
79
|
+
onChange={setField(f.key)}
|
|
80
|
+
onSubmit={submit}
|
|
81
|
+
focus={i === idx && !busy}
|
|
82
|
+
mask={f.mask ? '*' : undefined}
|
|
83
|
+
placeholder={i === idx ? 'type…' : ''}
|
|
84
|
+
/>
|
|
85
|
+
</Box>
|
|
86
|
+
))}
|
|
87
|
+
{busy ? <Box marginTop={1}><Spinner label="Signing in" /></Box> : null}
|
|
88
|
+
{error ? <Box marginTop={1}><ErrorView error={error} /></Box> : null}
|
|
89
|
+
<KeyHints hints={[['enter', 'next / submit'], ['tab ↑↓', 'fields'], ['esc', 'cancel']]} />
|
|
90
|
+
</Box>
|
|
91
|
+
);
|
|
92
|
+
}
|