# Примеры

Каждый пример — одно приложение из одного HTML-файла: его можно скачать, открыть в [эмуляторе](https://dev.pairx.org/tools#pairx-dev) или сразу вставить в Pairx. Примеры проходят `pairx check` и проверяются в эмуляторе у двух участников при каждой сборке этого сайта.

## Очередь на сдачу {#queue}

Журнал `log`: каждый встаёт в очередь и выходит из неё сам, порядок ставит сервер. Главная кнопка Pairx меняет надпись — «Встать в очередь» или «Выйти из очереди».

[Скачать queue.html](https://dev.pairx.org/examples/queue.html)

```html
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Очередь на сдачу</title>
<!-- log: записи в порядке сервера; убрать можно только свою. -->
<script type="application/pairx+json">
{"description": "Очередь на сдачу лабораторной: встать, выйти, видеть своё место.", "stores": {"queue": {"type": "log"}}}
</script>
<style>
  :root { color-scheme: light dark; --bg: #f5f6f8; --card: #fff; --text: #1b1e24; --muted: #757c88; --accent: #226cf0; }
  @media (prefers-color-scheme: dark) { :root { --bg: #101113; --card: #191b1f; --text: #f3f4f7; --muted: #999faa; --accent: #81acff; } }
  body { margin: 0; padding: 16px; font: 17px/1.4 -apple-system, system-ui, sans-serif; background: var(--bg); color: var(--text); }
  h1 { font-size: 22px; margin: 4px 0; }
  .hint { color: var(--muted); font-size: 15px; margin: 0 0 16px; }
  ol { list-style: none; margin: 0; padding: 0; }
  li { display: flex; gap: 12px; align-items: center; padding: 12px 14px; margin-bottom: 8px; border-radius: 14px; background: var(--card); }
  li.mine { outline: 2px solid var(--accent); }
  .place { min-width: 28px; font-weight: 600; color: var(--muted); }
  .empty { color: var(--muted); text-align: center; padding: 32px 0; }
</style>
</head>
<body>
<h1>Очередь на сдачу</h1>
<p class="hint" id="hint">Загрузка…</p>
<ol id="list"></ol>
<script>
  let me = null, seq = 0, entries = [];
  const labels = new Map();   // user → «Участник N» by first appearance: names come with profile.basic later

  function label(user) {
    if (user === me) return "Вы";
    if (!labels.has(user)) labels.set(user, "Участник " + (labels.size + 1));
    return labels.get(user);
  }

  function render() {
    const waiting = entries.filter((e) => !e.retracted);
    const list = document.getElementById("list");
    list.replaceChildren(...waiting.map((e, i) => {
      const item = document.createElement("li");
      if (e.user === me) item.className = "mine";
      const place = document.createElement("span");
      place.className = "place";
      place.textContent = i + 1;
      item.append(place, label(e.user));
      return item;
    }));
    if (!waiting.length) list.innerHTML = '<li class="empty">Пока никого</li>';
    const mine = waiting.findIndex((e) => e.user === me);
    document.getElementById("hint").textContent = mine < 0 ? "Вы не в очереди." : mine === 0 ? "Вы первый — идите сдавать." : "Перед вами: " + mine + ".";
    pairx.call("ui.mainButton", { text: mine < 0 ? "Встать в очередь" : "Выйти из очереди", visible: true });
  }

  // Changes come in seq order; the ones the snapshot already holds are skipped.
  pairx.on("storage.changed", ({ changes }) => {
    for (const c of changes) {
      if (c.seq <= seq || c.store !== "queue") continue;
      seq = c.seq;
      if (c.op === "log") entries.push({ entry: c.entry, user: c.user, value: c.value, retracted: false });
      if (c.op === "retract") { const e = entries.find((x) => x.entry === c.entry); if (e) e.retracted = true; }
    }
    render();
  });
  pairx.on("storage.reset", load);

  pairx.on("ui.mainButton.clicked", async () => {
    const mine = entries.find((e) => e.user === me && !e.retracted);
    pairx.call("ui.mainButton", { progress: true });
    try {
      if (mine) await pairx.call("storage.write", { op: "log.retract", store: "queue", entry: mine.entry });
      else await pairx.call("storage.write", { op: "log.append", store: "queue", value: "join" });
    } catch (error) {
      pairx.call("ui.toast", { text: error.code === "offline" ? "Нет соединения." : "Не получилось, попробуйте ещё раз." });
    }
    render();
  });

  async function load() {
    const snapshot = await pairx.call("storage.snapshot");
    seq = snapshot.seq;
    entries = snapshot.stores.queue.entries;
    render();
  }

  (async () => {
    me = (await pairx.call("user.get")).id;
    await load();
    pairx.call("app.ready");
  })();
</script>
</body>
</html>
```

## Общий список {#checklist}

Документ `doc` с версией: все участники ведут один список дел или покупок. Две правки одновременно не теряются — запись с устаревшей версией получает `conflict`, и приложение применяет свою правку к новой версии.

[Скачать checklist.html](https://dev.pairx.org/examples/checklist.html)

```html
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Общий список</title>
<!-- doc: один документ на чат; запись называет версию, которую прочитала, устаревшая получает conflict. -->
<script type="application/pairx+json">
{"description": "Список дел или покупок, который ведут все участники чата.", "stores": {"list": {"type": "doc"}}}
</script>
<style>
  :root { color-scheme: light dark; --bg: #f5f6f8; --card: #fff; --text: #1b1e24; --muted: #757c88; --accent: #226cf0; --line: #e4e7eb; }
  @media (prefers-color-scheme: dark) { :root { --bg: #101113; --card: #191b1f; --text: #f3f4f7; --muted: #999faa; --accent: #81acff; --line: #2b2e35; } }
  body { margin: 0; padding: 16px; font: 17px/1.4 -apple-system, system-ui, sans-serif; background: var(--bg); color: var(--text); }
  h1 { font-size: 22px; margin: 4px 0 12px; }
  form { display: flex; gap: 8px; margin-bottom: 12px; }
  input[type=text] { flex: 1; min-width: 0; padding: 12px 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--card); color: var(--text); font: inherit; }
  button { padding: 0 16px; border: 0; border-radius: 12px; background: var(--accent); color: #fff; font: inherit; }
  ul { list-style: none; margin: 0; padding: 0; border-radius: 14px; overflow: hidden; background: var(--card); }
  li { display: flex; gap: 12px; align-items: center; padding: 12px 14px; border-bottom: 1px solid var(--line); }
  li:last-child { border-bottom: 0; }
  li.done span { color: var(--muted); text-decoration: line-through; }
  li span { flex: 1; overflow-wrap: anywhere; }
  li .remove { background: none; color: var(--muted); padding: 0 4px; font-size: 20px; }
  input[type=checkbox] { width: 22px; height: 22px; accent-color: var(--accent); }
  .empty { color: var(--muted); justify-content: center; }
</style>
</head>
<body>
<h1>Общий список</h1>
<form id="add"><input type="text" id="text" maxlength="200" placeholder="Что добавить" autocomplete="off"><button>Добавить</button></form>
<ul id="items"></ul>
<script>
  let seq = 0, doc = { value: null, version: 0 };
  const items = () => (doc.value && doc.value.items) || [];

  function render() {
    const list = document.getElementById("items");
    list.replaceChildren(...items().map((item) => {
      const row = document.createElement("li");
      if (item.done) row.className = "done";
      const box = document.createElement("input");
      box.type = "checkbox";
      box.checked = item.done;
      box.onchange = () => change((all) => all.map((x) => x.id === item.id ? { ...x, done: box.checked } : x));
      const text = document.createElement("span");
      text.textContent = item.text;
      const remove = document.createElement("button");
      remove.className = "remove";
      remove.setAttribute("aria-label", "Удалить");
      remove.textContent = "×";
      remove.onclick = () => change((all) => all.filter((x) => x.id !== item.id));
      row.append(box, text, remove);
      return row;
    }));
    if (!items().length) list.innerHTML = '<li class="empty">Список пуст</li>';
  }

  // One edit: read the current list, change it, write it with the version it was read at. If someone wrote first the
  // service answers `conflict`; the change is then applied again to their version.
  async function change(edit) {
    for (let attempt = 0; attempt < 3; attempt++) {
      try {
        const value = { items: edit(items()) };
        const result = await pairx.call("storage.write", { op: "doc.set", store: "list", value, version: doc.version });
        if (result.version > doc.version) doc = { value, version: result.version };
        render();
        return;
      } catch (error) {
        if (error.code !== "conflict") { pairx.call("ui.toast", { text: error.code === "too_large" ? "Список слишком длинный." : "Не сохранилось." }); break; }
        await load();
      }
    }
    render();
  }

  pairx.on("storage.changed", ({ changes }) => {
    for (const c of changes) {
      if (c.seq <= seq || c.store !== "list") continue;
      seq = c.seq;
      if (c.op === "doc") doc = { value: c.value, version: c.version };
    }
    render();
  });
  pairx.on("storage.reset", load);

  document.getElementById("add").onsubmit = (event) => {
    event.preventDefault();
    const input = document.getElementById("text");
    const text = input.value.trim();
    if (!text) return;
    input.value = "";
    const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
    change((all) => [...all, { id, text, done: false }]);
  };

  async function load() {
    const snapshot = await pairx.call("storage.snapshot");
    seq = snapshot.seq;
    doc = { value: snapshot.stores.list.value, version: snapshot.stores.list.version };
    render();
  }

  load().then(() => pairx.call("app.ready"));
</script>
</body>
</html>
```

## Идеи с голосованием {#ideas}

Два хранилища: идеи — в журнале, голоса — в счётчике по номеру идеи. `per_user_limit: 1` — каждый голосует за идею не больше одного раза; это проверяет сервер, поэтому правило нельзя обойти, изменив код.

[Скачать ideas.html](https://dev.pairx.org/examples/ideas.html)

```html
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Идеи</title>
<!-- Идеи — журнал; голоса — счётчик по ID идеи. per_user_limit: 1 — каждый голосует за идею не больше одного раза,
     это проверяет сервер, а не приложение. -->
<script type="application/pairx+json">
{"description": "Предложить идею и проголосовать за чужие; лучшие — сверху.",
 "stores": {"ideas": {"type": "log"}, "votes": {"type": "counter", "per_user_limit": 1}}}
</script>
<style>
  :root { color-scheme: light dark; --bg: #f5f6f8; --card: #fff; --text: #1b1e24; --muted: #757c88; --accent: #226cf0; --line: #e4e7eb; }
  @media (prefers-color-scheme: dark) { :root { --bg: #101113; --card: #191b1f; --text: #f3f4f7; --muted: #999faa; --accent: #81acff; --line: #2b2e35; } }
  body { margin: 0; padding: 16px; font: 17px/1.4 -apple-system, system-ui, sans-serif; background: var(--bg); color: var(--text); }
  h1 { font-size: 22px; margin: 4px 0 12px; }
  textarea { width: 100%; box-sizing: border-box; min-height: 64px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 12px;
    background: var(--card); color: var(--text); font: inherit; resize: vertical; }
  .idea { display: flex; gap: 12px; align-items: flex-start; padding: 12px 14px; margin-top: 8px; border-radius: 14px; background: var(--card); }
  .idea p { flex: 1; margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
  .vote { min-width: 52px; padding: 6px 10px; border: 1.5px solid var(--line); border-radius: 10px; background: none; color: var(--text); font: inherit; }
  .vote.mine { border-color: var(--accent); color: var(--accent); }
  .empty { color: var(--muted); text-align: center; padding: 24px 0; }
</style>
</head>
<body>
<h1>Идеи</h1>
<textarea id="text" maxlength="500" placeholder="Ваша идея"></textarea>
<div id="list"></div>
<script>
  let me = null, seq = 0, ideas = [], totals = {}, mine = {};

  function render() {
    const list = document.getElementById("list");
    const shown = ideas.filter((i) => !i.retracted)
      .sort((a, b) => (totals[b.entry] || 0) - (totals[a.entry] || 0) || a.entry - b.entry);
    list.replaceChildren(...shown.map((idea) => {
      const row = document.createElement("div");
      row.className = "idea";
      const text = document.createElement("p");
      text.textContent = idea.value;
      const vote = document.createElement("button");
      const key = String(idea.entry);
      vote.className = "vote" + (mine[key] ? " mine" : "");
      vote.textContent = "▲ " + (totals[key] || 0);
      vote.onclick = () => write({ op: "counter.add", store: "votes", key, amount: mine[key] ? -1 : 1 });
      row.append(text, vote);
      return row;
    }));
    if (!shown.length) list.innerHTML = '<p class="empty">Идей пока нет — предложите первую.</p>';
  }

  async function write(body) {
    try { await pairx.call("storage.write", body); }
    catch (error) {
      const text = { rate_limited: "Слишком часто — подождите немного.", too_large: "Слишком длинно.", forbidden: "Так нельзя." }[error.code] || "Не получилось.";
      pairx.call("ui.toast", { text });
    }
  }

  pairx.on("storage.changed", ({ changes }) => {
    for (const c of changes) {
      if (c.seq <= seq) continue;
      seq = c.seq;
      if (c.store === "ideas" && c.op === "log") ideas.push({ entry: c.entry, user: c.user, value: c.value, retracted: false });
      if (c.store === "ideas" && c.op === "retract") { const i = ideas.find((x) => x.entry === c.entry); if (i) i.retracted = true; }
      if (c.store === "votes" && c.op === "counter") { totals[c.key] = c.total; if (c.user === me) mine[c.key] = c.mine; }
    }
    render();
  });
  pairx.on("storage.reset", load);

  // Pairx's main button sends the idea: it stays above the keyboard and looks the same as in the rest of Pairx.
  const text = document.getElementById("text");
  text.oninput = () => pairx.call("ui.mainButton", { text: "Предложить", visible: text.value.trim().length > 0 });
  pairx.on("ui.mainButton.clicked", async () => {
    const value = text.value.trim();
    if (!value) return;
    pairx.call("ui.mainButton", { text: "Предложить", progress: true });
    await write({ op: "log.append", store: "ideas", value });
    text.value = "";
    pairx.call("ui.mainButton", { visible: false });
  });

  async function load() {
    const snapshot = await pairx.call("storage.snapshot");
    seq = snapshot.seq;
    ideas = snapshot.stores.ideas.entries;
    totals = snapshot.stores.votes.totals;
    mine = snapshot.stores.votes.mine;
    render();
  }

  (async () => {
    me = (await pairx.call("user.get")).id;
    await load();
    pairx.call("app.ready");
  })();
</script>
</body>
</html>
```

## Голосование

`pairx create` создаёт ещё один пример — голосование на `slot`: у каждого один голос, его можно изменить.

```sh
pairx create my-poll
```
