@weactive8/8contentpro 0.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/README.md +33 -0
- package/dist/agent.js +115 -0
- package/dist/agent.js.map +1 -0
- package/dist/attach.js +144 -0
- package/dist/attach.js.map +1 -0
- package/dist/auth.js +49 -0
- package/dist/auth.js.map +1 -0
- package/dist/brand.js +110 -0
- package/dist/brand.js.map +1 -0
- package/dist/brandgen.js +76 -0
- package/dist/brandgen.js.map +1 -0
- package/dist/build-info.js +3 -0
- package/dist/build-info.js.map +1 -0
- package/dist/detect.js +163 -0
- package/dist/detect.js.map +1 -0
- package/dist/index.js +334 -0
- package/dist/index.js.map +1 -0
- package/dist/ink/App.js +733 -0
- package/dist/ink/App.js.map +1 -0
- package/dist/ink/components.js +214 -0
- package/dist/ink/components.js.map +1 -0
- package/dist/ink/run.js +9 -0
- package/dist/ink/run.js.map +1 -0
- package/dist/intel.js +110 -0
- package/dist/intel.js.map +1 -0
- package/dist/login.js +99 -0
- package/dist/login.js.map +1 -0
- package/dist/metering.js +53 -0
- package/dist/metering.js.map +1 -0
- package/dist/methodology/loader.js +68 -0
- package/dist/methodology/loader.js.map +1 -0
- package/dist/models.js +24 -0
- package/dist/models.js.map +1 -0
- package/dist/onboarding/onboard.js +102 -0
- package/dist/onboarding/onboard.js.map +1 -0
- package/dist/onboarding/schema.js +25 -0
- package/dist/onboarding/schema.js.map +1 -0
- package/dist/onboarding/setup.js +324 -0
- package/dist/onboarding/setup.js.map +1 -0
- package/dist/output/callouts.js +81 -0
- package/dist/output/callouts.js.map +1 -0
- package/dist/output/docx.js +155 -0
- package/dist/output/docx.js.map +1 -0
- package/dist/output/pdf.js +195 -0
- package/dist/output/pdf.js.map +1 -0
- package/dist/pipeline.js +140 -0
- package/dist/pipeline.js.map +1 -0
- package/dist/plan/targeting.js +165 -0
- package/dist/plan/targeting.js.map +1 -0
- package/dist/shell.js +286 -0
- package/dist/shell.js.map +1 -0
- package/dist/site/resources.js +88 -0
- package/dist/site/resources.js.map +1 -0
- package/dist/site/sitemap.js +92 -0
- package/dist/site/sitemap.js.map +1 -0
- package/dist/store.js +110 -0
- package/dist/store.js.map +1 -0
- package/dist/topics.js +50 -0
- package/dist/topics.js.map +1 -0
- package/dist/ui/spinner.js +111 -0
- package/dist/ui/spinner.js.map +1 -0
- package/dist/ui/statusbar.js +90 -0
- package/dist/ui/statusbar.js.map +1 -0
- package/dist/update.js +45 -0
- package/dist/update.js.map +1 -0
- package/package.json +47 -0
package/dist/ink/App.js
ADDED
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect, useRef } from "react";
|
|
3
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { mkdirSync } from "node:fs";
|
|
7
|
+
import { exec } from "node:child_process";
|
|
8
|
+
import { InputBox, Menu, Spinner, StatusBar, Header, Picker } from "./components.js";
|
|
9
|
+
import { loadProfile, saveProfile, getAccount } from "../store.js";
|
|
10
|
+
import { QUESTIONS, synthesizeProfile, summarizeAttachment, tuneSitemap, tunePersonas, tuneServices, tuneVoice } from "../onboarding/setup.js";
|
|
11
|
+
import { isProfileComplete } from "../onboarding/schema.js";
|
|
12
|
+
import { generateArticle, generateGuide, reviseArticle, AI_THRESHOLD, MAX_REVISES } from "../pipeline.js";
|
|
13
|
+
import { ideate, targetedBrief } from "../plan/targeting.js";
|
|
14
|
+
import { keywordContext, strikingDistanceFor, gscPropertyFor, gscRegister, gscVerify, GSC_SERVICE_ACCOUNT } from "../intel.js";
|
|
15
|
+
import { createUploadSession, openBrowser, pollUploadStatus } from "../attach.js";
|
|
16
|
+
import { checkForUpdate, versionLine } from "../update.js";
|
|
17
|
+
import { BUILD } from "../build-info.js";
|
|
18
|
+
import { loadTopics, addTopics, markUsed } from "../topics.js";
|
|
19
|
+
import { loadBrand, saveBrand } from "../brand.js";
|
|
20
|
+
import { buildBrand } from "../brandgen.js";
|
|
21
|
+
const HINT_MENU = "↑↓ move · Enter select · Ctrl+C quit";
|
|
22
|
+
const HINT_INPUT = "type your answer · Enter to submit · / for commands · Esc to go back";
|
|
23
|
+
const HINT_SETUP = "type your answer + Enter · / for commands · Esc to go back";
|
|
24
|
+
const HINT_WORK = "working… · Esc to interrupt";
|
|
25
|
+
const COMMANDS = [
|
|
26
|
+
{ name: "help", desc: "list commands" },
|
|
27
|
+
{ name: "clear", desc: "clear the screen" },
|
|
28
|
+
{ name: "attach", desc: "upload a file or screenshot" },
|
|
29
|
+
{ name: "files", desc: "list attached files + URLs" },
|
|
30
|
+
{ name: "back", desc: "previous question" },
|
|
31
|
+
{ name: "restart", desc: "start setup over" },
|
|
32
|
+
{ name: "menu", desc: "go to the main menu" },
|
|
33
|
+
{ name: "version", desc: "show build version + check for updates" },
|
|
34
|
+
{ name: "quit", desc: "exit 8ContentPro" },
|
|
35
|
+
];
|
|
36
|
+
const STAGE_LABEL = {
|
|
37
|
+
TOF: "Top of funnel — awareness",
|
|
38
|
+
MOF: "Middle of funnel — consideration",
|
|
39
|
+
BOF: "Bottom of funnel — decision",
|
|
40
|
+
auto: "Best fit (chosen for you)",
|
|
41
|
+
};
|
|
42
|
+
const stageLabel = (s) => (s ? STAGE_LABEL[s] ?? s : "");
|
|
43
|
+
export function App() {
|
|
44
|
+
const app = useApp();
|
|
45
|
+
const [profile, setProfile] = useState(null);
|
|
46
|
+
const [brand, setBrand] = useState(() => loadBrand());
|
|
47
|
+
const [screen, setScreen] = useState({ name: "loading" });
|
|
48
|
+
const [log, setLog] = useState([]);
|
|
49
|
+
const [spin, setSpin] = useState(null);
|
|
50
|
+
const [badge, setBadge] = useState(`local (your Claude) · v${BUILD.version}`);
|
|
51
|
+
const [hint, setHint] = useState(HINT_MENU);
|
|
52
|
+
// Setup state
|
|
53
|
+
const [qi, setQi] = useState(0);
|
|
54
|
+
const [answers, setAnswers] = useState({});
|
|
55
|
+
const [attachSummaries, setAttachSummaries] = useState([]);
|
|
56
|
+
const [files, setFiles] = useState([]);
|
|
57
|
+
const [history, setHistory] = useState([]); // recall previous inputs with Up/Down
|
|
58
|
+
const attachCancel = useRef(false);
|
|
59
|
+
// Article draft state
|
|
60
|
+
const draft = useRef({});
|
|
61
|
+
// The last generated article's markdown, so "revise with feedback" edits it in place.
|
|
62
|
+
const lastMd = useRef(null);
|
|
63
|
+
// How many operator revises this article has had — capped (cost) at MAX_REVISES.
|
|
64
|
+
const reviseCount = useRef(0);
|
|
65
|
+
const abort = useRef(null);
|
|
66
|
+
const logId = useRef(0);
|
|
67
|
+
const addLog = (text) => setLog((l) => [...l, { id: logId.current++, text }]);
|
|
68
|
+
// Echo a decision into the history so it reads like a conversation: "Question ❯ Choice".
|
|
69
|
+
const echo = (question, choice) => addLog(`${question} ❯ ${choice}`);
|
|
70
|
+
const workdir = join(homedir(), "8ContentPro", "drafts");
|
|
71
|
+
mkdirSync(workdir, { recursive: true });
|
|
72
|
+
// Key under which this operator's saved topics are filed (one profile per install).
|
|
73
|
+
const topicBrand = profile?.brand || profile?.site || "default";
|
|
74
|
+
async function refreshBadge(p) {
|
|
75
|
+
const acct = await getAccount();
|
|
76
|
+
const plan = acct ? `${acct.tier} · ${acct.remaining}/${acct.monthlyCredits} left` : "local (your Claude)";
|
|
77
|
+
setBadge(`${p?.brand ? p.brand + " · " : ""}${plan} · v${BUILD.version}`);
|
|
78
|
+
}
|
|
79
|
+
// Boot: load profile → setup or menu.
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
(async () => {
|
|
82
|
+
addLog(versionLine(await checkForUpdate()));
|
|
83
|
+
const p = await loadProfile();
|
|
84
|
+
await refreshBadge(p);
|
|
85
|
+
if (p && isProfileComplete(p)) {
|
|
86
|
+
setProfile(p);
|
|
87
|
+
addLog(`Welcome back, ${p.brand}. Setup 100%.`);
|
|
88
|
+
setScreen({ name: "menu" });
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
addLog("Welcome. Let's get you set up — a few quick questions (free until the last step).");
|
|
92
|
+
setScreen({ name: "setup" });
|
|
93
|
+
}
|
|
94
|
+
})();
|
|
95
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
96
|
+
}, []);
|
|
97
|
+
// ---- SETUP ----
|
|
98
|
+
function submitSetup(raw) {
|
|
99
|
+
const v = raw.trim();
|
|
100
|
+
if (v)
|
|
101
|
+
setHistory((h) => (h[h.length - 1] === v ? h : [...h, v]));
|
|
102
|
+
const low = v.toLowerCase();
|
|
103
|
+
const q = QUESTIONS[qi];
|
|
104
|
+
if (low === "quit" || low === "exit")
|
|
105
|
+
return app.exit();
|
|
106
|
+
if (low === "restart") {
|
|
107
|
+
setAnswers({});
|
|
108
|
+
setQi(0);
|
|
109
|
+
addLog("Starting fresh.");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (low === "back") {
|
|
113
|
+
if (qi > 0)
|
|
114
|
+
setQi(qi - 1);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (low === "files") {
|
|
118
|
+
if (!files.length)
|
|
119
|
+
addLog("No files attached yet — type 'attach' to add one.");
|
|
120
|
+
else
|
|
121
|
+
files.forEach((f, i) => addLog(` [${i + 1}] ${f.filename} → ${f.url}`));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (low === "attach" || low === "upload") {
|
|
125
|
+
setScreen({ name: "attaching", back: { name: "setup" } });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (!v && q.required) {
|
|
129
|
+
addLog(`This one's needed${q.hint ? ` — ${q.hint}` : ""}. (Or type 'back'.)`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (v && q.validate) {
|
|
133
|
+
const err = q.validate(v);
|
|
134
|
+
if (err) {
|
|
135
|
+
addLog(err);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const next = { ...answers, [q.key]: v };
|
|
140
|
+
setAnswers(next);
|
|
141
|
+
// Echo the answer but keep the history clean: collapse long pasted blobs (e.g. several
|
|
142
|
+
// upload URLs) to a short preview so the decision trail stays readable.
|
|
143
|
+
const shown = v.length > 88 ? v.slice(0, 88).replace(/\s+\S*$/, "") + " …" : v;
|
|
144
|
+
addLog(`[${qi + 1}/${QUESTIONS.length}] ${q.q} ❯ ${shown || "(skipped)"}`);
|
|
145
|
+
if (qi + 1 >= QUESTIONS.length) {
|
|
146
|
+
setScreen({ name: "synth" });
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
setQi(qi + 1);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Slash-command handler (works from any input screen).
|
|
153
|
+
function runCommand(name) {
|
|
154
|
+
if (name !== "clear")
|
|
155
|
+
addLog(`› /${name}`); // echo so it's clear the command ran
|
|
156
|
+
switch (name) {
|
|
157
|
+
case "help":
|
|
158
|
+
addLog("Commands: " + COMMANDS.map((c) => `/${c.name} (${c.desc})`).join(" · "));
|
|
159
|
+
break;
|
|
160
|
+
case "clear":
|
|
161
|
+
setLog([]);
|
|
162
|
+
break;
|
|
163
|
+
case "version":
|
|
164
|
+
checkForUpdate().then((i) => addLog(versionLine(i)));
|
|
165
|
+
break;
|
|
166
|
+
case "quit":
|
|
167
|
+
app.exit();
|
|
168
|
+
break;
|
|
169
|
+
case "attach":
|
|
170
|
+
setScreen({ name: "attaching", back: screen });
|
|
171
|
+
break;
|
|
172
|
+
case "files":
|
|
173
|
+
if (!files.length)
|
|
174
|
+
addLog("No files attached yet — /attach to add one.");
|
|
175
|
+
else
|
|
176
|
+
files.forEach((f, i) => addLog(` [${i + 1}] ${f.filename} → ${f.url}`));
|
|
177
|
+
break;
|
|
178
|
+
case "back":
|
|
179
|
+
if (screen.name === "setup" && qi > 0)
|
|
180
|
+
setQi(qi - 1);
|
|
181
|
+
else if (profile)
|
|
182
|
+
setScreen({ name: "menu" });
|
|
183
|
+
break;
|
|
184
|
+
case "restart":
|
|
185
|
+
if (screen.name === "setup") {
|
|
186
|
+
setAnswers({});
|
|
187
|
+
setQi(0);
|
|
188
|
+
addLog("Starting fresh.");
|
|
189
|
+
}
|
|
190
|
+
break;
|
|
191
|
+
case "menu":
|
|
192
|
+
if (profile)
|
|
193
|
+
setScreen({ name: "menu" });
|
|
194
|
+
else
|
|
195
|
+
addLog("Finish setup first.");
|
|
196
|
+
break;
|
|
197
|
+
default:
|
|
198
|
+
addLog(`Unknown command: /${name}. Type /help.`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// ---- SYNTH ----
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
if (screen.name !== "synth")
|
|
204
|
+
return;
|
|
205
|
+
setSpin({ label: "Building your profile…", tokens: 0 });
|
|
206
|
+
(async () => {
|
|
207
|
+
try {
|
|
208
|
+
const p = await synthesizeProfile(answers, attachSummaries, addLog);
|
|
209
|
+
await saveProfile(p);
|
|
210
|
+
setProfile(p);
|
|
211
|
+
await refreshBadge(p);
|
|
212
|
+
setSpin(null);
|
|
213
|
+
addLog(`You're configured. ${p.personas.length} personas · ${p.sitePages?.length ?? 0} pages learned.`);
|
|
214
|
+
setScreen({ name: "menu" });
|
|
215
|
+
}
|
|
216
|
+
catch (e) {
|
|
217
|
+
setSpin(null);
|
|
218
|
+
addLog(`Setup failed: ${e.message}`);
|
|
219
|
+
setScreen({ name: "setup" });
|
|
220
|
+
}
|
|
221
|
+
})();
|
|
222
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
223
|
+
}, [screen.name]);
|
|
224
|
+
// ---- ATTACHING (upload wait, Ink-native) ----
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
if (screen.name !== "attaching")
|
|
227
|
+
return;
|
|
228
|
+
const back = screen.back;
|
|
229
|
+
let stop = false;
|
|
230
|
+
attachCancel.current = false;
|
|
231
|
+
setSpin({ label: "Waiting for your upload in the browser… (Esc to cancel)", tokens: 0 });
|
|
232
|
+
(async () => {
|
|
233
|
+
const session = await createUploadSession();
|
|
234
|
+
if (!session) {
|
|
235
|
+
setSpin(null);
|
|
236
|
+
addLog("Couldn't reach the upload service — continuing without an attachment.");
|
|
237
|
+
setScreen(back);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
addLog(`Opening your browser: ${session.uploadUrl}`);
|
|
241
|
+
addLog("Paste screenshots (⌘V), drag files in, or choose them — add as many as you like, then Done.");
|
|
242
|
+
openBrowser(session.uploadUrl);
|
|
243
|
+
const deadline = Date.now() + 300_000;
|
|
244
|
+
while (!stop && !attachCancel.current && Date.now() < deadline) {
|
|
245
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
246
|
+
if (attachCancel.current) {
|
|
247
|
+
setSpin(null);
|
|
248
|
+
addLog("Cancelled — continuing without an attachment.");
|
|
249
|
+
setScreen(back);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const { done, files: got } = await pollUploadStatus(session.statusUrl);
|
|
253
|
+
if (got.length)
|
|
254
|
+
setSpin({ label: `${got.length} file(s) uploaded — click Done when finished`, tokens: 0 });
|
|
255
|
+
if (done) {
|
|
256
|
+
setSpin(null);
|
|
257
|
+
for (const f of got) {
|
|
258
|
+
setSpin({ label: `Reading "${f.filename}"…`, tokens: 0 });
|
|
259
|
+
const summary = await summarizeAttachment(f.fileUrl);
|
|
260
|
+
addLog(`Got it — I read "${f.filename}": ${summary}`);
|
|
261
|
+
addLog(` URL (reference it in any answer): ${f.fileUrl}`);
|
|
262
|
+
setAttachSummaries((a) => [...a, `${f.filename}: ${summary}`]);
|
|
263
|
+
setFiles((a) => [...a, { filename: f.filename, url: f.fileUrl }]);
|
|
264
|
+
}
|
|
265
|
+
setSpin(null);
|
|
266
|
+
setScreen(back);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
setSpin(null);
|
|
271
|
+
if (!stop)
|
|
272
|
+
addLog("No files uploaded (timed out). Continuing.");
|
|
273
|
+
setScreen(back);
|
|
274
|
+
})();
|
|
275
|
+
return () => {
|
|
276
|
+
stop = true;
|
|
277
|
+
};
|
|
278
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
279
|
+
}, [screen.name]);
|
|
280
|
+
// ---- WHITE-LABEL: read the logo, redraw it as an ASCII mark ----
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
if (screen.name !== "brand-gen")
|
|
283
|
+
return;
|
|
284
|
+
const { company, logo } = screen;
|
|
285
|
+
setSpin({ label: "Reading your logo and redrawing your mark…", tokens: 0 });
|
|
286
|
+
(async () => {
|
|
287
|
+
try {
|
|
288
|
+
const b = await buildBrand(company, logo);
|
|
289
|
+
saveBrand(b);
|
|
290
|
+
setBrand(b);
|
|
291
|
+
setSpin(null);
|
|
292
|
+
addLog(`Branding set — welcome, ${company} Content Pro. Your mark now shows in the splash (engine stays 8ContentPro).`);
|
|
293
|
+
setScreen({ name: "menu" });
|
|
294
|
+
}
|
|
295
|
+
catch (e) {
|
|
296
|
+
setSpin(null);
|
|
297
|
+
addLog(`Couldn't read that logo: ${e.message}`);
|
|
298
|
+
setScreen({ name: "menu" });
|
|
299
|
+
}
|
|
300
|
+
})();
|
|
301
|
+
}, [screen.name]);
|
|
302
|
+
// ---- IDEAS (research) ----
|
|
303
|
+
useEffect(() => {
|
|
304
|
+
if (screen.name !== "art-ideas")
|
|
305
|
+
return;
|
|
306
|
+
const { kind, persona, regen } = screen;
|
|
307
|
+
// regen=false → just open the existing queue (no tokens spent). regen=true → research
|
|
308
|
+
// a fresh batch and append it. Browsing your saved topics must never cost a generation.
|
|
309
|
+
setSpin({ label: regen ? "Researching live trends + your keyword data…" : "Opening your topic queue…", tokens: 0 });
|
|
310
|
+
(async () => {
|
|
311
|
+
try {
|
|
312
|
+
if (regen) {
|
|
313
|
+
const fresh = await ideate(profile ?? undefined, undefined, (l) => setSpin((s) => (s ? { ...s, label: l.slice(0, 60) } : s)), (n) => setSpin((s) => (s ? { ...s, tokens: n } : s)), { type: kind, persona });
|
|
314
|
+
// Persist the new ideas (deduped) so they survive across sessions.
|
|
315
|
+
if (fresh.length)
|
|
316
|
+
addTopics(topicBrand, fresh.map((f) => ({ title: f.title, angle: f.angle, whyNow: f.whyNow, funnelStage: f.funnelStage, category: f.category || "General" })));
|
|
317
|
+
if (fresh.length)
|
|
318
|
+
addLog(`Added ${fresh.length} fresh idea${fresh.length > 1 ? "s" : ""} to your queue.`);
|
|
319
|
+
}
|
|
320
|
+
const saved = loadTopics(topicBrand);
|
|
321
|
+
const prop = gscPropertyFor(profile?.site);
|
|
322
|
+
const quickWins = prop ? await strikingDistanceFor(prop) : [];
|
|
323
|
+
setSpin(null);
|
|
324
|
+
if (!saved.length && !quickWins.length) {
|
|
325
|
+
addLog("No saved topics yet — research some or type your own.");
|
|
326
|
+
setScreen({ name: "art-topic" });
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
if (quickWins.length)
|
|
330
|
+
addLog("Quick wins = search terms you already rank on page 2 for. One strong article can push them onto page 1.");
|
|
331
|
+
setScreen({ name: "art-pick", saved, quickWins });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
catch (e) {
|
|
335
|
+
setSpin(null);
|
|
336
|
+
addLog(`Ideation failed: ${e.message}`);
|
|
337
|
+
setScreen({ name: "menu" });
|
|
338
|
+
}
|
|
339
|
+
})();
|
|
340
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
341
|
+
}, [screen.name]);
|
|
342
|
+
// ---- WORKING (article/asset generate, interruptible) ----
|
|
343
|
+
useEffect(() => {
|
|
344
|
+
if (screen.name !== "working")
|
|
345
|
+
return;
|
|
346
|
+
const label = screen.label;
|
|
347
|
+
const controller = new AbortController();
|
|
348
|
+
abort.current = controller;
|
|
349
|
+
setSpin({ label, tokens: 0 });
|
|
350
|
+
(async () => {
|
|
351
|
+
try {
|
|
352
|
+
if (label === "revise") {
|
|
353
|
+
const { docxPath, md } = await reviseArticle(lastMd.current ?? "", screen.feedback ?? "", {
|
|
354
|
+
workdir,
|
|
355
|
+
profile: profile ?? undefined,
|
|
356
|
+
abortController: controller,
|
|
357
|
+
onProgress: (l) => setSpin((s) => (s ? { ...s, label: l.slice(0, 60) } : s)),
|
|
358
|
+
onResult: (b) => addLog(b),
|
|
359
|
+
});
|
|
360
|
+
lastMd.current = md;
|
|
361
|
+
reviseCount.current += 1;
|
|
362
|
+
setSpin(null);
|
|
363
|
+
exec(`open -R "${docxPath}"`, () => { });
|
|
364
|
+
setScreen({ name: "done", msg: `Revised → ${docxPath}`, canRevise: true });
|
|
365
|
+
}
|
|
366
|
+
else if (label === "article") {
|
|
367
|
+
const brief = targetedBrief(profile ?? undefined, { idea: draft.current.idea, stage: draft.current.stage === "auto" ? undefined : draft.current.stage, feature: draft.current.feature });
|
|
368
|
+
const kw = await keywordContext(draft.current.idea);
|
|
369
|
+
const { docxPath, md } = await generateArticle(brief + kw, {
|
|
370
|
+
workdir,
|
|
371
|
+
profile: profile ?? undefined,
|
|
372
|
+
abortController: controller,
|
|
373
|
+
onProgress: (l) => setSpin((s) => (s ? { ...s, label: l.slice(0, 60) } : s)),
|
|
374
|
+
onResult: (b) => addLog(b),
|
|
375
|
+
onTokens: (n) => setSpin((s) => (s ? { ...s, tokens: n } : s)),
|
|
376
|
+
});
|
|
377
|
+
lastMd.current = md;
|
|
378
|
+
reviseCount.current = 0; // fresh article — reset the revise budget
|
|
379
|
+
setSpin(null);
|
|
380
|
+
exec(`open -R "${docxPath}"`, () => { }); // reveal the file in Finder
|
|
381
|
+
setScreen({ name: "done", msg: `Saved to ${docxPath}`, canRevise: true });
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
const brief = `Create a downloadable RESOURCE GUIDE / EBOOK (a gated lead magnet)${profile?.brand ? ` for ${profile.brand}` : ""} on: ${draft.current.idea}. ~1500-2500 words, H1 title, framework sections, checklists, callouts, no em-dashes. Markdown only.`;
|
|
385
|
+
const { pdfPath } = await generateGuide(brief, {
|
|
386
|
+
workdir,
|
|
387
|
+
profile: profile ?? undefined,
|
|
388
|
+
abortController: controller,
|
|
389
|
+
onProgress: (l) => setSpin((s) => (s ? { ...s, label: l.slice(0, 60) } : s)),
|
|
390
|
+
onResult: (b) => addLog(b),
|
|
391
|
+
onTokens: (n) => setSpin((s) => (s ? { ...s, tokens: n } : s)),
|
|
392
|
+
});
|
|
393
|
+
setSpin(null);
|
|
394
|
+
exec(`open -R "${pdfPath}"`, () => { });
|
|
395
|
+
setScreen({ name: "done", msg: `Saved to ${pdfPath}` });
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
catch (e) {
|
|
399
|
+
setSpin(null);
|
|
400
|
+
const aborted = e.name === "AbortError" || controller.signal.aborted;
|
|
401
|
+
addLog(aborted ? "Interrupted — nothing saved for that run." : `Generation failed: ${e.message}`);
|
|
402
|
+
setScreen({ name: "menu" });
|
|
403
|
+
}
|
|
404
|
+
finally {
|
|
405
|
+
abort.current = null;
|
|
406
|
+
}
|
|
407
|
+
})();
|
|
408
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
409
|
+
}, [screen.name]);
|
|
410
|
+
// Esc interrupts an in-flight generation, or cancels the attach wait.
|
|
411
|
+
useInput((_input, key) => {
|
|
412
|
+
if (!key.escape)
|
|
413
|
+
return;
|
|
414
|
+
if (screen.name === "working" && abort.current) {
|
|
415
|
+
abort.current.abort();
|
|
416
|
+
addLog("Interrupting…");
|
|
417
|
+
}
|
|
418
|
+
else if (screen.name === "attaching") {
|
|
419
|
+
attachCancel.current = true;
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
// Keep the status hint in sync with the screen.
|
|
423
|
+
useEffect(() => {
|
|
424
|
+
if (screen.name === "setup")
|
|
425
|
+
setHint(HINT_SETUP);
|
|
426
|
+
else if (["working", "synth", "art-ideas", "attaching", "brand-gen"].includes(screen.name))
|
|
427
|
+
setHint(HINT_WORK);
|
|
428
|
+
else if (["art-topic", "asset-topic"].includes(screen.name))
|
|
429
|
+
setHint(HINT_INPUT);
|
|
430
|
+
else
|
|
431
|
+
setHint(HINT_MENU);
|
|
432
|
+
}, [screen.name]);
|
|
433
|
+
// ---- RETUNE / GSC helpers ----
|
|
434
|
+
async function runTune(what) {
|
|
435
|
+
setScreen({ name: "working", label: "tune" }); // reuse the spinner shell
|
|
436
|
+
setSpin({ label: "Updating…", tokens: 0 });
|
|
437
|
+
try {
|
|
438
|
+
let p = profile;
|
|
439
|
+
if (what === "sitemap")
|
|
440
|
+
p = await tuneSitemap(p, addLog);
|
|
441
|
+
else if (what === "personas")
|
|
442
|
+
p = await tunePersonas(p, addLog);
|
|
443
|
+
else if (what === "services")
|
|
444
|
+
p = await tuneServices(p, addLog);
|
|
445
|
+
else if (what === "voice")
|
|
446
|
+
p = await tuneVoice(p);
|
|
447
|
+
await saveProfile(p);
|
|
448
|
+
setProfile(p);
|
|
449
|
+
addLog("Saved.");
|
|
450
|
+
}
|
|
451
|
+
catch (e) {
|
|
452
|
+
addLog(`Couldn't update: ${e.message}`);
|
|
453
|
+
}
|
|
454
|
+
setSpin(null);
|
|
455
|
+
setScreen({ name: "menu" });
|
|
456
|
+
}
|
|
457
|
+
// ---- RENDER ----
|
|
458
|
+
const interactive = () => {
|
|
459
|
+
if (spin)
|
|
460
|
+
return _jsx(Spinner, { message: spin.label, tokens: spin.tokens });
|
|
461
|
+
switch (screen.name) {
|
|
462
|
+
case "setup": {
|
|
463
|
+
const q = QUESTIONS[qi];
|
|
464
|
+
return _jsx(InputBox, { label: `[${qi + 1}/${QUESTIONS.length}] ${q.q}`, initial: answers[q.key] ?? "", history: history, commands: COMMANDS, onCommand: runCommand, onSubmit: submitSetup }, `q${qi}`);
|
|
465
|
+
}
|
|
466
|
+
case "menu": {
|
|
467
|
+
const items = [
|
|
468
|
+
{ label: "Write a blog article", value: "article" },
|
|
469
|
+
{ label: "Create a gated asset (ebook / guide)", value: "asset" },
|
|
470
|
+
{ label: "Retune my setup", value: "tune" },
|
|
471
|
+
{ label: "Connect Google Search Console", value: "gsc" },
|
|
472
|
+
{ label: "White-label branding (your logo + name)", value: "brand" },
|
|
473
|
+
{ label: "Exit", value: "exit" },
|
|
474
|
+
];
|
|
475
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "What would you like to do?" }), _jsx(Menu, { items: items, onSelect: (v) => {
|
|
476
|
+
echo("What would you like to do?", items.find((i) => i.value === v).label);
|
|
477
|
+
if (v === "article")
|
|
478
|
+
setScreen({ name: "art-topic" });
|
|
479
|
+
else if (v === "asset")
|
|
480
|
+
setScreen({ name: "asset-topic" });
|
|
481
|
+
else if (v === "tune")
|
|
482
|
+
setScreen({ name: "tune" });
|
|
483
|
+
else if (v === "gsc")
|
|
484
|
+
setScreen({ name: "gsc" });
|
|
485
|
+
else if (v === "brand")
|
|
486
|
+
setScreen({ name: "brand-name" });
|
|
487
|
+
else
|
|
488
|
+
app.exit();
|
|
489
|
+
} })] }));
|
|
490
|
+
}
|
|
491
|
+
case "art-topic":
|
|
492
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "New article \u2014 what should it be about?" }), _jsx(Text, { dimColor: true, children: "Type a topic in a few words. Or press Enter (or type \"ideas\") to let 8ContentPro research fresh, on-brand ideas for you." }), _jsx(InputBox, { placeholder: "e.g. legal billing automation \u2014 or 'ideas'", history: history, commands: COMMANDS, onCommand: runCommand, onSubmit: (t) => {
|
|
493
|
+
const v = t.trim();
|
|
494
|
+
if (v.toLowerCase() === "ideas" || v === "") {
|
|
495
|
+
// If a queue already exists, open it (no tokens); else research a first batch.
|
|
496
|
+
if (loadTopics(topicBrand).length) {
|
|
497
|
+
echo("New article", "Browse my saved topics");
|
|
498
|
+
setScreen({ name: "art-ideas", regen: false });
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
echo("New article", "Research ideas for me");
|
|
502
|
+
setScreen({ name: "art-kind" });
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
echo("New article — topic", v);
|
|
507
|
+
draft.current.idea = v;
|
|
508
|
+
setScreen({ name: "art-funnel", idea: v });
|
|
509
|
+
}
|
|
510
|
+
}, onCancel: () => setScreen({ name: "menu" }) })] }));
|
|
511
|
+
case "art-kind": {
|
|
512
|
+
const items = [
|
|
513
|
+
{ label: "A mix (one of each type)", value: "mix" },
|
|
514
|
+
{ label: "Thought leadership / contrarian POV", value: "thought-leadership" },
|
|
515
|
+
{ label: "Educational how-to / evergreen", value: "how-to" },
|
|
516
|
+
{ label: "Timely / news reaction", value: "timely" },
|
|
517
|
+
{ label: "Comparison / buyer-decision", value: "comparison" },
|
|
518
|
+
{ label: "Data / research-driven", value: "data" },
|
|
519
|
+
];
|
|
520
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "What kind of ideas should I research?" }), _jsx(Text, { dimColor: true, children: "This shapes the angle of the topics I bring back. Pick a mix if you're not sure." }), _jsx(Menu, { items: items, onSelect: (v) => {
|
|
521
|
+
echo("Idea style", items.find((i) => i.value === v).label);
|
|
522
|
+
setScreen({ name: "art-persona", kind: v });
|
|
523
|
+
} })] }));
|
|
524
|
+
}
|
|
525
|
+
case "art-persona": {
|
|
526
|
+
const s = screen;
|
|
527
|
+
const items = [{ label: "Spread across all", value: "__all" }, ...(profile?.personas.map((p) => ({ label: `${p.name} (${p.role})`, value: p.name })) ?? [])];
|
|
528
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Who should these ideas speak to?" }), _jsx(Text, { dimColor: true, children: "The buyer the article targets. \"Spread across all\" mixes ideas for every persona you set up." }), _jsx(Menu, { items: items, onSelect: (v) => {
|
|
529
|
+
echo("Audience", items.find((i) => i.value === v).label);
|
|
530
|
+
setScreen({ name: "art-ideas", kind: s.kind, persona: v === "__all" ? undefined : v, regen: true });
|
|
531
|
+
} })] }));
|
|
532
|
+
}
|
|
533
|
+
case "art-pick": {
|
|
534
|
+
const s = screen;
|
|
535
|
+
const MORE = "__more__";
|
|
536
|
+
const rows = [];
|
|
537
|
+
// Saved topics, grouped by category (a category header per bucket). Used ones show
|
|
538
|
+
// struck through with a ✓ but stay selectable. Newest categories first.
|
|
539
|
+
const byCat = new Map();
|
|
540
|
+
for (const t of s.saved) {
|
|
541
|
+
const c = t.category || "General";
|
|
542
|
+
if (!byCat.has(c))
|
|
543
|
+
byCat.set(c, []);
|
|
544
|
+
byCat.get(c).push(t);
|
|
545
|
+
}
|
|
546
|
+
for (const [cat, topics] of byCat) {
|
|
547
|
+
const openCount = topics.filter((t) => !t.used).length;
|
|
548
|
+
rows.push({ type: "header", label: `${cat.toUpperCase()} (${openCount} new · ${topics.length} total)` });
|
|
549
|
+
rows.push(...topics.map((t) => ({
|
|
550
|
+
type: "item",
|
|
551
|
+
title: t.title,
|
|
552
|
+
desc: t.angle,
|
|
553
|
+
value: `${t.title} — ${t.angle}`,
|
|
554
|
+
badge: t.used ? "used" : t.funnelStage ?? "Idea",
|
|
555
|
+
badgeColor: t.used ? "gray" : "magenta",
|
|
556
|
+
used: t.used,
|
|
557
|
+
})));
|
|
558
|
+
}
|
|
559
|
+
if (s.quickWins.length) {
|
|
560
|
+
rows.push({ type: "header", label: "QUICK WINS (rank page 2 now · one strong piece → page 1)" });
|
|
561
|
+
rows.push(...s.quickWins.slice(0, 4).map((q) => ({
|
|
562
|
+
type: "item",
|
|
563
|
+
title: `"${q.query}"`,
|
|
564
|
+
desc: `Currently #${Math.round(q.position)}, ${q.impressions} monthly impressions.`,
|
|
565
|
+
value: `${q.query} — a quick-win piece: this brand already ranks ~#${Math.round(q.position)} for this on page 2 with real impressions; write the definitive article to push it onto page 1`,
|
|
566
|
+
badge: "Quick win",
|
|
567
|
+
badgeColor: "green",
|
|
568
|
+
})));
|
|
569
|
+
}
|
|
570
|
+
// Always offer a "generate more" action at the bottom — adds fresh ideas without
|
|
571
|
+
// losing the saved ones.
|
|
572
|
+
rows.push({ type: "header", label: "MORE" });
|
|
573
|
+
rows.push({ type: "item", title: "✨ Generate more ideas", desc: "Research fresh angles and add them to your queue — your existing topics stay.", value: MORE, badge: "action", badgeColor: "cyan" });
|
|
574
|
+
const usedCount = s.saved.filter((t) => t.used).length;
|
|
575
|
+
const summary = s.saved.length ? `Your topic queue — ${s.saved.length} saved · ${usedCount} used (struck through) · ${s.saved.length - usedCount} open` : "Your topic queue";
|
|
576
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Pick what to write about:" }), _jsx(Text, { dimColor: true, children: summary }), _jsx(Text, { dimColor: true, children: "\u2191\u2193 move \u00B7 Enter to write this one. Struck-through = already used. Each idea's funnel stage is the tag." }), _jsx(Picker, { rows: rows, onSelect: (v) => {
|
|
577
|
+
if (v === MORE) {
|
|
578
|
+
// Pick a style/audience for the fresh batch, then research + append.
|
|
579
|
+
setScreen({ name: "art-kind" });
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
draft.current.idea = v;
|
|
583
|
+
const picked = s.saved.find((t) => `${t.title} — ${t.angle}` === v);
|
|
584
|
+
if (picked)
|
|
585
|
+
markUsed(topicBrand, picked.title); // strike it through next time
|
|
586
|
+
const stage = picked?.funnelStage;
|
|
587
|
+
if (stage) {
|
|
588
|
+
// A trend idea already carries its funnel stage (the [MOF] tag) — don't ask again.
|
|
589
|
+
draft.current.stage = stage;
|
|
590
|
+
echo("Writing", picked.title);
|
|
591
|
+
addLog(`Angle: ${stageLabel(stage)} · already set by this idea`);
|
|
592
|
+
if (profile?.services?.length)
|
|
593
|
+
setScreen({ name: "art-feature", idea: v, stage });
|
|
594
|
+
else
|
|
595
|
+
setScreen({ name: "working", label: "article" });
|
|
596
|
+
}
|
|
597
|
+
else {
|
|
598
|
+
// A quick-win or free-text topic has no stage yet — ask the angle next.
|
|
599
|
+
echo("Writing", v.split(" — ")[0]);
|
|
600
|
+
setScreen({ name: "art-funnel", idea: v });
|
|
601
|
+
}
|
|
602
|
+
}, onCancel: () => setScreen({ name: "menu" }) })] }));
|
|
603
|
+
}
|
|
604
|
+
case "art-funnel": {
|
|
605
|
+
const s = screen;
|
|
606
|
+
const items = [
|
|
607
|
+
{ label: "Let 8ContentPro pick the best fit (recommended)", value: "auto" },
|
|
608
|
+
{ label: "Awareness — explain the problem (top of funnel)", value: "TOF" },
|
|
609
|
+
{ label: "Consideration — compare the options (middle)", value: "MOF" },
|
|
610
|
+
{ label: "Decision — make the case to buy (bottom)", value: "BOF" },
|
|
611
|
+
];
|
|
612
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "What angle should this article take?" }), _jsx(Text, { dimColor: true, children: "How ready-to-buy the reader is. It sets how much the piece educates vs. sells." }), _jsx(Menu, { items: items, onSelect: (v) => {
|
|
613
|
+
echo("Angle", items.find((i) => i.value === v).label);
|
|
614
|
+
draft.current.stage = v;
|
|
615
|
+
if (profile?.services?.length)
|
|
616
|
+
setScreen({ name: "art-feature", idea: s.idea, stage: v });
|
|
617
|
+
else
|
|
618
|
+
setScreen({ name: "working", label: "article" });
|
|
619
|
+
} })] }));
|
|
620
|
+
}
|
|
621
|
+
case "art-feature": {
|
|
622
|
+
const items = [{ label: "Let 8ContentPro decide (recommended)", value: "__none" }, ...(profile?.services.map((s) => ({ label: s.name, value: s.name })) ?? [])];
|
|
623
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Feature one of your products or services?" }), _jsx(Text, { dimColor: true, children: "The article can naturally lead toward one offering with an internal link. Or let it choose based on the topic." }), _jsx(Menu, { items: items, onSelect: (v) => {
|
|
624
|
+
echo("Feature", v === "__none" ? "Let it decide" : v);
|
|
625
|
+
draft.current.feature = v === "__none" ? undefined : v;
|
|
626
|
+
setScreen({ name: "working", label: "article" });
|
|
627
|
+
} })] }));
|
|
628
|
+
}
|
|
629
|
+
case "asset-topic":
|
|
630
|
+
return (_jsx(InputBox, { label: "What should the resource guide / ebook be about?", history: history, commands: COMMANDS, onCommand: runCommand, onSubmit: (t) => {
|
|
631
|
+
const v = t.trim();
|
|
632
|
+
if (!v)
|
|
633
|
+
return setScreen({ name: "menu" });
|
|
634
|
+
draft.current.idea = v;
|
|
635
|
+
setScreen({ name: "working", label: "asset" });
|
|
636
|
+
}, onCancel: () => setScreen({ name: "menu" }) }));
|
|
637
|
+
case "tune": {
|
|
638
|
+
const items = [
|
|
639
|
+
{ label: "Everything (re-run full setup — re-crawls your guides + pages)", value: "all" },
|
|
640
|
+
{ label: "Website pages (re-crawl sitemap)", value: "sitemap" },
|
|
641
|
+
{ label: "Personas", value: "personas" },
|
|
642
|
+
{ label: "Services / products", value: "services" },
|
|
643
|
+
{ label: "Brand voice", value: "voice" },
|
|
644
|
+
{ label: "Back", value: "back" },
|
|
645
|
+
];
|
|
646
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Retune your setup \u2014 what do you want to update?" }), _jsx(Text, { dimColor: true, children: "Change one thing without redoing the whole onboarding. Your other settings stay as they are." }), _jsx(Menu, { items: items, onSelect: (v) => {
|
|
647
|
+
if (v !== "back")
|
|
648
|
+
echo("Retune", items.find((i) => i.value === v).label);
|
|
649
|
+
if (v === "back")
|
|
650
|
+
setScreen({ name: "menu" });
|
|
651
|
+
else if (v === "all") {
|
|
652
|
+
// Pre-fill from the current profile so they can Enter through and just
|
|
653
|
+
// update what changed (e.g. give the root /resources/ URL at 7/7).
|
|
654
|
+
setAnswers({
|
|
655
|
+
brand: profile?.brand ?? "",
|
|
656
|
+
site: profile?.site ?? "",
|
|
657
|
+
summary: profile?.summary ?? "",
|
|
658
|
+
buyers: profile?.personas.map((p) => `${p.name} (${p.role})`).join("; ") ?? "",
|
|
659
|
+
services: profile?.services.map((s) => s.name).join(", ") ?? "",
|
|
660
|
+
voice: profile?.voice ?? "",
|
|
661
|
+
assets: profile?.assets?.[0]?.url ?? "",
|
|
662
|
+
competitors: profile?.competitors?.join(", ") ?? "",
|
|
663
|
+
});
|
|
664
|
+
setQi(0);
|
|
665
|
+
setScreen({ name: "setup" });
|
|
666
|
+
}
|
|
667
|
+
else
|
|
668
|
+
runTune(v);
|
|
669
|
+
} })] }));
|
|
670
|
+
}
|
|
671
|
+
case "gsc": {
|
|
672
|
+
const prop = gscPropertyFor(profile?.site);
|
|
673
|
+
return (_jsx(InputBox, { label: `Add ${GSC_SERVICE_ACCOUNT} as a Restricted user in your Search Console, then confirm your property:`, initial: prop, onSubmit: async (p) => {
|
|
674
|
+
const property = p.trim();
|
|
675
|
+
if (!property)
|
|
676
|
+
return setScreen({ name: "menu" });
|
|
677
|
+
setScreen({ name: "working", label: "tune" });
|
|
678
|
+
setSpin({ label: "Verifying Search Console access…", tokens: 0 });
|
|
679
|
+
await gscRegister(property, profile?.brand);
|
|
680
|
+
const v = await gscVerify(property);
|
|
681
|
+
setSpin(null);
|
|
682
|
+
addLog(v?.verified ? `Connected — I can see ${property}. Quick-wins will use your real rankings.` : "Not visible yet — give Google a minute after adding the user, then retry.");
|
|
683
|
+
setScreen({ name: "menu" });
|
|
684
|
+
}, onCancel: () => setScreen({ name: "menu" }) }));
|
|
685
|
+
}
|
|
686
|
+
case "brand-name":
|
|
687
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "White-label this install \u2014 your company name" }), _jsxs(Text, { dimColor: true, children: ["It becomes \"", "{Company}", " Content Pro\" in the splash. The engine name (8ContentPro) always stays \u2014 it's the model, like Claude's."] }), _jsx(InputBox, { initial: brand.companyName ?? "", placeholder: "e.g. ProfitSolv", onSubmit: (t) => {
|
|
688
|
+
const v = t.trim();
|
|
689
|
+
if (!v)
|
|
690
|
+
return setScreen({ name: "menu" });
|
|
691
|
+
echo("Company name", v);
|
|
692
|
+
setScreen({ name: "brand-logo", company: v });
|
|
693
|
+
}, onCancel: () => setScreen({ name: "menu" }) })] }));
|
|
694
|
+
case "brand-logo": {
|
|
695
|
+
const s = screen;
|
|
696
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Your logo \u2014 so I can redraw your mark in the splash" }), _jsx(Text, { dimColor: true, children: "Drag your logo file into the terminal (or paste its path). Just the icon/symbol works best \u2014 I'll redraw it as an ASCII mark in your brand color, the way our \"8\" is drawn." }), _jsx(InputBox, { placeholder: "/path/to/logo.png", onSubmit: (t) => {
|
|
697
|
+
const v = t.trim().replace(/^['"]|['"]$/g, "");
|
|
698
|
+
if (!v)
|
|
699
|
+
return setScreen({ name: "menu" });
|
|
700
|
+
echo("Logo", v);
|
|
701
|
+
setScreen({ name: "brand-gen", company: s.company, logo: v });
|
|
702
|
+
}, onCancel: () => setScreen({ name: "menu" }) })] }));
|
|
703
|
+
}
|
|
704
|
+
case "done": {
|
|
705
|
+
const revisesLeft = MAX_REVISES - reviseCount.current;
|
|
706
|
+
const canRevise = Boolean(screen.canRevise && lastMd.current && revisesLeft > 0);
|
|
707
|
+
const items = canRevise
|
|
708
|
+
? [
|
|
709
|
+
{ label: `Revise it — give feedback (${revisesLeft} left)`, value: "revise" },
|
|
710
|
+
{ label: "Back to menu", value: "menu" },
|
|
711
|
+
]
|
|
712
|
+
: [{ label: "Back to menu", value: "menu" }];
|
|
713
|
+
const exhausted = Boolean(screen.canRevise && lastMd.current && revisesLeft <= 0);
|
|
714
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "green", children: ["\u2713 ", screen.msg] }), canRevise ? _jsx(Text, { dimColor: true, children: "Not quite right? Tell it what to change (e.g. \"punchier intro, drop the section on X, add a real example\") \u2014 it edits this same piece." }) : null, exhausted ? _jsxs(Text, { dimColor: true, children: ["You've used all ", MAX_REVISES, " revises for this article \u2014 edit it directly in Word from here, or write a new one."] }) : null, _jsx(Menu, { items: items, onSelect: (v) => {
|
|
715
|
+
echo(canRevise ? "This article" : "Done", items.find((i) => i.value === v).label);
|
|
716
|
+
setScreen(v === "revise" ? { name: "revise-input" } : { name: "menu" });
|
|
717
|
+
} })] }));
|
|
718
|
+
}
|
|
719
|
+
case "revise-input":
|
|
720
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "What should I change about this article?" }), _jsxs(Text, { dimColor: true, children: ["Say it in your own words \u2014 as much detail as you like. It edits the existing draft (not a rewrite) and re-checks it stays under ", AI_THRESHOLD, "% AI."] }), _jsx(InputBox, { placeholder: "e.g. saw the intro is slow \u2014 make it punchier, and add a concrete law-firm example in the middle", history: history, commands: COMMANDS, onCommand: runCommand, onSubmit: (t) => {
|
|
721
|
+
const v = t.trim();
|
|
722
|
+
if (!v)
|
|
723
|
+
return setScreen({ name: "done", msg: "No changes made.", canRevise: true });
|
|
724
|
+
echo("Revise", v);
|
|
725
|
+
setScreen({ name: "working", label: "revise", feedback: v });
|
|
726
|
+
}, onCancel: () => setScreen({ name: "done", msg: "No changes made.", canRevise: true }) })] }));
|
|
727
|
+
default:
|
|
728
|
+
return _jsx(Text, { dimColor: true, children: "\u2026" });
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { brand: brand }), _jsx(Box, { flexDirection: "column", children: log.map((item) => (_jsxs(Text, { children: ["\u258E ", item.text] }, item.id))) }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: interactive() }), _jsx(StatusBar, { hint: hint, badge: badge })] }));
|
|
732
|
+
}
|
|
733
|
+
//# sourceMappingURL=App.js.map
|