Notes

index.jsx 274 lines

Files · 4
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274import React, { useState } from "react";
import { createRoot } from "react-dom/client";
import {
  Page,
  Header,
  Button,
  Input,
  Row,
  EmptyState,
  Toast,
  useToast,
} from "../../shared/ui.jsx";
import { RecordForm, ConfirmDelete } from "../../shared/forms.jsx";
import {
  useStore,
  inserted,
  fieldsOf,
  tagsOf,
  displayTime,
} from "../../shared/data.js";
const L = window.lattice;
const details = ["id", "title", "content", "tags"];
const fields = [
  { name: "title", label: "Title", required: true },
  { name: "content", label: "Note", multiline: true, rows: 10 },
  { name: "tags", label: "Tags, separated by commas" },
];
async function add(data) {
  if (!data?.title) return;
  const row = await inserted(
    "notes",
    "INSERT INTO notes (title,content,tags) VALUES (?,?,?)",
    [data.title, data.content || "", data.tags || ""],
  );
  await L.events.emit("note-created", fieldsOf(row, details));
}
function App() {
  const [search, setSearch] = useState(""),
    [tag, setTag] = useState(""),
    [sort, setSort] = useState("updated");
  const [editing, setEditing] = useState(null),
    [deleting, setDeleting] = useState(null);
  const { toast, notify, dismissToast } = useToast();
  const store = useStore({
    setup: () =>
      L.db.exec(
        `CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content TEXT, tags TEXT, pinned INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')))`,
      ),
    load: () =>
      L.db.query(
        "SELECT * FROM notes ORDER BY pinned DESC, updated_at DESC, id DESC",
      ),
    listeners: { "add-note": add },
  });
  const query = search.trim().toLowerCase(),
    allTags = [
      ...new Set(store.rows.flatMap((row) => tagsOf(row.tags))),
    ].sort();
  const filtered = store.rows
    .filter(
      (row) =>
        (!tag || tagsOf(row.tags).includes(tag)) &&
        ["title", "content", "tags"].some((key) =>
          String(row[key] || "")
            .toLowerCase()
            .includes(query),
        ),
    )
    .sort(
      (a, b) =>
        b.pinned - a.pinned ||
        (sort === "title"
          ? a.title.localeCompare(b.title)
          : String(b.updated_at).localeCompare(String(a.updated_at)) ||
            b.id - a.id),
    );
  async function save(form) {
    if (!form.title) return;
    const ok = await store.act(async () => {
      if (editing === "new") await add(form);
      else {
        await L.db.exec(
          "UPDATE notes SET title=?,content=?,tags=?,updated_at=datetime('now') WHERE id=?",
          [form.title, form.content, form.tags, editing.id],
        );
        await L.events.emit("note-updated", { id: editing.id, ...form });
      }
    });
    if (ok) {
      setEditing(null);
      notify("Note saved");
    }
  }
  return (
    <Page>
      <Header
        title="Notes"
        subtitle={`${store.rows.length} notes · ${store.rows.filter((row) => row.pinned).length} pinned`}
        actions={
          <Button variant="primary" onClick={() => setEditing("new")}>
            New note
          </Button>
        }
      />
      <div className="ui-toolbar">
        <Input
          type="search"
          aria-label="Search notes"
          placeholder="Search notes"
          value={search}
          onChange={(e) => setSearch(e.target.value)}
        />
        <select
          aria-label="Filter by tag"
          value={tag}
          onChange={(e) => setTag(e.target.value)}
        >
          <option value="">All tags</option>
          {allTags.map((t) => (
            <option key={t}>{t}</option>
          ))}
        </select>
        <select
          aria-label="Sort notes"
          value={sort}
          onChange={(e) => setSort(e.target.value)}
        >
          <option value="updated">Last edited</option>
          <option value="title">Title A–Z</option>
        </select>
      </div>
      {store.error && (
        <p role="alert" className="ui-error-text">
          {store.error}{" "}
          <Button onClick={() => store.act(async () => {})}>Retry</Button>
        </p>
      )}
      <div className="ui-panel">
        {!store.ready ? (
          <EmptyState
            title={store.error ? "Notes could not be loaded" : "Loading notes…"}
          />
        ) : !filtered.length ? (
          <EmptyState
            title={store.rows.length ? "No matching notes" : "No notes yet"}
            action={
              <Button
                onClick={() => {
                  setSearch("");
                  setTag("");
                  if (!store.rows.length) setEditing("new");
                }}
              >
                {store.rows.length ? "Clear filters" : "New note"}
              </Button>
            }
          />
        ) : (
          filtered.map((note) => (
            <Row
              key={note.id}
              title={
                <button onClick={() => setEditing(note)}>{note.title}</button>
              }
              actions={
                <>
                  <Button
                    variant="ghost"
                    aria-pressed={!!note.pinned}
                    aria-label={`${note.pinned ? "Unpin" : "Pin"} ${note.title}`}
                    onClick={() =>
                      store.act(() =>
                        L.db.exec("UPDATE notes SET pinned=? WHERE id=?", [
                          note.pinned ? 0 : 1,
                          note.id,
                        ]),
                      )
                    }
                  >
                    {note.pinned ? "Unpin" : "Pin"}
                  </Button>
                  <Button
                    variant="ghost"
                    aria-label={`Edit ${note.title}`}
                    onClick={() => setEditing(note)}
                  >
                    Edit
                  </Button>
                  <Button
                    variant="ghost"
                    aria-label={`Delete ${note.title}`}
                    onClick={() => setDeleting(note)}
                  >
                    Delete
                  </Button>
                </>
              }
            >
              <p
                className="ui-muted"
                style={{
                  whiteSpace: "pre-wrap",
                  margin: "6px 0",
                  display: "-webkit-box",
                  WebkitLineClamp: 3,
                  WebkitBoxOrient: "vertical",
                  overflow: "hidden",
                }}
              >
                {note.content}
              </p>
              <div className="ui-actions">
                {note.pinned ? (
                  <span className="ui-badge ui-badge-accent">Pinned</span>
                ) : null}
                {tagsOf(note.tags).map((t) => (
                  <button
                    key={t}
                    className="ui-badge"
                    style={{ border: 0, cursor: "pointer" }}
                    onClick={() => setTag(t)}
                  >
                    {t}
                  </button>
                ))}
                <small className="ui-muted">
                  {displayTime(note.updated_at)}
                </small>
              </div>
            </Row>
          ))
        )}
      </div>
      {editing && (
        <RecordForm
          title={editing === "new" ? "New note" : "Edit note"}
          record={editing}
          fields={fields}
          onSave={save}
          onClose={() => setEditing(null)}
          busy={store.busy}
          error={store.error}
          validate={(form) => (!form.title ? "Enter a title." : "")}
        />
      )}
      {deleting && (
        <ConfirmDelete
          title="Delete note?"
          onClose={() => setDeleting(null)}
          busy={store.busy}
          error={store.error}
          onConfirm={async () => {
            if (
              await store.act(async () => {
                await L.db.exec("DELETE FROM notes WHERE id=?", [deleting.id]);
                await L.events.emit(
                  "note-removed",
                  fieldsOf(deleting, ["id", "title"]),
                );
              })
            ) {
              setDeleting(null);
              notify("Note deleted");
            }
          }}
        >
          {deleting.title} will be permanently deleted.
        </ConfirmDelete>
      )}
      <Toast message={toast} onDismiss={dismissToast} />
    </Page>
  );
}
createRoot(document.getElementById("root")).render(<App />);