Files
hlamingo/templates/index.html
T
Alex CubeandClaude Opus 5 4d8175931b Выбор мозгов: Ollama или веб-версия DeepSeek
Разбор свободных фраз, теги и подсказку по фотографии теперь может делать
не своя модель, а веб-версия DeepSeek. Своего клиента DeepSeek здесь нет
и не нужно: рядом живёт отдельный сервис deepseek-api, который держит
вкладку браузера и отдаёт наружу протокол Ollama. Для hlamingo вся разница
сводится к тому, по какому адресу спрашивать.

  HLAMINGO_ENGINE=ollama|deepseek
  HLAMINGO_DEEPSEEK_HOST=http://localhost:11500

Эмбеддинги в выбор не входят и всегда идут на OLLAMA_HOST: веб-чат векторов
не отдаёт, а подделать их нечем. Поэтому адрес Ollama нужен и в режиме
DeepSeek, иначе молча умирает поиск по смыслу.

Предохранитель в ollama.py теперь считается по каждому адресу отдельно.
Иначе лежачая Ollama гасила бы живой DeepSeek: неудача по одному адресу
на полминуты объявляла мёртвым и второй.

Проба адреса больше не подменяет config.OLLAMA_HOST на время проверки —
адрес передаётся явным параметром. Запросы идут в несколько потоков,
и подмена задевала чужие.

В настройках (вкладка "шестерёнка") появился выбор "Кто думает", поле
адреса DeepSeek и вторая кнопка проверки связи. Списка моделей теперь два:
для разбора фраз — у выбранного движка, для поиска по смыслу — у Ollama.

Замер на разборе фразы и генерации тегов: Ollama qwen3:8b 2-3 с,
DeepSeek 8-12 с плюс 15 с на подъём браузера при первом запросе.

Тесты: 115 успешно, 0 провалено.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:48:30 +03:00

