duocnv 1.0.3 → 1.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/index.js +135 -111
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
-
import { useState, useRef } from "react";
|
|
3
|
+
import { useState, useRef, useEffect } from "react";
|
|
4
4
|
import { render, Box, Text, useInput, useApp } from "ink";
|
|
5
5
|
import SelectInput from "ink-select-input";
|
|
6
6
|
import gradient from "gradient-string";
|
|
@@ -9,57 +9,77 @@ import { spawn } from "child_process";
|
|
|
9
9
|
import { platform } from "os";
|
|
10
10
|
|
|
11
11
|
const coolGradient = gradient(["#00d4ff", "#7c3aed", "#f472b6"]);
|
|
12
|
+
const PROFILE_URL =
|
|
13
|
+
"https://raw.githubusercontent.com/nguyenvanduocit/nguyenvanduocit/master/profile.json";
|
|
14
|
+
|
|
15
|
+
// Default fallback data
|
|
16
|
+
const defaultProfile = {
|
|
17
|
+
name: "Duoc Nguyen",
|
|
18
|
+
tagline: "Pi-shaped engineer: Backend/AI tooling × AI agents/MCP × Product iteration",
|
|
19
|
+
stats: { years: 13, commits: "5K+", repos: 425, stars: "1.8K" },
|
|
20
|
+
bio: [
|
|
21
|
+
"I like building small tools that solve my own problems.",
|
|
22
|
+
"Currently tinkering with Clik (screenshot tool) and Just Read.",
|
|
23
|
+
"",
|
|
24
|
+
"Day job: Engineering Manager, helping teams ship good software.",
|
|
25
|
+
"Side quests: Open source, writing, and learning new things.",
|
|
26
|
+
"",
|
|
27
|
+
"All my side projects live at aiocean.io",
|
|
28
|
+
"I write at 12bit.vn (Vietnamese) and onepercent.plus (English)",
|
|
29
|
+
"",
|
|
30
|
+
"Helped start Vue.js Vietnam community back in 2016.",
|
|
31
|
+
"Still believe in sharing knowledge and helping others grow.",
|
|
32
|
+
],
|
|
33
|
+
projects: [
|
|
34
|
+
{
|
|
35
|
+
id: "jira-mcp",
|
|
36
|
+
title: "Jira MCP",
|
|
37
|
+
description: "AI-Jira bridge for Claude",
|
|
38
|
+
tech: "Go",
|
|
39
|
+
url: "https://github.com/nguyenvanduocit/jira-mcp",
|
|
40
|
+
stars: 79,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: "obsidian-open-gate",
|
|
44
|
+
title: "Obsidian Open Gate",
|
|
45
|
+
description: "Embed webpages in Obsidian",
|
|
46
|
+
tech: "TypeScript",
|
|
47
|
+
url: "https://github.com/nguyenvanduocit/obsidian-open-gate",
|
|
48
|
+
stars: 219,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: "duocnv",
|
|
52
|
+
title: "duocnv",
|
|
53
|
+
description: "This terminal card",
|
|
54
|
+
tech: "TypeScript",
|
|
55
|
+
url: "https://github.com/nguyenvanduocit/duocnv",
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
now: ["Building Claude plugin ecosystem", "Shipping interactive dev tools"],
|
|
59
|
+
links: {
|
|
60
|
+
github: "https://github.com/nguyenvanduocit",
|
|
61
|
+
twitter: "https://x.com/duocdev",
|
|
62
|
+
linkedin: "https://linkedin.com/in/duocnv",
|
|
63
|
+
blog: "https://12bit.vn",
|
|
64
|
+
website: "https://onepercent.plus",
|
|
65
|
+
},
|
|
66
|
+
};
|
|
12
67
|
|
|
13
68
|
// Terminal hyperlink (OSC 8)
|
|
14
69
|
const link = (url, text) => `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
|
|
15
70
|
|
|
16
|
-
// Open URL in browser
|
|
71
|
+
// Open URL in browser
|
|
17
72
|
const openBrowser = (url) => {
|
|
18
73
|
const cmd = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open";
|
|
19
74
|
const args = platform() === "win32" ? ["/c", "start", url] : [url];
|
|
20
75
|
spawn(cmd, args, { detached: true, stdio: "ignore" }).unref();
|
|
21
76
|
};
|
|
22
77
|
|
|
23
|
-
// Konami code
|
|
78
|
+
// Konami code
|
|
24
79
|
const KONAMI = ["up", "up", "down", "down", "left", "right", "left", "right", "b", "a"];
|
|
25
80
|
|
|
26
|
-
const links = {
|
|
27
|
-
github: "https://github.com/nguyenvanduocit",
|
|
28
|
-
twitter: "https://x.com/duocdev",
|
|
29
|
-
linkedin: "https://linkedin.com/in/duocnv",
|
|
30
|
-
blog: "https://12bit.vn",
|
|
31
|
-
website: "https://onepercent.plus",
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
const projectDetails = {
|
|
35
|
-
clik: {
|
|
36
|
-
title: "Clik - Screenshot Tool",
|
|
37
|
-
stats: [
|
|
38
|
-
"Annotate screenshots for AI prompts",
|
|
39
|
-
"Counter markers for referencing",
|
|
40
|
-
"Keyboard-first workflow",
|
|
41
|
-
],
|
|
42
|
-
tech: "Tauri + Rust + React",
|
|
43
|
-
url: "https://clik.aiocean.io",
|
|
44
|
-
},
|
|
45
|
-
justread: {
|
|
46
|
-
title: "Just Read - Reading Helper",
|
|
47
|
-
stats: ["Translation for English learners", "EPUB/PDF support", "Built for my own learning"],
|
|
48
|
-
tech: "TypeScript",
|
|
49
|
-
url: "https://aiocean.io",
|
|
50
|
-
},
|
|
51
|
-
obsidian: {
|
|
52
|
-
title: "Obsidian Open Gate",
|
|
53
|
-
stats: ["Embed webpages in Obsidian", "Simple plugin I made", "Open source"],
|
|
54
|
-
tech: "TypeScript, Obsidian API",
|
|
55
|
-
url: "https://github.com/nguyenvanduocit/obsidian-open-gate",
|
|
56
|
-
},
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
// Konami code hook
|
|
60
81
|
function useKonamiCode(onActivate) {
|
|
61
82
|
const sequence = useRef([]);
|
|
62
|
-
|
|
63
83
|
useInput((input, key) => {
|
|
64
84
|
let pressed = null;
|
|
65
85
|
if (key.upArrow) pressed = "up";
|
|
@@ -68,12 +88,9 @@ function useKonamiCode(onActivate) {
|
|
|
68
88
|
else if (key.rightArrow) pressed = "right";
|
|
69
89
|
else if (input === "b") pressed = "b";
|
|
70
90
|
else if (input === "a") pressed = "a";
|
|
71
|
-
|
|
72
91
|
if (pressed) {
|
|
73
92
|
sequence.current.push(pressed);
|
|
74
|
-
if (sequence.current.length > KONAMI.length)
|
|
75
|
-
sequence.current.shift();
|
|
76
|
-
}
|
|
93
|
+
if (sequence.current.length > KONAMI.length) sequence.current.shift();
|
|
77
94
|
if (sequence.current.join(",") === KONAMI.join(",")) {
|
|
78
95
|
sequence.current = [];
|
|
79
96
|
onActivate();
|
|
@@ -82,8 +99,9 @@ function useKonamiCode(onActivate) {
|
|
|
82
99
|
});
|
|
83
100
|
}
|
|
84
101
|
|
|
85
|
-
function Header() {
|
|
102
|
+
function Header({ profile }) {
|
|
86
103
|
const logoText = figlet.textSync("DUOC NV", { font: "ANSI Shadow", horizontalLayout: "fitted" });
|
|
104
|
+
const { stats, links, bio } = profile;
|
|
87
105
|
|
|
88
106
|
return (
|
|
89
107
|
<Box flexDirection="column">
|
|
@@ -94,22 +112,22 @@ function Header() {
|
|
|
94
112
|
<Text>
|
|
95
113
|
{" "}
|
|
96
114
|
<Text color="cyan" bold>
|
|
97
|
-
|
|
115
|
+
{stats.years}+
|
|
98
116
|
</Text>{" "}
|
|
99
117
|
<Text dimColor>years coding</Text>
|
|
100
118
|
<Text dimColor> • </Text>
|
|
101
119
|
<Text color="cyan" bold>
|
|
102
|
-
|
|
120
|
+
{stats.commits}
|
|
103
121
|
</Text>{" "}
|
|
104
122
|
<Text dimColor>commits</Text>
|
|
105
123
|
<Text dimColor> • </Text>
|
|
106
124
|
<Text color="cyan" bold>
|
|
107
|
-
|
|
125
|
+
{stats.repos}
|
|
108
126
|
</Text>{" "}
|
|
109
127
|
<Text dimColor>repositories</Text>
|
|
110
128
|
<Text dimColor> • </Text>
|
|
111
129
|
<Text color="cyan" bold>
|
|
112
|
-
|
|
130
|
+
{stats.stars}
|
|
113
131
|
</Text>{" "}
|
|
114
132
|
<Text dimColor>GitHub stars</Text>
|
|
115
133
|
</Text>
|
|
@@ -123,22 +141,17 @@ function Header() {
|
|
|
123
141
|
<Text> </Text>
|
|
124
142
|
<Text>
|
|
125
143
|
{" "}
|
|
126
|
-
<Text bold>Hi, I'm
|
|
144
|
+
<Text bold>Hi, I'm {profile.name}</Text>{" "}
|
|
127
145
|
<Text dimColor>- Engineering Manager • Curious Mind</Text>
|
|
128
146
|
</Text>
|
|
129
147
|
<Text> </Text>
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
<Text dimColor> All my side projects live at aiocean.io</Text>
|
|
138
|
-
<Text dimColor> I write at 12bit.vn (Vietnamese) and onepercent.plus (English)</Text>
|
|
139
|
-
<Text> </Text>
|
|
140
|
-
<Text dimColor> Helped start Vue.js Vietnam community back in 2016.</Text>
|
|
141
|
-
<Text dimColor> Still believe in sharing knowledge and helping others grow.</Text>
|
|
148
|
+
{bio &&
|
|
149
|
+
bio.map((line, i) => (
|
|
150
|
+
<Text key={`bio-${i}`} dimColor>
|
|
151
|
+
{" "}
|
|
152
|
+
{line}
|
|
153
|
+
</Text>
|
|
154
|
+
))}
|
|
142
155
|
<Text> </Text>
|
|
143
156
|
<Text>
|
|
144
157
|
{" "}
|
|
@@ -168,24 +181,17 @@ function EasterEgg({ onClose }) {
|
|
|
168
181
|
useInput((_, key) => {
|
|
169
182
|
if (key.escape || key.return) onClose();
|
|
170
183
|
});
|
|
171
|
-
|
|
172
184
|
const art = `
|
|
173
185
|
╔══════════════════════════════════════════╗
|
|
174
|
-
║ ║
|
|
175
186
|
║ 🎮 KONAMI CODE ACTIVATED! 🎮 ║
|
|
176
187
|
║ ║
|
|
177
|
-
║ You found the secret! ║
|
|
178
|
-
║ ║
|
|
179
188
|
║ Fun fact: I've been coding since ║
|
|
180
189
|
║ 2011 and still use "console.log" ║
|
|
181
|
-
║ for debugging.
|
|
182
|
-
║ change. 😄 ║
|
|
190
|
+
║ for debugging. 😄 ║
|
|
183
191
|
║ ║
|
|
184
192
|
║ Thanks for exploring my card! ║
|
|
185
|
-
║ ║
|
|
186
193
|
╚══════════════════════════════════════════╝
|
|
187
194
|
`;
|
|
188
|
-
|
|
189
195
|
return (
|
|
190
196
|
<Box flexDirection="column" marginLeft={2}>
|
|
191
197
|
<Text color="magenta">{art}</Text>
|
|
@@ -195,49 +201,45 @@ function EasterEgg({ onClose }) {
|
|
|
195
201
|
}
|
|
196
202
|
|
|
197
203
|
function ProjectDetail({ project, onBack }) {
|
|
198
|
-
const detail = projectDetails[project];
|
|
199
|
-
|
|
200
204
|
useInput((_, key) => {
|
|
201
205
|
if (key.escape) onBack();
|
|
202
|
-
if (key.return) openBrowser(
|
|
206
|
+
if (key.return) openBrowser(project.url);
|
|
203
207
|
});
|
|
204
208
|
|
|
205
209
|
return (
|
|
206
210
|
<Box flexDirection="column" marginLeft={2}>
|
|
207
211
|
<Text> </Text>
|
|
208
212
|
<Text color="cyan" bold>
|
|
209
|
-
{
|
|
213
|
+
{project.title}
|
|
210
214
|
</Text>
|
|
211
215
|
<Text> </Text>
|
|
212
|
-
|
|
213
|
-
|
|
216
|
+
<Text>
|
|
217
|
+
{" "}
|
|
218
|
+
• <Text color="yellow">{project.description}</Text>
|
|
219
|
+
</Text>
|
|
220
|
+
{project.stars && (
|
|
221
|
+
<Text>
|
|
214
222
|
{" "}
|
|
215
|
-
• <Text color="yellow">{
|
|
223
|
+
• <Text color="yellow">{project.stars} stars</Text>
|
|
216
224
|
</Text>
|
|
217
|
-
)
|
|
225
|
+
)}
|
|
218
226
|
<Text> </Text>
|
|
219
|
-
<Text dimColor> {
|
|
227
|
+
<Text dimColor> {project.tech}</Text>
|
|
220
228
|
<Text> </Text>
|
|
221
229
|
<Text>
|
|
222
230
|
{" "}
|
|
223
|
-
<Text color="cyan">{link(
|
|
231
|
+
<Text color="cyan">{link(project.url, project.url)}</Text>
|
|
224
232
|
</Text>
|
|
225
233
|
<HintBar hints="Enter to open in browser · Esc to back" />
|
|
226
234
|
</Box>
|
|
227
235
|
);
|
|
228
236
|
}
|
|
229
237
|
|
|
230
|
-
function LinkDetail({
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
useState(() => {
|
|
234
|
-
openBrowser(url);
|
|
235
|
-
});
|
|
236
|
-
|
|
238
|
+
function LinkDetail({ url, onBack }) {
|
|
239
|
+
useState(() => openBrowser(url));
|
|
237
240
|
useInput((_, key) => {
|
|
238
241
|
if (key.escape) onBack();
|
|
239
242
|
});
|
|
240
|
-
|
|
241
243
|
return (
|
|
242
244
|
<Box flexDirection="column" marginLeft={2}>
|
|
243
245
|
<Text> </Text>
|
|
@@ -254,11 +256,8 @@ function MainMenu({ onSelect, onEasterEgg }) {
|
|
|
254
256
|
{ label: "View projects", value: "projects", hint: "Things I've built" },
|
|
255
257
|
{ label: "Connect with me", value: "connect", hint: "Social links" },
|
|
256
258
|
];
|
|
257
|
-
|
|
258
259
|
const { exit } = useApp();
|
|
259
|
-
|
|
260
260
|
useKonamiCode(onEasterEgg);
|
|
261
|
-
|
|
262
261
|
useInput((_, key) => {
|
|
263
262
|
if (key.escape) exit();
|
|
264
263
|
});
|
|
@@ -280,12 +279,12 @@ function MainMenu({ onSelect, onEasterEgg }) {
|
|
|
280
279
|
);
|
|
281
280
|
}
|
|
282
281
|
|
|
283
|
-
function ProjectsMenu({ onSelect, onBack }) {
|
|
284
|
-
const items =
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
282
|
+
function ProjectsMenu({ projects, onSelect, onBack }) {
|
|
283
|
+
const items = projects.map((p) => ({
|
|
284
|
+
label: p.title,
|
|
285
|
+
value: p.id,
|
|
286
|
+
hint: p.description,
|
|
287
|
+
}));
|
|
289
288
|
|
|
290
289
|
useInput((_, key) => {
|
|
291
290
|
if (key.escape) onBack();
|
|
@@ -310,7 +309,7 @@ function ProjectsMenu({ onSelect, onBack }) {
|
|
|
310
309
|
);
|
|
311
310
|
}
|
|
312
311
|
|
|
313
|
-
function ConnectMenu({ onSelect, onBack }) {
|
|
312
|
+
function ConnectMenu({ links, onSelect, onBack }) {
|
|
314
313
|
const items = [
|
|
315
314
|
{ label: "GitHub", value: "github", hint: "@nguyenvanduocit" },
|
|
316
315
|
{ label: "X (Twitter)", value: "twitter", hint: "@duocdev" },
|
|
@@ -329,7 +328,7 @@ function ConnectMenu({ onSelect, onBack }) {
|
|
|
329
328
|
<Text> </Text>
|
|
330
329
|
<SelectInput
|
|
331
330
|
items={items}
|
|
332
|
-
onSelect={(item) => onSelect(item.value)}
|
|
331
|
+
onSelect={(item) => onSelect(links[item.value])}
|
|
333
332
|
itemComponent={({ isSelected, label, hint }) => (
|
|
334
333
|
<Text>
|
|
335
334
|
<Text color={isSelected ? "cyan" : undefined}>{label}</Text>
|
|
@@ -342,19 +341,38 @@ function ConnectMenu({ onSelect, onBack }) {
|
|
|
342
341
|
);
|
|
343
342
|
}
|
|
344
343
|
|
|
344
|
+
function Loading() {
|
|
345
|
+
return (
|
|
346
|
+
<Box flexDirection="column" padding={2}>
|
|
347
|
+
<Text color="cyan">Loading profile...</Text>
|
|
348
|
+
</Box>
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
345
352
|
function App() {
|
|
353
|
+
const [profile, setProfile] = useState(null);
|
|
346
354
|
const [screen, setScreen] = useState("main");
|
|
347
355
|
const [selectedProject, setSelectedProject] = useState(null);
|
|
348
|
-
const [
|
|
356
|
+
const [selectedLink, setSelectedLink] = useState(null);
|
|
349
357
|
const [showEasterEgg, setShowEasterEgg] = useState(false);
|
|
350
358
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
359
|
+
useEffect(() => {
|
|
360
|
+
fetch(PROFILE_URL)
|
|
361
|
+
.then((r) => r.json())
|
|
362
|
+
.then((data) => setProfile(data))
|
|
363
|
+
.catch(() => setProfile(defaultProfile));
|
|
364
|
+
}, []);
|
|
365
|
+
|
|
366
|
+
if (!profile) return <Loading />;
|
|
367
|
+
|
|
368
|
+
const handleProjectSelect = (id) => {
|
|
369
|
+
const project = profile.projects.find((p) => p.id === id);
|
|
370
|
+
setSelectedProject(project);
|
|
354
371
|
setScreen("project-detail");
|
|
355
372
|
};
|
|
356
|
-
|
|
357
|
-
|
|
373
|
+
|
|
374
|
+
const handleConnectSelect = (url) => {
|
|
375
|
+
setSelectedLink(url);
|
|
358
376
|
setScreen("connect-detail");
|
|
359
377
|
};
|
|
360
378
|
|
|
@@ -365,7 +383,7 @@ function App() {
|
|
|
365
383
|
if (showEasterEgg) {
|
|
366
384
|
return (
|
|
367
385
|
<Box flexDirection="column">
|
|
368
|
-
<Header />
|
|
386
|
+
<Header profile={profile} />
|
|
369
387
|
<EasterEgg onClose={() => setShowEasterEgg(false)} />
|
|
370
388
|
</Box>
|
|
371
389
|
);
|
|
@@ -373,18 +391,24 @@ function App() {
|
|
|
373
391
|
|
|
374
392
|
return (
|
|
375
393
|
<Box flexDirection="column">
|
|
376
|
-
<Header />
|
|
394
|
+
<Header profile={profile} />
|
|
377
395
|
{screen === "main" && (
|
|
378
|
-
<MainMenu onSelect={
|
|
396
|
+
<MainMenu onSelect={setScreen} onEasterEgg={() => setShowEasterEgg(true)} />
|
|
397
|
+
)}
|
|
398
|
+
{screen === "projects" && (
|
|
399
|
+
<ProjectsMenu
|
|
400
|
+
projects={profile.projects}
|
|
401
|
+
onSelect={handleProjectSelect}
|
|
402
|
+
onBack={goToMain}
|
|
403
|
+
/>
|
|
404
|
+
)}
|
|
405
|
+
{screen === "connect" && (
|
|
406
|
+
<ConnectMenu links={profile.links} onSelect={handleConnectSelect} onBack={goToMain} />
|
|
379
407
|
)}
|
|
380
|
-
{screen === "projects" && <ProjectsMenu onSelect={handleProjectSelect} onBack={goToMain} />}
|
|
381
|
-
{screen === "connect" && <ConnectMenu onSelect={handleConnectSelect} onBack={goToMain} />}
|
|
382
408
|
{screen === "project-detail" && (
|
|
383
409
|
<ProjectDetail project={selectedProject} onBack={goToProjects} />
|
|
384
410
|
)}
|
|
385
|
-
{screen === "connect-detail" &&
|
|
386
|
-
<LinkDetail platform={selectedPlatform} onBack={goToConnect} />
|
|
387
|
-
)}
|
|
411
|
+
{screen === "connect-detail" && <LinkDetail url={selectedLink} onBack={goToConnect} />}
|
|
388
412
|
</Box>
|
|
389
413
|
);
|
|
390
414
|
}
|