Admin/Public: refine topics workflow and gallery topic nav

Switch topic editing in admin to autosave-on-change with tree-based controls where parent selection is create-only and delete remains explicit. Add public sidebar navigation by topics with sort-index ordering, preserve topic context in photo navigation/comments, and show stable AI/comment badges on catalog cards.
This commit is contained in:
Alexander Andreev 2026-02-21 12:40:39 +03:00
parent 2d1f37d9eb
commit 0c2f0c2737
3 changed files with 170 additions and 19 deletions

View File

@ -112,6 +112,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$currentParentId = isset($topic['parent_id']) && $topic['parent_id'] !== null ? (int)$topic['parent_id'] : null;
topicUpdate($topicId, $name, $currentParentId, $sort);
$message = 'Тематика обновлена';
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => true, 'message' => $message], JSON_UNESCAPED_UNICODE);
exit;
}
}
if ($action === 'delete_topic') {
@ -737,7 +742,7 @@ function nextUniqueCodeName(string $base): string
.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{display:grid;grid-template-columns:minmax(180px,1fr) 110px;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%}}
@ -883,11 +888,11 @@ function nextUniqueCodeName(string $base): string
<?php foreach($topicTree as $root): ?>
<div class="topic-node level-1">
<p class="topic-node-head">Уровень 1</p>
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>&mode=topics" class="topic-row">
<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'] ?>">
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>&mode=topics" class="topic-row js-topic-form">
<input type="hidden" name="action" value="update_topic"><input type="hidden" name="ajax" value="1"><input type="hidden" name="token" value="<?= h($tokenIncoming) ?>"><input type="hidden" name="topic_id" value="<?= (int)$root['id'] ?>">
<input class="in" type="text" name="name" value="<?= h((string)$root['name']) ?>" required>
<input class="in" type="number" name="sort_order" value="<?= (int)$root['sort_order'] ?>">
<button class="btn btn-secondary" type="submit">Сохранить</button>
<div class="small js-save-status" style="grid-column:1 / -1"></div>
</form>
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>&mode=topics" style="margin-top:8px">
<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'] ?>">
@ -899,11 +904,11 @@ function nextUniqueCodeName(string $base): string
<?php foreach($root['children'] as $child): ?>
<div class="topic-node level-2">
<p class="topic-node-head">Уровень 2 · внутри «<?= h((string)$root['name']) ?>»</p>
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>&mode=topics" class="topic-row">
<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'] ?>">
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>&mode=topics" class="topic-row js-topic-form">
<input type="hidden" name="action" value="update_topic"><input type="hidden" name="ajax" value="1"><input type="hidden" name="token" value="<?= h($tokenIncoming) ?>"><input type="hidden" name="topic_id" value="<?= (int)$child['id'] ?>">
<input class="in" type="text" name="name" value="<?= h((string)$child['name']) ?>" required>
<input class="in" type="number" name="sort_order" value="<?= (int)$child['sort_order'] ?>">
<button class="btn btn-secondary" type="submit">Сохранить</button>
<div class="small js-save-status" style="grid-column:1 / -1"></div>
</form>
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>&mode=topics" style="margin-top:8px">
<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'] ?>">
@ -1216,6 +1221,7 @@ function nextUniqueCodeName(string $base): string
setupAutosave('.js-photo-form');
setupAutosave('.js-section-form');
setupAutosave('.js-topic-form');
window.confirmSectionDelete = () => {
const first = confirm('Удалить раздел?');

View File

@ -15,6 +15,8 @@ $viewer = $viewerToken !== '' ? commenterByToken($viewerToken) : null;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string)($_POST['action'] ?? '') === 'add_comment') {
$token = trim((string)($_POST['viewer'] ?? ''));
$photoId = (int)($_POST['photo_id'] ?? 0);
$sectionId = (int)($_POST['section_id'] ?? 0);
$topicId = (int)($_POST['topic_id'] ?? 0);
$text = trim((string)($_POST['comment_text'] ?? ''));
if ($token !== '' && $photoId > 0 && $text !== '') {
@ -25,6 +27,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string)($_POST['action'] ?? '') ==
}
$redirect = './?photo_id=' . $photoId;
if ($sectionId > 0) {
$redirect .= '&section_id=' . $sectionId;
}
if ($topicId > 0) {
$redirect .= '&topic_id=' . $topicId;
}
if ($token !== '') {
$redirect .= '&viewer=' . urlencode($token);
}
@ -35,11 +43,34 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string)($_POST['action'] ?? '') ==
$sections = sectionsAll();
$activeSectionId = (int)($_GET['section_id'] ?? 0);
$activePhotoId = (int)($_GET['photo_id'] ?? 0);
$activeTopicId = (int)($_GET['topic_id'] ?? 0);
$welcomeText = settingGet('welcome_text', 'Добро пожаловать в галерею. Выберите раздел слева, чтобы посмотреть фотографии.');
$photo = $activePhotoId > 0 ? photoById($activePhotoId) : null;
if ($photo && $activeSectionId < 1) {
$activeSectionId = (int)$photo['section_id'];
}
$comments = $photo ? commentsByPhoto($activePhotoId) : [];
$photos = $activeSectionId > 0 ? photosBySection($activeSectionId) : [];
$topics = [];
$topicCounts = [];
try {
$topics = topicsAllForSelect();
if ($activeTopicId > 0) {
if (!topicById($activeTopicId)) {
$activeTopicId = 0;
}
}
$topicCounts = topicPhotoCounts($activeSectionId > 0 ? $activeSectionId : null);
} catch (Throwable) {
$topics = [];
$topicCounts = [];
$activeTopicId = 0;
}
$photos = ($activeSectionId > 0 || $activeTopicId > 0)
? photosForPublic($activeSectionId > 0 ? $activeSectionId : null, $activeTopicId > 0 ? $activeTopicId : null)
: [];
$photoCommentCounts = photoCommentCountsByPhotoIds(array_map(static fn(array $p): int => (int)$p['id'], $photos));
$isHomePage = $activeSectionId < 1 && $activePhotoId < 1;
$detailTotal = 0;
@ -49,7 +80,20 @@ $nextPhotoId = 0;
$detailSectionId = 0;
if ($photo) {
$detailSectionId = (int)$photo['section_id'];
$detailPhotos = photosBySection($detailSectionId);
$detailPhotos = photosForPublic($detailSectionId, $activeTopicId > 0 ? $activeTopicId : null);
if ($activeTopicId > 0 && $detailPhotos !== []) {
$foundInTopic = false;
foreach ($detailPhotos as $d) {
if ((int)$d['id'] === $activePhotoId) {
$foundInTopic = true;
break;
}
}
if (!$foundInTopic) {
$detailPhotos = photosForPublic($detailSectionId, null);
$activeTopicId = 0;
}
}
$detailTotal = count($detailPhotos);
foreach ($detailPhotos as $i => $p) {
if ((int)$p['id'] !== $activePhotoId) {
@ -187,8 +231,17 @@ function outputWatermarked(string $path, string $mime): never
.sec{display:grid;gap:6px}
.sec a{display:block;padding:10px 12px;border-radius:10px;line-height:1.35;text-decoration:none;color:#111}
.sec a.active{background:#eef4ff;color:#1f6feb}
.topic-nav{margin-top:12px;padding-top:12px;border-top:1px solid #e8edf5;display:grid;gap:6px}
.topic-link{display:block;padding:8px 10px;border-radius:8px;text-decoration:none;color:#111;font-size:13px;line-height:1.3}
.topic-link.level-1{padding-left:20px}
.topic-link.active{background:#edf7f0;color:#146236}
.topic-link.disabled{opacity:.55}
.cards{display:grid;gap:10px;grid-template-columns:repeat(auto-fill,minmax(180px,1fr))}
.card{border:1px solid #e5e7eb;border-radius:10px;overflow:hidden;background:#fff}
.card-badges{position:absolute;top:8px;right:8px;display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end;z-index:4;pointer-events:none}
.card-badge{display:inline-flex;align-items:center;justify-content:center;background:rgba(17,24,39,.78);color:#fff;font-size:11px;line-height:1;padding:6px 7px;border-radius:999px}
.card-badge.ai{background:rgba(31,111,235,.92)}
.card-badge.comments{background:rgba(3,105,161,.9)}
.card img{width:100%;height:130px;object-fit:cover}
.cap{padding:8px;font-size:13px}
.detail img{max-width:100%;border-radius:10px;border:1px solid #e5e7eb}
@ -261,8 +314,20 @@ function outputWatermarked(string $path, string $mime): never
<?php endif; ?>
</div>
<?php foreach($sections as $s): ?>
<a class="<?= (int)$s['id']===$activeSectionId?'active':'' ?>" href="?section_id=<?= (int)$s['id'] ?><?= $viewerToken!=='' ? '&viewer=' . urlencode($viewerToken) : '' ?>"><?= h((string)$s['name']) ?> <span class="muted">(<?= (int)$s['photos_count'] ?>)</span></a>
<a class="<?= (int)$s['id']===$activeSectionId?'active':'' ?>" href="?section_id=<?= (int)$s['id'] ?><?= $activeTopicId > 0 ? '&topic_id=' . $activeTopicId : '' ?><?= $viewerToken!=='' ? '&viewer=' . urlencode($viewerToken) : '' ?>"><?= h((string)$s['name']) ?> <span class="muted">(<?= (int)$s['photos_count'] ?>)</span></a>
<?php endforeach; ?>
<?php if ($topics !== []): ?>
<div class="topic-nav">
<strong style="font-size:13px">Тематики</strong>
<a class="topic-link<?= $activeTopicId < 1 ? ' active' : '' ?>" href="?<?= $activeSectionId > 0 ? 'section_id=' . $activeSectionId : '' ?><?= $viewerToken!=='' ? (($activeSectionId > 0 ? '&' : '') . 'viewer=' . urlencode($viewerToken)) : '' ?>">Все тематики</a>
<?php foreach($topics as $t): ?>
<?php $topicCount = (int)($topicCounts[(int)$t['id']] ?? 0); ?>
<?php if ($activeSectionId > 0 && $topicCount < 1) continue; ?>
<a class="topic-link level-<?= (int)$t['level'] ?><?= (int)$t['id'] === $activeTopicId ? ' active' : '' ?>" href="?<?= $activeSectionId > 0 ? 'section_id=' . $activeSectionId . '&' : '' ?>topic_id=<?= (int)$t['id'] ?><?= $viewerToken!=='' ? '&viewer=' . urlencode($viewerToken) : '' ?>"><?= (int)$t['level'] === 1 ? '— ' : '' ?><?= h((string)$t['full_name']) ?> <span class="muted">(<?= $topicCount ?>)</span></a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<p class="note" style="margin-top:12px"><?= $viewer ? 'Вы авторизованы для комментариев: ' . h((string)$viewer['display_name']) : 'Режим просмотра' ?></p>
</aside>
<main>
@ -277,9 +342,11 @@ function outputWatermarked(string $path, string $mime): never
<h3 style="margin-top:16px">Комментарии</h3>
<?php if ($viewer): ?>
<form method="post" action="?photo_id=<?= (int)$photo['id'] ?>&viewer=<?= urlencode($viewerToken) ?>">
<form method="post" action="?photo_id=<?= (int)$photo['id'] ?>&section_id=<?= (int)$detailSectionId ?><?= $activeTopicId > 0 ? '&topic_id=' . $activeTopicId : '' ?>&viewer=<?= urlencode($viewerToken) ?>">
<input type="hidden" name="action" value="add_comment">
<input type="hidden" name="photo_id" value="<?= (int)$photo['id'] ?>">
<input type="hidden" name="section_id" value="<?= (int)$detailSectionId ?>">
<input type="hidden" name="topic_id" value="<?= (int)$activeTopicId ?>">
<input type="hidden" name="viewer" value="<?= h($viewerToken) ?>">
<textarea name="comment_text" required style="width:100%;min-height:80px;border:1px solid #d1d5db;border-radius:8px;padding:8px"></textarea>
<p><button class="btn" type="submit">Отправить</button></p>
@ -296,8 +363,8 @@ function outputWatermarked(string $path, string $mime): never
<div class="pager">
<div class="muted">Фото <?= (int)$detailIndex ?> из <?= (int)$detailTotal ?></div>
<div class="pager-actions">
<a class="pager-link<?= $prevPhotoId < 1 ? ' disabled' : '' ?>" href="?photo_id=<?= (int)$prevPhotoId ?>&section_id=<?= (int)$detailSectionId ?><?= $viewerToken!=='' ? '&viewer=' . urlencode($viewerToken) : '' ?>"> Предыдущее</a>
<a class="pager-link<?= $nextPhotoId < 1 ? ' disabled' : '' ?>" href="?photo_id=<?= (int)$nextPhotoId ?>&section_id=<?= (int)$detailSectionId ?><?= $viewerToken!=='' ? '&viewer=' . urlencode($viewerToken) : '' ?>">Следующее </a>
<a class="pager-link<?= $prevPhotoId < 1 ? ' disabled' : '' ?>" href="?photo_id=<?= (int)$prevPhotoId ?>&section_id=<?= (int)$detailSectionId ?><?= $activeTopicId > 0 ? '&topic_id=' . $activeTopicId : '' ?><?= $viewerToken!=='' ? '&viewer=' . urlencode($viewerToken) : '' ?>"> Предыдущее</a>
<a class="pager-link<?= $nextPhotoId < 1 ? ' disabled' : '' ?>" href="?photo_id=<?= (int)$nextPhotoId ?>&section_id=<?= (int)$detailSectionId ?><?= $activeTopicId > 0 ? '&topic_id=' . $activeTopicId : '' ?><?= $viewerToken!=='' ? '&viewer=' . urlencode($viewerToken) : '' ?>">Следующее </a>
</div>
</div>
<?php endif; ?>
@ -305,16 +372,20 @@ function outputWatermarked(string $path, string $mime): never
<?php else: ?>
<section class="panel">
<h3>Фотографии</h3>
<?php if ($activeSectionId < 1): ?>
<?php if ($activeSectionId < 1 && $activeTopicId < 1): ?>
<p class="muted"><?= nl2br(h($welcomeText)) ?></p>
<?php elseif ($photos === []): ?>
<p class="muted">В разделе пока нет фотографий.</p>
<?php else: ?>
<div class="cards">
<?php foreach($photos as $p): ?>
<a class="card" href="?photo_id=<?= (int)$p['id'] ?>&section_id=<?= (int)$activeSectionId ?><?= $viewerToken!=='' ? '&viewer=' . urlencode($viewerToken) : '' ?>" style="text-decoration:none;color:inherit;position:relative">
<?php $cardCommentCount = (int)($photoCommentCounts[(int)$p['id']] ?? 0); ?>
<a class="card" href="?photo_id=<?= (int)$p['id'] ?>&section_id=<?= (int)$p['section_id'] ?><?= $activeTopicId > 0 ? '&topic_id=' . $activeTopicId : '' ?><?= $viewerToken!=='' ? '&viewer=' . urlencode($viewerToken) : '' ?>" style="text-decoration:none;color:inherit;position:relative">
<?php if (!empty($p['before_file_id'])): ?><div class="img-box thumb-img-box"><img src="?action=image&file_id=<?= (int)$p['before_file_id'] ?>" alt="" loading="lazy" decoding="async" fetchpriority="low"></div><?php endif; ?>
<?php if (!empty($p['after_file_id'])): ?><span title="Есть обработанная версия" style="position:absolute;top:8px;right:8px;background:rgba(31,111,235,.92);color:#fff;font-size:11px;line-height:1;padding:6px 7px;border-radius:999px">AI</span><?php endif; ?>
<div class="card-badges">
<?php if ($cardCommentCount > 0): ?><span class="card-badge comments" title="Комментариев: <?= $cardCommentCount ?>">💬 <?= $cardCommentCount ?></span><?php endif; ?>
<?php if (!empty($p['after_file_id'])): ?><span class="card-badge ai" title="Есть обработанная версия">AI</span><?php endif; ?>
</div>
<div class="cap"><strong><?= h((string)$p['code_name']) ?></strong><br><span class="muted"><?= h((string)($p['description'] ?? '')) ?></span></div>
</a>
<?php endforeach; ?>
@ -331,10 +402,10 @@ function outputWatermarked(string $path, string $mime): never
</div>
<?php if ($hasMobilePhotoNav): ?>
<nav class="mobile-photo-nav" aria-label="Навигация по фото">
<a class="mobile-nav-link" href="?section_id=<?= (int)$detailSectionId ?><?= $viewerToken!=='' ? '&viewer=' . urlencode($viewerToken) : '' ?>">К разделу</a>
<a class="mobile-nav-link" href="?section_id=<?= (int)$detailSectionId ?><?= $activeTopicId > 0 ? '&topic_id=' . $activeTopicId : '' ?><?= $viewerToken!=='' ? '&viewer=' . urlencode($viewerToken) : '' ?>">К разделу</a>
<div class="mobile-nav-meta">Фото <?= (int)$detailIndex ?> из <?= (int)$detailTotal ?></div>
<a class="mobile-nav-link<?= $prevPhotoId < 1 ? ' disabled' : '' ?>" href="?photo_id=<?= (int)$prevPhotoId ?>&section_id=<?= (int)$detailSectionId ?><?= $viewerToken!=='' ? '&viewer=' . urlencode($viewerToken) : '' ?>" aria-disabled="<?= $prevPhotoId < 1 ? 'true' : 'false' ?>"></a>
<a class="mobile-nav-link<?= $nextPhotoId < 1 ? ' disabled' : '' ?>" href="?photo_id=<?= (int)$nextPhotoId ?>&section_id=<?= (int)$detailSectionId ?><?= $viewerToken!=='' ? '&viewer=' . urlencode($viewerToken) : '' ?>" aria-disabled="<?= $nextPhotoId < 1 ? 'true' : 'false' ?>"></a>
<a class="mobile-nav-link<?= $prevPhotoId < 1 ? ' disabled' : '' ?>" href="?photo_id=<?= (int)$prevPhotoId ?>&section_id=<?= (int)$detailSectionId ?><?= $activeTopicId > 0 ? '&topic_id=' . $activeTopicId : '' ?><?= $viewerToken!=='' ? '&viewer=' . urlencode($viewerToken) : '' ?>" aria-disabled="<?= $prevPhotoId < 1 ? 'true' : 'false' ?>"></a>
<a class="mobile-nav-link<?= $nextPhotoId < 1 ? ' disabled' : '' ?>" href="?photo_id=<?= (int)$nextPhotoId ?>&section_id=<?= (int)$detailSectionId ?><?= $activeTopicId > 0 ? '&topic_id=' . $activeTopicId : '' ?><?= $viewerToken!=='' ? '&viewer=' . urlencode($viewerToken) : '' ?>" aria-disabled="<?= $nextPhotoId < 1 ? 'true' : 'false' ?>"></a>
</nav>
<?php endif; ?>
<script>

View File

@ -52,6 +52,38 @@ function photosBySection(int $sectionId): array
return $st->fetchAll();
}
function photosForPublic(?int $sectionId = null, ?int $topicId = null): array
{
$sql = 'SELECT p.*,
bf.id AS before_file_id, bf.file_path AS before_path,
af.id AS after_file_id, af.file_path AS after_path
FROM photos p
LEFT JOIN photo_files bf ON bf.photo_id=p.id AND bf.kind="before"
LEFT JOIN photo_files af ON af.photo_id=p.id AND af.kind="after"';
$where = [];
$params = [];
if ($topicId !== null && $topicId > 0) {
$sql .= ' INNER JOIN photo_topics ptf ON ptf.photo_id=p.id AND ptf.topic_id=:tid';
$params['tid'] = $topicId;
}
if ($sectionId !== null && $sectionId > 0) {
$where[] = 'p.section_id=:sid';
$params['sid'] = $sectionId;
}
if ($where !== []) {
$sql .= ' WHERE ' . implode(' AND ', $where);
}
$sql .= ' ORDER BY p.sort_order, p.id DESC';
$st = db()->prepare($sql);
$st->execute($params);
return $st->fetchAll();
}
function photoById(int $photoId): ?array
{
$sql = 'SELECT p.*,
@ -66,6 +98,48 @@ function photoById(int $photoId): ?array
return $st->fetch() ?: null;
}
function photoCommentCountsByPhotoIds(array $photoIds): array
{
$photoIds = array_values(array_unique(array_map('intval', $photoIds)));
if ($photoIds === []) {
return [];
}
$placeholders = implode(',', array_fill(0, count($photoIds), '?'));
$sql = "SELECT photo_id, COUNT(*) AS cnt FROM photo_comments WHERE photo_id IN ($placeholders) GROUP BY photo_id";
$st = db()->prepare($sql);
$st->execute($photoIds);
$map = [];
foreach ($st->fetchAll() as $row) {
$map[(int)$row['photo_id']] = (int)$row['cnt'];
}
return $map;
}
function topicPhotoCounts(?int $sectionId = null): array
{
$sql = 'SELECT pt.topic_id, COUNT(DISTINCT p.id) AS cnt
FROM photo_topics pt
JOIN photos p ON p.id=pt.photo_id';
$params = [];
if ($sectionId !== null && $sectionId > 0) {
$sql .= ' WHERE p.section_id=:sid';
$params['sid'] = $sectionId;
}
$sql .= ' GROUP BY pt.topic_id';
$st = db()->prepare($sql);
$st->execute($params);
$map = [];
foreach ($st->fetchAll() as $row) {
$map[(int)$row['topic_id']] = (int)$row['cnt'];
}
return $map;
}
function topicById(int $id): ?array
{
$st = db()->prepare('SELECT * FROM topics WHERE id=:id');