hlamingo — помощник «где что лежит»

Веб-помощник для домашнего склада: помнит, в какой коробке и в каком месте
лежит вещь, и находит её по названию, тегам и маркировке. Открывается
с телефона, интеллект — локальная Ollama.

Основное:
- структурный ввод «место → ёмкость → предмет» и кнопки
  Положить / Забрать / Переместить / Удалить;
- иерархия мест (Гараж → Стеллаж А → Полка 2), ёмкости с кодом «буква+цифра»,
  состояние «на руках» для взятых вещей;
- поиск по словам с учётом русских окончаний, теги от модели расширяют
  словарь предмета, смысловой поиск — вспомогательный (замеры в search.py);
- фото к вещам и коробкам: съёмка и выбор из галереи;
- правка и удаление всех сущностей, удаление коробки не теряет её содержимое;
- работает и без Ollama: отключаются только разбор фраз, теги и поиск
  по смыслу.

Данные (data/, static/photos/) и локальный конфиг (.env) в репозиторий
не попадают. Тесты работают только в песочнице: путь к данным берётся
из окружения, tests/guard.py отказывается запускаться на живых данных.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 14:27:20 +03:00
co-authored by Claude Opus 5
commit 17b7d49d2f
26 changed files with 3760 additions and 0 deletions
+864
View File
@@ -0,0 +1,864 @@
<!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); }
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; }
.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; }
.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>
</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>
<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-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 || ''; }
// ---------- вкладки ----------
document.querySelectorAll('nav button').forEach(b => b.onclick = () => {
document.querySelectorAll('nav button').forEach(x => x.classList.toggle('on', x === b));
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('on', t.id === 'tab-' + b.dataset.tab));
if (b.dataset.tab === 'tree') loadTree();
});
// ---------- баннер ----------
api('/api/health').then(h => {
if (!h.ollama) {
$('#banner').style.display = 'block';
$('#banner').textContent = 'ИИ недоступен (' + h.host + '). Кнопки работают полностью; '
+ 'не будет только синонимов и разбора свободных фраз.';
}
}).catch(() => {});
// ---------- режимы ----------
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;
} catch (e) {
say('Не смог прочитать это изображение: ' + e);
clearPendingPhoto();
}
}
$('#formCamInput').onchange = () => takeFormPhoto($('#formCamInput'));
$('#formGalInput').onchange = () => takeFormPhoto($('#formGalInput'));
$('#formPhotoClear').onclick = clearPendingPhoto;
function clearPendingPhoto() {
pendingPhoto = null;
$('#formPhotoPreview').hidden = true;
$('#formPhotoPreview').innerHTML = '';
$('#formPhotoClear').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');
// Камера и галерея — раздельно: одним полем оба сценария на Android не покрыть.
const cam = document.createElement('button');
cam.textContent = '📷 Снять';
cam.onclick = e => {
e.stopPropagation();
photoTarget = {target, cardEl, btn: cam, dropBtn: drop};
$('#camInput').click();
};
bar.appendChild(cam);
const gal = document.createElement('button');
gal.className = 'narrow';
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.onclick = e => {
e.stopPropagation();
const box = cardEl.querySelector('.edit');
if (box) box.hidden = !box.hidden;
};
bar.appendChild(edit);
drop.className = 'warn';
drop.textContent = '🗑 Фото';
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 = ''; }
photo.textContent = '📷 Фото';
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">'
+ '<p class="editHint">Тег убирается нажатием на ×. По тегам вещь и находится, '
+ 'так что лишние лучше убрать, а свои — добавить.</p>'
+ '<div class="acts">'
+ '<button class="eSave">Сохранить</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('.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 = ''; (items || []).forEach(it => box.appendChild(card(it))); }
// ---------- найти ----------
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));
box.prepend(head);
(d.items || []).forEach(it => box.insertBefore(card(it), head.nextSibling));
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>