Contacts

index.jsx 246 lines

Files · 4
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246import 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 } from "../../shared/data.js";
const L = window.lattice;
const details = ["id", "name", "email", "phone", "birthday", "notes"];
const fields = [
  { name: "name", label: "Name", required: true },
  { name: "email", label: "Email", type: "email" },
  { name: "phone", label: "Phone", type: "tel" },
  { name: "birthday", label: "Birthday", type: "date" },
  { name: "notes", label: "Notes", multiline: true },
];
async function add(data) {
  if (!data?.name) return;
  const row = await inserted(
    "contacts",
    "INSERT INTO contacts (name,email,phone,birthday,notes) VALUES (?,?,?,?,?)",
    [
      data.name,
      data.email || "",
      data.phone || "",
      data.birthday || "",
      data.notes || "",
    ],
  );
  await L.events.emit("contact-added", fieldsOf(row, details));
}
function App() {
  const [search, setSearch] = useState(""),
    [sort, setSort] = useState("name");
  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 contacts (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, email TEXT, phone TEXT, birthday TEXT, notes TEXT, created_at TEXT DEFAULT (datetime('now')))`,
      ),
    load: () =>
      L.db.query("SELECT * FROM contacts ORDER BY name COLLATE NOCASE, id"),
    listeners: { "add-contact": add },
  });
  const query = search.trim().toLowerCase();
  const filtered = store.rows
    .filter((row) =>
      ["name", "email", "phone", "notes", "birthday"].some((key) =>
        String(row[key] || "")
          .toLowerCase()
          .includes(query),
      ),
    )
    .sort((a, b) =>
      sort === "newest"
        ? b.id - a.id
        : sort === "name-desc"
          ? b.name.localeCompare(a.name)
          : a.name.localeCompare(b.name),
    );
  async function save(form) {
    if (!form.name) return;
    const ok = await store.act(async () => {
      if (editing === "new") await add(form);
      else {
        await L.db.exec(
          "UPDATE contacts SET name=?,email=?,phone=?,birthday=?,notes=? WHERE id=?",
          [...details.slice(1).map((key) => form[key]), editing.id],
        );
        await L.events.emit("contact-updated", { id: editing.id, ...form });
      }
    });
    if (ok) {
      setEditing(null);
      notify("Contact saved");
    }
  }
  return (
    <Page>
      <Header
        title="Contacts"
        subtitle={`${store.rows.length} ${store.rows.length === 1 ? "contact" : "contacts"}`}
        actions={
          <Button variant="primary" onClick={() => setEditing("new")}>
            Add contact
          </Button>
        }
      />
      <div className="ui-toolbar">
        <Input
          type="search"
          aria-label="Search contacts"
          placeholder="Search name, email, phone or notes"
          value={search}
          onChange={(e) => setSearch(e.target.value)}
        />
        <select
          aria-label="Sort contacts"
          value={sort}
          onChange={(e) => setSort(e.target.value)}
        >
          <option value="name">Name A–Z</option>
          <option value="name-desc">Name Z–A</option>
          <option value="newest">Newest first</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 ? "Contacts could not be loaded" : "Loading contacts…"
            }
          />
        ) : !filtered.length ? (
          <EmptyState
            title={
              store.rows.length ? "No matching contacts" : "No contacts yet"
            }
            action={
              <Button
                onClick={() =>
                  store.rows.length ? setSearch("") : setEditing("new")
                }
              >
                {store.rows.length ? "Clear search" : "Add contact"}
              </Button>
            }
          />
        ) : (
          filtered.map((contact) => (
            <Row
              key={contact.id}
              leading={
                <span className="ui-avatar">
                  {contact.name
                    .trim()
                    .split(/\s+/)
                    .map((word) => word[0])
                    .slice(0, 2)
                    .join("")
                    .toUpperCase()}
                </span>
              }
              title={
                <button onClick={() => setEditing(contact)}>
                  {contact.name}
                </button>
              }
              subtitle={[contact.email, contact.phone]
                .filter(Boolean)
                .join(" · ")}
              actions={
                <>
                  <Button
                    variant="ghost"
                    onClick={() => setEditing(contact)}
                    aria-label={`Edit ${contact.name}`}
                  >
                    Edit
                  </Button>
                  <Button
                    variant="ghost"
                    onClick={() => setDeleting(contact)}
                    aria-label={`Delete ${contact.name}`}
                  >
                    Delete
                  </Button>
                </>
              }
            >
              {contact.birthday && (
                <span className="ui-badge" style={{ marginTop: 6 }}>
                  Birthday ·{" "}
                  {new Date(contact.birthday + "T12:00:00").toLocaleDateString(
                    [],
                    { month: "short", day: "numeric" },
                  )}
                </span>
              )}
              {contact.notes && (
                <p className="ui-muted" style={{ marginTop: 4 }}>
                  {contact.notes}
                </p>
              )}
            </Row>
          ))
        )}
      </div>
      {editing && (
        <RecordForm
          title={editing === "new" ? "Add contact" : "Edit contact"}
          record={editing}
          fields={fields}
          onSave={save}
          onClose={() => setEditing(null)}
          busy={store.busy}
          error={store.error}
          validate={(form) => (!form.name ? "Enter a name." : "")}
        />
      )}
      {deleting && (
        <ConfirmDelete
          title="Delete contact?"
          busy={store.busy}
          error={store.error}
          onClose={() => setDeleting(null)}
          onConfirm={async () => {
            if (
              await store.act(async () => {
                await L.db.exec("DELETE FROM contacts WHERE id=?", [
                  deleting.id,
                ]);
                await L.events.emit(
                  "contact-removed",
                  fieldsOf(deleting, ["id", "name"]),
                );
              })
            ) {
              setDeleting(null);
              notify("Contact deleted");
            }
          }}
        >
          {deleting.name} will be removed from Contacts.
        </ConfirmDelete>
      )}
      <Toast message={toast} onDismiss={dismissToast} />
    </Page>
  );
}
createRoot(document.getElementById("root")).render(<App />);