Init photo gallery with auto-indexing, thumbs, UI, deploy script
This commit is contained in:
commit
b5c49caeb2
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Runtime/generated
|
||||||
|
thumbs/*
|
||||||
|
!thumbs/.gitkeep
|
||||||
|
photos/*
|
||||||
|
!photos/.gitkeep
|
||||||
|
data/last_indexed.txt
|
||||||
|
|
||||||
|
# OS/editor
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Logs/temp
|
||||||
|
*.log
|
||||||
96
README.md
Normal file
96
README.md
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
# Галерея фотографий (PHP)
|
||||||
|
|
||||||
|
Локальный проект галереи, который:
|
||||||
|
|
||||||
|
- читает категории и фото из `photos/` (туда можно загружать по FTP),
|
||||||
|
- при каждом открытии страницы проверяет, появились ли новые/обновлённые фото,
|
||||||
|
- создаёт и обновляет превью в `thumbs/`,
|
||||||
|
- показывает категории и фото в веб-интерфейсе,
|
||||||
|
- открывает большую фотографию в лайтбоксе или в новой вкладке.
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
```text
|
||||||
|
photo-gallery/
|
||||||
|
├─ index.php # основной скрипт: индексация + HTML
|
||||||
|
├─ style.css # стили (material-like, строгий)
|
||||||
|
├─ app.js # лайтбокс
|
||||||
|
├─ photos/ # исходные фото по категориям (папкам)
|
||||||
|
├─ thumbs/ # автогенерируемые превью
|
||||||
|
└─ data/
|
||||||
|
└─ last_indexed.txt # timestamp последней индексации
|
||||||
|
```
|
||||||
|
|
||||||
|
## Как работает индексация
|
||||||
|
|
||||||
|
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 тоже ок)
|
||||||
|
- Расширение GD **или** Imagick
|
||||||
|
- если есть Imagick — будет использоваться он,
|
||||||
|
- иначе используется GD.
|
||||||
|
|
||||||
|
## Локальный запуск
|
||||||
|
|
||||||
|
Из папки `photo-gallery`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php -S 127.0.0.1:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Открыть в браузере:
|
||||||
|
|
||||||
|
- `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
|
||||||
|
cd ~/www/photo-gallery
|
||||||
|
bash scripts/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
По умолчанию ветка `main`. Для другой ветки:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BRANCH=master bash scripts/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Примечания
|
||||||
|
|
||||||
|
- Превью генерируются в формате JPEG с качеством ~82.
|
||||||
|
- При первом заходе на большую папку возможно небольшое ожидание (генерация превью).
|
||||||
|
- Для production обычно лучше вынести индексацию в cron/очередь, но для текущей задачи это intentionally on-request.
|
||||||
54
app.js
Normal file
54
app.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
(() => {
|
||||||
|
const lightbox = document.getElementById('lightbox');
|
||||||
|
const lightboxImage = document.getElementById('lightboxImage');
|
||||||
|
|
||||||
|
if (!lightbox || !lightboxImage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('contextmenu', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('dragstart', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
const key = e.key.toLowerCase();
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (key === 's' || key === 'u' || key === 'p')) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'f12') {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Escape' && !lightbox.hidden) {
|
||||||
|
closeLightbox();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.js-thumb').forEach((button) => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const full = button.dataset.full;
|
||||||
|
const title = button.dataset.title || 'Фото';
|
||||||
|
if (!full) return;
|
||||||
|
|
||||||
|
lightboxImage.src = full;
|
||||||
|
lightboxImage.alt = title;
|
||||||
|
lightbox.hidden = false;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
lightbox.querySelectorAll('.js-close').forEach((el) => {
|
||||||
|
el.addEventListener('click', closeLightbox);
|
||||||
|
});
|
||||||
|
|
||||||
|
function closeLightbox() {
|
||||||
|
lightbox.hidden = true;
|
||||||
|
lightboxImage.src = '';
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
})();
|
||||||
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
296
index.php
Normal file
296
index.php
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
<?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';
|
||||||
|
|
||||||
|
ensureDirectories([$photosDir, $thumbsDir, $dataDir]);
|
||||||
|
|
||||||
|
$action = $_GET['action'] ?? null;
|
||||||
|
if ($action === 'image') {
|
||||||
|
serveImage($photosDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastIndexedTimestamp = readLastIndexedTimestamp($lastIndexedFile);
|
||||||
|
$maxTimestamp = $lastIndexedTimestamp;
|
||||||
|
|
||||||
|
$categories = scanCategories($photosDir);
|
||||||
|
|
||||||
|
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['mtime'] = $sourceMtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($images, static function (array $a, array $b): int {
|
||||||
|
return $b['mtime'] <=> $a['mtime'];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
<header class="topbar">
|
||||||
|
<h1>Фотогалерея</h1>
|
||||||
|
<p class="subtitle">Категории и превью обновляются автоматически при каждом открытии страницы</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<?php if ($selectedCategory === null): ?>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Категории</h2>
|
||||||
|
<?php if (count($categories) === 0): ?>
|
||||||
|
<p class="empty">Пока нет папок с фото. Загрузите файлы в <code>photos/<категория>/</code> через FTP.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="categories-grid">
|
||||||
|
<?php foreach ($categories as $categoryName => $images): ?>
|
||||||
|
<a class="category-card" href="?category=<?= urlencode($categoryName) ?>">
|
||||||
|
<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['filename'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="<?= htmlspecialchars($img['thumb_path'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>"
|
||||||
|
alt="<?= htmlspecialchars($img['filename'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>"
|
||||||
|
loading="lazy"
|
||||||
|
>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="app.js" 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 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
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
$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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$result[$entry] = $images;
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($result, SORT_NATURAL | SORT_FLAG_CASE);
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
0
photos/.gitkeep
Normal file
0
photos/.gitkeep
Normal file
48
scripts/deploy.sh
Executable file
48
scripts/deploy.sh
Executable file
|
|
@ -0,0 +1,48 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Timeweb shared hosting deploy script
|
||||||
|
# Usage:
|
||||||
|
# bash scripts/deploy.sh
|
||||||
|
# Optional env:
|
||||||
|
# APP_DIR=/home/USER/www/photo-gallery
|
||||||
|
# BRANCH=main
|
||||||
|
|
||||||
|
APP_DIR="${APP_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||||
|
BRANCH="${BRANCH:-main}"
|
||||||
|
|
||||||
|
cd "$APP_DIR"
|
||||||
|
|
||||||
|
echo "[deploy] dir: $APP_DIR"
|
||||||
|
echo "[deploy] branch: $BRANCH"
|
||||||
|
|
||||||
|
if [ ! -d .git ]; then
|
||||||
|
echo "[deploy] ERROR: .git not found in $APP_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Keep runtime dirs
|
||||||
|
mkdir -p photos thumbs data
|
||||||
|
|
||||||
|
# Protect user-uploaded photos from direct HTTP access (Apache)
|
||||||
|
if [ -f photos/.htaccess ]; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
cat > photos/.htaccess <<'HTACCESS'
|
||||||
|
Require all denied
|
||||||
|
HTACCESS
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update code
|
||||||
|
current_branch="$(git rev-parse --abbrev-ref HEAD)"
|
||||||
|
if [ "$current_branch" != "$BRANCH" ]; then
|
||||||
|
git checkout "$BRANCH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
git fetch --all --prune
|
||||||
|
git reset --hard "origin/$BRANCH"
|
||||||
|
|
||||||
|
# Make sure runtime files exist
|
||||||
|
[ -f data/last_indexed.txt ] || echo "0" > data/last_indexed.txt
|
||||||
|
|
||||||
|
echo "[deploy] done"
|
||||||
220
style.css
Normal file
220
style.css
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
:root {
|
||||||
|
--bg: #f4f6f8;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-soft: #f9fbfd;
|
||||||
|
--text: #1f2937;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--primary: #1f6feb;
|
||||||
|
--primary-soft: #dbe9ff;
|
||||||
|
--border: #e5e7eb;
|
||||||
|
--shadow: 0 8px 28px rgba(15, 23, 42, 0.08);
|
||||||
|
--radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: Inter, Roboto, "Segoe UI", Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
background: var(--primary-soft);
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categories-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-card {
|
||||||
|
display: block;
|
||||||
|
background: var(--surface-soft);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text);
|
||||||
|
transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 22px rgba(31, 111, 235, 0.12);
|
||||||
|
border-color: #c5d8fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-title {
|
||||||
|
display: block;
|
||||||
|
font-weight: 650;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-count {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform .15s ease, box-shadow .15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 18px rgba(2, 6, 23, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-card img {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
aspect-ratio: 3 / 2;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(17, 24, 39, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: min(92vw, 1100px);
|
||||||
|
max-height: 90vh;
|
||||||
|
background: #0f172a;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-content img {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 78vh;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #020617;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-link {
|
||||||
|
align-self: flex-end;
|
||||||
|
color: #dbeafe;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,0.22);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 22px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.app {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
0
thumbs/.gitkeep
Normal file
0
thumbs/.gitkeep
Normal file
Loading…
Reference in New Issue
Block a user