Admin/Public: improve comments UX and photo navigation
Split comment users and comment moderation into separate admin sections, and add comment search by photo and user for faster moderation. Rework public photo viewing with full-frame repeated watermarking, in-section prev/next navigation, sticky desktop sidebar, and a mobile bottom nav with section return and photo position.
This commit is contained in:
parent
25aae39bd8
commit
c94dc1e73e
112
admin.php
112
admin.php
|
|
@ -240,25 +240,18 @@ if (!$activeSection && $sections !== []) {
|
||||||
}
|
}
|
||||||
$photos = $activeSectionId > 0 ? photosBySection($activeSectionId) : [];
|
$photos = $activeSectionId > 0 ? photosBySection($activeSectionId) : [];
|
||||||
$commenters = commentersAll();
|
$commenters = commentersAll();
|
||||||
$latestComments = commentsLatest(80);
|
|
||||||
$welcomeText = settingGet('welcome_text', 'Добро пожаловать в галерею. Выберите раздел слева, чтобы посмотреть фотографии.');
|
$welcomeText = settingGet('welcome_text', 'Добро пожаловать в галерею. Выберите раздел слева, чтобы посмотреть фотографии.');
|
||||||
$adminMode = (string)($_GET['mode'] ?? 'photos');
|
$adminMode = (string)($_GET['mode'] ?? 'photos');
|
||||||
if ($adminMode === 'media') {
|
if ($adminMode === 'media') {
|
||||||
$adminMode = 'photos';
|
$adminMode = 'photos';
|
||||||
}
|
}
|
||||||
if (!in_array($adminMode, ['sections', 'photos', 'comments', 'welcome'], true)) {
|
if (!in_array($adminMode, ['sections', 'photos', 'commenters', 'comments', 'welcome'], true)) {
|
||||||
$adminMode = 'photos';
|
$adminMode = 'photos';
|
||||||
}
|
}
|
||||||
$previewVersion = (string)time();
|
$previewVersion = (string)time();
|
||||||
$commentPhotoId = (int)($_GET['comment_photo_id'] ?? ($_POST['comment_photo_id'] ?? 0));
|
$commentPhotoQuery = trim((string)($_GET['comment_photo'] ?? ($_POST['comment_photo'] ?? '')));
|
||||||
if ($commentPhotoId < 0) {
|
$commentUserQuery = trim((string)($_GET['comment_user'] ?? ($_POST['comment_user'] ?? '')));
|
||||||
$commentPhotoId = 0;
|
$filteredComments = commentsSearch($commentPhotoQuery, $commentUserQuery, 200);
|
||||||
}
|
|
||||||
$selectedCommentPhoto = $commentPhotoId > 0 ? photoById($commentPhotoId) : null;
|
|
||||||
if (!$selectedCommentPhoto) {
|
|
||||||
$commentPhotoId = 0;
|
|
||||||
}
|
|
||||||
$photoComments = $commentPhotoId > 0 ? commentsByPhoto($commentPhotoId) : [];
|
|
||||||
$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));
|
||||||
|
|
||||||
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'); }
|
||||||
|
|
@ -282,6 +275,36 @@ function commentCountsByPhotoIds(array $photoIds): array
|
||||||
return $map;
|
return $map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function commentsSearch(string $photoQuery, string $userQuery, int $limit = 200): array
|
||||||
|
{
|
||||||
|
$limit = max(1, min(500, $limit));
|
||||||
|
$where = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($photoQuery !== '') {
|
||||||
|
$where[] = 'p.code_name LIKE :photo';
|
||||||
|
$params['photo'] = '%' . $photoQuery . '%';
|
||||||
|
}
|
||||||
|
if ($userQuery !== '') {
|
||||||
|
$where[] = 'COALESCE(u.display_name, "") LIKE :user';
|
||||||
|
$params['user'] = '%' . $userQuery . '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = 'SELECT c.id, c.photo_id, c.comment_text, c.created_at, p.code_name, u.display_name
|
||||||
|
FROM photo_comments c
|
||||||
|
JOIN photos p ON p.id=c.photo_id
|
||||||
|
LEFT JOIN comment_users u ON u.id=c.user_id';
|
||||||
|
|
||||||
|
if ($where !== []) {
|
||||||
|
$sql .= ' WHERE ' . implode(' AND ', $where);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= ' ORDER BY c.id DESC LIMIT ' . $limit;
|
||||||
|
$st = db()->prepare($sql);
|
||||||
|
$st->execute($params);
|
||||||
|
return $st->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
function saveBulkBefore(array $files, int $sectionId): array
|
function saveBulkBefore(array $files, int $sectionId): array
|
||||||
{
|
{
|
||||||
$ok = 0;
|
$ok = 0;
|
||||||
|
|
@ -548,6 +571,9 @@ function nextUniqueCodeName(string $base): string
|
||||||
.comment-row:first-child{border-top:0;padding-top:0}
|
.comment-row:first-child{border-top:0;padding-top:0}
|
||||||
.comment-row-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px}
|
.comment-row-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px}
|
||||||
.comment-row-body{white-space:pre-wrap}
|
.comment-row-body{white-space:pre-wrap}
|
||||||
|
.comment-search{display:grid;grid-template-columns:minmax(180px,1fr) minmax(180px,1fr) auto auto;gap:8px;align-items:center;margin-bottom:12px}
|
||||||
|
.comment-search .btn{height:36px}
|
||||||
|
@media (max-width:760px){.comment-search{grid-template-columns:1fr}}
|
||||||
@media (max-width:960px){.grid{grid-template-columns:1fr}}
|
@media (max-width:960px){.grid{grid-template-columns:1fr}}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -564,7 +590,8 @@ function nextUniqueCodeName(string $base): string
|
||||||
<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==='welcome'?'active':'' ?>" href="?token=<?= urlencode($tokenIncoming) ?>&mode=welcome">Приветственное сообщение</a>
|
<a class="<?= $adminMode==='welcome'?'active':'' ?>" href="?token=<?= urlencode($tokenIncoming) ?>&mode=welcome">Приветственное сообщение</a>
|
||||||
<a class="<?= $adminMode==='comments'?'active':'' ?>" href="?token=<?= urlencode($tokenIncoming) ?>&mode=comments">Комментаторы и комментарии</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -584,10 +611,10 @@ function nextUniqueCodeName(string $base): string
|
||||||
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($adminMode === 'comments'): ?>
|
<?php if ($adminMode === 'commenters'): ?>
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h3>Комментаторы</h3>
|
<h3>Новый пользователь комментариев</h3>
|
||||||
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>&mode=comments">
|
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>&mode=commenters">
|
||||||
<input type="hidden" name="action" value="create_commenter"><input type="hidden" name="token" value="<?= h($tokenIncoming) ?>">
|
<input type="hidden" name="action" value="create_commenter"><input type="hidden" name="token" value="<?= h($tokenIncoming) ?>">
|
||||||
<p><input class="in" name="display_name" placeholder="Имя" required></p>
|
<p><input class="in" name="display_name" placeholder="Имя" required></p>
|
||||||
<button class="btn" type="submit">Создать</button>
|
<button class="btn" type="submit">Создать</button>
|
||||||
|
|
@ -724,9 +751,9 @@ function nextUniqueCodeName(string $base): string
|
||||||
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($adminMode === 'comments'): ?>
|
<?php if ($adminMode === 'commenters'): ?>
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h3>Комментаторы и комментарии</h3>
|
<h3>Пользователи комментариев</h3>
|
||||||
<table class="tbl"><tr><th>Пользователь</th><th>Ссылка</th><th>Действия</th></tr>
|
<table class="tbl"><tr><th>Пользователь</th><th>Ссылка</th><th>Действия</th></tr>
|
||||||
<?php foreach($commenters as $u): ?>
|
<?php foreach($commenters as $u): ?>
|
||||||
<?php $viewerLink = !empty($u['token_plain']) ? ((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . '/?viewer=' . urlencode((string)$u['token_plain'])) : ''; ?>
|
<?php $viewerLink = !empty($u['token_plain']) ? ((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . '/?viewer=' . urlencode((string)$u['token_plain'])) : ''; ?>
|
||||||
|
|
@ -737,50 +764,45 @@ function nextUniqueCodeName(string $base): string
|
||||||
<span class="small">Нет сохранённой ссылки (старый пользователь)</span>
|
<span class="small">Нет сохранённой ссылки (старый пользователь)</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td><td style="display:flex;gap:8px;flex-wrap:wrap">
|
</td><td style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>&mode=comments">
|
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>&mode=commenters">
|
||||||
<input type="hidden" name="action" value="regenerate_commenter_token"><input type="hidden" name="token" value="<?= h($tokenIncoming) ?>"><input type="hidden" name="id" value="<?= (int)$u['id'] ?>">
|
<input type="hidden" name="action" value="regenerate_commenter_token"><input type="hidden" name="token" value="<?= h($tokenIncoming) ?>"><input type="hidden" name="id" value="<?= (int)$u['id'] ?>">
|
||||||
<button class="btn" type="submit">Новая ссылка</button>
|
<button class="btn" type="submit">Новая ссылка</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>&mode=comments" onsubmit="return confirm('Удалить пользователя?')">
|
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>&mode=commenters" onsubmit="return confirm('Удалить пользователя?')">
|
||||||
<input type="hidden" name="action" value="delete_commenter"><input type="hidden" name="token" value="<?= h($tokenIncoming) ?>"><input type="hidden" name="id" value="<?= (int)$u['id'] ?>">
|
<input type="hidden" name="action" value="delete_commenter"><input type="hidden" name="token" value="<?= h($tokenIncoming) ?>"><input type="hidden" name="id" value="<?= (int)$u['id'] ?>">
|
||||||
<button class="btn btn-danger" type="submit">Удалить доступ</button>
|
<button class="btn btn-danger" type="submit">Удалить доступ</button>
|
||||||
</form>
|
</form>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</table>
|
</table>
|
||||||
<hr style="border:none;border-top:1px solid #eee;margin:12px 0">
|
</section>
|
||||||
<?php if ($commentPhotoId > 0 && $selectedCommentPhoto): ?>
|
<?php endif; ?>
|
||||||
<h4 style="margin:0 0 10px">Комментарии к фото: <?= h((string)$selectedCommentPhoto['code_name']) ?></h4>
|
|
||||||
<?php if ($photoComments === []): ?>
|
<?php if ($adminMode === 'comments'): ?>
|
||||||
<p class="small">К этой карточке комментариев пока нет.</p>
|
<section class="card">
|
||||||
<?php else: ?>
|
<h3>Комментарии</h3>
|
||||||
<table class="tbl"><tr><th>Пользователь</th><th>Комментарий</th><th>Дата</th><th></th></tr>
|
<form method="get" action="admin.php" class="comment-search">
|
||||||
<?php foreach($photoComments as $c): ?>
|
<input type="hidden" name="token" value="<?= h($tokenIncoming) ?>">
|
||||||
<tr>
|
<input type="hidden" name="mode" value="comments">
|
||||||
<td><?= h((string)($c['display_name'] ?? '—')) ?></td>
|
<input class="in" type="search" name="comment_photo" value="<?= h($commentPhotoQuery) ?>" placeholder="Поиск по имени фото">
|
||||||
<td><?= nl2br(h((string)$c['comment_text'])) ?></td>
|
<input class="in" type="search" name="comment_user" value="<?= h($commentUserQuery) ?>" placeholder="Поиск по пользователю">
|
||||||
<td><?= h((string)$c['created_at']) ?></td>
|
<button class="btn" type="submit">Найти</button>
|
||||||
<td>
|
<a class="btn btn-secondary" href="?token=<?= urlencode($tokenIncoming) ?>&mode=comments">Сбросить</a>
|
||||||
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>&mode=comments&comment_photo_id=<?= (int)$commentPhotoId ?>" onsubmit="return confirm('Удалить комментарий?')">
|
</form>
|
||||||
<input type="hidden" name="action" value="delete_comment"><input type="hidden" name="token" value="<?= h($tokenIncoming) ?>"><input type="hidden" name="id" value="<?= (int)$c['id'] ?>"><input type="hidden" name="comment_photo_id" value="<?= (int)$commentPhotoId ?>">
|
|
||||||
<button class="btn btn-danger" type="submit">Удалить</button>
|
<?php if ($filteredComments === []): ?>
|
||||||
</form>
|
<p class="small">Комментарии не найдены.</p>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</table>
|
|
||||||
<?php endif; ?>
|
|
||||||
<p style="margin-top:10px"><a href="?token=<?= urlencode($tokenIncoming) ?>&mode=comments">Показать все последние комментарии</a></p>
|
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<table class="tbl"><tr><th>Фото</th><th>Пользователь</th><th>Комментарий</th><th></th></tr>
|
<table class="tbl"><tr><th>Фото</th><th>Пользователь</th><th>Комментарий</th><th>Дата</th><th></th></tr>
|
||||||
<?php foreach($latestComments as $c): ?>
|
<?php foreach($filteredComments as $c): ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td><?= h((string)$c['code_name']) ?></td>
|
<td><?= h((string)$c['code_name']) ?></td>
|
||||||
<td><?= h((string)($c['display_name'] ?? '—')) ?></td>
|
<td><?= h((string)($c['display_name'] ?? '—')) ?></td>
|
||||||
<td><?= h((string)$c['comment_text']) ?></td>
|
<td><?= h((string)$c['comment_text']) ?></td>
|
||||||
|
<td><?= h((string)$c['created_at']) ?></td>
|
||||||
<td>
|
<td>
|
||||||
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>&mode=comments" onsubmit="return confirm('Удалить комментарий?')">
|
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>&mode=comments" onsubmit="return confirm('Удалить комментарий?')">
|
||||||
<input type="hidden" name="action" value="delete_comment"><input type="hidden" name="token" value="<?= h($tokenIncoming) ?>"><input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
|
<input type="hidden" name="action" value="delete_comment"><input type="hidden" name="token" value="<?= h($tokenIncoming) ?>"><input type="hidden" name="id" value="<?= (int)$c['id'] ?>"><input type="hidden" name="comment_photo" value="<?= h($commentPhotoQuery) ?>"><input type="hidden" name="comment_user" value="<?= h($commentUserQuery) ?>">
|
||||||
<button class="btn btn-danger" type="submit">Удалить</button>
|
<button class="btn btn-danger" type="submit">Удалить</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
103
index.php
103
index.php
|
|
@ -42,6 +42,37 @@ $comments = $photo ? commentsByPhoto($activePhotoId) : [];
|
||||||
$photos = $activeSectionId > 0 ? photosBySection($activeSectionId) : [];
|
$photos = $activeSectionId > 0 ? photosBySection($activeSectionId) : [];
|
||||||
$isHomePage = $activeSectionId < 1 && $activePhotoId < 1;
|
$isHomePage = $activeSectionId < 1 && $activePhotoId < 1;
|
||||||
|
|
||||||
|
$detailTotal = 0;
|
||||||
|
$detailIndex = 0;
|
||||||
|
$prevPhotoId = 0;
|
||||||
|
$nextPhotoId = 0;
|
||||||
|
$detailSectionId = 0;
|
||||||
|
if ($photo) {
|
||||||
|
$detailSectionId = (int)$photo['section_id'];
|
||||||
|
$detailPhotos = photosBySection($detailSectionId);
|
||||||
|
$detailTotal = count($detailPhotos);
|
||||||
|
foreach ($detailPhotos as $i => $p) {
|
||||||
|
if ((int)$p['id'] !== $activePhotoId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$detailIndex = $i + 1;
|
||||||
|
if ($i > 0) {
|
||||||
|
$prevPhotoId = (int)$detailPhotos[$i - 1]['id'];
|
||||||
|
}
|
||||||
|
if ($i < $detailTotal - 1) {
|
||||||
|
$nextPhotoId = (int)$detailPhotos[$i + 1]['id'];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasMobilePhotoNav = $activePhotoId > 0 && $photo && $detailTotal > 0;
|
||||||
|
$bodyClasses = [$isHomePage ? 'is-home' : 'is-inner'];
|
||||||
|
if ($hasMobilePhotoNav) {
|
||||||
|
$bodyClasses[] = 'has-mobile-nav';
|
||||||
|
}
|
||||||
|
|
||||||
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); }
|
||||||
function limitText(string $text, int $len): string { return function_exists('mb_substr') ? mb_substr($text, 0, $len) : substr($text, 0, $len); }
|
function limitText(string $text, int $len): string { return function_exists('mb_substr') ? mb_substr($text, 0, $len) : substr($text, 0, $len); }
|
||||||
|
|
@ -84,11 +115,22 @@ function outputWatermarked(string $path, string $mime): never
|
||||||
|
|
||||||
if (extension_loaded('imagick')) {
|
if (extension_loaded('imagick')) {
|
||||||
$im = new Imagick($path);
|
$im = new Imagick($path);
|
||||||
|
$w = max(1, (int)$im->getImageWidth());
|
||||||
|
$h = max(1, (int)$im->getImageHeight());
|
||||||
$draw = new ImagickDraw();
|
$draw = new ImagickDraw();
|
||||||
$draw->setFillColor(new ImagickPixel('rgba(255,255,255,0.22)'));
|
$draw->setFillColor(new ImagickPixel('rgba(255,255,255,0.16)'));
|
||||||
$draw->setFontSize(max(18, (int)($im->getImageWidth() / 24)));
|
$draw->setFontSize(max(12, (int)($w / 46)));
|
||||||
$draw->setGravity(Imagick::GRAVITY_SOUTHEAST);
|
$draw->setTextAntialias(true);
|
||||||
$im->annotateImage($draw, 20, 24, -15, $text);
|
|
||||||
|
$lineText = $text . ' ' . $text . ' ' . $text;
|
||||||
|
$stepY = max(28, (int)($h / 10));
|
||||||
|
$stepX = max(120, (int)($w / 3));
|
||||||
|
for ($y = -$h; $y < $h * 2; $y += $stepY) {
|
||||||
|
for ($x = -$w; $x < $w * 2; $x += $stepX) {
|
||||||
|
$im->annotateImage($draw, $x, $y, -28, $lineText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
header('Content-Type: ' . ($mime !== '' ? $mime : 'image/jpeg'));
|
header('Content-Type: ' . ($mime !== '' ? $mime : 'image/jpeg'));
|
||||||
$im->setImageCompressionQuality(88);
|
$im->setImageCompressionQuality(88);
|
||||||
echo $im;
|
echo $im;
|
||||||
|
|
@ -110,11 +152,19 @@ function outputWatermarked(string $path, string $mime): never
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$font = 5;
|
$font = 2;
|
||||||
$color = imagecolorallocatealpha($img, 255, 255, 255, 90);
|
$color = imagecolorallocatealpha($img, 255, 255, 255, 96);
|
||||||
$x = max(5, $w - (imagefontwidth($font) * strlen($text)) - 15);
|
$lineText = $text . ' ' . $text . ' ' . $text;
|
||||||
$y = max(5, $h - imagefontheight($font) - 12);
|
$stepY = max(16, imagefontheight($font) + 8);
|
||||||
imagestring($img, $font, $x, $y, $text, $color);
|
$stepX = max(120, (int)($w / 3));
|
||||||
|
$row = 0;
|
||||||
|
for ($y = -$h; $y < $h * 2; $y += $stepY) {
|
||||||
|
$offset = ($row * 22) % $stepX;
|
||||||
|
for ($x = -$w - $offset; $x < $w * 2; $x += $stepX) {
|
||||||
|
imagestring($img, $font, $x, $y, $lineText, $color);
|
||||||
|
}
|
||||||
|
$row++;
|
||||||
|
}
|
||||||
|
|
||||||
header('Content-Type: image/jpeg');
|
header('Content-Type: image/jpeg');
|
||||||
imagejpeg($img, null, 88);
|
imagejpeg($img, null, 88);
|
||||||
|
|
@ -133,6 +183,7 @@ function outputWatermarked(string $path, string $mime): never
|
||||||
.note{color:#6b7280;font-size:13px}
|
.note{color:#6b7280;font-size:13px}
|
||||||
.page{display:grid;gap:16px;grid-template-columns:300px minmax(0,1fr)}
|
.page{display:grid;gap:16px;grid-template-columns:300px minmax(0,1fr)}
|
||||||
.panel{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:14px}
|
.panel{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:14px}
|
||||||
|
.sidebar{position:sticky;top:14px;align-self:start;max-height:calc(100dvh - 28px);overflow:auto}
|
||||||
.sec a{display:block;padding:8px 10px;border-radius:8px;text-decoration:none;color:#111}
|
.sec a{display:block;padding:8px 10px;border-radius:8px;text-decoration:none;color:#111}
|
||||||
.sec a.active{background:#eef4ff;color:#1f6feb}
|
.sec a.active{background:#eef4ff;color:#1f6feb}
|
||||||
.cards{display:grid;gap:10px;grid-template-columns:repeat(auto-fill,minmax(180px,1fr))}
|
.cards{display:grid;gap:10px;grid-template-columns:repeat(auto-fill,minmax(180px,1fr))}
|
||||||
|
|
@ -143,6 +194,14 @@ function outputWatermarked(string $path, string $mime): never
|
||||||
.stack{display:grid;gap:12px;grid-template-columns:1fr}
|
.stack{display:grid;gap:12px;grid-template-columns:1fr}
|
||||||
.cmt{border-top:1px solid #eee;padding:8px 0}
|
.cmt{border-top:1px solid #eee;padding:8px 0}
|
||||||
.muted{color:#6b7280;font-size:13px}
|
.muted{color:#6b7280;font-size:13px}
|
||||||
|
.pager{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-top:16px;padding-top:12px;border-top:1px solid #e5e7eb}
|
||||||
|
.pager-actions{display:flex;gap:8px;flex-wrap:wrap}
|
||||||
|
.pager-link{display:inline-flex;align-items:center;justify-content:center;padding:8px 12px;border-radius:8px;border:1px solid #d1d5db;background:#fff;color:#111;text-decoration:none;font-size:14px}
|
||||||
|
.pager-link.disabled{opacity:.45;pointer-events:none}
|
||||||
|
.mobile-photo-nav{display:none}
|
||||||
|
.mobile-nav-link{display:inline-flex;align-items:center;justify-content:center;white-space:nowrap;border:1px solid #d1d5db;background:#fff;color:#111;border-radius:10px;padding:9px 10px;text-decoration:none;font-size:14px}
|
||||||
|
.mobile-nav-link.disabled{opacity:.45;pointer-events:none}
|
||||||
|
.mobile-nav-meta{font-size:13px;color:#4b5563;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
.img-box{position:relative;display:block;overflow:hidden;background:linear-gradient(110deg,#eef2f7 8%,#f8fafc 18%,#eef2f7 33%);background-size:200% 100%;animation:skeleton 1.2s linear infinite}
|
.img-box{position:relative;display:block;overflow:hidden;background:linear-gradient(110deg,#eef2f7 8%,#f8fafc 18%,#eef2f7 33%);background-size:200% 100%;animation:skeleton 1.2s linear infinite}
|
||||||
.img-box img{display:block;position:relative;z-index:1}
|
.img-box img{display:block;position:relative;z-index:1}
|
||||||
.thumb-img-box{height:130px}
|
.thumb-img-box{height:130px}
|
||||||
|
|
@ -160,6 +219,12 @@ function outputWatermarked(string $path, string $mime): never
|
||||||
.topbar{display:flex;align-items:center;justify-content:space-between;gap:10px}
|
.topbar{display:flex;align-items:center;justify-content:space-between;gap:10px}
|
||||||
.topbar h1{margin:0;font-size:24px}
|
.topbar h1{margin:0;font-size:24px}
|
||||||
.page{grid-template-columns:1fr}
|
.page{grid-template-columns:1fr}
|
||||||
|
.sidebar{position:static;max-height:none}
|
||||||
|
.pager{display:none}
|
||||||
|
|
||||||
|
.has-mobile-nav .app{padding-bottom:84px}
|
||||||
|
.mobile-photo-nav{position:fixed;left:0;right:0;bottom:0;z-index:50;display:grid;grid-template-columns:auto 1fr auto auto;align-items:center;gap:8px;padding:10px 12px calc(10px + env(safe-area-inset-bottom));background:rgba(255,255,255,.97);backdrop-filter:blur(6px);border-top:1px solid #e5e7eb}
|
||||||
|
.mobile-nav-link{padding:8px 10px;font-size:13px}
|
||||||
|
|
||||||
.is-inner .sidebar-toggle{display:inline-flex;align-items:center;justify-content:center;white-space:nowrap}
|
.is-inner .sidebar-toggle{display:inline-flex;align-items:center;justify-content:center;white-space:nowrap}
|
||||||
.is-inner .sidebar{position:fixed;top:0;left:0;z-index:40;width:min(86vw,320px);height:100dvh;overflow-y:auto;border-radius:0 12px 12px 0;transform:translateX(-105%);transition:transform .2s ease;padding-top:18px}
|
.is-inner .sidebar{position:fixed;top:0;left:0;z-index:40;width:min(86vw,320px);height:100dvh;overflow-y:auto;border-radius:0 12px 12px 0;transform:translateX(-105%);transition:transform .2s ease;padding-top:18px}
|
||||||
|
|
@ -175,7 +240,7 @@ function outputWatermarked(string $path, string $mime): never
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="<?= $isHomePage ? 'is-home' : 'is-inner' ?>">
|
<body class="<?= h(implode(' ', $bodyClasses)) ?>">
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<h1>Фотогалерея</h1>
|
<h1>Фотогалерея</h1>
|
||||||
|
|
@ -226,6 +291,16 @@ function outputWatermarked(string $path, string $mime): never
|
||||||
<?php foreach($comments as $c): ?>
|
<?php foreach($comments as $c): ?>
|
||||||
<div class="cmt"><strong><?= h((string)($c['display_name'] ?? 'Пользователь')) ?></strong> <span class="muted">· <?= h((string)$c['created_at']) ?></span><br><?= nl2br(h((string)$c['comment_text'])) ?></div>
|
<div class="cmt"><strong><?= h((string)($c['display_name'] ?? 'Пользователь')) ?></strong> <span class="muted">· <?= h((string)$c['created_at']) ?></span><br><?= nl2br(h((string)$c['comment_text'])) ?></div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<?php if ($detailTotal > 0): ?>
|
||||||
|
<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 ?>§ion_id=<?= (int)$detailSectionId ?><?= $viewerToken!=='' ? '&viewer=' . urlencode($viewerToken) : '' ?>">← Предыдущее</a>
|
||||||
|
<a class="pager-link<?= $nextPhotoId < 1 ? ' disabled' : '' ?>" href="?photo_id=<?= (int)$nextPhotoId ?>§ion_id=<?= (int)$detailSectionId ?><?= $viewerToken!=='' ? '&viewer=' . urlencode($viewerToken) : '' ?>">Следующее →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
</section>
|
</section>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
|
|
@ -254,6 +329,14 @@ function outputWatermarked(string $path, string $mime): never
|
||||||
<small class="footer-author">by <a href="https://t.me/andr33vru" target="_blank" rel="noopener noreferrer">andr33vru</a></small>
|
<small class="footer-author">by <a href="https://t.me/andr33vru" target="_blank" rel="noopener noreferrer">andr33vru</a></small>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</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>
|
||||||
|
<div class="mobile-nav-meta">Фото <?= (int)$detailIndex ?> из <?= (int)$detailTotal ?></div>
|
||||||
|
<a class="mobile-nav-link<?= $prevPhotoId < 1 ? ' disabled' : '' ?>" href="?photo_id=<?= (int)$prevPhotoId ?>§ion_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 ?>§ion_id=<?= (int)$detailSectionId ?><?= $viewerToken!=='' ? '&viewer=' . urlencode($viewerToken) : '' ?>" aria-disabled="<?= $nextPhotoId < 1 ? 'true' : 'false' ?>">→</a>
|
||||||
|
</nav>
|
||||||
|
<?php endif; ?>
|
||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
document.querySelectorAll('img').forEach((img) => {
|
document.querySelectorAll('img').forEach((img) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user