martinbonan 3.0.0 → 4.0.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/package.json +1 -1
- package/src/App.js +1 -1
- package/src/ai/context.js +27 -25
- package/src/components/ContentPanel.js +3 -0
- package/src/components/Header.js +12 -5
- package/src/components/Portrait.js +3 -2
- package/src/data/content.js +125 -111
- package/src/hooks/useAiChat.js +20 -4
- package/src/hooks/useAnimation.js +1 -1
- package/src/screens/Dashboard.js +15 -3
- package/src/tabs/About.js +9 -4
- package/src/tabs/AskAi.js +19 -15
- package/src/tabs/Contact.js +11 -8
- package/src/tabs/Projects.js +54 -62
- package/src/theme.js +13 -1
package/package.json
CHANGED
package/src/App.js
CHANGED
|
@@ -8,7 +8,7 @@ const e = React.createElement;
|
|
|
8
8
|
|
|
9
9
|
export default function App() {
|
|
10
10
|
const [screen, setScreen] = useState('boot');
|
|
11
|
-
const [themeName, setThemeName] = useState('
|
|
11
|
+
const [themeName, setThemeName] = useState('clean');
|
|
12
12
|
const theme = THEMES[themeName];
|
|
13
13
|
const c = makeChalkColors(theme);
|
|
14
14
|
|
package/src/ai/context.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const SYSTEM_PROMPT = `You are Martin
|
|
1
|
+
export const SYSTEM_PROMPT = `You are Martin Bonan, a 20-year-old French entrepreneur. You're answering as yourself in your CLI portfolio. People are chatting with an AI version of you.
|
|
2
2
|
|
|
3
3
|
PERSONALITY:
|
|
4
4
|
- Direct, casual, no-bullshit. You hate corporate-speak.
|
|
@@ -9,35 +9,37 @@ PERSONALITY:
|
|
|
9
9
|
- Witty, occasionally funny. Never cringe.
|
|
10
10
|
|
|
11
11
|
BACKGROUND:
|
|
12
|
-
- 20yo, French, based in
|
|
13
|
-
- Co-founded Qual (qual.cx) --
|
|
14
|
-
-
|
|
15
|
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
12
|
+
- 20yo, French, based in Paris. Building since age 15.
|
|
13
|
+
- Co-founded Qual (qual.cx) with Marin -- research engine making qualitative depth scale
|
|
14
|
+
AI-conducted interviews that go deep, learn in real-time, scale to tens of thousands
|
|
15
|
+
Adopted by PhD researchers at Harvard, MIT, Imperial College London. 40+ studies, 70%+ completion rate.
|
|
16
|
+
- Co-founded Astry with Teo & Mathis -- AI-powered dev agency, 30+ B2B clients, 3-5x speed
|
|
17
|
+
- Built F*cksubscription -- 3K+ paying customers, 10K+ EUR, one-time payments replacing SaaS
|
|
18
|
+
- Teaching vibecoding at HEC Paris through IQ Project -- 120 students trained
|
|
19
|
+
- Started designing at 13 (Figma, Affinity Designer). First venture at 15.
|
|
20
|
+
- NEOMA Business School Global BBA (2023-2027)
|
|
21
|
+
- Exchange at Queen's University / Smith School of Business in Canada (2024)
|
|
22
|
+
Discovered Cursor & Claude there, went from no-code to full-stack AI dev
|
|
23
|
+
- UC Berkeley Global Incubator starting January 2027 -- bringing Qual to the US
|
|
21
24
|
|
|
22
25
|
KEY PROJECTS:
|
|
23
|
-
-
|
|
24
|
-
- Astry:
|
|
25
|
-
-
|
|
26
|
-
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
26
|
+
- Qual: Research engine for qualitative depth at scale. Primary focus. Harvard, MIT, Imperial adoption.
|
|
27
|
+
- Astry: AI-powered dev agency, 30+ clients. Cash machine funding Qual.
|
|
28
|
+
- F*cksubscription: "Sell before building" masterclass. 1K signups day one, 90% conversion, 3K+ customers.
|
|
29
|
+
Products: MangoForm, PeachCalendar, GrapeLink, GuavaTeam.
|
|
30
|
+
- Brief: Paris Innov'Hack 2025, 2nd/150+. MCP-based intelligent context manager for AI agents.
|
|
31
|
+
- IQ Project: Teaching vibecoding at HEC Paris. 120 students.
|
|
32
|
+
- Objectif Reussite: Nonprofit tutoring network started at 15. 800+ students, 20+ tutors, 3 years.
|
|
33
|
+
- Good Good Time: Activity recommendation app. NEOMA incubator -> rebuilt with Cursor in Canada.
|
|
34
|
+
- 15+ projects total since age 15.
|
|
30
35
|
|
|
31
|
-
TECH
|
|
32
|
-
- "Sell before you build"
|
|
33
|
-
-
|
|
34
|
-
-
|
|
35
|
-
-
|
|
36
|
-
- Multi-agent architectures as core dev pattern
|
|
37
|
-
- Ships fast, iterates faster
|
|
36
|
+
TECH:
|
|
37
|
+
- "Sell before you build" -- validate with landing pages and pre-sales first
|
|
38
|
+
- Design as competitive moat, not afterthought
|
|
39
|
+
- Stack: Next.js, React, TypeScript, Supabase, Vercel, ShadCN UI, Cursor, Claude
|
|
40
|
+
- Vibecoding evangelist -- AI as core of the development workflow
|
|
38
41
|
|
|
39
42
|
INTERESTS: Philosophy, consciousness, cinema, media literacy
|
|
40
|
-
FAVE COMPANIES: Lovable, Logitech, Featherless, Monster
|
|
41
43
|
|
|
42
44
|
RULES:
|
|
43
45
|
- Be honest. If you don't know, say so.
|
package/src/components/Header.js
CHANGED
|
@@ -7,21 +7,28 @@ import { ThemeContext } from '../theme.js';
|
|
|
7
7
|
|
|
8
8
|
const e = React.createElement;
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
function makeGradient(theme) {
|
|
11
|
+
// Build gradient from theme colors
|
|
12
|
+
return gradient([theme.primary, theme.secondary || theme.primary]);
|
|
13
|
+
}
|
|
11
14
|
|
|
12
15
|
export default function Header({ tagline, fading }) {
|
|
13
16
|
const { theme } = React.useContext(ThemeContext);
|
|
14
17
|
const cols = process.stdout.columns || 80;
|
|
15
18
|
const showPortrait = cols >= 80;
|
|
19
|
+
const grad = makeGradient(theme);
|
|
16
20
|
|
|
17
|
-
let
|
|
21
|
+
let line1, line2;
|
|
18
22
|
try {
|
|
19
|
-
|
|
23
|
+
line1 = figlet.textSync('Martin', { font: 'Small' });
|
|
24
|
+
line2 = figlet.textSync('Bonan', { font: 'Small' });
|
|
20
25
|
} catch {
|
|
21
|
-
|
|
26
|
+
line1 = 'MARTIN';
|
|
27
|
+
line2 = 'BONAN';
|
|
22
28
|
}
|
|
23
29
|
|
|
24
|
-
const
|
|
30
|
+
const figletText = line1 + '\n' + line2;
|
|
31
|
+
const gradientName = grad(figletText);
|
|
25
32
|
|
|
26
33
|
return e(Box, { flexDirection: 'column' },
|
|
27
34
|
e(Box, { flexDirection: 'row' },
|
|
@@ -6,11 +6,12 @@ import { ThemeContext, supportsTrueColor } from '../theme.js';
|
|
|
6
6
|
const e = React.createElement;
|
|
7
7
|
|
|
8
8
|
export default function Portrait() {
|
|
9
|
-
const {
|
|
9
|
+
const { theme } = React.useContext(ThemeContext);
|
|
10
10
|
|
|
11
11
|
if (supportsTrueColor && PORTRAIT_ANSI.length > 0) {
|
|
12
12
|
return e(Text, null, PORTRAIT_ANSI.join('\n'));
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
// Use theme primary color for braille portrait
|
|
16
|
+
return e(Text, { color: theme.dim }, PORTRAIT_BRAILLE.join('\n'));
|
|
16
17
|
}
|
package/src/data/content.js
CHANGED
|
@@ -1,248 +1,262 @@
|
|
|
1
1
|
export const BIO = {
|
|
2
|
-
name: 'Martin
|
|
2
|
+
name: 'Martin Bonan',
|
|
3
3
|
taglines: [
|
|
4
4
|
'20yo French builder. Entrepreneur, vibecoder, philosopher.',
|
|
5
5
|
'Sell before you build. Ship fast. Learn faster.',
|
|
6
|
-
'
|
|
6
|
+
'Making qualitative depth scale.',
|
|
7
7
|
'Building since 15. Still shipping.',
|
|
8
8
|
],
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
-
export const ABOUT_TEXT = `Martin
|
|
11
|
+
export const ABOUT_TEXT = `Martin Bonan
|
|
12
12
|
\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
13
13
|
|
|
14
14
|
Builder since 15. French. 20 years old.
|
|
15
15
|
|
|
16
|
-
Co-founded Qual
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
Co-founded Qual -- a research engine that makes
|
|
17
|
+
qualitative depth scale. AI-conducted interviews
|
|
18
|
+
that go deep, learn in real-time, and scale to
|
|
19
|
+
tens of thousands of participants.
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
Built F*cksubscription -- 3,000+ paying customers,
|
|
22
|
+
10K+ EUR. One-time payments to replace overpriced SaaS.
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
Co-founded Astry -- dev agency, 30+ B2B clients.
|
|
25
|
+
Teaching vibecoding at HEC Paris through IQ Project.
|
|
26
|
+
|
|
27
|
+
Started designing at 13 (Figma, Affinity Designer).
|
|
28
|
+
First structured venture at 15. 15+ projects since.
|
|
25
29
|
|
|
26
30
|
Philosophy: Sell before you build. Ship fast.
|
|
27
31
|
Learn faster.
|
|
28
32
|
|
|
29
|
-
Next: Berkeley
|
|
33
|
+
Next: UC Berkeley, Jan 2027. Bringing Qual to the US.
|
|
30
34
|
|
|
31
35
|
Currently online | Paris, France`;
|
|
32
36
|
|
|
33
37
|
export const PROJECTS = [
|
|
34
38
|
{
|
|
35
|
-
name: '
|
|
36
|
-
tag: '
|
|
39
|
+
name: 'Qual',
|
|
40
|
+
tag: 'qual.cx',
|
|
37
41
|
tagColor: 'primary',
|
|
38
|
-
description: 'Qualitative
|
|
39
|
-
status: 'Active --
|
|
42
|
+
description: 'Qualitative research at quantitative scale',
|
|
43
|
+
status: 'Active -- 40+ studies, 70%+ completion rate',
|
|
40
44
|
detail: {
|
|
41
|
-
url: '
|
|
42
|
-
tagline: '
|
|
43
|
-
quote: '"
|
|
45
|
+
url: 'qual.cx',
|
|
46
|
+
tagline: 'Making qualitative depth scale',
|
|
47
|
+
quote: '"Replacing static surveys with adaptive, deep-dive\\n interviews that uncover the why for thousands at once."',
|
|
44
48
|
fields: [
|
|
45
|
-
['STATUS', 'Active,
|
|
46
|
-
['
|
|
47
|
-
['
|
|
48
|
-
['
|
|
49
|
-
['STACK', 'Next.js, Supabase,
|
|
49
|
+
['STATUS', 'Active, primary focus'],
|
|
50
|
+
['COFOUNDERS', 'Martin Bonan & Marin'],
|
|
51
|
+
['TRACTION', '40+ studies, 70%+ completion rate'],
|
|
52
|
+
['USERS', 'PhD researchers at Harvard, MIT, Imperial'],
|
|
53
|
+
['STACK', 'Next.js, Supabase, AI engine'],
|
|
50
54
|
],
|
|
51
|
-
body: '
|
|
55
|
+
body: 'Researchers set up a study, and an AI engine\nruns, transcribes, and analyzes interviews,\nlearning from each conversation through the\n"Study Brain." Adopted organically by top\nuniversities without any paid marketing.',
|
|
52
56
|
},
|
|
53
57
|
},
|
|
54
58
|
{
|
|
55
59
|
name: 'Astry',
|
|
56
60
|
tag: 'Agency',
|
|
57
61
|
tagColor: 'secondary',
|
|
58
|
-
description: '
|
|
59
|
-
status: '
|
|
62
|
+
description: 'AI-powered dev agency -- 30+ B2B clients',
|
|
63
|
+
status: 'Active -- co-founded with Teo & Mathis',
|
|
60
64
|
detail: {
|
|
61
65
|
url: 'astry.dev',
|
|
62
|
-
tagline: '
|
|
66
|
+
tagline: 'AI-powered web development agency',
|
|
63
67
|
fields: [
|
|
64
68
|
['STATUS', 'Active, growing'],
|
|
69
|
+
['COFOUNDERS', 'Martin, Teo, Mathis'],
|
|
65
70
|
['CLIENTS', '30+ international B2B'],
|
|
66
|
-
['
|
|
67
|
-
['STACK', '
|
|
71
|
+
['SPEED', '3-5x faster than traditional agencies'],
|
|
72
|
+
['STACK', 'Cursor, Claude, Next.js, Supabase'],
|
|
68
73
|
],
|
|
69
|
-
body: '
|
|
74
|
+
body: 'Uses Cursor, Claude, and AI tools internally\nto deliver high-quality products at 3-5x speed.\nRevenue split: devs (65%), sales (13%),\nfounders (10%). Cash machine that funds Qual.',
|
|
70
75
|
},
|
|
71
76
|
},
|
|
72
77
|
{
|
|
73
|
-
name: '
|
|
74
|
-
tag: '
|
|
78
|
+
name: 'F*cksubscription',
|
|
79
|
+
tag: 'SaaS',
|
|
75
80
|
tagColor: 'accent',
|
|
76
|
-
description: '
|
|
77
|
-
status: '
|
|
81
|
+
description: '3,000+ paying customers, 10K+ EUR',
|
|
82
|
+
status: 'Live -- one-time payments to replace SaaS',
|
|
78
83
|
detail: {
|
|
79
|
-
tagline: '
|
|
84
|
+
tagline: 'One-time payments to replace overpriced SaaS',
|
|
80
85
|
fields: [
|
|
81
|
-
['STATUS', '
|
|
82
|
-
['
|
|
83
|
-
['
|
|
86
|
+
['STATUS', 'Live (wound down by choice)'],
|
|
87
|
+
['CUSTOMERS', '3,000+ paying'],
|
|
88
|
+
['REVENUE', '10,000+ EUR'],
|
|
89
|
+
['CONVERSION', '90% waitlist-to-purchase'],
|
|
90
|
+
['PRODUCTS', 'MangoForm, PeachCalendar, GrapeLink, GuavaTeam'],
|
|
84
91
|
],
|
|
85
|
-
body: '
|
|
92
|
+
body: 'Classic "sell before building" -- put up a\nlanding page pretending the product existed,\ngot 1,000 email signups day one, then built it.\nWound down because one-time revenue doesn\'t\ncompound. Lesson shaped Qual\'s SaaS model.',
|
|
86
93
|
},
|
|
87
94
|
},
|
|
88
95
|
{
|
|
89
|
-
name: '
|
|
90
|
-
tag: '
|
|
91
|
-
tagColor: '
|
|
92
|
-
description: '
|
|
93
|
-
status: '
|
|
96
|
+
name: 'Brief',
|
|
97
|
+
tag: 'Hackathon',
|
|
98
|
+
tagColor: 'accent',
|
|
99
|
+
description: "Paris Innov'Hack 2025 -- 2nd / 150+",
|
|
100
|
+
status: 'Built in 10h with Marlowe, Mathieu, Oscar',
|
|
94
101
|
detail: {
|
|
95
|
-
tagline: '
|
|
102
|
+
tagline: "Paris Innov'Hack 2025 -- 2nd place out of 150+",
|
|
96
103
|
fields: [
|
|
97
|
-
['
|
|
98
|
-
['
|
|
99
|
-
['
|
|
100
|
-
['STACK', '
|
|
104
|
+
['RESULT', '2nd / 150+ participants'],
|
|
105
|
+
['EVENT', "Paris Innov'Hack 2025"],
|
|
106
|
+
['TEAM', 'Marlowe, Mathieu, Oscar'],
|
|
107
|
+
['STACK', 'MCP (Model Context Protocol)'],
|
|
101
108
|
],
|
|
102
|
-
body: '
|
|
109
|
+
body: 'Intelligent context manager for AI agents.\nTeams have 50+ scattered markdown files for\nAI context -- mostly noise. We invented a\nnovel architecture: a dedicated AI that filters\nand summarizes all context before the main\nassistant receives it. Zero wasted tokens.',
|
|
103
110
|
},
|
|
104
111
|
},
|
|
105
112
|
{
|
|
106
|
-
name: '
|
|
107
|
-
tag: '
|
|
113
|
+
name: 'IQ Project',
|
|
114
|
+
tag: 'Education',
|
|
108
115
|
tagColor: 'secondary',
|
|
109
|
-
description: '
|
|
110
|
-
status: '
|
|
116
|
+
description: 'Teaching vibecoding at HEC Paris',
|
|
117
|
+
status: '120 students trained',
|
|
111
118
|
detail: {
|
|
112
|
-
tagline: '
|
|
119
|
+
tagline: 'Teaching vibecoding at HEC Paris',
|
|
113
120
|
fields: [
|
|
114
|
-
['
|
|
115
|
-
['
|
|
116
|
-
['
|
|
121
|
+
['STUDENTS', '120 HEC Paris students'],
|
|
122
|
+
['TOPICS', 'Claude Code, sub-agents, agent teams'],
|
|
123
|
+
['PARTNERS', 'X-HEC Entrepreneurs, HEC Startup Launchpad'],
|
|
117
124
|
],
|
|
118
|
-
body: '
|
|
125
|
+
body: 'Taught 120 HEC students how to go from zero\nto shipped product in hours. Showed them\nOpenClaw. They realized the barrier between\n"I can\'t build" and "I can build anything"\nis thinner than anyone told them.',
|
|
119
126
|
},
|
|
120
127
|
},
|
|
121
128
|
{
|
|
122
|
-
name: '
|
|
123
|
-
tag: '
|
|
124
|
-
tagColor: '
|
|
125
|
-
description:
|
|
126
|
-
status: '
|
|
129
|
+
name: 'Objectif Reussite',
|
|
130
|
+
tag: 'Nonprofit',
|
|
131
|
+
tagColor: 'dim',
|
|
132
|
+
description: '800+ students helped, 20+ volunteer tutors',
|
|
133
|
+
status: 'Started at 15 -- ran 3 years',
|
|
127
134
|
detail: {
|
|
128
|
-
tagline:
|
|
135
|
+
tagline: 'Discord-based tutoring network',
|
|
129
136
|
fields: [
|
|
130
|
-
['
|
|
131
|
-
['
|
|
132
|
-
['
|
|
137
|
+
['STATUS', 'Completed (ran 3 years)'],
|
|
138
|
+
['STUDENTS', '800+ helped'],
|
|
139
|
+
['TUTORS', '20+ volunteers'],
|
|
140
|
+
['STARTED', 'Age 15, during lycee'],
|
|
133
141
|
],
|
|
134
|
-
body:
|
|
142
|
+
body: 'Built a Discord-based tutoring network that\nconnected struggling high school students with\nvolunteer teachers for free. Proved I could\nbuild a community from scratch, recruit people,\nand sustain something meaningful over time.',
|
|
135
143
|
},
|
|
136
144
|
},
|
|
137
145
|
{
|
|
138
|
-
name: '
|
|
139
|
-
tag: '
|
|
140
|
-
tagColor: '
|
|
141
|
-
description: '
|
|
142
|
-
status: '
|
|
146
|
+
name: 'Good Good Time',
|
|
147
|
+
tag: 'Social',
|
|
148
|
+
tagColor: 'primary',
|
|
149
|
+
description: 'Activity recommendation app',
|
|
150
|
+
status: 'Built at NEOMA incubator, rebuilt in Canada',
|
|
143
151
|
detail: {
|
|
144
|
-
tagline: '
|
|
152
|
+
tagline: 'Activity recommendations based on preferences',
|
|
145
153
|
fields: [
|
|
146
|
-
['
|
|
147
|
-
['
|
|
148
|
-
['
|
|
154
|
+
['V1', 'No-code + Supabase at NEOMA incubator'],
|
|
155
|
+
['V2', 'Full rebuild with Cursor in Canada'],
|
|
156
|
+
['TEAM', 'Included co-founder with $2.4B exit'],
|
|
149
157
|
],
|
|
150
|
-
body: '
|
|
158
|
+
body: 'Activity recommendation app based on user\npreferences. V1 built with no-code at NEOMA\nincubator. Rebuilt from scratch as v2 in Canada\nwhen I discovered Cursor. Team included a\nco-founder with a $2.4B exit, which set the\nbar for every project after.',
|
|
151
159
|
},
|
|
152
160
|
},
|
|
153
161
|
];
|
|
154
162
|
|
|
155
163
|
export const STACK = {
|
|
156
164
|
languages: [
|
|
157
|
-
{ name: 'TypeScript', level:
|
|
165
|
+
{ name: 'TypeScript', level: 90 },
|
|
158
166
|
{ name: 'JavaScript', level: 90 },
|
|
159
167
|
{ name: 'Python', level: 65 },
|
|
160
168
|
{ name: 'SQL', level: 55 },
|
|
161
169
|
],
|
|
162
170
|
frameworks: [
|
|
163
|
-
{ name: 'React/Next.js', level:
|
|
171
|
+
{ name: 'React/Next.js', level: 95 },
|
|
164
172
|
{ name: 'React Native', level: 65 },
|
|
165
173
|
{ name: 'Node.js', level: 80 },
|
|
166
174
|
],
|
|
167
175
|
infra: [
|
|
168
176
|
{ name: 'Supabase', level: 95 },
|
|
177
|
+
{ name: 'Vercel', level: 85 },
|
|
169
178
|
{ name: 'PostgreSQL', level: 65 },
|
|
170
179
|
],
|
|
171
180
|
ai: [
|
|
172
|
-
{ name: 'Claude
|
|
181
|
+
{ name: 'Claude/Cursor', level: 95 },
|
|
173
182
|
{ name: 'Agent Teams', level: 80 },
|
|
174
183
|
{ name: 'MCP', level: 80 },
|
|
175
184
|
],
|
|
176
185
|
};
|
|
177
186
|
|
|
178
187
|
export const STACK_PHILOSOPHY = [
|
|
188
|
+
'Sell before you build -- validate with landing pages first',
|
|
189
|
+
'Design as competitive moat, not afterthought',
|
|
179
190
|
'Vibecoding > traditional coding',
|
|
180
|
-
'
|
|
181
|
-
'Sell before you build',
|
|
191
|
+
'Ship fast, iterate faster',
|
|
182
192
|
];
|
|
183
193
|
|
|
184
194
|
export const HISTORY = [
|
|
185
195
|
{
|
|
186
|
-
year: '
|
|
187
|
-
title: 'Started
|
|
188
|
-
details: '
|
|
196
|
+
year: '2019',
|
|
197
|
+
title: 'Started designing at 13',
|
|
198
|
+
details: 'Figma, Affinity Designer\nFirst creative experiments',
|
|
189
199
|
},
|
|
190
200
|
{
|
|
191
|
-
year: '
|
|
192
|
-
title: '
|
|
193
|
-
details: '
|
|
201
|
+
year: '2021',
|
|
202
|
+
title: 'First ventures at 15',
|
|
203
|
+
details: 'Objectif Reussite: 800+ students, 20+ tutors\nNYM Digital: first client work\n15+ projects started since then',
|
|
194
204
|
},
|
|
195
205
|
{
|
|
196
206
|
year: '2023',
|
|
197
|
-
title: '
|
|
198
|
-
details: '
|
|
207
|
+
title: 'NEOMA Business School',
|
|
208
|
+
details: 'Global BBA started\nNEOMA incubator: Good Good Time\nCo-founded Astry with Teo & Mathis',
|
|
199
209
|
},
|
|
200
210
|
{
|
|
201
211
|
year: '2024',
|
|
202
|
-
title:
|
|
203
|
-
details: '
|
|
212
|
+
title: "Canada & the AI shift",
|
|
213
|
+
details: "Queen's University / Smith School of Business\nDiscovered Cursor & Claude\nWent from no-code to full-stack AI dev",
|
|
204
214
|
},
|
|
205
215
|
{
|
|
206
216
|
year: '2025',
|
|
207
|
-
title: 'The
|
|
208
|
-
details: "
|
|
217
|
+
title: 'The breakout year',
|
|
218
|
+
details: "Co-founded Qual with Marin\nF*cksubscription: 3K+ customers, 10K+ EUR\nParis Innov'Hack: 2nd / 150+\nTaught 120 HEC Paris students vibecoding",
|
|
209
219
|
},
|
|
210
220
|
{
|
|
211
221
|
year: '2026',
|
|
212
|
-
title: '
|
|
213
|
-
details: '
|
|
222
|
+
title: 'Scaling',
|
|
223
|
+
details: 'Growing Qual -- top university adoption\nAstry: 30+ clients\nPreparing UC Berkeley move',
|
|
214
224
|
},
|
|
215
225
|
{
|
|
216
226
|
year: '2027',
|
|
217
|
-
title: 'Berkeley
|
|
218
|
-
details: '
|
|
227
|
+
title: 'UC Berkeley, San Francisco',
|
|
228
|
+
details: '6-month program through NEOMA incubator\nBringing Qual to the US market',
|
|
219
229
|
},
|
|
220
230
|
];
|
|
221
231
|
|
|
222
232
|
export const EDUCATION = [
|
|
223
233
|
{
|
|
224
234
|
title: 'NEOMA Business School',
|
|
225
|
-
detail: 'Global BBA
|
|
235
|
+
detail: 'Global BBA (2023-2027)\nInternational business, strategy, marketing',
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
title: "Queen's University / Smith School of Business",
|
|
239
|
+
detail: 'Exchange year in Canada (2024)\nDiscovered Cursor & Claude -- went full-stack',
|
|
226
240
|
},
|
|
227
241
|
{
|
|
228
|
-
title: 'Berkeley Global Incubator',
|
|
229
|
-
detail: '
|
|
242
|
+
title: 'UC Berkeley -- Global Incubator',
|
|
243
|
+
detail: '6-month program starting January 2027\nBringing Qual to the US market',
|
|
230
244
|
},
|
|
231
245
|
{
|
|
232
|
-
title: 'Teaching @ IQ Project',
|
|
233
|
-
detail: 'Vibecoding & AI product building\
|
|
246
|
+
title: 'Teaching @ IQ Project / HEC Paris',
|
|
247
|
+
detail: 'Vibecoding & AI product building\n120 HEC Paris students trained\nPartnered with X-HEC Entrepreneurs',
|
|
234
248
|
},
|
|
235
249
|
];
|
|
236
250
|
|
|
237
251
|
export const CONTACT = [
|
|
238
|
-
{ label: '
|
|
239
|
-
{ label: 'WEB', value: 'qual.cx' },
|
|
240
|
-
{ label: 'LINKEDIN', value: 'linkedin.com/in/
|
|
241
|
-
{ label: 'GITHUB', value: 'github.com/
|
|
242
|
-
{ label: '
|
|
252
|
+
{ label: 'EMAIL', value: 'bonanmartin@gmail.com', url: 'mailto:bonanmartin@gmail.com' },
|
|
253
|
+
{ label: 'WEB', value: 'qual.cx', url: 'https://qual.cx' },
|
|
254
|
+
{ label: 'LINKEDIN', value: 'linkedin.com/in/martinbonan', url: 'https://linkedin.com/in/martinbonan' },
|
|
255
|
+
{ label: 'GITHUB', value: 'github.com/martinbon39', url: 'https://github.com/martinbon39' },
|
|
256
|
+
{ label: 'X', value: 'x.com/martinbonan', url: 'https://x.com/martinbonan' },
|
|
243
257
|
];
|
|
244
258
|
|
|
245
|
-
export const TABS = ['About', 'Projects', 'Stack', 'History', 'Education', 'Contact'
|
|
259
|
+
export const TABS = ['About', 'Projects', 'Stack', 'History', 'Education', 'Contact'];
|
|
246
260
|
|
|
247
261
|
export const EASTER_EGGS = {
|
|
248
262
|
hack: true,
|
package/src/hooks/useAiChat.js
CHANGED
|
@@ -1,12 +1,28 @@
|
|
|
1
|
-
import { useState, useCallback } from 'react';
|
|
1
|
+
import { useState, useCallback, useRef } from 'react';
|
|
2
2
|
import { PROXY_URL, CLI_SECRET } from '../ai/context.js';
|
|
3
3
|
|
|
4
|
+
const MAX_MESSAGES = 15;
|
|
5
|
+
|
|
4
6
|
export function useAiChat() {
|
|
5
7
|
const [messages, setMessages] = useState([]);
|
|
6
8
|
const [loading, setLoading] = useState(false);
|
|
7
9
|
const [streamingText, setStreamingText] = useState('');
|
|
10
|
+
const [limitReached, setLimitReached] = useState(false);
|
|
11
|
+
const messageCount = useRef(0);
|
|
8
12
|
|
|
9
13
|
const sendMessage = useCallback(async (text) => {
|
|
14
|
+
messageCount.current++;
|
|
15
|
+
|
|
16
|
+
if (messageCount.current > MAX_MESSAGES) {
|
|
17
|
+
setLimitReached(true);
|
|
18
|
+
setMessages(prev => [
|
|
19
|
+
...prev,
|
|
20
|
+
{ role: 'user', content: text },
|
|
21
|
+
{ role: 'assistant', content: 'Message limit reached (15 messages per session). Restart the CLI to chat again.' },
|
|
22
|
+
]);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
10
26
|
const userMsg = { role: 'user', content: text };
|
|
11
27
|
const newMessages = [...messages, userMsg];
|
|
12
28
|
setMessages(newMessages);
|
|
@@ -30,7 +46,6 @@ export function useAiChat() {
|
|
|
30
46
|
const contentType = response.headers.get('content-type') || '';
|
|
31
47
|
|
|
32
48
|
if (contentType.includes('text/event-stream')) {
|
|
33
|
-
// Streaming response
|
|
34
49
|
let fullText = '';
|
|
35
50
|
const reader = response.body.getReader();
|
|
36
51
|
const decoder = new TextDecoder();
|
|
@@ -64,7 +79,6 @@ export function useAiChat() {
|
|
|
64
79
|
setMessages(prev => [...prev, { role: 'assistant', content: fullText || 'No response.' }]);
|
|
65
80
|
setStreamingText('');
|
|
66
81
|
} else {
|
|
67
|
-
// Non-streaming JSON response
|
|
68
82
|
const data = await response.json();
|
|
69
83
|
const text = data.content?.[0]?.text || data.error || 'No response.';
|
|
70
84
|
setMessages(prev => [...prev, { role: 'assistant', content: text }]);
|
|
@@ -82,7 +96,9 @@ export function useAiChat() {
|
|
|
82
96
|
const clearChat = useCallback(() => {
|
|
83
97
|
setMessages([]);
|
|
84
98
|
setStreamingText('');
|
|
99
|
+
messageCount.current = 0;
|
|
100
|
+
setLimitReached(false);
|
|
85
101
|
}, []);
|
|
86
102
|
|
|
87
|
-
return { messages, loading, streamingText, sendMessage, clearChat };
|
|
103
|
+
return { messages, loading, streamingText, sendMessage, clearChat, limitReached };
|
|
88
104
|
}
|
package/src/screens/Dashboard.js
CHANGED
|
@@ -4,7 +4,8 @@ import Header from '../components/Header.js';
|
|
|
4
4
|
import TabBar from '../components/TabBar.js';
|
|
5
5
|
import ContentPanel from '../components/ContentPanel.js';
|
|
6
6
|
import StatusBar from '../components/StatusBar.js';
|
|
7
|
-
import { TABS, BIO, PROJECTS, HISTORY } from '../data/content.js';
|
|
7
|
+
import { TABS, BIO, PROJECTS, HISTORY, CONTACT } from '../data/content.js';
|
|
8
|
+
import { exec } from 'child_process';
|
|
8
9
|
import { ThemeContext, getNextThemeName } from '../theme.js';
|
|
9
10
|
|
|
10
11
|
const e = React.createElement;
|
|
@@ -35,11 +36,13 @@ export default function Dashboard() {
|
|
|
35
36
|
const isAiTab = tabName === 'Ask AI';
|
|
36
37
|
const isProjectsTab = tabName === 'Projects';
|
|
37
38
|
const isHistoryTab = tabName === 'History';
|
|
39
|
+
const isContactTab = tabName === 'Contact';
|
|
38
40
|
|
|
39
41
|
// Get max index for current tab's selectable items
|
|
40
42
|
const getMaxIndex = () => {
|
|
41
43
|
if (isProjectsTab) return PROJECTS.length - 1;
|
|
42
44
|
if (isHistoryTab) return HISTORY.length - 1;
|
|
45
|
+
if (isContactTab) return CONTACT.length - 1;
|
|
43
46
|
return 0;
|
|
44
47
|
};
|
|
45
48
|
|
|
@@ -126,7 +129,7 @@ export default function Dashboard() {
|
|
|
126
129
|
|
|
127
130
|
// Vertical navigation
|
|
128
131
|
if (key.upArrow) {
|
|
129
|
-
if (isProjectsTab || isHistoryTab) {
|
|
132
|
+
if (isProjectsTab || isHistoryTab || isContactTab) {
|
|
130
133
|
setSelectedIndex(i => Math.max(0, i - 1));
|
|
131
134
|
} else {
|
|
132
135
|
setScrollOffset(s => Math.max(0, s - 1));
|
|
@@ -134,7 +137,7 @@ export default function Dashboard() {
|
|
|
134
137
|
return;
|
|
135
138
|
}
|
|
136
139
|
if (key.downArrow) {
|
|
137
|
-
if (isProjectsTab || isHistoryTab) {
|
|
140
|
+
if (isProjectsTab || isHistoryTab || isContactTab) {
|
|
138
141
|
setSelectedIndex(i => Math.min(getMaxIndex(), i + 1));
|
|
139
142
|
} else {
|
|
140
143
|
setScrollOffset(s => s + 1);
|
|
@@ -148,6 +151,15 @@ export default function Dashboard() {
|
|
|
148
151
|
setAiFocused(true);
|
|
149
152
|
} else if (isProjectsTab) {
|
|
150
153
|
setExpandedProject(selectedIndex);
|
|
154
|
+
} else if (isContactTab) {
|
|
155
|
+
const contact = CONTACT[selectedIndex];
|
|
156
|
+
if (contact && contact.url) {
|
|
157
|
+
// Open URL in default browser
|
|
158
|
+
const platform = process.platform;
|
|
159
|
+
const cmd = platform === 'darwin' ? 'open' :
|
|
160
|
+
platform === 'win32' ? 'start' : 'xdg-open';
|
|
161
|
+
exec(`${cmd} "${contact.url}"`);
|
|
162
|
+
}
|
|
151
163
|
}
|
|
152
164
|
return;
|
|
153
165
|
}
|
package/src/tabs/About.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useEffect, useContext } from 'react';
|
|
1
|
+
import React, { useState, useEffect, useContext, useRef } from 'react';
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { ABOUT_TEXT } from '../data/content.js';
|
|
4
4
|
import TypeWriter from '../components/TypeWriter.js';
|
|
@@ -6,9 +6,12 @@ import { ThemeContext } from '../theme.js';
|
|
|
6
6
|
|
|
7
7
|
const e = React.createElement;
|
|
8
8
|
|
|
9
|
+
// Persist across remounts so the typewriter only plays once
|
|
10
|
+
let hasPlayedGlobal = false;
|
|
11
|
+
|
|
9
12
|
export default function About() {
|
|
10
13
|
const { theme } = useContext(ThemeContext);
|
|
11
|
-
const [hasPlayed, setHasPlayed] = useState(
|
|
14
|
+
const [hasPlayed, setHasPlayed] = useState(hasPlayedGlobal);
|
|
12
15
|
const [bright, setBright] = useState(true);
|
|
13
16
|
|
|
14
17
|
useEffect(() => {
|
|
@@ -17,7 +20,6 @@ export default function About() {
|
|
|
17
20
|
}, []);
|
|
18
21
|
|
|
19
22
|
const lines = ABOUT_TEXT.split('\n');
|
|
20
|
-
const lastLine = lines[lines.length - 1];
|
|
21
23
|
|
|
22
24
|
if (!hasPlayed) {
|
|
23
25
|
return e(Box, { flexDirection: 'column', paddingLeft: 1 },
|
|
@@ -25,7 +27,10 @@ export default function About() {
|
|
|
25
27
|
text: ABOUT_TEXT,
|
|
26
28
|
speed: 15,
|
|
27
29
|
color: theme.text,
|
|
28
|
-
onComplete: () =>
|
|
30
|
+
onComplete: () => {
|
|
31
|
+
hasPlayedGlobal = true;
|
|
32
|
+
setHasPlayed(true);
|
|
33
|
+
},
|
|
29
34
|
})
|
|
30
35
|
);
|
|
31
36
|
}
|
package/src/tabs/AskAi.js
CHANGED
|
@@ -14,20 +14,20 @@ const EASTER_EGGS = {
|
|
|
14
14
|
'\u2502 Permission granted. Contract incoming. \u2502\n' +
|
|
15
15
|
'\u2502 Just kidding. But seriously, let\'s talk.\u2502\n' +
|
|
16
16
|
'\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F\n\n' +
|
|
17
|
-
'WEB:
|
|
17
|
+
'WEB: qual.cx\nLINKEDIN: linkedin.com/in/martinbonan\nEMAIL: bonanmartin@gmail.com',
|
|
18
18
|
neofetch:
|
|
19
19
|
'OS: VibeOS 3.0\n' +
|
|
20
20
|
'Shell: martin-sh\n' +
|
|
21
21
|
'CPU: Pure caffeine\n' +
|
|
22
|
-
'Memory:
|
|
23
|
-
'Uptime: Building since
|
|
22
|
+
'Memory: 15+ projects loaded\n' +
|
|
23
|
+
'Uptime: Building since 2019',
|
|
24
24
|
vim: "You're a vim person? Respect. But :q won't save you here.",
|
|
25
25
|
ls: "You're looking for files? Try 'projects' tab instead.",
|
|
26
26
|
};
|
|
27
27
|
|
|
28
28
|
export default function AskAi({ isFocused }) {
|
|
29
29
|
const { theme } = useContext(ThemeContext);
|
|
30
|
-
const { messages, loading, streamingText, sendMessage, clearChat } = useAiChat();
|
|
30
|
+
const { messages, loading, streamingText, sendMessage, clearChat, limitReached } = useAiChat();
|
|
31
31
|
const [query, setQuery] = useState('');
|
|
32
32
|
const [localMessages, setLocalMessages] = useState([]);
|
|
33
33
|
|
|
@@ -92,16 +92,20 @@ export default function AskAi({ isFocused }) {
|
|
|
92
92
|
: null,
|
|
93
93
|
),
|
|
94
94
|
|
|
95
|
-
|
|
96
|
-
e(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
95
|
+
limitReached
|
|
96
|
+
? e(Box, { marginTop: 1 },
|
|
97
|
+
e(Text, { color: theme.dim }, ' Message limit reached (15/session). Restart to chat again.')
|
|
98
|
+
)
|
|
99
|
+
: e(Box, { marginTop: 1 },
|
|
100
|
+
e(Text, { color: theme.accent }, ' YOU: '),
|
|
101
|
+
isFocused
|
|
102
|
+
? e(TextInput, {
|
|
103
|
+
value: query,
|
|
104
|
+
onChange: setQuery,
|
|
105
|
+
onSubmit: handleSubmit,
|
|
106
|
+
placeholder: 'Type your question...',
|
|
107
|
+
})
|
|
108
|
+
: e(Text, { color: theme.dim }, 'Press enter to start chatting...')
|
|
109
|
+
)
|
|
106
110
|
);
|
|
107
111
|
}
|
package/src/tabs/Contact.js
CHANGED
|
@@ -5,7 +5,7 @@ import { ThemeContext } from '../theme.js';
|
|
|
5
5
|
|
|
6
6
|
const e = React.createElement;
|
|
7
7
|
|
|
8
|
-
export default function Contact() {
|
|
8
|
+
export default function Contact({ selectedIndex = 0 }) {
|
|
9
9
|
const { theme } = useContext(ThemeContext);
|
|
10
10
|
const separator = '\u2500'.repeat(40);
|
|
11
11
|
|
|
@@ -13,14 +13,17 @@ export default function Contact() {
|
|
|
13
13
|
e(Text, { color: theme.text }, ' Get in touch'),
|
|
14
14
|
e(Text, { color: theme.dim }, ' ' + separator),
|
|
15
15
|
e(Box, { flexDirection: 'column', marginTop: 1 },
|
|
16
|
-
...CONTACT.map((c, i) =>
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
e(Text, { color: theme.primary },
|
|
20
|
-
|
|
21
|
-
|
|
16
|
+
...CONTACT.map((c, i) => {
|
|
17
|
+
const isSelected = i === selectedIndex;
|
|
18
|
+
return e(Box, { key: i },
|
|
19
|
+
e(Text, { color: isSelected ? theme.primary : theme.dim }, isSelected ? '> ' : ' '),
|
|
20
|
+
e(Text, { color: theme.dim }, c.label.padEnd(10)),
|
|
21
|
+
e(Text, { color: isSelected ? theme.primary : theme.text, bold: isSelected, underline: isSelected }, c.value),
|
|
22
|
+
);
|
|
23
|
+
})
|
|
22
24
|
),
|
|
23
|
-
e(Text, { color: theme.dim,
|
|
25
|
+
e(Text, { color: theme.dim, dimColor: true }, '\n ' + separator),
|
|
26
|
+
e(Text, { color: theme.dim, italic: true }, ' enter to open in browser'),
|
|
24
27
|
e(Text, { color: theme.dim, italic: true }, ' This CLI was built with ink, Claude, and vibes.'),
|
|
25
28
|
);
|
|
26
29
|
}
|
package/src/tabs/Projects.js
CHANGED
|
@@ -5,16 +5,9 @@ import { ThemeContext } from '../theme.js';
|
|
|
5
5
|
|
|
6
6
|
const e = React.createElement;
|
|
7
7
|
|
|
8
|
-
const TAG_COLOR_MAP = {
|
|
9
|
-
primary: 'primary',
|
|
10
|
-
secondary: 'secondary',
|
|
11
|
-
accent: 'accent',
|
|
12
|
-
dim: 'dim',
|
|
13
|
-
};
|
|
14
|
-
|
|
15
8
|
function resolveTagColor(tagColor, theme) {
|
|
16
|
-
const
|
|
17
|
-
return
|
|
9
|
+
const map = { primary: 'primary', secondary: 'secondary', accent: 'accent', dim: 'dim' };
|
|
10
|
+
return theme[map[tagColor]] || theme.dim;
|
|
18
11
|
}
|
|
19
12
|
|
|
20
13
|
function CompactCard({ project, isSelected, theme }) {
|
|
@@ -35,70 +28,69 @@ function CompactCard({ project, isSelected, theme }) {
|
|
|
35
28
|
|
|
36
29
|
function ExpandedCard({ project, theme }) {
|
|
37
30
|
const detail = project.detail;
|
|
38
|
-
|
|
31
|
+
|
|
32
|
+
// Compute width dynamically from content
|
|
33
|
+
const allLines = [];
|
|
34
|
+
if (detail.url) allLines.push(detail.url);
|
|
35
|
+
if (detail.tagline) allLines.push(detail.tagline);
|
|
36
|
+
if (detail.quote) allLines.push(detail.quote.replace(/\\n/g, ' '));
|
|
37
|
+
for (const [label, value] of (detail.fields || [])) {
|
|
38
|
+
allLines.push(label.padEnd(12) + value);
|
|
39
|
+
}
|
|
40
|
+
if (detail.body) {
|
|
41
|
+
for (const line of detail.body.split('\n')) allLines.push(line);
|
|
42
|
+
}
|
|
43
|
+
allLines.push('press esc to go back');
|
|
44
|
+
|
|
45
|
+
const maxContent = Math.max(...allLines.map(l => l.length));
|
|
46
|
+
const cols = process.stdout.columns || 80;
|
|
47
|
+
const width = Math.min(Math.max(maxContent + 6, project.name.length + 10), cols - 4);
|
|
48
|
+
|
|
39
49
|
const nameBar = '\u2500\u2500\u2500 ' + project.name + ' ';
|
|
40
|
-
const topFill = '\u2500'.repeat(Math.max(width - nameBar.length -
|
|
50
|
+
const topFill = '\u2500'.repeat(Math.max(width - nameBar.length - 1, 0));
|
|
41
51
|
const top = '\u256D' + nameBar + topFill + '\u256E';
|
|
42
|
-
const bottom = '\u2570' + '\u2500'.repeat(width -
|
|
43
|
-
const
|
|
44
|
-
const empty = '\u2502' + ' '.repeat(width - 2) + '\u2502';
|
|
52
|
+
const bottom = '\u2570' + '\u2500'.repeat(width - 1) + '\u256F';
|
|
53
|
+
const empty = '\u2502' + ' '.repeat(width - 1) + '\u2502';
|
|
45
54
|
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
55
|
+
const row = (children) => {
|
|
56
|
+
return children;
|
|
57
|
+
};
|
|
49
58
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
const textRow = (text, color = theme.text) => {
|
|
60
|
+
const pad = Math.max(width - text.length - 4, 0);
|
|
61
|
+
return e(Text, { color: theme.border },
|
|
62
|
+
'\u2502 ', e(Text, { color }, text), ' '.repeat(pad), ' \u2502'
|
|
63
|
+
);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const fieldRow = (label, value, i) => {
|
|
67
|
+
const content = label.padEnd(12) + value;
|
|
68
|
+
const pad = Math.max(width - content.length - 4, 0);
|
|
69
|
+
return e(Text, { key: `f-${i}`, color: theme.border },
|
|
70
|
+
'\u2502 ',
|
|
71
|
+
e(Text, { color: theme.primary, bold: true }, label.padEnd(12)),
|
|
72
|
+
e(Text, { color: theme.text }, value),
|
|
73
|
+
' '.repeat(pad), ' \u2502'
|
|
74
|
+
);
|
|
75
|
+
};
|
|
60
76
|
|
|
61
77
|
return e(Box, { flexDirection: 'column', paddingLeft: 1 },
|
|
62
78
|
e(Text, { color: theme.border }, top),
|
|
63
79
|
e(Text, { color: theme.border }, empty),
|
|
64
|
-
detail.url ?
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
) : null,
|
|
68
|
-
detail.tagline ? e(Text, { color: theme.border },
|
|
69
|
-
'\u2502 ', e(Text, { color: theme.text }, detail.tagline),
|
|
70
|
-
' '.repeat(Math.max(width - (detail.tagline || '').length - 5, 0)), '\u2502'
|
|
71
|
-
) : null,
|
|
72
|
-
detail.quote ? e(Text, { color: theme.border },
|
|
73
|
-
'\u2502 ', e(Text, { color: theme.dim }, detail.quote.replace(/\\n/g, ' ')),
|
|
74
|
-
' '.repeat(Math.max(width - detail.quote.replace(/\\n/g, ' ').length - 5, 0)), '\u2502'
|
|
75
|
-
) : null,
|
|
80
|
+
detail.url ? textRow(detail.url, theme.text) : null,
|
|
81
|
+
detail.tagline ? textRow(detail.tagline, theme.text) : null,
|
|
82
|
+
detail.quote ? textRow(detail.quote.replace(/\\n/g, ' '), theme.dim) : null,
|
|
76
83
|
e(Text, { color: theme.border }, empty),
|
|
77
|
-
...(detail.fields || []).map(([label, value], i) =>
|
|
78
|
-
e(Text, { key: `f-${i}`, color: theme.border },
|
|
79
|
-
'\u2502 ',
|
|
80
|
-
e(Text, { color: theme.primary, bold: true }, label.padEnd(10)),
|
|
81
|
-
e(Text, { color: theme.text }, value),
|
|
82
|
-
' '.repeat(Math.max(width - 10 - value.length - 5, 0)),
|
|
83
|
-
'\u2502'
|
|
84
|
-
)
|
|
85
|
-
),
|
|
84
|
+
...(detail.fields || []).map(([label, value], i) => fieldRow(label, value, i)),
|
|
86
85
|
e(Text, { color: theme.border }, empty),
|
|
87
|
-
...(detail.body ? detail.body.split('\n').map((line, i) =>
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
e(Text, { color: theme.text }, line),
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
)
|
|
94
|
-
) : []),
|
|
86
|
+
...(detail.body ? detail.body.split('\n').map((line, i) => {
|
|
87
|
+
const pad = Math.max(width - line.length - 4, 0);
|
|
88
|
+
return e(Text, { key: `b-${i}`, color: theme.border },
|
|
89
|
+
'\u2502 ', e(Text, { color: theme.text }, line), ' '.repeat(pad), ' \u2502'
|
|
90
|
+
);
|
|
91
|
+
}) : []),
|
|
95
92
|
e(Text, { color: theme.border }, empty),
|
|
96
|
-
|
|
97
|
-
'\u2502 ',
|
|
98
|
-
e(Text, { color: theme.dim }, 'press esc to go back'),
|
|
99
|
-
' '.repeat(Math.max(width - 'press esc to go back'.length - 5, 0)),
|
|
100
|
-
'\u2502'
|
|
101
|
-
),
|
|
93
|
+
textRow('press esc to go back', theme.dim),
|
|
102
94
|
e(Text, { color: theme.border }, bottom),
|
|
103
95
|
);
|
|
104
96
|
}
|
package/src/theme.js
CHANGED
|
@@ -2,6 +2,18 @@ import React from 'react';
|
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
|
|
4
4
|
export const THEMES = {
|
|
5
|
+
clean: {
|
|
6
|
+
name: 'clean',
|
|
7
|
+
primary: '#A0C4FF',
|
|
8
|
+
secondary: '#A0C4FF',
|
|
9
|
+
accent: '#C2D9FF',
|
|
10
|
+
text: '#D6E4F0',
|
|
11
|
+
dim: '#4A5568',
|
|
12
|
+
border: '#2D3748',
|
|
13
|
+
bar_filled: '#A0C4FF',
|
|
14
|
+
bar_empty: '#1A202C',
|
|
15
|
+
bg: '#0D1117',
|
|
16
|
+
},
|
|
5
17
|
cyberpunk: {
|
|
6
18
|
name: 'cyberpunk',
|
|
7
19
|
primary: '#00D4AA',
|
|
@@ -52,7 +64,7 @@ export const THEMES = {
|
|
|
52
64
|
},
|
|
53
65
|
};
|
|
54
66
|
|
|
55
|
-
const THEME_ORDER = ['cyberpunk', 'mono', 'retro', 'sunset'];
|
|
67
|
+
const THEME_ORDER = ['clean', 'cyberpunk', 'mono', 'retro', 'sunset'];
|
|
56
68
|
|
|
57
69
|
export const ThemeContext = React.createContext(null);
|
|
58
70
|
|