/** * lost-property, in a terminal. The forge runs this on a pty when someone * `ssh`es to the app, with FORGE_APP_DATA pointing at the same data the * web page uses. * * j/k move, enter opens a case, a accessions something new, s/m leave a * note on an open case (saw one / have the other half), b or esc goes * back, q leaves. Plain ANSI, no dependencies. */ import { addNote, getItem, listItems, listNotes, createItem, type NoteKind } from "./db.ts"; import { catalogueNumber, conditionOf, vitrineOf, VITRINES } from "./museum.ts"; const where = process.env.FORGE_APP_ENV === "preview" ? `preview of #${process.env.FORGE_APP_PULL ?? "?"}` : (process.env.FORGE_APP_ENV ?? "local"); let screen: "list" | "case" = "list"; let cursor = 0; let caseId: number | undefined; let typing: { for: "description" } | { for: "note"; kind: NoteKind } | undefined; let input = ""; const out = (s: string): void => { process.stdout.write(s); }; const esc = "\x1b["; function drawList(): void { const items = listItems(); const lines: string[] = []; lines.push(`${esc}1mlost-property${esc}0m ${esc}2m${where}${esc}0m`); lines.push(""); if (items.length === 0) lines.push(`${esc}2mNothing accessioned yet. Press a to describe something you lost.${esc}0m`); items.forEach((item, i) => { const mark = i === cursor ? "▸" : " "; const short = item.description.length > 60 ? item.description.slice(0, 60) + "…" : item.description; const label = i === cursor ? `${esc}1m${short}${esc}0m` : short; lines.push(`${mark} ${label} ${esc}2m${catalogueNumber(item.id, item.created_at)} · ${item.note_count} note${item.note_count === 1 ? "" : "s"}${esc}0m`); }); lines.push(""); lines.push( typing?.for === "description" ? `Describe what you lost: ${input}${esc}7m ${esc}0m` : `${esc}2mj/k move · enter open · a accession · q quit${esc}0m`, ); out(`${esc}2J${esc}H` + lines.join("\r\n")); } function drawCase(): void { const item = caseId === undefined ? undefined : getItem(caseId); if (item === undefined) { screen = "list"; drawList(); return; } const vitrineIndex = vitrineOf(item.description); const notes = listNotes(item.id); const lines: string[] = []; lines.push(`${esc}1m${catalogueNumber(item.id, item.created_at)}${esc}0m ${esc}2m${where}${esc}0m`); lines.push(`${esc}1m${VITRINES[vitrineIndex]}${esc}0m — Vitrine ${vitrineIndex + 1}`); lines.push(""); lines.push(item.description); lines.push(""); lines.push(`${esc}2mAccessioned ${item.created_at}${esc}0m`); lines.push(`${esc}2mCondition: ${conditionOf(item.id)}${esc}0m`); lines.push(""); lines.push(`${esc}1mNotes${esc}0m`); if (notes.length === 0) lines.push(`${esc}2mNo notes yet.${esc}0m`); for (const n of notes) { lines.push(`${n.kind === "match" ? "Has the other half" : "Saw one like it"}: ${n.body}`); lines.push(` ${esc}2m${n.created_at}${esc}0m`); } lines.push(""); lines.push( typing?.for === "note" ? `${typing.kind === "match" ? "Have the other half" : "Saw one like it"}: ${input}${esc}7m ${esc}0m` : `${esc}2ms saw one like it · m have the other half · b back · q quit${esc}0m`, ); out(`${esc}2J${esc}H` + lines.join("\r\n")); } function draw(): void { if (screen === "list") drawList(); else drawCase(); } process.stdin.setRawMode?.(true); process.stdin.resume(); process.stdin.setEncoding("utf8"); draw(); process.stdin.on("data", (key: string) => { if (typing !== undefined) { if (key === "\r") { const value = input.trim().slice(0, 500); if (typing.for === "description") { if (value) { caseId = createItem(value); screen = "case"; } } else if (value && caseId !== undefined) { addNote(caseId, typing.kind, value); } typing = undefined; input = ""; } else if (key === "\x1b") { typing = undefined; input = ""; } else if (key === "\x7f") input = input.slice(0, -1); else if (key >= " ") input += key; draw(); return; } if (screen === "list") { if (key === "q" || key === "\x03") { out(`${esc}2J${esc}H`); process.exit(0); } const count = listItems().length; if (key === "j" || key === "\x1b[B") cursor = Math.min(cursor + 1, Math.max(0, count - 1)); if (key === "k" || key === "\x1b[A") cursor = Math.max(cursor - 1, 0); if (key === "a") typing = { for: "description" }; if (key === "\r") { const item = listItems()[cursor]; if (item) { caseId = item.id; screen = "case"; } } } else { if (key === "q" || key === "\x03") { out(`${esc}2J${esc}H`); process.exit(0); } if (key === "b" || key === "\x1b") { screen = "list"; caseId = undefined; } if (key === "s") typing = { for: "note", kind: "sighting" }; if (key === "m") typing = { for: "note", kind: "match" }; } draw(); }); process.stdout.on("resize", draw);