false, 'message' => 'Некорректный photo_id'], JSON_UNESCAPED_UNICODE); exit; } $photo = photoById($photoId); if (!$photo) { http_response_code(404); header('Content-Type: application/json; charset=utf-8'); echo json_encode(['ok' => false, 'message' => 'Фото не найдено'], JSON_UNESCAPED_UNICODE); exit; } header('Content-Type: application/json; charset=utf-8'); echo json_encode([ 'ok' => true, 'photo' => ['id' => (int)$photo['id'], 'code_name' => (string)$photo['code_name']], 'comments' => commentsByPhoto($photoId), ], JSON_UNESCAPED_UNICODE); exit; } $message = ''; $errors = []; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $action = (string)($_POST['action'] ?? ''); $isAjax = (string)($_POST['ajax'] ?? '') === '1' || strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest'; 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 === 'update_section') { $sectionId = (int)($_POST['section_id'] ?? 0); $name = trim((string)($_POST['name'] ?? '')); $sort = (int)($_POST['sort_order'] ?? 1000); if ($sectionId < 1) throw new RuntimeException('Некорректный раздел'); if ($name === '') throw new RuntimeException('Название раздела пустое'); if (!sectionById($sectionId)) throw new RuntimeException('Раздел не найден'); sectionUpdate($sectionId, $name, $sort); $message = 'Раздел обновлён'; if ($isAjax) { header('Content-Type: application/json; charset=utf-8'); echo json_encode(['ok' => true, 'message' => $message], JSON_UNESCAPED_UNICODE); exit; } } if ($action === 'create_topic') { $name = trim((string)($_POST['name'] ?? '')); $sort = (int)($_POST['sort_order'] ?? 1000); $parentId = (int)($_POST['parent_id'] ?? 0); $parent = null; if ($name === '') throw new RuntimeException('Название тематики пустое'); if ($parentId > 0) { $parent = topicById($parentId); if (!$parent) throw new RuntimeException('Родительская тематика не найдена'); if (!empty($parent['parent_id'])) { throw new RuntimeException('Разрешено только 2 уровня вложенности тематик'); } } topicCreate($name, $parentId > 0 ? $parentId : null, $sort); $message = 'Тематика создана'; } if ($action === 'delete_section') { $sectionId = (int)($_POST['section_id'] ?? 0); if ($sectionId < 1) throw new RuntimeException('Некорректный раздел'); if (!sectionById($sectionId)) throw new RuntimeException('Раздел не найден'); removeSectionImageFiles($sectionId); sectionDelete($sectionId); deleteSectionStorage($sectionId); $message = 'Раздел удалён'; } if ($action === 'update_welcome') { $text = trim((string)($_POST['welcome_text'] ?? '')); settingSet('welcome_text', $text); $message = 'Приветственное сообщение сохранено'; } 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('Файлы не переданы'); $result = saveBulkBefore($_FILES['before_bulk'], $sectionId); $message = 'Загружено: ' . $result['ok']; $errors = array_merge($errors, $result['errors']); } 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; 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 ($isAjax) { header('Content-Type: application/json; charset=utf-8'); echo json_encode(['ok' => true, 'message' => $message], JSON_UNESCAPED_UNICODE); exit; } } if ($action === 'upload_after_file') { $photoId = (int)($_POST['photo_id'] ?? 0); if ($photoId < 1) throw new RuntimeException('Некорректный photo_id'); if (!isset($_FILES['after'])) throw new RuntimeException('Файл не передан'); $photo = photoById($photoId); if (!$photo) throw new RuntimeException('Фото не найдено'); $oldAfterPath = (string)($photo['after_path'] ?? ''); $up = saveSingleImage($_FILES['after'], (string)$photo['code_name'] . 'р', (int)$photo['section_id']); photoFileUpsert($photoId, 'after', $up['path'], $up['mime'], $up['size']); if ($oldAfterPath !== '' && $oldAfterPath !== $up['path']) { $oldAbs = __DIR__ . '/' . ltrim($oldAfterPath, '/'); if (is_file($oldAbs)) { @unlink($oldAbs); } } $updatedPhoto = photoById($photoId); $afterFileId = (int)($updatedPhoto['after_file_id'] ?? 0); $previewUrl = $afterFileId > 0 ? ('index.php?action=image&file_id=' . $afterFileId . '&v=' . rawurlencode((string)time())) : ''; $message = 'Фото после обновлено'; if ($isAjax) { header('Content-Type: application/json; charset=utf-8'); echo json_encode([ 'ok' => true, 'message' => $message, 'photo_id' => $photoId, 'preview_url' => $previewUrl, ], JSON_UNESCAPED_UNICODE); exit; } } if ($action === 'attach_photo_topic') { $photoId = (int)($_POST['photo_id'] ?? 0); $topicId = (int)($_POST['topic_id'] ?? 0); if ($photoId < 1 || !photoById($photoId)) throw new RuntimeException('Фото не найдено'); if ($topicId < 1 || !topicById($topicId)) throw new RuntimeException('Тематика не найдена'); photoTopicAttach($photoId, $topicId); $topics = array_map(static fn(array $t): array => [ 'id' => (int)$t['id'], 'full_name' => (string)$t['full_name'], ], photoTopicsByPhotoId($photoId)); $message = 'Тематика добавлена'; if ($isAjax) { header('Content-Type: application/json; charset=utf-8'); echo json_encode(['ok' => true, 'message' => $message, 'photo_id' => $photoId, 'topics' => $topics], JSON_UNESCAPED_UNICODE); exit; } } if ($action === 'detach_photo_topic') { $photoId = (int)($_POST['photo_id'] ?? 0); $topicId = (int)($_POST['topic_id'] ?? 0); if ($photoId < 1 || !photoById($photoId)) throw new RuntimeException('Фото не найдено'); if ($topicId < 1) throw new RuntimeException('Тематика не найдена'); photoTopicDetach($photoId, $topicId); $topics = array_map(static fn(array $t): array => [ 'id' => (int)$t['id'], 'full_name' => (string)$t['full_name'], ], photoTopicsByPhotoId($photoId)); $message = 'Тематика удалена'; if ($isAjax) { header('Content-Type: application/json; charset=utf-8'); echo json_encode(['ok' => true, 'message' => $message, 'photo_id' => $photoId, 'topics' => $topics], JSON_UNESCAPED_UNICODE); exit; } } if ($action === 'photo_delete') { $photoId = (int)($_POST['photo_id'] ?? 0); if ($photoId > 0) { $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); } } } $st = db()->prepare('DELETE FROM photos WHERE id=:id'); $st->execute(['id' => $photoId]); $message = 'Фото удалено'; } } if ($action === 'rotate_photo_file') { $photoId = (int)($_POST['photo_id'] ?? 0); $kind = (string)($_POST['kind'] ?? ''); $direction = (string)($_POST['direction'] ?? 'right'); if ($photoId < 1) throw new RuntimeException('Некорректный photo_id'); if (!in_array($kind, ['before', 'after'], true)) throw new RuntimeException('Некорректный тип файла'); $photo = photoById($photoId); if (!$photo) throw new RuntimeException('Фото не найдено'); $pathKey = $kind === 'before' ? 'before_path' : 'after_path'; $relPath = (string)($photo[$pathKey] ?? ''); if ($relPath === '') throw new RuntimeException('Файл отсутствует'); $absPath = __DIR__ . '/' . ltrim($relPath, '/'); if (!is_file($absPath)) throw new RuntimeException('Файл не найден на диске'); $degrees = $direction === 'left' ? -90 : 90; rotateImageOnDisk($absPath, $degrees); $st = db()->prepare('UPDATE photo_files SET updated_at=CURRENT_TIMESTAMP WHERE photo_id=:pid AND kind=:kind'); $st->execute(['pid' => $photoId, 'kind' => $kind]); $message = 'Изображение повернуто'; if ($isAjax) { header('Content-Type: application/json; charset=utf-8'); echo json_encode(['ok' => true, 'message' => $message, 'photo_id' => $photoId, 'kind' => $kind], JSON_UNESCAPED_UNICODE); exit; } } 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 = 'Комментатор удалён (доступ отозван)'; } } if ($action === 'regenerate_commenter_token') { $id = (int)($_POST['id'] ?? 0); if ($id > 0) { $token = commenterRegenerateToken($id); $link = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . '/?viewer=' . urlencode($token); $message = 'Токен обновлён | ссылка: ' . $link; } } if ($action === 'delete_comment') { $id = (int)($_POST['id'] ?? 0); if ($id > 0) { commentDelete($id); $message = 'Комментарий удалён'; if ($isAjax) { header('Content-Type: application/json; charset=utf-8'); echo json_encode(['ok' => true, 'message' => $message], JSON_UNESCAPED_UNICODE); exit; } } } } catch (Throwable $e) { if ($isAjax) { header('Content-Type: application/json; charset=utf-8'); http_response_code(400); echo json_encode(['ok' => false, 'message' => $e->getMessage()], JSON_UNESCAPED_UNICODE); exit; } $errors[] = $e->getMessage(); } } $sections = sectionsAll(); $activeSectionId = (int)($_GET['section_id'] ?? ($_POST['section_id'] ?? ($sections[0]['id'] ?? 0))); $activeSection = $activeSectionId > 0 ? sectionById($activeSectionId) : null; if (!$activeSection && $sections !== []) { $activeSectionId = (int)$sections[0]['id']; $activeSection = sectionById($activeSectionId); } $photos = $activeSectionId > 0 ? photosBySection($activeSectionId) : []; $commenters = commentersAll(); $welcomeText = settingGet('welcome_text', 'Добро пожаловать в галерею. Выберите раздел слева, чтобы посмотреть фотографии.'); $adminMode = (string)($_GET['mode'] ?? 'photos'); if ($adminMode === 'media') { $adminMode = 'photos'; } if (!in_array($adminMode, ['sections', 'photos', 'topics', 'commenters', 'comments', 'welcome'], true)) { $adminMode = 'photos'; } $previewVersion = (string)time(); $commentPhotoQuery = trim((string)($_GET['comment_photo'] ?? ($_POST['comment_photo'] ?? ''))); $commentUserQuery = trim((string)($_GET['comment_user'] ?? ($_POST['comment_user'] ?? ''))); $filteredComments = commentsSearch($commentPhotoQuery, $commentUserQuery, 200); $photoCommentCounts = commentCountsByPhotoIds(array_map(static fn(array $p): int => (int)$p['id'], $photos)); $topics = []; $topicRoots = []; $photoTopicsMap = []; $topicsError = ''; try { $topics = topicsAllForSelect(); foreach ($topics as $topic) { if ((int)$topic['level'] === 0) { $topicRoots[] = $topic; } } $photoTopicsMap = photoTopicsMapByPhotoIds(array_map(static fn(array $p): int => (int)$p['id'], $photos)); } catch (Throwable $e) { $topicsError = 'Тематики недоступны. Запусти миграции: php scripts/migrate.php'; } function h(string $v): string { return htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } function assetUrl(string $path): string { $f=__DIR__ . '/' . ltrim($path,'/'); $v=is_file($f)?(string)filemtime($f):(string)time(); return $path . '?v=' . rawurlencode($v); } function commentCountsByPhotoIds(array $photoIds): array { $photoIds = array_values(array_unique(array_map('intval', $photoIds))); if ($photoIds === []) { return []; } $placeholders = implode(',', array_fill(0, count($photoIds), '?')); $st = db()->prepare("SELECT photo_id, COUNT(*) AS cnt FROM photo_comments WHERE photo_id IN ($placeholders) GROUP BY photo_id"); $st->execute($photoIds); $map = []; foreach ($st->fetchAll() as $row) { $map[(int)$row['photo_id']] = (int)$row['cnt']; } 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 { $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(); } } return ['ok' => $ok, 'errors' => $errors]; } 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 МБ'); $tmp = (string)($file['tmp_name'] ?? ''); if (!is_uploaded_file($tmp)) throw new RuntimeException('Некорректный источник'); $mime = mime_content_type($tmp) ?: ''; if (!isset($allowedMime[$mime])) throw new RuntimeException('Недопустимый тип файла'); $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); $name = uniqueName($dir, $safeBase, $ext); $dest = $dir . '/' . $name; 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 deleteSectionStorage(int $sectionId): void { $dir = __DIR__ . '/photos/section_' . $sectionId; if (!is_dir($dir)) { return; } deleteDirRecursive($dir); } function deleteDirRecursive(string $dir): void { $items = scandir($dir); if (!is_array($items)) { return; } foreach ($items as $item) { if ($item === '.' || $item === '..') { continue; } $path = $dir . '/' . $item; if (is_dir($path)) { deleteDirRecursive($path); continue; } @unlink($path); } @rmdir($dir); } function removeSectionImageFiles(int $sectionId): void { $st = db()->prepare('SELECT pf.file_path FROM photo_files pf JOIN photos p ON p.id = pf.photo_id WHERE p.section_id = :sid'); $st->execute(['sid' => $sectionId]); $paths = $st->fetchAll(PDO::FETCH_COLUMN); if (!is_array($paths)) { return; } foreach ($paths as $path) { if (!is_string($path) || $path === '') { continue; } $abs = __DIR__ . '/' . ltrim($path, '/'); if (is_file($abs)) { @unlink($abs); } } } function rotateImageOnDisk(string $path, int $degrees): void { $mime = mime_content_type($path) ?: ''; if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp', 'image/gif'], true)) { throw new RuntimeException('Недопустимый тип файла для поворота'); } if (extension_loaded('imagick')) { $im = new Imagick($path); $im->setImageOrientation(Imagick::ORIENTATION_TOPLEFT); $im->rotateImage(new ImagickPixel('none'), $degrees); $im->setImagePage(0, 0, 0, 0); if ($mime === 'image/jpeg') { $im->setImageCompressionQuality(92); } $im->writeImage($path); $im->clear(); $im->destroy(); return; } $src = match ($mime) { 'image/jpeg' => @imagecreatefromjpeg($path), 'image/png' => @imagecreatefrompng($path), 'image/webp' => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($path) : false, 'image/gif' => @imagecreatefromgif($path), default => false, }; if (!$src) { throw new RuntimeException('Не удалось открыть изображение'); } $bgColor = 0; if ($mime === 'image/png' || $mime === 'image/webp') { $bgColor = imagecolorallocatealpha($src, 0, 0, 0, 127); } $rotated = imagerotate($src, -$degrees, $bgColor); if (!$rotated) { imagedestroy($src); throw new RuntimeException('Не удалось повернуть изображение'); } if ($mime === 'image/png' || $mime === 'image/webp') { imagealphablending($rotated, false); imagesavealpha($rotated, true); } $ok = match ($mime) { 'image/jpeg' => imagejpeg($rotated, $path, 92), 'image/png' => imagepng($rotated, $path), 'image/webp' => function_exists('imagewebp') ? imagewebp($rotated, $path, 92) : false, 'image/gif' => imagegif($rotated, $path), default => false, }; imagedestroy($src); imagedestroy($rotated); if (!$ok) { throw new RuntimeException('Не удалось сохранить повернутое изображение'); } } 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++; } } ?> Админка

Админка

Приветственное сообщение (публичная часть)

Редактировать выбранный раздел

Нет разделов для редактирования.

Создать раздел

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

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

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

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

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

0): ?>

После загрузки имя (code_name) заполняется автоматически из имени файла — затем можно отредактировать.

Сначала выбери раздел слева.

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

ДоПослеПоляДействия
Фото после не загружено

Тематики
Не выбрано
0): ?> Комментариев нет

Пользователи комментариев

ПользовательСсылкаДействия
Нет сохранённой ссылки (старый пользователь)

Комментарии

Комментарии не найдены.

ФотоПользовательКомментарийДата