1288 lines
61 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">Здесь настраиваются «мозги» помощника. Без них
всё остальное работает, но не будет разбора свободных фраз, автоматических
тегов и подсказок по фотографии.</p>
<label>Кто думает</label>
<select id="cfgEngine">
<option value="ollama">Ollama — своя модель на своём компьютере</option>
<option value="deepseek">DeepSeek — веб-версия через браузер</option>
</select>
<p class="hint">Ollama работает без интернета и ни о чём никому не сообщает.
DeepSeek умнее, но отвечает медленнее, требует открытого браузера
с выполненным входом, и ваши фразы уходят на его серверы.</p>
<div id="deepseekRow" hidden>
<label>Адрес сервиса DeepSeek</label>
<input type="text" id="cfgDeepseekHost" placeholder="http://localhost:11500"
autocomplete="off" inputmode="url">
<p class="hint">Это отдельная программа <code>deepseek-api</code>: она держит
вкладку с чатом DeepSeek и притворяется Ollama. Её нужно запустить
(<code>start.cmd</code>) — сама она не поднимется.</p>
<div class="row mt">
<button class="act ghost" id="cfgProbeDeepseek">Проверить DeepSeek</button>
</div>
<div id="cfgProbeDeepseekResult"></div>
</div>
<label>Адрес Ollama</label>
<input type="text" id="cfgHost" placeholder="http://192.168.1.50:11434" autocomplete="off"
inputmode="url">
<p class="hint" id="cfgHostHint">Если Ollama на этом же компьютере —
<code>http://localhost:11434</code>. Если на другой машине — её адрес
в локальной сети.</p>
<div class="row mt">
<button class="act ghost" id="cfgProbe">Проверить связь</button>
</div>
<div id="cfgProbeResult"></div>
<label id="cfgChatLabel">Модель для разбора фраз</label>
<select id="cfgChat"></select>
<label>Модель для поиска по смыслу</label>
<select id="cfgEmbed"></select>
<p class="hint">Список заполняется моделями, которые реально есть.
Нужных нет — на машине с Ollama: <code>ollama pull qwen3:8b</code>
и <code>ollama pull bge-m3</code>.</p>
<div class="mt"><button class="act" id="cfgSave">Сохранить</button></div>
<p class="hint" id="cfgWhere"></p>
<h3 style="margin-top:26px">Резервные копии</h3>
<p class="hint">Копии внутри программы лежат на этом же диске. Если он умрёт,
пропадут и записи, и фотографии — поэтому копию стоит держать ещё где-то.</p>
<label>Папка для копий вне компьютера</label>
<input type="text" id="cfgBackupDir" placeholder="T:\hlamingo-backup" autocomplete="off">
<p class="hint">Сетевой диск или папка. Пусто — внешние копии выключены.
Копирование идёт само, не чаще раза в час, и не мешает работе, если
диск отвалился.</p>
<div id="backupState"></div>
<div class="row mt">
<button class="act ghost" id="backupNow">Скопировать сейчас</button>
</div>
<label>Архив со всеми данными</label>
<div class="row">
<button class="act ghost" id="exportBtn">📦 Скачать архив</button>
<button class="act ghost" id="importBtn">📥 Загрузить архив</button>
</div>
<p class="hint">В архиве база и все фотографии. Загрузка ЗАМЕНЯЕТ текущие
данные — прежние сохранятся отдельно, чтобы можно было вернуться.</p>
<div id="importInfo"></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>
<input type="file" id="archiveInput" accept=".zip,application/zip" 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';
const who = h.engine === 'deepseek' ? 'сервис DeepSeek' : 'Ollama';
b.textContent = h.fresh
? 'Похоже, помощник только установлен, а ' + who + ' по адресу ' + h.host
+ ' не отвечает. '
: 'ИИ недоступен: ' + who + ' по адресу ' + 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');
$('#cfgEngine').value = d.engine || 'ollama';
$('#cfgHost').value = d.ollama_host || '';
$('#cfgDeepseekHost').value = d.deepseek_host || '';
showEngine();
fillModels(d.models || [], d.embed_models || [], d.chat_model, d.embed_model);
$('#cfgWhere').textContent = 'Настройки хранятся в файле ' + d.env_file
+ ' — он не попадает в репозиторий.';
// Ollama нужна всегда: даже когда думает DeepSeek, векторы для поиска
// по смыслу считает она — веб-чат их не отдаёт.
$('#cfgProbeResult').innerHTML = d.embed_available ? ''
: '<div class="card"><div class="addr taken">Ollama по этому адресу не отвечает.</div></div>';
if (d.engine === 'deepseek') {
$('#cfgProbeDeepseekResult').innerHTML = d.available ? ''
: '<div class="card"><div class="addr taken">Сервис DeepSeek не отвечает. '
+ 'Запущен ли deepseek-api и открыт ли браузер с чатом?</div></div>';
}
showBackupState(d.backup || {});
}
function showBackupState(b) {
$('#cfgBackupDir').value = b.path || '';
const box = $('#backupState');
if (!b.enabled) {
box.innerHTML = '<div class="card"><div class="meta">Внешние копии выключены. '
+ 'Укажите папку выше, чтобы данные жили не только на этом диске.</div></div>';
return;
}
const good = b.reachable;
let html = '<div class="card"><div class="addr' + (good ? '' : ' taken') + '">'
+ (good ? 'Папка доступна' : 'Папка сейчас недоступна') + '</div>'
+ '<div class="meta">' + esc(b.path) + '</div>';
if (b.at) {
html += '<div class="meta">Последняя копия: ' + esc(b.at)
+ (b.ok ? '' : ' — с ошибкой') + '</div>';
} else {
html += '<div class="meta">Копий ещё не было.</div>';
}
if (b.error) html += '<div class="meta">' + esc(b.error) + '</div>';
box.innerHTML = html + '</div>';
}
$('#backupNow').onclick = async () => {
const btn = $('#backupNow');
btn.disabled = true; btn.textContent = 'Копирую…';
try {
const d = await api('/api/backup/now', {method: 'POST'});
say(d.reply);
if (d.state) showBackupState(d.state);
} finally { btn.disabled = false; btn.textContent = 'Скопировать сейчас'; }
};
// Экспорт: обычная ссылка на скачивание — так файл уходит в загрузки телефона.
$('#exportBtn').onclick = () => { window.location = '/api/export'; };
$('#importBtn').onclick = () => $('#archiveInput').click();
$('#archiveInput').onchange = async () => {
const file = $('#archiveInput').files[0];
$('#archiveInput').value = '';
if (!file) return;
const box = $('#importInfo');
box.innerHTML = '<div class="card"><div class="meta">Смотрю, что в архиве…</div></div>';
// Сначала показываем, что внутри: импорт заменяет данные целиком,
// и человек должен видеть, на что меняет.
const fd = new FormData();
fd.append('archive', file);
const check = await api('/api/import/check', {method: 'POST', body: fd});
if (!check.ok) {
box.innerHTML = '<div class="card"><div class="addr taken">' + esc(check.reply) + '</div></div>';
return;
}
const a = check.archive, c = check.current;
box.innerHTML = '<div class="card">'
+ '<h3>Заменить данные?</h3>'
+ '<div class="meta">Сейчас: ' + c.items + ' вещей, ' + c.places + ' мест, '
+ c.containers + ' коробок</div>'
+ '<div class="addr">В архиве: ' + a.items + ' вещей, ' + a.places + ' мест, '
+ a.containers + ' коробок, ' + a.photos + ' фото</div>'
+ '<div class="meta">Текущие данные сохранятся отдельно — вернуться можно.</div>'
+ '</div>';
const bar = document.createElement('div');
bar.className = 'acts';
const go = document.createElement('button');
go.textContent = 'Заменить';
go.onclick = async () => {
go.disabled = true; go.textContent = 'Восстанавливаю…';
const fd2 = new FormData();
fd2.append('archive', file);
const d = await api('/api/import', {method: 'POST', body: fd2});
say(d.reply);
box.innerHTML = '<div class="card"><div class="' + (d.ok ? 'addr' : 'addr taken') + '">'
+ esc(d.reply) + '</div></div>';
if (d.ok) { loadPlaces().then(loadBoxes); loadTree(); }
};
const no = document.createElement('button');
no.textContent = 'Отмена';
no.onclick = () => { box.innerHTML = ''; };
bar.appendChild(go); bar.appendChild(no);
box.firstChild.appendChild(bar);
};
// Модель выбирается из установленных; текущее значение показываем даже если
// его нет в списке — иначе выбор молча сбросился бы на чужую модель.
// Списков два: разбор фраз может считать DeepSeek, а векторы для поиска
// по смыслу — всегда настоящая Ollama.
function fillModels(chatModels, embedModels, chat, embed) {
const opts = (list, extra) => ['<option value="">— не задана —</option>']
.concat(list.map(m => '<option value="' + esc(m) + '">' + esc(m) + '</option>'))
.concat(extra && !list.includes(extra)
? ['<option value="' + esc(extra) + '">' + esc(extra) + ' (не установлена)</option>'] : [])
.join('');
$('#cfgChat').innerHTML = opts(chatModels, chat);
$('#cfgEmbed').innerHTML = opts(embedModels, embed);
$('#cfgChat').value = chat || '';
$('#cfgEmbed').value = embed || '';
}
// Поле адреса DeepSeek показываем только когда он выбран, а подпись у Ollama
// меняем: в режиме DeepSeek она нужна уже не для мыслей, а только для поиска.
function showEngine() {
const ds = $('#cfgEngine').value === 'deepseek';
$('#deepseekRow').hidden = !ds;
$('#cfgHostHint').innerHTML = ds
? 'Нужна и в этом режиме: поиск по смыслу считает только Ollama — веб-чат '
+ 'векторов не отдаёт. Если её нет, останется поиск по названию и тегам.'
: 'Если Ollama на этом же компьютере — <code>http://localhost:11434</code>. '
+ 'Если на другой машине — её адрес в локальной сети.';
$('#cfgChatLabel').textContent = ds
? 'Чат DeepSeek для разбора фраз' : 'Модель для разбора фраз';
}
$('#cfgEngine').onchange = showEngine;
// Обе кнопки проверки делают одно и то же, отличаются только адресом и тем,
// куда класть ответ.
async function probeHost(btn, input, box, label) {
const b = $(btn), was = $(btn).textContent;
b.disabled = true; b.textContent = 'Проверяю…';
try {
const d = await api('/api/config/probe', {method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({host: $(input).value.trim()})});
$(box).innerHTML = '<div class="card"><div class="addr'
+ (d.ok ? '' : ' taken') + '">' + esc(d.reply || '') + '</div></div>';
return d;
} finally { b.disabled = false; b.textContent = was; }
}
$('#cfgProbe').onclick = async () => {
const d = await probeHost('#cfgProbe', '#cfgHost', '#cfgProbeResult');
if (!d.ok) return;
// Модели Ollama — всегда для поиска по смыслу; для разбора фраз они годятся
// только когда думает она же.
const ds = $('#cfgEngine').value === 'deepseek';
fillModels(ds ? [...$('#cfgChat').options].map(o => o.value).filter(Boolean)
: (d.models || []),
d.models || [], $('#cfgChat').value, $('#cfgEmbed').value);
};
$('#cfgProbeDeepseek').onclick = async () => {
const d = await probeHost('#cfgProbeDeepseek', '#cfgDeepseekHost',
'#cfgProbeDeepseekResult');
if (d.ok) fillModels(d.models || [],
[...$('#cfgEmbed').options].map(o => o.value).filter(Boolean),
$('#cfgChat').value, $('#cfgEmbed').value);
};
$('#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({engine: $('#cfgEngine').value,
ollama_host: $('#cfgHost').value.trim(),
deepseek_host: $('#cfgDeepseekHost').value.trim(),
chat_model: $('#cfgChat').value,
embed_model: $('#cfgEmbed').value,
backup_dir: $('#cfgBackupDir').value.trim()})});
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>