Switch main app to MySQL flow and restore section-first bulk upload UX
This commit is contained in:
parent
8a0b5aad55
commit
e45fd0dcb0
14
README.md
14
README.md
|
|
@ -118,16 +118,20 @@ BRANCH=master bash scripts/deploy.sh
|
|||
|
||||
## Админка загрузки (по токену)
|
||||
|
||||
Новый контур на MySQL:
|
||||
Новый контур на MySQL (основной):
|
||||
|
||||
- `admin-mysql.php?token=...` — админка
|
||||
- `index-mysql.php` — публичная витрина + комментарии
|
||||
- `admin.php?token=...` — админка
|
||||
- `index.php` — публичная витрина + комментарии
|
||||
|
||||
Совместимость:
|
||||
- `admin-mysql.php` и `index-mysql.php` оставлены как алиасы на новые основные файлы.
|
||||
|
||||
Что уже есть в MySQL-контуре:
|
||||
- создание разделов,
|
||||
- загрузка фото "до" + опционально "после",
|
||||
- сценарий загрузки: сначала выбор раздела, затем массовая загрузка только фото "до",
|
||||
- после загрузки автоматический prefill имени (code_name) из имени файла,
|
||||
- для каждой карточки фото можно отредактировать: имя, сортировку, комментарий и добавить/заменить фото "после",
|
||||
- запись в таблицы `sections`, `photos`, `photo_files`,
|
||||
- просмотр разделов и загруженных фото,
|
||||
- персональные комментаторы (генерация ссылок),
|
||||
- плоские комментарии к фото,
|
||||
- удаление комментариев админом,
|
||||
|
|
|
|||
258
admin-mysql.php
258
admin-mysql.php
|
|
@ -1,257 +1,3 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/lib/db_gallery.php';
|
||||
|
||||
const MAX_UPLOAD_BYTES = 3 * 1024 * 1024;
|
||||
|
||||
$configPath = __DIR__ . '/deploy-config.php';
|
||||
if (!is_file($configPath)) {
|
||||
http_response_code(500);
|
||||
exit('deploy-config.php not found');
|
||||
}
|
||||
$config = require $configPath;
|
||||
$tokenExpected = (string)($config['token'] ?? '');
|
||||
$tokenIncoming = (string)($_REQUEST['token'] ?? '');
|
||||
if ($tokenExpected === '' || !hash_equals($tokenExpected, $tokenIncoming)) {
|
||||
http_response_code(403);
|
||||
exit('Forbidden');
|
||||
}
|
||||
|
||||
$message = '';
|
||||
$errors = [];
|
||||
|
||||
try {
|
||||
db();
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
exit('DB error: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = (string)($_POST['action'] ?? '');
|
||||
try {
|
||||
if ($action === 'create_section') {
|
||||
$name = trim((string)($_POST['name'] ?? ''));
|
||||
$sort = (int)($_POST['sort_order'] ?? 1000);
|
||||
if ($name === '') throw new RuntimeException('Название раздела пустое');
|
||||
sectionCreate($name, $sort);
|
||||
$message = 'Раздел создан';
|
||||
}
|
||||
|
||||
if ($action === 'upload_photo') {
|
||||
$sectionId = (int)($_POST['section_id'] ?? 0);
|
||||
$codeName = trim((string)($_POST['code_name'] ?? ''));
|
||||
$sortOrder = (int)($_POST['sort_order'] ?? 1000);
|
||||
$description = trim((string)($_POST['description'] ?? ''));
|
||||
$description = $description !== '' ? $description : null;
|
||||
|
||||
if ($sectionId < 1) throw new RuntimeException('Выбери раздел');
|
||||
if ($codeName === '') throw new RuntimeException('Укажи код фото (например АВФ1)');
|
||||
if (!isset($_FILES['before'])) throw new RuntimeException('Файл "до" обязателен');
|
||||
if (!sectionById($sectionId)) throw new RuntimeException('Раздел не найден');
|
||||
|
||||
$photoId = photoCreate($sectionId, $codeName, $description, $sortOrder);
|
||||
|
||||
$before = saveImageUpload($_FILES['before'], $codeName, 'before', $sectionId);
|
||||
photoFileUpsert($photoId, 'before', $before['path'], $before['mime'], $before['size']);
|
||||
|
||||
if (isset($_FILES['after']) && (int)($_FILES['after']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
|
||||
$after = saveImageUpload($_FILES['after'], $codeName . 'р', 'after', $sectionId);
|
||||
photoFileUpsert($photoId, 'after', $after['path'], $after['mime'], $after['size']);
|
||||
}
|
||||
|
||||
$message = 'Фото добавлено';
|
||||
}
|
||||
|
||||
if ($action === 'create_commenter') {
|
||||
$displayName = trim((string)($_POST['display_name'] ?? ''));
|
||||
if ($displayName === '') throw new RuntimeException('Укажи имя комментатора');
|
||||
$u = commenterCreate($displayName);
|
||||
$link = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . '/index-mysql.php?viewer=' . urlencode($u['token']);
|
||||
$message = 'Комментатор создан: ' . $u['display_name'] . ' | ссылка: ' . $link;
|
||||
}
|
||||
|
||||
if ($action === 'delete_commenter') {
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
commenterDelete($id);
|
||||
$message = 'Комментатор удалён (доступ отозван)';
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'delete_comment') {
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
commentDelete($id);
|
||||
$message = 'Комментарий удалён';
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$errors[] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$sections = sectionsAll();
|
||||
$activeSectionId = (int)($_GET['section_id'] ?? ($sections[0]['id'] ?? 0));
|
||||
$photos = $activeSectionId > 0 ? photosBySection($activeSectionId) : [];
|
||||
$commenters = commentersAll();
|
||||
$latestComments = commentsLatest(80);
|
||||
|
||||
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 saveImageUpload(array $file, string $baseName, string $kind, int $sectionId): array
|
||||
{
|
||||
$allowedMime = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif'];
|
||||
$err = (int)($file['error'] ?? UPLOAD_ERR_NO_FILE);
|
||||
if ($err !== UPLOAD_ERR_OK) throw new RuntimeException("Ошибка загрузки ({$kind})");
|
||||
$size = (int)($file['size'] ?? 0);
|
||||
if ($size < 1 || $size > MAX_UPLOAD_BYTES) throw new RuntimeException("Файл {$kind}: превышен лимит 3 МБ");
|
||||
|
||||
$tmp = (string)($file['tmp_name'] ?? '');
|
||||
if (!is_uploaded_file($tmp)) throw new RuntimeException("Файл {$kind}: некорректный источник");
|
||||
|
||||
$mime = mime_content_type($tmp) ?: '';
|
||||
if (!isset($allowedMime[$mime])) throw new RuntimeException("Файл {$kind}: недопустимый mime {$mime}");
|
||||
|
||||
$safeBase = preg_replace('/[^\p{L}\p{N}._-]+/u', '_', $baseName) ?? 'photo';
|
||||
$safeBase = trim($safeBase, '._-');
|
||||
if ($safeBase === '') $safeBase = 'photo';
|
||||
|
||||
$ext = $allowedMime[$mime];
|
||||
$dir = __DIR__ . '/photos/section_' . $sectionId;
|
||||
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) throw new RuntimeException('Не удалось создать папку раздела');
|
||||
|
||||
$final = uniqueName($dir, $safeBase, $ext);
|
||||
$dest = $dir . '/' . $final;
|
||||
if (!move_uploaded_file($tmp, $dest)) throw new RuntimeException('Не удалось сохранить файл');
|
||||
|
||||
return [
|
||||
'path' => 'photos/section_' . $sectionId . '/' . $final,
|
||||
'mime' => $mime,
|
||||
'size' => $size,
|
||||
];
|
||||
}
|
||||
|
||||
function uniqueName(string $dir, string $base, string $ext): string
|
||||
{
|
||||
$i = 0;
|
||||
do {
|
||||
$name = $i === 0 ? "{$base}.{$ext}" : "{$base}_{$i}.{$ext}";
|
||||
$i++;
|
||||
} while (is_file($dir . '/' . $name));
|
||||
return $name;
|
||||
}
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Админка (MySQL)</title>
|
||||
<link rel="icon" type="image/svg+xml" href="<?= h(assetUrl('favicon.svg')) ?>">
|
||||
<link rel="stylesheet" href="<?= h(assetUrl('style.css')) ?>">
|
||||
<style>.wrap{max-width:1180px;margin:0 auto;padding:24px}.card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:14px;margin-bottom:14px}.grid{display:grid;gap:12px;grid-template-columns:1fr 1fr}.full{grid-column:1/-1}.in{width:100%;padding:8px;border:1px solid #d1d5db;border-radius:8px}.btn{border:0;background:#1f6feb;color:#fff;padding:8px 12px;border-radius:8px;cursor:pointer}.btn-danger{background:#b42318}.ok{background:#ecfdf3;padding:8px;border-radius:8px;margin-bottom:8px}.err{background:#fef2f2;padding:8px;border-radius:8px;margin-bottom:8px}.tbl{width:100%;border-collapse:collapse}.tbl td,.tbl th{padding:8px;border-bottom:1px solid #eee;vertical-align:top}.small{font-size:12px;color:#667085}</style>
|
||||
</head>
|
||||
<body><div class="wrap">
|
||||
<h1>Админка (MySQL)</h1>
|
||||
<p><a href="index-mysql.php">Открыть публичную MySQL-галерею</a></p>
|
||||
<?php if ($message!==''): ?><div class="ok"><?= h($message) ?></div><?php endif; ?>
|
||||
<?php foreach($errors as $e): ?><div class="err"><?= h($e) ?></div><?php endforeach; ?>
|
||||
|
||||
<div class="grid">
|
||||
<section class="card">
|
||||
<h3>Создать раздел</h3>
|
||||
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>">
|
||||
<input type="hidden" name="action" value="create_section"><input type="hidden" name="token" value="<?= h($tokenIncoming) ?>">
|
||||
<p><input class="in" name="name" placeholder="Название раздела" required></p>
|
||||
<p><input class="in" type="number" name="sort_order" value="1000"></p>
|
||||
<button class="btn" type="submit">Создать</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h3>Добавить фото</h3>
|
||||
<form method="post" enctype="multipart/form-data" action="?token=<?= urlencode($tokenIncoming) ?>§ion_id=<?= (int)$activeSectionId ?>">
|
||||
<input type="hidden" name="action" value="upload_photo"><input type="hidden" name="token" value="<?= h($tokenIncoming) ?>">
|
||||
<p><select class="in" name="section_id" required><option value="">— Раздел —</option><?php foreach($sections as $s): ?><option value="<?= (int)$s['id'] ?>" <?= (int)$s['id']===$activeSectionId?'selected':'' ?>><?= h((string)$s['name']) ?></option><?php endforeach; ?></select></p>
|
||||
<p><input class="in" name="code_name" placeholder="Код фото, например АВФ1" required></p>
|
||||
<p><input class="in" type="number" name="sort_order" value="1000"></p>
|
||||
<p><textarea class="in" name="description" placeholder="Краткое описание (опционально)"></textarea></p>
|
||||
<p>Фото до: <input type="file" name="before" accept="image/jpeg,image/png,image/webp,image/gif" required></p>
|
||||
<p>Фото после (опционально): <input type="file" name="after" accept="image/jpeg,image/png,image/webp,image/gif"></p>
|
||||
<button class="btn" type="submit">Загрузить</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card full">
|
||||
<h3>Комментаторы (персональные ссылки)</h3>
|
||||
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<input type="hidden" name="action" value="create_commenter"><input type="hidden" name="token" value="<?= h($tokenIncoming) ?>">
|
||||
<input class="in" name="display_name" placeholder="Имя (например: Александр)" style="max-width:360px" required>
|
||||
<button class="btn" type="submit">Создать</button>
|
||||
</form>
|
||||
<table class="tbl"><tr><th>ID</th><th>Имя</th><th>Статус</th><th>Действие</th></tr>
|
||||
<?php foreach($commenters as $u): ?>
|
||||
<tr>
|
||||
<td><?= (int)$u['id'] ?></td>
|
||||
<td><?= h((string)$u['display_name']) ?></td>
|
||||
<td><?= (int)$u['is_active'] ? 'active' : 'off' ?></td>
|
||||
<td>
|
||||
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>" 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'] ?>">
|
||||
<button class="btn btn-danger" type="submit">Удалить</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<p class="small">После создания ссылки токен показывается один раз в зелёном сообщении.</p>
|
||||
</section>
|
||||
|
||||
<section class="card full">
|
||||
<h3>Разделы</h3>
|
||||
<table class="tbl"><tr><th>ID</th><th>Название</th><th>Порядок</th><th>Фото</th></tr>
|
||||
<?php foreach($sections as $s): ?>
|
||||
<tr><td><?= (int)$s['id'] ?></td><td><a href="?token=<?= urlencode($tokenIncoming) ?>§ion_id=<?= (int)$s['id'] ?>"><?= h((string)$s['name']) ?></a></td><td><?= (int)$s['sort_order'] ?></td><td><?= (int)$s['photos_count'] ?></td></tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="card full">
|
||||
<h3>Фото раздела</h3>
|
||||
<table class="tbl"><tr><th>ID</th><th>Код</th><th>Превью</th><th>Описание</th><th>Порядок</th></tr>
|
||||
<?php foreach($photos as $p): ?>
|
||||
<tr>
|
||||
<td><?= (int)$p['id'] ?></td>
|
||||
<td><?= h((string)$p['code_name']) ?></td>
|
||||
<td><?php if (!empty($p['before_file_id'])): ?><img src="index-mysql.php?action=image&file_id=<?= (int)$p['before_file_id'] ?>" alt="" style="width:90px;height:60px;object-fit:cover;border:1px solid #e5e7eb;border-radius:6px"><?php endif; ?></td>
|
||||
<td><?= h((string)($p['description'] ?? '')) ?></td>
|
||||
<td><?= (int)$p['sort_order'] ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="card full">
|
||||
<h3>Последние комментарии</h3>
|
||||
<table class="tbl"><tr><th>ID</th><th>Фото</th><th>Пользователь</th><th>Комментарий</th><th></th></tr>
|
||||
<?php foreach($latestComments as $c): ?>
|
||||
<tr>
|
||||
<td><?= (int)$c['id'] ?></td>
|
||||
<td><?= h((string)$c['code_name']) ?></td>
|
||||
<td><?= h((string)($c['display_name'] ?? '—')) ?></td>
|
||||
<td><?= h((string)$c['comment_text']) ?></td>
|
||||
<td>
|
||||
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>" 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'] ?>">
|
||||
<button class="btn btn-danger" type="submit">Удалить</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
</div></body></html>
|
||||
// Backward-compat alias
|
||||
require __DIR__ . '/admin.php';
|
||||
|
|
|
|||
580
admin.php
580
admin.php
|
|
@ -2,330 +2,348 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/lib/db_gallery.php';
|
||||
|
||||
const MAX_UPLOAD_BYTES = 3 * 1024 * 1024;
|
||||
|
||||
$rootDir = __DIR__;
|
||||
$photosDir = $rootDir . '/photos';
|
||||
$thumbsDir = $rootDir . '/thumbs';
|
||||
$dataDir = $rootDir . '/data';
|
||||
$sortFile = $dataDir . '/sort.json';
|
||||
|
||||
@mkdir($photosDir, 0775, true);
|
||||
@mkdir($thumbsDir, 0775, true);
|
||||
@mkdir($dataDir, 0775, true);
|
||||
|
||||
$configPath = __DIR__ . '/deploy-config.php';
|
||||
if (!is_file($configPath)) {
|
||||
http_response_code(500);
|
||||
echo 'deploy-config.php not found';
|
||||
exit;
|
||||
exit('deploy-config.php not found');
|
||||
}
|
||||
$config = require $configPath;
|
||||
$tokenExpected = (string)($config['token'] ?? '');
|
||||
$tokenIncoming = (string)($_REQUEST['token'] ?? '');
|
||||
|
||||
if ($tokenExpected === '' || !hash_equals($tokenExpected, $tokenIncoming)) {
|
||||
http_response_code(403);
|
||||
echo 'Forbidden';
|
||||
exit;
|
||||
exit('Forbidden');
|
||||
}
|
||||
|
||||
$sortData = loadSortData($sortFile);
|
||||
$sortData = reconcileSortData($photosDir, $sortData);
|
||||
saveSortData($sortFile, $sortData);
|
||||
$message = '';
|
||||
$errors = [];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = (string)($_POST['action'] ?? '');
|
||||
|
||||
if ($action === 'create_category') {
|
||||
$name = sanitizeCategoryName((string)($_POST['category_name'] ?? ''));
|
||||
if ($name === '') {
|
||||
$errors[] = 'Некорректное имя папки.';
|
||||
} else {
|
||||
$dir = $photosDir . '/' . $name;
|
||||
if (!is_dir($dir) && !mkdir($dir, 0775, true)) {
|
||||
$errors[] = 'Не удалось создать папку.';
|
||||
} else {
|
||||
$message = 'Папка создана: ' . $name;
|
||||
$sortData['categories'][$name] = nextSortIndex($sortData['categories']);
|
||||
}
|
||||
try {
|
||||
if ($action === 'create_section') {
|
||||
$name = trim((string)($_POST['name'] ?? ''));
|
||||
$sort = (int)($_POST['sort_order'] ?? 1000);
|
||||
if ($name === '') throw new RuntimeException('Название раздела пустое');
|
||||
sectionCreate($name, $sort);
|
||||
$message = 'Раздел создан';
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'category_update') {
|
||||
$current = sanitizeCategoryName((string)($_POST['category_current'] ?? ''));
|
||||
$newName = sanitizeCategoryName((string)($_POST['category_new_name'] ?? ''));
|
||||
$sortIndex = (int)($_POST['category_sort'] ?? 1000);
|
||||
if ($action === 'upload_before_bulk') {
|
||||
$sectionId = (int)($_POST['section_id'] ?? 0);
|
||||
if ($sectionId < 1 || !sectionById($sectionId)) throw new RuntimeException('Выбери раздел');
|
||||
if (!isset($_FILES['before_bulk'])) throw new RuntimeException('Файлы не переданы');
|
||||
|
||||
if ($current === '' || !is_dir($photosDir . '/' . $current)) {
|
||||
$errors[] = 'Категория не найдена.';
|
||||
} else {
|
||||
if ($newName !== '' && $newName !== $current) {
|
||||
$oldDir = $photosDir . '/' . $current;
|
||||
$newDir = $photosDir . '/' . $newName;
|
||||
$oldThumb = $thumbsDir . '/' . $current;
|
||||
$newThumb = $thumbsDir . '/' . $newName;
|
||||
|
||||
if (is_dir($newDir)) {
|
||||
$errors[] = 'Категория с таким именем уже существует.';
|
||||
} else {
|
||||
rename($oldDir, $newDir);
|
||||
if (is_dir($oldThumb)) {
|
||||
@rename($oldThumb, $newThumb);
|
||||
}
|
||||
|
||||
if (isset($sortData['categories'][$current])) {
|
||||
$sortData['categories'][$newName] = $sortData['categories'][$current];
|
||||
unset($sortData['categories'][$current]);
|
||||
}
|
||||
if (isset($sortData['photos'][$current])) {
|
||||
$sortData['photos'][$newName] = $sortData['photos'][$current];
|
||||
unset($sortData['photos'][$current]);
|
||||
}
|
||||
$current = $newName;
|
||||
$message = 'Категория переименована.';
|
||||
}
|
||||
}
|
||||
|
||||
$sortData['categories'][$current] = $sortIndex;
|
||||
$message = $message ?: 'Категория обновлена.';
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'category_delete') {
|
||||
$category = sanitizeCategoryName((string)($_POST['category_current'] ?? ''));
|
||||
if ($category === '' || !is_dir($photosDir . '/' . $category)) {
|
||||
$errors[] = 'Категория не найдена.';
|
||||
} else {
|
||||
rrmdir($photosDir . '/' . $category);
|
||||
rrmdir($thumbsDir . '/' . $category);
|
||||
unset($sortData['categories'][$category], $sortData['photos'][$category]);
|
||||
$message = 'Категория удалена: ' . $category;
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'upload') {
|
||||
$category = sanitizeCategoryName((string)($_POST['category'] ?? ''));
|
||||
if ($category === '' || !is_dir($photosDir . '/' . $category)) {
|
||||
$errors[] = 'Выберите существующую категорию.';
|
||||
} elseif (!isset($_FILES['photos'])) {
|
||||
$errors[] = 'Файлы не переданы.';
|
||||
} else {
|
||||
$result = handleUploads($_FILES['photos'], $photosDir . '/' . $category, $sortData, $category);
|
||||
$result = saveBulkBefore($_FILES['before_bulk'], $sectionId);
|
||||
$message = 'Загружено: ' . $result['ok'];
|
||||
$errors = array_merge($errors, $result['errors']);
|
||||
if ($result['ok'] > 0) {
|
||||
$message = 'Загружено: ' . $result['ok'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'photo_update') {
|
||||
$category = sanitizeCategoryName((string)($_POST['category'] ?? ''));
|
||||
$currentFile = basename((string)($_POST['photo_current'] ?? ''));
|
||||
$newBase = sanitizeFileBase((string)($_POST['photo_new_name'] ?? ''));
|
||||
$sortIndex = (int)($_POST['photo_sort'] ?? 1000);
|
||||
if ($action === 'photo_update') {
|
||||
$photoId = (int)($_POST['photo_id'] ?? 0);
|
||||
$code = trim((string)($_POST['code_name'] ?? ''));
|
||||
$sort = (int)($_POST['sort_order'] ?? 1000);
|
||||
$descr = trim((string)($_POST['description'] ?? ''));
|
||||
$descr = $descr !== '' ? $descr : null;
|
||||
|
||||
$src = $photosDir . '/' . $category . '/' . $currentFile;
|
||||
if ($category === '' || $currentFile === '' || !is_file($src)) {
|
||||
$errors[] = 'Фото не найдено.';
|
||||
} else {
|
||||
$finalName = $currentFile;
|
||||
if ($newBase !== '') {
|
||||
$ext = strtolower(pathinfo($currentFile, PATHINFO_EXTENSION));
|
||||
$candidate = uniqueFileNameForRename($photosDir . '/' . $category, $newBase, $ext, $currentFile);
|
||||
if ($candidate !== $currentFile) {
|
||||
$dst = $photosDir . '/' . $category . '/' . $candidate;
|
||||
if (@rename($src, $dst)) {
|
||||
$oldThumb = $thumbsDir . '/' . $category . '/' . pathinfo($currentFile, PATHINFO_FILENAME) . '.jpg';
|
||||
$newThumb = $thumbsDir . '/' . $category . '/' . pathinfo($candidate, PATHINFO_FILENAME) . '.jpg';
|
||||
if (is_file($oldThumb)) {
|
||||
@rename($oldThumb, $newThumb);
|
||||
if ($photoId < 1) throw new RuntimeException('Некорректный photo_id');
|
||||
if ($code === '') throw new RuntimeException('Код фото пустой');
|
||||
|
||||
$st = db()->prepare('UPDATE photos SET code_name=:c, sort_order=:s, description=:d WHERE id=:id');
|
||||
$st->execute(['c' => $code, 's' => $sort, 'd' => $descr, 'id' => $photoId]);
|
||||
|
||||
if (isset($_FILES['after']) && (int)($_FILES['after']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
|
||||
$p = photoById($photoId);
|
||||
if (!$p) throw new RuntimeException('Фото не найдено');
|
||||
$up = saveSingleImage($_FILES['after'], $code . 'р', (int)$p['section_id']);
|
||||
photoFileUpsert($photoId, 'after', $up['path'], $up['mime'], $up['size']);
|
||||
}
|
||||
|
||||
$message = 'Фото обновлено';
|
||||
}
|
||||
|
||||
if ($action === 'photo_delete') {
|
||||
$photoId = (int)($_POST['photo_id'] ?? 0);
|
||||
if ($photoId > 0) {
|
||||
$p = photoById($photoId);
|
||||
if ($p) {
|
||||
foreach (['before_path', 'after_path'] as $k) {
|
||||
if (!empty($p[$k])) {
|
||||
$abs = __DIR__ . '/' . ltrim((string)$p[$k], '/');
|
||||
if (is_file($abs)) @unlink($abs);
|
||||
}
|
||||
if (isset($sortData['photos'][$category][$currentFile])) {
|
||||
$sortData['photos'][$category][$candidate] = $sortData['photos'][$category][$currentFile];
|
||||
unset($sortData['photos'][$category][$currentFile]);
|
||||
}
|
||||
$finalName = $candidate;
|
||||
}
|
||||
}
|
||||
$st = db()->prepare('DELETE FROM photos WHERE id=:id');
|
||||
$st->execute(['id' => $photoId]);
|
||||
$message = 'Фото удалено';
|
||||
}
|
||||
|
||||
$sortData['photos'][$category][$finalName] = $sortIndex;
|
||||
$message = 'Фото обновлено.';
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'photo_delete') {
|
||||
$category = sanitizeCategoryName((string)($_POST['category'] ?? ''));
|
||||
$file = basename((string)($_POST['photo_current'] ?? ''));
|
||||
$src = $photosDir . '/' . $category . '/' . $file;
|
||||
if ($category === '' || $file === '' || !is_file($src)) {
|
||||
$errors[] = 'Фото не найдено.';
|
||||
} else {
|
||||
@unlink($src);
|
||||
$thumb = $thumbsDir . '/' . $category . '/' . pathinfo($file, PATHINFO_FILENAME) . '.jpg';
|
||||
if (is_file($thumb)) {
|
||||
@unlink($thumb);
|
||||
if ($action === 'create_commenter') {
|
||||
$displayName = trim((string)($_POST['display_name'] ?? ''));
|
||||
if ($displayName === '') throw new RuntimeException('Укажи имя комментатора');
|
||||
$u = commenterCreate($displayName);
|
||||
$link = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . '/?viewer=' . urlencode($u['token']);
|
||||
$message = 'Комментатор создан: ' . $u['display_name'] . ' | ссылка: ' . $link;
|
||||
}
|
||||
|
||||
if ($action === 'delete_commenter') {
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
commenterDelete($id);
|
||||
$message = 'Комментатор удалён (доступ отозван)';
|
||||
}
|
||||
unset($sortData['photos'][$category][$file]);
|
||||
$message = 'Фото удалено.';
|
||||
}
|
||||
|
||||
if ($action === 'delete_comment') {
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
commentDelete($id);
|
||||
$message = 'Комментарий удалён';
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$errors[] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$sections = sectionsAll();
|
||||
$activeSectionId = (int)($_GET['section_id'] ?? ($_POST['section_id'] ?? ($sections[0]['id'] ?? 0)));
|
||||
$photos = $activeSectionId > 0 ? photosBySection($activeSectionId) : [];
|
||||
$commenters = commentersAll();
|
||||
$latestComments = commentsLatest(80);
|
||||
|
||||
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 saveBulkBefore(array $files, int $sectionId): array
|
||||
{
|
||||
$ok = 0;
|
||||
$errors = [];
|
||||
|
||||
$names = $files['name'] ?? [];
|
||||
$tmp = $files['tmp_name'] ?? [];
|
||||
$sizes = $files['size'] ?? [];
|
||||
$errs = $files['error'] ?? [];
|
||||
if (!is_array($names)) {
|
||||
$names = [$names];
|
||||
$tmp = [$tmp];
|
||||
$sizes = [$sizes];
|
||||
$errs = [$errs];
|
||||
}
|
||||
|
||||
foreach ($names as $i => $orig) {
|
||||
$file = [
|
||||
'name' => $orig,
|
||||
'tmp_name' => $tmp[$i] ?? '',
|
||||
'size' => $sizes[$i] ?? 0,
|
||||
'error' => $errs[$i] ?? UPLOAD_ERR_NO_FILE,
|
||||
];
|
||||
|
||||
try {
|
||||
$base = (string)pathinfo((string)$orig, PATHINFO_FILENAME);
|
||||
$base = trim(preg_replace('/[^\p{L}\p{N}._-]+/u', '_', $base) ?? 'photo', '._-');
|
||||
if ($base === '') $base = 'photo';
|
||||
|
||||
$codeName = nextUniqueCodeName($base);
|
||||
$photoId = photoCreate($sectionId, $codeName, null, nextSortOrderForSection($sectionId));
|
||||
$saved = saveSingleImage($file, $codeName, $sectionId);
|
||||
photoFileUpsert($photoId, 'before', $saved['path'], $saved['mime'], $saved['size']);
|
||||
$ok++;
|
||||
} catch (Throwable $e) {
|
||||
$errors[] = (string)$orig . ': ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
saveSortData($sortFile, $sortData);
|
||||
return ['ok' => $ok, 'errors' => $errors];
|
||||
}
|
||||
|
||||
$categories = listCategories($photosDir, $sortData);
|
||||
$selectedCategory = sanitizeCategoryName((string)($_GET['edit_category'] ?? ($_POST['category'] ?? '')));
|
||||
$photos = $selectedCategory !== '' ? listPhotos($photosDir, $thumbsDir, $selectedCategory, $sortData) : [];
|
||||
function saveSingleImage(array $file, string $baseName, int $sectionId): array
|
||||
{
|
||||
$allowedMime = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif'];
|
||||
$err = (int)($file['error'] ?? UPLOAD_ERR_NO_FILE);
|
||||
if ($err !== UPLOAD_ERR_OK) throw new RuntimeException('Ошибка загрузки');
|
||||
$size = (int)($file['size'] ?? 0);
|
||||
if ($size < 1 || $size > MAX_UPLOAD_BYTES) throw new RuntimeException('Превышен лимит 3 МБ');
|
||||
|
||||
?><!doctype html>
|
||||
<html lang="ru"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Админка галереи</title>
|
||||
<link rel="icon" type="image/svg+xml" href="<?= h(assetUrl('favicon.svg')) ?>">
|
||||
<link rel="stylesheet" href="<?= h(assetUrl('style.css')) ?>">
|
||||
<style>
|
||||
.admin-wrap{max-width:1150px;margin:0 auto;padding:24px}.card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:14px;margin-bottom:14px}
|
||||
.grid{display:grid;gap:12px;grid-template-columns:1fr 1fr}.full{grid-column:1/-1}.in{width:100%;padding:8px;border:1px solid #d1d5db;border-radius:8px}
|
||||
.btn{border:0;background:#1f6feb;color:#fff;padding:8px 12px;border-radius:8px;cursor:pointer}.btn-danger{background:#b42318}.muted{color:#667085;font-size:13px}
|
||||
.table{width:100%;border-collapse:collapse}.table td,.table th{padding:8px;border-bottom:1px solid #eee;vertical-align:top}.ok{background:#ecfdf3;padding:8px;border-radius:8px;margin-bottom:8px}.err{background:#fef2f2;padding:8px;border-radius:8px;margin-bottom:8px}
|
||||
@media (max-width:900px){.grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
</head><body><div class="admin-wrap">
|
||||
<h1>Админка галереи</h1>
|
||||
<p><a href="./">← В галерею</a></p>
|
||||
<?php if ($message !== ''): ?><div class="ok"><?= h($message) ?></div><?php endif; ?>
|
||||
<?php foreach ($errors as $e): ?><div class="err"><?= h($e) ?></div><?php endforeach; ?>
|
||||
$tmp = (string)($file['tmp_name'] ?? '');
|
||||
if (!is_uploaded_file($tmp)) throw new RuntimeException('Некорректный источник');
|
||||
|
||||
<div class="grid">
|
||||
<section class="card">
|
||||
<h3>Создать папку</h3>
|
||||
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>">
|
||||
<input type="hidden" name="token" value="<?= h($tokenIncoming) ?>"><input type="hidden" name="action" value="create_category">
|
||||
<input class="in" name="category_name" placeholder="например: Тест" required>
|
||||
<p style="margin-top:8px"><button class="btn" type="submit">Создать</button></p>
|
||||
</form>
|
||||
</section>
|
||||
$mime = mime_content_type($tmp) ?: '';
|
||||
if (!isset($allowedMime[$mime])) throw new RuntimeException('Недопустимый тип файла');
|
||||
|
||||
<section class="card full">
|
||||
<h3>Категории (редактирование / сортировка / удаление)</h3>
|
||||
<table class="table"><tr><th>Категория</th><th>Порядок</th><th>Новое имя</th><th>Действия</th></tr>
|
||||
<?php foreach ($categories as $c): ?>
|
||||
<tr>
|
||||
<td><a href="?token=<?= urlencode($tokenIncoming) ?>&edit_category=<?= urlencode($c) ?>"><?= h($c) ?></a></td>
|
||||
<td>
|
||||
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>">
|
||||
<input type="hidden" name="token" value="<?= h($tokenIncoming) ?>"><input type="hidden" name="action" value="category_update">
|
||||
<input type="hidden" name="category_current" value="<?= h($c) ?>">
|
||||
<input class="in" name="category_sort" type="number" value="<?= (int)($sortData['categories'][$c] ?? 1000) ?>">
|
||||
</td>
|
||||
<td><input class="in" name="category_new_name" value="<?= h($c) ?>"></td>
|
||||
<td style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn" type="submit">Сохранить</button>
|
||||
</form>
|
||||
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>" onsubmit="return confirm('Удалить категорию и все фото?')">
|
||||
<input type="hidden" name="token" value="<?= h($tokenIncoming) ?>"><input type="hidden" name="action" value="category_delete"><input type="hidden" name="category_current" value="<?= h($c) ?>">
|
||||
<button class="btn btn-danger" type="submit">Удалить</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
</section>
|
||||
$safeBase = preg_replace('/[^\p{L}\p{N}._-]+/u', '_', $baseName) ?? 'photo';
|
||||
$safeBase = trim($safeBase, '._-');
|
||||
if ($safeBase === '') $safeBase = 'photo';
|
||||
|
||||
<section class="card full">
|
||||
<h3>Фото в категории: <?= h($selectedCategory ?: '—') ?></h3>
|
||||
<?php if ($selectedCategory !== ''): ?>
|
||||
<form method="post" enctype="multipart/form-data" action="?token=<?= urlencode($tokenIncoming) ?>&edit_category=<?= urlencode($selectedCategory) ?>" style="margin:10px 0 14px;display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||||
<input type="hidden" name="token" value="<?= h($tokenIncoming) ?>"><input type="hidden" name="action" value="upload">
|
||||
<input type="hidden" name="category" value="<?= h($selectedCategory) ?>">
|
||||
<input type="file" name="photos[]" accept="image/jpeg,image/png,image/webp,image/gif" multiple required>
|
||||
<button class="btn" type="submit">Загрузить в «<?= h($selectedCategory) ?>»</button>
|
||||
</form>
|
||||
<p class="muted" style="margin-top:-6px">Только JPG/PNG/WEBP/GIF, максимум 3 МБ на файл.</p>
|
||||
<?php endif; ?>
|
||||
<?php if ($selectedCategory === ''): ?>
|
||||
<p class="muted">Сначала выбери категорию в блоке выше (клик по её названию).</p>
|
||||
<?php elseif ($photos === []): ?>
|
||||
<p class="muted">В категории пока нет фото.</p>
|
||||
<?php else: ?>
|
||||
<table class="table"><tr><th>Превью</th><th>Фото</th><th>Порядок</th><th>Новое имя (без расширения)</th><th>Действия</th></tr>
|
||||
<?php foreach ($photos as $p): ?>
|
||||
<tr>
|
||||
<td><?php if ($p['thumb'] !== ''): ?><img src="<?= h($p['thumb']) ?>" alt="" style="width:78px;height:52px;object-fit:cover;border-radius:6px;border:1px solid #e5e7eb"><?php endif; ?></td>
|
||||
<td><?= h($p['file']) ?></td>
|
||||
<td>
|
||||
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>&edit_category=<?= urlencode($selectedCategory) ?>">
|
||||
<input type="hidden" name="token" value="<?= h($tokenIncoming) ?>"><input type="hidden" name="action" value="photo_update">
|
||||
<input type="hidden" name="category" value="<?= h($selectedCategory) ?>"><input type="hidden" name="photo_current" value="<?= h($p['file']) ?>">
|
||||
<input class="in" type="number" name="photo_sort" value="<?= (int)$p['sort'] ?>">
|
||||
</td>
|
||||
<td><input class="in" name="photo_new_name" value="<?= h(pathinfo($p['file'], PATHINFO_FILENAME)) ?>"></td>
|
||||
<td style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn" type="submit">Сохранить</button>
|
||||
</form>
|
||||
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>&edit_category=<?= urlencode($selectedCategory) ?>" onsubmit="return confirm('Удалить фото?')">
|
||||
<input type="hidden" name="token" value="<?= h($tokenIncoming) ?>"><input type="hidden" name="action" value="photo_delete">
|
||||
<input type="hidden" name="category" value="<?= h($selectedCategory) ?>"><input type="hidden" name="photo_current" value="<?= h($p['file']) ?>">
|
||||
<button class="btn btn-danger" type="submit">Удалить</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</div></div></body></html>
|
||||
<?php
|
||||
function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
|
||||
function assetUrl(string $relativePath): string { $file=__DIR__ . '/' . ltrim($relativePath, '/'); $v=is_file($file)?(string)filemtime($file):(string)time(); return $relativePath . '?v=' . rawurlencode($v); }
|
||||
function sanitizeCategoryName(string $name): string { $name=trim($name); $name=preg_replace('/[^\p{L}\p{N}\s._-]+/u','',$name)??''; return trim($name,". \t\n\r\0\x0B"); }
|
||||
function sanitizeFileBase(string $name): string { $name=trim($name); $name=preg_replace('/[^\p{L}\p{N}._-]+/u','_',$name)??''; return trim($name,'._-'); }
|
||||
function loadSortData(string $file): array { if(!is_file($file)) return ['categories'=>[],'photos'=>[]]; $d=json_decode((string)file_get_contents($file),true); return is_array($d)?['categories'=>(array)($d['categories']??[]),'photos'=>(array)($d['photos']??[])]:['categories'=>[],'photos'=>[]]; }
|
||||
function saveSortData(string $file, array $data): void { file_put_contents($file, json_encode($data, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT)); }
|
||||
function nextSortIndex(array $map): int { return $map===[]?10:((int)max(array_map('intval',$map))+10); }
|
||||
function listCategories(string $photosDir, array $sortData): array { $out=[]; foreach((@scandir($photosDir)?:[]) as $x){ if($x==='.'||$x==='..')continue; if(is_dir($photosDir.'/'.$x))$out[]=$x; } usort($out, fn($a,$b)=>((int)($sortData['categories'][$a]??1000)<=> (int)($sortData['categories'][$b]??1000)) ?: strnatcasecmp($a,$b)); return $out; }
|
||||
function listPhotos(string $photosDir, string $thumbsDir, string $category, array $sortData): array { $out=[]; $dir=$photosDir.'/'.$category; foreach((@scandir($dir)?:[]) as $f){ if($f==='.'||$f==='..')continue; $p=$dir.'/'.$f; if(!is_file($p)||!isImageExt($f)) continue; $thumbAbs=$thumbsDir.'/'.$category.'/'.pathinfo($f, PATHINFO_FILENAME).'.jpg'; $thumb=is_file($thumbAbs)?('thumbs/'.rawurlencode($category).'/'.rawurlencode(pathinfo($f, PATHINFO_FILENAME).'.jpg')):''; $out[]=['file'=>$f,'sort'=>(int)($sortData['photos'][$category][$f]??1000),'thumb'=>$thumb]; } usort($out, fn($a,$b)=>($a['sort']<=>$b['sort']) ?: strnatcasecmp($a['file'],$b['file'])); return $out; }
|
||||
$ext = $allowedMime[$mime];
|
||||
$dir = __DIR__ . '/photos/section_' . $sectionId;
|
||||
if (!is_dir($dir)) mkdir($dir, 0775, true);
|
||||
$name = uniqueName($dir, $safeBase, $ext);
|
||||
$dest = $dir . '/' . $name;
|
||||
|
||||
function reconcileSortData(string $photosDir, array $sortData): array {
|
||||
$clean=['categories'=>[],'photos'=>[]];
|
||||
$cats=[];
|
||||
foreach((@scandir($photosDir)?:[]) as $c){
|
||||
if($c==='.'||$c==='..') continue;
|
||||
if(!is_dir($photosDir.'/'.$c)) continue;
|
||||
$cats[]=$c;
|
||||
}
|
||||
foreach($cats as $c){
|
||||
$clean['categories'][$c]=(int)($sortData['categories'][$c] ?? 1000);
|
||||
$clean['photos'][$c]=[];
|
||||
foreach((@scandir($photosDir.'/'.$c)?:[]) as $f){
|
||||
if($f==='.'||$f==='..') continue;
|
||||
if(!is_file($photosDir.'/'.$c.'/'.$f) || !isImageExt($f)) continue;
|
||||
$clean['photos'][$c][$f]=(int)($sortData['photos'][$c][$f] ?? 1000);
|
||||
if (!move_uploaded_file($tmp, $dest)) throw new RuntimeException('Не удалось сохранить файл');
|
||||
|
||||
return [
|
||||
'path' => 'photos/section_' . $sectionId . '/' . $name,
|
||||
'mime' => $mime,
|
||||
'size' => $size,
|
||||
];
|
||||
}
|
||||
|
||||
function uniqueName(string $dir, string $base, string $ext): string
|
||||
{
|
||||
$i = 0;
|
||||
do {
|
||||
$name = $i === 0 ? "{$base}.{$ext}" : "{$base}_{$i}.{$ext}";
|
||||
$i++;
|
||||
} while (is_file($dir . '/' . $name));
|
||||
return $name;
|
||||
}
|
||||
|
||||
function nextSortOrderForSection(int $sectionId): int
|
||||
{
|
||||
$st = db()->prepare('SELECT COALESCE(MAX(sort_order),0)+10 FROM photos WHERE section_id=:sid');
|
||||
$st->execute(['sid' => $sectionId]);
|
||||
return (int)$st->fetchColumn();
|
||||
}
|
||||
|
||||
function nextUniqueCodeName(string $base): string
|
||||
{
|
||||
$candidate = $base;
|
||||
$i = 1;
|
||||
while (true) {
|
||||
$st = db()->prepare('SELECT 1 FROM photos WHERE code_name=:c LIMIT 1');
|
||||
$st->execute(['c' => $candidate]);
|
||||
if (!$st->fetchColumn()) {
|
||||
return $candidate;
|
||||
}
|
||||
$candidate = $base . '_' . $i;
|
||||
$i++;
|
||||
}
|
||||
return $clean;
|
||||
}
|
||||
function isImageExt(string $file): bool { return in_array(strtolower(pathinfo($file, PATHINFO_EXTENSION)), ['jpg','jpeg','png','webp','gif'], true); }
|
||||
function rrmdir(string $dir): void { if(!is_dir($dir)) return; $it=scandir($dir)?:[]; foreach($it as $x){ if($x==='.'||$x==='..')continue; $p=$dir.'/'.$x; if(is_dir($p)) rrmdir($p); else @unlink($p);} @rmdir($dir); }
|
||||
function uniqueFileNameForRename(string $dir,string $base,string $ext,string $current): string{ $n=0; do{ $cand=$n===0?"{$base}.{$ext}":"{$base}_{$n}.{$ext}"; if($cand===$current||!file_exists($dir.'/'.$cand)) return $cand; $n++; }while(true); }
|
||||
function handleUploads(array $files, string $targetDir, array &$sortData, string $category): array {
|
||||
$allowedMime=['image/jpeg','image/png','image/webp','image/gif']; $allowedExt=['jpg','jpeg','png','webp','gif']; $ok=0; $errors=[];
|
||||
$names=$files['name']??[]; $tmp=$files['tmp_name']??[]; $sizes=$files['size']??[]; $errs=$files['error']??[];
|
||||
if(!is_array($names)){ $names=[$names]; $tmp=[$tmp]; $sizes=[$sizes]; $errs=[$errs]; }
|
||||
$finfo=finfo_open(FILEINFO_MIME_TYPE);
|
||||
foreach($names as $i=>$orig){ if((int)($errs[$i]??UPLOAD_ERR_NO_FILE)!==UPLOAD_ERR_OK){$errors[]="{$orig}: ошибка загрузки";continue;}
|
||||
$size=(int)($sizes[$i]??0); if($size<1||$size>MAX_UPLOAD_BYTES){$errors[]="{$orig}: >3MB";continue;}
|
||||
$tmpFile=(string)($tmp[$i]??''); if($tmpFile===''||!is_uploaded_file($tmpFile)){ $errors[]="{$orig}: источник"; continue;}
|
||||
$mime=$finfo?(string)finfo_file($finfo,$tmpFile):''; if(!in_array($mime,$allowedMime,true)){ $errors[]="{$orig}: тип {$mime}"; continue;}
|
||||
$ext=strtolower(pathinfo((string)$orig, PATHINFO_EXTENSION)); if(!in_array($ext,$allowedExt,true)){ $errors[]="{$orig}: расширение"; continue;}
|
||||
$base=sanitizeFileBase(pathinfo((string)$orig, PATHINFO_FILENAME)); if($base==='')$base='photo'; $name=uniqueFileNameForRename($targetDir,$base,$ext,'');
|
||||
if(!move_uploaded_file($tmpFile,$targetDir.'/'.$name)){ $errors[]="{$orig}: не сохранить"; continue; }
|
||||
$sortData['photos'][$category][$name]=nextSortIndex((array)($sortData['photos'][$category]??[])); $ok++;
|
||||
}
|
||||
if($finfo)finfo_close($finfo);
|
||||
return ['ok'=>$ok,'errors'=>$errors];
|
||||
}
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Админка</title>
|
||||
<link rel="icon" type="image/svg+xml" href="<?= h(assetUrl('favicon.svg')) ?>">
|
||||
<link rel="stylesheet" href="<?= h(assetUrl('style.css')) ?>">
|
||||
<style>.wrap{max-width:1180px;margin:0 auto;padding:24px}.card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:14px;margin-bottom:14px}.grid{display:grid;gap:12px;grid-template-columns:320px 1fr}.in{width:100%;padding:8px;border:1px solid #d1d5db;border-radius:8px}.btn{border:0;background:#1f6feb;color:#fff;padding:8px 12px;border-radius:8px;cursor:pointer}.btn-danger{background:#b42318}.ok{background:#ecfdf3;padding:8px;border-radius:8px;margin-bottom:8px}.err{background:#fef2f2;padding:8px;border-radius:8px;margin-bottom:8px}.tbl{width:100%;border-collapse:collapse}.tbl td,.tbl th{padding:8px;border-bottom:1px solid #eee;vertical-align:top}.sec a{display:block;padding:8px 10px;border-radius:8px;text-decoration:none;color:#111}.sec a.active{background:#eef4ff;color:#1f6feb}.small{font-size:12px;color:#667085}</style>
|
||||
</head>
|
||||
<body><div class="wrap">
|
||||
<h1>Админка</h1>
|
||||
<?php if ($message!==''): ?><div class="ok"><?= h($message) ?></div><?php endif; ?>
|
||||
<?php foreach($errors as $e): ?><div class="err"><?= h($e) ?></div><?php endforeach; ?>
|
||||
|
||||
<div class="grid">
|
||||
<aside>
|
||||
<section class="card">
|
||||
<h3>Разделы</h3>
|
||||
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>">
|
||||
<input type="hidden" name="action" value="create_section"><input type="hidden" name="token" value="<?= h($tokenIncoming) ?>">
|
||||
<p><input class="in" name="name" placeholder="Новый раздел" required></p>
|
||||
<p><input class="in" type="number" name="sort_order" value="1000"></p>
|
||||
<button class="btn" type="submit">Создать раздел</button>
|
||||
</form>
|
||||
<hr style="border:none;border-top:1px solid #eee;margin:12px 0">
|
||||
<div class="sec">
|
||||
<?php foreach($sections as $s): ?>
|
||||
<a class="<?= (int)$s['id']===$activeSectionId?'active':'' ?>" href="?token=<?= urlencode($tokenIncoming) ?>§ion_id=<?= (int)$s['id'] ?>"><?= h((string)$s['name']) ?> <span class="small">(<?= (int)$s['photos_count'] ?>)</span></a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h3>Комментаторы</h3>
|
||||
<form method="post" action="?token=<?= urlencode($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>
|
||||
<button class="btn" type="submit">Создать</button>
|
||||
</form>
|
||||
<div class="small" style="margin-top:8px">Ссылка доступа показывается в зелёном сообщении после создания.</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<section class="card">
|
||||
<h3>Загрузка фото “до” в выбранный раздел</h3>
|
||||
<?php if ($activeSectionId > 0): ?>
|
||||
<form method="post" enctype="multipart/form-data" action="?token=<?= urlencode($tokenIncoming) ?>§ion_id=<?= (int)$activeSectionId ?>">
|
||||
<input type="hidden" name="action" value="upload_before_bulk"><input type="hidden" name="token" value="<?= h($tokenIncoming) ?>"><input type="hidden" name="section_id" value="<?= (int)$activeSectionId ?>">
|
||||
<p><input type="file" name="before_bulk[]" accept="image/jpeg,image/png,image/webp,image/gif" multiple required></p>
|
||||
<button class="btn" type="submit">Загрузить массово</button>
|
||||
</form>
|
||||
<p class="small">После загрузки имя (code_name) заполняется автоматически из имени файла — затем можно отредактировать.</p>
|
||||
<?php else: ?>
|
||||
<p class="small">Сначала выбери раздел слева.</p>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h3>Фото в разделе</h3>
|
||||
<table class="tbl">
|
||||
<tr><th>Превью</th><th>Поля</th><th>Действия</th></tr>
|
||||
<?php foreach($photos as $p): ?>
|
||||
<tr>
|
||||
<td><?php if (!empty($p['before_file_id'])): ?><img src="?action=image&file_id=<?= (int)$p['before_file_id'] ?>" style="width:100px;height:70px;object-fit:cover;border:1px solid #e5e7eb;border-radius:6px"><?php endif; ?></td>
|
||||
<td>
|
||||
<form method="post" enctype="multipart/form-data" action="?token=<?= urlencode($tokenIncoming) ?>§ion_id=<?= (int)$activeSectionId ?>">
|
||||
<input type="hidden" name="action" value="photo_update"><input type="hidden" name="token" value="<?= h($tokenIncoming) ?>"><input type="hidden" name="photo_id" value="<?= (int)$p['id'] ?>">
|
||||
<p><input class="in" name="code_name" value="<?= h((string)$p['code_name']) ?>"></p>
|
||||
<p><input class="in" type="number" name="sort_order" value="<?= (int)$p['sort_order'] ?>"></p>
|
||||
<p><textarea class="in" name="description" placeholder="Комментарий"><?= h((string)($p['description'] ?? '')) ?></textarea></p>
|
||||
<p class="small">Фото после (опционально): <input type="file" name="after" accept="image/jpeg,image/png,image/webp,image/gif"></p>
|
||||
<button class="btn" type="submit">Сохранить</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>§ion_id=<?= (int)$activeSectionId ?>" onsubmit="return confirm('Удалить фото?')">
|
||||
<input type="hidden" name="action" value="photo_delete"><input type="hidden" name="token" value="<?= h($tokenIncoming) ?>"><input type="hidden" name="photo_id" value="<?= (int)$p['id'] ?>">
|
||||
<button class="btn btn-danger" type="submit">Удалить</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h3>Комментаторы и комментарии</h3>
|
||||
<table class="tbl"><tr><th>Пользователь</th><th>Действие</th></tr>
|
||||
<?php foreach($commenters as $u): ?>
|
||||
<tr><td><?= h((string)$u['display_name']) ?></td><td>
|
||||
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>" 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'] ?>">
|
||||
<button class="btn btn-danger" type="submit">Удалить доступ</button>
|
||||
</form>
|
||||
</td></tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<hr style="border:none;border-top:1px solid #eee;margin:12px 0">
|
||||
<table class="tbl"><tr><th>Фото</th><th>Пользователь</th><th>Комментарий</th><th></th></tr>
|
||||
<?php foreach($latestComments as $c): ?>
|
||||
<tr>
|
||||
<td><?= h((string)$c['code_name']) ?></td>
|
||||
<td><?= h((string)($c['display_name'] ?? '—')) ?></td>
|
||||
<td><?= h((string)$c['comment_text']) ?></td>
|
||||
<td>
|
||||
<form method="post" action="?token=<?= urlencode($tokenIncoming) ?>" 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'] ?>">
|
||||
<button class="btn btn-danger" type="submit">Удалить</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div></body></html>
|
||||
|
|
|
|||
212
index-mysql.php
212
index-mysql.php
|
|
@ -1,211 +1,3 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/lib/db_gallery.php';
|
||||
|
||||
$action = (string)($_GET['action'] ?? '');
|
||||
if ($action === 'image') {
|
||||
serveImage();
|
||||
}
|
||||
|
||||
$viewerToken = trim((string)($_GET['viewer'] ?? ''));
|
||||
$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);
|
||||
$text = trim((string)($_POST['comment_text'] ?? ''));
|
||||
|
||||
if ($token !== '' && $photoId > 0 && $text !== '') {
|
||||
$u = commenterByToken($token);
|
||||
if ($u) {
|
||||
commentAdd($photoId, (int)$u['id'], mb_substr($text, 0, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
$redirect = './index-mysql.php?photo_id=' . $photoId;
|
||||
if ($token !== '') {
|
||||
$redirect .= '&viewer=' . urlencode($token);
|
||||
}
|
||||
header('Location: ' . $redirect);
|
||||
exit;
|
||||
}
|
||||
|
||||
$sections = sectionsAll();
|
||||
$activeSectionId = (int)($_GET['section_id'] ?? 0);
|
||||
$activePhotoId = (int)($_GET['photo_id'] ?? 0);
|
||||
|
||||
if ($activePhotoId > 0) {
|
||||
$photo = photoById($activePhotoId);
|
||||
if (!$photo) {
|
||||
http_response_code(404);
|
||||
$photo = null;
|
||||
}
|
||||
$comments = $photo ? commentsByPhoto($activePhotoId) : [];
|
||||
} else {
|
||||
$photo = null;
|
||||
$comments = [];
|
||||
}
|
||||
|
||||
$photos = $activeSectionId > 0 ? photosBySection($activeSectionId) : [];
|
||||
|
||||
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 serveImage(): never
|
||||
{
|
||||
$fileId = (int)($_GET['file_id'] ?? 0);
|
||||
if ($fileId < 1) {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
|
||||
$f = photoFileById($fileId);
|
||||
if (!$f) {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
|
||||
$abs = __DIR__ . '/' . ltrim((string)$f['file_path'], '/');
|
||||
if (!is_file($abs)) {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
|
||||
$kind = (string)$f['kind'];
|
||||
if ($kind !== 'after') {
|
||||
header('Content-Type: ' . ((string)$f['mime_type'] ?: 'application/octet-stream'));
|
||||
header('Content-Length: ' . (string)filesize($abs));
|
||||
header('Cache-Control: private, max-age=60');
|
||||
header('X-Robots-Tag: noindex, nofollow');
|
||||
readfile($abs);
|
||||
exit;
|
||||
}
|
||||
|
||||
outputWatermarked($abs, (string)$f['mime_type']);
|
||||
}
|
||||
|
||||
function outputWatermarked(string $path, string $mime): never
|
||||
{
|
||||
$text = 'photo.andr33v.ru';
|
||||
|
||||
if (extension_loaded('imagick')) {
|
||||
$im = new Imagick($path);
|
||||
$draw = new ImagickDraw();
|
||||
$draw->setFillColor(new ImagickPixel('rgba(255,255,255,0.22)'));
|
||||
$draw->setFontSize(max(18, (int)($im->getImageWidth() / 24)));
|
||||
$draw->setGravity(Imagick::GRAVITY_SOUTHEAST);
|
||||
$im->annotateImage($draw, 20, 24, -15, $text);
|
||||
header('Content-Type: ' . ($mime !== '' ? $mime : 'image/jpeg'));
|
||||
$im->setImageCompressionQuality(88);
|
||||
echo $im;
|
||||
$im->clear();
|
||||
$im->destroy();
|
||||
exit;
|
||||
}
|
||||
|
||||
[$w, $h, $type] = @getimagesize($path) ?: [0,0,0];
|
||||
if ($w < 1 || $h < 1) {
|
||||
readfile($path);
|
||||
exit;
|
||||
}
|
||||
|
||||
$img = match ($type) {
|
||||
IMAGETYPE_JPEG => imagecreatefromjpeg($path),
|
||||
IMAGETYPE_PNG => imagecreatefrompng($path),
|
||||
IMAGETYPE_GIF => imagecreatefromgif($path),
|
||||
IMAGETYPE_WEBP => function_exists('imagecreatefromwebp') ? imagecreatefromwebp($path) : null,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (!$img) {
|
||||
readfile($path);
|
||||
exit;
|
||||
}
|
||||
|
||||
$font = 5;
|
||||
$color = imagecolorallocatealpha($img, 255, 255, 255, 90);
|
||||
$x = max(5, $w - (imagefontwidth($font) * strlen($text)) - 15);
|
||||
$y = max(5, $h - imagefontheight($font) - 12);
|
||||
imagestring($img, $font, $x, $y, $text, $color);
|
||||
|
||||
header('Content-Type: image/jpeg');
|
||||
imagejpeg($img, null, 88);
|
||||
imagedestroy($img);
|
||||
exit;
|
||||
}
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Фотогалерея (MySQL)</title>
|
||||
<link rel="icon" type="image/svg+xml" href="<?= h(assetUrl('favicon.svg')) ?>">
|
||||
<link rel="stylesheet" href="<?= h(assetUrl('style.css')) ?>">
|
||||
<style>.note{color:#6b7280;font-size:13px}.page{display:grid;gap:16px;grid-template-columns:300px 1fr}.panel{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:14px}.sec a{display:block;padding:8px 10px;border-radius:8px;text-decoration:none;color:#111}.sec a.active{background:#eef4ff;color:#1f6feb}.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 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}.two{display:grid;gap:10px;grid-template-columns:1fr 1fr}.cmt{border-top:1px solid #eee;padding:8px 0}.muted{color:#6b7280;font-size:13px}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header class="topbar"><h1>Фотогалерея</h1><p class="subtitle">Простая галерея, которая управляется через файловый менеджер.</p></header>
|
||||
<div class="page">
|
||||
<aside class="panel sec">
|
||||
<h3>Разделы</h3>
|
||||
<?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>
|
||||
<?php endforeach; ?>
|
||||
<p class="note" style="margin-top:12px"><?= $viewer ? 'Вы авторизованы для комментариев: ' . h((string)$viewer['display_name']) : 'Режим просмотра' ?></p>
|
||||
<p><a href="admin-mysql.php?token=<?= h(urlencode((string)($_GET['token'] ?? ''))) ?>">Админка MySQL</a></p>
|
||||
</aside>
|
||||
<main>
|
||||
<?php if ($activePhotoId > 0 && $photo): ?>
|
||||
<section class="panel detail">
|
||||
<p><a href="?section_id=<?= (int)$photo['section_id'] ?><?= $viewerToken!=='' ? '&viewer=' . urlencode($viewerToken) : '' ?>">← к разделу</a></p>
|
||||
<h2><?= h((string)$photo['code_name']) ?></h2>
|
||||
<p class="muted"><?= h((string)($photo['description'] ?? '')) ?></p>
|
||||
<div class="two">
|
||||
<?php if (!empty($photo['before_file_id'])): ?><div><div class="muted">До обработки</div><img src="?action=image&file_id=<?= (int)$photo['before_file_id'] ?>" alt=""></div><?php endif; ?>
|
||||
<?php if (!empty($photo['after_file_id'])): ?><div><div class="muted">После обработки (watermark)</div><img src="?action=image&file_id=<?= (int)$photo['after_file_id'] ?>" alt=""></div><?php endif; ?>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top:16px">Комментарии</h3>
|
||||
<?php if ($viewer): ?>
|
||||
<form method="post" action="?photo_id=<?= (int)$photo['id'] ?>&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="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>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<p class="muted">Комментарии может оставлять только пользователь с персональной ссылкой.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?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>
|
||||
<?php endforeach; ?>
|
||||
</section>
|
||||
<?php else: ?>
|
||||
<section class="panel">
|
||||
<h3>Фотографии</h3>
|
||||
<?php if ($activeSectionId < 1): ?>
|
||||
<p class="muted">Выберите раздел слева.</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'] ?>§ion_id=<?= (int)$activeSectionId ?><?= $viewerToken!=='' ? '&viewer=' . urlencode($viewerToken) : '' ?>" style="text-decoration:none;color:inherit">
|
||||
<?php if (!empty($p['before_file_id'])): ?><img src="?action=image&file_id=<?= (int)$p['before_file_id'] ?>" alt=""><?php endif; ?>
|
||||
<div class="cap"><strong><?= h((string)$p['code_name']) ?></strong><br><span class="muted"><?= h((string)($p['description'] ?? '')) ?></span></div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
// Backward-compat alias
|
||||
require __DIR__ . '/index.php';
|
||||
|
|
|
|||
500
index.php
500
index.php
|
|
@ -2,376 +2,192 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
const THUMB_WIDTH = 360;
|
||||
const THUMB_HEIGHT = 240;
|
||||
require_once __DIR__ . '/lib/db_gallery.php';
|
||||
|
||||
$baseDir = __DIR__;
|
||||
$photosDir = $baseDir . '/photos';
|
||||
$thumbsDir = $baseDir . '/thumbs';
|
||||
$dataDir = $baseDir . '/data';
|
||||
$lastIndexedFile = $dataDir . '/last_indexed.txt';
|
||||
$sortFile = $dataDir . '/sort.json';
|
||||
|
||||
ensureDirectories([$photosDir, $thumbsDir, $dataDir]);
|
||||
$sortData = loadSortData($sortFile);
|
||||
|
||||
$action = $_GET['action'] ?? null;
|
||||
$action = (string)($_GET['action'] ?? '');
|
||||
if ($action === 'image') {
|
||||
serveImage($photosDir);
|
||||
serveImage();
|
||||
}
|
||||
|
||||
$lastIndexedTimestamp = readLastIndexedTimestamp($lastIndexedFile);
|
||||
$maxTimestamp = $lastIndexedTimestamp;
|
||||
$viewerToken = trim((string)($_GET['viewer'] ?? ''));
|
||||
$viewer = $viewerToken !== '' ? commenterByToken($viewerToken) : null;
|
||||
|
||||
$categories = scanCategories($photosDir, $sortData);
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string)($_POST['action'] ?? '') === 'add_comment') {
|
||||
$token = trim((string)($_POST['viewer'] ?? ''));
|
||||
$photoId = (int)($_POST['photo_id'] ?? 0);
|
||||
$text = trim((string)($_POST['comment_text'] ?? ''));
|
||||
|
||||
foreach ($categories as $categoryName => &$images) {
|
||||
$categoryThumbDir = $thumbsDir . '/' . $categoryName;
|
||||
if (!is_dir($categoryThumbDir)) {
|
||||
mkdir($categoryThumbDir, 0775, true);
|
||||
}
|
||||
|
||||
foreach ($images as &$image) {
|
||||
$sourcePath = $image['abs_path'];
|
||||
$sourceMtime = (int) filemtime($sourcePath);
|
||||
$maxTimestamp = max($maxTimestamp, $sourceMtime);
|
||||
|
||||
$thumbExt = 'jpg';
|
||||
$thumbName = pathinfo($image['filename'], PATHINFO_FILENAME) . '.jpg';
|
||||
$thumbAbsPath = $categoryThumbDir . '/' . $thumbName;
|
||||
$thumbWebPath = 'thumbs/' . rawurlencode($categoryName) . '/' . rawurlencode($thumbName);
|
||||
|
||||
$needsThumb = !file_exists($thumbAbsPath)
|
||||
|| filemtime($thumbAbsPath) < $sourceMtime
|
||||
|| $sourceMtime > $lastIndexedTimestamp;
|
||||
|
||||
if ($needsThumb) {
|
||||
createThumbnail($sourcePath, $thumbAbsPath, THUMB_WIDTH, THUMB_HEIGHT);
|
||||
if ($token !== '' && $photoId > 0 && $text !== '') {
|
||||
$u = commenterByToken($token);
|
||||
if ($u) {
|
||||
commentAdd($photoId, (int)$u['id'], mb_substr($text, 0, 1000));
|
||||
}
|
||||
|
||||
$image['thumb_path'] = $thumbWebPath;
|
||||
$image['full_path'] = '?action=image&category=' . rawurlencode($categoryName) . '&file=' . rawurlencode($image['filename']);
|
||||
$image['title'] = titleFromFilename($image['filename']);
|
||||
$image['mtime'] = $sourceMtime;
|
||||
}
|
||||
|
||||
usort($images, static function (array $a, array $b): int {
|
||||
$bySort = ($a['sort_index'] ?? 0) <=> ($b['sort_index'] ?? 0);
|
||||
if ($bySort !== 0) {
|
||||
return $bySort;
|
||||
}
|
||||
|
||||
return ($b['mtime'] ?? 0) <=> ($a['mtime'] ?? 0);
|
||||
});
|
||||
}
|
||||
unset($images, $image);
|
||||
|
||||
if ($maxTimestamp > $lastIndexedTimestamp) {
|
||||
file_put_contents($lastIndexedFile, (string)$maxTimestamp);
|
||||
}
|
||||
|
||||
$selectedCategory = isset($_GET['category']) ? trim((string)$_GET['category']) : null;
|
||||
if ($selectedCategory !== null && $selectedCategory !== '' && !isset($categories[$selectedCategory])) {
|
||||
http_response_code(404);
|
||||
$selectedCategory = null;
|
||||
}
|
||||
|
||||
?><!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Фотогалерея</title>
|
||||
<link rel="icon" type="image/svg+xml" href="<?= htmlspecialchars(assetUrl('favicon.svg'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>">
|
||||
<link rel="stylesheet" href="<?= htmlspecialchars(assetUrl('style.css'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<h1>Фотогалерея</h1>
|
||||
<p class="subtitle">Простая галерея, которая управляется через файловый менеджер.</p>
|
||||
</header>
|
||||
|
||||
<?php if ($selectedCategory === null): ?>
|
||||
<section class="panel">
|
||||
<h2>Категории</h2>
|
||||
<?php if (count($categories) === 0): ?>
|
||||
<p class="empty">Пока нет папок с фото. Загрузите файлы в <code>photos/<категория>/</code> через FTP.</p>
|
||||
<?php else: ?>
|
||||
<div class="categories-grid">
|
||||
<?php foreach ($categories as $categoryName => $images): ?>
|
||||
<?php $cover = $images[0]['thumb_path'] ?? null; ?>
|
||||
<a class="category-card" href="?category=<?= urlencode($categoryName) ?>">
|
||||
<?php if ($cover): ?>
|
||||
<img
|
||||
class="category-cover"
|
||||
src="<?= htmlspecialchars($cover, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>"
|
||||
alt="<?= htmlspecialchars($categoryName, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>"
|
||||
loading="lazy"
|
||||
>
|
||||
<?php endif; ?>
|
||||
<span class="category-title"><?= htmlspecialchars($categoryName, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></span>
|
||||
<span class="category-count"><?= count($images) ?> фото</span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
<?php else: ?>
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2><?= htmlspecialchars($selectedCategory, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></h2>
|
||||
<a class="btn" href="./">← Все категории</a>
|
||||
</div>
|
||||
|
||||
<?php $images = $categories[$selectedCategory] ?? []; ?>
|
||||
<?php if (count($images) === 0): ?>
|
||||
<p class="empty">В этой категории пока нет изображений.</p>
|
||||
<?php else: ?>
|
||||
<div class="gallery-grid">
|
||||
<?php foreach ($images as $img): ?>
|
||||
<button
|
||||
class="thumb-card js-thumb"
|
||||
data-full="<?= htmlspecialchars($img['full_path'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>"
|
||||
data-title="<?= htmlspecialchars($img['title'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>"
|
||||
type="button"
|
||||
>
|
||||
<img
|
||||
src="<?= htmlspecialchars($img['thumb_path'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>"
|
||||
alt="<?= htmlspecialchars($img['title'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>"
|
||||
loading="lazy"
|
||||
>
|
||||
<span class="thumb-title"><?= htmlspecialchars($img['title'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></span>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<footer class="footer">
|
||||
<small>Последняя индексация: <?= file_exists($lastIndexedFile) ? date('Y-m-d H:i:s', (int)trim((string)file_get_contents($lastIndexedFile))) : '—' ?></small>
|
||||
<small class="footer-author">by <a href="https://t.me/andr33vru" target="_blank" rel="noopener noreferrer">andr33vru</a></small>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div class="lightbox" id="lightbox" hidden>
|
||||
<div class="lightbox-backdrop js-close"></div>
|
||||
<div class="lightbox-content">
|
||||
<button class="lightbox-close js-close" type="button" aria-label="Закрыть">×</button>
|
||||
<img id="lightboxImage" src="" alt="">
|
||||
<div id="lightboxTitle" class="lightbox-title"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="<?= htmlspecialchars(assetUrl('app.js'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
|
||||
function serveImage(string $photosDir): never
|
||||
{
|
||||
$category = isset($_GET['category']) ? basename((string)$_GET['category']) : '';
|
||||
$file = isset($_GET['file']) ? basename((string)$_GET['file']) : '';
|
||||
|
||||
if ($category === '' || $file === '') {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
$redirect = './?photo_id=' . $photoId;
|
||||
if ($token !== '') {
|
||||
$redirect .= '&viewer=' . urlencode($token);
|
||||
}
|
||||
|
||||
$path = $photosDir . '/' . $category . '/' . $file;
|
||||
if (!is_file($path) || !isImage($path)) {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
|
||||
$mime = mime_content_type($path) ?: 'application/octet-stream';
|
||||
header('Content-Type: ' . $mime);
|
||||
header('Content-Length: ' . (string)filesize($path));
|
||||
header('X-Robots-Tag: noindex, nofollow');
|
||||
header('Content-Disposition: inline; filename="image"');
|
||||
header('Cache-Control: private, max-age=60');
|
||||
|
||||
readfile($path);
|
||||
header('Location: ' . $redirect);
|
||||
exit;
|
||||
}
|
||||
|
||||
function titleFromFilename(string $filename): string
|
||||
$sections = sectionsAll();
|
||||
$activeSectionId = (int)($_GET['section_id'] ?? 0);
|
||||
$activePhotoId = (int)($_GET['photo_id'] ?? 0);
|
||||
|
||||
$photo = $activePhotoId > 0 ? photoById($activePhotoId) : null;
|
||||
$comments = $photo ? commentsByPhoto($activePhotoId) : [];
|
||||
$photos = $activeSectionId > 0 ? photosBySection($activeSectionId) : [];
|
||||
|
||||
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 serveImage(): never
|
||||
{
|
||||
$name = pathinfo($filename, PATHINFO_FILENAME);
|
||||
$name = str_replace(['_', '-'], ' ', $name);
|
||||
$name = preg_replace('/\s+/', ' ', $name) ?? $name;
|
||||
$name = trim($name);
|
||||
|
||||
if ($name === '') {
|
||||
return $filename;
|
||||
$fileId = (int)($_GET['file_id'] ?? 0);
|
||||
if ($fileId < 1) {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (function_exists('mb_convert_case')) {
|
||||
return mb_convert_case($name, MB_CASE_TITLE, 'UTF-8');
|
||||
$f = photoFileById($fileId);
|
||||
if (!$f) {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
|
||||
return ucwords(strtolower($name));
|
||||
$abs = __DIR__ . '/' . ltrim((string)$f['file_path'], '/');
|
||||
if (!is_file($abs)) {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ((string)$f['kind'] !== 'after') {
|
||||
header('Content-Type: ' . ((string)$f['mime_type'] ?: 'application/octet-stream'));
|
||||
header('Content-Length: ' . (string)filesize($abs));
|
||||
header('Cache-Control: private, max-age=60');
|
||||
header('X-Robots-Tag: noindex, nofollow');
|
||||
readfile($abs);
|
||||
exit;
|
||||
}
|
||||
|
||||
outputWatermarked($abs, (string)$f['mime_type']);
|
||||
}
|
||||
|
||||
function ensureDirectories(array $dirs): void
|
||||
function outputWatermarked(string $path, string $mime): never
|
||||
{
|
||||
foreach ($dirs as $dir) {
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0775, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
$text = 'photo.andr33v.ru';
|
||||
|
||||
function readLastIndexedTimestamp(string $path): int
|
||||
{
|
||||
if (!file_exists($path)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$value = trim((string) file_get_contents($path));
|
||||
return ctype_digit($value) ? (int)$value : 0;
|
||||
}
|
||||
|
||||
function scanCategories(string $photosDir, array $sortData): array
|
||||
{
|
||||
$result = [];
|
||||
$categorySortMap = (array)($sortData['categories'] ?? []);
|
||||
$photoSortMap = (array)($sortData['photos'] ?? []);
|
||||
|
||||
$entries = @scandir($photosDir) ?: [];
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$categoryPath = $photosDir . '/' . $entry;
|
||||
if (!is_dir($categoryPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$images = [];
|
||||
$files = @scandir($categoryPath) ?: [];
|
||||
foreach ($files as $filename) {
|
||||
if ($filename === '.' || $filename === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$absPath = $categoryPath . '/' . $filename;
|
||||
if (!is_file($absPath) || !isImage($absPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$images[] = [
|
||||
'filename' => $filename,
|
||||
'abs_path' => $absPath,
|
||||
'sort_index' => (int)(($photoSortMap[$entry][$filename] ?? 1000)),
|
||||
];
|
||||
}
|
||||
|
||||
$result[$entry] = $images;
|
||||
}
|
||||
|
||||
uksort($result, static function (string $a, string $b) use ($categorySortMap): int {
|
||||
$aSort = (int)($categorySortMap[$a] ?? 1000);
|
||||
$bSort = (int)($categorySortMap[$b] ?? 1000);
|
||||
|
||||
if ($aSort !== $bSort) {
|
||||
return $aSort <=> $bSort;
|
||||
}
|
||||
|
||||
return strnatcasecmp($a, $b);
|
||||
});
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
function assetUrl(string $relativePath): string
|
||||
{
|
||||
$file = __DIR__ . '/' . ltrim($relativePath, '/');
|
||||
$v = is_file($file) ? (string)filemtime($file) : (string)time();
|
||||
return $relativePath . '?v=' . rawurlencode($v);
|
||||
}
|
||||
|
||||
function loadSortData(string $sortFile): array
|
||||
{
|
||||
if (!is_file($sortFile)) {
|
||||
return ['categories' => [], 'photos' => []];
|
||||
}
|
||||
|
||||
$json = file_get_contents($sortFile);
|
||||
if ($json === false || trim($json) === '') {
|
||||
return ['categories' => [], 'photos' => []];
|
||||
}
|
||||
|
||||
$data = json_decode($json, true);
|
||||
if (!is_array($data)) {
|
||||
return ['categories' => [], 'photos' => []];
|
||||
}
|
||||
|
||||
return [
|
||||
'categories' => is_array($data['categories'] ?? null) ? $data['categories'] : [],
|
||||
'photos' => is_array($data['photos'] ?? null) ? $data['photos'] : [],
|
||||
];
|
||||
}
|
||||
|
||||
function isImage(string $path): bool
|
||||
{
|
||||
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
return in_array($ext, ['jpg', 'jpeg', 'png', 'webp', 'gif'], true);
|
||||
}
|
||||
|
||||
function createThumbnail(string $srcPath, string $thumbPath, int $targetWidth, int $targetHeight): void
|
||||
{
|
||||
if (extension_loaded('imagick')) {
|
||||
createThumbnailWithImagick($srcPath, $thumbPath, $targetWidth, $targetHeight);
|
||||
return;
|
||||
$im = new Imagick($path);
|
||||
$draw = new ImagickDraw();
|
||||
$draw->setFillColor(new ImagickPixel('rgba(255,255,255,0.22)'));
|
||||
$draw->setFontSize(max(18, (int)($im->getImageWidth() / 24)));
|
||||
$draw->setGravity(Imagick::GRAVITY_SOUTHEAST);
|
||||
$im->annotateImage($draw, 20, 24, -15, $text);
|
||||
header('Content-Type: ' . ($mime !== '' ? $mime : 'image/jpeg'));
|
||||
$im->setImageCompressionQuality(88);
|
||||
echo $im;
|
||||
$im->clear();
|
||||
$im->destroy();
|
||||
exit;
|
||||
}
|
||||
|
||||
createThumbnailWithGd($srcPath, $thumbPath, $targetWidth, $targetHeight);
|
||||
}
|
||||
|
||||
function createThumbnailWithImagick(string $srcPath, string $thumbPath, int $targetWidth, int $targetHeight): void
|
||||
{
|
||||
$imagick = new Imagick($srcPath);
|
||||
$imagick->setIteratorIndex(0);
|
||||
$imagick->setImageOrientation(Imagick::ORIENTATION_UNDEFINED);
|
||||
$imagick->thumbnailImage($targetWidth, $targetHeight, true, true);
|
||||
$imagick->setImageFormat('jpeg');
|
||||
$imagick->setImageCompressionQuality(82);
|
||||
$imagick->writeImage($thumbPath);
|
||||
$imagick->clear();
|
||||
$imagick->destroy();
|
||||
}
|
||||
|
||||
function createThumbnailWithGd(string $srcPath, string $thumbPath, int $targetWidth, int $targetHeight): void
|
||||
{
|
||||
[$srcW, $srcH, $type] = @getimagesize($srcPath) ?: [0, 0, 0];
|
||||
if ($srcW < 1 || $srcH < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$src = match ($type) {
|
||||
IMAGETYPE_JPEG => @imagecreatefromjpeg($srcPath),
|
||||
IMAGETYPE_PNG => @imagecreatefrompng($srcPath),
|
||||
IMAGETYPE_GIF => @imagecreatefromgif($srcPath),
|
||||
IMAGETYPE_WEBP => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($srcPath) : null,
|
||||
[$w, $h, $type] = @getimagesize($path) ?: [0,0,0];
|
||||
$img = match ($type) {
|
||||
IMAGETYPE_JPEG => @imagecreatefromjpeg($path),
|
||||
IMAGETYPE_PNG => @imagecreatefrompng($path),
|
||||
IMAGETYPE_GIF => @imagecreatefromgif($path),
|
||||
IMAGETYPE_WEBP => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($path) : null,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (!$src) {
|
||||
return;
|
||||
if (!$img) {
|
||||
readfile($path);
|
||||
exit;
|
||||
}
|
||||
|
||||
$scale = min($targetWidth / $srcW, $targetHeight / $srcH);
|
||||
$dstW = max(1, (int) floor($srcW * $scale));
|
||||
$dstH = max(1, (int) floor($srcH * $scale));
|
||||
$font = 5;
|
||||
$color = imagecolorallocatealpha($img, 255, 255, 255, 90);
|
||||
$x = max(5, $w - (imagefontwidth($font) * strlen($text)) - 15);
|
||||
$y = max(5, $h - imagefontheight($font) - 12);
|
||||
imagestring($img, $font, $x, $y, $text, $color);
|
||||
|
||||
$dst = imagecreatetruecolor($dstW, $dstH);
|
||||
imagecopyresampled($dst, $src, 0, 0, 0, 0, $dstW, $dstH, $srcW, $srcH);
|
||||
|
||||
imagejpeg($dst, $thumbPath, 82);
|
||||
|
||||
imagedestroy($src);
|
||||
imagedestroy($dst);
|
||||
header('Content-Type: image/jpeg');
|
||||
imagejpeg($img, null, 88);
|
||||
imagedestroy($img);
|
||||
exit;
|
||||
}
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Фотогалерея</title>
|
||||
<link rel="icon" type="image/svg+xml" href="<?= h(assetUrl('favicon.svg')) ?>">
|
||||
<link rel="stylesheet" href="<?= h(assetUrl('style.css')) ?>">
|
||||
<style>.note{color:#6b7280;font-size:13px}.page{display:grid;gap:16px;grid-template-columns:300px 1fr}.panel{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:14px}.sec a{display:block;padding:8px 10px;border-radius:8px;text-decoration:none;color:#111}.sec a.active{background:#eef4ff;color:#1f6feb}.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 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}.two{display:grid;gap:10px;grid-template-columns:1fr 1fr}.cmt{border-top:1px solid #eee;padding:8px 0}.muted{color:#6b7280;font-size:13px}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header class="topbar"><h1>Фотогалерея</h1><p class="subtitle">Простая галерея, которая управляется через файловый менеджер.</p></header>
|
||||
<div class="page">
|
||||
<aside class="panel sec">
|
||||
<h3>Разделы</h3>
|
||||
<?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>
|
||||
<?php endforeach; ?>
|
||||
<p class="note" style="margin-top:12px"><?= $viewer ? 'Вы авторизованы для комментариев: ' . h((string)$viewer['display_name']) : 'Режим просмотра' ?></p>
|
||||
</aside>
|
||||
<main>
|
||||
<?php if ($activePhotoId > 0 && $photo): ?>
|
||||
<section class="panel detail">
|
||||
<p><a href="?section_id=<?= (int)$photo['section_id'] ?><?= $viewerToken!=='' ? '&viewer=' . urlencode($viewerToken) : '' ?>">← к разделу</a></p>
|
||||
<h2><?= h((string)$photo['code_name']) ?></h2>
|
||||
<p class="muted"><?= h((string)($photo['description'] ?? '')) ?></p>
|
||||
<div class="two">
|
||||
<?php if (!empty($photo['before_file_id'])): ?><div><div class="muted">До обработки</div><img src="?action=image&file_id=<?= (int)$photo['before_file_id'] ?>" alt=""></div><?php endif; ?>
|
||||
<?php if (!empty($photo['after_file_id'])): ?><div><div class="muted">После обработки (watermark)</div><img src="?action=image&file_id=<?= (int)$photo['after_file_id'] ?>" alt=""></div><?php endif; ?>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top:16px">Комментарии</h3>
|
||||
<?php if ($viewer): ?>
|
||||
<form method="post" action="?photo_id=<?= (int)$photo['id'] ?>&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="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>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<p class="muted">Комментарии может оставлять только пользователь с персональной ссылкой.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?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>
|
||||
<?php endforeach; ?>
|
||||
</section>
|
||||
<?php else: ?>
|
||||
<section class="panel">
|
||||
<h3>Фотографии</h3>
|
||||
<?php if ($activeSectionId < 1): ?>
|
||||
<p class="muted">Выберите раздел слева.</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'] ?>§ion_id=<?= (int)$activeSectionId ?><?= $viewerToken!=='' ? '&viewer=' . urlencode($viewerToken) : '' ?>" style="text-decoration:none;color:inherit">
|
||||
<?php if (!empty($p['before_file_id'])): ?><img src="?action=image&file_id=<?= (int)$p['before_file_id'] ?>" alt=""><?php endif; ?>
|
||||
<div class="cap"><strong><?= h((string)$p['code_name']) ?></strong><br><span class="muted"><?= h((string)($p['description'] ?? '')) ?></span></div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user