Fixes
This commit is contained in:
parent
2c887ca451
commit
cb93544793
5 changed files with 336 additions and 78 deletions
|
|
@ -48,11 +48,20 @@
|
|||
const p = panels.db;
|
||||
p.innerHTML = `
|
||||
<h3>База мешканців району</h3>
|
||||
<p class="dim small">Пошук за іменем: досить початку — «шев» знайде «Шевченко», регістр не важить. Кілька слів звужують (ім'я + прізвище). Значення для фільтрів беріть зі знайдених слідів і вводьте самі. Реєстр місцями застарілий: одяг і розмір взуття надійніше звіряти очним оглядом (ордер у польового).</p>
|
||||
<p class="dim small">Реєстр місцями застарілий: одяг і розмір взуття надійніше звіряти очним оглядом (ордер у польового).</p>
|
||||
<!-- Два незалежні запити, а не один із додатковими полями: пошук за іменем
|
||||
і пошук за прикметами не поєднуються й перебивають один одного. Тому
|
||||
вони рознесені підписами і рискою — раніше два рядки полів підряд
|
||||
читались як одна форма з кнопкою «Знайти» вгорі. -->
|
||||
<div class="dbsec">Пошук за іменем</div>
|
||||
<p class="dim small">Досить початку — «шев» знайде «Шевченко», регістр не важить. Кілька слів звужують (ім'я + прізвище).</p>
|
||||
<div class="filters">
|
||||
<input id="q-name" placeholder="Ім'я Прізвище" style="min-width:220px">
|
||||
<button id="q-go" class="primary">Знайти</button>
|
||||
</div>
|
||||
<div class="dbsplit"></div>
|
||||
<div class="dbsec">Пошук за прикметами</div>
|
||||
<p class="dim small">Значення беріть зі знайдених слідів і вводьте самі. Порожні поля не враховуються.</p>
|
||||
<div class="filters">
|
||||
<select id="f-sex"><option value="">стать: будь-яка</option><option value="ч">чол.</option><option value="ж">жін.</option></select>
|
||||
<input id="f-smin" type="number" placeholder="взуття від" min="35" max="46">
|
||||
|
|
@ -66,6 +75,12 @@
|
|||
</div>
|
||||
<div id="db-out"></div>`;
|
||||
dbOut = p.querySelector('#db-out');
|
||||
// Enter шукає / фільтрує — щоб не тягнутись мишею до кнопки на кожен із
|
||||
// десятків пошуків за гру. Числові поля фільтрів теж: набрав розмір взуття,
|
||||
// натиснув Enter. У <select> потреби немає — вони й так змінюються з клавіатури.
|
||||
submitOn(p.querySelector('#q-name'), p.querySelector('#q-go'));
|
||||
for (const id of ['#f-smin', '#f-smax', '#f-hmin', '#f-hmax'])
|
||||
submitOn(p.querySelector(id), p.querySelector('#f-go'));
|
||||
p.querySelector('#q-go').onclick = () => {
|
||||
const q = p.querySelector('#q-name').value.trim();
|
||||
if (!q) { lastRows = null; dbOut.innerHTML = '<p class="dim">— Введіть ім\'я або прізвище.</p>'; return; }
|
||||
|
|
@ -161,7 +176,7 @@
|
|||
if (res.length) html += '<div class="syshead">Мешканці</div>' + res.map(c => `<div class="small">${c.home}: ${esc(c.name)}</div>`).join('');
|
||||
if (work.length) html += '<div class="syshead">Працюють</div>' + work.map(c => `<div class="small">${esc(c.name)} — ${esc(c.job)}</div>`).join('');
|
||||
html += `<div class="syshead">Зараз тут</div><div class="small dim">${here.length ? here.map(c => esc(c.name)).join(', ') : 'нікого не зафіксовано'}</div>`;
|
||||
openModal(html);
|
||||
openModal(html, { key: 'building:' + b.id });
|
||||
}
|
||||
|
||||
// ---------- Системи-форми ----------
|
||||
|
|
@ -456,7 +471,10 @@
|
|||
const mm = openModal(`<h3>Тріангуляція номера ${esc(data.number)}</h3>
|
||||
<p class="dim small">Три вежі, три відстані до апарата — станом на зараз. Клацніть будинок, що лежить на перетині всіх трьох дуг.</p>
|
||||
<canvas id="trcv"></canvas>
|
||||
<div id="trreads" class="small" style="margin-top:6px"></div>`);
|
||||
<div id="trreads" class="small" style="margin-top:6px"></div>`,
|
||||
// Прилітає у відповідь на запит до оператора: два натискання «Тріангуляція»
|
||||
// дали б дві мапи одна на одній.
|
||||
{ key: 'triangulate' });
|
||||
const cv = mm.inner.querySelector('#trcv'); cv.id = 'cv';
|
||||
fitCanvas(cv);
|
||||
const ctx = cv.getContext('2d');
|
||||
|
|
|
|||
|
|
@ -162,6 +162,88 @@ function wireDraft(input, key) {
|
|||
noteDraft[key] = input.value;
|
||||
});
|
||||
}
|
||||
|
||||
// Які нотатки зараз відкриті на правку. Дошка перемальовується на кожен снапшот
|
||||
// стану, тож цей прапорець мусить жити ПОЗА рендером — інакше будь-яка дія
|
||||
// напарника згортала б поле просто під пальцями.
|
||||
const noteEditing = new Set();
|
||||
|
||||
// Рядок нотатки на дошці: текст, автор, ✎ і видалення. Один рендер на два місця —
|
||||
// спільні нотатки у вкладці й точкові на картці доказу, — щоб вони не розійшлися,
|
||||
// як свого часу розійшлися дві копії пошуку по базі.
|
||||
// compact — вигляд для картки доказу (рядок) проти вкладки «Нотатки» (картка);
|
||||
// draftKey — власний ключ чернетки, НЕ спільний із полем нової нотатки;
|
||||
// onSave(txt) / onDel() — хто саме шле повідомлення, вирішує викличне місце.
|
||||
// Правка йде на місці: сервер міняє лише txt, тож нотатка лишається там, де була.
|
||||
function noteRow(n, { compact, draftKey, onSave, onDel }) {
|
||||
const author = n.by === "field" ? "польовий" : "аналітик";
|
||||
const row = el("div", compact ? "small dim" : "card");
|
||||
|
||||
if (noteEditing.has(n.id)) {
|
||||
const inp = el(compact ? "input" : "textarea", "small");
|
||||
if (!compact) inp.rows = 2;
|
||||
inp.style.cssText = "width:100%";
|
||||
inp.value = noteDraft[draftKey] ?? n.txt;
|
||||
wireDraft(inp, draftKey);
|
||||
const finish = () => {
|
||||
noteEditing.delete(n.id);
|
||||
delete noteDraft[draftKey];
|
||||
};
|
||||
const save = () => {
|
||||
const txt = inp.value.trim();
|
||||
const changed = txt && txt !== n.txt;
|
||||
finish();
|
||||
if (changed) onSave(txt);
|
||||
renderSide();
|
||||
};
|
||||
const cancel = () => {
|
||||
finish();
|
||||
renderSide();
|
||||
};
|
||||
inp.onkeydown = (e) => {
|
||||
// У textarea Shift+Enter лишається переносом рядка — зберігає лише чистий Enter.
|
||||
if (e.key === "Enter" && (compact || !e.shiftKey)) {
|
||||
e.preventDefault();
|
||||
save();
|
||||
}
|
||||
// Esc скасовує правку і далі не йде: глобальний обробник інакше просто
|
||||
// зняв би фокус, і поле лишилось би відкритим без причини.
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
cancel();
|
||||
}
|
||||
};
|
||||
const ok = el("button", "small", "Зберегти");
|
||||
ok.onclick = save;
|
||||
const no = el("button", "small", "Скасувати");
|
||||
no.onclick = cancel;
|
||||
row.append(inp, ok, no);
|
||||
return row;
|
||||
}
|
||||
|
||||
if (compact)
|
||||
row.append(el("span", "", `✎ ${esc(n.txt)} `), el("span", "tag", author));
|
||||
else row.append(el("div", "", esc(n.txt)), el("div", "small dim", author));
|
||||
|
||||
const ed = el("button", "small", "✎");
|
||||
ed.title = "редагувати нотатку";
|
||||
ed.onclick = () => {
|
||||
noteEditing.add(n.id);
|
||||
renderSide();
|
||||
// Перемальовка щойно замінила вузол — фокус ставимо на новий, інакше
|
||||
// гравцеві довелося б клацати ще раз у поле, яке він сам щойно відкрив.
|
||||
const f = $(`#sidebody [data-draft-key="${CSS.escape(draftKey)}"]`);
|
||||
if (f) {
|
||||
f.focus();
|
||||
f.setSelectionRange(f.value.length, f.value.length);
|
||||
}
|
||||
};
|
||||
const del = el("button", "small", compact ? "×" : "видалити");
|
||||
del.title = "видалити нотатку";
|
||||
del.onclick = onDel;
|
||||
row.append(ed, del);
|
||||
return row;
|
||||
}
|
||||
$("#sidetabs").onclick = (e) => {
|
||||
const b = e.target.closest("button");
|
||||
if (!b) return;
|
||||
|
|
@ -180,16 +262,20 @@ const EV_MARKS = [
|
|||
["cross", "викреслити", null],
|
||||
["hide", "сховати", null],
|
||||
];
|
||||
// Порядок кольорів — це порядок кнопок. Замість синього тут фіолетовий: синій
|
||||
// (#5b8dbf) і зелений (#5aa870) майже збігалися за яскравістю, і на кольоровій
|
||||
// лівій грані рядка — смужці 4px — розрізнити їх було майже неможливо.
|
||||
const PEOPLE_MARKS = [
|
||||
["green", null, "var(--mk-green)"],
|
||||
["red", null, "var(--mk-red)"],
|
||||
["blue", null, "var(--mk-blue)"],
|
||||
["violet", null, "var(--mk-violet)"],
|
||||
["amber", null, "var(--mk-amber)"],
|
||||
["hide", "сховати", null],
|
||||
];
|
||||
const MARK_GLYPH = { tick: "✓", cross: "✗", hide: "…" };
|
||||
// Сейви зі старих версій тримають на людях ✓/✗ — читаємо їх як два перші кольори.
|
||||
const LEGACY_PERSON_MARK = { tick: "green", cross: "red" };
|
||||
// Сейви зі старих версій тримають на людях ✓/✗ — читаємо їх як два перші кольори,
|
||||
// а колишній синій показуємо як фіолетовий, що прийшов йому на зміну.
|
||||
const LEGACY_PERSON_MARK = { tick: "green", cross: "red", blue: "violet" };
|
||||
const isPersonKey = (key) => String(key).startsWith("c:");
|
||||
|
||||
function markOf(key) {
|
||||
|
|
@ -343,18 +429,16 @@ function renderSide() {
|
|||
}
|
||||
c.append(markButtons(key));
|
||||
const notes = (App.state.board.enotes || {})[e.id] || [];
|
||||
for (const n of notes) {
|
||||
const row = el(
|
||||
"div",
|
||||
"small dim",
|
||||
`✎ ${esc(n.txt)} <span class="tag">${n.by === "field" ? "польовий" : "аналітик"}</span>`,
|
||||
for (const n of notes)
|
||||
c.append(
|
||||
noteRow(n, {
|
||||
compact: true,
|
||||
draftKey: "edit:" + n.id,
|
||||
onSave: (txt) =>
|
||||
App.send({ t: "enote_edit", evId: e.id, id: n.id, txt }),
|
||||
onDel: () => App.send({ t: "enote_del", evId: e.id, id: n.id }),
|
||||
}),
|
||||
);
|
||||
const d = el("button", "small", "×");
|
||||
d.title = "видалити нотатку";
|
||||
d.onclick = () => App.send({ t: "enote_del", evId: e.id, id: n.id });
|
||||
row.append(d);
|
||||
c.append(row);
|
||||
}
|
||||
const ni = el("input", "small");
|
||||
ni.placeholder = "нотатка до доказу…";
|
||||
ni.style.cssText = "width:100%;margin-top:4px";
|
||||
|
|
@ -428,17 +512,15 @@ function renderSide() {
|
|||
}
|
||||
};
|
||||
body.append(ta, add);
|
||||
for (const n of [...App.state.board.notes].reverse()) {
|
||||
const c = el("div", "card");
|
||||
c.append(
|
||||
el("div", "", esc(n.txt)),
|
||||
el("div", "small dim", n.by === "field" ? "польовий" : "аналітик"),
|
||||
for (const n of [...App.state.board.notes].reverse())
|
||||
body.append(
|
||||
noteRow(n, {
|
||||
compact: false,
|
||||
draftKey: "edit:" + n.id,
|
||||
onSave: (txt) => App.send({ t: "note_edit", id: n.id, txt }),
|
||||
onDel: () => App.send({ t: "note_del", id: n.id }),
|
||||
}),
|
||||
);
|
||||
const d = el("button", "small", "видалити");
|
||||
d.onclick = () => App.send({ t: "note_del", id: n.id });
|
||||
c.append(d);
|
||||
body.append(c);
|
||||
}
|
||||
}
|
||||
|
||||
// Повернути фокус і курсор у поле, де друкували до перемальовки, — але лише
|
||||
|
|
@ -478,7 +560,10 @@ function showDossier(c) {
|
|||
<tr><th>Верхній одяг</th><td>${esc(c.coat)}</td></tr>
|
||||
<tr><th>Звички</th><td>${c.smoker ? "курить; " : ""}${c.dog ? "собака " + esc(c.dog) + "; " : ""}${c.tattoo ? "тату: " + esc(c.tattoo) : ""}</td></tr>
|
||||
<tr><th>ДНК-профіль</th><td>${dnaCells}</td></tr>
|
||||
</table>`);
|
||||
</table>`,
|
||||
// Ключ із id: досьє іншої людини — це інша модалка, а не дубль цієї.
|
||||
{ key: "dossier:" + c.id },
|
||||
);
|
||||
if (App.state && App.state.warrants.includes(c.id))
|
||||
m.inner.append(el("div", "small", "📜 Ордер на обшук житла — отримано"));
|
||||
m.inner.append(logSection(c.id));
|
||||
|
|
@ -549,30 +634,66 @@ function matchName(citizens, query) {
|
|||
});
|
||||
}
|
||||
|
||||
// Enter у полі = натиснути кнопку поруч. Поля пошуку лежать у <div class="filters">,
|
||||
// а не у <form>, тож неявного сабміту браузер не дає — доводиться вішати руками.
|
||||
// Хелпер спільний, бо це вже четверте місце з тим самим рядком, а база мешканців
|
||||
// існує у двох копіях (аналітик і польовий), які одного разу вже розійшлися.
|
||||
const submitOn = (input, button) => {
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
button.click();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ---------- Модалки ----------
|
||||
// Кроки більше не заміщають один одного: батьківська модалка лишається під
|
||||
// поточною, тож скасування picker'а повертає до розмови, а не в порожнечу.
|
||||
// opts.back — явне «← Назад» там, де попередній крок треба перезібрати
|
||||
// (порожня тека ордера, шортліст лабораторії); opts.onClose — прибирання за собою.
|
||||
//
|
||||
// opts.key робить відкриття ІДЕМПОТЕНТНИМ: повторний клік по тій самій кнопці
|
||||
// замінює наявний шар, а не кладе ще один зверху. Раніше N кліків по «Справа»
|
||||
// давали N однакових модалок стосом — і оскільки .modal це fixed/inset:0, вони
|
||||
// накладались ідеально й читались як панель, що не закривається.
|
||||
// Ключ — це КОНКРЕТНА ціль ('unit:C8', 'talk:c23'), а не тип панелі: інакше
|
||||
// перехід «квартира A2 → квартира A3» затирав би сам себе. Кроки одного ланцюга
|
||||
// (відділок → ордер → вибір підстави) ключів не мають зовсім — там стос
|
||||
// навмисний, на ньому тримається «← Назад».
|
||||
function openModal(html, opts = {}) {
|
||||
// Заміна, а не закриття: onClose тут не кличемо свідомо — у деяких місцях він
|
||||
// прибирає БАТЬКІВСЬКУ модалку (шортліст лабораторії), і повторний клік
|
||||
// рознiс би ланцюг, який гравець не чіпав.
|
||||
if (opts.key) {
|
||||
const old = document.querySelector(`.modal[data-key="${CSS.escape(opts.key)}"]`);
|
||||
if (old) old.remove();
|
||||
}
|
||||
const wrap = el("div", "modal");
|
||||
if (opts.key) wrap.dataset.key = opts.key;
|
||||
// Позначка для Esc: locked-модалки (мінігра вилучення) мають власний вихід
|
||||
// «Відкласти» — клавіатурою їх не обходять.
|
||||
if (opts.locked) wrap.dataset.locked = "1";
|
||||
const inner = el("div", "", html);
|
||||
wrap.append(inner);
|
||||
const close = () => {
|
||||
wrap.remove();
|
||||
if (opts.onClose) opts.onClose();
|
||||
};
|
||||
const goBack = opts.back
|
||||
? () => {
|
||||
wrap.remove();
|
||||
opts.back();
|
||||
}
|
||||
: null;
|
||||
if (!opts.locked) {
|
||||
wrap.onclick = (e) => {
|
||||
if (e.target === wrap) close();
|
||||
};
|
||||
const foot = el("div", "modalfoot");
|
||||
if (opts.back) {
|
||||
if (goBack) {
|
||||
const bk = el("button", "small", "← Назад");
|
||||
bk.onclick = () => {
|
||||
wrap.remove();
|
||||
opts.back();
|
||||
};
|
||||
bk.onclick = goBack;
|
||||
foot.append(bk);
|
||||
}
|
||||
const x = el("button", "small", "Закрити");
|
||||
|
|
@ -583,10 +704,44 @@ function openModal(html, opts = {}) {
|
|||
// Мікрозадача переносить кнопки в кінець, коли всі синхронні append'и минули.
|
||||
queueMicrotask(() => inner.append(foot));
|
||||
}
|
||||
// Хендли на самому вузлі: глобальний Esc знаходить модалку в DOM і мусить
|
||||
// закрити її тим самим шляхом, що й кнопка, — інакше загубиться onClose.
|
||||
wrap.__close = close;
|
||||
wrap.__back = goBack;
|
||||
document.body.append(wrap);
|
||||
return { wrap, inner, close };
|
||||
}
|
||||
|
||||
// Прибрати модалку за ключем ззовні: стан прийшов — чернетку звинувачення
|
||||
// відкликано, справу закрито. Заміняє ручні змінні-сингтони, які раніше
|
||||
// тримали посилання на вузол лише заради цього.
|
||||
const closeModalByKey = (key) => {
|
||||
const m = document.querySelector(`.modal[data-key="${CSS.escape(key)}"]`);
|
||||
if (m) m.remove();
|
||||
};
|
||||
|
||||
// Esc закриває ВЕРХНЮ модалку — саме верхню, бо стос навмисний: жест, який
|
||||
// здуває весь ланцюг, зруйнував би навігацію «← Назад» так само, як її зруйнував
|
||||
// би глобальний сингтон замість ключів.
|
||||
addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Escape") return;
|
||||
// Перший Esc із поля вводу лише звільняє поле — клавіатура дорожча за модалку:
|
||||
// у польового це аварійне повернення керування району (той самий жест у
|
||||
// field.js), а для аналітика, де того обробника немає, знімаємо фокус тут.
|
||||
// Модалку закриє наступний Esc — уже «чистий».
|
||||
const t = e.target;
|
||||
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA")) {
|
||||
t.blur();
|
||||
return;
|
||||
}
|
||||
const open = document.querySelectorAll(".modal:not([data-locked])");
|
||||
const top = open[open.length - 1];
|
||||
if (!top) return;
|
||||
e.preventDefault();
|
||||
// Є «← Назад» — Esc робить крок назад, а не викидає з усього ланцюга.
|
||||
(top.__back || top.__close)();
|
||||
});
|
||||
|
||||
function showIntro() {
|
||||
const i = App.init.intro;
|
||||
const fieldHow = `
|
||||
|
|
@ -610,11 +765,14 @@ function showIntro() {
|
|||
<li><b>Лабораторія:</b> ДНК і відбитки порівнюєте вручну — до 6 профілів за раз. Шифри теж тут. Увага: ДНК каже лише, ЧИЯ вона, — не коли й чому лишена. На місці буває ДНК людей, що не мають стосунку до злочину: збіг — привід для допиту, а не вирок.</li>
|
||||
<li>Клік по порожньому місцю мапи — мітка для напарника.</li>
|
||||
</ul>`;
|
||||
openModal(`<h3>${esc(i.title)}: ${esc(i.scene)}, ~${esc(i.time)}</h3><p>${esc(i.text)}</p>
|
||||
openModal(
|
||||
`<h3>${esc(i.title)}: ${esc(i.scene)}, ~${esc(i.time)}</h3><p>${esc(i.text)}</p>
|
||||
<p class="dim small">Роль: ${App.role === "field" ? "польовий детектив — район, обшуки, розмови" : "аналітик — бази, камери, лабораторія"}.</p>
|
||||
${App.role === "field" ? fieldHow : analystHow}
|
||||
<div class="syshead">Фінал</div>
|
||||
<p class="small">Одне звинувачення на двох: ХТО (ім'я з клавіатури) / ЯК чи ДЕ / ЧОМУ, підписи обох. Прокурор дивиться лише на закріплені 📌 докази: потрібне підтвердження особи (ДНК, відбиток або зламане алібі), мотив і спосіб. Нотатки — спільні: загальні у вкладці, точкові — прямо на картці доказу.</p>`);
|
||||
<p class="small">Одне звинувачення на двох: ХТО (ім'я з клавіатури) / ЯК чи ДЕ / ЧОМУ, підписи обох. Прокурор дивиться лише на закріплені 📌 докази: потрібне підтвердження особи (ДНК, відбиток або зламане алібі), мотив і спосіб. Нотатки — спільні: загальні у вкладці, точкові — прямо на картці доказу.</p>`,
|
||||
{ key: "intro" },
|
||||
);
|
||||
}
|
||||
$("#introbtn").onclick = showIntro;
|
||||
|
||||
|
|
@ -638,7 +796,11 @@ $("#accusebtn").onclick = () => {
|
|||
<p>${isK ? "ДЕ утримують:" : "ЯК / ЧИМ:"}<br><select id="ac-how" style="width:100%">${o.methods.map((x) => `<option>${esc(x)}</option>`).join("")}</select></p>
|
||||
<p>ЧОМУ:<br><select id="ac-why" style="width:100%">${o.motives.map((x) => `<option>${esc(x)}</option>`).join("")}</select></p>
|
||||
<p>Закріплено доказів: <b>${st.board.pins.length}</b></p>
|
||||
<button class="danger" id="ac-go">Подати на підпис напарнику</button>`);
|
||||
<button class="danger" id="ac-go">Подати на підпис напарнику</button>`,
|
||||
// Спільний ключ із модалкою статусу: щойно з'явиться чернетка, форма
|
||||
// сама поступиться їй місцем, а не ляже під нею другим шаром.
|
||||
{ key: "accuse" },
|
||||
);
|
||||
m.inner.querySelector("#ac-go").onclick = () => {
|
||||
const who = m.inner.querySelector("#ac-who").value.trim();
|
||||
if (!who) return toast("Впишіть ім'я.");
|
||||
|
|
@ -652,11 +814,9 @@ $("#accusebtn").onclick = () => {
|
|||
};
|
||||
};
|
||||
|
||||
let accuseModal = null;
|
||||
function showAccuseStatus() {
|
||||
const d = App.state.accuseDraft;
|
||||
if (accuseModal) accuseModal.wrap.remove();
|
||||
accuseModal = openModal(`
|
||||
const m = openModal(`
|
||||
<h3>${App.init.caseType === "kidnap" ? "Рапорт на штурм — на підписі" : "Звинувачення на підписі"}</h3>
|
||||
<table>
|
||||
<tr><th>ХТО</th><td>${esc(d.who)}</td></tr>
|
||||
|
|
@ -666,17 +826,19 @@ function showAccuseStatus() {
|
|||
<p class="small">Підписано: ${d.signed} з ${d.need}</p>
|
||||
<p class="dim small">Потрібен підпис КОЖНОГО гравця — і вироку вже не відкликати.</p>
|
||||
<button class="danger" id="ac-sign">Підписати</button>
|
||||
<button id="ac-cancel">Відкликати</button>`);
|
||||
accuseModal.inner.querySelector("#ac-sign").onclick = () =>
|
||||
<button id="ac-cancel">Відкликати</button>`,
|
||||
{ key: "accuse" },
|
||||
);
|
||||
m.inner.querySelector("#ac-sign").onclick = () =>
|
||||
App.send({ t: "accuse_confirm" });
|
||||
accuseModal.inner.querySelector("#ac-cancel").onclick = () =>
|
||||
m.inner.querySelector("#ac-cancel").onclick = () =>
|
||||
App.send({ t: "accuse_cancel" });
|
||||
}
|
||||
// Статус перезбирається на кожен снапшот (змінилася кількість підписів), тож
|
||||
// стару модалку прибираємо явно: ключ сам по собі замінив би її лише в момент
|
||||
// відкриття нової, а тут буває й відкликання — коли нову не відкривають зовсім.
|
||||
App.on("state", () => {
|
||||
if (accuseModal) {
|
||||
accuseModal.wrap.remove();
|
||||
accuseModal = null;
|
||||
}
|
||||
closeModalByKey("accuse");
|
||||
if (App.state.accuseDraft && !App.state.ended) showAccuseStatus();
|
||||
});
|
||||
|
||||
|
|
@ -687,10 +849,10 @@ function renderEnd() {
|
|||
if (!e || endShown) return;
|
||||
endShown = true;
|
||||
if (window.Snd) Snd.sting(!!e.solved);
|
||||
if (accuseModal) {
|
||||
accuseModal.wrap.remove();
|
||||
accuseModal = null;
|
||||
}
|
||||
closeModalByKey("accuse");
|
||||
// endShown лишається: він гасить не подвійний клік, а повторний показ фіналу
|
||||
// на кожен наступний снапшот — ключ від цього не рятує, бо модалку щоразу
|
||||
// відкривали б наново.
|
||||
openModal(
|
||||
`
|
||||
<div id="endscreen">
|
||||
|
|
@ -699,6 +861,6 @@ function renderEnd() {
|
|||
<p>${esc(e.recap)}</p>
|
||||
<p class="dim small">${e.solved ? "Новий код сесії — цілком нова справа." : "Справжню картину відновив ваш наступник. Новий код сесії — нова спроба в новій справі."}</p>
|
||||
</div>`,
|
||||
{ locked: false },
|
||||
{ key: "end" },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -302,10 +302,13 @@
|
|||
function stationModal() {
|
||||
// Відділок лишається під усім, що з нього відкривають: і база, і ордер —
|
||||
// це кроки, з яких треба вміти повернутись, а не заміщення екрана.
|
||||
const m = openModal(`<h3>Відділок поліції</h3>
|
||||
const m = openModal(
|
||||
`<h3>Відділок поліції</h3>
|
||||
<p class="dim small">Ваша база: дошка справи і термінал аналітика синхронізовані. Тут-таки сидить прокурор — ордер на обшук житла подають особисто, з доказом, який прямо вказує на людину.</p>
|
||||
<button class="primary" id="warr">📜 Подати на ордер</button>
|
||||
<button id="db">🗂 База мешканців</button>`);
|
||||
<button id="db">🗂 База мешканців</button>`,
|
||||
{ key: "station" },
|
||||
);
|
||||
m.inner.querySelector("#db").onclick = residentsModal;
|
||||
m.inner.querySelector("#warr").onclick = warrantFlow;
|
||||
}
|
||||
|
|
@ -350,9 +353,14 @@
|
|||
const coats = [...new Set(App.init.citizens.map((c) => c.coat))];
|
||||
const m = openModal(`<h3>База мешканців району</h3>
|
||||
<p class="dim small">Термінал аналітика, тільки перегляд. Кольорові мітки ставить аналітик — тут вони лише видно; що означає кожен колір, домовляєтесь ви двоє. Реєстр місцями застарілий: одяг і взуття надійніше звіряти очним оглядом за ордером.</p>
|
||||
<!-- Той самий поділ, що й у базі аналітика: два незалежні запити, які
|
||||
перебивають один одного, а не одна форма. Два екрани — один вигляд. -->
|
||||
<div class="dbsec">Пошук за іменем</div>
|
||||
<div class="filters">
|
||||
<input id="rq" placeholder="Ім'я Прізвище" style="min-width:200px"><button id="rqgo">Знайти</button>
|
||||
</div>
|
||||
<div class="dbsplit"></div>
|
||||
<div class="dbsec">Пошук за прикметами</div>
|
||||
<div class="filters">
|
||||
<select id="rf-sex"><option value="">стать: будь-яка</option><option value="ч">чол.</option><option value="ж">жін.</option></select>
|
||||
<input id="rf-smin" type="number" placeholder="взуття від" min="35" max="46">
|
||||
|
|
@ -364,7 +372,9 @@
|
|||
<label class="small dim"><input type="checkbox" id="rf-smoke"> курить</label>
|
||||
<button id="rfgo">Фільтрувати</button>
|
||||
</div>
|
||||
<div id="rout"><p class="dim small">Задайте пошук або фільтр.</p></div>`);
|
||||
<div id="rout"><p class="dim small">Задайте пошук або фільтр.</p></div>`,
|
||||
{ key: "db" },
|
||||
);
|
||||
const out = m.inner.querySelector("#rout");
|
||||
const draw = (rows) => {
|
||||
out.innerHTML = `<p class="dim small">Збігів: ${rows.length}</p>`;
|
||||
|
|
@ -430,6 +440,10 @@
|
|||
};
|
||||
m.inner.querySelector("#rqgo").onclick = byName;
|
||||
m.inner.querySelector("#rfgo").onclick = byFilter;
|
||||
// Той самий Enter, що й у базі аналітика: два екрани — одна поведінка.
|
||||
submitOn(m.inner.querySelector("#rq"), m.inner.querySelector("#rqgo"));
|
||||
for (const id of ["#rf-smin", "#rf-smax", "#rf-hmin", "#rf-hmax"])
|
||||
submitOn(m.inner.querySelector(id), m.inner.querySelector("#rfgo"));
|
||||
}
|
||||
|
||||
// одиночний вибір мешканця з фільтром за іменем.
|
||||
|
|
@ -494,7 +508,8 @@
|
|||
.join("") +
|
||||
"</div>";
|
||||
}
|
||||
const m = openModal(html);
|
||||
// Ключ із id будівлі: зайшов у сусідній під'їзд — це інша модалка, не дубль.
|
||||
const m = openModal(html, { key: "building:" + b.id });
|
||||
m.inner.onclick = (e) => {
|
||||
const t = e.target;
|
||||
if (t.dataset.spot != null)
|
||||
|
|
@ -518,7 +533,9 @@
|
|||
const m = openModal(`<h3>${esc(b.name)} — кв. ${unit}</h3>
|
||||
<p class="dim small">${home.length ? "Мешканці: " + home.map((c) => esc(c.name)).join(", ") : "За реєстром тут ніхто не живе."}</p>
|
||||
<button id="knock">Постукати</button>
|
||||
<button id="enter">Увійти${App.state.keys.includes(unit) ? " 🔑" : ""}</button>`);
|
||||
<button id="enter">Увійти${App.state.keys.includes(unit) ? " 🔑" : ""}</button>`,
|
||||
{ key: "door:" + unit },
|
||||
);
|
||||
// Двері лишаються на екрані: «Замкнено» тепер не викидає в порожнечу —
|
||||
// тост прилітає, а спробувати постукати чи піти назад можна одразу.
|
||||
m.inner.querySelector("#knock").onclick = () => {
|
||||
|
|
@ -537,9 +554,15 @@
|
|||
App.state.locs[c.id] === unitB(m.unit) &&
|
||||
c.id !== App.state.victim,
|
||||
);
|
||||
const mm = openModal(`<h3>Кв. ${m.unit} — обшук</h3>
|
||||
// Тут подвоєння приходить не з рендера, а з мережі: два кліки «Увійти» —
|
||||
// два повідомлення — дві відповіді. Гард на кнопці цього не покрив би,
|
||||
// тому ключ стоїть саме в місці створення модалки.
|
||||
const mm = openModal(
|
||||
`<h3>Кв. ${m.unit} — обшук</h3>
|
||||
${inside.length ? '<p class="dim small">Вдома: ' + inside.map((c) => `<button data-cid="${c.id}" class="small">${esc(c.name)}</button>`).join(" ") + "</p>" : ""}
|
||||
<div class="grid">${m.slots.map((s, i) => `<button data-slot="${i}">${esc(s)}</button>`).join("")}</div>`);
|
||||
<div class="grid">${m.slots.map((s, i) => `<button data-slot="${i}">${esc(s)}</button>`).join("")}</div>`,
|
||||
{ key: "unit:" + m.unit },
|
||||
);
|
||||
mm.inner.onclick = (e) => {
|
||||
if (e.target.dataset.slot != null)
|
||||
App.send({
|
||||
|
|
@ -577,8 +600,9 @@
|
|||
|
||||
// Розмова — це цикл «питання → відповідь → наступне питання», а не одноразовий
|
||||
// крок. Модалка розмови завжди одна: сервер віддає теми у відповідь на `talk`,
|
||||
// і кожна нова відповідь просто перемальовує її разом із журналом.
|
||||
let talkModal = null;
|
||||
// і кожна нова відповідь просто перемальовує її разом із журналом. Одиничність
|
||||
// тримає ключ 'talk' — без id, бо перехід до іншої людини теж має замінювати
|
||||
// вікно, а не класти друге поверх.
|
||||
let flashCid = null; // до кого прилетіла свіжа відповідь — підсвітити останній запис
|
||||
|
||||
App.on("topics", (m) => {
|
||||
|
|
@ -600,11 +624,6 @@
|
|||
.join("") +
|
||||
'</div><p class="dim small">Сірі теми вже в доказах на дошці.</p>'
|
||||
: "";
|
||||
if (talkModal) {
|
||||
const old = talkModal;
|
||||
talkModal = null; // прибираємо самі — onClose не має скидати нову модалку
|
||||
old.wrap.remove();
|
||||
}
|
||||
const mm = openModal(
|
||||
`<h3>${esc(c.name)}</h3>` +
|
||||
topicsHtml +
|
||||
|
|
@ -621,9 +640,8 @@
|
|||
</div>
|
||||
<p class="dim small">Відповіді можуть бути правдою, брехнею або «не знаю» — перевіряйте камерами і чужими словами. Модалка лишається відкритою: питайте далі, «Закрити» завершує розмову.</p>` +
|
||||
gossip,
|
||||
{ onClose: () => { if (talkModal === mm) talkModal = null; } },
|
||||
{ key: "talk" },
|
||||
);
|
||||
talkModal = mm;
|
||||
mm.inner.append(logSection(m.cid)); // журнал із мітками (спільний із досьє аналітика)
|
||||
// Свіжа відповідь лежить останнім записом журналу — прокручуємо й підсвічуємо,
|
||||
// інакше її не видно за темами й плітками.
|
||||
|
|
@ -684,7 +702,9 @@
|
|||
// Відкривається поверх розмови, «Закрити» повертає до співрозмовника.
|
||||
App.on("inspect", (m) => {
|
||||
const c = App.byId(m.cid);
|
||||
openModal(`<h3>Очний огляд: ${esc(c.name)}</h3><p>${esc(m.body)}</p>`);
|
||||
openModal(`<h3>Очний огляд: ${esc(c.name)}</h3><p>${esc(m.body)}</p>`, {
|
||||
key: "inspect:" + m.cid,
|
||||
});
|
||||
});
|
||||
|
||||
// Відповідь допитуваного. Сироту-модалку не відкриваємо: просимо теми ще раз —
|
||||
|
|
@ -708,7 +728,10 @@
|
|||
<button class="primary" id="mgfix">Зафіксувати</button>
|
||||
<button id="mgoff">Відкласти</button>
|
||||
<span class="dim small" id="mgmsg"></span>`,
|
||||
{ locked: true },
|
||||
// locked — власний вихід «Відкласти», Esc сюди не лізе. Ключ на випадок,
|
||||
// якщо сервер надішле вилучення двічі: друга спроба замінить першу,
|
||||
// а не запустить другий таймер поверх.
|
||||
{ locked: true, key: "mg" },
|
||||
);
|
||||
const q = (sel) => mm.inner.querySelector(sel);
|
||||
const zone = q("#mgzone"),
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@
|
|||
--txt:#cfd6df; --dim:#7d8794; --acc:#e8b04b; --red:#c94f4f; --green:#5aa870; --blue:#5b8dbf;
|
||||
--paper:#e9ddc0; --paper2:#dcCfae; --ink:#282218; --inkdim:#6f6450; --paperline:#c3b28b;
|
||||
--amber:#ffb454; --amberdim:#8a6a34; --crt:#0e0903;
|
||||
--mk-green:#5aa870; --mk-red:#c94f4f; --mk-blue:#5b8dbf; --mk-amber:#e8b04b;
|
||||
/* Фіолетовий замість синього: #5b8dbf і зелений #5aa870 мали майже однакову
|
||||
яскравість, і на 4px смужці лівої грані рядка різниця не читалася зовсім. */
|
||||
--mk-green:#5aa870; --mk-red:#c94f4f; --mk-violet:#a25ce0; --mk-amber:#e8b04b;
|
||||
--noise:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='140' height='140'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .05 0'/></filter><rect width='140' height='140' filter='url(%23n)'/></svg>");
|
||||
}
|
||||
* { box-sizing:border-box; margin:0; }
|
||||
|
|
@ -168,7 +170,7 @@
|
|||
tr[class*="mk-"] > td:first-child { border-left:4px solid transparent; padding-left:6px; }
|
||||
tr.mk-green > td:first-child { border-left-color:var(--mk-green); }
|
||||
tr.mk-red > td:first-child { border-left-color:var(--mk-red); }
|
||||
tr.mk-blue > td:first-child { border-left-color:var(--mk-blue); }
|
||||
tr.mk-violet> td:first-child { border-left-color:var(--mk-violet); }
|
||||
tr.mk-amber > td:first-child { border-left-color:var(--mk-amber); }
|
||||
.dot { display:inline-block; width:10px; height:10px; border-radius:50%; vertical-align:-1px;
|
||||
box-shadow:0 0 0 1px rgba(0,0,0,.45); }
|
||||
|
|
@ -183,6 +185,12 @@
|
|||
#cc-cam { max-width:min(100%, 460px); }
|
||||
.syshead { color:var(--dim); text-transform:uppercase; font-size:12px; letter-spacing:2px; margin:14px 0 6px; }
|
||||
.syshead::before { content:'— '; opacity:.5; }
|
||||
/* База мешканців: пошук за іменем і пошук за прикметами — два незалежні запити,
|
||||
які перебивають один одного, а не одна форма з двома рядами полів. Риска й
|
||||
підписи розводять їх; стилі спільні для обох ролей, бо екран той самий. */
|
||||
.dbsec { color:var(--dim); text-transform:uppercase; font-size:11px; letter-spacing:1.6px; margin:12px 0 4px; }
|
||||
.dbsplit { height:1px; background:var(--line); margin:16px 0 2px; }
|
||||
.role-analyst #stage .dbsec { color:var(--amber); opacity:.7; }
|
||||
|
||||
/* ---------- Модалки: паперовий протокол ---------- */
|
||||
.modal { position:fixed; inset:0; background:rgba(2,4,8,.72); display:flex; align-items:center; justify-content:center; z-index:40;
|
||||
|
|
@ -198,6 +206,9 @@
|
|||
text-transform:uppercase; letter-spacing:1.5px; font-size:14px; }
|
||||
.modal .dim, .modal .small.dim { color:var(--inkdim); }
|
||||
.modal .syshead { color:#7a1f1f; opacity:.8; }
|
||||
/* База польового живе в модалці — на папері, а не на панелі: свої чорнило й лінія. */
|
||||
.modal .dbsec { color:#7a1f1f; opacity:.75; }
|
||||
.modal .dbsplit { background:var(--paperline); }
|
||||
.modal button { background:#d9cba6; color:var(--ink); border:1px solid #b3a077; border-radius:2px; }
|
||||
.modal button:hover { border-color:#7a1f1f; }
|
||||
.modal button.primary { background:#7a1f1f; color:var(--paper); }
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ let clientSeq = 0; // стабільний id на кожне з'єднання
|
|||
|
||||
// Допустимі мітки на дошці. Клієнт малює їх із того ж поділу (public/client.js).
|
||||
const EV_MARKS = ['tick', 'cross', 'hide']; // докази і записи журналу
|
||||
const PERSON_MARKS = ['green', 'red', 'blue', 'amber', 'hide']; // люди: кольори без нав'язаного значення
|
||||
// Люди: кольори без нав'язаного значення. 'blue' лишається в списку лише заради
|
||||
// старих сейвів — нові клієнти шлють 'violet' (синій надто злипався з зеленим).
|
||||
const PERSON_MARKS = ['green', 'red', 'violet', 'amber', 'hide', 'blue'];
|
||||
|
||||
// Присутність зараз (не плутати з evening — правдою вечора злочину): вдома або на роботі.
|
||||
// Перетасовується, коли польовий заходить у будівлю, — крім будівлі, куди він зайшов.
|
||||
|
|
@ -54,6 +56,12 @@ function getSession(code) {
|
|||
board: { pins: [], marks: {}, notes: [], enotes: {} }, // marks: {'e:id'|'c:id': 'tick'|'cross'|'hide'}
|
||||
spawn: { x: stn.pos.x + stn.pos.w / 2, y: stn.pos.y + stn.pos.h + 15 }, // точка появи у відділку
|
||||
extra: [], xn: 0, // динамічні картки (результати запитів, зізнання)
|
||||
// Наскрізний порядок дошки: id карток обох видів у тій послідовності, в якій
|
||||
// вони на дошці з'явились. Раніше view() зшивав два незалежні масиви
|
||||
// (st.found + extra), а клієнт розвертав шов як одну хронологію — через це
|
||||
// будь-який результат запиту назавжди опинявся вище будь-якої фізичної
|
||||
// знахідки, і нова знахідка після пачки камер лягала десь усередині списку.
|
||||
order: [],
|
||||
log: {}, // журнал допитів: cid → [{id, q, a}]
|
||||
logN: 0, // лічильник id записів журналу (для міток на них)
|
||||
alibiClaims: {}, // cid → building: що людина заявила про вечір злочину
|
||||
|
|
@ -83,7 +91,7 @@ function getSession(code) {
|
|||
} catch {}
|
||||
}
|
||||
if (!loaded) {
|
||||
for (const id of cs.startingFacts) RT.applyFound(cs, s.st, cs.evidence[id]);
|
||||
for (const id of cs.startingFacts) { RT.applyFound(cs, s.st, cs.evidence[id]); noteOrder(s, id); }
|
||||
saveNow(s); // синхронно: файл має існувати одразу, до першого throttle-запису
|
||||
}
|
||||
return sessions[code] = s;
|
||||
|
|
@ -96,8 +104,8 @@ function getSession(code) {
|
|||
// непарсабельного) JSON.
|
||||
const SAVE_MS = 300;
|
||||
function saveData(s) {
|
||||
const { st, board, accuseDraft, ended, extra, xn, log, logN, markers, alibiClaims, cracked, mgOffered } = s;
|
||||
return JSON.stringify({ st, board, accuseDraft, ended, extra, xn, log, logN, markers, alibiClaims, cracked, mgOffered });
|
||||
const { st, board, accuseDraft, ended, extra, xn, order, log, logN, markers, alibiClaims, cracked, mgOffered } = s;
|
||||
return JSON.stringify({ st, board, accuseDraft, ended, extra, xn, order, log, logN, markers, alibiClaims, cracked, mgOffered });
|
||||
}
|
||||
// Синхронний запис на диск — для першого сейву щойно створеної сесії. Інакше файл
|
||||
// з'явиться лише через SAVE_MS, і другий гравець, що зайшов раніше, тригерне
|
||||
|
|
@ -156,6 +164,15 @@ function view(s) {
|
|||
// Динамічні картки (результати запитів) тега не мають — даємо дефолтний,
|
||||
// інакше вони випадали б із будь-якого зрізу по чипах.
|
||||
}).concat(s.extra.map(x => (x.tag ? x : { ...x, tag: 'запит' })));
|
||||
// Зшиті два списки розкладаємо в справжню хронологію дошки. Клієнт розвертає
|
||||
// цей масив (найновіше зверху), тож без сортування результати запитів, які
|
||||
// йдуть у хвості конкатенації, назавжди перекривали фізичні знахідки.
|
||||
// Картка без місця в порядку (сейв старішої версії) лишається найстарішою —
|
||||
// тобто на дні дошки, у тому ж відносному порядку, що й досі.
|
||||
// Ранг мапою, а не indexOf у компараторі: view() ганяється на кожен broadcast,
|
||||
// тобто на кожну дію кожного гравця.
|
||||
const rank = new Map((s.order || []).map((id, i) => [id, i]));
|
||||
found.sort((a, b) => (rank.get(a.id) ?? -1) - (rank.get(b.id) ?? -1));
|
||||
const killerC = cs.citizens.find(c => c.id === cs.finale.killer);
|
||||
const dnaOwner = cs.citizens.find(c => c.id === (cs.accomplice || cs.finale.killer));
|
||||
return {
|
||||
|
|
@ -237,7 +254,14 @@ function validFieldPos(p, cs) {
|
|||
return { x: p.x | 0, y: p.y | 0 };
|
||||
}
|
||||
|
||||
function reveal(s, ev) { RT.applyFound(s.cs, s.st, ev); }
|
||||
// Позначити картку в наскрізному порядку дошки. Ідемпотентно: те, що вже лежить
|
||||
// на дошці, повторний виклик не пересуває.
|
||||
function noteOrder(s, id) {
|
||||
if (!s.order) s.order = []; // сейв старішої версії — порядку в ньому немає
|
||||
if (!s.order.includes(id)) s.order.push(id);
|
||||
}
|
||||
|
||||
function reveal(s, ev) { RT.applyFound(s.cs, s.st, ev); noteOrder(s, ev.id); }
|
||||
|
||||
// головоломка розв'язана / нові вимоги виконані → носії відкриваються ланцюжком
|
||||
function autoUnlockCarriers(s) {
|
||||
|
|
@ -256,7 +280,9 @@ function autoUnlockCarriers(s) {
|
|||
// pointsTo (опційно) — на кого картка вказує, щоб з неї теж можна було взяти ордер.
|
||||
function extraCard(s, key, title, detail, pointsTo) {
|
||||
if (s.extra.some(x => x.key === key)) return;
|
||||
s.extra.push({ id: 'x' + (++s.xn), key, title, detail, side: 'analyst', ...(pointsTo ? { pointsTo } : {}) });
|
||||
const id = 'x' + (++s.xn);
|
||||
s.extra.push({ id, key, title, detail, side: 'analyst', ...(pointsTo ? { pointsTo } : {}) });
|
||||
noteOrder(s, id); // в один потік із доказами, а не окремим списком у хвіст
|
||||
}
|
||||
|
||||
// На кого вказує доказ (для ордера) — і зі статичних доказів, і з динамічних карток.
|
||||
|
|
@ -391,7 +417,10 @@ function relationOf(z, x) {
|
|||
function handle(s, ws, m) {
|
||||
const { cs, st } = s;
|
||||
const done = () => { autoUnlockCarriers(s); save(s); broadcast(s); };
|
||||
if (s.ended && !['note', 'note_del', 'enote', 'enote_del', 'mark'].includes(m.t)) return; // справу закрито
|
||||
// Після вироку дошка лишається доступною «на почитати»: нотатки й мітки живуть далі.
|
||||
// Правки нотаток — у тому ж списку, що й додавання: інакше виходило б безглуздя —
|
||||
// дописати після фіналу можна, а виправити в дописаному одрук уже ні.
|
||||
if (s.ended && !['note', 'note_edit', 'note_del', 'enote', 'enote_edit', 'enote_del', 'mark'].includes(m.t)) return; // справу закрито
|
||||
|
||||
switch (m.t) {
|
||||
case 'search': { // публічна точка: {building, spot}
|
||||
|
|
@ -775,12 +804,27 @@ function handle(s, ws, m) {
|
|||
s.board.notes.push({ id: Date.now() + Math.random(), txt: m.txt.slice(0, 500), by: ws.role });
|
||||
return done();
|
||||
}
|
||||
// Правка НА МІСЦІ: міняємо лише txt, лишаючи id, автора й позицію в масиві.
|
||||
// Через це виправлена нотатка не стрибає на верх списку (клієнт малює спільні
|
||||
// нотатки найновішими зверху) і не ламає хронологію міркувань. Автора теж не
|
||||
// переписуємо: дошка спільна, і «хто це записав» важить більше, ніж «хто
|
||||
// виправив одрук у чужому записі».
|
||||
case 'note_edit': { // {id, txt}
|
||||
const n = s.board.notes.find(x => x.id === m.id);
|
||||
if (n && typeof m.txt === 'string' && m.txt.trim()) n.txt = m.txt.slice(0, 500);
|
||||
return done();
|
||||
}
|
||||
case 'note_del': { s.board.notes = s.board.notes.filter(n => n.id !== m.id); return done(); }
|
||||
case 'enote': { // нотатка на конкретному доказі
|
||||
if (typeof m.txt !== 'string' || !m.txt.trim()) return;
|
||||
(s.board.enotes[m.evId] ??= []).push({ id: Date.now() + Math.random(), txt: m.txt.slice(0, 300), by: ws.role });
|
||||
return done();
|
||||
}
|
||||
case 'enote_edit': { // {evId, id, txt}
|
||||
const n = (s.board.enotes[m.evId] || []).find(x => x.id === m.id);
|
||||
if (n && typeof m.txt === 'string' && m.txt.trim()) n.txt = m.txt.slice(0, 300);
|
||||
return done();
|
||||
}
|
||||
case 'enote_del': {
|
||||
const l = s.board.enotes[m.evId];
|
||||
if (l) s.board.enotes[m.evId] = l.filter(n => n.id !== m.id);
|
||||
|
|
|
|||
Loading…
Reference in a new issue