komado 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +208 -0
  2. package/dist/app.js +59 -0
  3. package/dist/cli.js +196 -0
  4. package/dist/components/List.js +41 -0
  5. package/dist/components/screens/ContinueScreen.js +66 -0
  6. package/dist/components/screens/HomeScreen.js +59 -0
  7. package/dist/components/screens/LibraryScreen.js +69 -0
  8. package/dist/components/screens/LoginScreen.js +85 -0
  9. package/dist/components/screens/MangaScreen.js +107 -0
  10. package/dist/components/screens/ReaderScreen.js +195 -0
  11. package/dist/components/screens/SearchScreen.js +111 -0
  12. package/dist/components/screens/SettingsScreen.js +144 -0
  13. package/dist/components/ui.js +33 -0
  14. package/dist/config.js +51 -0
  15. package/dist/domain/shape.js +44 -0
  16. package/dist/hooks/useStdoutDimensions.js +17 -0
  17. package/dist/lib/AppError.js +37 -0
  18. package/dist/lib/cache.js +54 -0
  19. package/dist/lib/catchAsync.js +12 -0
  20. package/dist/lib/envelope.js +15 -0
  21. package/dist/lib/fetchWithBackoff.js +65 -0
  22. package/dist/lib/logger.js +41 -0
  23. package/dist/lib/natsort.js +7 -0
  24. package/dist/lib/text.js +20 -0
  25. package/dist/render/chafa.js +56 -0
  26. package/dist/render/detect.js +86 -0
  27. package/dist/render/halfblock.js +42 -0
  28. package/dist/render/image.js +23 -0
  29. package/dist/render/sixel.js +88 -0
  30. package/dist/sixel-reader.js +309 -0
  31. package/dist/sources/index.js +17 -0
  32. package/dist/sources/local/archive.js +68 -0
  33. package/dist/sources/local/index.js +147 -0
  34. package/dist/sources/mangadex/auth.js +102 -0
  35. package/dist/sources/mangadex/client.js +76 -0
  36. package/dist/sources/mangadex/index.js +156 -0
  37. package/dist/sources/mangadex/normalize.js +54 -0
  38. package/dist/state/store.js +91 -0
  39. package/dist/ui-context.js +11 -0
  40. package/package.json +50 -0
  41. package/src/app.js +73 -0
  42. package/src/cli.js +218 -0
  43. package/src/components/List.js +60 -0
  44. package/src/components/screens/ContinueScreen.js +73 -0
  45. package/src/components/screens/HomeScreen.js +54 -0
  46. package/src/components/screens/LibraryScreen.js +79 -0
  47. package/src/components/screens/LoginScreen.js +92 -0
  48. package/src/components/screens/MangaScreen.js +125 -0
  49. package/src/components/screens/ReaderScreen.js +230 -0
  50. package/src/components/screens/SearchScreen.js +123 -0
  51. package/src/components/screens/SettingsScreen.js +146 -0
  52. package/src/components/ui.js +42 -0
  53. package/src/config.js +49 -0
  54. package/src/domain/shape.js +47 -0
  55. package/src/hooks/useStdoutDimensions.js +19 -0
  56. package/src/lib/AppError.js +26 -0
  57. package/src/lib/cache.js +57 -0
  58. package/src/lib/catchAsync.js +12 -0
  59. package/src/lib/envelope.js +14 -0
  60. package/src/lib/fetchWithBackoff.js +74 -0
  61. package/src/lib/logger.js +41 -0
  62. package/src/lib/natsort.js +7 -0
  63. package/src/lib/text.js +18 -0
  64. package/src/render/chafa.js +64 -0
  65. package/src/render/detect.js +112 -0
  66. package/src/render/halfblock.js +46 -0
  67. package/src/render/image.js +24 -0
  68. package/src/render/sixel.js +141 -0
  69. package/src/sixel-reader.js +359 -0
  70. package/src/sources/index.js +17 -0
  71. package/src/sources/local/archive.js +74 -0
  72. package/src/sources/local/index.js +155 -0
  73. package/src/sources/mangadex/auth.js +125 -0
  74. package/src/sources/mangadex/client.js +83 -0
  75. package/src/sources/mangadex/index.js +166 -0
  76. package/src/sources/mangadex/normalize.js +70 -0
  77. package/src/state/store.js +90 -0
  78. package/src/ui-context.js +12 -0
