Files
hlamingo/templates/index.html
T
alexandClaude Opus 5 bae5116d77 Подсказка названия по фотографии
Фото выбрано в форме — приложение показывает его модели и предлагает название
с прочитанными надписями. «Подставить» переносит в поля, «Не надо» прячет.
Само не подставляется, и это принципиально: модели домысливают марки
и артикулы, когда надписей не видно. В замере клипсу для кабеля модель назвала
«конструктором LEGO Technic, 8376, 401 шт.». Запрет на выдумывание в промпте
помог: та же вещь стала «красной щёткой для посуды».

Смотрит gemma3:4b. Выбрана замером на настоящих снимках из базы:
  gemma3:4b     3.5 с, отвечает всегда, надписи читает верно
                («Лампа светодиодная C35, Нейтральный белый, 9 Вт LED, E14»);
  qwen3-vl:8b   11-32 с и часто пустой ответ — это ризонер, ему нужен бюджет
                в 2000+ токенов, и уменьшение картинки не спасает.
Проверялась и гипотеза, что дело в размере снимка: у gemma3 разницы между
1280 и 512 px почти нет, у qwen3-vl время падает с 62 до 41 с, но ответ
всё равно пуст. Картинка всё же уменьшается до 768 px — незачем гонять лишнее.

Генерация тегов чинилась по ходу: на «Стабилизатор 7805 (TO-220)» модель
отвечала массивом объектов с пояснениями, не укладывалась в 256 токенов,
и JSON обрывался на полуслове — теги выходили пустыми. Бюджет поднят до 500,
промпт требует плоский массив строк, а из битого ответа строки теперь
вытаскиваются регуляркой: лучше несколько живых тегов, чем ничего.

Pillow добавлен в зависимости — для уменьшения картинки перед распознаванием.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 01:24:49 +03:00

