gallery-for-aav/index.php
2026-02-19 17:38:14 +03:00

376 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
const THUMB_WIDTH = 360;
const THUMB_HEIGHT = 240;
$baseDir = __DIR__;
$photosDir = $baseDir . '/photos';
$thumbsDir = $baseDir . '/thumbs';
$dataDir = $baseDir . '/data';
$lastIndexedFile = $dataDir . '/last_indexed.txt';
$sortFile = $dataDir . '/sort.json';
ensureDirectories([$photosDir, $thumbsDir, $dataDir]);
$sortData = loadSortData($sortFile);
$action = $_GET['action'] ?? null;
if ($action === 'image') {
serveImage($photosDir);
}
$lastIndexedTimestamp = readLastIndexedTimestamp($lastIndexedFile);
$maxTimestamp = $lastIndexedTimestamp;
$categories = scanCategories($photosDir, $sortData);
foreach ($categories as $categoryName => &$images) {
$categoryThumbDir = $thumbsDir . '/' . $categoryName;
if (!is_dir($categoryThumbDir)) {
mkdir($categoryThumbDir, 0775, true);
}
foreach ($images as &$image) {
$sourcePath = $image['abs_path'];
$sourceMtime = (int) filemtime($sourcePath);
$maxTimestamp = max($maxTimestamp, $sourceMtime);
$thumbExt = 'jpg';
$thumbName = pathinfo($image['filename'], PATHINFO_FILENAME) . '.jpg';
$thumbAbsPath = $categoryThumbDir . '/' . $thumbName;
$thumbWebPath = 'thumbs/' . rawurlencode($categoryName) . '/' . rawurlencode($thumbName);
$needsThumb = !file_exists($thumbAbsPath)
|| filemtime($thumbAbsPath) < $sourceMtime
|| $sourceMtime > $lastIndexedTimestamp;
if ($needsThumb) {
createThumbnail($sourcePath, $thumbAbsPath, THUMB_WIDTH, THUMB_HEIGHT);
}
$image['thumb_path'] = $thumbWebPath;
$image['full_path'] = '?action=image&category=' . rawurlencode($categoryName) . '&file=' . rawurlencode($image['filename']);
$image['title'] = titleFromFilename($image['filename']);
$image['mtime'] = $sourceMtime;
}
usort($images, static function (array $a, array $b): int {
$bySort = ($a['sort_index'] ?? 0) <=> ($b['sort_index'] ?? 0);
if ($bySort !== 0) {
return $bySort;
}
return ($b['mtime'] ?? 0) <=> ($a['mtime'] ?? 0);
});
}
unset($images, $image);
if ($maxTimestamp > $lastIndexedTimestamp) {
file_put_contents($lastIndexedFile, (string)$maxTimestamp);
}
$selectedCategory = isset($_GET['category']) ? trim((string)$_GET['category']) : null;
if ($selectedCategory !== null && $selectedCategory !== '' && !isset($categories[$selectedCategory])) {
http_response_code(404);
$selectedCategory = null;
}
?><!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Фотогалерея</title>
<link rel="stylesheet" href="<?= htmlspecialchars(assetUrl('style.css'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>">
</head>
<body>
<div class="app">
<header class="topbar">
<h1>Фотогалерея</h1>
<p class="subtitle">Категории и превью обновляются автоматически при каждом открытии страницы</p>
</header>
<?php if ($selectedCategory === null): ?>
<section class="panel">
<h2>Категории</h2>
<?php if (count($categories) === 0): ?>
<p class="empty">Пока нет папок с фото. Загрузите файлы в <code>photos/&lt;категория&gt;/</code> через FTP.</p>
<?php else: ?>
<div class="categories-grid">
<?php foreach ($categories as $categoryName => $images): ?>
<?php $cover = $images[0]['thumb_path'] ?? null; ?>
<a class="category-card" href="?category=<?= urlencode($categoryName) ?>">
<?php if ($cover): ?>
<img
class="category-cover"
src="<?= htmlspecialchars($cover, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>"
alt="<?= htmlspecialchars($categoryName, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>"
loading="lazy"
>
<?php endif; ?>
<span class="category-title"><?= htmlspecialchars($categoryName, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></span>
<span class="category-count"><?= count($images) ?> фото</span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php else: ?>
<section class="panel">
<div class="panel-header">
<h2><?= htmlspecialchars($selectedCategory, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></h2>
<a class="btn" href="./">← Все категории</a>
</div>
<?php $images = $categories[$selectedCategory] ?? []; ?>
<?php if (count($images) === 0): ?>
<p class="empty">В этой категории пока нет изображений.</p>
<?php else: ?>
<div class="gallery-grid">
<?php foreach ($images as $img): ?>
<button
class="thumb-card js-thumb"
data-full="<?= htmlspecialchars($img['full_path'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>"
data-title="<?= htmlspecialchars($img['title'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>"
type="button"
>
<img
src="<?= htmlspecialchars($img['thumb_path'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>"
alt="<?= htmlspecialchars($img['title'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>"
loading="lazy"
>
<span class="thumb-title"><?= htmlspecialchars($img['title'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></span>
</button>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php endif; ?>
<footer class="footer">
<small>Последняя индексация: <?= file_exists($lastIndexedFile) ? date('Y-m-d H:i:s', (int)trim((string)file_get_contents($lastIndexedFile))) : '—' ?></small>
</footer>
</div>
<div class="lightbox" id="lightbox" hidden>
<div class="lightbox-backdrop js-close"></div>
<div class="lightbox-content">
<button class="lightbox-close js-close" type="button" aria-label="Закрыть">×</button>
<img id="lightboxImage" src="" alt="">
<div id="lightboxTitle" class="lightbox-title"></div>
</div>
</div>
<script src="<?= htmlspecialchars(assetUrl('app.js'), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>" defer></script>
</body>
</html>
<?php
function serveImage(string $photosDir): never
{
$category = isset($_GET['category']) ? basename((string)$_GET['category']) : '';
$file = isset($_GET['file']) ? basename((string)$_GET['file']) : '';
if ($category === '' || $file === '') {
http_response_code(404);
exit;
}
$path = $photosDir . '/' . $category . '/' . $file;
if (!is_file($path) || !isImage($path)) {
http_response_code(404);
exit;
}
$mime = mime_content_type($path) ?: 'application/octet-stream';
header('Content-Type: ' . $mime);
header('Content-Length: ' . (string)filesize($path));
header('X-Robots-Tag: noindex, nofollow');
header('Content-Disposition: inline; filename="image"');
header('Cache-Control: private, max-age=60');
readfile($path);
exit;
}
function titleFromFilename(string $filename): string
{
$name = pathinfo($filename, PATHINFO_FILENAME);
$name = str_replace(['_', '-'], ' ', $name);
$name = preg_replace('/\s+/', ' ', $name) ?? $name;
$name = trim($name);
if ($name === '') {
return $filename;
}
if (function_exists('mb_convert_case')) {
return mb_convert_case($name, MB_CASE_TITLE, 'UTF-8');
}
return ucwords(strtolower($name));
}
function ensureDirectories(array $dirs): void
{
foreach ($dirs as $dir) {
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
}
}
function readLastIndexedTimestamp(string $path): int
{
if (!file_exists($path)) {
return 0;
}
$value = trim((string) file_get_contents($path));
return ctype_digit($value) ? (int)$value : 0;
}
function scanCategories(string $photosDir, array $sortData): array
{
$result = [];
$categorySortMap = (array)($sortData['categories'] ?? []);
$photoSortMap = (array)($sortData['photos'] ?? []);
$entries = @scandir($photosDir) ?: [];
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$categoryPath = $photosDir . '/' . $entry;
if (!is_dir($categoryPath)) {
continue;
}
$images = [];
$files = @scandir($categoryPath) ?: [];
foreach ($files as $filename) {
if ($filename === '.' || $filename === '..') {
continue;
}
$absPath = $categoryPath . '/' . $filename;
if (!is_file($absPath) || !isImage($absPath)) {
continue;
}
$images[] = [
'filename' => $filename,
'abs_path' => $absPath,
'sort_index' => (int)(($photoSortMap[$entry][$filename] ?? 1000)),
];
}
$result[$entry] = $images;
}
uksort($result, static function (string $a, string $b) use ($categorySortMap): int {
$aSort = (int)($categorySortMap[$a] ?? 1000);
$bSort = (int)($categorySortMap[$b] ?? 1000);
if ($aSort !== $bSort) {
return $aSort <=> $bSort;
}
return strnatcasecmp($a, $b);
});
return $result;
}
function assetUrl(string $relativePath): string
{
$file = __DIR__ . '/' . ltrim($relativePath, '/');
$v = is_file($file) ? (string)filemtime($file) : (string)time();
return $relativePath . '?v=' . rawurlencode($v);
}
function loadSortData(string $sortFile): array
{
if (!is_file($sortFile)) {
return ['categories' => [], 'photos' => []];
}
$json = file_get_contents($sortFile);
if ($json === false || trim($json) === '') {
return ['categories' => [], 'photos' => []];
}
$data = json_decode($json, true);
if (!is_array($data)) {
return ['categories' => [], 'photos' => []];
}
return [
'categories' => is_array($data['categories'] ?? null) ? $data['categories'] : [],
'photos' => is_array($data['photos'] ?? null) ? $data['photos'] : [],
];
}
function isImage(string $path): bool
{
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
return in_array($ext, ['jpg', 'jpeg', 'png', 'webp', 'gif'], true);
}
function createThumbnail(string $srcPath, string $thumbPath, int $targetWidth, int $targetHeight): void
{
if (extension_loaded('imagick')) {
createThumbnailWithImagick($srcPath, $thumbPath, $targetWidth, $targetHeight);
return;
}
createThumbnailWithGd($srcPath, $thumbPath, $targetWidth, $targetHeight);
}
function createThumbnailWithImagick(string $srcPath, string $thumbPath, int $targetWidth, int $targetHeight): void
{
$imagick = new Imagick($srcPath);
$imagick->setIteratorIndex(0);
$imagick->setImageOrientation(Imagick::ORIENTATION_UNDEFINED);
$imagick->thumbnailImage($targetWidth, $targetHeight, true, true);
$imagick->setImageFormat('jpeg');
$imagick->setImageCompressionQuality(82);
$imagick->writeImage($thumbPath);
$imagick->clear();
$imagick->destroy();
}
function createThumbnailWithGd(string $srcPath, string $thumbPath, int $targetWidth, int $targetHeight): void
{
[$srcW, $srcH, $type] = @getimagesize($srcPath) ?: [0, 0, 0];
if ($srcW < 1 || $srcH < 1) {
return;
}
$src = match ($type) {
IMAGETYPE_JPEG => @imagecreatefromjpeg($srcPath),
IMAGETYPE_PNG => @imagecreatefrompng($srcPath),
IMAGETYPE_GIF => @imagecreatefromgif($srcPath),
IMAGETYPE_WEBP => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($srcPath) : null,
default => null,
};
if (!$src) {
return;
}
$scale = min($targetWidth / $srcW, $targetHeight / $srcH);
$dstW = max(1, (int) floor($srcW * $scale));
$dstH = max(1, (int) floor($srcH * $scale));
$dst = imagecreatetruecolor($dstW, $dstH);
imagecopyresampled($dst, $src, 0, 0, 0, 0, $dstW, $dstH, $srcW, $srcH);
imagejpeg($dst, $thumbPath, 82);
imagedestroy($src);
imagedestroy($dst);
}