@@ -0,0 +1,125 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ import { useUI } from '../../ui-context.js';
4
+ import { getSource } from '../../sources/index.js';
5
+ import { getProgress } from '../../state/store.js';
6
+ import { isLoggedIn } from '../../sources/mangadex/auth.js';
7
+ import { chapterLabel } from '../../domain/shape.js';
8
+ import { List } from '../List.js';
9
+ import { Header, Spinner, ErrorView, KeyHints } from '../ui.js';
10
+ import { truncate } from '../../lib/text.js';
11
+
12
+ export function MangaScreen({ params }) {
13
+ const { sourceId, manga: initial } = params;
14
+ const ui = useUI();
15
+ const source = getSource(sourceId);
16
+
17
+ const [manga, setManga] = useState(initial);
18
+ const [chapters, setChapters] = useState([]);
19
+ const [loading, setLoading] = useState(true);
20
+ const [error, setError] = useState(null);
21
+ const [readSet, setReadSet] = useState(null); // chapter ids read on MangaDex
22
+ const progress = getProgress(initial.key);
23
+
24
+ useEffect(() => {
25
+ let cancelled = false;
26
+ const ctrl = new AbortController();
27
+ setLoading(true);
28
+ setError(null);
29
+ (async () => {
30
+ try {
31
+ const [full, chRes] = await Promise.all([
32
+ source.getManga(initial.id, { signal: ctrl.signal }).catch(() => initial),
33
+ source.listChapters(initial.id, { signal: ctrl.signal, limit: 500 }),
34
+ ]);
35
+ if (cancelled) return;
36
+ setManga(full);
37
+ setChapters(chRes.data);
38
+ // Decorate the list with MangaDex read-markers (logged-in only).
39
+ if (isLoggedIn() && source.getReadMarkers) {
40
+ source.getReadMarkers(initial.id, { signal: ctrl.signal })
41
+ .then((ids) => { if (!cancelled) setReadSet(new Set(ids)); })
42
+ .catch(() => {}); // decorative — never block the screen on this
43
+ }
44
+ } catch (err) {
45
+ if (!cancelled) setError(err);
46
+ } finally {
47
+ if (!cancelled) setLoading(false);
48
+ }
49
+ })();
50
+ return () => {
51
+ cancelled = true;
52
+ ctrl.abort();
53
+ };
54
+ }, [initial.key]);
55
+
56
+ const openAt = (index, startPage = 0) =>
57
+ ui.openReader({ sourceId, manga, chapters, chapterIndex: index, startPage });
58
+
59
+ const resume = () => {
60
+ if (!progress) return;
61
+ const idx = chapters.findIndex((c) => c.id === progress.chapterId);
62
+ if (idx >= 0) openAt(idx, progress.page || 0);
63
+ };
64
+
65
+ useInput((input) => {
66
+ if (input === 'r' && progress && chapters.length) resume();
67
+ });
68
+
69
+ const cols = ui.dimensions.cols || 80;
70
+ const resumeChapterId = progress?.chapterId;
71
+
72
+ return (
73
+ <Box flexDirection="column">
74
+ <Header
75
+ title={truncate(manga.title, cols - 4)}
76
+ subtitle={[manga.authors?.join(', '), manga.status].filter(Boolean).join(' · ')}
77
+ />
78
+ {manga.tags?.length ? (
79
+ <Text color="blue">{truncate(manga.tags.join(' · '), cols - 4)}</Text>
80
+ ) : null}
81
+ {manga.description ? (
82
+ <Box marginTop={1} width={cols - 4}>
83
+ <Text dimColor wrap="truncate-end">
84
+ {truncate(manga.description.replace(/\s+/g, ' '), (cols - 4) * 3)}
85
+ </Text>
86
+ </Box>
87
+ ) : null}
88
+ {progress ? (
89
+ <Box marginTop={1}>
90
+ <Text color="green">{`▶ Resume Ch.${progress.chapterNumber ?? '?'} p.${(progress.page || 0) + 1} (press r)`}</Text>
91
+ </Box>
92
+ ) : null}
93
+
94
+ <Box marginTop={1} flexDirection="column">
95
+ <Text bold>{`Chapters ${chapters.length ? `(${chapters.length})` : ''}`}</Text>
96
+ {loading ? <Spinner label="Loading chapters" /> : null}
97
+ {error ? <ErrorView error={error} /> : null}
98
+ {!loading && !error ? (
99
+ <List
100
+ items={chapters}
101
+ height={Math.max(4, (ui.dimensions.rows || 24) - 12)}
102
+ onSelect={(_c, index) => openAt(index)}
103
+ emptyText="No chapters available in this language (try changing language in Settings)."
104
+ renderItem={(ch, active) => {
105
+ const isResume = ch.id === resumeChapterId;
106
+ const read = readSet?.has(ch.id);
107
+ return (
108
+ <Box key={ch.id}>
109
+ <Text
110
+ inverse={active}
111
+ color={active ? 'cyanBright' : isResume ? 'green' : undefined}
112
+ dimColor={!active && read && !isResume}
113
+ >
114
+ {` ${isResume ? '▶ ' : read ? '✓ ' : ' '}${truncate(chapterLabel(ch), cols - 8)} `}
115
+ </Text>
116
+ </Box>
117
+ );
118
+ }}
119
+ />
120
+ ) : null}
121
+ </Box>
122
+ <KeyHints hints={[['↑↓', 'move'], ['enter', 'read'], ...(progress ? [['r', 'resume']] : []), ['esc', 'back']]} />
123
+ </Box>
124
+ );
125
+ }
@@ -0,0 +1,230 @@
1
+ import { useState, useEffect, useRef, useMemo } from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ import { useUI } from '../../ui-context.js';
4
+ import { getSource } from '../../sources/index.js';
5
+ import { setProgress, getConfig } from '../../state/store.js';
6
+ import { chapterLabel } from '../../domain/shape.js';
7
+ import { renderInline, imageSize } from '../../render/image.js';
8
+ import { pickInlineBackend, RENDERER_CYCLE } from '../../render/detect.js';
9
+ import { Spinner, ErrorView, KeyHints } from '../ui.js';
10
+ import { truncate } from '../../lib/text.js';
11
+
12
+ export 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
+
18
+ const [chapterIndex, setChapterIndex] = useState(startChapter);
19
+ const [pages, setPages] = useState(null);
20
+ const [pageIndex, setPageIndex] = useState(startPage);
21
+ const [scroll, setScroll] = useState(0);
22
+ const [rendered, setRendered] = useState(null); // { lines, cols, rows }
23
+ const [status, setStatus] = useState('loading'); // loading | ready | error
24
+ const [error, setError] = useState(null);
25
+ const [fitMode, setFitMode] = useState(false);
26
+ const [rendererPref, setRendererPref] = useState(getConfig().renderer || 'auto');
27
+
28
+ const chapter = chapters[chapterIndex];
29
+ const viewportRows = Math.max(3, rows - 3); // status line + help line + margin
30
+ const renderCache = useRef(new Map());
31
+ const backend = useMemo(() => pickInlineBackend({ renderer: rendererPref }), [rendererPref]);
32
+
33
+ // --- Load page descriptors when the chapter changes ---
34
+ useEffect(() => {
35
+ let cancelled = false;
36
+ const ctrl = new AbortController();
37
+ setStatus('loading');
38
+ setPages(null);
39
+ setError(null);
40
+ (async () => {
41
+ try {
42
+ const pgs = await source.getPages(chapter.id, { signal: ctrl.signal });
43
+ if (cancelled) return;
44
+ setPages(pgs);
45
+ setPageIndex((p) => Math.max(0, Math.min(p, pgs.length - 1)));
46
+ } catch (err) {
47
+ if (!cancelled) {
48
+ setError(err);
49
+ setStatus('error');
50
+ }
51
+ }
52
+ })();
53
+ return () => {
54
+ cancelled = true;
55
+ ctrl.abort();
56
+ };
57
+ }, [chapter?.id]);
58
+
59
+ const cacheKeyFor = (idx) =>
60
+ `${chapter?.id}:${idx}:${cols}:${viewportRows}:${backend}:${fitMode ? 'fit' : 'scroll'}`;
61
+
62
+ // Load + render one page into the cache, returning its lines.
63
+ const renderPage = async (idx, signal) => {
64
+ const key = cacheKeyFor(idx);
65
+ if (renderCache.current.has(key)) return renderCache.current.get(key);
66
+ const buf = await source.loadPageBuffer(pages[idx], { signal });
67
+ let renderCols = cols;
68
+ if (fitMode) {
69
+ const { width, height } = await imageSize(buf);
70
+ // Pick a width so the whole page fits within the viewport height.
71
+ renderCols = Math.max(8, Math.min(cols, Math.floor(viewportRows * 2 * (width / height))));
72
+ }
73
+ const out = await renderInline(buf, { cols: renderCols, backend });
74
+ renderCache.current.set(key, out);
75
+ return out;
76
+ };
77
+
78
+ // Background prefetch (own controller — survives page turns, aborts on unmount).
79
+ const prefetchers = useRef(new Set());
80
+ useEffect(() => () => {
81
+ for (const c of prefetchers.current) c.abort();
82
+ prefetchers.current.clear();
83
+ }, []);
84
+ const prefetch = (idx) => {
85
+ if (!pages?.[idx] || renderCache.current.has(cacheKeyFor(idx))) return;
86
+ const c = new AbortController();
87
+ prefetchers.current.add(c);
88
+ renderPage(idx, c.signal).catch(() => {}).finally(() => prefetchers.current.delete(c));
89
+ };
90
+
91
+ // --- Render current page (no spinner flicker on cache hits) + prefetch next ---
92
+ useEffect(() => {
93
+ if (!pages || !pages[pageIndex]) return;
94
+ let cancelled = false;
95
+ const ctrl = new AbortController();
96
+
97
+ if (renderCache.current.has(cacheKeyFor(pageIndex))) {
98
+ setRendered(renderCache.current.get(cacheKeyFor(pageIndex)));
99
+ setScroll(0);
100
+ setStatus('ready');
101
+ } else {
102
+ setStatus('loading');
103
+ renderPage(pageIndex, ctrl.signal)
104
+ .then((out) => {
105
+ if (cancelled) return;
106
+ setRendered(out);
107
+ setScroll(0);
108
+ setStatus('ready');
109
+ })
110
+ .catch((err) => {
111
+ if (!cancelled && !ctrl.signal.aborted) {
112
+ setError(err);
113
+ setStatus('error');
114
+ }
115
+ });
116
+ }
117
+ prefetch(pageIndex + 1);
118
+
119
+ return () => {
120
+ cancelled = true;
121
+ ctrl.abort();
122
+ };
123
+ }, [pages, pageIndex, cols, viewportRows, backend, fitMode]);
124
+
125
+ // --- Persist reading progress on every settled page ---
126
+ useEffect(() => {
127
+ if (status !== 'ready' || !pages) return;
128
+ setProgress(manga.key, {
129
+ source: sourceId,
130
+ mangaId: manga.id,
131
+ mangaTitle: manga.title,
132
+ chapterId: chapter.id,
133
+ chapterNumber: chapter.number,
134
+ page: pageIndex,
135
+ });
136
+ // Reaching the last page = finished the chapter → push a read-marker to
137
+ // MangaDex. Self-guarded (login + setting) and deduped inside the source.
138
+ if (pageIndex === pages.length - 1 && source.syncChapterRead) {
139
+ source.syncChapterRead(manga.id, chapter.id);
140
+ }
141
+ }, [pageIndex, chapter?.id, status]);
142
+
143
+ // --- Navigation helpers ---
144
+ const lines = rendered?.lines || [];
145
+ const maxScroll = Math.max(0, lines.length - viewportRows);
146
+
147
+ const changeChapter = (delta) => {
148
+ const next = chapterIndex + delta;
149
+ if (next < 0 || next >= chapters.length) return;
150
+ setChapterIndex(next);
151
+ setPageIndex(0);
152
+ setScroll(0);
153
+ };
154
+ const nextPage = () => {
155
+ if (pages && pageIndex < pages.length - 1) {
156
+ setPageIndex(pageIndex + 1);
157
+ setScroll(0);
158
+ } else {
159
+ changeChapter(1);
160
+ }
161
+ };
162
+ const prevPage = () => {
163
+ if (pageIndex > 0) {
164
+ setPageIndex(pageIndex - 1);
165
+ setScroll(0);
166
+ } else {
167
+ changeChapter(-1);
168
+ }
169
+ };
170
+ const cycleRenderer = () => {
171
+ const i = RENDERER_CYCLE.indexOf(rendererPref);
172
+ setRendererPref(RENDERER_CYCLE[(i + 1) % RENDERER_CYCLE.length]);
173
+ };
174
+
175
+ useInput((input, key) => {
176
+ if (key.downArrow || input === 'j') setScroll((s) => Math.min(maxScroll, s + 1));
177
+ else if (key.upArrow || input === 'k') setScroll((s) => Math.max(0, s - 1));
178
+ else if (input === ' ' || key.pageDown) {
179
+ if (scroll >= maxScroll) nextPage();
180
+ else setScroll((s) => Math.min(maxScroll, s + viewportRows - 1));
181
+ } else if (key.pageUp) setScroll((s) => Math.max(0, s - (viewportRows - 1)));
182
+ else if (key.rightArrow || input === 'l' || input === 'n') nextPage();
183
+ else if (key.leftArrow || input === 'h' || input === 'p') prevPage();
184
+ else if (input === 'g') setScroll(0);
185
+ else if (input === 'G') setScroll(maxScroll);
186
+ else if (input === 'N') changeChapter(1);
187
+ else if (input === 'P') changeChapter(-1);
188
+ else if (input === 'f') setFitMode((f) => !f);
189
+ else if (input === 'r') cycleRenderer();
190
+ });
191
+
192
+ // --- Render ---
193
+ const pageLabel = pages ? `${pageIndex + 1}/${pages.length}` : '…';
194
+ const slice = lines.slice(scroll, scroll + viewportRows);
195
+
196
+ return (
197
+ <Box flexDirection="column">
198
+ <Box justifyContent="space-between">
199
+ <Text color="magentaBright" bold>{truncate(manga.title, Math.max(10, cols - 34))}</Text>
200
+ <Text>{`${truncate(chapterLabel(chapter), 24)} · ${pageLabel}${fitMode ? ' · fit' : ''}`}</Text>
201
+ </Box>
202
+
203
+ <Box height={viewportRows} flexDirection="column">
204
+ {status === 'loading' ? (
205
+ <Spinner label={pages ? `Rendering page ${pageIndex + 1}` : 'Loading chapter'} />
206
+ ) : status === 'error' ? (
207
+ <Box flexDirection="column">
208
+ <ErrorView error={error} />
209
+ <Text dimColor>Press N / P to skip to another chapter, or Esc to go back.</Text>
210
+ </Box>
211
+ ) : (
212
+ slice.map((ln, i) => (
213
+ <Text key={scroll + i} wrap="truncate-end">{ln}</Text>
214
+ ))
215
+ )}
216
+ </Box>
217
+
218
+ <KeyHints
219
+ hints={[
220
+ ['←→', 'page'],
221
+ ['↑↓', 'scroll'],
222
+ ['N/P', 'chapter'],
223
+ ['f', fitMode ? 'scroll' : 'fit'],
224
+ ['r', `render:${rendererPref}`],
225
+ ['esc', 'back'],
226
+ ]}
227
+ />
228
+ </Box>
229
+ );
230
+ }
@@ -0,0 +1,123 @@
1
+ import { useState, useEffect, useRef } 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 { 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
+
10
+ const PAGE = 20;
11
+
12
+ export function SearchScreen({ params }) {
13
+ const { sourceId, mode } = params;
14
+ const ui = useUI();
15
+ const source = getSource(sourceId);
16
+
17
+ const [query, setQuery] = useState('');
18
+ const [submitted, setSubmitted] = useState(mode === 'browse' ? '' : null); // null = not searched yet
19
+ const [results, setResults] = useState([]);
20
+ const [pagination, setPagination] = useState(null);
21
+ const [loading, setLoading] = useState(mode === 'browse');
22
+ const [error, setError] = useState(null);
23
+ const [focusInput, setFocusInput] = useState(mode !== 'browse');
24
+
25
+ // Raise the typing flag while the input is focused so global keys don't fire.
26
+ useEffect(() => {
27
+ ui.setTyping(focusInput);
28
+ return () => ui.setTyping(false);
29
+ }, [focusInput]);
30
+
31
+ // Monotonic request id guards against out-of-order responses (cancelled-flag).
32
+ const reqId = useRef(0);
33
+ const fetchPage = async (q, offset, append) => {
34
+ const id = ++reqId.current;
35
+ const ctrl = new AbortController();
36
+ setLoading(true);
37
+ setError(null);
38
+ try {
39
+ const res = await source.search(q, { offset, limit: PAGE, signal: ctrl.signal });
40
+ if (id !== reqId.current) return; // a newer request superseded this one
41
+ setResults((prev) => (append ? [...prev, ...res.data] : res.data));
42
+ setPagination(res.pagination);
43
+ } catch (err) {
44
+ if (id === reqId.current) setError(err);
45
+ } finally {
46
+ if (id === reqId.current) setLoading(false);
47
+ }
48
+ };
49
+
50
+ useEffect(() => {
51
+ if (mode === 'browse') fetchPage('', 0, false);
52
+ }, []);
53
+
54
+ const onSubmit = () => {
55
+ setSubmitted(query);
56
+ setFocusInput(false);
57
+ fetchPage(query, 0, false);
58
+ };
59
+
60
+ const loadMore = () => {
61
+ if (loading || !pagination?.hasMore) return;
62
+ fetchPage(submitted ?? '', pagination.offset + pagination.limit, true);
63
+ };
64
+
65
+ const onHighlight = (_item, index) => {
66
+ if (index >= results.length - 2) loadMore(); // prefetch near the end
67
+ };
68
+
69
+ // `/` refocuses the search box; Esc blurs it (handled here only while typing,
70
+ // so it doesn't collide with the app-level Esc=back).
71
+ useInput((input, key) => {
72
+ if (!focusInput && input === '/') setFocusInput(true);
73
+ else if (focusInput && key.escape) setFocusInput(false);
74
+ });
75
+
76
+ const listHeight = Math.max(4, (ui.dimensions.rows || 24) - 9);
77
+
78
+ return (
79
+ <Box flexDirection="column">
80
+ <Header
81
+ title={source.label}
82
+ subtitle={sourceId === 'local' ? 'filter your local library' : 'search the online catalog'}
83
+ />
84
+ <Box>
85
+ <Text color={focusInput ? 'cyanBright' : 'gray'}>{focusInput ? '› ' : ' '}</Text>
86
+ <TextInput
87
+ value={query}
88
+ onChange={setQuery}
89
+ onSubmit={onSubmit}
90
+ focus={focusInput}
91
+ placeholder={sourceId === 'local' ? 'type to filter…' : 'type a title, enter to search…'}
92
+ />
93
+ </Box>
94
+ {loading ? <Box marginTop={1}><Spinner label="Loading" /></Box> : null}
95
+ {error ? <Box marginTop={1}><ErrorView error={error} /></Box> : null}
96
+ {!error && submitted !== null ? (
97
+ <Box flexDirection="column" marginTop={1}>
98
+ <List
99
+ items={results}
100
+ isActive={!focusInput}
101
+ height={listHeight}
102
+ onSelect={(m) => ui.navigate('manga', { sourceId, manga: m })}
103
+ onHighlight={onHighlight}
104
+ emptyText={loading ? ' ' : submitted === '' ? 'Nothing found.' : `No results for "${submitted}".`}
105
+ renderItem={(m, active) => (
106
+ <Box key={m.key}>
107
+ <Text inverse={active} color={active ? 'cyanBright' : undefined}>
108
+ {` ${truncate(m.title, Math.max(20, (ui.dimensions.cols || 80) - 24))} `}
109
+ </Text>
110
+ <Text dimColor>{` ${m.source === 'local' ? `${m.chaptersCount ?? '?'} ch` : m.status || ''}`}</Text>
111
+ </Box>
112
+ )}
113
+ />
114
+ </Box>
115
+ ) : null}
116
+ <KeyHints
117
+ hints={focusInput
118
+ ? [['enter', 'search'], ['esc', 'to results']]
119
+ : [['↑↓', 'move'], ['enter', 'open'], ['/', 'search'], ['esc', 'back']]}
120
+ />
121
+ </Box>
122
+ );
123
+ }
@@ -0,0 +1,146 @@
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 { getConfig, setConfig } from '../../state/store.js';
6
+ import { scan } from '../../sources/local/index.js';
7
+ import { isLoggedIn, logout } from '../../sources/mangadex/auth.js';
8
+ import { detectCapabilities } from '../../render/detect.js';
9
+ import { List } from '../List.js';
10
+ import { Header, KeyHints } from '../ui.js';
11
+ import { truncate } from '../../lib/text.js';
12
+
13
+ const RENDERERS = ['auto', 'halfblock', 'chafa'];
14
+ const RATING_PRESETS = [
15
+ ['safe'],
16
+ ['safe', 'suggestive'],
17
+ ['safe', 'suggestive', 'erotica'],
18
+ ['safe', 'suggestive', 'erotica', 'pornographic'],
19
+ ];
20
+ const ratingLabel = (arr) => arr.join('+');
21
+ const cycle = (list, current) => list[(list.indexOf(current) + 1) % list.length];
22
+
23
+ export function SettingsScreen() {
24
+ const ui = useUI();
25
+ const caps = detectCapabilities();
26
+ const [cfg, setCfg] = useState(getConfig());
27
+ const [editing, setEditing] = useState(null); // null | 'language' | 'addPath'
28
+ const [draft, setDraft] = useState('');
29
+ const [highlighted, setHighlighted] = useState(null);
30
+ const [, setTick] = useState(0); // force a re-render after login/logout
31
+
32
+ const loggedIn = isLoggedIn();
33
+
34
+ useEffect(() => {
35
+ ui.setTyping(!!editing);
36
+ return () => ui.setTyping(false);
37
+ }, [editing]);
38
+
39
+ const save = (patch) => setCfg({ ...setConfig(patch) });
40
+
41
+ const items = [
42
+ { id: 'account', kind: 'account', label: loggedIn ? 'MangaDex account' : 'Log in to MangaDex…',
43
+ value: loggedIn ? 'logged in · enter to log out' : 'enter to log in' },
44
+ ...(loggedIn ? [{ id: 'syncProgress', kind: 'toggle', label: 'Sync reading progress (MangaDex)', value: cfg.syncProgress ? 'on' : 'off' }] : []),
45
+ { id: 'renderer', kind: 'cycle', label: 'Renderer', value: cfg.renderer + (caps.chafa ? '' : ' (chafa N/A)') },
46
+ { id: 'dataSaver', kind: 'toggle', label: 'Data saver (smaller images)', value: cfg.dataSaver ? 'on' : 'off' },
47
+ { id: 'rating', kind: 'cycle', label: 'Content rating', value: ratingLabel(cfg.contentRating) },
48
+ { id: 'language', kind: 'edit', label: 'Language (MangaDex)', value: cfg.language },
49
+ { id: 'addPath', kind: 'action', label: 'Add library path…', value: '' },
50
+ ...cfg.localLibraryPaths.map((p, i) => ({
51
+ id: `path:${i}`, kind: 'path', pathIndex: i, label: `Library: ${truncate(p, 48)}`, value: 'd to remove',
52
+ })),
53
+ ];
54
+
55
+ const activate = (item) => {
56
+ switch (item.kind) {
57
+ case 'account':
58
+ if (loggedIn) { logout(); return setTick((t) => t + 1); }
59
+ return ui.navigate('login');
60
+ case 'toggle':
61
+ if (item.id === 'syncProgress') return save({ syncProgress: !cfg.syncProgress });
62
+ return save({ dataSaver: !cfg.dataSaver });
63
+ case 'cycle':
64
+ if (item.id === 'renderer') return save({ renderer: cycle(RENDERERS, cfg.renderer) });
65
+ if (item.id === 'rating') {
66
+ const idx = RATING_PRESETS.findIndex((p) => ratingLabel(p) === ratingLabel(cfg.contentRating));
67
+ return save({ contentRating: RATING_PRESETS[(idx + 1) % RATING_PRESETS.length] });
68
+ }
69
+ return undefined;
70
+ case 'edit':
71
+ setDraft(cfg.language);
72
+ return setEditing('language');
73
+ case 'action':
74
+ setDraft('');
75
+ return setEditing('addPath');
76
+ default:
77
+ return undefined;
78
+ }
79
+ };
80
+
81
+ const submitEdit = () => {
82
+ if (editing === 'language') {
83
+ save({ language: draft.trim() || 'en' });
84
+ } else if (editing === 'addPath' && draft.trim()) {
85
+ save({ localLibraryPaths: [...cfg.localLibraryPaths, draft.trim()] });
86
+ scan();
87
+ }
88
+ setEditing(null);
89
+ setDraft('');
90
+ };
91
+
92
+ useInput((input, key) => {
93
+ if (editing) {
94
+ if (key.escape) {
95
+ setEditing(null);
96
+ setDraft('');
97
+ }
98
+ return;
99
+ }
100
+ if (input === 'd' && highlighted?.kind === 'path') {
101
+ save({ localLibraryPaths: cfg.localLibraryPaths.filter((_, i) => i !== highlighted.pathIndex) });
102
+ scan();
103
+ }
104
+ });
105
+
106
+ return (
107
+ <Box flexDirection="column">
108
+ <Header
109
+ title="Settings"
110
+ subtitle={`chafa: ${caps.chafa ? caps.chafaVersion : 'not installed'} · backend: ${caps.chafa ? 'chafa-symbols' : 'half-block'}`}
111
+ />
112
+
113
+ {editing ? (
114
+ <Box flexDirection="column">
115
+ <Text color="cyanBright">
116
+ {editing === 'addPath' ? 'New library path (folder of manga / CBZ):' : 'Language code (e.g. en, fr, ja):'}
117
+ </Text>
118
+ <Box>
119
+ <Text color="cyanBright">{'› '}</Text>
120
+ <TextInput value={draft} onChange={setDraft} onSubmit={submitEdit} focus={true} />
121
+ </Box>
122
+ <KeyHints hints={[['enter', 'save'], ['esc', 'cancel']]} />
123
+ </Box>
124
+ ) : (
125
+ <Box flexDirection="column">
126
+ <List
127
+ items={items}
128
+ isActive={true}
129
+ height={Math.max(6, (ui.dimensions.rows || 24) - 7)}
130
+ onSelect={activate}
131
+ onHighlight={(it) => setHighlighted(it)}
132
+ renderItem={(it, active) => (
133
+ <Box key={it.id} justifyContent="space-between">
134
+ <Text inverse={active} color={active ? 'cyanBright' : it.kind === 'path' ? 'blue' : undefined}>
135
+ {` ${it.label} `}
136
+ </Text>
137
+ {it.value ? <Text dimColor>{it.value}</Text> : null}
138
+ </Box>
139
+ )}
140
+ />
141
+ <KeyHints hints={[['↑↓', 'move'], ['enter', 'change'], ['d', 'remove path'], ['esc', 'back']]} />
142
+ </Box>
143
+ )}
144
+ </Box>
145
+ );
146
+ }
@@ -0,0 +1,42 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { Box, Text } from 'ink';
3
+
4
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
5
+
6
+ // Hand-rolled spinner — dependency-light, matching your preference for not
7
+ // pulling a package for something this small.
8
+ export function Spinner({ label = 'Loading' }) {
9
+ const [frame, setFrame] = useState(0);
10
+ useEffect(() => {
11
+ const t = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), 80);
12
+ return () => clearInterval(t);
13
+ }, []);
14
+ return <Text color="cyan">{`${FRAMES[frame]} ${label}…`}</Text>;
15
+ }
16
+
17
+ export function Header({ title, subtitle }) {
18
+ return (
19
+ <Box flexDirection="column" marginBottom={1}>
20
+ <Text color="magentaBright" bold>{title}</Text>
21
+ {subtitle ? <Text dimColor>{subtitle}</Text> : null}
22
+ </Box>
23
+ );
24
+ }
25
+
26
+ export function ErrorView({ error }) {
27
+ return (
28
+ <Box flexDirection="column">
29
+ <Text color="red" bold>{`✖ ${error?.message || 'Something went wrong'}`}</Text>
30
+ {error?.statusCode ? <Text dimColor>{`status ${error.statusCode}`}</Text> : null}
31
+ </Box>
32
+ );
33
+ }
34
+
35
+ // Footer key legend. `hints` is an array of [key, label] pairs.
36
+ export function KeyHints({ hints = [] }) {
37
+ return (
38
+ <Box marginTop={1}>
39
+ <Text dimColor>{hints.map(([k, l]) => `${k} ${l}`).join(' ')}</Text>
40
+ </Box>
41
+ );
42
+ }