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.
Files changed (2) hide show
  1. package/index.js +137 -118
  2. 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 safely using spawn
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 sequence
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> Engineering Manager • Curious Mind</Text>
110
+ <Text dimColor> From Vietnam</Text>
93
111
  <Text> </Text>
94
112
  <Text>
95
113
  {" "}
96
114
  <Text color="cyan" bold>
97
- 13+
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
- 5K+
120
+ {stats.commits}
103
121
  </Text>{" "}
104
122
  <Text dimColor>commits</Text>
105
123
  <Text dimColor> • </Text>
106
124
  <Text color="cyan" bold>
107
- 425
125
+ {stats.repos}
108
126
  </Text>{" "}
109
127
  <Text dimColor>repositories</Text>
110
128
  <Text dimColor> • </Text>
111
129
  <Text color="cyan" bold>
112
- 1.8K
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 Được Nguyễn</Text>{" "}
127
- <Text dimColor>- a curious developer from Vietnam</Text>
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
- <Text dimColor> I like building small tools that solve my own problems.</Text>
131
- <Text dimColor> Currently tinkering with Clik (screenshot tool) and Just Read</Text>
132
- <Text dimColor> (reading helper) in my spare time.</Text>
133
- <Text> </Text>
134
- <Text dimColor> Day job: Engineering Manager, helping teams ship good software.</Text>
135
- <Text dimColor> Side quests: Open source, writing, and learning new things.</Text>
136
- <Text> </Text>
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. Some things never
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(detail.url);
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
- {detail.title}
213
+ {project.title}
215
214
  </Text>
216
215
  <Text> </Text>
217
- {detail.stats.map((stat, i) => (
218
- <Text key={i}>
216
+ <Text>
217
+ {" "}
218
+ • <Text color="yellow">{project.description}</Text>
219
+ </Text>
220
+ {project.stars && (
221
+ <Text>
219
222
  {" "}
220
- • <Text color="yellow">{stat}</Text>
223
+ • <Text color="yellow">{project.stars} stars</Text>
221
224
  </Text>
222
- ))}
225
+ )}
223
226
  <Text> </Text>
224
- <Text dimColor> {detail.tech}</Text>
227
+ <Text dimColor> {project.tech}</Text>
225
228
  <Text> </Text>
226
229
  <Text>
227
230
  {" "}
228
- <Text color="cyan">{link(detail.url, detail.url)}</Text>
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({ platform: plat, onBack }) {
236
- const url = links[plat];
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
- { label: "Clik", value: "clik", hint: "Screenshot annotations" },
291
- { label: "Just Read", value: "justread", hint: "Reading helper for learners" },
292
- { label: "Obsidian Open Gate", value: "obsidian", hint: "Embed webpages in Obsidian" },
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 [selectedPlatform, setSelectedPlatform] = useState(null);
356
+ const [selectedLink, setSelectedLink] = useState(null);
354
357
  const [showEasterEgg, setShowEasterEgg] = useState(false);
355
358
 
356
- const handleMainSelect = (value) => setScreen(value);
357
- const handleProjectSelect = (value) => {
358
- setSelectedProject(value);
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
- const handleConnectSelect = (value) => {
362
- setSelectedPlatform(value);
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={handleMainSelect} onEasterEgg={() => setShowEasterEgg(true)} />
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "duocnv",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Terminal business card for Được Nguyễn - Tech Ecosystem Builder",
5
5
  "keywords": [
6
6
  "bunx",