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.
Files changed (2) hide show
  1. package/index.js +135 -111
  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,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
- 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>{" "}
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
- <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
  {" "}
@@ -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. Some things never
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(detail.url);
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
- {detail.title}
213
+ {project.title}
210
214
  </Text>
211
215
  <Text> </Text>
212
- {detail.stats.map((stat, i) => (
213
- <Text key={i}>
216
+ <Text>
217
+ {" "}
218
+ • <Text color="yellow">{project.description}</Text>
219
+ </Text>
220
+ {project.stars && (
221
+ <Text>
214
222
  {" "}
215
- • <Text color="yellow">{stat}</Text>
223
+ • <Text color="yellow">{project.stars} stars</Text>
216
224
  </Text>
217
- ))}
225
+ )}
218
226
  <Text> </Text>
219
- <Text dimColor> {detail.tech}</Text>
227
+ <Text dimColor> {project.tech}</Text>
220
228
  <Text> </Text>
221
229
  <Text>
222
230
  {" "}
223
- <Text color="cyan">{link(detail.url, detail.url)}</Text>
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({ platform: plat, onBack }) {
231
- const url = links[plat];
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
- { label: "Clik", value: "clik", hint: "Screenshot annotations" },
286
- { label: "Just Read", value: "justread", hint: "Reading helper for learners" },
287
- { label: "Obsidian Open Gate", value: "obsidian", hint: "Embed webpages in Obsidian" },
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 [selectedPlatform, setSelectedPlatform] = useState(null);
356
+ const [selectedLink, setSelectedLink] = useState(null);
349
357
  const [showEasterEgg, setShowEasterEgg] = useState(false);
350
358
 
351
- const handleMainSelect = (value) => setScreen(value);
352
- const handleProjectSelect = (value) => {
353
- 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);
354
371
  setScreen("project-detail");
355
372
  };
356
- const handleConnectSelect = (value) => {
357
- setSelectedPlatform(value);
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={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} />
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "duocnv",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "Terminal business card for Được Nguyễn - Tech Ecosystem Builder",
5
5
  "keywords": [
6
6
  "bunx",