duocnv 1.0.2 → 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 +137 -118
- 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,34 +99,35 @@ 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">
|
|
90
108
|
<Text> </Text>
|
|
91
109
|
<Text>{coolGradient(logoText)}</Text>
|
|
92
|
-
<Text dimColor>
|
|
110
|
+
<Text dimColor> From Vietnam</Text>
|
|
93
111
|
<Text> </Text>
|
|
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
|
|
127
|
-
<Text dimColor>-
|
|
144
|
+
<Text bold>Hi, I'm {profile.name}</Text>{" "}
|
|
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
|
{" "}
|
|
@@ -150,11 +163,6 @@ function Header() {
|
|
|
150
163
|
<Text color="cyan">→</Text> <Text dimColor>Twitter:</Text>{" "}
|
|
151
164
|
<Text color="cyan">{link(links.twitter, "@duocdev")}</Text>
|
|
152
165
|
</Text>
|
|
153
|
-
<Text>
|
|
154
|
-
{" "}
|
|
155
|
-
<Text color="cyan">→</Text> <Text dimColor>Blog:</Text>{" "}
|
|
156
|
-
<Text color="cyan">{link(links.blog, "12bit.vn")}</Text>
|
|
157
|
-
</Text>
|
|
158
166
|
<Text> </Text>
|
|
159
167
|
</Box>
|
|
160
168
|
);
|
|
@@ -173,24 +181,17 @@ function EasterEgg({ onClose }) {
|
|
|
173
181
|
useInput((_, key) => {
|
|
174
182
|
if (key.escape || key.return) onClose();
|
|
175
183
|
});
|
|
176
|
-
|
|
177
184
|
const art = `
|
|
178
185
|
╔══════════════════════════════════════════╗
|
|
179
|
-
║ ║
|
|
180
186
|
║ 🎮 KONAMI CODE ACTIVATED! 🎮 ║
|
|
181
187
|
║ ║
|
|
182
|
-
║ You found the secret! ║
|
|
183
|
-
║ ║
|
|
184
188
|
║ Fun fact: I've been coding since ║
|
|
185
189
|
║ 2011 and still use "console.log" ║
|
|
186
|
-
║ for debugging.
|
|
187
|
-
║ change. 😄 ║
|
|
190
|
+
║ for debugging. 😄 ║
|
|
188
191
|
║ ║
|
|
189
192
|
║ Thanks for exploring my card! ║
|
|
190
|
-
║ ║
|
|
191
193
|
╚══════════════════════════════════════════╝
|
|
192
194
|
`;
|
|
193
|
-
|
|
194
195
|
return (
|
|
195
196
|
<Box flexDirection="column" marginLeft={2}>
|
|
196
197
|
<Text color="magenta">{art}</Text>
|
|
@@ -200,49 +201,45 @@ function EasterEgg({ onClose }) {
|
|
|
200
201
|
}
|
|
201
202
|
|
|
202
203
|
function ProjectDetail({ project, onBack }) {
|
|
203
|
-
const detail = projectDetails[project];
|
|
204
|
-
|
|
205
204
|
useInput((_, key) => {
|
|
206
205
|
if (key.escape) onBack();
|
|
207
|
-
if (key.return) openBrowser(
|
|
206
|
+
if (key.return) openBrowser(project.url);
|
|
208
207
|
});
|
|
209
208
|
|
|
210
209
|
return (
|
|
211
210
|
<Box flexDirection="column" marginLeft={2}>
|
|
212
211
|
<Text> </Text>
|
|
213
212
|
<Text color="cyan" bold>
|
|
214
|
-
{
|
|
213
|
+
{project.title}
|
|
215
214
|
</Text>
|
|
216
215
|
<Text> </Text>
|
|
217
|
-
|
|
218
|
-
|
|
216
|
+
<Text>
|
|
217
|
+
{" "}
|
|
218
|
+
• <Text color="yellow">{project.description}</Text>
|
|
219
|
+
</Text>
|
|
220
|
+
{project.stars && (
|
|
221
|
+
<Text>
|
|
219
222
|
{" "}
|
|
220
|
-
• <Text color="yellow">{
|
|
223
|
+
• <Text color="yellow">{project.stars} stars</Text>
|
|
221
224
|
</Text>
|
|
222
|
-
)
|
|
225
|
+
)}
|
|
223
226
|
<Text> </Text>
|
|
224
|
-
<Text dimColor> {
|
|
227
|
+
<Text dimColor> {project.tech}</Text>
|
|
225
228
|
<Text> </Text>
|
|
226
229
|
<Text>
|
|
227
230
|
{" "}
|
|
228
|
-
<Text color="cyan">{link(
|
|
231
|
+
<Text color="cyan">{link(project.url, project.url)}</Text>
|
|
229
232
|
</Text>
|
|
230
233
|
<HintBar hints="Enter to open in browser · Esc to back" />
|
|
231
234
|
</Box>
|
|
232
235
|
);
|
|
233
236
|
}
|
|
234
237
|
|
|
235
|
-
function LinkDetail({
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
useState(() => {
|
|
239
|
-
openBrowser(url);
|
|
240
|
-
});
|
|
241
|
-
|
|
238
|
+
function LinkDetail({ url, onBack }) {
|
|
239
|
+
useState(() => openBrowser(url));
|
|
242
240
|
useInput((_, key) => {
|
|
243
241
|
if (key.escape) onBack();
|
|
244
242
|
});
|
|
245
|
-
|
|
246
243
|
return (
|
|
247
244
|
<Box flexDirection="column" marginLeft={2}>
|
|
248
245
|
<Text> </Text>
|
|
@@ -259,11 +256,8 @@ function MainMenu({ onSelect, onEasterEgg }) {
|
|
|
259
256
|
{ label: "View projects", value: "projects", hint: "Things I've built" },
|
|
260
257
|
{ label: "Connect with me", value: "connect", hint: "Social links" },
|
|
261
258
|
];
|
|
262
|
-
|
|
263
259
|
const { exit } = useApp();
|
|
264
|
-
|
|
265
260
|
useKonamiCode(onEasterEgg);
|
|
266
|
-
|
|
267
261
|
useInput((_, key) => {
|
|
268
262
|
if (key.escape) exit();
|
|
269
263
|
});
|
|
@@ -285,12 +279,12 @@ function MainMenu({ onSelect, onEasterEgg }) {
|
|
|
285
279
|
);
|
|
286
280
|
}
|
|
287
281
|
|
|
288
|
-
function ProjectsMenu({ onSelect, onBack }) {
|
|
289
|
-
const items =
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
+
}));
|
|
294
288
|
|
|
295
289
|
useInput((_, key) => {
|
|
296
290
|
if (key.escape) onBack();
|
|
@@ -315,7 +309,7 @@ function ProjectsMenu({ onSelect, onBack }) {
|
|
|
315
309
|
);
|
|
316
310
|
}
|
|
317
311
|
|
|
318
|
-
function ConnectMenu({ onSelect, onBack }) {
|
|
312
|
+
function ConnectMenu({ links, onSelect, onBack }) {
|
|
319
313
|
const items = [
|
|
320
314
|
{ label: "GitHub", value: "github", hint: "@nguyenvanduocit" },
|
|
321
315
|
{ label: "X (Twitter)", value: "twitter", hint: "@duocdev" },
|
|
@@ -334,7 +328,7 @@ function ConnectMenu({ onSelect, onBack }) {
|
|
|
334
328
|
<Text> </Text>
|
|
335
329
|
<SelectInput
|
|
336
330
|
items={items}
|
|
337
|
-
onSelect={(item) => onSelect(item.value)}
|
|
331
|
+
onSelect={(item) => onSelect(links[item.value])}
|
|
338
332
|
itemComponent={({ isSelected, label, hint }) => (
|
|
339
333
|
<Text>
|
|
340
334
|
<Text color={isSelected ? "cyan" : undefined}>{label}</Text>
|
|
@@ -347,19 +341,38 @@ function ConnectMenu({ onSelect, onBack }) {
|
|
|
347
341
|
);
|
|
348
342
|
}
|
|
349
343
|
|
|
344
|
+
function Loading() {
|
|
345
|
+
return (
|
|
346
|
+
<Box flexDirection="column" padding={2}>
|
|
347
|
+
<Text color="cyan">Loading profile...</Text>
|
|
348
|
+
</Box>
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
350
352
|
function App() {
|
|
353
|
+
const [profile, setProfile] = useState(null);
|
|
351
354
|
const [screen, setScreen] = useState("main");
|
|
352
355
|
const [selectedProject, setSelectedProject] = useState(null);
|
|
353
|
-
const [
|
|
356
|
+
const [selectedLink, setSelectedLink] = useState(null);
|
|
354
357
|
const [showEasterEgg, setShowEasterEgg] = useState(false);
|
|
355
358
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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);
|
|
359
371
|
setScreen("project-detail");
|
|
360
372
|
};
|
|
361
|
-
|
|
362
|
-
|
|
373
|
+
|
|
374
|
+
const handleConnectSelect = (url) => {
|
|
375
|
+
setSelectedLink(url);
|
|
363
376
|
setScreen("connect-detail");
|
|
364
377
|
};
|
|
365
378
|
|
|
@@ -370,7 +383,7 @@ function App() {
|
|
|
370
383
|
if (showEasterEgg) {
|
|
371
384
|
return (
|
|
372
385
|
<Box flexDirection="column">
|
|
373
|
-
<Header />
|
|
386
|
+
<Header profile={profile} />
|
|
374
387
|
<EasterEgg onClose={() => setShowEasterEgg(false)} />
|
|
375
388
|
</Box>
|
|
376
389
|
);
|
|
@@ -378,18 +391,24 @@ function App() {
|
|
|
378
391
|
|
|
379
392
|
return (
|
|
380
393
|
<Box flexDirection="column">
|
|
381
|
-
<Header />
|
|
394
|
+
<Header profile={profile} />
|
|
382
395
|
{screen === "main" && (
|
|
383
|
-
<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} />
|
|
384
407
|
)}
|
|
385
|
-
{screen === "projects" && <ProjectsMenu onSelect={handleProjectSelect} onBack={goToMain} />}
|
|
386
|
-
{screen === "connect" && <ConnectMenu onSelect={handleConnectSelect} onBack={goToMain} />}
|
|
387
408
|
{screen === "project-detail" && (
|
|
388
409
|
<ProjectDetail project={selectedProject} onBack={goToProjects} />
|
|
389
410
|
)}
|
|
390
|
-
{screen === "connect-detail" &&
|
|
391
|
-
<LinkDetail platform={selectedPlatform} onBack={goToConnect} />
|
|
392
|
-
)}
|
|
411
|
+
{screen === "connect-detail" && <LinkDetail url={selectedLink} onBack={goToConnect} />}
|
|
393
412
|
</Box>
|
|
394
413
|
);
|
|
395
414
|
}
|