Обзор и поиск — плиткой, как витрина в магазине

Раньше карточки шли одна под другой, и коробка с двумя десятками вещей
превращалась в длинную колонку. Теперь сетка на CSS grid с auto-fill: сколько
колонок влезает в экран, столько и будет — на телефоне обычно две,
на планшете три-четыре.

Чтобы плитка была компактной:
- превью квадратные (object-fit: cover) — ряд выглядит ровным;
- кнопки только иконками, подписи в них не влезали; что означает какая,
  написано подсказкой под сеткой и в title;
- теги в плитке скрыты, они её распирали — видны при правке;
- карточка с открытой правкой растягивается на всю ширину, иначе поля
  ввода оказались бы в колонке шириной с ладонь.

Один результат сеткой не оформляется: выглядело бы как обрезанная витрина.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 15:03:17 +03:00
co-authored by Claude Opus 5
parent 4f298ac203
commit 9cbc7f02ac
2 changed files with 70 additions and 9 deletions
+5
View File
@@ -83,6 +83,11 @@ start.cmd запускает сервер и печатает адреса
| **Переместить** | Вещь переезжает в другую коробку или место | | **Переместить** | Вещь переезжает в другую коробку или место |
| **Удалить** | Убирает запись совсем | | **Удалить** | Убирает запись совсем |
Найденное и содержимое коробок показывается плиткой, как витрина в магазине:
сколько колонок влезает в экран, столько и будет. Кнопки на карточках —
иконками: 📷 снять, 🖼 из галереи, ✏️ изменить, 🗑 убрать фото. При правке
карточка растягивается на всю ширину, чтобы поля были нормального размера.
Ввод структурный: **место → ёмкость → предмет**. Место и коробку выбираешь из Ввод структурный: **место → ёмкость → предмет**. Место и коробку выбираешь из
списков, поэтому ИИ в самом ответственном месте не участвует и ошибиться списков, поэтому ИИ в самом ответственном месте не участвует и ошибиться
разбором фразы негде. Новое место добавляется *внутрь* выбранного — так и разбором фразы негде. Новое место добавляется *внутрь* выбранного — так и
+65 -9
View File
@@ -85,6 +85,25 @@
.card { margin-top:12px; padding:13px; background:var(--card); .card { margin-top:12px; padding:13px; background:var(--card);
border:1px solid var(--line); border-radius:var(--radius); } border:1px solid var(--line); border-radius:var(--radius); }
.card h3 { margin:0 0 4px; font-size:17px; } .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 { color:var(--accent); font-size:15px; }
.addr.taken { color:var(--warn); } .addr.taken { color:var(--warn); }
.meta { color:var(--dim); font-size:13px; margin-top:5px; } .meta { color:var(--dim); font-size:13px; margin-top:5px; }
@@ -107,6 +126,7 @@
.tag.del { cursor:pointer; padding-right:6px; } .tag.del { cursor:pointer; padding-right:6px; }
.tag.del b { color:#e06c5a; margin-left:6px; font-weight:700; } .tag.del b { color:#e06c5a; margin-left:6px; font-weight:700; }
.editHint { color:var(--dim); font-size:13px; margin:6px 0 0; } .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); } .why { float:right; font-size:12px; color:var(--dim); }
.pick { cursor:pointer; } .pick { cursor:pointer; }
.pick:active { border-color:var(--accent); } .pick:active { border-color:var(--accent); }
@@ -621,9 +641,11 @@ function actionBar(target, cardEl, hasPhoto) {
const drop = document.createElement('button'); const drop = document.createElement('button');
// Камера и галерея — раздельно: одним полем оба сценария на Android не покрыть. // Только иконки: карточка в плитке узкая, подписи в неё не влезают.
// Что означает какая — написано подсказкой под сеткой и в title.
const cam = document.createElement('button'); const cam = document.createElement('button');
cam.textContent = '📷 Снять'; cam.textContent = '📷';
cam.title = 'Снять камерой';
cam.onclick = e => { cam.onclick = e => {
e.stopPropagation(); e.stopPropagation();
photoTarget = {target, cardEl, btn: cam, dropBtn: drop}; photoTarget = {target, cardEl, btn: cam, dropBtn: drop};
@@ -631,8 +653,8 @@ function actionBar(target, cardEl, hasPhoto) {
}; };
bar.appendChild(cam); bar.appendChild(cam);
// Камера и галерея — раздельно: одним полем оба сценария на Android не покрыть.
const gal = document.createElement('button'); const gal = document.createElement('button');
gal.className = 'narrow';
gal.textContent = '🖼'; gal.textContent = '🖼';
gal.title = 'Выбрать из галереи'; gal.title = 'Выбрать из галереи';
gal.onclick = e => { gal.onclick = e => {
@@ -643,16 +665,22 @@ function actionBar(target, cardEl, hasPhoto) {
bar.appendChild(gal); bar.appendChild(gal);
const edit = document.createElement('button'); const edit = document.createElement('button');
edit.textContent = '✏️ Изменить'; edit.textContent = '✏️';
edit.title = 'Изменить';
edit.onclick = e => { edit.onclick = e => {
e.stopPropagation(); e.stopPropagation();
const box = cardEl.querySelector('.edit'); const box = cardEl.querySelector('.edit');
if (box) box.hidden = !box.hidden; if (!box) return;
box.hidden = !box.hidden;
// В плитке раскрытая карточка занимает всю ширину, иначе поля ввода
// оказались бы в колонке шириной с ладонь.
cardEl.classList.toggle('wide', !box.hidden);
}; };
bar.appendChild(edit); bar.appendChild(edit);
drop.className = 'warn'; drop.className = 'warn';
drop.textContent = '🗑 Фото'; drop.textContent = '🗑';
drop.title = 'Убрать фото';
drop.hidden = !hasPhoto; drop.hidden = !hasPhoto;
drop.onclick = async e => { drop.onclick = async e => {
e.stopPropagation(); e.stopPropagation();
@@ -664,7 +692,6 @@ function actionBar(target, cardEl, hasPhoto) {
if (d.ok) { if (d.ok) {
const ph = cardEl.querySelector('.photo'); const ph = cardEl.querySelector('.photo');
if (ph) { ph.hidden = true; ph.innerHTML = ''; } if (ph) { ph.hidden = true; ph.innerHTML = ''; }
photo.textContent = '📷 Фото';
drop.hidden = true; drop.hidden = true;
} }
}; };
@@ -751,7 +778,28 @@ function itemEditor(it, cardEl) {
return box; return box;
} }
function render(box, items) { box.innerHTML = ''; (items || []).forEach(it => box.appendChild(card(it))); } // Раскладывает карточки плиткой. Один предмет сеткой не оформляем — выглядело
// бы как обрезанная витрина.
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() { async function doFind() {
@@ -808,8 +856,16 @@ async function openBox(code) {
// Фото самой коробки: снял, как она выглядит и что в ней лежит. // Фото самой коробки: снял, как она выглядит и что в ней лежит.
head.appendChild(actionBar({type:'container', code}, head, !!d.photo)); head.appendChild(actionBar({type:'container', code}, head, !!d.photo));
head.appendChild(boxEditor(code, head)); 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); box.prepend(head);
(d.items || []).forEach(it => box.insertBefore(card(it), head.nextSibling)); if (items.length > 1) hintIcons(grid);
window.scrollTo({top:0, behavior:'smooth'}); window.scrollTo({top:0, behavior:'smooth'});
} }