From 3f80f3fbb7c132e94e5fb659c13fd55034217ad2 Mon Sep 17 00:00:00 2001 From: Alexander Andreev Date: Sat, 21 Feb 2026 12:15:36 +0300 Subject: [PATCH] Admin: add hierarchical topics with photo tagging Introduce a new 'topics' entity with two-level nesting and a many-to-many link to photos. Add topic management in admin and inline AJAX attach/detach controls on photo cards so editors can assign or remove topics without reloading the page. --- admin.php | 285 +++++++++++++++++++++++++++++++++++++- lib/db_gallery.php | 122 ++++++++++++++++ migrations/004_topics.sql | 20 +++ 3 files changed, 424 insertions(+), 3 deletions(-) create mode 100644 migrations/004_topics.sql diff --git a/admin.php b/admin.php index f1a6570..9fd9e23 100644 --- a/admin.php +++ b/admin.php @@ -79,6 +79,25 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } } + if ($action === 'create_topic') { + $name = trim((string)($_POST['name'] ?? '')); + $sort = (int)($_POST['sort_order'] ?? 1000); + $parentId = (int)($_POST['parent_id'] ?? 0); + $parent = null; + if ($name === '') throw new RuntimeException('Название тематики пустое'); + + if ($parentId > 0) { + $parent = topicById($parentId); + if (!$parent) throw new RuntimeException('Родительская тематика не найдена'); + if (!empty($parent['parent_id'])) { + throw new RuntimeException('Разрешено только 2 уровня вложенности тематик'); + } + } + + topicCreate($name, $parentId > 0 ? $parentId : null, $sort); + $message = 'Тематика создана'; + } + if ($action === 'delete_section') { $sectionId = (int)($_POST['section_id'] ?? 0); if ($sectionId < 1) throw new RuntimeException('Некорректный раздел'); @@ -170,6 +189,46 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } } + if ($action === 'attach_photo_topic') { + $photoId = (int)($_POST['photo_id'] ?? 0); + $topicId = (int)($_POST['topic_id'] ?? 0); + if ($photoId < 1 || !photoById($photoId)) throw new RuntimeException('Фото не найдено'); + if ($topicId < 1 || !topicById($topicId)) throw new RuntimeException('Тематика не найдена'); + + photoTopicAttach($photoId, $topicId); + $topics = array_map(static fn(array $t): array => [ + 'id' => (int)$t['id'], + 'full_name' => (string)$t['full_name'], + ], photoTopicsByPhotoId($photoId)); + $message = 'Тематика добавлена'; + + if ($isAjax) { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['ok' => true, 'message' => $message, 'photo_id' => $photoId, 'topics' => $topics], JSON_UNESCAPED_UNICODE); + exit; + } + } + + if ($action === 'detach_photo_topic') { + $photoId = (int)($_POST['photo_id'] ?? 0); + $topicId = (int)($_POST['topic_id'] ?? 0); + if ($photoId < 1 || !photoById($photoId)) throw new RuntimeException('Фото не найдено'); + if ($topicId < 1) throw new RuntimeException('Тематика не найдена'); + + photoTopicDetach($photoId, $topicId); + $topics = array_map(static fn(array $t): array => [ + 'id' => (int)$t['id'], + 'full_name' => (string)$t['full_name'], + ], photoTopicsByPhotoId($photoId)); + $message = 'Тематика удалена'; + + if ($isAjax) { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['ok' => true, 'message' => $message, 'photo_id' => $photoId, 'topics' => $topics], JSON_UNESCAPED_UNICODE); + exit; + } + } + if ($action === 'photo_delete') { $photoId = (int)($_POST['photo_id'] ?? 0); if ($photoId > 0) { @@ -281,7 +340,7 @@ $adminMode = (string)($_GET['mode'] ?? 'photos'); if ($adminMode === 'media') { $adminMode = 'photos'; } -if (!in_array($adminMode, ['sections', 'photos', 'commenters', 'comments', 'welcome'], true)) { +if (!in_array($adminMode, ['sections', 'photos', 'topics', 'commenters', 'comments', 'welcome'], true)) { $adminMode = 'photos'; } $previewVersion = (string)time(); @@ -289,6 +348,21 @@ $commentPhotoQuery = trim((string)($_GET['comment_photo'] ?? ($_POST['comment_ph $commentUserQuery = trim((string)($_GET['comment_user'] ?? ($_POST['comment_user'] ?? ''))); $filteredComments = commentsSearch($commentPhotoQuery, $commentUserQuery, 200); $photoCommentCounts = commentCountsByPhotoIds(array_map(static fn(array $p): int => (int)$p['id'], $photos)); +$topics = []; +$topicRoots = []; +$photoTopicsMap = []; +$topicsError = ''; +try { + $topics = topicsAllForSelect(); + foreach ($topics as $topic) { + if ((int)$topic['level'] === 0) { + $topicRoots[] = $topic; + } + } + $photoTopicsMap = photoTopicsMapByPhotoIds(array_map(static fn(array $p): int => (int)$p['id'], $photos)); +} catch (Throwable $e) { + $topicsError = 'Тематики недоступны. Запусти миграции: php scripts/migrate.php'; +} function h(string $v): string { return htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } function assetUrl(string $path): string { $f=__DIR__ . '/' . ltrim($path,'/'); $v=is_file($f)?(string)filemtime($f):(string)time(); return $path . '?v=' . rawurlencode($v); } @@ -598,6 +672,14 @@ function nextUniqueCodeName(string $base): string .preview-actions{display:flex;gap:6px;margin-top:6px;flex-wrap:nowrap} .preview-actions form{margin:0} .is-hidden{display:none} + .topic-editor{display:grid;gap:8px;margin-top:10px} + .topic-controls{display:flex;gap:8px;align-items:center} + .topic-controls .in{min-width:0} + .topic-list{display:flex;flex-wrap:wrap;gap:6px} + .topic-chip{display:inline-flex;align-items:center;gap:6px;background:#f5f8ff;border:1px solid #dbe7ff;border-radius:999px;padding:4px 8px;font-size:12px;color:#1f3b7a;white-space:nowrap} + .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-status{font-size:12px;min-height:16px;color:#667085} .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[hidden]{display:none} @@ -627,6 +709,7 @@ function nextUniqueCodeName(string $base): string
Разделы Фото + Тематики Приветственное сообщение Пользователи комментариев Комментарии @@ -705,6 +788,49 @@ function nextUniqueCodeName(string $base): string + +
+

Создать тематику

+ +

+ +
+ +

+

+ +

+

+ +
+ +
+ +
+

Список тематик

+ +

+ +

Тематик пока нет.

+ + + + + + + + + +
ТематикаУровень
+ +
+ +

Загрузка фото “до” в выбранный раздел

@@ -722,10 +848,14 @@ function nextUniqueCodeName(string $base): string

Фото в разделе

+ +

+ +
ДоПослеПоляДействия
@@ -776,6 +906,33 @@ function nextUniqueCodeName(string $base): string

+ +
+
Тематики
+
+ + Не выбрано + + + + + + + + +
+ +
+ + +
+
+
@@ -894,11 +1051,12 @@ function nextUniqueCodeName(string $base): string if (!status) return; status.textContent = text; status.style.color = isError ? '#b42318' : '#667085'; + status.style.display = text ? 'block' : 'none'; }; + setStatus(''); const mark = () => { dirty = true; - setStatus('Есть несохранённые изменения…'); }; form.querySelectorAll('input,textarea,select').forEach((el) => { @@ -930,7 +1088,6 @@ function nextUniqueCodeName(string $base): string async function submitNow() { if (!dirty || busy) return; busy = true; - setStatus('Сохраняю…'); try { if (ajaxInput) ajaxInput.value = '1'; @@ -1174,6 +1331,128 @@ function nextUniqueCodeName(string $base): string }); }); + const setTopicStatus = (editor, text, isError = false) => { + const status = editor.querySelector('.js-topic-status'); + if (!status) return; + status.textContent = text; + status.style.color = isError ? '#b42318' : '#667085'; + }; + + const renderTopicChips = (editor, topics) => { + const list = editor.querySelector('.js-topic-list'); + if (!list) { + return; + } + + list.textContent = ''; + if (!Array.isArray(topics) || topics.length === 0) { + const empty = document.createElement('span'); + empty.className = 'topic-empty js-topic-empty'; + empty.textContent = 'Не выбрано'; + list.appendChild(empty); + return; + } + + topics.forEach((topic) => { + const chip = document.createElement('span'); + chip.className = 'topic-chip'; + chip.dataset.topicId = String(topic.id || 0); + + const label = document.createElement('span'); + label.textContent = topic.full_name || ''; + + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'js-topic-remove'; + removeBtn.dataset.topicId = String(topic.id || 0); + removeBtn.setAttribute('aria-label', 'Убрать тематику'); + removeBtn.textContent = '×'; + + chip.append(label, removeBtn); + list.appendChild(chip); + }); + }; + + const postTopicAction = async (editor, action, topicId) => { + const photoId = Number(editor.dataset.photoId || 0); + const endpoint = editor.dataset.endpoint || window.location.href; + if (photoId < 1 || topicId < 1) { + throw new Error('Некорректные параметры'); + } + + const addBtn = editor.querySelector('.js-topic-add'); + const select = editor.querySelector('.js-topic-select'); + if (addBtn) addBtn.disabled = true; + if (select) select.disabled = true; + + const fd = new FormData(); + fd.set('action', action); + fd.set('token', adminToken); + fd.set('photo_id', String(photoId)); + fd.set('topic_id', String(topicId)); + fd.set('ajax', '1'); + + try { + const r = await fetch(endpoint, { + method: 'POST', + body: fd, + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json', + }, + }); + const raw = await r.text(); + let j = null; + try { + j = JSON.parse(raw); + } catch { + throw new Error(raw.slice(0, 180) || 'Некорректный ответ сервера'); + } + + if (!r.ok || !j.ok) { + throw new Error(j?.message || 'Ошибка сохранения тематик'); + } + + renderTopicChips(editor, j.topics || []); + setTopicStatus(editor, j.message || 'Сохранено'); + if (select) { + select.value = ''; + } + } finally { + if (addBtn) addBtn.disabled = false; + if (select) select.disabled = false; + } + }; + + document.addEventListener('click', (e) => { + const addBtn = e.target.closest('.js-topic-add'); + if (addBtn) { + const editor = addBtn.closest('.js-topic-editor'); + if (!editor) return; + const select = editor.querySelector('.js-topic-select'); + const topicId = Number(select?.value || 0); + if (topicId < 1) { + setTopicStatus(editor, 'Сначала выбери тематику', true); + return; + } + + postTopicAction(editor, 'attach_photo_topic', topicId) + .catch((err) => setTopicStatus(editor, err?.message || 'Ошибка добавления', true)); + return; + } + + const removeBtn = e.target.closest('.js-topic-remove'); + if (removeBtn) { + const editor = removeBtn.closest('.js-topic-editor'); + if (!editor) return; + const topicId = Number(removeBtn.dataset.topicId || 0); + if (topicId < 1) return; + + postTopicAction(editor, 'detach_photo_topic', topicId) + .catch((err) => setTopicStatus(editor, err?.message || 'Ошибка удаления', true)); + } + }); + const closeCommentsModal = () => { if (!commentsModal) return; commentsModal.hidden = true; diff --git a/lib/db_gallery.php b/lib/db_gallery.php index 51a9984..3d84416 100644 --- a/lib/db_gallery.php +++ b/lib/db_gallery.php @@ -66,6 +66,128 @@ function photoById(int $photoId): ?array return $st->fetch() ?: null; } +function topicById(int $id): ?array +{ + $st = db()->prepare('SELECT * FROM topics WHERE id=:id'); + $st->execute(['id' => $id]); + return $st->fetch() ?: null; +} + +function topicsAllForSelect(): array +{ + $sql = 'SELECT t.id, t.parent_id, t.name, t.sort_order, + p.name AS parent_name, p.sort_order AS parent_sort_order + FROM topics t + LEFT JOIN topics p ON p.id=t.parent_id + ORDER BY + CASE WHEN t.parent_id IS NULL THEN t.sort_order ELSE p.sort_order END, + CASE WHEN t.parent_id IS NULL THEN t.name ELSE p.name END, + CASE WHEN t.parent_id IS NULL THEN 0 ELSE 1 END, + t.sort_order, + t.name'; + $rows = db()->query($sql)->fetchAll(); + + foreach ($rows as &$row) { + $isRoot = empty($row['parent_id']); + $row['level'] = $isRoot ? 0 : 1; + $row['full_name'] = $isRoot + ? (string)$row['name'] + : ((string)$row['parent_name'] . ' / ' . (string)$row['name']); + } + unset($row); + + return $rows; +} + +function topicCreate(string $name, ?int $parentId, int $sortOrder = 1000): int +{ + $st = db()->prepare('INSERT INTO topics(parent_id, name, sort_order) VALUES (:pid,:name,:sort)'); + $st->bindValue('pid', $parentId, $parentId === null ? PDO::PARAM_NULL : PDO::PARAM_INT); + $st->bindValue('name', $name, PDO::PARAM_STR); + $st->bindValue('sort', $sortOrder, PDO::PARAM_INT); + $st->execute(); + return (int)db()->lastInsertId(); +} + +function topicDelete(int $topicId): void +{ + $st = db()->prepare('DELETE FROM topics WHERE id=:id'); + $st->execute(['id' => $topicId]); +} + +function photoTopicAttach(int $photoId, int $topicId): void +{ + $st = db()->prepare('INSERT IGNORE INTO photo_topics(photo_id, topic_id) VALUES (:pid,:tid)'); + $st->execute(['pid' => $photoId, 'tid' => $topicId]); +} + +function photoTopicDetach(int $photoId, int $topicId): void +{ + $st = db()->prepare('DELETE FROM photo_topics WHERE photo_id=:pid AND topic_id=:tid'); + $st->execute(['pid' => $photoId, 'tid' => $topicId]); +} + +function photoTopicsByPhotoId(int $photoId): array +{ + $sql = 'SELECT t.id, t.parent_id, t.name, t.sort_order, + p.name AS parent_name, + CASE WHEN t.parent_id IS NULL THEN t.name ELSE CONCAT(p.name, " / ", t.name) END AS full_name + FROM photo_topics pt + JOIN topics t ON t.id=pt.topic_id + LEFT JOIN topics p ON p.id=t.parent_id + WHERE pt.photo_id=:pid + ORDER BY + CASE WHEN t.parent_id IS NULL THEN t.sort_order ELSE p.sort_order END, + CASE WHEN t.parent_id IS NULL THEN t.name ELSE p.name END, + CASE WHEN t.parent_id IS NULL THEN 0 ELSE 1 END, + t.sort_order, + t.name'; + $st = db()->prepare($sql); + $st->execute(['pid' => $photoId]); + return $st->fetchAll(); +} + +function photoTopicsMapByPhotoIds(array $photoIds): array +{ + $photoIds = array_values(array_unique(array_map('intval', $photoIds))); + if ($photoIds === []) { + return []; + } + + $placeholders = implode(',', array_fill(0, count($photoIds), '?')); + $sql = "SELECT pt.photo_id, t.id, t.parent_id, t.name, t.sort_order, + p.name AS parent_name, + CASE WHEN t.parent_id IS NULL THEN t.name ELSE CONCAT(p.name, ' / ', t.name) END AS full_name + FROM photo_topics pt + JOIN topics t ON t.id=pt.topic_id + LEFT JOIN topics p ON p.id=t.parent_id + WHERE pt.photo_id IN ($placeholders) + ORDER BY pt.photo_id, + CASE WHEN t.parent_id IS NULL THEN t.sort_order ELSE p.sort_order END, + CASE WHEN t.parent_id IS NULL THEN t.name ELSE p.name END, + CASE WHEN t.parent_id IS NULL THEN 0 ELSE 1 END, + t.sort_order, + t.name"; + + $st = db()->prepare($sql); + $st->execute($photoIds); + + $map = []; + foreach ($st->fetchAll() as $row) { + $pid = (int)$row['photo_id']; + if (!isset($map[$pid])) { + $map[$pid] = []; + } + $map[$pid][] = [ + 'id' => (int)$row['id'], + 'parent_id' => $row['parent_id'] !== null ? (int)$row['parent_id'] : null, + 'name' => (string)$row['name'], + 'full_name' => (string)$row['full_name'], + ]; + } + return $map; +} + function photoCreate(int $sectionId, string $codeName, ?string $description, int $sortOrder): int { $st = db()->prepare('INSERT INTO photos(section_id, code_name, description, sort_order) VALUES (:sid,:code,:descr,:sort)'); diff --git a/migrations/004_topics.sql b/migrations/004_topics.sql new file mode 100644 index 0000000..deabb14 --- /dev/null +++ b/migrations/004_topics.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS topics ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + parent_id BIGINT UNSIGNED NULL, + name VARCHAR(191) NOT NULL, + sort_order INT NOT NULL DEFAULT 1000, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_topics_parent FOREIGN KEY (parent_id) REFERENCES topics(id) ON DELETE CASCADE, + KEY idx_topics_parent_sort (parent_id, sort_order, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS photo_topics ( + photo_id BIGINT UNSIGNED NOT NULL, + topic_id BIGINT UNSIGNED NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (photo_id, topic_id), + CONSTRAINT fk_photo_topics_photo FOREIGN KEY (photo_id) REFERENCES photos(id) ON DELETE CASCADE, + CONSTRAINT fk_photo_topics_topic FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE, + KEY idx_photo_topics_topic (topic_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;