Admin: split action handlers and refresh project docs
This commit is contained in:
parent
ccb13f3e38
commit
d295afd8ec
269
README.md
269
README.md
|
|
@ -1,208 +1,145 @@
|
||||||
# Галерея фотографий (PHP)
|
# Галерея фото (PHP + MySQL)
|
||||||
|
|
||||||
Локальный проект галереи, который:
|
Актуальная версия проекта — это MySQL-контур с двумя основными точками входа:
|
||||||
|
|
||||||
- читает категории и фото из `photos/` (туда можно загружать по FTP),
|
- `index.php` — публичная витрина (разделы, тематики, карточки фото, комментарии),
|
||||||
- при каждом открытии страницы проверяет, появились ли новые/обновлённые фото,
|
- `admin.php?token=...` — закрытая админка (управление разделами/тематиками/фото/пользователями/комментариями/настройками).
|
||||||
- создаёт и обновляет превью в `thumbs/`,
|
|
||||||
- показывает категории и фото в веб-интерфейсе,
|
`index-mysql.php` и `admin-mysql.php` оставлены как алиасы для обратной совместимости.
|
||||||
- открывает большую фотографию в лайтбоксе.
|
|
||||||
|
## Что умеет проект
|
||||||
|
|
||||||
|
- Иерархия каталога: разделы + тематики (2 уровня).
|
||||||
|
- Для фото поддерживаются версии `before` и `after`.
|
||||||
|
- Версия `after` на публичной части отдается с watermark (текст, яркость и угол настраиваются в админке).
|
||||||
|
- Комментарии доступны только по персональной viewer-ссылке.
|
||||||
|
- Для карточек каталога используются превью из `thumbs/`.
|
||||||
|
- Превью создаются/обновляются автоматически при загрузке, замене и повороте изображения.
|
||||||
|
|
||||||
## Структура
|
## Структура
|
||||||
|
|
||||||
```text
|
```text
|
||||||
photo-gallery/
|
photo.andr33v.ru/
|
||||||
├─ index.php # основной скрипт: индексация + HTML
|
├─ index.php # публичная витрина
|
||||||
├─ style.css # стили (material-like, строгий)
|
├─ admin.php # админка по токену
|
||||||
├─ app.js # лайтбокс + защита от простого скачивания
|
├─ index-mysql.php # alias -> index.php
|
||||||
├─ deploy.php # webhook-триггер деплоя
|
├─ admin-mysql.php # alias -> admin.php
|
||||||
├─ admin.php # закрытая админка (папки/фото/сортировка)
|
├─ deploy.php # webhook деплоя
|
||||||
├─ deploy-config.php.example # пример конфига webhook
|
├─ style.css # базовые стили
|
||||||
├─ photos/ # исходные фото по категориям (папкам)
|
├─ favicon.svg
|
||||||
├─ thumbs/ # автогенерируемые превью
|
├─ config.php.example # шаблон конфига БД
|
||||||
└─ data/
|
├─ deploy-config.php.example # шаблон токена/настроек деплоя
|
||||||
├─ last_indexed.txt # timestamp последней индексации
|
├─ lib/
|
||||||
└─ sort.json # порядок категорий и фото
|
│ ├─ db.php # PDO + загрузка config.php
|
||||||
|
│ ├─ db_gallery.php # доступ к данным галереи
|
||||||
|
│ ├─ thumbs.php # генерация/чтение/удаление превью
|
||||||
|
│ ├─ admin_http.php # JSON-ответы админки
|
||||||
|
│ ├─ admin_get_actions.php # GET-экшены админки
|
||||||
|
│ ├─ admin_post_actions.php # POST-экшены админки
|
||||||
|
│ └─ admin_helpers.php # helper-функции админки
|
||||||
|
├─ migrations/
|
||||||
|
│ ├─ 001_init.sql
|
||||||
|
│ ├─ 002_site_settings.sql
|
||||||
|
│ ├─ 003_comment_users_plain_token.sql
|
||||||
|
│ └─ 004_topics.sql
|
||||||
|
├─ scripts/
|
||||||
|
│ ├─ migrate.php # запуск миграций
|
||||||
|
│ ├─ generate_thumbs.php # backfill превью
|
||||||
|
│ └─ deploy.sh # deploy на shared hosting
|
||||||
|
├─ photos/ # оригиналы (runtime)
|
||||||
|
├─ thumbs/ # превью (runtime)
|
||||||
|
└─ data/ # runtime (логи/служебные файлы)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Как работает индексация
|
|
||||||
|
|
||||||
1. Скрипт читает `data/last_indexed.txt`.
|
|
||||||
2. Сканирует `photos/<категория>/`.
|
|
||||||
3. Для каждого изображения (`jpg/jpeg/png/webp/gif`):
|
|
||||||
- если файл новее `last_indexed`,
|
|
||||||
- или превью не существует,
|
|
||||||
- или превью старее оригинала,
|
|
||||||
тогда создаётся/обновляется превью (`.jpg`) в `thumbs/<категория>/`.
|
|
||||||
4. Записывает новый timestamp в `last_indexed.txt`.
|
|
||||||
|
|
||||||
Индексация выполняется **на каждом обращении к `index.php`**.
|
|
||||||
|
|
||||||
Также на публичной странице категории показываются с обложкой (берётся превью первой фотографии по текущей сортировке).
|
|
||||||
|
|
||||||
## Требования
|
## Требования
|
||||||
|
|
||||||
- PHP 8.2+ (8.3 тоже ок)
|
- PHP 8.2+
|
||||||
- Расширение GD **или** Imagick
|
- MySQL 8+ (или совместимая MariaDB)
|
||||||
- если есть Imagick — будет использоваться он,
|
- PHP-расширения:
|
||||||
- иначе используется GD.
|
- `pdo_mysql` (обязательно),
|
||||||
|
- `gd` или `imagick` (для watermark/превью).
|
||||||
|
|
||||||
## Локальный запуск
|
## Быстрый старт
|
||||||
|
|
||||||
Из папки `photo-gallery`:
|
1. Создай `config.php`:
|
||||||
|
|
||||||
```bash
|
|
||||||
php -S 127.0.0.1:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
## MySQL конфиг и миграции (этап перехода на БД)
|
|
||||||
|
|
||||||
### Быстрый запуск на хостинге (Timeweb)
|
|
||||||
|
|
||||||
1. Создать базу MySQL в панели хостинга.
|
|
||||||
2. Подтянуть проект из git.
|
|
||||||
3. Создать `config.php` из шаблона:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp config.php.example config.php
|
cp config.php.example config.php
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Заполнить в `config.php` параметры подключения к БД.
|
2. Заполни доступы к БД в `config.php`.
|
||||||
5. Запустить миграции:
|
|
||||||
|
3. Прогон миграций:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
php scripts/migrate.php
|
php scripts/migrate.php
|
||||||
```
|
```
|
||||||
|
|
||||||
После этого проект должен работать.
|
4. Локальный запуск:
|
||||||
|
|
||||||
### Про кодировку
|
|
||||||
|
|
||||||
Проект использует `utf8mb4`:
|
|
||||||
- в PDO DSN (`charset=utf8mb4`),
|
|
||||||
- в миграциях для таблиц (`CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`).
|
|
||||||
|
|
||||||
Если база на хостинге по умолчанию с другой кодировкой, это обычно не мешает, т.к. таблицы создаются миграциями с нужной кодировкой.
|
|
||||||
|
|
||||||
Файлы:
|
|
||||||
- `lib/db.php` — подключение PDO
|
|
||||||
- `migrations/*.sql` — схема БД
|
|
||||||
- `scripts/migrate.php` — runner миграций
|
|
||||||
|
|
||||||
Открыть в браузере:
|
|
||||||
|
|
||||||
- `http://127.0.0.1:8080`
|
|
||||||
|
|
||||||
## Загрузка фото
|
|
||||||
|
|
||||||
Через FTP кладите файлы в:
|
|
||||||
|
|
||||||
- `photos/Свадьба/001.jpg`
|
|
||||||
- `photos/Портреты/img_10.png`
|
|
||||||
- и т.д.
|
|
||||||
|
|
||||||
Папка верхнего уровня = категория.
|
|
||||||
|
|
||||||
## Деплой (Timeweb shared hosting)
|
|
||||||
|
|
||||||
В проекте есть скрипт:
|
|
||||||
|
|
||||||
- `scripts/deploy.sh`
|
|
||||||
|
|
||||||
Он:
|
|
||||||
|
|
||||||
1. делает `git fetch`,
|
|
||||||
2. жёстко переключает код на `origin/<branch>`,
|
|
||||||
3. сохраняет runtime-папки (`photos`, `thumbs`, `data`),
|
|
||||||
4. создаёт `data/last_indexed.txt` при первом запуске.
|
|
||||||
|
|
||||||
Запуск на хостинге:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/www/photo-gallery
|
php -S 127.0.0.1:8080
|
||||||
bash scripts/deploy.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
По умолчанию ветка `main`. Для другой ветки:
|
5. Открой:
|
||||||
|
|
||||||
```bash
|
- `http://127.0.0.1:8080` — публичная часть,
|
||||||
BRANCH=master bash scripts/deploy.sh
|
- `http://127.0.0.1:8080/admin.php?token=<SECRET>` — админка (после настройки `deploy-config.php`).
|
||||||
```
|
|
||||||
|
|
||||||
## Админка загрузки (по токену)
|
## Настройка админки
|
||||||
|
|
||||||
Новый контур на MySQL (основной):
|
Админка использует `token` из `deploy-config.php`.
|
||||||
|
|
||||||
- `admin.php?token=...` — админка
|
Создай файл:
|
||||||
- `index.php` — публичная витрина + комментарии
|
|
||||||
|
|
||||||
Совместимость:
|
|
||||||
- `admin-mysql.php` и `index-mysql.php` оставлены как алиасы на новые основные файлы.
|
|
||||||
|
|
||||||
Что уже есть в MySQL-контуре:
|
|
||||||
- создание разделов,
|
|
||||||
- сценарий загрузки: сначала выбор раздела, затем массовая загрузка только фото "до",
|
|
||||||
- после загрузки автоматический prefill имени (code_name) из имени файла,
|
|
||||||
- для каждой карточки фото можно отредактировать: имя, сортировку, комментарий и добавить/заменить фото "после",
|
|
||||||
- запись в таблицы `sections`, `photos`, `photo_files`,
|
|
||||||
- персональные комментаторы (генерация ссылок + повторный просмотр/перевыпуск ссылки),
|
|
||||||
- плоские комментарии к фото,
|
|
||||||
- удаление комментариев админом,
|
|
||||||
- watermark на выдаче версии "после".
|
|
||||||
- превью каталога (`thumbs/`) генерируются при загрузке/замене файла и после поворота.
|
|
||||||
|
|
||||||
|
|
||||||
Админка использует тот же `token`, что и `deploy.php`, из файла `deploy-config.php`.
|
|
||||||
|
|
||||||
Ссылка входа:
|
|
||||||
|
|
||||||
```text
|
|
||||||
https://<домен>/admin.php?token=<твой_секрет>
|
|
||||||
```
|
|
||||||
|
|
||||||
В админке можно:
|
|
||||||
- создавать папки-категории,
|
|
||||||
- переименовывать/удалять категории,
|
|
||||||
- задавать порядок (индекс сортировки) категорий,
|
|
||||||
- загружать фото в выбранную папку,
|
|
||||||
- переименовывать/удалять фото,
|
|
||||||
- задавать порядок (индекс сортировки) фото внутри категории,
|
|
||||||
- видеть превью фото в таблице админки,
|
|
||||||
- автоматически очищать `data/sort.json` от несуществующих папок/файлов.
|
|
||||||
|
|
||||||
Ограничения загрузки:
|
|
||||||
- только изображения: JPG/PNG/WEBP/GIF,
|
|
||||||
- максимум 3 МБ на файл,
|
|
||||||
- MIME-тип и расширение проверяются на сервере.
|
|
||||||
|
|
||||||
Загрузка новых фото выполняется в секции выбранной категории (чтобы нельзя было загрузить в неправильную папку).
|
|
||||||
|
|
||||||
## Удалённый запуск деплоя по ссылке (webhook)
|
|
||||||
|
|
||||||
1. На хостинге создай конфиг из примера:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp deploy-config.php.example deploy-config.php
|
cp deploy-config.php.example deploy-config.php
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Заполни в `deploy-config.php` минимум:
|
Минимально заполни:
|
||||||
- `token` (длинный секрет)
|
|
||||||
- при желании `allowed_ips`
|
|
||||||
- при желании `basic_auth_user/basic_auth_pass`
|
|
||||||
|
|
||||||
3. Запуск деплоя:
|
- `token` — длинный случайный секрет.
|
||||||
|
|
||||||
```text
|
В админке доступны разделы:
|
||||||
https://<домен>/deploy.php?token=<твой_секрет>
|
|
||||||
|
- Разделы,
|
||||||
|
- Тематики,
|
||||||
|
- Фото,
|
||||||
|
- Пользователи,
|
||||||
|
- Комментарии,
|
||||||
|
- Настройки (welcome + watermark параметры).
|
||||||
|
|
||||||
|
## Превью и watermark
|
||||||
|
|
||||||
|
- Публичный каталог карточек грузит превью через `?action=thumb&file_id=...`.
|
||||||
|
- Превью создаются автоматически в `thumbs/`:
|
||||||
|
- при загрузке файлов,
|
||||||
|
- при замене файлов,
|
||||||
|
- после поворота.
|
||||||
|
- Для старых данных можно догенерировать превью:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php scripts/generate_thumbs.php
|
||||||
```
|
```
|
||||||
|
|
||||||
Рекомендация: включить IP whitelist и Basic Auth.
|
- Версия `after` в публичке отдается с watermark (`?action=image&file_id=...`).
|
||||||
|
|
||||||
|
## Деплой
|
||||||
|
|
||||||
|
Webhook:
|
||||||
|
|
||||||
|
- `deploy.php?token=<SECRET>`
|
||||||
|
|
||||||
|
Скрипт `scripts/deploy.sh`:
|
||||||
|
|
||||||
|
1. делает `git fetch --all --prune`,
|
||||||
|
2. переключает код на `origin/<branch>` через `git reset --hard`,
|
||||||
|
3. сохраняет runtime-папки (`photos`, `thumbs`, `data`).
|
||||||
|
|
||||||
|
Важно: деплой-скрипт перетирает рабочие изменения в репозитории на сервере.
|
||||||
|
|
||||||
## Примечания
|
## Примечания
|
||||||
|
|
||||||
- Превью генерируются в формате JPEG с качеством ~82.
|
- Проект принудительно редиректит на HTTPS и non-www через `.htaccess`.
|
||||||
- Для разового backfill превью можно запустить: `php scripts/generate_thumbs.php`.
|
- Для production рекомендуется ограничить webhook по IP и/или Basic Auth (`deploy-config.php`).
|
||||||
- При первом заходе на большую папку возможно небольшое ожидание (генерация превью).
|
- Если `config.php` отсутствует, приложение корректно падает с ошибкой подключения к БД.
|
||||||
- CSS/JS и favicon подключаются с cache-busting параметром `?v=<filemtime>`, чтобы после деплоя пользователю не приходилось чистить кеш вручную.
|
|
||||||
- В футере публичной страницы есть ненавязчивое авторство со ссылкой: `https://t.me/andr33vru`.
|
|
||||||
- Для production обычно лучше вынести индексацию в cron/очередь, но для текущей задачи это intentionally on-request.
|
|
||||||
|
|
|
||||||
673
admin.php
673
admin.php
|
|
@ -4,6 +4,10 @@ declare(strict_types=1);
|
||||||
|
|
||||||
require_once __DIR__ . '/lib/db_gallery.php';
|
require_once __DIR__ . '/lib/db_gallery.php';
|
||||||
require_once __DIR__ . '/lib/thumbs.php';
|
require_once __DIR__ . '/lib/thumbs.php';
|
||||||
|
require_once __DIR__ . '/lib/admin_http.php';
|
||||||
|
require_once __DIR__ . '/lib/admin_helpers.php';
|
||||||
|
require_once __DIR__ . '/lib/admin_get_actions.php';
|
||||||
|
require_once __DIR__ . '/lib/admin_post_actions.php';
|
||||||
|
|
||||||
const MAX_UPLOAD_BYTES = 3 * 1024 * 1024;
|
const MAX_UPLOAD_BYTES = 3 * 1024 * 1024;
|
||||||
|
|
||||||
|
|
@ -21,30 +25,8 @@ if ($tokenExpected === '' || !hash_equals($tokenExpected, $tokenIncoming)) {
|
||||||
}
|
}
|
||||||
|
|
||||||
$requestAction = (string)($_REQUEST['action'] ?? '');
|
$requestAction = (string)($_REQUEST['action'] ?? '');
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'GET' && $requestAction === 'photo_comments') {
|
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||||
$photoId = (int)($_GET['photo_id'] ?? 0);
|
adminHandleGetAction($requestAction);
|
||||||
if ($photoId < 1) {
|
|
||||||
http_response_code(400);
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
|
||||||
echo json_encode(['ok' => 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 = '';
|
$message = '';
|
||||||
|
|
@ -56,327 +38,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|| strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest';
|
|| strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if ($action === 'create_section') {
|
$result = adminHandlePostAction($action, $isAjax, __DIR__);
|
||||||
$name = trim((string)($_POST['name'] ?? ''));
|
$message = (string)($result['message'] ?? '');
|
||||||
if ($name === '') throw new RuntimeException('Название раздела пустое');
|
if (isset($result['errors']) && is_array($result['errors']) && $result['errors'] !== []) {
|
||||||
$sort = nextSectionSortOrder();
|
|
||||||
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'] ?? ''));
|
|
||||||
$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 уровня вложенности тематик');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$sort = nextTopicSortOrder($parentId > 0 ? $parentId : null);
|
|
||||||
topicCreate($name, $parentId > 0 ? $parentId : null, $sort);
|
|
||||||
$message = 'Тематика создана';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($action === 'update_topic') {
|
|
||||||
$topicId = (int)($_POST['topic_id'] ?? 0);
|
|
||||||
$name = trim((string)($_POST['name'] ?? ''));
|
|
||||||
$sort = (int)($_POST['sort_order'] ?? 1000);
|
|
||||||
|
|
||||||
if ($topicId < 1) throw new RuntimeException('Некорректная тематика');
|
|
||||||
if ($name === '') throw new RuntimeException('Название тематики пустое');
|
|
||||||
|
|
||||||
$topic = topicById($topicId);
|
|
||||||
if (!$topic) throw new RuntimeException('Тематика не найдена');
|
|
||||||
|
|
||||||
$currentParentId = isset($topic['parent_id']) && $topic['parent_id'] !== null ? (int)$topic['parent_id'] : null;
|
|
||||||
topicUpdate($topicId, $name, $currentParentId, $sort);
|
|
||||||
$message = 'Тематика обновлена';
|
|
||||||
if ($isAjax) {
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
|
||||||
echo json_encode(['ok' => true, 'message' => $message], JSON_UNESCAPED_UNICODE);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($action === 'delete_topic') {
|
|
||||||
$topicId = (int)($_POST['topic_id'] ?? 0);
|
|
||||||
if ($topicId < 1) throw new RuntimeException('Некорректная тематика');
|
|
||||||
if (!topicById($topicId)) throw new RuntimeException('Тематика не найдена');
|
|
||||||
|
|
||||||
topicDelete($topicId);
|
|
||||||
$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_settings' || $action === 'update_welcome') {
|
|
||||||
$text = trim((string)($_POST['welcome_text'] ?? ''));
|
|
||||||
$wmText = trim((string)($_POST['watermark_text'] ?? 'photo.andr33v.ru'));
|
|
||||||
$wmBrightness = (int)($_POST['watermark_brightness'] ?? 35);
|
|
||||||
$wmAngle = (int)($_POST['watermark_angle'] ?? -28);
|
|
||||||
|
|
||||||
if ($wmText === '') {
|
|
||||||
$wmText = 'photo.andr33v.ru';
|
|
||||||
}
|
|
||||||
$wmBrightness = max(5, min(100, $wmBrightness));
|
|
||||||
$wmAngle = max(-75, min(75, $wmAngle));
|
|
||||||
|
|
||||||
settingSet('welcome_text', $text);
|
|
||||||
settingSet('watermark_text', $wmText);
|
|
||||||
settingSet('watermark_brightness', (string)$wmBrightness);
|
|
||||||
settingSet('watermark_angle', (string)$wmAngle);
|
|
||||||
$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']);
|
$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('Фото не найдено');
|
|
||||||
$oldAfterPath = (string)($p['after_path'] ?? '');
|
|
||||||
$up = saveSingleImage($_FILES['after'], $code . 'р', (int)$p['section_id']);
|
|
||||||
photoFileUpsert($photoId, 'after', $up['path'], $up['mime'], $up['size']);
|
|
||||||
|
|
||||||
if ($oldAfterPath !== '' && $oldAfterPath !== $up['path']) {
|
|
||||||
deleteThumbBySourcePath(__DIR__, $oldAfterPath);
|
|
||||||
$oldAbs = __DIR__ . '/' . ltrim($oldAfterPath, '/');
|
|
||||||
if (is_file($oldAbs)) {
|
|
||||||
@unlink($oldAbs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$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']) {
|
|
||||||
deleteThumbBySourcePath(__DIR__, $oldAfterPath);
|
|
||||||
$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])) {
|
|
||||||
deleteThumbBySourcePath(__DIR__, (string)$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);
|
|
||||||
ensureThumbForSource(__DIR__, $relPath);
|
|
||||||
|
|
||||||
$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) {
|
} catch (Throwable $e) {
|
||||||
if ($isAjax) {
|
if ($isAjax) {
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
adminJsonResponse(['ok' => false, 'message' => $e->getMessage()], 400);
|
||||||
http_response_code(400);
|
|
||||||
echo json_encode(['ok' => false, 'message' => $e->getMessage()], JSON_UNESCAPED_UNICODE);
|
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
$errors[] = $e->getMessage();
|
$errors[] = $e->getMessage();
|
||||||
}
|
}
|
||||||
|
|
@ -427,328 +96,6 @@ try {
|
||||||
|
|
||||||
function h(string $v): string { return htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
|
function h(string $v): string { return htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
|
||||||
function assetUrl(string $path): string { $f=__DIR__ . '/' . ltrim($path,'/'); $v=is_file($f)?(string)filemtime($f):(string)time(); return $path . '?v=' . rawurlencode($v); }
|
function assetUrl(string $path): string { $f=__DIR__ . '/' . ltrim($path,'/'); $v=is_file($f)?(string)filemtime($f):(string)time(); return $path . '?v=' . rawurlencode($v); }
|
||||||
|
|
||||||
function 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 buildTopicTree(array $topics): array
|
|
||||||
{
|
|
||||||
$roots = [];
|
|
||||||
$children = [];
|
|
||||||
|
|
||||||
foreach ($topics as $topic) {
|
|
||||||
$pid = isset($topic['parent_id']) && $topic['parent_id'] !== null ? (int)$topic['parent_id'] : 0;
|
|
||||||
if ($pid === 0) {
|
|
||||||
$roots[] = $topic;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!isset($children[$pid])) {
|
|
||||||
$children[$pid] = [];
|
|
||||||
}
|
|
||||||
$children[$pid][] = $topic;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($roots as &$root) {
|
|
||||||
$rootId = (int)$root['id'];
|
|
||||||
$root['children'] = $children[$rootId] ?? [];
|
|
||||||
}
|
|
||||||
unset($root);
|
|
||||||
|
|
||||||
return $roots;
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextSectionSortOrder(): int
|
|
||||||
{
|
|
||||||
$sort = (int)db()->query('SELECT COALESCE(MAX(sort_order), 990) + 10 FROM sections')->fetchColumn();
|
|
||||||
return max(10, $sort);
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextTopicSortOrder(?int $parentId): int
|
|
||||||
{
|
|
||||||
$st = db()->prepare('SELECT COALESCE(MAX(sort_order), 990) + 10 FROM topics WHERE parent_id <=> :pid');
|
|
||||||
$st->bindValue('pid', $parentId, $parentId === null ? PDO::PARAM_NULL : PDO::PARAM_INT);
|
|
||||||
$st->execute();
|
|
||||||
$sort = (int)$st->fetchColumn();
|
|
||||||
return max(10, $sort);
|
|
||||||
}
|
|
||||||
|
|
||||||
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('Не удалось сохранить файл');
|
|
||||||
|
|
||||||
$storedRelPath = 'photos/section_' . $sectionId . '/' . $name;
|
|
||||||
ensureThumbForSource(__DIR__, $storedRelPath);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'path' => $storedRelPath,
|
|
||||||
'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);
|
|
||||||
}
|
|
||||||
deleteThumbBySourcePath(__DIR__, $path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
?>
|
?>
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
|
|
|
||||||
26
lib/admin_get_actions.php
Normal file
26
lib/admin_get_actions.php
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
function adminHandleGetAction(string $action): void
|
||||||
|
{
|
||||||
|
if ($action !== 'photo_comments') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$photoId = (int)($_GET['photo_id'] ?? 0);
|
||||||
|
if ($photoId < 1) {
|
||||||
|
adminJsonResponse(['ok' => false, 'message' => 'Некорректный photo_id'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$photo = photoById($photoId);
|
||||||
|
if (!$photo) {
|
||||||
|
adminJsonResponse(['ok' => false, 'message' => 'Фото не найдено'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
adminJsonResponse([
|
||||||
|
'ok' => true,
|
||||||
|
'photo' => ['id' => (int)$photo['id'], 'code_name' => (string)$photo['code_name']],
|
||||||
|
'comments' => commentsByPhoto($photoId),
|
||||||
|
]);
|
||||||
|
}
|
||||||
343
lib/admin_helpers.php
Normal file
343
lib/admin_helpers.php
Normal file
|
|
@ -0,0 +1,343 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
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 buildTopicTree(array $topics): array
|
||||||
|
{
|
||||||
|
$roots = [];
|
||||||
|
$children = [];
|
||||||
|
|
||||||
|
foreach ($topics as $topic) {
|
||||||
|
$pid = isset($topic['parent_id']) && $topic['parent_id'] !== null ? (int)$topic['parent_id'] : 0;
|
||||||
|
if ($pid === 0) {
|
||||||
|
$roots[] = $topic;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isset($children[$pid])) {
|
||||||
|
$children[$pid] = [];
|
||||||
|
}
|
||||||
|
$children[$pid][] = $topic;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($roots as &$root) {
|
||||||
|
$rootId = (int)$root['id'];
|
||||||
|
$root['children'] = $children[$rootId] ?? [];
|
||||||
|
}
|
||||||
|
unset($root);
|
||||||
|
|
||||||
|
return $roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextSectionSortOrder(): int
|
||||||
|
{
|
||||||
|
$sort = (int)db()->query('SELECT COALESCE(MAX(sort_order), 990) + 10 FROM sections')->fetchColumn();
|
||||||
|
return max(10, $sort);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextTopicSortOrder(?int $parentId): int
|
||||||
|
{
|
||||||
|
$st = db()->prepare('SELECT COALESCE(MAX(sort_order), 990) + 10 FROM topics WHERE parent_id <=> :pid');
|
||||||
|
$st->bindValue('pid', $parentId, $parentId === null ? PDO::PARAM_NULL : PDO::PARAM_INT);
|
||||||
|
$st->execute();
|
||||||
|
$sort = (int)$st->fetchColumn();
|
||||||
|
return max(10, $sort);
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
|
$root = dirname(__DIR__);
|
||||||
|
$dir = $root . '/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('Не удалось сохранить файл');
|
||||||
|
}
|
||||||
|
|
||||||
|
$storedRelPath = 'photos/section_' . $sectionId . '/' . $name;
|
||||||
|
ensureThumbForSource($root, $storedRelPath);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'path' => $storedRelPath,
|
||||||
|
'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 = dirname(__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;
|
||||||
|
}
|
||||||
|
|
||||||
|
$root = dirname(__DIR__);
|
||||||
|
foreach ($paths as $path) {
|
||||||
|
if (!is_string($path) || $path === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$abs = $root . '/' . ltrim($path, '/');
|
||||||
|
if (is_file($abs)) {
|
||||||
|
@unlink($abs);
|
||||||
|
}
|
||||||
|
deleteThumbBySourcePath($root, $path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
lib/admin_http.php
Normal file
11
lib/admin_http.php
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
function adminJsonResponse(array $payload, int $statusCode = 200): never
|
||||||
|
{
|
||||||
|
http_response_code($statusCode);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
396
lib/admin_post_actions.php
Normal file
396
lib/admin_post_actions.php
Normal file
|
|
@ -0,0 +1,396 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
function adminHandlePostAction(string $action, bool $isAjax, string $projectRoot): array
|
||||||
|
{
|
||||||
|
$message = '';
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
switch ($action) {
|
||||||
|
case 'create_section': {
|
||||||
|
$name = trim((string)($_POST['name'] ?? ''));
|
||||||
|
if ($name === '') {
|
||||||
|
throw new RuntimeException('Название раздела пустое');
|
||||||
|
}
|
||||||
|
$sort = nextSectionSortOrder();
|
||||||
|
sectionCreate($name, $sort);
|
||||||
|
$message = 'Раздел создан';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case '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) {
|
||||||
|
adminJsonResponse(['ok' => true, 'message' => $message]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'create_topic': {
|
||||||
|
$name = trim((string)($_POST['name'] ?? ''));
|
||||||
|
$parentId = (int)($_POST['parent_id'] ?? 0);
|
||||||
|
|
||||||
|
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 уровня вложенности тематик');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$sort = nextTopicSortOrder($parentId > 0 ? $parentId : null);
|
||||||
|
topicCreate($name, $parentId > 0 ? $parentId : null, $sort);
|
||||||
|
$message = 'Тематика создана';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'update_topic': {
|
||||||
|
$topicId = (int)($_POST['topic_id'] ?? 0);
|
||||||
|
$name = trim((string)($_POST['name'] ?? ''));
|
||||||
|
$sort = (int)($_POST['sort_order'] ?? 1000);
|
||||||
|
|
||||||
|
if ($topicId < 1) {
|
||||||
|
throw new RuntimeException('Некорректная тематика');
|
||||||
|
}
|
||||||
|
if ($name === '') {
|
||||||
|
throw new RuntimeException('Название тематики пустое');
|
||||||
|
}
|
||||||
|
|
||||||
|
$topic = topicById($topicId);
|
||||||
|
if (!$topic) {
|
||||||
|
throw new RuntimeException('Тематика не найдена');
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentParentId = isset($topic['parent_id']) && $topic['parent_id'] !== null ? (int)$topic['parent_id'] : null;
|
||||||
|
topicUpdate($topicId, $name, $currentParentId, $sort);
|
||||||
|
$message = 'Тематика обновлена';
|
||||||
|
if ($isAjax) {
|
||||||
|
adminJsonResponse(['ok' => true, 'message' => $message]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'delete_topic': {
|
||||||
|
$topicId = (int)($_POST['topic_id'] ?? 0);
|
||||||
|
if ($topicId < 1) {
|
||||||
|
throw new RuntimeException('Некорректная тематика');
|
||||||
|
}
|
||||||
|
if (!topicById($topicId)) {
|
||||||
|
throw new RuntimeException('Тематика не найдена');
|
||||||
|
}
|
||||||
|
|
||||||
|
topicDelete($topicId);
|
||||||
|
$message = 'Тематика удалена';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case '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 = 'Раздел удалён';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'update_settings':
|
||||||
|
case 'update_welcome': {
|
||||||
|
$text = trim((string)($_POST['welcome_text'] ?? ''));
|
||||||
|
$wmText = trim((string)($_POST['watermark_text'] ?? 'photo.andr33v.ru'));
|
||||||
|
$wmBrightness = (int)($_POST['watermark_brightness'] ?? 35);
|
||||||
|
$wmAngle = (int)($_POST['watermark_angle'] ?? -28);
|
||||||
|
|
||||||
|
if ($wmText === '') {
|
||||||
|
$wmText = 'photo.andr33v.ru';
|
||||||
|
}
|
||||||
|
$wmBrightness = max(5, min(100, $wmBrightness));
|
||||||
|
$wmAngle = max(-75, min(75, $wmAngle));
|
||||||
|
|
||||||
|
settingSet('welcome_text', $text);
|
||||||
|
settingSet('watermark_text', $wmText);
|
||||||
|
settingSet('watermark_brightness', (string)$wmBrightness);
|
||||||
|
settingSet('watermark_angle', (string)$wmAngle);
|
||||||
|
$message = 'Настройки сохранены';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case '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']);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case '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('Фото не найдено');
|
||||||
|
}
|
||||||
|
$oldAfterPath = (string)($p['after_path'] ?? '');
|
||||||
|
$up = saveSingleImage($_FILES['after'], $code . 'р', (int)$p['section_id']);
|
||||||
|
photoFileUpsert($photoId, 'after', $up['path'], $up['mime'], $up['size']);
|
||||||
|
|
||||||
|
if ($oldAfterPath !== '' && $oldAfterPath !== $up['path']) {
|
||||||
|
deleteThumbBySourcePath($projectRoot, $oldAfterPath);
|
||||||
|
$oldAbs = $projectRoot . '/' . ltrim($oldAfterPath, '/');
|
||||||
|
if (is_file($oldAbs)) {
|
||||||
|
@unlink($oldAbs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = 'Фото обновлено';
|
||||||
|
if ($isAjax) {
|
||||||
|
adminJsonResponse(['ok' => true, 'message' => $message]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case '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']) {
|
||||||
|
deleteThumbBySourcePath($projectRoot, $oldAfterPath);
|
||||||
|
$oldAbs = $projectRoot . '/' . 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) {
|
||||||
|
adminJsonResponse([
|
||||||
|
'ok' => true,
|
||||||
|
'message' => $message,
|
||||||
|
'photo_id' => $photoId,
|
||||||
|
'preview_url' => $previewUrl,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case '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) {
|
||||||
|
adminJsonResponse(['ok' => true, 'message' => $message, 'photo_id' => $photoId, 'topics' => $topics]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case '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) {
|
||||||
|
adminJsonResponse(['ok' => true, 'message' => $message, 'photo_id' => $photoId, 'topics' => $topics]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case '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])) {
|
||||||
|
deleteThumbBySourcePath($projectRoot, (string)$p[$k]);
|
||||||
|
$abs = $projectRoot . '/' . ltrim((string)$p[$k], '/');
|
||||||
|
if (is_file($abs)) {
|
||||||
|
@unlink($abs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$st = db()->prepare('DELETE FROM photos WHERE id=:id');
|
||||||
|
$st->execute(['id' => $photoId]);
|
||||||
|
$message = 'Фото удалено';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case '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 = $projectRoot . '/' . ltrim($relPath, '/');
|
||||||
|
if (!is_file($absPath)) {
|
||||||
|
throw new RuntimeException('Файл не найден на диске');
|
||||||
|
}
|
||||||
|
|
||||||
|
$degrees = $direction === 'left' ? -90 : 90;
|
||||||
|
rotateImageOnDisk($absPath, $degrees);
|
||||||
|
ensureThumbForSource($projectRoot, $relPath);
|
||||||
|
|
||||||
|
$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) {
|
||||||
|
adminJsonResponse(['ok' => true, 'message' => $message, 'photo_id' => $photoId, 'kind' => $kind]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case '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;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'delete_commenter': {
|
||||||
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
|
if ($id > 0) {
|
||||||
|
commenterDelete($id);
|
||||||
|
$message = 'Комментатор удалён (доступ отозван)';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case '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;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'delete_comment': {
|
||||||
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
|
if ($id > 0) {
|
||||||
|
commentDelete($id);
|
||||||
|
$message = 'Комментарий удалён';
|
||||||
|
if ($isAjax) {
|
||||||
|
adminJsonResponse(['ok' => true, 'message' => $message]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['message' => $message, 'errors' => $errors];
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user