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.
This commit is contained in:
parent
da426974ac
commit
3f80f3fbb7
285
admin.php
285
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') {
|
if ($action === 'delete_section') {
|
||||||
$sectionId = (int)($_POST['section_id'] ?? 0);
|
$sectionId = (int)($_POST['section_id'] ?? 0);
|
||||||
if ($sectionId < 1) throw new RuntimeException('Некорректный раздел');
|
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') {
|
if ($action === 'photo_delete') {
|
||||||
$photoId = (int)($_POST['photo_id'] ?? 0);
|
$photoId = (int)($_POST['photo_id'] ?? 0);
|
||||||
if ($photoId > 0) {
|
if ($photoId > 0) {
|
||||||
|
|
@ -281,7 +340,7 @@ $adminMode = (string)($_GET['mode'] ?? 'photos');
|
||||||
if ($adminMode === 'media') {
|
if ($adminMode === 'media') {
|
||||||
$adminMode = 'photos';
|
$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';
|
$adminMode = 'photos';
|
||||||
}
|
}
|
||||||
$previewVersion = (string)time();
|
$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'] ?? '')));
|
$commentUserQuery = trim((string)($_GET['comment_user'] ?? ($_POST['comment_user'] ?? '')));
|
||||||
$filteredComments = commentsSearch($commentPhotoQuery, $commentUserQuery, 200);
|
$filteredComments = commentsSearch($commentPhotoQuery, $commentUserQuery, 200);
|
||||||
$photoCommentCounts = commentCountsByPhotoIds(array_map(static fn(array $p): int => (int)$p['id'], $photos));
|
$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 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); }
|
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{display:flex;gap:6px;margin-top:6px;flex-wrap:nowrap}
|
||||||
.preview-actions form{margin:0}
|
.preview-actions form{margin:0}
|
||||||
.is-hidden{display:none}
|
.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}
|
.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}
|
||||||
|
|
@ -627,6 +709,7 @@ function nextUniqueCodeName(string $base): string
|
||||||
<div class="sec">
|
<div class="sec">
|
||||||
<a class="<?= $adminMode==='sections'?'active':'' ?>" href="?token=<?= urlencode($tokenIncoming) ?>&mode=sections<?= $activeSectionId>0 ? '§ion_id='.(int)$activeSectionId : '' ?>">Разделы</a>
|
<a class="<?= $adminMode==='sections'?'active':'' ?>" href="?token=<?= urlencode($tokenIncoming) ?>&mode=sections<?= $activeSectionId>0 ? '§ion_id='.(int)$activeSectionId : '' ?>">Разделы</a>
|
||||||
<a class="<?= $adminMode==='photos'?'active':'' ?>" href="?token=<?= urlencode($tokenIncoming) ?>&mode=photos<?= $activeSectionId>0 ? '§ion_id='.(int)$activeSectionId : '' ?>">Фото</a>
|
<a class="<?= $adminMode==='photos'?'active':'' ?>" href="?token=<?= urlencode($tokenIncoming) ?>&mode=photos<?= $activeSectionId>0 ? '§ion_id='.(int)$activeSectionId : '' ?>">Фото</a>
|
||||||
|
<a class="<?= $adminMode==='topics'?'active':'' ?>" href="?token=<?= urlencode($tokenIncoming) ?>&mode=topics">Тематики</a>
|
||||||
<a class="<?= $adminMode==='welcome'?'active':'' ?>" href="?token=<?= urlencode($tokenIncoming) ?>&mode=welcome">Приветственное сообщение</a>
|
<a class="<?= $adminMode==='welcome'?'active':'' ?>" href="?token=<?= urlencode($tokenIncoming) ?>&mode=welcome">Приветственное сообщение</a>
|
||||||
<a class="<?= $adminMode==='commenters'?'active':'' ?>" href="?token=<?= urlencode($tokenIncoming) ?>&mode=commenters">Пользователи комментариев</a>
|
<a class="<?= $adminMode==='commenters'?'active':'' ?>" href="?token=<?= urlencode($tokenIncoming) ?>&mode=commenters">Пользователи комментариев</a>
|
||||||
<a class="<?= $adminMode==='comments'?'active':'' ?>" href="?token=<?= urlencode($tokenIncoming) ?>&mode=comments">Комментарии</a>
|
<a class="<?= $adminMode==='comments'?'active':'' ?>" href="?token=<?= urlencode($tokenIncoming) ?>&mode=comments">Комментарии</a>
|
||||||
|
|
@ -705,6 +788,49 @@ function nextUniqueCodeName(string $base): string
|
||||||
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($adminMode === 'topics'): ?>
|
||||||
|
<section class="card">
|
||||||
|
<h3>Создать тематику</h3>
|
||||||
|
<?php if ($topicsError !== ''): ?>
|
||||||
|
<p class="small" style="color:#b42318"><?= h($topicsError) ?></p>
|
||||||
|
<?php else: ?>
|
||||||
|
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>&mode=topics">
|
||||||
|
<input type="hidden" name="action" value="create_topic"><input type="hidden" name="token" value="<?= h($tokenIncoming) ?>">
|
||||||
|
<p><input class="in" name="name" placeholder="Название тематики" required></p>
|
||||||
|
<p>
|
||||||
|
<select class="in" name="parent_id">
|
||||||
|
<option value="0">Без родителя (верхний уровень)</option>
|
||||||
|
<?php foreach($topicRoots as $root): ?>
|
||||||
|
<option value="<?= (int)$root['id'] ?>">Внутри: <?= h((string)$root['name']) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
|
<p><input class="in" type="number" name="sort_order" value="1000"></p>
|
||||||
|
<button class="btn" type="submit">Создать тематику</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h3>Список тематик</h3>
|
||||||
|
<?php if ($topicsError !== ''): ?>
|
||||||
|
<p class="small" style="color:#b42318"><?= h($topicsError) ?></p>
|
||||||
|
<?php elseif ($topics === []): ?>
|
||||||
|
<p class="small">Тематик пока нет.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<table class="tbl">
|
||||||
|
<tr><th>Тематика</th><th>Уровень</th></tr>
|
||||||
|
<?php foreach($topics as $topic): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= h((string)$topic['full_name']) ?></td>
|
||||||
|
<td><?= (int)$topic['level'] === 0 ? '1' : '2' ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($adminMode === 'photos'): ?>
|
<?php if ($adminMode === 'photos'): ?>
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h3>Загрузка фото “до” в выбранный раздел</h3>
|
<h3>Загрузка фото “до” в выбранный раздел</h3>
|
||||||
|
|
@ -722,10 +848,14 @@ function nextUniqueCodeName(string $base): string
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h3>Фото в разделе</h3>
|
<h3>Фото в разделе</h3>
|
||||||
|
<?php if ($topicsError !== ''): ?>
|
||||||
|
<p class="small" style="color:#b42318"><?= h($topicsError) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
<table class="tbl">
|
<table class="tbl">
|
||||||
<tr><th>До</th><th>После</th><th>Поля</th><th>Действия</th></tr>
|
<tr><th>До</th><th>После</th><th>Поля</th><th>Действия</th></tr>
|
||||||
<?php foreach($photos as $p): ?>
|
<?php foreach($photos as $p): ?>
|
||||||
<?php $photoCommentCount = (int)($photoCommentCounts[(int)$p['id']] ?? 0); ?>
|
<?php $photoCommentCount = (int)($photoCommentCounts[(int)$p['id']] ?? 0); ?>
|
||||||
|
<?php $attachedTopics = $photoTopicsMap[(int)$p['id']] ?? []; ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<?php if (!empty($p['before_file_id'])): ?>
|
<?php if (!empty($p['before_file_id'])): ?>
|
||||||
|
|
@ -776,6 +906,33 @@ function nextUniqueCodeName(string $base): string
|
||||||
<p><label class="small" for="descr-<?= (int)$p['id'] ?>">Описание фотографии</label><textarea id="descr-<?= (int)$p['id'] ?>" class="in" name="description" placeholder="Описание фотографии"><?= h((string)($p['description'] ?? '')) ?></textarea></p>
|
<p><label class="small" for="descr-<?= (int)$p['id'] ?>">Описание фотографии</label><textarea id="descr-<?= (int)$p['id'] ?>" class="in" name="description" placeholder="Описание фотографии"><?= h((string)($p['description'] ?? '')) ?></textarea></p>
|
||||||
<div class="small js-save-status"></div>
|
<div class="small js-save-status"></div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div class="topic-editor js-topic-editor" data-photo-id="<?= (int)$p['id'] ?>" data-endpoint="<?= h('admin.php?token=' . urlencode($tokenIncoming) . '§ion_id=' . (int)$activeSectionId . '&mode=photos') ?>">
|
||||||
|
<div class="small">Тематики</div>
|
||||||
|
<div class="topic-list js-topic-list">
|
||||||
|
<?php if ($attachedTopics === []): ?>
|
||||||
|
<span class="topic-empty js-topic-empty">Не выбрано</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach($attachedTopics as $topic): ?>
|
||||||
|
<span class="topic-chip" data-topic-id="<?= (int)$topic['id'] ?>">
|
||||||
|
<?= h((string)$topic['full_name']) ?>
|
||||||
|
<button class="js-topic-remove" type="button" data-topic-id="<?= (int)$topic['id'] ?>" aria-label="Убрать тематику">×</button>
|
||||||
|
</span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topic-controls">
|
||||||
|
<select class="in js-topic-select" <?= $topics === [] ? 'disabled' : '' ?>>
|
||||||
|
<option value="">Выбери тематику</option>
|
||||||
|
<?php foreach($topics as $topic): ?>
|
||||||
|
<option value="<?= (int)$topic['id'] ?>"><?= (int)$topic['level'] === 1 ? '— ' : '' ?><?= h((string)$topic['full_name']) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-secondary btn-xs js-topic-add" type="button" <?= $topics === [] ? 'disabled' : '' ?>>Добавить</button>
|
||||||
|
</div>
|
||||||
|
<div class="topic-status js-topic-status"></div>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
|
|
@ -894,11 +1051,12 @@ function nextUniqueCodeName(string $base): string
|
||||||
if (!status) return;
|
if (!status) return;
|
||||||
status.textContent = text;
|
status.textContent = text;
|
||||||
status.style.color = isError ? '#b42318' : '#667085';
|
status.style.color = isError ? '#b42318' : '#667085';
|
||||||
|
status.style.display = text ? 'block' : 'none';
|
||||||
};
|
};
|
||||||
|
setStatus('');
|
||||||
|
|
||||||
const mark = () => {
|
const mark = () => {
|
||||||
dirty = true;
|
dirty = true;
|
||||||
setStatus('Есть несохранённые изменения…');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
form.querySelectorAll('input,textarea,select').forEach((el) => {
|
form.querySelectorAll('input,textarea,select').forEach((el) => {
|
||||||
|
|
@ -930,7 +1088,6 @@ function nextUniqueCodeName(string $base): string
|
||||||
async function submitNow() {
|
async function submitNow() {
|
||||||
if (!dirty || busy) return;
|
if (!dirty || busy) return;
|
||||||
busy = true;
|
busy = true;
|
||||||
setStatus('Сохраняю…');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (ajaxInput) ajaxInput.value = '1';
|
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 = () => {
|
const closeCommentsModal = () => {
|
||||||
if (!commentsModal) return;
|
if (!commentsModal) return;
|
||||||
commentsModal.hidden = true;
|
commentsModal.hidden = true;
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,128 @@ function photoById(int $photoId): ?array
|
||||||
return $st->fetch() ?: null;
|
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
|
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)');
|
$st = db()->prepare('INSERT INTO photos(section_id, code_name, description, sort_order) VALUES (:sid,:code,:descr,:sort)');
|
||||||
|
|
|
||||||
20
migrations/004_topics.sql
Normal file
20
migrations/004_topics.sql
Normal file
|
|
@ -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;
|
||||||
Loading…
Reference in New Issue
Block a user