1105 lines
51 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0f1216">
<title>hlamingo — где что лежит</title>
<!-- Иконки: Android берёт их из манифеста, iOS — из apple-touch-icon.
Оба хотят PNG, поэтому SVG во вкладке тут не годится. -->
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png">
<link rel="icon" type="image/png" sizes="192x192" href="/static/icon-192.png">
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
<link rel="manifest" href="/manifest.webmanifest">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="hlamingo">
<style>
:root {
--bg:#0f1216; --card:#171c22; --line:#2a333d; --fg:#e8edf2; --dim:#93a1b0;
--accent:#ff7a9c; --ok:#4ade80; --warn:#fbbf24; --radius:14px;
}
@media (prefers-color-scheme: light) {
:root { --bg:#f4f6f8; --card:#fff; --line:#dde3ea; --fg:#151b22; --dim:#5d6b7a; --accent:#d6336c; }
}
* { box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
/* Ни одна картинка не должна расширять страницу: снимок с телефона — 1280 px,
и без этого правила появляется горизонтальная прокрутка. */
img { max-width:100%; height:auto; }
body { margin:0; background:var(--bg); color:var(--fg);
font:17px/1.5 -apple-system,"Segoe UI",Roboto,sans-serif;
padding:0 0 env(safe-area-inset-bottom);
/* Длинный артикул без пробелов («КР142ЕН5А-XYZ-000123») не должен
распирать страницу — пусть переносится. */
overflow-wrap:break-word; }
header { position:sticky; top:0; z-index:10; background:var(--bg);
padding:14px 16px 0; border-bottom:1px solid var(--line); }
h1 { margin:0 0 10px; font-size:20px; } h1 span { color:var(--accent); }
#banner { display:none; margin:0 0 10px; padding:9px 12px; border-radius:10px;
background:#3a2e10; color:var(--warn); font-size:14px; }
nav { display:flex; gap:6px; }
nav button { flex:1; padding:11px 4px; font-size:15px; font-weight:600; background:none;
border:none; border-bottom:3px solid transparent; color:var(--dim); cursor:pointer; }
nav button.on { color:var(--fg); border-bottom-color:var(--accent); }
nav button.narrowTab { flex:0 0 48px; font-size:18px; }
code { background:var(--card); padding:1px 5px; border-radius:5px; font-size:14px; }
main { padding:16px; max-width:720px; margin:0 auto; }
.tab { display:none; } .tab.on { display:block; }
label { display:block; margin:14px 0 5px; font-size:14px; color:var(--dim); }
input, select, textarea {
width:100%; padding:13px; font:inherit; color:var(--fg); background:var(--card);
border:1px solid var(--line); border-radius:var(--radius); appearance:none;
}
select { background-image:linear-gradient(45deg,transparent 50%,var(--dim) 50%),
linear-gradient(135deg,var(--dim) 50%,transparent 50%);
background-position:calc(100% - 20px) 22px, calc(100% - 14px) 22px;
background-size:6px 6px,6px 6px; background-repeat:no-repeat; padding-right:40px; }
textarea { min-height:76px; resize:vertical; }
.row { display:flex; gap:10px; }
.row > * { flex:1; }
.mt { margin-top:12px; }
/* переключатель действия */
.modes { display:grid; grid-template-columns:repeat(4,1fr); gap:6px; margin-bottom:4px; }
.modes button {
padding:12px 2px; font:600 14px/1.2 inherit; color:var(--dim); cursor:pointer;
background:var(--card); border:1px solid var(--line); border-radius:12px;
}
.modes button.on { color:#fff; background:var(--accent); border-color:var(--accent); }
.modes button.on.danger { background:#c0392b; border-color:#c0392b; }
button.act { width:100%; padding:16px; font:600 17px/1 inherit; color:#fff; cursor:pointer;
background:var(--accent); border:none; border-radius:var(--radius); }
button.act.danger { background:#c0392b; }
button.act.ghost { background:var(--card); color:var(--fg); border:1px solid var(--line); }
button.act:disabled { opacity:.5; }
.small { padding:11px; font-size:15px; }
.hint { margin:10px 2px 0; color:var(--dim); font-size:14px; }
details { margin-top:18px; border-top:1px solid var(--line); padding-top:12px; }
summary { cursor:pointer; color:var(--dim); font-size:15px; }
#reply { margin-top:16px; padding:14px; border-radius:var(--radius); background:var(--card);
border-left:4px solid var(--accent); display:none; white-space:pre-wrap; }
.card { margin-top:12px; padding:13px; background:var(--card);
border:1px solid var(--line); border-radius:var(--radius); }
.card h3 { margin:0 0 4px; font-size:17px; }
/* Плитка как в интернет-магазинах: сколько колонок влезет, столько и будет.
auto-fill с minmax — на телефоне обычно две, на планшете три-четыре. */
.cards { display:grid; gap:10px; margin-top:12px; align-items:start;
grid-template-columns:repeat(auto-fill, minmax(150px, 1fr)); }
.cards .card { margin-top:0; padding:10px; }
.cards .card h3 { font-size:15px; line-height:1.25; }
.cards .addr { font-size:13px; }
.cards .meta { font-size:12px; }
/* Теги в плитке не показываем — они её распирают. Видны при правке. */
.cards .card:not(.wide) .tags { display:none; }
/* Квадратные превью: ряд выглядит ровным, как витрина. */
.cards .photo img { aspect-ratio:1/1; object-fit:cover; margin-top:8px; }
.cards .acts { gap:6px; }
.cards .acts button { min-width:0; flex:1 1 auto; padding:9px 2px; font-size:15px; }
/* Карточка с открытой правкой растягивается на всю ширину: в узкой колонке
поля ввода были бы непригодны. */
.cards .card.wide { grid-column:1 / -1; }
.cards .card.wide .photo img { aspect-ratio:auto; object-fit:contain; }
.addr { color:var(--accent); font-size:15px; }
.addr.taken { color:var(--warn); }
.meta { color:var(--dim); font-size:13px; margin-top:5px; }
.tags { margin-top:7px; display:flex; flex-wrap:wrap; gap:5px; }
.tag { font-size:12px; padding:3px 8px; border-radius:20px; background:var(--bg);
border:1px solid var(--line); color:var(--dim); }
.photo img { margin-top:10px; width:100%; border-radius:10px; display:block; }
/* Превью в форме не должно занимать весь экран — до него ещё кнопка записи. */
#formPhotoPreview img { max-height:40vh; width:auto; object-fit:contain; }
.acts { display:flex; gap:8px; margin-top:10px; flex-wrap:wrap; }
.acts button { flex:1 1 auto; min-width:76px; padding:10px 4px;
font:600 14px/1.2 inherit; cursor:pointer;
background:var(--bg); color:var(--fg); border:1px solid var(--line);
border-radius:10px; }
.acts button.narrow { flex:0 0 46px; min-width:46px; font-size:16px; }
.acts button.warn { color:#e06c5a; }
.edit { margin-top:12px; padding-top:12px; border-top:1px solid var(--line); }
.edit label { margin:10px 0 4px; }
.edit input { padding:11px; }
.tag.del { cursor:pointer; padding-right:6px; }
.tag.del b { color:#e06c5a; margin-left:6px; font-weight:700; }
.editHint { color:var(--dim); font-size:13px; margin:6px 0 0; }
.iconHint { font-size:13px; margin-top:8px; }
.why { float:right; font-size:12px; color:var(--dim); }
.pick { cursor:pointer; }
.pick:active { border-color:var(--accent); }
/* Глубокая иерархия наращивает отступ на каждом уровне: прокручиваем сам
блок, а не всю страницу. */
.tree { overflow-x:auto; }
.tree ul { list-style:none; margin:4px 0 0; padding-left:16px; border-left:1px solid var(--line); }
.tree li { margin:6px 0; }
.place { font-weight:600; }
.editPlace { cursor:pointer; }
.box { display:inline-block; padding:4px 10px; margin:3px 5px 3px 0; background:var(--card);
border:1px solid var(--line); border-radius:8px; font-size:15px; cursor:pointer; }
.box b { color:var(--accent); }
.empty { color:var(--dim); padding:24px 4px; text-align:center; }
</style>
</head>
<body>
<header>
<h1>hla<span>mingo</span> — где что лежит</h1>
<div id="banner"></div>
<nav>
<button class="on" data-tab="do">Действие</button>
<button data-tab="find">Найти</button>
<button data-tab="tree">Обзор</button>
<button data-tab="setup" class="narrowTab"></button>
</nav>
</header>
<main>
<!-- ================= Действие ================= -->
<section class="tab on" id="tab-do">
<div class="modes">
<button class="on" data-mode="put">Положить</button>
<button data-mode="take">Забрать</button>
<button data-mode="move">Переместить</button>
<button data-mode="delete" class="danger">Удалить</button>
</div>
<!-- выбор вещи: для забрать / переместить / удалить -->
<div id="pickWrap" hidden>
<label>Какая вещь</label>
<input type="text" id="pickQuery" placeholder="начни вводить название" autocomplete="off">
<div id="pickList"></div>
<div id="picked" class="card" hidden></div>
</div>
<!-- куда: для положить / переместить -->
<div id="whereWrap">
<label>Место</label>
<select id="placeSel"></select>
<div class="row mt">
<input type="text" id="newPlaceName" placeholder="новое место, напр. Полка 2">
<button class="act ghost small" id="addPlaceBtn" style="flex:0 0 38%">Добавить</button>
</div>
<p class="hint">Новое место добавится внутрь выбранного выше — так строится «Гараж → Стеллаж А → Полка 2».</p>
<label>Ёмкость</label>
<select id="boxSel"></select>
<div class="row mt">
<input type="text" id="newBoxCode" placeholder="код коробки, напр. A3">
<button class="act ghost small" id="addBoxBtn" style="flex:0 0 38%">Добавить</button>
</div>
</div>
<!-- что: для положить -->
<div id="whatWrap">
<label>Предмет</label>
<input type="text" id="itemName" placeholder="стабилизатор 7805" autocomplete="off">
<div class="row mt">
<input type="text" id="itemQty" placeholder="количество, напр. 12 шт">
<input type="text" id="itemNote" placeholder="заметка">
</div>
<label>Фото (можно сразу)</label>
<div class="row">
<button class="act ghost small" id="formCamBtn">📷 Снять</button>
<button class="act ghost small" id="formGalBtn">🖼 Из галереи</button>
<button class="act ghost small" id="formPhotoClear" style="flex:0 0 26%" hidden>Убрать</button>
</div>
<div id="formPhotoPreview" class="photo" hidden></div>
<div id="guessBox" class="card" hidden></div>
</div>
<div class="mt"><button class="act" id="goBtn">Положить</button></div>
<p class="hint">Фото можно добавить сразу здесь — оно прикрепится к новой вещи.
«Снять» открывает камеру, «Из галереи» — уже готовые снимки.
К уже лежащим вещам фото добавляется теми же кнопками на их карточках,
на вкладках «Найти» и «Обзор».</p>
<details>
<summary>Или просто напиши фразой</summary>
<textarea id="sayText" class="mt" placeholder="положил стабилизаторы 7805, 12 штук, в коробку A3 на второй полке стеллажа А в гараже"></textarea>
<div class="mt"><button class="act ghost" id="sayBtn">Разобрать и записать</button></div>
<p class="hint">Спросить тоже можно: «где мультиметр», «что в коробке A3».</p>
</details>
<div id="reply"></div>
<div id="doCards"></div>
</section>
<!-- ================= Найти ================= -->
<section class="tab" id="tab-find">
<input type="text" id="findText" placeholder="стабилизатор на 5 вольт" autocomplete="off">
<div class="mt"><button class="act" id="findBtn">Искать</button></div>
<p class="hint">Ищет по названию, синонимам и маркировке: «стабилизатор 5 вольт» находит 7805.</p>
<div id="findCards"></div>
</section>
<!-- ================= Настройки ================= -->
<section class="tab" id="tab-setup">
<p class="hint" id="setupIntro">Здесь настраивается связь с Ollama — это «мозги»
помощника. Без неё всё остальное работает, но не будет разбора фраз,
автоматических тегов и поиска по смыслу.</p>
<label>Адрес Ollama</label>
<input type="text" id="cfgHost" placeholder="http://192.168.1.50:11434" autocomplete="off"
inputmode="url">
<p class="hint">Если Ollama на этом же компьютере — <code>http://localhost:11434</code>.
Если на другой машине — её адрес в локальной сети.</p>
<div class="row mt">
<button class="act ghost" id="cfgProbe">Проверить связь</button>
</div>
<div id="cfgProbeResult"></div>
<label>Модель для разбора фраз</label>
<select id="cfgChat"></select>
<label>Модель для поиска по смыслу</label>
<select id="cfgEmbed"></select>
<p class="hint">Список заполняется моделями, которые реально установлены.
Нужных нет — на машине с Ollama: <code>ollama pull qwen2.5:7b</code>
и <code>ollama pull nomic-embed-text</code>.</p>
<div class="mt"><button class="act" id="cfgSave">Сохранить</button></div>
<p class="hint" id="cfgWhere"></p>
</section>
<!-- ================= Обзор ================= -->
<section class="tab" id="tab-tree">
<button class="act ghost" id="treeBtn">Обновить</button>
<div id="treeBox" class="tree"></div>
</section>
</main>
<!-- Два поля, а не одно, и это вынужденно.
С capture телефон открывает камеру, но галерея недоступна.
Без capture Android 13+ показывает системный Photo Picker, в котором
опции «Камера» нет вообще — только готовые файлы.
Поэтому съёмка и выбор из галереи разведены по отдельным кнопкам. -->
<input type="file" id="camInput" accept="image/*" capture="environment" hidden>
<input type="file" id="galInput" accept="image/*" hidden>
<input type="file" id="formCamInput" accept="image/*" capture="environment" hidden>
<input type="file" id="formGalInput" accept="image/*" hidden>
<script>
const $ = s => document.querySelector(s);
let mode = 'put';
let photoTarget = null; // куда прикрепить снимок: {target, cardEl, btn}
let pendingPhoto = null; // фото, выбранное ДО записи — прицепится к новой вещи
let pickedItem = null; // выбранная вещь для забрать/переместить/удалить
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g,
c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
async function api(url, opts) {
const r = await fetch(url, opts);
return r.json();
}
// Последнее место и коробку помним на телефоне: складывая десяток деталей
// в одну коробку, выбирать её каждый раз заново незачем.
const LAST = {
get(key) { try { return localStorage.getItem('hlamingo.' + key) || ''; } catch (e) { return ''; } },
set(key, value) { try { localStorage.setItem('hlamingo.' + key, value || ''); } catch (e) {} }
};
function say(text) { $('#reply').style.display = 'block'; $('#reply').textContent = text || ''; }
// ---------- вкладки ----------
function openTab(name) {
document.querySelectorAll('nav button').forEach(x =>
x.classList.toggle('on', x.dataset.tab === name));
document.querySelectorAll('.tab').forEach(t =>
t.classList.toggle('on', t.id === 'tab-' + name));
if (name === 'tree') loadTree();
if (name === 'setup') loadConfig();
window.scrollTo({top: 0});
}
document.querySelectorAll('nav button').forEach(b => b.onclick = () => openTab(b.dataset.tab));
// ---------- баннер ----------
api('/api/health').then(h => {
if (h.ollama) return;
const b = $('#banner');
b.style.display = 'block';
b.textContent = h.fresh
? 'Похоже, помощник только установлен, а Ollama по адресу ' + h.host + ' не отвечает. '
: 'ИИ недоступен (' + h.host + '). Кнопки работают полностью; не будет только '
+ 'тегов и разбора свободных фраз. ';
const link = document.createElement('a');
link.href = '#';
link.textContent = 'Настроить →';
link.style.color = 'inherit';
link.style.fontWeight = '700';
link.onclick = e => { e.preventDefault(); openTab('setup'); };
b.appendChild(link);
}).catch(() => {});
// ---------- настройки ----------
async function loadConfig() {
const d = await api('/api/config');
$('#cfgHost').value = d.ollama_host || '';
fillModels(d.models || [], d.chat_model, d.embed_model);
$('#cfgWhere').textContent = 'Настройки хранятся в файле ' + d.env_file
+ ' — он не попадает в репозиторий.';
if (!d.available) {
$('#cfgProbeResult').innerHTML =
'<div class="card"><div class="addr taken">Ollama по этому адресу не отвечает.</div></div>';
}
}
// Модель выбирается из установленных; текущее значение показываем даже если
// его нет в списке — иначе выбор молча сбросился бы на чужую модель.
function fillModels(models, chat, embed) {
const opts = extra => ['<option value="">— не задана —</option>']
.concat((models.length ? models : []).map(m =>
'<option value="' + esc(m) + '">' + esc(m) + '</option>'))
.concat(extra && !models.includes(extra)
? ['<option value="' + esc(extra) + '">' + esc(extra) + ' (не установлена)</option>'] : [])
.join('');
$('#cfgChat').innerHTML = opts(chat);
$('#cfgEmbed').innerHTML = opts(embed);
$('#cfgChat').value = chat || '';
$('#cfgEmbed').value = embed || '';
}
$('#cfgProbe').onclick = async () => {
const btn = $('#cfgProbe');
btn.disabled = true; btn.textContent = 'Проверяю…';
try {
const d = await api('/api/config/probe', {method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ollama_host: $('#cfgHost').value.trim()})});
$('#cfgProbeResult').innerHTML = '<div class="card"><div class="addr'
+ (d.ok ? '' : ' taken') + '">' + esc(d.reply || '') + '</div></div>';
if (d.ok) fillModels(d.models || [], $('#cfgChat').value, $('#cfgEmbed').value);
} finally { btn.disabled = false; btn.textContent = 'Проверить связь'; }
};
$('#cfgSave').onclick = async () => {
const btn = $('#cfgSave');
btn.disabled = true; btn.textContent = 'Сохраняю…';
try {
const d = await api('/api/config', {method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ollama_host: $('#cfgHost').value.trim(),
chat_model: $('#cfgChat').value,
embed_model: $('#cfgEmbed').value})});
say(d.reply);
if (d.ok) {
$('#banner').style.display = d.available ? 'none' : 'block';
loadConfig();
}
} finally { btn.disabled = false; btn.textContent = 'Сохранить'; }
};
// ---------- режимы ----------
const LABEL = {put:'Положить', take:'Забрать', move:'Переместить', delete:'Удалить'};
document.querySelectorAll('.modes button').forEach(b => b.onclick = () => {
mode = b.dataset.mode;
document.querySelectorAll('.modes button').forEach(x => x.classList.toggle('on', x === b));
$('#pickWrap').hidden = (mode === 'put');
$('#whereWrap').hidden = (mode === 'take' || mode === 'delete');
$('#whatWrap').hidden = (mode !== 'put');
$('#goBtn').textContent = LABEL[mode];
$('#goBtn').className = 'act' + (mode === 'delete' ? ' danger' : '');
pickedItem = null; $('#picked').hidden = true; $('#pickList').innerHTML = '';
$('#pickQuery').value = '';
});
// ---------- справочники ----------
async function loadPlaces(keep) {
const d = await api('/api/places');
const sel = $('#placeSel');
// Что подставить: текущий выбор, а при первой загрузке — прошлый сеанс.
const want = (keep ? sel.value : '') || sel.value || LAST.get('place');
sel.innerHTML = '<option value="">— без места —</option>'
+ d.places.map(p => '<option value="' + esc(p.id) + '">' + esc(p.path) + '</option>').join('');
sel.value = want;
// Место могли удалить — тогда select молча остался пустым, и помнить его нечего.
if (sel.value !== want) LAST.set('place', '');
return d.places;
}
async function loadBoxes() {
const place = $('#placeSel').value;
// Показываем коробки выбранного места, но не прячем остальные: коробку могли
// ещё не привязать к месту, и она не должна исчезнуть из списка.
const d = await api('/api/containers' + (place ? '?place=' + encodeURIComponent(place) : ''));
const all = place ? (await api('/api/containers')).containers : d.containers;
const here = d.containers.map(b => b.code);
const sel = $('#boxSel');
const want = sel.value || LAST.get('box');
const opt = b => '<option value="' + esc(b.code) + '">' + esc(b.code)
+ (b.count ? ' · ' + b.count + ' поз.' : '') + (here.includes(b.code) ? '' : ' — ' + esc(b.address || 'без места')) + '</option>';
const mine = all.filter(b => here.includes(b.code));
const rest = all.filter(b => !here.includes(b.code));
sel.innerHTML = '<option value="">— без коробки —</option>' + mine.map(opt).join('')
+ (rest.length ? '<optgroup label="другие места">' + rest.map(opt).join('') + '</optgroup>' : '');
sel.value = want;
if (sel.value !== want) LAST.set('box', ''); // коробку удалили или переименовали
}
$('#placeSel').onchange = () => { LAST.set('place', $('#placeSel').value); loadBoxes(); };
$('#boxSel').onchange = () => LAST.set('box', $('#boxSel').value);
$('#addPlaceBtn').onclick = async () => {
const name = $('#newPlaceName').value.trim();
if (!name) return;
const d = await api('/api/places', {method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({name, parent: $('#placeSel').value || null})});
if (!d.ok) return say(d.reply);
$('#newPlaceName').value = '';
await loadPlaces();
$('#placeSel').value = d.id;
LAST.set('place', d.id); // программная установка не поднимает onchange
await loadBoxes();
say('Место добавлено: ' + d.path);
};
$('#addBoxBtn').onclick = async () => {
const code = $('#newBoxCode').value.trim();
if (!code) return;
const d = await api('/api/containers', {method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({code, place: $('#placeSel').value || null})});
if (!d.ok) return say(d.reply);
$('#newBoxCode').value = '';
await loadBoxes();
$('#boxSel').value = d.code;
LAST.set('box', d.code);
say('Коробка ' + d.code + ' добавлена: ' + d.address);
};
// ---------- выбор вещи ----------
let pickTimer = null;
$('#pickQuery').addEventListener('input', () => {
clearTimeout(pickTimer);
pickTimer = setTimeout(async () => {
const q = $('#pickQuery').value.trim();
const d = await api('/api/items' + (q ? '?q=' + encodeURIComponent(q) : ''));
$('#pickList').innerHTML = '';
(d.items || []).slice(0, 8).forEach(it => {
const el = card(it, true); el.classList.add('pick');
el.onclick = () => {
pickedItem = it;
$('#picked').hidden = false;
$('#picked').innerHTML = '<h3>' + esc(it.name) + '</h3><div class="addr">'
+ esc(it.address) + '</div>';
$('#pickList').innerHTML = '';
$('#pickQuery').value = it.name;
};
$('#pickList').appendChild(el);
});
}, 250);
});
$('#pickQuery').addEventListener('focus', () => $('#pickQuery').dispatchEvent(new Event('input')));
// ---------- фото до записи ----------
// Так вещь заводится одним заходом: название, место, снимок — и всё.
$('#formCamBtn').onclick = () => $('#formCamInput').click();
$('#formGalBtn').onclick = () => $('#formGalInput').click();
async function takeFormPhoto(input) {
const file = input.files[0];
input.value = '';
if (!file) return;
try {
pendingPhoto = await shrink(file);
const preview = $('#formPhotoPreview');
preview.hidden = false;
preview.innerHTML = '';
const img = document.createElement('img');
img.src = URL.createObjectURL(pendingPhoto);
preview.appendChild(img);
$('#formPhotoClear').hidden = false;
recognizePending(); // фото есть — спросим модель, что на нём
} catch (e) {
say('Не смог прочитать это изображение: ' + e);
clearPendingPhoto();
}
}
// Подсказка по фото: показываем, что увидела модель, и даём подставить это
// в поле названия. Само не подставляется — модель домысливает бренды,
// когда надписей не видно.
async function recognizePending() {
if (!pendingPhoto) return;
const box = $('#guessBox');
box.hidden = false;
box.innerHTML = '<div class="meta">Смотрю на фото…</div>';
try {
const fd = new FormData();
fd.append('photo', pendingPhoto, 'photo.jpg');
const d = await api('/api/recognize', {method: 'POST', body: fd});
if (!d.ok) { box.hidden = true; return; }
const g = d.guess;
box.innerHTML = '<div class="meta">На фото похоже на:</div>'
+ '<h3>' + esc(g.name) + '</h3>'
+ (g.text ? '<div class="meta">Надписи: ' + esc(g.text) + '</div>' : '')
+ (g.sure ? '' : '<div class="meta">Модель не уверена — проверьте.</div>');
const bar = document.createElement('div');
bar.className = 'acts';
const use = document.createElement('button');
use.textContent = '✓ Подставить';
use.onclick = () => {
$('#itemName').value = g.name;
if (g.text && !$('#itemNote').value) $('#itemNote').value = g.text;
box.hidden = true;
$('#itemName').focus();
};
const skip = document.createElement('button');
skip.textContent = 'Не надо';
skip.onclick = () => { box.hidden = true; };
bar.appendChild(use);
bar.appendChild(skip);
box.appendChild(bar);
} catch (e) {
box.hidden = true;
}
}
$('#formCamInput').onchange = () => takeFormPhoto($('#formCamInput'));
$('#formGalInput').onchange = () => takeFormPhoto($('#formGalInput'));
$('#formPhotoClear').onclick = clearPendingPhoto;
function clearPendingPhoto() {
pendingPhoto = null;
$('#formPhotoPreview').hidden = true;
$('#formPhotoPreview').innerHTML = '';
$('#formPhotoClear').hidden = true;
$('#guessBox').hidden = true;
}
// Отправляет уже сжатый blob к готовой записи. Возвращает имя файла или null.
async function uploadBlob(blob, target) {
const fd = new FormData();
fd.append('photo', blob, 'photo.jpg');
fd.append('target', target.type);
if (target.type === 'item') fd.append('id', target.id); else fd.append('code', target.code);
const d = await api('/api/photo', {method:'POST', body: fd});
return d.ok ? d.photo : null;
}
// ---------- главное действие ----------
$('#goBtn').onclick = async () => {
const btn = $('#goBtn');
btn.disabled = true;
const was = btn.textContent; btn.textContent = 'Секунду…';
try {
let d;
if (mode === 'put') {
const name = $('#itemName').value.trim();
if (!name) return say('Напиши, что кладём.');
d = await api('/api/put', {method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({
name, qty: $('#itemQty').value.trim() || null, note: $('#itemNote').value.trim() || null,
place: $('#placeSel').value || null, container: $('#boxSel').value || null})});
// Фото, выбранное до записи, цепляем к только что созданной вещи.
if (d.ok && pendingPhoto && d.item) {
btn.textContent = 'Гружу фото…';
const name = await uploadBlob(pendingPhoto, {type:'item', id: d.item.id});
if (name) { d.item.photo = name; d.reply += ' Фото прикрепил.'; }
else { d.reply += ' Но фото загрузить не удалось.'; }
clearPendingPhoto();
}
if (d.ok) {
// Название и заметку чистим, а место с коробкой оставляем: следующая
// вещь, скорее всего, ложится туда же.
$('#itemName').value = ''; $('#itemQty').value = ''; $('#itemNote').value = '';
LAST.set('place', $('#placeSel').value);
LAST.set('box', $('#boxSel').value);
$('#itemName').focus();
}
} else if (!pickedItem) {
return say('Сначала выбери вещь из списка.');
} else if (mode === 'take') {
d = await api('/api/take', {method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({id: pickedItem.id})});
} else if (mode === 'move') {
d = await api('/api/move', {method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({id: pickedItem.id, place: $('#placeSel').value || null,
container: $('#boxSel').value || null})});
} else {
if (!confirm('Удалить «' + pickedItem.name + '» из памяти?')) return;
d = await api('/api/item/' + encodeURIComponent(pickedItem.id), {method:'DELETE'});
}
say(d.reply);
const items = d.items || (d.item ? [d.item] : []);
render($('#doCards'), items);
if (d.ok && mode !== 'put') {
pickedItem = null; $('#picked').hidden = true; $('#pickQuery').value = '';
}
if (d.ok) { loadBoxes(); }
} finally {
btn.disabled = false; btn.textContent = was;
}
};
// ---------- свободная фраза ----------
$('#sayBtn').onclick = async () => {
const text = $('#sayText').value.trim();
if (!text) return;
const btn = $('#sayBtn'); btn.disabled = true; btn.textContent = 'Думаю…';
try {
const d = await api('/api/say', {method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({text})});
say(d.reply);
render($('#doCards'), d.items || (d.item ? [d.item] : []));
if (d.kind === 'put') { $('#sayText').value = ''; loadPlaces(true); loadBoxes(); }
} catch (e) { say('Не достучался до сервера: ' + e); }
finally { btn.disabled = false; btn.textContent = 'Разобрать и записать'; }
};
// ---------- карточки ----------
// noPhoto=true для карточек-вариантов при выборе вещи: там кнопка мешала бы
// попасть по самой карточке.
function card(it, noPhoto) {
const el = document.createElement('div');
el.className = 'card';
let html = '';
if (it.why) html += '<span class="why">' + esc(it.why) + '</span>';
html += '<h3>' + esc(it.name || '') + '</h3>';
html += '<div class="addr' + (it.state === 'taken' ? ' taken' : '') + '">'
+ esc(it.address || '') + '</div>';
const meta = [];
if (it.qty) meta.push(esc(it.qty));
if (it.note) meta.push(esc(it.note));
if (meta.length) html += '<div class="meta">' + meta.join(' · ') + '</div>';
if (it.aliases && it.aliases.length)
html += '<div class="tags">' + it.aliases.map(a => '<span class="tag">' + esc(a) + '</span>').join('') + '</div>';
html += '<div class="photo"' + (it.photo ? '' : ' hidden') + '>'
+ (it.photo ? '<img src="/static/photos/' + encodeURIComponent(it.photo) + '" alt="">' : '')
+ '</div>';
el.innerHTML = html;
if (!noPhoto) {
el.appendChild(actionBar({type:'item', id: it.id}, el, !!it.photo));
el.appendChild(itemEditor(it, el));
}
return el;
}
// Ряд кнопок под карточкой: снять, выбрать из галереи, правка, удалить фото.
function actionBar(target, cardEl, hasPhoto) {
const bar = document.createElement('div');
bar.className = 'acts';
const drop = document.createElement('button');
// Только иконки: карточка в плитке узкая, подписи в неё не влезают.
// Что означает какая — написано подсказкой под сеткой и в title.
const cam = document.createElement('button');
cam.textContent = '📷';
cam.title = 'Снять камерой';
cam.onclick = e => {
e.stopPropagation();
photoTarget = {target, cardEl, btn: cam, dropBtn: drop};
$('#camInput').click();
};
bar.appendChild(cam);
// Камера и галерея — раздельно: одним полем оба сценария на Android не покрыть.
const gal = document.createElement('button');
gal.textContent = '🖼';
gal.title = 'Выбрать из галереи';
gal.onclick = e => {
e.stopPropagation();
photoTarget = {target, cardEl, btn: gal, dropBtn: drop};
$('#galInput').click();
};
bar.appendChild(gal);
const edit = document.createElement('button');
edit.textContent = '✏️';
edit.title = 'Изменить';
edit.onclick = e => {
e.stopPropagation();
const box = cardEl.querySelector('.edit');
if (!box) return;
box.hidden = !box.hidden;
// В плитке раскрытая карточка занимает всю ширину, иначе поля ввода
// оказались бы в колонке шириной с ладонь.
cardEl.classList.toggle('wide', !box.hidden);
};
bar.appendChild(edit);
drop.className = 'warn';
drop.textContent = '🗑';
drop.title = 'Убрать фото';
drop.hidden = !hasPhoto;
drop.onclick = async e => {
e.stopPropagation();
const body = target.type === 'item' ? {target:'item', id: target.id}
: {target:'container', code: target.code};
const d = await api('/api/photo', {method:'DELETE',
headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
say(d.reply);
if (d.ok) {
const ph = cardEl.querySelector('.photo');
if (ph) { ph.hidden = true; ph.innerHTML = ''; }
drop.hidden = true;
}
};
bar.appendChild(drop);
bar._dropBtn = drop;
return bar;
}
// Форма правки предмета. Теги снимаются по одному: модель регулярно
// придумывает мусор вроде «электротool Makita», и его надо убирать руками.
function itemEditor(it, cardEl) {
const box = document.createElement('div');
box.className = 'edit';
box.hidden = true;
box.innerHTML =
'<label>Название</label><input class="eName">'
+ '<label>Количество</label><input class="eQty">'
+ '<label>Заметка</label><input class="eNote">'
+ '<label>Теги для поиска</label><div class="tags eTags"></div>'
+ '<input class="eNewTag mt" placeholder="добавить тег и нажать Enter">'
+ '<div class="acts">'
+ '<button class="eRetag">🔄 Пересоздать теги</button>'
+ '</div>'
+ '<p class="editHint">Тег убирается нажатием на ×. По тегам вещь и находится, '
+ 'так что лишние лучше убрать, а свои — добавить. «Пересоздать» просит модель '
+ 'придумать их заново — полезно после смены модели.</p>'
+ '<div class="acts">'
+ '<button class="eSave">Сохранить</button>'
+ '<button class="eCancel">Отмена</button>'
+ '<button class="eDel warn">🗑 Удалить</button>'
+ '</div>';
const q = s => box.querySelector(s);
q('.eName').value = it.name || '';
q('.eQty').value = it.qty || '';
q('.eNote').value = it.note || '';
let tags = (it.aliases || []).slice();
function drawTags() {
const holder = q('.eTags');
holder.innerHTML = '';
if (!tags.length) {
holder.innerHTML = '<span class="tag">тегов нет</span>';
return;
}
tags.forEach((t, i) => {
const el = document.createElement('span');
el.className = 'tag del';
el.innerHTML = esc(t) + '<b>×</b>';
el.onclick = () => { tags.splice(i, 1); drawTags(); };
holder.appendChild(el);
});
}
drawTags();
q('.eNewTag').addEventListener('keydown', e => {
if (e.key !== 'Enter') return;
e.preventDefault();
const v = e.target.value.trim();
if (v && !tags.some(t => t.toLowerCase() === v.toLowerCase())) tags.push(v);
e.target.value = '';
drawTags();
});
q('.eSave').onclick = async () => {
const btn = q('.eSave');
btn.disabled = true;
try {
const d = await api('/api/item/' + encodeURIComponent(it.id), {
method:'PATCH', headers:{'Content-Type':'application/json'},
body: JSON.stringify({name: q('.eName').value, qty: q('.eQty').value,
note: q('.eNote').value, aliases: tags})});
say(d.reply);
if (d.ok) {
// Перерисовываем карточку целиком: изменились и заголовок, и теги.
const fresh = card(d.item);
cardEl.replaceWith(fresh);
}
} finally { btn.disabled = false; }
};
// Отмена: закрыть правку, вернув поля к тому, что было. Без неё случайное
// изменение можно было только сохранить или удалить вещь целиком.
q('.eCancel').onclick = () => {
q('.eName').value = it.name || '';
q('.eQty').value = it.qty || '';
q('.eNote').value = it.note || '';
tags = (it.aliases || []).slice();
drawTags();
q('.eNewTag').value = '';
box.hidden = true;
cardEl.classList.remove('wide');
};
q('.eRetag').onclick = async () => {
const btn = q('.eRetag');
btn.disabled = true;
const was = btn.textContent;
btn.textContent = 'Думаю…';
try {
const d = await api('/api/item/' + encodeURIComponent(it.id) + '/retag',
{method:'POST'});
say(d.reply);
if (d.ok && d.item) {
// Показываем сразу, но не сохраняем молча: список ещё можно поправить
// руками, а «Сохранить» закрепит его.
tags = (d.item.aliases || []).slice();
it.aliases = tags.slice();
drawTags();
}
} finally { btn.disabled = false; btn.textContent = was; }
};
q('.eDel').onclick = async () => {
if (!confirm('Удалить «' + (it.name || '') + '» из памяти?')) return;
const d = await api('/api/item/' + encodeURIComponent(it.id), {method:'DELETE'});
say(d.reply);
if (d.ok) cardEl.remove();
};
return box;
}
// Раскладывает карточки плиткой. Один предмет сеткой не оформляем — выглядело
// бы как обрезанная витрина.
function render(box, items) {
box.innerHTML = '';
dropHint(box);
const list = items || [];
box.classList.toggle('cards', list.length > 1);
list.forEach(it => box.appendChild(card(it)));
if (list.length > 1) hintIcons(box);
}
// Один раз под сеткой поясняем иконки: в плитке у кнопок нет подписей.
function hintIcons(box) {
const p = document.createElement('p');
p.className = 'hint iconHint';
p.textContent = '📷 снять · 🖼 из галереи · ✏️ изменить · 🗑 убрать фото';
box.after(p);
}
function dropHint(box) {
const next = box.nextElementSibling;
if (next && next.classList.contains('iconHint')) next.remove();
}
// ---------- найти ----------
async function doFind() {
const q = $('#findText').value.trim();
if (!q) return;
const btn = $('#findBtn'); btn.disabled = true; btn.textContent = 'Ищу…';
try {
const d = await api('/api/find?q=' + encodeURIComponent(q));
const box = $('#findCards');
if (!d.items || !d.items.length) box.innerHTML = '<p class="empty">' + esc(d.reply || 'Ничего не нашлось.') + '</p>';
else render(box, d.items);
} finally { btn.disabled = false; btn.textContent = 'Искать'; }
}
$('#findBtn').onclick = doFind;
$('#findText').addEventListener('keydown', e => { if (e.key === 'Enter') doFind(); });
// ---------- обзор ----------
async function loadTree() {
const box = $('#treeBox');
box.innerHTML = '<p class="empty">Загружаю…</p>';
const d = await api('/api/tree');
if (!(d.places || []).length && !(d.loose_containers || []).length && !(d.loose_items || []).length) {
box.innerHTML = '<p class="empty">Пока пусто. Положи первую вещь на вкладке «Действие».</p>';
return;
}
box.innerHTML = '<ul>' + (d.places || []).map(place).join('')
+ (d.loose_containers || []).map(b => '<li>' + boxTag(b) + '</li>').join('')
+ (d.loose_items || []).map(i => '<li>' + esc(i.name) + '</li>').join('') + '</ul>';
box.querySelectorAll('.box').forEach(el => el.onclick = () => openBox(el.dataset.code));
box.querySelectorAll('.editPlace').forEach(el => el.onclick = () => openPlace(el.dataset.id));
}
function place(p) {
const inner = (p.places || []).map(place).join('')
+ (p.containers || []).map(b => '<li>' + boxTag(b) + '</li>').join('')
+ (p.items || []).map(i => '<li>' + esc(i.name) + '</li>').join('');
return '<li><span class="place editPlace" data-id="' + esc(p.id) + '">' + esc(p.name)
+ ' ✏️</span>' + (inner ? '<ul>' + inner + '</ul>' : '') + '</li>';
}
function boxTag(b) {
return '<span class="box" data-code="' + esc(b.code) + '"><b>' + esc(b.code) + '</b> · ' + b.count + '</span>';
}
// ---- коробка: содержимое + правка ----
async function openBox(code) {
const d = await api('/api/container/' + encodeURIComponent(code));
const box = $('#treeBox');
const head = document.createElement('div');
head.className = 'card';
head.innerHTML = '<h3>Коробка ' + esc(code) + '</h3>'
+ '<div class="addr">' + esc(d.reply || '') + '</div>'
+ '<div class="photo"' + (d.photo ? '' : ' hidden') + '>'
+ (d.photo ? '<img src="/static/photos/' + encodeURIComponent(d.photo) + '" alt="">' : '')
+ '</div>';
// Фото самой коробки: снял, как она выглядит и что в ней лежит.
head.appendChild(actionBar({type:'container', code}, head, !!d.photo));
head.appendChild(boxEditor(code, head));
// Содержимое коробки — витриной, а не колонкой в одну карточку на строку.
const grid = document.createElement('div');
const items = d.items || [];
grid.className = items.length > 1 ? 'cards' : '';
items.forEach(it => grid.appendChild(card(it)));
box.prepend(grid);
box.prepend(head);
if (items.length > 1) hintIcons(grid);
window.scrollTo({top:0, behavior:'smooth'});
}
function boxEditor(code, cardEl) {
const box = document.createElement('div');
box.className = 'edit';
box.hidden = true;
box.innerHTML =
'<label>Код коробки</label><input class="bCode">'
+ '<label>Что это</label><input class="bKind" placeholder="коробка, ящик, пакет">'
+ '<label>Где стоит</label><select class="bPlace"></select>'
+ '<label>Заметка</label><input class="bNote">'
+ '<div class="acts">'
+ '<button class="bSave">Сохранить</button>'
+ '<button class="bDel warn">🗑 Удалить коробку</button>'
+ '</div>'
+ '<p class="editHint">При удалении коробки вещи из неё не пропадут — '
+ 'они останутся в том месте, где она стояла.</p>';
const q = s => box.querySelector(s);
q('.bCode').value = code;
api('/api/containers').then(d => {
const me = (d.containers || []).find(b => b.code === code) || {};
q('.bKind').value = me.kind || 'коробка';
q('.bNote').value = me.note || '';
return api('/api/places').then(pd => {
q('.bPlace').innerHTML = '<option value="">— без места —</option>'
+ pd.places.map(p => '<option value="' + esc(p.id) + '">' + esc(p.path) + '</option>').join('');
if (me.place) q('.bPlace').value = me.place;
});
});
q('.bSave').onclick = async () => {
const d = await api('/api/containers/' + encodeURIComponent(code), {
method:'PATCH', headers:{'Content-Type':'application/json'},
body: JSON.stringify({code: q('.bCode').value, kind: q('.bKind').value,
place: q('.bPlace').value || null, note: q('.bNote').value})});
say(d.reply);
if (d.ok) { await loadTree(); if (d.code) openBox(d.code); }
};
q('.bDel').onclick = async () => {
if (!confirm('Удалить коробку ' + code + '? Вещи из неё останутся в её месте.')) return;
const d = await api('/api/containers/' + encodeURIComponent(code), {method:'DELETE'});
say(d.reply);
if (d.ok) loadTree();
};
return box;
}
// ---- место: правка ----
async function openPlace(placeId) {
const pd = await api('/api/places');
const me = (pd.places || []).find(p => p.id === placeId);
if (!me) return;
const box = $('#treeBox');
const head = document.createElement('div');
head.className = 'card';
head.innerHTML = '<h3>Место</h3><div class="addr">' + esc(me.path) + '</div>'
+ '<label>Название</label><input class="pName">'
+ '<label>Внутри чего</label><select class="pParent"></select>'
+ '<div class="acts">'
+ '<button class="pSave">Сохранить</button>'
+ '<button class="pDel warn">🗑 Удалить место</button>'
+ '</div>'
+ '<p class="editHint">Удалить можно только пустое место — иначе покажу, '
+ 'что внутри, чтобы записи о вещах не пропали молча.</p>';
const q = s => head.querySelector(s);
q('.pName').value = me.path.split(' → ').pop();
// Себя в списке родителей не показываем; вложенные отсечёт сервер.
q('.pParent').innerHTML = '<option value="">— это верхний уровень —</option>'
+ pd.places.filter(p => p.id !== placeId)
.map(p => '<option value="' + esc(p.id) + '">' + esc(p.path) + '</option>').join('');
const parentPath = me.path.split(' → ').slice(0, -1).join(' → ');
const parent = pd.places.find(p => p.path === parentPath);
if (parent) q('.pParent').value = parent.id;
q('.pSave').onclick = async () => {
const d = await api('/api/places/' + encodeURIComponent(placeId), {
method:'PATCH', headers:{'Content-Type':'application/json'},
body: JSON.stringify({name: q('.pName').value, parent: q('.pParent').value || null})});
say(d.reply);
if (d.ok) { await loadTree(); loadPlaces(true).then(loadBoxes); }
};
q('.pDel').onclick = async () => {
if (!confirm('Удалить место «' + q('.pName').value + '»?')) return;
const d = await api('/api/places/' + encodeURIComponent(placeId), {method:'DELETE'});
say(d.reply);
if (d.ok) { await loadTree(); loadPlaces().then(loadBoxes); }
};
box.prepend(head);
window.scrollTo({top:0, behavior:'smooth'});
}
$('#treeBtn').onclick = loadTree;
// ---------- фото ----------
async function attachPhoto(input) {
const file = input.files[0];
if (!file || !photoTarget) { input.value = ''; return; }
const {target, cardEl, btn, dropBtn} = photoTarget;
const was = btn.textContent;
btn.disabled = true; btn.textContent = 'Гружу…';
try {
const blob = await shrink(file);
const fd = new FormData();
fd.append('photo', blob, 'photo.jpg');
fd.append('target', target.type);
if (target.type === 'item') fd.append('id', target.id); else fd.append('code', target.code);
const d = await api('/api/photo', {method:'POST', body: fd});
say(d.reply);
if (d.ok) {
const box = cardEl.querySelector('.photo');
if (box) {
box.hidden = false;
box.innerHTML = '<img src="/static/photos/' + encodeURIComponent(d.photo) + '" alt="">';
}
if (dropBtn) dropBtn.hidden = false;
}
btn.textContent = was;
} catch (e) {
say('Фото не загрузилось: ' + e); btn.textContent = was;
} finally {
btn.disabled = false; input.value = ''; photoTarget = null;
}
}
$('#camInput').onchange = () => attachPhoto($('#camInput'));
$('#galInput').onchange = () => attachPhoto($('#galInput'));
// Снимок с телефона — 3-5 МБ. Ужимаем до 1280 px, иначе вайфай и папка с фото
// быстро становятся проблемой.
function shrink(file, max = 1280, quality = 0.82) {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
const scale = Math.min(1, max / Math.max(img.width, img.height));
const c = document.createElement('canvas');
c.width = Math.round(img.width * scale); c.height = Math.round(img.height * scale);
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
c.toBlob(b => b ? resolve(b) : reject(new Error('canvas')), 'image/jpeg', quality);
};
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('image')); };
img.src = url;
});
}
// ---------- старт ----------
loadPlaces().then(loadBoxes);
</script>
</body>
</html>