Bookmarks

index.jsx 303 lines

Files · 4
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303import 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,
  httpUrl,
} from "../../shared/data.js";
const L = window.lattice;
const details = ["id", "url", "title", "description", "tags", "folder"];
const fields = [
  { name: "url", label: "URL", required: true, type: "url" },
  { name: "title", label: "Title" },
  { name: "description", label: "Description", multiline: true },
  { name: "folder", label: "Folder", defaultValue: "Unsorted" },
  { name: "tags", label: "Tags, separated by commas" },
];
async function add(data) {
  if (!data?.url) return;
  // Listener values retain their documented meaning. Unsupported URL schemes are
  // displayed as text; only http(s) values receive an Open link.
  const row = await inserted(
    "bookmarks",
    "INSERT INTO bookmarks (url,title,description,tags,folder) VALUES (?,?,?,?,?)",
    [
      data.url,
      data.title || data.url,
      data.description || "",
      data.tags || "",
      data.folder || "Unsorted",
    ],
  );
  await L.events.emit("bookmark-added", fieldsOf(row, details));
}
function App() {
  const [search, setSearch] = useState(""),
    [folder, setFolder] = useState(""),
    [tag, setTag] = useState(""),
    [sort, setSort] = useState("newest");
  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 bookmarks (id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT NOT NULL, title TEXT NOT NULL, description TEXT, tags TEXT, folder TEXT DEFAULT 'Unsorted', created_at TEXT DEFAULT (datetime('now')))`,
      ),
    load: () => L.db.query("SELECT * FROM bookmarks ORDER BY id DESC"),
    listeners: { "add-bookmark": add },
  });
  const query = search.trim().toLowerCase();
  const filtered = store.rows
    .filter(
      (row) =>
        (!folder || row.folder === folder) &&
        (!tag || tagsOf(row.tags).includes(tag)) &&
        ["title", "url", "description", "tags", "folder"].some((key) =>
          String(row[key] || "")
            .toLowerCase()
            .includes(query),
        ),
    )
    .sort((a, b) =>
      sort === "title"
        ? a.title.localeCompare(b.title)
        : sort === "oldest"
          ? a.id - b.id
          : b.id - a.id,
    );
  async function save(form) {
    form = {
      ...form,
      title: form.title || form.url,
      folder: form.folder || "Unsorted",
    };
    if (
      await store.act(async () => {
        if (editing === "new") await add(form);
        else {
          await L.db.exec(
            "UPDATE bookmarks SET url=?,title=?,description=?,tags=?,folder=? WHERE id=?",
            [...details.slice(1).map((key) => form[key]), editing.id],
          );
          await L.events.emit("bookmark-updated", { id: editing.id, ...form });
        }
      })
    ) {
      setEditing(null);
      notify("Bookmark saved");
    }
  }
  return (
    <Page>
      <Header
        title="Bookmarks"
        subtitle={`${store.rows.length} saved links`}
        actions={
          <Button variant="primary" onClick={() => setEditing("new")}>
            Add link
          </Button>
        }
      />
      <div className="ui-toolbar">
        <Input
          type="search"
          aria-label="Search bookmarks"
          placeholder="Search title, URL, folder or tags"
          value={search}
          onChange={(e) => setSearch(e.target.value)}
        />
        <select
          aria-label="Filter by folder"
          value={folder}
          onChange={(e) => setFolder(e.target.value)}
        >
          <option value="">All folders</option>
          {[...new Set(store.rows.map((row) => row.folder).filter(Boolean))]
            .sort()
            .map((value) => (
              <option key={value}>{value}</option>
            ))}
        </select>
        <select
          aria-label="Filter by tag"
          value={tag}
          onChange={(e) => setTag(e.target.value)}
        >
          <option value="">All tags</option>
          {[...new Set(store.rows.flatMap((row) => tagsOf(row.tags)))]
            .sort()
            .map((value) => (
              <option key={value}>{value}</option>
            ))}
        </select>
        <select
          aria-label="Sort bookmarks"
          value={sort}
          onChange={(e) => setSort(e.target.value)}
        >
          <option value="newest">Newest first</option>
          <option value="oldest">Oldest first</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
                ? "Bookmarks could not be loaded"
                : "Loading bookmarks…"
            }
          />
        ) : !filtered.length ? (
          <EmptyState
            title={
              store.rows.length ? "No matching bookmarks" : "No bookmarks yet"
            }
            action={
              <Button
                onClick={() => {
                  setSearch("");
                  setFolder("");
                  setTag("");
                  if (!store.rows.length) setEditing("new");
                }}
              >
                {store.rows.length ? "Clear filters" : "Add link"}
              </Button>
            }
          />
        ) : (
          filtered.map((bookmark) => (
            <Row
              key={bookmark.id}
              leading={
                <span className="ui-avatar">
                  {bookmark.title[0]?.toUpperCase() || "↗"}
                </span>
              }
              title={
                <button onClick={() => setEditing(bookmark)}>
                  {bookmark.title}
                </button>
              }
              subtitle={bookmark.url}
              actions={
                <>
                  {httpUrl(bookmark.url) && (
                    <a
                      className="ui-button"
                      href={httpUrl(bookmark.url)}
                      target="_blank"
                      rel="noopener noreferrer"
                      aria-label={`Open ${bookmark.title}`}
                    >
                      Open ↗
                    </a>
                  )}
                  <Button
                    variant="ghost"
                    aria-label={`Edit ${bookmark.title}`}
                    onClick={() => setEditing(bookmark)}
                  >
                    Edit
                  </Button>
                  <Button
                    variant="ghost"
                    aria-label={`Delete ${bookmark.title}`}
                    onClick={() => setDeleting(bookmark)}
                  >
                    Delete
                  </Button>
                </>
              }
            >
              {bookmark.description && (
                <p className="ui-muted" style={{ marginTop: 4 }}>
                  {bookmark.description}
                </p>
              )}
              <div className="ui-actions" style={{ marginTop: 7 }}>
                <span className="ui-badge ui-badge-accent">
                  {bookmark.folder || "Unsorted"}
                </span>
                {tagsOf(bookmark.tags).map((t) => (
                  <button
                    className="ui-badge"
                    key={t}
                    style={{ border: 0, cursor: "pointer" }}
                    onClick={() => setTag(t)}
                  >
                    {t}
                  </button>
                ))}
              </div>
            </Row>
          ))
        )}
      </div>
      {editing && (
        <RecordForm
          title={editing === "new" ? "Add link" : "Edit bookmark"}
          record={editing}
          fields={fields}
          onSave={save}
          onClose={() => setEditing(null)}
          busy={store.busy}
          error={store.error}
          validate={(form) =>
            !httpUrl(form.url) ? "Enter an http:// or https:// URL." : ""
          }
        />
      )}
      {deleting && (
        <ConfirmDelete
          title="Delete bookmark?"
          onClose={() => setDeleting(null)}
          busy={store.busy}
          error={store.error}
          onConfirm={async () => {
            if (
              await store.act(async () => {
                await L.db.exec("DELETE FROM bookmarks WHERE id=?", [
                  deleting.id,
                ]);
                await L.events.emit(
                  "bookmark-removed",
                  fieldsOf(deleting, ["id", "url", "title"]),
                );
              })
            ) {
              setDeleting(null);
              notify("Bookmark deleted");
            }
          }}
        >
          {deleting.title} will be permanently deleted.
        </ConfirmDelete>
      )}
      <Toast message={toast} onDismiss={dismissToast} />
    </Page>
  );
}
createRoot(document.getElementById("root")).render(<App />);