@tenyokjs/devdevian-cli 0.5.1-alpha.1 → 0.6.1-alpha.1

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/lib/tui.mjs CHANGED
@@ -48,6 +48,18 @@ import { editExternal } from "./external-editor.mjs";
48
48
  import { markdownRows } from "./rich-text.mjs";
49
49
  import { problemMessage } from "./problems.mjs";
50
50
  import { ReviewWorkspace, codeRange } from "./review-workspace.mjs";
51
+ import {
52
+ catalogFields,
53
+ initialCatalog,
54
+ catalogDocument,
55
+ prepareCatalog,
56
+ publishCatalog,
57
+ saveCalendar,
58
+ validateFilter,
59
+ localTime,
60
+ viewerZone,
61
+ webURL,
62
+ } from "./catalog.mjs";
51
63
  const h = React.createElement;
52
64
  const itemsOf = (value) => (Array.isArray(value) ? value : value?.items || []);
53
65
  const stamp = (value) => {
@@ -74,6 +86,8 @@ const labelOf = (route) =>
74
86
  community: "Community",
75
87
  feedback: "Code feedback",
76
88
  thread: "Reply thread",
89
+ project: "Project",
90
+ event: "Event",
77
91
  }[route.view] ||
78
92
  "Workspace";
79
93
  function openBrowser(url) {
@@ -92,6 +106,8 @@ function openBrowser(url) {
92
106
  child.unref();
93
107
  }
94
108
  function fieldsFor(form) {
109
+ if (["catalog", "catalogFilter", "calendar"].includes(form.kind))
110
+ return catalogFields(form);
95
111
  if (form.kind === "appearance") return appearanceFields;
96
112
  if (form.kind === "publication")
97
113
  return [
@@ -331,7 +347,15 @@ function listRows(route, data, index, width, colors, density = "comfortable") {
331
347
  let title = item.title || item.name || "",
332
348
  meta = "",
333
349
  body = "";
334
- if (route.view === "drafts") {
350
+ if (route.view === "projects") {
351
+ title = item.name;
352
+ meta = `${item.stage} · #${item.topic} · ${item.founder_name}`;
353
+ body = item.tagline || item.description;
354
+ } else if (route.view === "events") {
355
+ title = (item.cancelled_at ? "[Cancelled] " : "") + item.title;
356
+ meta = `${stamp(item.start_at)} · ${item.organizer_name}`;
357
+ body = item.description;
358
+ } else if (route.view === "drafts") {
335
359
  title = item.form.values.title || item.form.title;
336
360
  meta = "Local draft · " + stamp(item.savedAt);
337
361
  body =
@@ -423,6 +447,8 @@ function listRows(route, data, index, width, colors, density = "comfortable") {
423
447
  });
424
448
  }
425
449
  function documentRows(route, data, width, colors) {
450
+ if (route.view === "project" || route.view === "event")
451
+ return markdownRows(catalogDocument(route, data), width, colors);
426
452
  if (route.view !== "post" || (route.tab && route.tab !== "overview"))
427
453
  return contentLines(route, data).flatMap((line) => wrap(line, width));
428
454
  const post = data?.post;
@@ -804,6 +830,20 @@ function App({ initialConfig, flags, root, initialAppearance }) {
804
830
  await queueDraft(() => drafts.remove(draftIdentity(value)));
805
831
  }
806
832
  async function restoreDraft(item) {
833
+ if (
834
+ item.form.kind === "catalog" &&
835
+ item.form.catalogProgress?.completedId
836
+ ) {
837
+ navigate({
838
+ view: item.form.entity,
839
+ id: item.form.catalogProgress.completedId,
840
+ page: 1,
841
+ });
842
+ setNotice(
843
+ "This draft was already published. Opened the existing publication.",
844
+ );
845
+ return;
846
+ }
807
847
  await task(async () => {
808
848
  const saved = await drafts.read(item.id),
809
849
  next = { ...saved.form, draftId: item.id };
@@ -842,6 +882,8 @@ function App({ initialConfig, flags, root, initialAppearance }) {
842
882
  }
843
883
  }
844
884
  }
885
+ if (next.kind === "catalog")
886
+ next.topics = itemsOf(await api("/topics")).map((t) => [t.id, t.name]);
845
887
  openForm(next);
846
888
  setNotice(
847
889
  "Restored local draft. Check the code selection before publishing.",
@@ -1059,6 +1101,128 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1059
1101
  setCommandCursor(0);
1060
1102
  }
1061
1103
  }
1104
+ async function composeCatalog(entity, editing = false) {
1105
+ await task(async () => {
1106
+ const row = editing ? await api(`/${entity}s/${route.id}`) : {};
1107
+ if (editing && row.user_id !== user.id)
1108
+ throw Error("Only the owner can edit this publication.");
1109
+ const topics = itemsOf(await api("/topics")).map((t) => [t.id, t.name]);
1110
+ const values = initialCatalog(entity, row);
1111
+ if (!topics.some(([id]) => id === values.topic))
1112
+ values.topic = topics[0]?.[0] || "web";
1113
+ openForm({
1114
+ kind: "catalog",
1115
+ entity,
1116
+ entityId: row.id,
1117
+ revision: row.revision,
1118
+ existing: row,
1119
+ title: (editing ? "Edit your " : "Create a ") + entity,
1120
+ topics,
1121
+ values,
1122
+ });
1123
+ });
1124
+ }
1125
+ async function catalogFilter() {
1126
+ await task(async () => {
1127
+ const entity = route.view === "projects" ? "project" : "event";
1128
+ const topics = itemsOf(await api("/topics")).map((t) => [t.id, t.name]);
1129
+ openForm({
1130
+ kind: "catalogFilter",
1131
+ entity,
1132
+ title: "Filter " + route.view,
1133
+ topics,
1134
+ values: {
1135
+ query: route.query || "",
1136
+ topic: route.topic || "",
1137
+ stage: route.stage || "",
1138
+ author: route.author || "",
1139
+ status: route.status || "",
1140
+ date: route.date || "",
1141
+ timezone: route.timezone || viewerZone(),
1142
+ mine: route.mine || "",
1143
+ order: route.order || (entity === "project" ? "newest" : "earliest"),
1144
+ },
1145
+ });
1146
+ });
1147
+ }
1148
+ function catalogAction(id) {
1149
+ const row = current?.project || current?.event;
1150
+ if (!row) return;
1151
+ if (id === "edit") {
1152
+ composeCatalog(route.view, true);
1153
+ return;
1154
+ }
1155
+ if (id === "conversation") {
1156
+ navigate({ view: "post", id: row.post_id, tab: "overview", page: 1 });
1157
+ return;
1158
+ }
1159
+ if (id === "calendar") {
1160
+ openForm({
1161
+ kind: "calendar",
1162
+ eventId: row.id,
1163
+ title: "Export calendar",
1164
+ values: { path: `devdevian-event-${row.id}.ics` },
1165
+ });
1166
+ return;
1167
+ }
1168
+ if (id === "attend") {
1169
+ const attending = !row.viewer_attending;
1170
+ askConfirm(
1171
+ attending ? "Attend this event?" : "Leave this event?",
1172
+ row.title,
1173
+ () =>
1174
+ task(async () => {
1175
+ await api(`/events/${row.id}/attendance`, {
1176
+ method: "PUT",
1177
+ body: { attending },
1178
+ });
1179
+ invalidate(
1180
+ attending
1181
+ ? "You're attending. Your browser account sees the same update."
1182
+ : "You left the event.",
1183
+ );
1184
+ }),
1185
+ );
1186
+ return;
1187
+ }
1188
+ if (id === "lifecycle") {
1189
+ if (row.user_id !== user.id) {
1190
+ setError("Only the organizer can manage this event.");
1191
+ return;
1192
+ }
1193
+ const action = row.cancelled_at ? "restore" : "cancel";
1194
+ askConfirm(
1195
+ action === "cancel" ? "Cancel your event?" : "Restore your event?",
1196
+ "Attendees will receive the event update.",
1197
+ () =>
1198
+ task(async () => {
1199
+ await api(`/events/${row.id}/lifecycle`, {
1200
+ method: "POST",
1201
+ body: { action, revision: row.revision },
1202
+ });
1203
+ invalidate("Event updated.");
1204
+ }),
1205
+ );
1206
+ return;
1207
+ }
1208
+ const target =
1209
+ id === "website"
1210
+ ? row.url
1211
+ : id === "source"
1212
+ ? row.repo
1213
+ : id === "meeting"
1214
+ ? row.url
1215
+ : config.origin +
1216
+ (route.view === "project"
1217
+ ? "/app#post/" + row.post_id
1218
+ : "/app#events/" + row.id);
1219
+ try {
1220
+ openBrowser(webURL(target));
1221
+ setNotice("Opened in your browser.");
1222
+ } catch (error) {
1223
+ setError(error.message);
1224
+ }
1225
+ }
1062
1226
  function openAppearance() {
1063
1227
  openForm({
1064
1228
  kind: "appearance",
@@ -1234,6 +1398,28 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1234
1398
  }
1235
1399
  async function prepare() {
1236
1400
  if (!form) return;
1401
+ if (form.kind === "catalogFilter") {
1402
+ await task(async () => {
1403
+ const values = validateFilter(form.values);
1404
+ setForm(null);
1405
+ navigate({ view: form.entity + "s", ...values, page: 1 });
1406
+ });
1407
+ return;
1408
+ }
1409
+ if (form.kind === "calendar") {
1410
+ await task(async () => {
1411
+ const path = await saveCalendar(
1412
+ api,
1413
+ root,
1414
+ form.eventId,
1415
+ form.values.path,
1416
+ );
1417
+ setForm(null);
1418
+ setFocus("content");
1419
+ setNotice("Calendar saved: " + clean(path));
1420
+ });
1421
+ return;
1422
+ }
1237
1423
  if (form.kind === "appearance") {
1238
1424
  await task(async () => {
1239
1425
  const saved = await saveAppearance(form.values);
@@ -1272,6 +1458,23 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1272
1458
  navigate({ view: "post", id, tab: "overview", page: 1 });
1273
1459
  return;
1274
1460
  }
1461
+ if (form.kind === "catalog") {
1462
+ if (form.catalogProgress?.uncertain)
1463
+ throw Error(
1464
+ "Publication outcome is unknown. Check your projects/events before creating another copy. Keep this draft for reference.",
1465
+ );
1466
+ const plan = await prepareCatalog(api, root, form);
1467
+ setPreview({
1468
+ catalog: plan,
1469
+ payload: plan.payload,
1470
+ title: form.title,
1471
+ method: form.entityId ? "PATCH" : "POST",
1472
+ url: `/${form.entity}s`,
1473
+ });
1474
+ setScroll(0);
1475
+ setYes(false);
1476
+ return;
1477
+ }
1275
1478
  let payload,
1276
1479
  method = "POST",
1277
1480
  url,
@@ -1363,10 +1566,22 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1363
1566
  async function publish() {
1364
1567
  if (!preview) return;
1365
1568
  await task(async () => {
1366
- const response = await api(preview.url, {
1367
- method: preview.method,
1368
- body: preview.payload,
1369
- });
1569
+ let working = form;
1570
+ const response = preview.catalog
1571
+ ? await publishCatalog(
1572
+ api,
1573
+ preview.catalog,
1574
+ form.catalogProgress,
1575
+ async (progress) => {
1576
+ working = { ...working, catalogProgress: progress };
1577
+ setForm(working);
1578
+ await queueDraft(() => drafts.save(working));
1579
+ },
1580
+ )
1581
+ : await api(preview.url, {
1582
+ method: preview.method,
1583
+ body: preview.payload,
1584
+ });
1370
1585
  cache.current.clear();
1371
1586
  let localWarning = "";
1372
1587
  try {
@@ -1378,7 +1593,9 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1378
1593
  setPreview(null);
1379
1594
  setForm(null);
1380
1595
  setScroll(0);
1381
- if (preview.url === "/posts")
1596
+ if (preview.catalog)
1597
+ navigate({ view: form.entity, id: response.id, page: 1 });
1598
+ else if (preview.url === "/posts")
1382
1599
  navigate({ view: "post", id: response.id, tab: "overview", page: 1 });
1383
1600
  else if (preview.url === "/communities")
1384
1601
  navigate({ view: "community", id: response.id, page: 1 });
@@ -1396,6 +1613,11 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1396
1613
  });
1397
1614
  }
1398
1615
  async function openSelected() {
1616
+ if (route.view === "projects" || route.view === "events") {
1617
+ if (selected)
1618
+ navigate({ view: route.view.slice(0, -1), id: selected.id, page: 1 });
1619
+ return;
1620
+ }
1399
1621
  if (route.view === "community" && !current?.community?.is_member) return;
1400
1622
  const item = selected;
1401
1623
  if (!item) return;
@@ -1636,6 +1858,10 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1636
1858
  }
1637
1859
  function runAction(id) {
1638
1860
  setActionIndex(null);
1861
+ if (id?.startsWith("catalog-")) {
1862
+ catalogAction(id.slice(8));
1863
+ return;
1864
+ }
1639
1865
  if (id === "read") {
1640
1866
  if (current?.post) {
1641
1867
  setReading(selected);
@@ -1694,6 +1920,10 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1694
1920
  );
1695
1921
  return;
1696
1922
  }
1923
+ if (parsed.command === "project" || parsed.command === "event") {
1924
+ navigate({ view: parsed.command, id: parsed.arg, page: 1 });
1925
+ return;
1926
+ }
1697
1927
  if (parsed.command === "appearance") {
1698
1928
  openAppearance();
1699
1929
  return;
@@ -1711,14 +1941,16 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1711
1941
  navigate({
1712
1942
  view: parsed.command,
1713
1943
  page: 1,
1714
- ...(parsed.command === "search" ? { query: parsed.arg } : {}),
1944
+ ...(["search", "projects", "events"].includes(parsed.command)
1945
+ ? { query: parsed.arg }
1946
+ : {}),
1715
1947
  });
1716
1948
  } catch (e) {
1717
1949
  setError(e.message);
1718
1950
  }
1719
1951
  }
1720
1952
  function askQuit() {
1721
- if (form?.kind === "appearance") {
1953
+ if (["appearance", "catalogFilter", "calendar"].includes(form?.kind)) {
1722
1954
  exit();
1723
1955
  return;
1724
1956
  }
@@ -1865,6 +2097,22 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1865
2097
  return;
1866
2098
  }
1867
2099
  if (form) {
2100
+ if (keyEvent.ctrl && input === "o" && form.kind === "catalog") {
2101
+ const active = fieldsFor(form)[field];
2102
+ if (["logo", "banner", "screenshots"].includes(active?.key)) {
2103
+ setPicker({ mediaKey: active.key });
2104
+ return;
2105
+ }
2106
+ }
2107
+ if (
2108
+ ["catalogFilter", "calendar"].includes(form.kind) &&
2109
+ keyEvent.escape
2110
+ ) {
2111
+ setForm(null);
2112
+ setFocus("content");
2113
+ setError("");
2114
+ return;
2115
+ }
1868
2116
  if (form.kind === "appearance" && keyEvent.escape) {
1869
2117
  setForm(null);
1870
2118
  setFocus("content");
@@ -1944,6 +2192,14 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1944
2192
  return;
1945
2193
  }
1946
2194
  if (keyEvent.return && !active.multiline) {
2195
+ if (
2196
+ form.kind === "catalog" &&
2197
+ ["logo", "banner", "screenshots"].includes(active.key) &&
2198
+ !form.values[active.key]
2199
+ ) {
2200
+ setPicker({ mediaKey: active.key });
2201
+ return;
2202
+ }
1947
2203
  if (active.key === "file" && !form.values.file) {
1948
2204
  setPicker("files");
1949
2205
  return;
@@ -2029,6 +2285,17 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2029
2285
  setReviewFocus((v) => (v === "code" ? "notes" : "code"));
2030
2286
  return;
2031
2287
  }
2288
+ if (
2289
+ (input === "f" || input === "F") &&
2290
+ ["projects", "events"].includes(route.view)
2291
+ ) {
2292
+ catalogFilter();
2293
+ return;
2294
+ }
2295
+ if (input === "j" && route.view === "event") {
2296
+ catalogAction("attend");
2297
+ return;
2298
+ }
2032
2299
  if (
2033
2300
  reviewing &&
2034
2301
  (input === "f" || input === "F") &&
@@ -2094,7 +2361,8 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2094
2361
  const prose =
2095
2362
  route.view === "help" ||
2096
2363
  (route.view === "post" && (!route.tab || route.tab === "overview")) ||
2097
- (route.view === "community" && !current?.community?.is_member);
2364
+ (route.view === "community" && !current?.community?.is_member) ||
2365
+ ["project", "event"].includes(route.view);
2098
2366
  if (
2099
2367
  keyEvent.upArrow ||
2100
2368
  keyEvent.downArrow ||
@@ -2166,6 +2434,10 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2166
2434
  return;
2167
2435
  }
2168
2436
  if (input === "n") {
2437
+ if (["projects", "events"].includes(route.view)) {
2438
+ composeCatalog(route.view.slice(0, -1));
2439
+ return;
2440
+ }
2169
2441
  if (route.view === "communities")
2170
2442
  openForm({
2171
2443
  kind: "community",
@@ -2194,6 +2466,10 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2194
2466
  return;
2195
2467
  }
2196
2468
  if (input === "e" || input === "E") {
2469
+ if (["project", "event"].includes(route.view)) {
2470
+ composeCatalog(route.view, true);
2471
+ return;
2472
+ }
2197
2473
  editSelected();
2198
2474
  return;
2199
2475
  }
@@ -2250,9 +2526,33 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2250
2526
  root,
2251
2527
  width,
2252
2528
  height: bodyHeight,
2253
- initial: picker,
2529
+ initial: picker.mediaKey ? "files" : picker,
2530
+ media: !!picker.mediaKey,
2254
2531
  onCancel: () => setPicker(null),
2255
2532
  onSelect: (source) => {
2533
+ if (picker.mediaKey) {
2534
+ const key = picker.mediaKey;
2535
+ setForm((old) => ({
2536
+ ...old,
2537
+ values: {
2538
+ ...old.values,
2539
+ [key]:
2540
+ key === "screenshots"
2541
+ ? [
2542
+ old.values[key] === "-" ? "" : old.values[key],
2543
+ source.path,
2544
+ ]
2545
+ .filter(Boolean)
2546
+ .join("\n")
2547
+ : source.path,
2548
+ },
2549
+ }));
2550
+ setPicker(null);
2551
+ setNotice(
2552
+ "Image selected locally. It is uploaded only after confirmation.",
2553
+ );
2554
+ return;
2555
+ }
2256
2556
  setForm((old) => ({
2257
2557
  ...old,
2258
2558
  source,
@@ -2416,8 +2716,29 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2416
2716
  const raw = [
2417
2717
  "AUDIENCE · " + audience,
2418
2718
  "",
2419
- ...(payload.title ? [payload.title, ""] : []),
2719
+ ...(payload.title || payload.name
2720
+ ? [payload.title || payload.name, ""]
2721
+ : []),
2420
2722
  payload.body || payload.reason || payload.description || "",
2723
+ ...(preview.catalog
2724
+ ? [
2725
+ "",
2726
+ ...Object.entries(payload)
2727
+ .filter(([key]) => !["description", "details"].includes(key))
2728
+ .map(([key, value]) => `${key}: ${value}`),
2729
+ "",
2730
+ "DETAILS",
2731
+ JSON.stringify(payload.details, null, 2),
2732
+ "",
2733
+ "IMAGES TO UPLOAD",
2734
+ ...(preview.catalog.files.length
2735
+ ? preview.catalog.files.map(
2736
+ (file) =>
2737
+ `${file.path} · ${file.mime} · ${file.size} bytes`,
2738
+ )
2739
+ : ["No new images"]),
2740
+ ]
2741
+ : []),
2421
2742
  ...(payload.code ? ["", "CODE TO PUBLISH", payload.code] : []),
2422
2743
  ...(payload.review_focus
2423
2744
  ? ["", "REVIEW FOCUS", payload.review_focus]
@@ -2776,27 +3097,31 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2776
3097
  );
2777
3098
  }
2778
3099
  const hint =
2779
- form?.kind === "appearance"
2780
- ? "← → choose · Tab next · Ctrl+S save · Esc cancel · 0 default preview"
2781
- : actionIndex !== null
2782
- ? "Actions are scoped to the selected item · Esc closes"
2783
- : reviewing && !form && !preview && !reading
2784
- ? "W code / discussion · Space select · C comment · X actions · F filter"
2785
- : picker
2786
- ? "Project files stay local until publication is confirmed"
2787
- : reading
2788
- ? "↑↓ scroll · R reply · E edit · D delete · Esc close"
2789
- : preview
2790
- ? "← → choose · Enter confirm · Esc edit"
2791
- : form
2792
- ? "Tab field · Ctrl+E editor · Ctrl+O files · Ctrl+G diff · Ctrl+S preview"
2793
- : phase !== "ready"
2794
- ? "Enter connect / open browser · Ctrl+C exit"
2795
- : route.view === "feedback"
2796
- ? "↑↓ select · R reply · E edit · D delete · S resolve · ! report"
2797
- : route.view === "post"
2798
- ? "←→ tabs · ↑↓ select · R reply · E edit · D delete · Space read"
2799
- : "↑↓ select · Enter open · Tab sections · [ ] pages · N new · / search";
3100
+ ["projects", "events"].includes(route.view) && !form && !preview && !picker
3101
+ ? "↑↓ select · Enter open · F filters · N new · [ ] pages · / search"
3102
+ : ["project", "event"].includes(route.view) && !form && !preview
3103
+ ? "↑↓ scroll · X actions · E edit your publication · Esc back"
3104
+ : form?.kind === "appearance"
3105
+ ? "← → choose · Tab next · Ctrl+S save · Esc cancel · 0 default preview"
3106
+ : actionIndex !== null
3107
+ ? "Actions are scoped to the selected item · Esc closes"
3108
+ : reviewing && !form && !preview && !reading
3109
+ ? "W code / discussion · Space select · C comment · X actions · F filter"
3110
+ : picker
3111
+ ? "Project files stay local until publication is confirmed"
3112
+ : reading
3113
+ ? "↑↓ scroll · R reply · E edit · D delete · Esc close"
3114
+ : preview
3115
+ ? "← → choose · Enter confirm · Esc edit"
3116
+ : form
3117
+ ? "Tab field · Ctrl+E editor · Ctrl+O files · Ctrl+G diff · Ctrl+S preview"
3118
+ : phase !== "ready"
3119
+ ? "Enter connect / open browser · Ctrl+C exit"
3120
+ : route.view === "feedback"
3121
+ ? "↑↓ select · R reply · E edit · D delete · S resolve · ! report"
3122
+ : route.view === "post"
3123
+ ? "←→ tabs · ↑↓ select · R reply · E edit · D delete · Space read"
3124
+ : "↑↓ select · Enter open · Tab sections · [ ] pages · N new · / search";
2800
3125
  return h(
2801
3126
  ThemeContext.Provider,
2802
3127
  { value: colors },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenyokjs/devdevian-cli",
3
- "version": "0.5.1-alpha.1",
3
+ "version": "0.6.1-alpha.1",
4
4
  "description": "Devdevian in your terminal: discussions, questions and human code reviews.",
5
5
  "type": "module",
6
6
  "bin": {