Files
hlamingo/templates/index.html
T
alexandClaude Opus 5 1ae98b0b6f Экспорт, импорт и копии вне компьютера
Все 40 резервных копий лежали на том же диске, что и оригинал, а фотографии
не дублировались нигде: смерть диска уносила и 59 записей, и 69 снимков.

Что появилось:
- «📦 Скачать архив» — zip с базой и всеми фотографиями (13 МБ на текущих
  данных). Работает и с телефона, годится для переноса на другой компьютер.
- «📥 Загрузить архив» — восстановление. Сначала показывает, что внутри
  (сколько вещей в архиве против текущих), и только после подтверждения
  заменяет данные. Прежнее состояние откладывается в data/backups —
  и база, и папка со старыми фотографиями.
- HLAMINGO_BACKUP_DIR — путь, куда сама уходит копия базы и новых фотографий.
  Не чаще раза в час и только если путь доступен: сетевой диск отваливается
  регулярно, а терять запись о вещи из-за этого недопустимо. Состояние
  и кнопка «Скопировать сейчас» — на вкладке настроек.

Имена файлов из архива как пути не используются: только базовое имя
и расширение из белого списка. Иначе архив со строкой «../../» писал бы
куда угодно — тест это проверяет отдельно.

Попутно исправлены два дефекта:
- предел загрузки был 8 МБ, и архив в 13 МБ не прошёл бы. Разделён на общий
  предел запроса и отдельный предел для одного снимка;
- настройки нельзя было менять по одной: непереданные поля приходили как null
  и валились на проверке. Теперь null означает «не трогать», а пустая строка —
  осознанное «очистить».

Про защиту копий на сервере. Задумывался .htaccess, но сайты обслуживает
nginx, который его игнорирует, — файл создавал бы ложное чувство защиты.
Настоящая защита в расположении: папка лежит рядом с корнем сайта, а не внутри,
и nginx за пределы своего root не выпускает. Проверено запросами извне:
и сама папка, и db.json, и фотографии дают 404, включая попытку выйти
через «/../». .htaccess положен рядом как подстраховка на случай переезда
на Apache, о чём честно написано внутри самого файла.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 11:17:08 +03:00

