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 />);