From b5c49caeb2f020ac0395924362756b98cc8737c1 Mon Sep 17 00:00:00 2001 From: Alex Assistant Date: Thu, 19 Feb 2026 16:37:51 +0300 Subject: [PATCH] Init photo gallery with auto-indexing, thumbs, UI, deploy script --- .gitignore | 17 +++ README.md | 96 +++++++++++++++ app.js | 54 +++++++++ data/.gitkeep | 0 index.php | 296 ++++++++++++++++++++++++++++++++++++++++++++++ photos/.gitkeep | 0 scripts/deploy.sh | 48 ++++++++ style.css | 220 ++++++++++++++++++++++++++++++++++ thumbs/.gitkeep | 0 9 files changed, 731 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.js create mode 100644 data/.gitkeep create mode 100644 index.php create mode 100644 photos/.gitkeep create mode 100755 scripts/deploy.sh create mode 100644 style.css create mode 100644 thumbs/.gitkeep diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..88052f0 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..29ea61b --- /dev/null +++ b/README.md @@ -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/`, +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. diff --git a/app.js b/app.js new file mode 100644 index 0000000..af92055 --- /dev/null +++ b/app.js @@ -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 = ''; + } +})(); diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/index.php b/index.php new file mode 100644 index 0000000..609d409 --- /dev/null +++ b/index.php @@ -0,0 +1,296 @@ + &$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; +} + +?> + + + + + Фотогалерея + + + +
+
+

Фотогалерея

+

Категории и превью обновляются автоматически при каждом открытии страницы

+
+ + +
+

Категории

+ +

Пока нет папок с фото. Загрузите файлы в photos/<категория>/ через FTP.

+ +
+ $images): ?> + + + фото + + +
+ +
+ +
+ + + + +

В этой категории пока нет изображений.

+ + + +
+ + +
+ Последняя индексация: +
+
+ + + + + + + $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); +} diff --git a/photos/.gitkeep b/photos/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..398d095 --- /dev/null +++ b/scripts/deploy.sh @@ -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" diff --git a/style.css b/style.css new file mode 100644 index 0000000..da16ff3 --- /dev/null +++ b/style.css @@ -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; + } +} diff --git a/thumbs/.gitkeep b/thumbs/.gitkeep new file mode 100644 index 0000000..e69de29