1216 lines
57 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0f1216">
<title>hlamingo — где что лежит</title>
<!-- Иконки: Android берёт их из манифеста, iOS — из apple-touch-icon.
Оба хотят PNG, поэтому SVG во вкладке тут не годится. -->
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png">
<link rel="icon" type="image/png" sizes="192x192" href="/static/icon-192.png">
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
<link rel="manifest" href="/manifest.webmanifest">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="hlamingo">
<style>
:root {
--bg:#0f1216; --card:#171c22; --line:#2a333d; --fg:#e8edf2; --dim:#93a1b0;
--accent:#ff7a9c; --ok:#4ade80; --warn:#fbbf24; --radius:14px;
}
@media (prefers-color-scheme: light) {
:root { --bg:#f4f6f8; --card:#fff; --line:#dde3ea; --fg:#151b22; --dim:#5d6b7a; --accent:#d6336c; }
}
* { box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
/* Ни одна картинка не должна расширять страницу: снимок с телефона — 1280 px,
и без этого правила появляется горизонтальная прокрутка. */
img { max-width:100%; height:auto; }
body { margin:0; background:var(--bg); color:var(--fg);
font:17px/1.5 -apple-system,"Segoe UI",Roboto,sans-serif;
padding:0 0 env(safe-area-inset-bottom);
/* Длинный артикул без пробелов («КР142ЕН5А-XYZ-000123») не должен
распирать страницу — пусть переносится. */
overflow-wrap:break-word; }
header { position:sticky; top:0; z-index:10; background:var(--bg);
padding:14px 16px 0; border-bottom:1px solid var(--line); }
h1 { margin:0 0 10px; font-size:20px; } h1 span { color:var(--accent); }
#banner { display:none; margin:0 0 10px; padding:9px 12px; border-radius:10px;
background:#3a2e10; color:var(--warn); font-size:14px; }
nav { display:flex; gap:6px; }
nav button { flex:1; padding:11px 4px; font-size:15px; font-weight:600; background:none;
border:none; border-bottom:3px solid transparent; color:var(--dim); cursor:pointer; }
nav button.on { color:var(--fg); border-bottom-color:var(--accent); }
nav button.narrowTab { flex:0 0 48px; font-size:18px; }
code { background:var(--card); padding:1px 5px; border-radius:5px; font-size:14px; }
main { padding:16px; max-width:720px; margin:0 auto; }
.tab { display:none; } .tab.on { display:block; }
label { display:block; margin:14px 0 5px; font-size:14px; color:var(--dim); }
input, select, textarea {
width:100%; padding:13px; font:inherit; color:var(--fg); background:var(--card);
border:1px solid var(--line); border-radius:var(--radius); appearance:none;
}
select { background-image:linear-gradient(45deg,transparent 50%,var(--dim) 50%),
linear-gradient(135deg,var(--dim) 50%,transparent 50%);
background-position:calc(100% - 20px) 22px, calc(100% - 14px) 22px;
background-size:6px 6px,6px 6px; background-repeat:no-repeat; padding-right:40px; }
textarea { min-height:76px; resize:vertical; }
.row { display:flex; gap:10px; }
.row > * { flex:1; }
.mt { margin-top:12px; }
/* переключатель действия */
.modes { display:grid; grid-template-columns:repeat(4,1fr); gap:6px; margin-bottom:4px; }
.modes button {
padding:12px 2px; font:600 14px/1.2 inherit; color:var(--dim); cursor:pointer;
background:var(--card); border:1px solid var(--line); border-radius:12px;
}
.modes button.on { color:#fff; background:var(--accent); border-color:var(--accent); }
.modes button.on.danger { background:#c0392b; border-color:#c0392b; }
button.act { width:100%; padding:16px; font:600 17px/1 inherit; color:#fff; cursor:pointer;
background:var(--accent); border:none; border-radius:var(--radius); }
button.act.danger { background:#c0392b; }
button.act.ghost { background:var(--card); color:var(--fg); border:1px solid var(--line); }
button.act:disabled { opacity:.5; }
.small { padding:11px; font-size:15px; }
.hint { margin:10px 2px 0; color:var(--dim); font-size:14px; }
details { margin-top:18px; border-top:1px solid var(--line); padding-top:12px; }
summary { cursor:pointer; color:var(--dim); font-size:15px; }
#reply { margin-top:16px; padding:14px; border-radius:var(--radius); background:var(--card);
border-left:4px solid var(--accent); display:none; white-space:pre-wrap; }
.card { margin-top:12px; padding:13px; background:var(--card);
border:1px solid var(--line); border-radius:var(--radius); }
.card h3 { margin:0 0 4px; font-size:17px; }
/* Плитка как в интернет-магазинах: сколько колонок влезет, столько и будет.
auto-fill с minmax — на телефоне обычно две, на планшете три-четыре. */
.cards { display:grid; gap:10px; margin-top:12px; align-items:start;
grid-template-columns:repeat(auto-fill, minmax(150px, 1fr)); }
.cards .card { margin-top:0; padding:10px; }
.cards .card h3 { font-size:15px; line-height:1.25; }
.cards .addr { font-size:13px; }
.cards .meta { font-size:12px; }
/* Теги в плитке не показываем — они её распирают. Видны при правке. */
.cards .card:not(.wide) .tags { display:none; }
/* Квадратные превью: ряд выглядит ровным, как витрина. */
.cards .photo img { aspect-ratio:1/1; object-fit:cover; margin-top:8px; }
.cards .acts { gap:6px; }
.cards .acts button { min-width:0; flex:1 1 auto; padding:9px 2px; font-size:15px; }
/* Карточка с открытой правкой растягивается на всю ширину: в узкой колонке
поля ввода были бы непригодны. */
.cards .card.wide { grid-column:1 / -1; }
.cards .card.wide .photo img { aspect-ratio:auto; object-fit:contain; }
.addr { color:var(--accent); font-size:15px; }
.addr.taken { color:var(--warn); }
.meta { color:var(--dim); font-size:13px; margin-top:5px; }
.tags { margin-top:7px; display:flex; flex-wrap:wrap; gap:5px; }
.tag { font-size:12px; padding:3px 8px; border-radius:20px; background:var(--bg);
border:1px solid var(--line); color:var(--dim); }
.photo img { margin-top:10px; width:100%; border-radius:10px; display:block; }
/* Превью в форме не должно занимать весь экран — до него ещё кнопка записи. */
#formPhotoPreview img { max-height:40vh; width:auto; object-fit:contain; }
.acts { display:flex; gap:8px; margin-top:10px; flex-wrap:wrap; }
.acts button { flex:1 1 auto; min-width:76px; padding:10px 4px;
font:600 14px/1.2 inherit; cursor:pointer;
background:var(--bg); color:var(--fg); border:1px solid var(--line);
border-radius:10px; }
.acts button.narrow { flex:0 0 46px; min-width:46px; font-size:16px; }
.acts button.warn { color:#e06c5a; }
.edit { margin-top:12px; padding-top:12px; border-top:1px solid var(--line); }
.edit label { margin:10px 0 4px; }
.edit input { padding:11px; }
.tag.del { cursor:pointer; padding-right:6px; }
.tag.del b { color:#e06c5a; margin-left:6px; font-weight:700; }
.editHint { color:var(--dim); font-size:13px; margin:6px 0 0; }
.iconHint { font-size:13px; margin-top:8px; }
.why { float:right; font-size:12px; color:var(--dim); }
.pick { cursor:pointer; }
.pick:active { border-color:var(--accent); }
/* Глубокая иерархия наращивает отступ на каждом уровне: прокручиваем сам
блок, а не всю страницу. */
.tree { overflow-x:auto; }
.tree ul { list-style:none; margin:4px 0 0; padding-left:16px; border-left:1px solid var(--line); }
.tree li { margin:6px 0; }
.place { font-weight:600; }
.editPlace { cursor:pointer; }
.box { display:inline-block; padding:4px 10px; margin:3px 5px 3px 0; background:var(--card);
border:1px solid var(--line); border-radius:8px; font-size:15px; cursor:pointer; }
.box b { color:var(--accent); }
.empty { color:var(--dim); padding:24px 4px; text-align:center; }
</style>
</head>
<body>
<header>
<h1>hla<span>mingo</span> — где что лежит</h1>
<div id="banner"></div>
<nav>
<button class="on" data-tab="do">Действие</button>
<button data-tab="find">Найти</button>
<button data-tab="tree">Обзор</button>
<button data-tab="setup" class="narrowTab"></button>
</nav>
</header>
<main>
<!-- ================= Действие ================= -->
<section class="tab on" id="tab-do">
<div class="modes">
<button class="on" data-mode="put">Положить</button>
<button data-mode="take">Забрать</button>
<button data-mode="move">Переместить</button>
<button data-mode="delete" class="danger">Удалить</button>
</div>
<!-- выбор вещи: для забрать / переместить / удалить -->
<div id="pickWrap" hidden>
<label>Какая вещь</label>
<input type="text" id="pickQuery" placeholder="начни вводить название" autocomplete="off">
<div id="pickList"></div>
<div id="picked" class="card" hidden></div>
</div>
<!-- куда: для положить / переместить -->
<div id="whereWrap">
<label>Место</label>
<select id="placeSel"></select>
<div class="row mt">
<input type="text" id="newPlaceName" placeholder="новое место, напр. Полка 2">
<button class="act ghost small" id="addPlaceBtn" style="flex:0 0 38%">Добавить</button>
</div>
<p class="hint">Новое место добавится внутрь выбранного выше — так строится «Гараж → Стеллаж А → Полка 2».</p>
<label>Ёмкость</label>
<select id="boxSel"></select>
<div class="row mt">
<input type="text" id="newBoxCode" placeholder="код коробки, напр. A3">
<button class="act ghost small" id="addBoxBtn" style="flex:0 0 38%">Добавить</button>
</div>
</div>
<!-- что: для положить -->
<div id="whatWrap">
<label>Предмет</label>
<input type="text" id="itemName" placeholder="стабилизатор 7805" autocomplete="off">
<div class="row mt">
<input type="text" id="itemQty" placeholder="количество, напр. 12 шт">
<input type="text" id="itemNote" placeholder="заметка">
</div>
<label>Фото (можно сразу)</label>
<div class="row">
<button class="act ghost small" id="formCamBtn">📷 Снять</button>
<button class="act ghost small" id="formGalBtn">🖼 Из галереи</button>
<button class="act ghost small" id="formPhotoClear" style="flex:0 0 26%" hidden>Убрать</button>
</div>
<div id="formPhotoPreview" class="photo" hidden></div>
<div id="guessBox" class="card" hidden></div>
</div>
<div class="mt"><button class="act" id="goBtn">Положить</button></div>
<p class="hint">Фото можно добавить сразу здесь — оно прикрепится к новой вещи.
«Снять» открывает камеру, «Из галереи» — уже готовые снимки.
К уже лежащим вещам фото добавляется теми же кнопками на их карточках,
на вкладках «Найти» и «Обзор».</p>
<details>
<summary>Или просто напиши фразой</summary>
<textarea id="sayText" class="mt" placeholder="положил стабилизаторы 7805, 12 штук, в коробку A3 на второй полке стеллажа А в гараже"></textarea>
<div class="mt"><button class="act ghost" id="sayBtn">Разобрать и записать</button></div>
<p class="hint">Спросить тоже можно: «где мультиметр», «что в коробке A3».</p>
</details>
<div id="reply"></div>
<div id="doCards"></div>
</section>
<!-- ================= Найти ================= -->
<section class="tab" id="tab-find">
<input type="text" id="findText" placeholder="стабилизатор на 5 вольт" autocomplete="off">
<div class="mt"><button class="act" id="findBtn">Искать</button></div>
<p class="hint">Ищет по названию, синонимам и маркировке: «стабилизатор 5 вольт» находит 7805.</p>
<div id="findCards"></div>
</section>
<!-- ================= Настройки ================= -->
<section class="tab" id="tab-setup">
<p class="hint" id="setupIntro">Здесь настраивается связь с Ollama — это «мозги»
помощника. Без неё всё остальное работает, но не будет разбора фраз,
автоматических тегов и поиска по смыслу.</p>
<label>Адрес Ollama</label>
<input type="text" id="cfgHost" placeholder="http://192.168.1.50:11434" autocomplete="off"
inputmode="url">
<p class="hint">Если Ollama на этом же компьютере — <code>http://localhost:11434</code>.
Если на другой машине — её адрес в локальной сети.</p>
<div class="row mt">
<button class="act ghost" id="cfgProbe">Проверить связь</button>
</div>
<div id="cfgProbeResult"></div>
<label>Модель для разбора фраз</label>
<select id="cfgChat"></select>
<label>Модель для поиска по смыслу</label>
<select id="cfgEmbed"></select>
<p class="hint">Список заполняется моделями, которые реально установлены.
Нужных нет — на машине с Ollama: <code>ollama pull qwen2.5:7b</code>
и <code>ollama pull nomic-embed-text</code>.</p>
<div class="mt"><button class="act" id="cfgSave">Сохранить</button></div>
<p class="hint" id="cfgWhere"></p>
<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';
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>';
}
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);
};
// Модель выбирается из установленных; текущее значение показываем даже если
// его нет в списке — иначе выбор молча сбросился бы на чужую модель.
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,
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>