Фото выбрано в форме — приложение показывает его модели и предлагает название
с прочитанными надписями. «Подставить» переносит в поля, «Не надо» прячет.
Само не подставляется, и это принципиально: модели домысливают марки
и артикулы, когда надписей не видно. В замере клипсу для кабеля модель назвала
«конструктором 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>
1105 lines
51 KiB
HTML
1105 lines
51 KiB
HTML
<!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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|