coomer-downloader 3.2.0 → 3.4.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 (43) hide show
  1. package/README.md +14 -3
  2. package/biome.json +6 -4
  3. package/dist/index.js +539 -285
  4. package/docs/images/Screenshot 01.jpg +0 -0
  5. package/package.json +14 -5
  6. package/src/api/bunkr.ts +1 -1
  7. package/src/api/coomer-api.ts +23 -6
  8. package/src/api/gofile.ts +2 -2
  9. package/src/api/index.ts +5 -1
  10. package/src/api/nsfw.xxx.ts +3 -3
  11. package/src/api/plain-curl.ts +1 -1
  12. package/src/{args-handler.ts → cli/args-handler.ts} +17 -12
  13. package/src/cli/ui/app.tsx +40 -0
  14. package/src/cli/ui/components/file.tsx +44 -0
  15. package/src/cli/ui/components/filelist.tsx +52 -0
  16. package/src/cli/ui/components/index.ts +6 -0
  17. package/src/cli/ui/components/keyboardinfo.tsx +41 -0
  18. package/src/cli/ui/components/loading.tsx +20 -0
  19. package/src/cli/ui/components/preview.tsx +32 -0
  20. package/src/cli/ui/components/spinner.tsx +28 -0
  21. package/src/cli/ui/components/titlebar.tsx +15 -0
  22. package/src/cli/ui/hooks/downloader.ts +21 -0
  23. package/src/cli/ui/hooks/input.ts +17 -0
  24. package/src/cli/ui/index.tsx +7 -0
  25. package/src/cli/ui/store/index.ts +19 -0
  26. package/src/index.ts +42 -23
  27. package/src/logger/index.ts +15 -0
  28. package/src/services/downloader.ts +161 -0
  29. package/src/services/file.ts +113 -0
  30. package/src/types/index.ts +16 -1
  31. package/src/utils/duplicates.ts +23 -0
  32. package/src/utils/filters.ts +15 -15
  33. package/src/utils/io.ts +25 -0
  34. package/src/utils/mediatypes.ts +13 -0
  35. package/src/utils/promise.ts +0 -50
  36. package/src/utils/requests.ts +2 -2
  37. package/src/utils/strings.ts +1 -10
  38. package/src/utils/timer.ts +11 -9
  39. package/tsconfig.json +2 -1
  40. package/src/utils/downloader.ts +0 -108
  41. package/src/utils/file.ts +0 -75
  42. package/src/utils/index.ts +0 -11
  43. package/src/utils/multibar.ts +0 -62
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coomer-downloader",
3
- "version": "3.2.0",
3
+ "version": "3.4.0",
4
4
  "author": "smartacephal",
5
5
  "license": "MIT",
6
6
  "description": "Downloads images/videos from Coomer/Kemono, Bunkr, GoFile, Reddit-NSFW user posts",
@@ -22,10 +22,11 @@
22
22
  "type": "git",
23
23
  "url": "git+https://github.com/smartacephale/coomer-downloader.git"
24
24
  },
25
- "main": "./src/index.ts",
25
+ "main": "./src/index.tsx",
26
26
  "type": "module",
27
27
  "scripts": {
28
28
  "start": "tsx ./src/index.ts",
29
+ "start-watch": "tsx watch ./src/index.ts",
29
30
  "build": "node build.js",
30
31
  "start-build": "npm run build && node ./dist/index.js"
31
32
  },
@@ -38,18 +39,26 @@
38
39
  },
39
40
  "dependencies": {
40
41
  "cheerio": "1.1.0",
41
- "cli-progress": "^3.12.0",
42
+ "cli-spinners": "^3.3.0",
42
43
  "http-cookie-agent": "^6.0.0",
44
+ "ink": "^6.5.1",
45
+ "ink-picture": "^1.3.3",
46
+ "react": "^19.2.0",
43
47
  "rxjs": "^7.8.2",
44
48
  "tough-cookie": "5.1.2",
45
49
  "undici": "^6.22.0",
46
- "yargs": "^17.7.2"
50
+ "yargs": "^17.7.2",
51
+ "zustand": "^5.0.8"
47
52
  },
48
53
  "devDependencies": {
49
- "@types/cli-progress": "^3.11.6",
50
54
  "@types/node": "^24.10.0",
55
+ "@types/react": "^19.2.6",
51
56
  "@types/yargs": "^17.0.34",
52
57
  "esbuild": "^0.27.0",
58
+ "eslint-config-xo-react": "^0.27.0",
59
+ "eslint-plugin-react": "^7.32.2",
60
+ "eslint-plugin-react-hooks": "^4.6.0",
61
+ "pino": "^10.1.0",
53
62
  "tsx": "^4.20.6"
54
63
  }
