wren/lost-property Public Public: anyone can read this; nobody can change it.

workbenchthis machine
Files b22929f92e5a52d5b767141cad1712e731975be5
146 lines · 4.9 KB · ~1.4k tok Raw History
1
/**
2
 * lost-property, in a terminal. The forge runs this on a pty when someone
3
 * `ssh`es to the app, with FORGE_APP_DATA pointing at the same data the
4
 * web page uses.
5
 *
6
 * j/k move, enter opens a case, a accessions something new, s/m leave a
7
 * note on an open case (saw one / have the other half), b or esc goes
8
 * back, q leaves. Plain ANSI, no dependencies.
9
 */
10
11
import { addNote, getItem, listItems, listNotes, createItem, type NoteKind } from "./db.ts";
12
import { catalogueNumber, conditionOf, vitrineOf, VITRINES } from "./museum.ts";
13
14
const where =
15
  process.env.FORGE_APP_ENV === "preview"
16
    ? `preview of #${process.env.FORGE_APP_PULL ?? "?"}`
17
    : (process.env.FORGE_APP_ENV ?? "local");
18
19
let screen: "list" | "case" = "list";
20
let cursor = 0;
21
let caseId: number | undefined;
22
let typing: { for: "description" } | { for: "note"; kind: NoteKind } | undefined;
23
let input = "";
24
25
const out = (s: string): void => {
26
  process.stdout.write(s);
27
};
28
const esc = "\x1b[";
29
30
function drawList(): void {
31
  const items = listItems();
32
  const lines: string[] = [];
33
  lines.push(`${esc}1mlost-property${esc}0m  ${esc}2m${where}${esc}0m`);
34
  lines.push("");
35
  if (items.length === 0) lines.push(`${esc}2mNothing accessioned yet. Press a to describe something you lost.${esc}0m`);
36
  items.forEach((item, i) => {
37
    const mark = i === cursor ? "▸" : " ";
38
    const short = item.description.length > 60 ? item.description.slice(0, 60) + "…" : item.description;
39
    const label = i === cursor ? `${esc}1m${short}${esc}0m` : short;
40
    lines.push(`${mark} ${label}  ${esc}2m${catalogueNumber(item.id, item.created_at)} · ${item.note_count} note${item.note_count === 1 ? "" : "s"}${esc}0m`);
41
  });
42
  lines.push("");
43
  lines.push(
44
    typing?.for === "description"
45
      ? `Describe what you lost: ${input}${esc}7m ${esc}0m`
46
      : `${esc}2mj/k move · enter open · a accession · q quit${esc}0m`,
47
  );
48
  out(`${esc}2J${esc}H` + lines.join("\r\n"));
49
}
50
51
function drawCase(): void {
52
  const item = caseId === undefined ? undefined : getItem(caseId);
53
  if (item === undefined) {
54
    screen = "list";
55
    drawList();
56
    return;
57
  }
58
  const vitrineIndex = vitrineOf(item.description);
59
  const notes = listNotes(item.id);
60
  const lines: string[] = [];
61
  lines.push(`${esc}1m${catalogueNumber(item.id, item.created_at)}${esc}0m  ${esc}2m${where}${esc}0m`);
62
  lines.push(`${esc}1m${VITRINES[vitrineIndex]}${esc}0m — Vitrine ${vitrineIndex + 1}`);
63
  lines.push("");
64
  lines.push(item.description);
65
  lines.push("");
66
  lines.push(`${esc}2mAccessioned ${item.created_at}${esc}0m`);
67
  lines.push(`${esc}2mCondition: ${conditionOf(item.id)}${esc}0m`);
68
  lines.push("");
69
  lines.push(`${esc}1mNotes${esc}0m`);
70
  if (notes.length === 0) lines.push(`${esc}2mNo notes yet.${esc}0m`);
71
  for (const n of notes) {
72
    lines.push(`${n.kind === "match" ? "Has the other half" : "Saw one like it"}: ${n.body}`);
73
    lines.push(`  ${esc}2m${n.created_at}${esc}0m`);
74
  }
75
  lines.push("");
76
  lines.push(
77
    typing?.for === "note"
78
      ? `${typing.kind === "match" ? "Have the other half" : "Saw one like it"}: ${input}${esc}7m ${esc}0m`
79
      : `${esc}2ms saw one like it · m have the other half · b back · q quit${esc}0m`,
80
  );
81
  out(`${esc}2J${esc}H` + lines.join("\r\n"));
82
}
83
84
function draw(): void {
85
  if (screen === "list") drawList();
86
  else drawCase();
87
}
88
89
process.stdin.setRawMode?.(true);
90
process.stdin.resume();
91
process.stdin.setEncoding("utf8");
92
draw();
93
94
process.stdin.on("data", (key: string) => {
95
  if (typing !== undefined) {
96
    if (key === "\r") {
97
      const value = input.trim().slice(0, 500);
98
      if (typing.for === "description") {
99
        if (value) {
100
          caseId = createItem(value);
101
          screen = "case";
102
        }
103
      } else if (value && caseId !== undefined) {
104
        addNote(caseId, typing.kind, value);
105
      }
106
      typing = undefined;
107
      input = "";
108
    } else if (key === "\x1b") {
109
      typing = undefined;
110
      input = "";
111
    } else if (key === "\x7f") input = input.slice(0, -1);
112
    else if (key >= " ") input += key;
113
    draw();
114
    return;
115
  }
116
  if (screen === "list") {
117
    if (key === "q" || key === "\x03") {
118
      out(`${esc}2J${esc}H`);
119
      process.exit(0);
120
    }
121
    const count = listItems().length;
122
    if (key === "j" || key === "\x1b[B") cursor = Math.min(cursor + 1, Math.max(0, count - 1));
123
    if (key === "k" || key === "\x1b[A") cursor = Math.max(cursor - 1, 0);
124
    if (key === "a") typing = { for: "description" };
125
    if (key === "\r") {
126
      const item = listItems()[cursor];
127
      if (item) {
128
        caseId = item.id;
129
        screen = "case";
130
      }
131
    }
132
  } else {
133
    if (key === "q" || key === "\x03") {
134
      out(`${esc}2J${esc}H`);
135
      process.exit(0);
136
    }
137
    if (key === "b" || key === "\x1b") {
138
      screen = "list";
139
      caseId = undefined;
140
    }
141
    if (key === "s") typing = { for: "note", kind: "sighting" };
142
    if (key === "m") typing = { for: "note", kind: "match" };
143
  }
144
  draw();
145
});
146
process.stdout.on("resize", draw);
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts