Extend admin: rename/delete/sort categories and photos

This commit is contained in:
Alex Assistant 2026-02-19 17:17:03 +03:00
parent 0a8d0ade23
commit 58a29a3d3b
3 changed files with 320 additions and 268 deletions

View File

@ -104,7 +104,11 @@ https://<домен>/admin.php?token=<твой_секрет>
В админке можно:
- создавать папки-категории,
- загружать фото в выбранную папку.
- переименовывать/удалять категории,
- задавать порядок (индекс сортировки) категорий,
- загружать фото в выбранную папку,
- переименовывать/удалять фото,
- задавать порядок (индекс сортировки) фото внутри категории.
Ограничения загрузки:
- только изображения: JPG/PNG/WEBP/GIF,

532
admin.php
View File

@ -2,33 +2,35 @@
declare(strict_types=1);
const MAX_UPLOAD_BYTES = 3 * 1024 * 1024; // 3 MB
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);
header('Content-Type: text/plain; charset=utf-8');
echo "deploy-config.php not found. Create it from deploy-config.php.example\n";
echo 'deploy-config.php not found';
exit;
}
/** @var array<string,mixed> $config */
$config = require $configPath;
$tokenExpected = (string)($config['token'] ?? '');
$tokenIncoming = (string)($_REQUEST['token'] ?? '');
if ($tokenExpected === '' || !hash_equals($tokenExpected, $tokenIncoming)) {
http_response_code(403);
header('Content-Type: text/plain; charset=utf-8');
echo "Forbidden: invalid token\n";
echo 'Forbidden';
exit;
}
$photosDir = __DIR__ . '/photos';
if (!is_dir($photosDir)) {
mkdir($photosDir, 0775, true);
}
$sortData = loadSortData($sortFile);
$message = '';
$errors = [];
@ -36,273 +38,277 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = (string)($_POST['action'] ?? '');
if ($action === 'create_category') {
$rawName = trim((string)($_POST['category_name'] ?? ''));
$safeName = sanitizeCategoryName($rawName);
if ($safeName === '') {
$errors[] = 'Введите корректное имя папки.';
$name = sanitizeCategoryName((string)($_POST['category_name'] ?? ''));
if ($name === '') {
$errors[] = 'Некорректное имя папки.';
} else {
$dir = $photosDir . '/' . $safeName;
if (is_dir($dir)) {
$message = 'Папка уже существует.';
} elseif (mkdir($dir, 0775, true)) {
$message = 'Папка создана: ' . $safeName;
} else {
$dir = $photosDir . '/' . $name;
if (!is_dir($dir) && !mkdir($dir, 0775, true)) {
$errors[] = 'Не удалось создать папку.';
} else {
$message = 'Папка создана: ' . $name;
$sortData['categories'][$name] = nextSortIndex($sortData['categories']);
}
}
}
if ($action === 'category_update') {
$current = sanitizeCategoryName((string)($_POST['category_current'] ?? ''));
$newName = sanitizeCategoryName((string)($_POST['category_new_name'] ?? ''));
$sortIndex = (int)($_POST['category_sort'] ?? 1000);
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') {
$selectedCategory = sanitizeCategoryName((string)($_POST['category'] ?? ''));
if ($selectedCategory === '') {
$errors[] = 'Выберите папку для загрузки.';
$category = sanitizeCategoryName((string)($_POST['category'] ?? ''));
if ($category === '' || !is_dir($photosDir . '/' . $category)) {
$errors[] = 'Выберите существующую категорию.';
} elseif (!isset($_FILES['photos'])) {
$errors[] = 'Файлы не переданы.';
} else {
$categoryDir = $photosDir . '/' . $selectedCategory;
if (!is_dir($categoryDir)) {
$errors[] = 'Выбранная папка не существует.';
} else {
if (!isset($_FILES['photos'])) {
$errors[] = 'Файлы не переданы.';
} else {
$result = handleUploads($_FILES['photos'], $categoryDir);
$errors = array_merge($errors, $result['errors']);
if ($result['ok'] > 0) {
$message = 'Загружено файлов: ' . $result['ok'];
}
}
$result = handleUploads($_FILES['photos'], $photosDir . '/' . $category, $sortData, $category);
$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);
$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 (isset($sortData['photos'][$category][$currentFile])) {
$sortData['photos'][$category][$candidate] = $sortData['photos'][$category][$currentFile];
unset($sortData['photos'][$category][$currentFile]);
}
$finalName = $candidate;
}
}
}
$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);
}
unset($sortData['photos'][$category][$file]);
$message = 'Фото удалено.';
}
}
saveSortData($sortFile, $sortData);
}
$categories = listCategories($photosDir);
$tokenForUrl = urlencode($tokenIncoming);
$categories = listCategories($photosDir, $sortData);
$selectedCategory = sanitizeCategoryName((string)($_GET['edit_category'] ?? ($_POST['category'] ?? '')));
if ($selectedCategory === '' && $categories !== []) {
$selectedCategory = $categories[0];
}
$photos = $selectedCategory !== '' ? listPhotos($photosDir, $selectedCategory, $sortData) : [];
?><!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Админка галереи</title>
<link rel="stylesheet" href="style.css">
<style>
.admin-wrap { max-width: 980px; margin: 0 auto; padding: 24px; }
.admin-grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(290px, 1fr)); }
.admin-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 14px; padding: 16px; box-shadow: 0 8px 24px rgba(15,23,42,.06); }
.admin-card h2 { margin-top: 0; font-size: 18px; }
.admin-input, .admin-select { width: 100%; padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 10px; font: inherit; }
.admin-btn { border: 0; background: #1f6feb; color: #fff; padding: 10px 14px; border-radius: 10px; font-weight: 600; cursor: pointer; }
.admin-help { color: #6b7280; font-size: 13px; }
.alert-ok { background: #ecfdf3; color: #166534; border: 1px solid #bbf7d0; border-radius: 10px; padding: 10px 12px; margin-bottom: 12px; }
.alert-err { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; border-radius: 10px; padding: 10px 12px; margin-bottom: 12px; }
.row { display: grid; gap: 8px; margin-bottom: 10px; }
.category-list { margin: 0; padding-left: 18px; }
.top-links { margin-bottom: 12px; font-size: 14px; }
</style>
</head>
<body>
<div class="admin-wrap">
<h1>Админка загрузки</h1>
<div class="top-links">
<a href="./"> В галерею</a>
</div>
<html lang="ru"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Админка галереи</title>
<link rel="stylesheet" href="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; ?>
<?php if ($message !== ''): ?>
<div class="alert-ok"><?= htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></div>
<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>
<section class="card">
<h3>Загрузка фото</h3>
<form method="post" enctype="multipart/form-data" action="?token=<?= urlencode($tokenIncoming) ?>">
<input type="hidden" name="token" value="<?= h($tokenIncoming) ?>"><input type="hidden" name="action" value="upload">
<select class="in" name="category" required>
<option value=""> Категория </option>
<?php foreach ($categories as $c): ?><option value="<?= h($c) ?>" <?= $c===$selectedCategory?'selected':'' ?>><?= h($c) ?></option><?php endforeach; ?>
</select>
<p><input type="file" name="photos[]" accept="image/jpeg,image/png,image/webp,image/gif" multiple required></p>
<button class="btn" type="submit">Загрузить</button>
<p class="muted">Только JPG/PNG/WEBP/GIF, максимум 3 МБ на файл.</p>
</form>
</section>
<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>
<section class="card full">
<h3>Фото в категории: <?= h($selectedCategory ?: '—') ?></h3>
<?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></tr>
<?php foreach ($photos as $p): ?>
<tr>
<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; ?>
<?php foreach ($errors as $err): ?>
<div class="alert-err"><?= htmlspecialchars($err, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></div>
<?php endforeach; ?>
<div class="admin-grid">
<section class="admin-card">
<h2>Создать папку (категорию)</h2>
<form method="post" action="?token=<?= $tokenForUrl ?>">
<input type="hidden" name="token" value="<?= htmlspecialchars($tokenIncoming, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>">
<input type="hidden" name="action" value="create_category">
<div class="row">
<label for="category_name">Имя папки</label>
<input class="admin-input" id="category_name" name="category_name" required placeholder="Например: weddings_2026">
</div>
<button class="admin-btn" type="submit">Создать</button>
</form>
<p class="admin-help">Разрешены буквы/цифры/пробел/._- (остальное отфильтруется).</p>
</section>
<section class="admin-card">
<h2>Загрузка фотографий</h2>
<form method="post" action="?token=<?= $tokenForUrl ?>" enctype="multipart/form-data">
<input type="hidden" name="token" value="<?= htmlspecialchars($tokenIncoming, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>">
<input type="hidden" name="action" value="upload">
<div class="row">
<label for="category">Папка</label>
<select class="admin-select" id="category" name="category" required>
<option value=""> Выберите папку </option>
<?php foreach ($categories as $cat): ?>
<option value="<?= htmlspecialchars($cat, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>"><?= htmlspecialchars($cat, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="row">
<label for="photos">Фотографии</label>
<input id="photos" name="photos[]" type="file" accept="image/jpeg,image/png,image/webp,image/gif" multiple required>
</div>
<button class="admin-btn" type="submit">Загрузить</button>
</form>
<p class="admin-help">Ограничения: только JPG/PNG/WEBP/GIF, максимум 3 МБ на файл.</p>
</section>
<section class="admin-card">
<h2>Текущие категории</h2>
<?php if ($categories === []): ?>
<p class="admin-help">Пока нет категорий.</p>
<?php else: ?>
<ul class="category-list">
<?php foreach ($categories as $cat): ?>
<li><?= htmlspecialchars($cat, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</section>
</div>
</div>
</body>
</html>
</section>
</div></div></body></html>
<?php
function sanitizeCategoryName(string $name): string
{
$name = trim($name);
$name = preg_replace('/\s+/', ' ', $name) ?? '';
$name = preg_replace('/[^\p{L}\p{N}\s._-]+/u', '', $name) ?? '';
$name = trim($name, ". \t\n\r\0\x0B");
if ($name === '' || $name === '.' || $name === '..') {
return '';
function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
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 $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)) $out[]=['file'=>$f,'sort'=>(int)($sortData['photos'][$category][$f]??1000)]; } usort($out, fn($a,$b)=>($a['sort']<=>$b['sort']) ?: strnatcasecmp($a['file'],$b['file'])); return $out; }
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++;
}
return $name;
}
/**
* @param array<string,mixed> $files
* @return array{ok:int,errors:string[]}
*/
function handleUploads(array $files, string $targetDir): array
{
$allowedMime = [
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
];
$allowedExt = ['jpg', 'jpeg', 'png', 'webp', 'gif'];
$ok = 0;
$errors = [];
$names = $files['name'] ?? [];
$tmpNames = $files['tmp_name'] ?? [];
$sizes = $files['size'] ?? [];
$errs = $files['error'] ?? [];
if (!is_array($names)) {
$names = [$names];
$tmpNames = [$tmpNames];
$sizes = [$sizes];
$errs = [$errs];
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
foreach ($names as $i => $originalName) {
$errCode = (int)($errs[$i] ?? UPLOAD_ERR_NO_FILE);
if ($errCode !== UPLOAD_ERR_OK) {
$errors[] = "Файл {$originalName}: ошибка загрузки ({$errCode}).";
continue;
}
$size = (int)($sizes[$i] ?? 0);
if ($size < 1 || $size > MAX_UPLOAD_BYTES) {
$errors[] = "Файл {$originalName}: превышен лимит 3 МБ.";
continue;
}
$tmp = (string)($tmpNames[$i] ?? '');
if ($tmp === '' || !is_uploaded_file($tmp)) {
$errors[] = "Файл {$originalName}: некорректный источник загрузки.";
continue;
}
$mime = $finfo ? (string)finfo_file($finfo, $tmp) : '';
if (!in_array($mime, $allowedMime, true)) {
$errors[] = "Файл {$originalName}: недопустимый тип ({$mime}).";
continue;
}
$ext = strtolower(pathinfo((string)$originalName, PATHINFO_EXTENSION));
if (!in_array($ext, $allowedExt, true)) {
$errors[] = "Файл {$originalName}: недопустимое расширение.";
continue;
}
$base = pathinfo((string)$originalName, PATHINFO_FILENAME);
$safeBase = preg_replace('/[^\p{L}\p{N}._-]+/u', '_', $base) ?? 'photo';
$safeBase = trim($safeBase, '._-');
if ($safeBase === '') {
$safeBase = 'photo';
}
$finalName = uniqueFileName($targetDir, $safeBase, $ext);
$dest = $targetDir . '/' . $finalName;
if (!move_uploaded_file($tmp, $dest)) {
$errors[] = "Файл {$originalName}: не удалось сохранить.";
continue;
}
@chmod($dest, 0664);
$ok++;
}
if ($finfo) {
finfo_close($finfo);
}
return ['ok' => $ok, 'errors' => $errors];
}
function uniqueFileName(string $dir, string $base, string $ext): string
{
$candidate = $base . '.' . $ext;
$n = 1;
while (file_exists($dir . '/' . $candidate)) {
$candidate = $base . '_' . $n . '.' . $ext;
$n++;
}
return $candidate;
}
/**
* @return string[]
*/
function listCategories(string $photosDir): array
{
$out = [];
$items = @scandir($photosDir) ?: [];
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
if (is_dir($photosDir . '/' . $item)) {
$out[] = $item;
}
}
sort($out, SORT_NATURAL | SORT_FLAG_CASE);
return $out;
if($finfo)finfo_close($finfo);
return ['ok'=>$ok,'errors'=>$errors];
}

View File

@ -10,8 +10,10 @@ $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;
if ($action === 'image') {
@ -21,7 +23,7 @@ if ($action === 'image') {
$lastIndexedTimestamp = readLastIndexedTimestamp($lastIndexedFile);
$maxTimestamp = $lastIndexedTimestamp;
$categories = scanCategories($photosDir);
$categories = scanCategories($photosDir, $sortData);
foreach ($categories as $categoryName => &$images) {
$categoryThumbDir = $thumbsDir . '/' . $categoryName;
@ -54,7 +56,12 @@ foreach ($categories as $categoryName => &$images) {
}
usort($images, static function (array $a, array $b): int {
return $b['mtime'] <=> $a['mtime'];
$bySort = ($a['sort_index'] ?? 0) <=> ($b['sort_index'] ?? 0);
if ($bySort !== 0) {
return $bySort;
}
return ($b['mtime'] ?? 0) <=> ($a['mtime'] ?? 0);
});
}
unset($images, $image);
@ -215,9 +222,11 @@ function readLastIndexedTimestamp(string $path): int
return ctype_digit($value) ? (int)$value : 0;
}
function scanCategories(string $photosDir): array
function scanCategories(string $photosDir, array $sortData): array
{
$result = [];
$categorySortMap = (array)($sortData['categories'] ?? []);
$photoSortMap = (array)($sortData['photos'] ?? []);
$entries = @scandir($photosDir) ?: [];
foreach ($entries as $entry) {
@ -245,16 +254,49 @@ function scanCategories(string $photosDir): array
$images[] = [
'filename' => $filename,
'abs_path' => $absPath,
'sort_index' => (int)(($photoSortMap[$entry][$filename] ?? 1000)),
];
}
$result[$entry] = $images;
}
ksort($result, SORT_NATURAL | SORT_FLAG_CASE);
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 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));