Admin: switch topics to tree editing layout

Render topics as a two-level tree so editing is clearer and limit updates to name/sort while parent selection stays create-only. Keep topic deletion safe with FK cascades and ensure photo tag links are cleaned automatically when a topic is removed.
This commit is contained in:
Alexander Andreev 2026-02-21 12:29:28 +03:00
parent f9b949a7bf
commit 2d1f37d9eb
2 changed files with 74 additions and 54 deletions

121
admin.php
View File

@ -102,7 +102,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$topicId = (int)($_POST['topic_id'] ?? 0); $topicId = (int)($_POST['topic_id'] ?? 0);
$name = trim((string)($_POST['name'] ?? '')); $name = trim((string)($_POST['name'] ?? ''));
$sort = (int)($_POST['sort_order'] ?? 1000); $sort = (int)($_POST['sort_order'] ?? 1000);
$parentId = (int)($_POST['parent_id'] ?? 0);
if ($topicId < 1) throw new RuntimeException('Некорректная тематика'); if ($topicId < 1) throw new RuntimeException('Некорректная тематика');
if ($name === '') throw new RuntimeException('Название тематики пустое'); if ($name === '') throw new RuntimeException('Название тематики пустое');
@ -110,22 +109,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$topic = topicById($topicId); $topic = topicById($topicId);
if (!$topic) throw new RuntimeException('Тематика не найдена'); if (!$topic) throw new RuntimeException('Тематика не найдена');
if ($parentId === $topicId) { $currentParentId = isset($topic['parent_id']) && $topic['parent_id'] !== null ? (int)$topic['parent_id'] : null;
throw new RuntimeException('Тематика не может быть родителем самой себя'); topicUpdate($topicId, $name, $currentParentId, $sort);
}
if ($parentId > 0) {
$parent = topicById($parentId);
if (!$parent) throw new RuntimeException('Родительская тематика не найдена');
if (!empty($parent['parent_id'])) {
throw new RuntimeException('Разрешено только 2 уровня вложенности тематик');
}
if (topicChildrenCount($topicId) > 0) {
throw new RuntimeException('Тематика с дочерними элементами должна оставаться в верхнем уровне');
}
}
topicUpdate($topicId, $name, $parentId > 0 ? $parentId : null, $sort);
$message = 'Тематика обновлена'; $message = 'Тематика обновлена';
} }
@ -391,6 +376,7 @@ $photoCommentCounts = commentCountsByPhotoIds(array_map(static fn(array $p): int
$topics = []; $topics = [];
$topicRoots = []; $topicRoots = [];
$photoTopicsMap = []; $photoTopicsMap = [];
$topicTree = [];
$topicsError = ''; $topicsError = '';
try { try {
$topics = topicsAllForSelect(); $topics = topicsAllForSelect();
@ -399,6 +385,7 @@ try {
$topicRoots[] = $topic; $topicRoots[] = $topic;
} }
} }
$topicTree = buildTopicTree($topics);
$photoTopicsMap = photoTopicsMapByPhotoIds(array_map(static fn(array $p): int => (int)$p['id'], $photos)); $photoTopicsMap = photoTopicsMapByPhotoIds(array_map(static fn(array $p): int => (int)$p['id'], $photos));
} catch (Throwable $e) { } catch (Throwable $e) {
$topicsError = 'Тематики недоступны. Запусти миграции: php scripts/migrate.php'; $topicsError = 'Тематики недоступны. Запусти миграции: php scripts/migrate.php';
@ -455,6 +442,32 @@ function commentsSearch(string $photoQuery, string $userQuery, int $limit = 200)
return $st->fetchAll(); return $st->fetchAll();
} }
function buildTopicTree(array $topics): array
{
$roots = [];
$children = [];
foreach ($topics as $topic) {
$pid = isset($topic['parent_id']) && $topic['parent_id'] !== null ? (int)$topic['parent_id'] : 0;
if ($pid === 0) {
$roots[] = $topic;
continue;
}
if (!isset($children[$pid])) {
$children[$pid] = [];
}
$children[$pid][] = $topic;
}
foreach ($roots as &$root) {
$rootId = (int)$root['id'];
$root['children'] = $children[$rootId] ?? [];
}
unset($root);
return $roots;
}
function saveBulkBefore(array $files, int $sectionId): array function saveBulkBefore(array $files, int $sectionId): array
{ {
$ok = 0; $ok = 0;
@ -720,6 +733,14 @@ function nextUniqueCodeName(string $base): string
.topic-chip button{border:0;background:transparent;color:#a11b1b;cursor:pointer;font-size:14px;line-height:1;padding:0} .topic-chip button{border:0;background:transparent;color:#a11b1b;cursor:pointer;font-size:14px;line-height:1;padding:0}
.topic-empty{font-size:12px;color:#667085} .topic-empty{font-size:12px;color:#667085}
.topic-status{font-size:12px;min-height:16px;color:#667085} .topic-status{font-size:12px;min-height:16px;color:#667085}
.topic-tree{display:grid;gap:10px}
.topic-node{border:1px solid #e5e7eb;border-radius:10px;padding:10px;background:#fff}
.topic-node.level-2{margin-left:20px;border-color:#edf2fb;background:#fbfdff}
.topic-node-head{font-size:12px;color:#667085;margin:0 0 8px}
.topic-row{display:grid;grid-template-columns:minmax(180px,1fr) 110px auto;gap:8px;align-items:center}
.topic-row .btn{height:36px}
.topic-children{display:grid;gap:8px;margin-top:8px}
@media (max-width:900px){.topic-row{grid-template-columns:1fr 110px}.topic-row .btn{width:100%}}
.row-actions{display:flex;flex-direction:column;align-items:flex-start;gap:8px} .row-actions{display:flex;flex-direction:column;align-items:flex-start;gap:8px}
.modal{position:fixed;inset:0;z-index:90;display:flex;align-items:center;justify-content:center;padding:16px} .modal{position:fixed;inset:0;z-index:90;display:flex;align-items:center;justify-content:center;padding:16px}
.modal[hidden]{display:none} .modal[hidden]{display:none}
@ -858,37 +879,43 @@ function nextUniqueCodeName(string $base): string
<?php elseif ($topics === []): ?> <?php elseif ($topics === []): ?>
<p class="small">Тематик пока нет.</p> <p class="small">Тематик пока нет.</p>
<?php else: ?> <?php else: ?>
<table class="tbl"> <div class="topic-tree">
<tr><th>Тематика</th><th>Уровень</th><th>Действия</th></tr> <?php foreach($topicTree as $root): ?>
<?php foreach($topics as $topic): ?> <div class="topic-node level-1">
<tr> <p class="topic-node-head">Уровень 1</p>
<td> <form method="post" action="?token=<?= urlencode($tokenIncoming) ?>&mode=topics" class="topic-row">
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>&mode=topics" style="display:grid;gap:8px;min-width:320px"> <input type="hidden" name="action" value="update_topic"><input type="hidden" name="token" value="<?= h($tokenIncoming) ?>"><input type="hidden" name="topic_id" value="<?= (int)$root['id'] ?>">
<input type="hidden" name="action" value="update_topic"><input type="hidden" name="token" value="<?= h($tokenIncoming) ?>"><input type="hidden" name="topic_id" value="<?= (int)$topic['id'] ?>"> <input class="in" type="text" name="name" value="<?= h((string)$root['name']) ?>" required>
<input class="in" type="text" name="name" value="<?= h((string)$topic['name']) ?>" required> <input class="in" type="number" name="sort_order" value="<?= (int)$root['sort_order'] ?>">
<div style="display:grid;grid-template-columns:1fr 110px;gap:8px"> <button class="btn btn-secondary" type="submit">Сохранить</button>
<select class="in" name="parent_id"> </form>
<option value="0" <?= empty($topic['parent_id']) ? 'selected' : '' ?>>Без родителя</option> <form method="post" action="?token=<?= urlencode($tokenIncoming) ?>&mode=topics" style="margin-top:8px">
<?php foreach($topicRoots as $root): ?> <input type="hidden" name="action" value="delete_topic"><input type="hidden" name="token" value="<?= h($tokenIncoming) ?>"><input type="hidden" name="topic_id" value="<?= (int)$root['id'] ?>">
<?php if ((int)$root['id'] === (int)$topic['id']) continue; ?> <button class="btn btn-danger" type="submit" onclick="return confirm('Удалить тематику? Дочерние тематики и привязки к фото тоже удалятся.')">Удалить</button>
<option value="<?= (int)$root['id'] ?>" <?= (int)$topic['parent_id'] === (int)$root['id'] ? 'selected' : '' ?>>Внутри: <?= h((string)$root['name']) ?></option> </form>
<?php endforeach; ?>
</select> <?php if (!empty($root['children'])): ?>
<input class="in" type="number" name="sort_order" value="<?= (int)$topic['sort_order'] ?>"> <div class="topic-children">
</div> <?php foreach($root['children'] as $child): ?>
<button class="btn btn-secondary" type="submit">Сохранить</button> <div class="topic-node level-2">
</form> <p class="topic-node-head">Уровень 2 · внутри «<?= h((string)$root['name']) ?>»</p>
</td> <form method="post" action="?token=<?= urlencode($tokenIncoming) ?>&mode=topics" class="topic-row">
<td><?= (int)$topic['level'] === 0 ? '1' : '2' ?></td> <input type="hidden" name="action" value="update_topic"><input type="hidden" name="token" value="<?= h($tokenIncoming) ?>"><input type="hidden" name="topic_id" value="<?= (int)$child['id'] ?>">
<td> <input class="in" type="text" name="name" value="<?= h((string)$child['name']) ?>" required>
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>&mode=topics" onsubmit="return confirm('Удалить тематику? Дочерние тематики и привязки к фото тоже удалятся.')"> <input class="in" type="number" name="sort_order" value="<?= (int)$child['sort_order'] ?>">
<input type="hidden" name="action" value="delete_topic"><input type="hidden" name="token" value="<?= h($tokenIncoming) ?>"><input type="hidden" name="topic_id" value="<?= (int)$topic['id'] ?>"> <button class="btn btn-secondary" type="submit">Сохранить</button>
<button class="btn btn-danger" type="submit">Удалить</button> </form>
</form> <form method="post" action="?token=<?= urlencode($tokenIncoming) ?>&mode=topics" style="margin-top:8px">
</td> <input type="hidden" name="action" value="delete_topic"><input type="hidden" name="token" value="<?= h($tokenIncoming) ?>"><input type="hidden" name="topic_id" value="<?= (int)$child['id'] ?>">
</tr> <button class="btn btn-danger" type="submit" onclick="return confirm('Удалить тематику?')">Удалить</button>
</form>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?> <?php endforeach; ?>
</table> </div>
<?php endif; ?> <?php endif; ?>
</section> </section>
<?php endif; ?> <?php endif; ?>

View File

@ -119,13 +119,6 @@ function topicUpdate(int $topicId, string $name, ?int $parentId, int $sortOrder
$st->execute(); $st->execute();
} }
function topicChildrenCount(int $topicId): int
{
$st = db()->prepare('SELECT COUNT(*) FROM topics WHERE parent_id=:id');
$st->execute(['id' => $topicId]);
return (int)$st->fetchColumn();
}
function topicDelete(int $topicId): void function topicDelete(int $topicId): void
{ {
$st = db()->prepare('DELETE FROM topics WHERE id=:id'); $st = db()->prepare('DELETE FROM topics WHERE id=:id');