@pinkpixel/memi 0.0.0-stage → 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/ui/index.html ADDED
@@ -0,0 +1,75 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
+ <meta name="color-scheme" content="dark" />
7
+ <title>memi</title>
8
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
9
+ <link rel="stylesheet" href="/css/tokens.css" />
10
+ <link rel="stylesheet" href="/css/layout.css" />
11
+ <link rel="stylesheet" href="/css/controls.css" />
12
+ <link rel="stylesheet" href="/css/memories.css" />
13
+ <link rel="stylesheet" href="/css/dialogs.css" />
14
+ <script type="module" src="/js/main.js"></script>
15
+ </head>
16
+ <body>
17
+ <div class="app">
18
+ <aside class="sidebar" id="sidebar" aria-label="Filters">
19
+ <div class="sidebar-head">
20
+ <span class="brand">memi</span>
21
+ <button class="btn btn-icon sidebar-close" id="drawer-close" type="button" title="Close filters" aria-label="Close filters">
22
+ <span data-icon="x"></span>
23
+ </button>
24
+ </div>
25
+ <div class="sidebar-body" id="filters"></div>
26
+ <div class="sidebar-foot" id="embedder"></div>
27
+ </aside>
28
+ <button class="backdrop" id="backdrop" type="button" tabindex="-1" aria-label="Close filters"></button>
29
+
30
+ <main class="main">
31
+ <header class="topbar">
32
+ <button class="btn filters-toggle" id="drawer-open" type="button" aria-controls="sidebar" aria-expanded="false" title="Filters">
33
+ <span data-icon="sliders-horizontal"></span>
34
+ Filters
35
+ </button>
36
+ <span class="spacer"></span>
37
+ <div class="search">
38
+ <span data-icon="search"></span>
39
+ <input class="input" id="search" type="search" placeholder="Search memories" aria-label="Search memories" autocomplete="off" spellcheck="false" />
40
+ <button class="btn btn-icon clear" id="search-clear" type="button" title="Clear search" aria-label="Clear search" hidden>
41
+ <span data-icon="x"></span>
42
+ </button>
43
+ </div>
44
+ <button class="btn btn-primary" id="new" type="button" title="New memory (n)">
45
+ <span data-icon="plus"></span>
46
+ New
47
+ </button>
48
+ </header>
49
+
50
+ <div class="statusline">
51
+ <div class="left" id="summary" aria-live="polite"></div>
52
+ <div class="right">
53
+ <div class="segmented" id="mode" role="group" aria-label="Search mode" hidden></div>
54
+ <select class="select" id="order" aria-label="Sort order">
55
+ <option value="recent">Recent</option>
56
+ <option value="oldest">Oldest</option>
57
+ <option value="importance">Importance</option>
58
+ </select>
59
+ </div>
60
+ </div>
61
+
62
+ <div class="scroller" id="scroller">
63
+ <div class="banners" id="banners"></div>
64
+ <ul class="list" id="list" aria-label="Memories"></ul>
65
+ <div id="empty"></div>
66
+ <div class="list-foot" id="more"></div>
67
+ </div>
68
+ </main>
69
+ </div>
70
+
71
+ <dialog class="sheet" id="editor" aria-labelledby="editor-title"></dialog>
72
+ <dialog class="modal" id="confirm" aria-labelledby="confirm-title"></dialog>
73
+ <div id="toasts" role="status" aria-live="polite"></div>
74
+ </body>
75
+ </html>
package/ui/js/api.js ADDED
@@ -0,0 +1,30 @@
1
+ async function call(method, path, body) {
2
+ let res;
3
+ try {
4
+ res = await fetch(path, {
5
+ method,
6
+ headers: body === undefined ? undefined : { "content-type": "application/json" },
7
+ body: body === undefined ? undefined : JSON.stringify(body),
8
+ });
9
+ } catch {
10
+ throw new Error("Can't reach memi. Is `memi ui` still running?");
11
+ }
12
+ if (res.status === 204) return null;
13
+ const data = await res.json().catch(() => ({}));
14
+ if (!res.ok) throw new Error(data.error ?? `Request failed (${res.status})`);
15
+ return data;
16
+ }
17
+
18
+ export const getStatus = () => call("GET", "/api/status");
19
+
20
+ export function listMemories(params) {
21
+ const qs = new URLSearchParams();
22
+ for (const [key, value] of Object.entries(params)) {
23
+ if (value !== undefined && value !== null && value !== "") qs.set(key, String(value));
24
+ }
25
+ return call("GET", `/api/memories?${qs}`);
26
+ }
27
+
28
+ export const addMemory = (body) => call("POST", "/api/memories", body);
29
+ export const updateMemory = (id, body) => call("PATCH", `/api/memories/${id}`, body);
30
+ export const deleteMemory = (id) => call("DELETE", `/api/memories/${id}`);
@@ -0,0 +1,29 @@
1
+ import { $, clear, h } from "./dom.js";
2
+
3
+ const SNIPPET = 160;
4
+
5
+ /** Asks before deleting. Resolves true only if Delete was chosen. */
6
+ export function confirmDelete(memory) {
7
+ const dlg = $("#confirm");
8
+ const text = memory.content.length > SNIPPET ? `${memory.content.slice(0, SNIPPET)}...` : memory.content;
9
+ clear(dlg).append(
10
+ h(
11
+ "form",
12
+ { method: "dialog", class: "dialog-form" },
13
+ h("div", { class: "dialog-head" }, h("h2", { id: "confirm-title" }, `Delete memory #${memory.id}?`)),
14
+ h("div", { class: "dialog-body" }, h("p", {}, text), h("p", { class: "fine" }, "This can't be undone.")),
15
+ h(
16
+ "div",
17
+ { class: "dialog-foot" },
18
+ h("span", { class: "spacer" }),
19
+ h("button", { class: "btn", type: "submit", value: "cancel", autofocus: true }, "Cancel"),
20
+ h("button", { class: "btn btn-danger solid", type: "submit", value: "delete" }, "Delete"),
21
+ ),
22
+ ),
23
+ );
24
+ return new Promise((resolve) => {
25
+ dlg.addEventListener("close", () => resolve(dlg.returnValue === "delete"), { once: true });
26
+ dlg.returnValue = "cancel";
27
+ dlg.showModal();
28
+ });
29
+ }
package/ui/js/dom.js ADDED
@@ -0,0 +1,49 @@
1
+ import { icon } from "./icons.js";
2
+
3
+ // These are set as properties. Everything else becomes an attribute (aria-*, for, list, role, ...).
4
+ const PROPS = new Set(["value", "checked", "disabled", "selected", "hidden", "textContent"]);
5
+
6
+ /**
7
+ * Creates an element. String children always become text nodes, never markup, so memory
8
+ * text (which anyone can write) can't inject HTML.
9
+ */
10
+ export function h(tag, props = {}, ...children) {
11
+ const el = document.createElement(tag);
12
+ for (const [key, value] of Object.entries(props ?? {})) {
13
+ if (value == null || value === false) continue;
14
+ if (key === "class") el.className = value;
15
+ else if (key.startsWith("on") && typeof value === "function") el.addEventListener(key.slice(2), value);
16
+ else if (PROPS.has(key)) el[key] = value;
17
+ else el.setAttribute(key, value === true ? "" : String(value));
18
+ }
19
+ el.append(...children.flat().filter((c) => c != null && c !== false));
20
+ return el;
21
+ }
22
+
23
+ export const $ = (selector, root = document) => root.querySelector(selector);
24
+
25
+ export function clear(el) {
26
+ el.replaceChildren();
27
+ return el;
28
+ }
29
+
30
+ /** Fills every <span data-icon="name"> under root with its icon. */
31
+ export function hydrateIcons(root = document) {
32
+ for (const el of root.querySelectorAll("[data-icon]")) el.replaceWith(icon(el.dataset.icon));
33
+ }
34
+
35
+ /** Re-renders and keeps keyboard focus on the same control if it still exists. */
36
+ export function keepFocus(render) {
37
+ const key = document.activeElement?.dataset?.key;
38
+ render();
39
+ if (key) document.querySelector(`[data-key="${CSS.escape(key)}"]`)?.focus();
40
+ }
41
+
42
+ export async function copyText(text) {
43
+ try {
44
+ await navigator.clipboard.writeText(text);
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
@@ -0,0 +1,133 @@
1
+ import * as api from "./api.js";
2
+ import { $, clear, h } from "./dom.js";
3
+ import { icon } from "./icons.js";
4
+
5
+ const MAX = 8000;
6
+
7
+ function field(label, id, control, ...rest) {
8
+ return h("div", { class: "field" }, h("label", { for: id }, label), control, ...rest);
9
+ }
10
+
11
+ const radio = (name, value, label, checked, onchange) =>
12
+ h("label", {}, h("input", { type: "radio", name, value: String(value), checked, onchange }), label);
13
+
14
+ /**
15
+ * Opens the side sheet to add a memory (memory = null) or edit one.
16
+ * ctx: { categories, projects, currentProject, onSaved(result, isNew), onDelete(memory) }
17
+ */
18
+ export function openEditor(ctx, memory) {
19
+ const dlg = $("#editor");
20
+ const isNew = !memory;
21
+ const m = memory ?? {
22
+ content: "",
23
+ category: "context",
24
+ importance: 3,
25
+ tags: [],
26
+ pinned: false,
27
+ scope: ctx.currentProject ? "project" : "global",
28
+ project: ctx.currentProject,
29
+ };
30
+
31
+ const content = h("textarea", { class: "textarea", id: "f-content", required: true, maxlength: MAX, rows: 7, value: m.content, placeholder: "What should be remembered?" });
32
+ const category = h("input", { class: "input", id: "f-category", list: "f-categories", value: m.category, autocomplete: "off", spellcheck: "false", maxlength: 32, required: true });
33
+ const tags = h("input", { class: "input", id: "f-tags", value: m.tags.join(", "), autocomplete: "off", spellcheck: "false", placeholder: "ci, deploy" });
34
+ const project = h("input", { class: "input", id: "f-project", list: "f-projects", value: m.project ?? "", autocomplete: "off", spellcheck: "false", placeholder: "Project name" });
35
+ const pinned = h("input", { type: "checkbox", id: "f-pinned", checked: m.pinned });
36
+ const error = h("p", { class: "form-error", role: "alert", hidden: true });
37
+ const save = h("button", { class: "btn btn-primary", type: "submit" }, isNew ? "Save" : "Save changes");
38
+
39
+ const syncScope = () => {
40
+ const isProject = form.elements.scope.value === "project";
41
+ projectField.hidden = !isProject;
42
+ project.required = isProject;
43
+ };
44
+
45
+ const projectField = h("div", { class: "field" }, h("label", { for: "f-project" }, "Project"), project);
46
+ const form = h(
47
+ "form",
48
+ { class: "dialog-form" },
49
+ h(
50
+ "div",
51
+ { class: "dialog-head" },
52
+ h("h2", { id: "editor-title" }, isNew ? "New memory" : `Memory #${m.id}`),
53
+ h("button", { class: "btn btn-icon", type: "button", title: "Close", "aria-label": "Close", onclick: () => dlg.close() }, icon("x")),
54
+ ),
55
+ h(
56
+ "div",
57
+ { class: "dialog-body" },
58
+ field("Memory", "f-content", content),
59
+ h(
60
+ "div",
61
+ { class: "field-row" },
62
+ h("div", { class: "field" }, h("label", { for: "f-category" }, "Category"), category),
63
+ h("div", { class: "field" }, h("span", { class: "label", id: "l-importance" }, "Importance"), h("div", { class: "segmented", role: "radiogroup", "aria-labelledby": "l-importance" }, ...[1, 2, 3, 4, 5].map((n) => radio("importance", n, String(n), m.importance === n)))),
64
+ ),
65
+ h(
66
+ "div",
67
+ { class: "field" },
68
+ h("span", { class: "label", id: "l-scope" }, "Scope"),
69
+ h("div", { class: "segmented", role: "radiogroup", "aria-labelledby": "l-scope" }, radio("scope", "global", "Global", m.scope === "global", syncScope), radio("scope", "project", "Project", m.scope === "project", syncScope)),
70
+ ),
71
+ projectField,
72
+ field("Tags", "f-tags", tags),
73
+ h("label", { class: "check-row", for: "f-pinned" }, pinned, "Pinned"),
74
+ h("datalist", { id: "f-categories" }, ...ctx.categories.map((c) => h("option", { value: c.name }))),
75
+ h("datalist", { id: "f-projects" }, ...ctx.projects.map((p) => h("option", { value: p.project }))),
76
+ error,
77
+ isNew ? null : h("p", { class: "fine" }, `Created ${new Date(m.createdAt).toLocaleString()}`, h("br"), `Updated ${new Date(m.updatedAt).toLocaleString()}`, m.agent ? ` by ${m.agent}` : ""),
78
+ ),
79
+ h(
80
+ "div",
81
+ { class: "dialog-foot" },
82
+ isNew ? null : h("button", { class: "btn btn-danger", type: "button", onclick: () => ctx.onDelete(memory) }, icon("trash-2", 16), "Delete"),
83
+ h("span", { class: "spacer" }),
84
+ h("button", { class: "btn", type: "button", onclick: () => dlg.close() }, "Cancel"),
85
+ save,
86
+ ),
87
+ );
88
+
89
+ form.addEventListener("submit", async (event) => {
90
+ event.preventDefault();
91
+ const isProject = form.elements.scope.value === "project";
92
+ const body = {
93
+ content: content.value,
94
+ category: category.value,
95
+ importance: Number(form.elements.importance.value),
96
+ tags: tags.value.split(",").map((t) => t.trim()).filter(Boolean),
97
+ pinned: pinned.checked,
98
+ scope: isProject ? "project" : "global",
99
+ project: isProject ? project.value.trim() : isNew ? undefined : null,
100
+ };
101
+ error.hidden = true;
102
+ save.classList.add("loading");
103
+ try {
104
+ const result = isNew ? await api.addMemory(body) : await api.updateMemory(m.id, body);
105
+ dlg.close();
106
+ ctx.onSaved(result, isNew);
107
+ } catch (err) {
108
+ error.textContent = err.message;
109
+ error.hidden = false;
110
+ } finally {
111
+ save.classList.remove("loading");
112
+ }
113
+ });
114
+ form.addEventListener("keydown", (e) => {
115
+ if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) form.requestSubmit();
116
+ });
117
+
118
+ // Clicking the dimmed area closes the sheet, but not when a text selection merely ends there.
119
+ let downOnBackdrop = false;
120
+ dlg.onmousedown = (e) => (downOnBackdrop = e.target === dlg);
121
+ dlg.onclick = (e) => {
122
+ if (e.target === dlg && downOnBackdrop) dlg.close();
123
+ };
124
+
125
+ clear(dlg).append(form);
126
+ syncScope();
127
+ dlg.showModal();
128
+ // On a phone, focusing an existing memory would raise the keyboard over the form. New memories always want typing.
129
+ if (isNew || matchMedia("(pointer: fine)").matches) {
130
+ content.focus();
131
+ if (!isNew) content.setSelectionRange(0, 0);
132
+ }
133
+ }
package/ui/js/icons.js ADDED
@@ -0,0 +1,29 @@
1
+ // Icons from Lucide (https://lucide.dev), ISC License. Copyright (c) Lucide Contributors.
2
+ // The markup below is static and trusted. Never put user text in it.
3
+ const PATHS = {
4
+ "check": '<path d="M20 6 9 17l-5-5"/>',
5
+ "copy": '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/> <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
6
+ "folder": '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
7
+ "globe": '<circle cx="12" cy="12" r="10"/> <path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/> <path d="M2 12h20"/>',
8
+ "info": '<circle cx="12" cy="12" r="10"/> <path d="M12 16v-4"/> <path d="M12 8h.01"/>',
9
+ "pencil": '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/> <path d="m15 5 4 4"/>',
10
+ "pin-off": '<path d="M12 17v5"/> <path d="M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89"/> <path d="m2 2 20 20"/> <path d="M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11"/>',
11
+ "pin": '<path d="M12 17v5"/> <path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/>',
12
+ "plus": '<path d="M5 12h14"/> <path d="M12 5v14"/>',
13
+ "search": '<path d="m21 21-4.34-4.34"/> <circle cx="11" cy="11" r="8"/>',
14
+ "sliders-horizontal": '<path d="M10 5H3"/> <path d="M12 19H3"/> <path d="M14 3v4"/> <path d="M16 17v4"/> <path d="M21 12h-9"/> <path d="M21 19h-5"/> <path d="M21 5h-7"/> <path d="M8 10v4"/> <path d="M8 12H3"/>',
15
+ "trash-2": '<path d="M10 11v6"/> <path d="M14 11v6"/> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/> <path d="M3 6h18"/> <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
16
+ "triangle-alert": '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/> <path d="M12 9v4"/> <path d="M12 17h.01"/>',
17
+ "x": '<path d="M18 6 6 18"/> <path d="m6 6 12 12"/>',
18
+ };
19
+
20
+ /** Returns an inline SVG element. It inherits color from the surrounding text. */
21
+ export function icon(name, size = 18) {
22
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
23
+ for (const [k, v] of Object.entries({
24
+ width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor",
25
+ "stroke-width": 2, "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": "true", focusable: "false",
26
+ })) svg.setAttribute(k, v);
27
+ svg.innerHTML = PATHS[name] ?? "";
28
+ return svg;
29
+ }
package/ui/js/list.js ADDED
@@ -0,0 +1,66 @@
1
+ import { h } from "./dom.js";
2
+ import { icon } from "./icons.js";
3
+
4
+ const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
5
+ const UNITS = [
6
+ ["year", 31536000],
7
+ ["month", 2592000],
8
+ ["day", 86400],
9
+ ["hour", 3600],
10
+ ["minute", 60],
11
+ ];
12
+
13
+ function ago(iso) {
14
+ const seconds = (Date.parse(iso) - Date.now()) / 1000;
15
+ for (const [unit, size] of UNITS) if (Math.abs(seconds) >= size) return rtf.format(Math.round(seconds / size), unit);
16
+ return "just now";
17
+ }
18
+
19
+ function pips(n) {
20
+ const wrap = h("span", { class: "pips", role: "img", "aria-label": `Importance ${n} of 5`, title: `Importance ${n} of 5` });
21
+ for (let i = 1; i <= 5; i++) wrap.append(h("span", { class: i <= n ? "pip on" : "pip" }));
22
+ return wrap;
23
+ }
24
+
25
+ function meta(m) {
26
+ const where = m.scope === "global" ? [icon("globe", 12), "global"] : [icon("folder", 12), m.project];
27
+ return h(
28
+ "div",
29
+ { class: "meta" },
30
+ m.pinned ? h("span", { class: "chip pinned" }, icon("pin", 12), h("span", { class: "sr-only" }, "Pinned")) : null,
31
+ h("span", { class: "chip category" }, m.category),
32
+ pips(m.importance),
33
+ h("span", { class: "chip" }, ...where),
34
+ ...m.tags.map((t) => h("span", { class: "chip" }, `#${t}`)),
35
+ h("span", {}, `#${m.id}`),
36
+ h("time", { datetime: m.updatedAt, title: new Date(m.updatedAt).toLocaleString() }, ago(m.updatedAt)),
37
+ m.agent ? h("span", {}, `by ${m.agent}`) : null,
38
+ );
39
+ }
40
+
41
+ /** One memory row: the text opens the editor, the buttons beside it pin and delete. */
42
+ export function rowEl(m, { onEdit, onPin, onDelete }) {
43
+ return h(
44
+ "li",
45
+ { class: "row", "data-id": m.id },
46
+ h("button", { class: "row-main", type: "button", "aria-label": `Edit memory ${m.id}`, onclick: () => onEdit(m) }, h("span", { class: "row-content" }, m.content), meta(m)),
47
+ h(
48
+ "div",
49
+ { class: "row-actions" },
50
+ h(
51
+ "button",
52
+ {
53
+ class: "btn btn-icon",
54
+ type: "button",
55
+ "data-key": `pin-${m.id}`,
56
+ "aria-pressed": m.pinned ? "true" : "false",
57
+ title: m.pinned ? "Unpin" : "Pin",
58
+ "aria-label": m.pinned ? `Unpin memory ${m.id}` : `Pin memory ${m.id}`,
59
+ onclick: () => onPin(m),
60
+ },
61
+ icon(m.pinned ? "pin-off" : "pin"),
62
+ ),
63
+ h("button", { class: "btn btn-icon danger", type: "button", title: "Delete", "aria-label": `Delete memory ${m.id}`, onclick: () => onDelete(m) }, icon("trash-2")),
64
+ ),
65
+ );
66
+ }
package/ui/js/main.js ADDED
@@ -0,0 +1,244 @@
1
+ import * as api from "./api.js";
2
+ import { confirmDelete } from "./confirm.js";
3
+ import { $, clear, copyText, h, hydrateIcons, keepFocus } from "./dom.js";
4
+ import { openEditor } from "./editor.js";
5
+ import { icon } from "./icons.js";
6
+ import { rowEl } from "./list.js";
7
+ import { renderSidebar } from "./sidebar.js";
8
+ import { PAGE, hasFilters, queryParams, savePrefs, state } from "./state.js";
9
+ import { toast } from "./toast.js";
10
+
11
+ const els = {
12
+ sidebar: $("#sidebar"),
13
+ backdrop: $("#backdrop"),
14
+ drawerOpen: $("#drawer-open"),
15
+ search: $("#search"),
16
+ clear: $("#search-clear"),
17
+ mode: $("#mode"),
18
+ order: $("#order"),
19
+ summary: $("#summary"),
20
+ banners: $("#banners"),
21
+ list: $("#list"),
22
+ empty: $("#empty"),
23
+ more: $("#more"),
24
+ };
25
+
26
+ const phone = matchMedia("(max-width: 899px)");
27
+ let loadSeq = 0;
28
+
29
+ /* Drawer (phones only) */
30
+
31
+ function setDrawer(open) {
32
+ els.sidebar.classList.toggle("open", open);
33
+ els.backdrop.classList.toggle("open", open);
34
+ els.drawerOpen.setAttribute("aria-expanded", String(open));
35
+ els.sidebar.inert = phone.matches && !open;
36
+ if (open) els.sidebar.querySelector("button, input")?.focus();
37
+ }
38
+ phone.addEventListener("change", () => setDrawer(false));
39
+
40
+ /* Loading */
41
+
42
+ async function loadStatus() {
43
+ try {
44
+ state.status = await api.getStatus();
45
+ keepFocus(() => renderSidebar(onFilterChange));
46
+ renderBanners();
47
+ } catch (err) {
48
+ state.error = err.message;
49
+ renderResults();
50
+ }
51
+ }
52
+
53
+ async function loadMemories({ append = false } = {}) {
54
+ const seq = ++loadSeq;
55
+ state.loading = true;
56
+ els.list.setAttribute("aria-busy", "true");
57
+ try {
58
+ const r = await api.listMemories(queryParams(append ? state.items.length : 0));
59
+ if (seq !== loadSeq) return;
60
+ state.items = append ? [...state.items, ...r.items] : r.items;
61
+ state.total = r.total;
62
+ state.modeUsed = r.modeUsed;
63
+ state.note = r.note;
64
+ state.error = null;
65
+ } catch (err) {
66
+ if (seq !== loadSeq) return;
67
+ state.error = err.message;
68
+ }
69
+ state.loading = false;
70
+ els.list.removeAttribute("aria-busy");
71
+ renderResults();
72
+ }
73
+
74
+ const refresh = () => Promise.all([loadStatus(), loadMemories()]);
75
+
76
+ function onFilterChange() {
77
+ if (phone.matches) setDrawer(false);
78
+ keepFocus(() => renderSidebar(onFilterChange));
79
+ loadMemories();
80
+ }
81
+
82
+ function clearFilters() {
83
+ Object.assign(state, { query: "", scope: { kind: "all", project: null }, category: "", minImportance: 0, pinned: false });
84
+ els.search.value = "";
85
+ els.clear.hidden = true;
86
+ renderSidebar(onFilterChange);
87
+ loadMemories();
88
+ }
89
+
90
+ /* Rendering */
91
+
92
+ const MODE_LABELS = { hybrid: "Hybrid", semantic: "Meaning", text: "Keyword" };
93
+
94
+ function renderBanners() {
95
+ clear(els.banners);
96
+ const s = state.status;
97
+ if (state.error) {
98
+ els.banners.append(
99
+ h("div", { class: "banner error", role: "alert" }, icon("triangle-alert"), h("span", { class: "text" }, state.error), h("button", { class: "btn", type: "button", onclick: refresh }, "Retry")),
100
+ );
101
+ }
102
+ if (!s) return;
103
+ const command = (text) =>
104
+ h("code", {}, text, h("button", { class: "btn btn-icon", type: "button", title: "Copy command", "aria-label": `Copy ${text}`, onclick: async (e) => ((await copyText(text)) ? toast("Copied.", { kind: "success", ms: 1800 }) : toast("Couldn't copy. Select it instead.", { kind: "error" })) }, icon("copy", 14)));
105
+ if (s.state.status === "mismatch") {
106
+ const { provider, model } = s.state.stored;
107
+ els.banners.append(
108
+ h("div", { class: "banner", title: s.warning }, icon("triangle-alert"), h("span", { class: "text" }, `Embedder changed from ${provider}/${model}. Semantic search is off. Memories are kept. Run`), command("memi reindex")),
109
+ );
110
+ } else if (s.state.status === "ok" && s.vectors < s.total) {
111
+ els.banners.append(h("div", { class: "banner" }, icon("info"), h("span", { class: "text" }, `${s.total - s.vectors} memories have no vector yet. Run`), command("memi reindex --missing")));
112
+ }
113
+ }
114
+
115
+ function renderResults() {
116
+ renderBanners();
117
+ const searching = state.query !== "";
118
+ const noun = (n) => `${n} ${searching ? "result" : "memor"}${searching ? (n === 1 ? "" : "s") : n === 1 ? "y" : "ies"}`;
119
+
120
+ clear(els.summary).append(h("span", {}, state.loading && !state.items.length ? "Loading" : noun(state.total)));
121
+ if (searching && state.modeUsed && state.modeUsed !== state.mode && state.note) {
122
+ els.summary.append(h("span", { class: "chip", title: state.note }, icon("info", 12), `${MODE_LABELS[state.modeUsed]} results only`));
123
+ }
124
+ els.order.hidden = searching;
125
+ els.mode.hidden = !searching;
126
+
127
+ clear(els.list);
128
+ const handlers = { onEdit: edit, onPin: pin, onDelete: remove };
129
+ els.list.append(...state.items.map((m) => rowEl(m, handlers)));
130
+
131
+ clear(els.empty);
132
+ if (!state.items.length && !state.loading && !state.error) {
133
+ els.empty.append(
134
+ h(
135
+ "div",
136
+ { class: "empty" },
137
+ h("p", {}, searching || hasFilters() ? "Nothing matches." : "No memories yet."),
138
+ searching || hasFilters()
139
+ ? h("button", { class: "btn", type: "button", onclick: clearFilters }, "Clear filters")
140
+ : h("button", { class: "btn btn-primary", type: "button", onclick: add }, icon("plus", 16), "New memory"),
141
+ ),
142
+ );
143
+ }
144
+
145
+ clear(els.more);
146
+ if (!searching && state.items.length < state.total) {
147
+ els.more.append(h("button", { class: "btn", type: "button", onclick: () => loadMemories({ append: true }) }, `Show more (${state.total - state.items.length} left)`));
148
+ } else if (searching && state.total >= PAGE) {
149
+ els.more.append(h("span", { class: "fine" }, `Top ${PAGE} shown`));
150
+ }
151
+ }
152
+
153
+ function renderMode() {
154
+ clear(els.mode).append(
155
+ ...Object.entries(MODE_LABELS).map(([value, label]) =>
156
+ h("button", { type: "button", "aria-pressed": state.mode === value ? "true" : "false", onclick: () => ((state.mode = value), savePrefs(), renderMode(), loadMemories()) }, label),
157
+ ),
158
+ );
159
+ }
160
+
161
+ /* Actions */
162
+
163
+ const editorContext = () => ({
164
+ categories: state.status?.categories ?? [],
165
+ projects: state.status?.projects ?? [],
166
+ currentProject: state.status?.currentProject ?? null,
167
+ onSaved: (result, isNew) => {
168
+ toast(isNew ? `Saved #${result.memory.id}.` : "Saved.", { kind: "success" });
169
+ if (result.similar?.length) toast(`Similar to ${result.similar.map((s) => `#${s.memory.id}`).join(", ")}. You may want to merge them.`);
170
+ if (result.warning) toast(result.warning, { kind: "error" });
171
+ refresh();
172
+ },
173
+ onDelete: remove,
174
+ });
175
+
176
+ const add = () => openEditor(editorContext(), null);
177
+ const edit = (memory) => openEditor(editorContext(), memory);
178
+
179
+ async function pin(memory) {
180
+ try {
181
+ const { memory: updated } = await api.updateMemory(memory.id, { pinned: !memory.pinned });
182
+ if (state.pinned) return refresh();
183
+ const i = state.items.findIndex((m) => m.id === memory.id);
184
+ if (i >= 0) state.items[i] = updated;
185
+ keepFocus(renderResults);
186
+ } catch (err) {
187
+ toast(err.message, { kind: "error" });
188
+ }
189
+ }
190
+
191
+ async function remove(memory) {
192
+ if (!(await confirmDelete(memory))) return;
193
+ try {
194
+ await api.deleteMemory(memory.id);
195
+ $("#editor").close();
196
+ toast(`Deleted #${memory.id}.`);
197
+ refresh();
198
+ } catch (err) {
199
+ toast(err.message, { kind: "error" });
200
+ }
201
+ }
202
+
203
+ /* Wiring */
204
+
205
+ let timer;
206
+ function onSearchInput() {
207
+ els.clear.hidden = els.search.value === "";
208
+ clearTimeout(timer);
209
+ timer = setTimeout(() => {
210
+ state.query = els.search.value.trim();
211
+ loadMemories();
212
+ }, 220);
213
+ }
214
+
215
+ els.search.addEventListener("input", onSearchInput);
216
+ els.search.addEventListener("keydown", (e) => {
217
+ if (e.key === "Escape" && els.search.value) {
218
+ els.search.value = "";
219
+ onSearchInput();
220
+ }
221
+ });
222
+ els.clear.addEventListener("click", () => {
223
+ els.search.value = "";
224
+ onSearchInput();
225
+ els.search.focus();
226
+ });
227
+ els.order.value = state.order;
228
+ els.order.addEventListener("change", () => ((state.order = els.order.value), savePrefs(), loadMemories()));
229
+ $("#new").addEventListener("click", add);
230
+ els.drawerOpen.addEventListener("click", () => setDrawer(true));
231
+ $("#drawer-close").addEventListener("click", () => (setDrawer(false), els.drawerOpen.focus()));
232
+ els.backdrop.addEventListener("click", () => setDrawer(false));
233
+ document.addEventListener("keydown", (e) => {
234
+ if (e.key === "Escape" && els.sidebar.classList.contains("open")) return (setDrawer(false), els.drawerOpen.focus());
235
+ const typing = e.target.closest?.("input, textarea, select, [contenteditable]");
236
+ if (typing || e.ctrlKey || e.metaKey || e.altKey || document.querySelector("dialog[open]")) return;
237
+ if (e.key === "/") (e.preventDefault(), els.search.focus());
238
+ else if (e.key === "n") (e.preventDefault(), add());
239
+ });
240
+
241
+ hydrateIcons();
242
+ renderMode();
243
+ setDrawer(false);
244
+ refresh();