55
64
  }
package/src/api/bunkr.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as cheerio from 'cheerio';
2
2
  import { fetch } from 'undici';
3
- import { CoomerFile, CoomerFileList } from '../utils/file';
3
+ import { CoomerFile, CoomerFileList } from '../services/file';
4
4
 
5
5
  type EncData = { url: string; timestamp: number };
6
6
 
@@ -1,5 +1,6 @@
1
- import { CoomerFile, CoomerFileList } from '../utils/file.js';
2
- import { fetchWithGlobalHeader, isImage, setGlobalHeaders } from '../utils/index.js';
1
+ import { CoomerFile, CoomerFileList } from '../services/file';
2
+ import { isImage } from '../utils/mediatypes';
3
+ import { fetchWithGlobalHeader, setGlobalHeaders } from '../utils/requests';
3
4
 
4
5
  type CoomerAPIUser = { domain: string; service: string; id: string; name?: string };
5
6
  type CoomerAPIUserData = { name: string };
@@ -33,7 +34,10 @@ async function getUserProfileData(user: CoomerAPIUser): Promise<CoomerAPIUserDat
33
34
  return result as CoomerAPIUserData;
34
35
  }
35
36
 
36
- async function getUserPostsAPI(user: CoomerAPIUser, offset: number): Promise<CoomerAPIPost[]> {
37
+ async function getUserPostsAPI(
38
+ user: CoomerAPIUser,
39
+ offset: number,
40
+ ): Promise<CoomerAPIPost[]> {
37
41
  const url = `${user.domain}/api/v1/${user.service}/user/${user.id}/posts?o=${offset}`;
38
42
  const posts = await fetchWithGlobalHeader(url).then((r) => r.json());
39
43
  return posts as CoomerAPIPost[];
@@ -43,10 +47,10 @@ export async function getUserFiles(user: CoomerAPIUser): Promise<CoomerFileList>
43
47
  const userPosts = [];
44
48
 
45
49
  const offset = 50;
46
- for (let i = 0; i < 1000; i++) {
50
+ for (let i = 0; i < 10_000; i++) {
47
51
  const posts = await getUserPostsAPI(user, i * offset);
48
52
  userPosts.push(...posts);
49
- if (posts.length < 50) break;
53
+ if (posts.length < offset) break;
50
54
  }
51
55
 
52
56
  const filelist = new CoomerFileList();
@@ -62,7 +66,7 @@ export async function getUserFiles(user: CoomerAPIUser): Promise<CoomerFileList>
62
66
  .map((f, i) => {
63
67
  const ext = f.name.split('.').pop();
64
68
  const name = `${datentitle} ${i + 1}.${ext}`;
65
- const url = `${user.domain}/${f.path}`;
69
+ const url = getUrl(f, user);
66
70
  return CoomerFile.from({ name, url, content });
67
71
  });
68
72
 
@@ -72,6 +76,19 @@ export async function getUserFiles(user: CoomerAPIUser): Promise<CoomerFileList>
72
76
  return filelist;
73
77
  }
74
78
 
79
+ function getUrl(f: CoomerAPIFile, user: CoomerAPIUser) {
80
+ // Normalize f.path to avoid protocol-relative or multiple-leading-slash paths
81
+ const normalizedPath = f.path.replace(/^\/+/, '/');
82
+ let url = '';
83
+ try {
84
+ url = new URL(normalizedPath, user.domain).toString();
85
+ } catch (_) {
86
+ // Fallback: join with a single slash
87
+ url = `${user.domain}/${normalizedPath.replace(/^\//, '')}`;
88
+ }
89
+ return url;
90
+ }
91
+
75
92
  async function parseUser(url: string): Promise<CoomerAPIUser> {
76
93
  const [_, domain, service, id] = url.match(
77
94
  /(https:\/\/\w+\.\w+)\/(\w+)\/user\/([\w|.|-]+)/,
package/src/api/gofile.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { fetch } from 'undici';
2
- import { CoomerFile, CoomerFileList } from '../utils/file.js';
3
- import { setGlobalHeaders } from '../utils/index.js';
2
+ import { CoomerFile, CoomerFileList } from '../services/file';
3
+ import { setGlobalHeaders } from '../utils/requests';
4
4
 
5
5
  type GoFileAPIToken = { status: string; data: { token: string } };
6
6
  type GoFileAPIFilelist = { data: { children: { link: string; name: string }[] } };
package/src/api/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { CoomerFileList } from '../utils/file';
1
+ import type { CoomerFileList } from '../services/file';
2
2
  import { getBunkrData } from './bunkr';
3
3
  import { getCoomerData } from './coomer-api';
4
4
  import { getGofileData } from './gofile';
@@ -11,15 +11,19 @@ export async function apiHandler(url_: string): Promise<CoomerFileList> {
11
11
  if (/^u\/\w+$/.test(url.origin)) {
12
12
  return getRedditData(url.href);
13
13
  }
14
+
14
15
  if (/coomer|kemono/.test(url.origin)) {
15
16
  return getCoomerData(url.href);
16
17
  }
18
+
17
19
  if (/bunkr/.test(url.origin)) {
18
20
  return getBunkrData(url.href);
19
21
  }
22
+
20
23
  if (/gofile\.io/.test(url.origin)) {
21
24
  return getGofileData(url.href);
22
25
  }
26
+
23
27
  if (/\.\w+/.test(url.pathname)) {
24
28
  return getPlainFileData(url.href);
25
29
  }
@@ -1,6 +1,6 @@
1
1
  import * as cheerio from 'cheerio';
2
2
  import { fetch } from 'undici';
3
- import { CoomerFile, CoomerFileList } from '../utils/file';
3
+ import { CoomerFile, CoomerFileList } from '../services/file';
4
4
 
5
5
  async function getUserPage(user: string, offset: number) {
6
6
  const url = `https://nsfw.xxx/page/${offset}?nsfw[]=0&types[]=image&types[]=video&types[]=gallery&slider=1&jsload=1&user=${user}&_=${Date.now()}`;
@@ -8,7 +8,6 @@ async function getUserPage(user: string, offset: number) {
8
8
  }
9
9
 
10
10
  async function getUserPosts(user: string): Promise<string[]> {
11
- console.log('Fetching user posts...');
12
11
  const posts = [];
13
12
  for (let i = 1; i < 100000; i++) {
14
13
  const page = await getUserPage(user, i);
@@ -26,7 +25,6 @@ async function getUserPosts(user: string): Promise<string[]> {
26
25
  }
27
26
 
28
27
  async function getPostsData(posts: string[]): Promise<CoomerFileList> {
29
- console.log('Fetching posts data...');
30
28
  const filelist = new CoomerFileList();
31
29
  for (const post of posts) {
32
30
  const page = await fetch(post).then((r) => r.text());
@@ -53,7 +51,9 @@ async function getPostsData(posts: string[]): Promise<CoomerFileList> {
53
51
 
54
52
  export async function getRedditData(url: string): Promise<CoomerFileList> {
55
53
  const user = url.match(/u\/(\w+)/)?.[1] as string;
54
+ console.log('Fetching user posts...');
56
55
  const posts = await getUserPosts(user);
56
+ console.log('Fetching posts data...');
57
57
  const filelist = await getPostsData(posts);
58
58
  filelist.dirName = `${user}-reddit`;
59
59
  return filelist;
@@ -1,4 +1,4 @@
1
- import { CoomerFile, CoomerFileList } from '../utils/file';
1
+ import { CoomerFile, CoomerFileList } from '../services/file';
2
2
 
3
3
  export async function getPlainFileData(url: string): Promise<CoomerFileList> {
4
4
  const name = url.split('/').pop() as string;
@@ -1,16 +1,7 @@
1
1
  import yargs from 'yargs';
2
2
  import { hideBin } from 'yargs/helpers';
3
3
 
4
- type ArgumentHandlerResult = {
5
- url: string;
6
- dir: string;
7
- media: string;
8
- include: string;
9
- exclude: string;
10
- skip: number;
11
- };
12
-
13
- export function argumentHander(): ArgumentHandlerResult {
4
+ export function argumentHander() {
14
5
  return yargs(hideBin(process.argv))
15
6
  .option('url', {
16
7
  alias: 'u',
@@ -26,8 +17,7 @@ export function argumentHander(): ArgumentHandlerResult {
26
17
  })
27
18
  .option('media', {
28
19
  type: 'string',
29
- choices: ['video', 'image', 'all'],
30
- default: 'all',
20
+ choices: ['video', 'image'],
31
21
  description:
32
22
  "The type of media to download: 'video', 'image', or 'all'. 'all' is the default.",
33
23
  })
@@ -41,11 +31,26 @@ export function argumentHander(): ArgumentHandlerResult {
41
31
  default: '',
42
32
  description: 'Filter file names by a comma-separated list of keywords to exclude',
43
33
  })
34
+ .option('min-size', {
35
+ type: 'string',
36
+ default: '',
37
+ description: 'Minimum file size to download. Example: "1mb" or "500kb"',
38
+ })
39
+ .option('max-size', {
40
+ type: 'string',
41
+ default: '',
42
+ description: 'Maximum file size to download. Example: "1mb" or "500kb"',
43
+ })
44
44
  .option('skip', {
45
45
  type: 'number',
46
46
  default: 0,
47
47
  description: 'Skips the first N files in the download queue',
48
48
  })
49
+ .option('remove-dupilicates', {
50
+ type: 'boolean',
51
+ default: true,
52
+ description: 'removes duplicates by url and file hash',
53
+ })
49
54
  .help()
50
55
  .alias('help', 'h')
51
56
  .parseSync();
@@ -0,0 +1,40 @@
1
+ import { Box } from 'ink';
2
+ import React from 'react';
3
+ import { CoomerFileList } from '../../services/file';
4
+ import { FileBox, FileListStateBox, KeyboardControlsInfo, Loading, TitleBar } from './components';
5
+ import { useDownloaderHook } from './hooks/downloader';
6
+ import { useInputHook } from './hooks/input';
7
+ import { useInkStore } from './store';
8
+
9
+ export function App() {
10
+ useInputHook();
11
+ useDownloaderHook();
12
+
13
+ const downloader = useInkStore((state) => state.downloader);
14
+ const filelist = downloader?.filelist;
15
+ const isFilelist = filelist instanceof CoomerFileList;
16
+
17
+ return (
18
+ <Box borderStyle="single" flexDirection="column" borderColor="blue" width={80}>
19
+ <TitleBar />
20
+ {!isFilelist ? (
21
+ <Loading />
22
+ ) : (
23
+ <>
24
+ <Box>
25
+ <Box>
26
+ <FileListStateBox filelist={filelist} />
27
+ </Box>
28
+ <Box flexBasis={30}>
29
+ <KeyboardControlsInfo />
30
+ </Box>
31
+ </Box>
32
+
33
+ {filelist.getActiveFiles().map((file) => {
34
+ return <FileBox file={file} key={file.name} />;
35
+ })}
36
+ </>
37
+ )}
38
+ </Box>
39
+ );
40
+ }
@@ -0,0 +1,44 @@
1
+ import { Box, Spacer, Text } from 'ink';
2
+ import React from 'react';
3
+ import type { CoomerFile } from '../../../services/file';
4
+ import { b2mb } from '../../../utils/strings';
5
+ import { Preview } from './preview';
6
+ import { Spinner } from './spinner';
7
+
8
+ interface FileBoxProps {
9
+ file: CoomerFile;
10
+ }
11
+
12
+ export function FileBox({ file }: FileBoxProps) {
13
+ const percentage = Number((file.downloaded / (file.size as number)) * 100).toFixed(2);
14
+ return (
15
+ <>
16
+ <Box
17
+ borderStyle="single"
18
+ borderColor="magentaBright"
19
+ borderDimColor
20
+ paddingX={1}
21
+ flexDirection="column"
22
+ >
23
+ <Box>
24
+ <Text color="blue" dimColor wrap="truncate-middle">
25
+ {file.name}
26
+ </Text>
27
+ </Box>
28
+ <Box flexDirection="row-reverse">
29
+ <Text color="cyan" dimColor>
30
+ {b2mb(file.downloaded)}/{file.size ? b2mb(file.size) : '∞'} MB
31
+ </Text>
32
+ <Text color="redBright" dimColor>
33
+ {file.size ? ` ${percentage}% ` : ''}
34
+ </Text>
35
+ <Spacer />
36
+ <Text color={'green'} dimColor>
37
+ <Spinner></Spinner>
38
+ </Text>
39
+ </Box>
40
+ </Box>
41
+ <Preview file={file} />
42
+ </>
43
+ );
44
+ }
@@ -0,0 +1,52 @@
1
+ import { Box, Text } from 'ink';
2
+ import React from 'react';
3
+ import type { CoomerFileList } from '../../../services/file';
4
+
5
+ interface FileListStateBoxProps {
6
+ filelist: CoomerFileList;
7
+ }
8
+
9
+ export function FileListStateBox({ filelist }: FileListStateBoxProps) {
10
+ return (
11
+ <Box
12
+ paddingX={1}
13
+ flexDirection="column"
14
+ borderStyle={'single'}
15
+ borderColor={'magenta'}
16
+ borderDimColor
17
+ >
18
+ <Box>
19
+ <Box marginRight={1}>
20
+ <Text color="cyanBright" dimColor>
21
+ Found:
22
+ </Text>
23
+ </Box>
24
+ <Text color="blue" dimColor wrap="wrap">
25
+ {filelist.files.length}
26
+ </Text>
27
+ </Box>
28
+ <Box>
29
+ <Box marginRight={1}>
30
+ <Text color="cyanBright" dimColor>
31
+ Downloaded:
32
+ </Text>
33
+ </Box>
34
+ <Text color="blue" dimColor wrap="wrap">
35
+ {filelist.getDownloaded().length}
36
+ </Text>
37
+ </Box>
38
+ <Box>
39
+ <Box width={9}>
40
+ <Text color="cyanBright" dimColor>
41
+ Folder:
42
+ </Text>
43
+ </Box>
44
+ <Box flexGrow={1}>
45
+ <Text color="blue" dimColor wrap="truncate-middle">
46
+ {filelist.dirPath}
47
+ </Text>
48
+ </Box>
49
+ </Box>
50
+ </Box>
51
+ );
52
+ }
@@ -0,0 +1,6 @@
1
+ export { FileBox } from './file';
2
+ export { FileListStateBox } from './filelist';
3
+ export { KeyboardControlsInfo } from './keyboardinfo';
4
+ export { Loading } from './loading';
5
+ export { Spinner } from './spinner';
6
+ export { TitleBar } from './titlebar';
@@ -0,0 +1,41 @@
1
+ import { Box, Text } from 'ink';
2
+ import React from 'react';
3
+
4
+ const info = {
5
+ 's ': 'skip current file',
6
+ p: 'on/off image preview',
7
+ };
8
+
9
+ export function KeyboardControlsInfo() {
10
+ const infoRender = Object.entries(info).map(([key, value]) => {
11
+ return (
12
+ <Box key={key}>
13
+ <Box marginRight={2}>
14
+ <Text color={'red'} dimColor bold>
15
+ {key}
16
+ </Text>
17
+ </Box>
18
+ <Text dimColor bold={false}>
19
+ {value}
20
+ </Text>
21
+ </Box>
22
+ );
23
+ });
24
+
25
+ return (
26
+ <Box
27
+ flexDirection="column"
28
+ paddingX={1}
29
+ borderStyle={'single'}
30
+ borderColor={'gray'}
31
+ borderDimColor
32
+ >
33
+ <Box>
34
+ <Text color={'red'} dimColor bold>
35
+ Keyboard controls:
36
+ </Text>
37
+ </Box>
38
+ {infoRender}
39
+ </Box>
40
+ );
41
+ }
@@ -0,0 +1,20 @@
1
+ import { Box, Text } from 'ink';
2
+ import React from 'react';
3
+ import { Spinner } from './spinner';
4
+
5
+ export function Loading() {
6
+ return (
7
+ <Box paddingX={1} borderDimColor flexDirection="column">
8
+ <Box alignSelf="center">
9
+ <Text dimColor color={'redBright'}>
10
+ Fetching Data
11
+ </Text>
12
+ </Box>
13
+ <Box alignSelf="center">
14
+ <Text color={'blueBright'} dimColor>
15
+ <Spinner type="grenade"></Spinner>
16
+ </Text>
17
+ </Box>
18
+ </Box>
19
+ );
20
+ }
@@ -0,0 +1,32 @@
1
+ import { Box } from 'ink';
2
+ import Image, { TerminalInfoProvider } from 'ink-picture';
3
+ import React from 'react';
4
+ import type { CoomerFile } from '../../../services/file';
5
+ import { isImage } from '../../../utils/mediatypes';
6
+ import { useInkStore } from '../store';
7
+
8
+ interface PreviewProps {
9
+ file: CoomerFile;
10
+ }
11
+
12
+ export function Preview({ file }: PreviewProps) {
13
+ const previewEnabled = useInkStore((state) => state.preview);
14
+ const bigEnough = file.downloaded > 50 * 1024;
15
+ const shouldShow = previewEnabled && bigEnough && isImage(file.filepath as string);
16
+ const imgInfo = `
17
+ can't read partial images yet...
18
+ actual size: ${file.size}}
19
+ downloaded: ${file.downloaded}}
20
+ `;
21
+ return (
22
+ shouldShow && (
23
+ <Box paddingX={1}>
24
+ <TerminalInfoProvider>
25
+ <Box width={30} height={15}>
26
+ <Image src={file.filepath as string} alt={imgInfo} />
27
+ </Box>
28
+ </TerminalInfoProvider>
29
+ </Box>
30
+ )
31
+ );
32
+ }
@@ -0,0 +1,28 @@
1
+ import type { SpinnerName } from 'cli-spinners';
2
+ import spinners from 'cli-spinners';
3
+ import { Text } from 'ink';
4
+ import React, { useEffect, useState } from 'react';
5
+
6
+ interface SpinnerProps {
7
+ type?: SpinnerName;
8
+ }
9
+
10
+ export function Spinner({ type = 'dots' }: SpinnerProps) {
11
+ const spinner = spinners[type];
12
+ const randomFrame = (spinner.frames.length * Math.random()) | 0;
13
+ const [frame, setFrame] = useState(randomFrame);
14
+
15
+ useEffect(() => {
16
+ const timer = setInterval(() => {
17
+ setFrame((previousFrame) => {
18
+ return (previousFrame + 1) % spinner.frames.length;
19
+ });
20
+ }, spinner.interval);
21
+
22
+ return () => {
23
+ clearInterval(timer);
24
+ };
25
+ }, [spinner]);
26
+
27
+ return <Text>{spinner.frames[frame]}</Text>;
28
+ }
@@ -0,0 +1,15 @@
1
+ import { Box, Spacer, Text } from 'ink';
2
+ import React from 'react';
3
+ import { version } from '../../../../package.json';
4
+
5
+ export function TitleBar() {
6
+ return (
7
+ <Box>
8
+ <Spacer></Spacer>
9
+ <Box borderColor={'magenta'} borderStyle={'arrow'}>
10
+ <Text color={'cyanBright'}>Coomer-Downloader {version}</Text>
11
+ </Box>
12
+ <Spacer></Spacer>
13
+ </Box>
14
+ );
15
+ }
@@ -0,0 +1,21 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { useInkStore } from '../store';
3
+
4
+ export const useDownloaderHook = () => {
5
+ const downloader = useInkStore((state) => state.downloader);
6
+ const filelist = downloader?.filelist;
7
+
8
+ const [_, setHelper] = useState(0);
9
+
10
+ useEffect(() => {
11
+ downloader?.subject.subscribe(({ type }) => {
12
+ if (
13
+ type === 'FILE_DOWNLOADING_START' ||
14
+ type === 'FILE_DOWNLOADING_END' ||
15
+ type === 'CHUNK_DOWNLOADING_UPDATE'
16
+ ) {
17
+ setHelper(Date.now());
18
+ }
19
+ });
20
+ });
21
+ };
@@ -0,0 +1,17 @@
1
+ import { useInput } from 'ink';
2
+ import { useInkStore } from '../store';
3
+
4
+ // problems with tsx watch
5
+ export const useInputHook = () => {
6
+ const downloader = useInkStore((state) => state.downloader);
7
+ const switchPreview = useInkStore((state) => state.switchPreview);
8
+
9
+ useInput((input) => {
10
+ if (input === 's') {
11
+ downloader?.skip();
12
+ }
13
+ if (input === 'p') {
14
+ switchPreview();
15
+ }
16
+ });
17
+ };
@@ -0,0 +1,7 @@
1
+ import { render } from 'ink';
2
+ import React from 'react';
3
+ import { App } from './app';
4
+
5
+ export function createReactInk() {
6
+ return render(<App />);
7
+ }
@@ -0,0 +1,19 @@
1
+ import { create } from 'zustand';
2
+ import type { Downloader } from '../../../services/downloader';
3
+
4
+ interface InkState {
5
+ preview: boolean;
6
+ switchPreview: () => void;
7
+ downloader?: Downloader;
8
+ setDownloader: (downloader: Downloader) => void;
9
+ }
10
+
11
+ export const useInkStore = create<InkState>((set) => ({
12
+ preview: false,
13
+ switchPreview: () =>
14
+ set((state) => ({
15
+ preview: !state.preview,
16
+ })),
17
+ downloader: undefined,
18
+ setDownloader: (downloader: Downloader) => set({ downloader }),
19
+ }));