πΌοΈ Image Upload & Gallery System
Project Introduction
A comprehensive image management system that allows users to upload, organize, and display images in a beautiful gallery format. This system integrates seamlessly with the existing blog website, enabling rich media content for blog posts, user profiles, and standalone galleries. Features include image optimization, multiple upload support, album organization, and advanced image editing capabilities.
β¨ Features
User-Facing Features
- Drag & Drop Upload: Easy image uploading with progress indicators
- Multiple File Upload: Upload several images at once
- Image Gallery: Responsive grid layout with lightbox preview
- Album Organization: Create and manage image albums
- Image Tags: Categorize images with tags for easy searching
- Public/Private Settings: Control who can see your images
- Download Options: Allow users to download original or resized versions
Image Processing
- Automatic Optimization: Compress images without quality loss
- Multiple Sizes: Generate thumbnails, medium, and large versions
- Format Conversion: Convert between JPEG, PNG, WebP, etc.
- EXIF Data: Preserve or remove metadata as needed
- Watermarking: Add custom watermarks to protect images
- Face Detection: Optional cropping based on faces
Admin Features
- Storage Management: Track disk usage and limits
- Moderation Queue: Review and approve user uploads
- Bulk Operations: Delete, move, or tag multiple images
- CDN Integration: Serve images via CDN for faster loading
- Backup System: Automatic image backups
- Analytics: Track popular images and downloads
Technical Features
- Chunked Uploads: Handle large files efficiently
- Resumable Uploads: Continue interrupted uploads
- Image Processing Queue: Background processing for large files
- Caching System: Redis/Memcached for thumbnails
- REST API: Programmatic image management
- WebP Support: Modern image format for better performance
π File Structure
blog-website/ β βββ gallery.php # Main gallery page βββ upload.php # Image upload interface βββ album.php # Album view page βββ image.php # Single image view βββ manage-images.php # User image management β βββ includes/ β βββ gallery-config.php # Gallery configuration β βββ gallery-functions.php # Core gallery functions β βββ image-processor.php # Image processing logic β βββ upload-handler.php # Upload handling β βββ gallery-api.php # REST API endpoints β βββ admin/ β βββ gallery-manager.php # Admin image management β βββ gallery-settings.php # Gallery settings β βββ moderation-queue.php # Image moderation β βββ storage-stats.php # Storage analytics β βββ uploads/ β βββ images/ # Original images β β βββ year/month/ # Organized by date β βββ thumbnails/ # Thumbnail versions β βββ medium/ # Medium size images β βββ large/ # Large size images β βββ temp/ # Temporary uploads β βββ css/ β βββ gallery.css # Gallery styling β βββ lightbox.css # Lightbox styles β βββ js/ β βββ gallery.js # Gallery functionality β βββ upload.js # Upload handling β βββ lightbox.js # Lightbox implementation β βββ vendor/ # Composer dependencies β βββ .env # Environment variables βββ composer.json # Composer dependencies β βββ database/ βββ gallery.sql # Database schema
ποΈ Database Schema (database/gallery.sql)
-- Create gallery database tables
CREATE DATABASE IF NOT EXISTS gallery_db;
USE gallery_db;
-- Albums table
CREATE TABLE IF NOT EXISTS albums (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NULL,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL,
description TEXT,
cover_image_id INT NULL,
privacy ENUM('public', 'private', 'password') DEFAULT 'public',
password VARCHAR(255) NULL,
sort_order INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_privacy (privacy),
INDEX idx_slug (slug),
FULLTEXT INDEX idx_search (name, description)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Images table
CREATE TABLE IF NOT EXISTS images (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NULL,
album_id INT NULL,
filename VARCHAR(255) NOT NULL,
original_filename VARCHAR(255) NOT NULL,
title VARCHAR(255),
description TEXT,
alt_text VARCHAR(255),
tags JSON,
file_size INT NOT NULL,
width INT,
height INT,
mime_type VARCHAR(100),
exif_data JSON,
storage_path VARCHAR(500) NOT NULL,
thumbnail_path VARCHAR(500),
medium_path VARCHAR(500),
large_path VARCHAR(500),
webp_path VARCHAR(500),
views INT DEFAULT 0,
downloads INT DEFAULT 0,
is_featured BOOLEAN DEFAULT FALSE,
status ENUM('active', 'pending', 'rejected', 'trashed') DEFAULT 'active',
sort_order INT DEFAULT 0,
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_album_id (album_id),
INDEX idx_status (status),
INDEX idx_uploaded (uploaded_at),
INDEX idx_views (views),
FULLTEXT INDEX idx_search (title, description, alt_text),
FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Image tags table
CREATE TABLE IF NOT EXISTS image_tags (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
slug VARCHAR(100) NOT NULL UNIQUE,
count INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_slug (slug),
INDEX idx_count (count)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Image-tag relationships
CREATE TABLE IF NOT EXISTS image_tag_relations (
image_id INT NOT NULL,
tag_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (image_id, tag_id),
FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES image_tags(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Image comments
CREATE TABLE IF NOT EXISTS image_comments (
id INT AUTO_INCREMENT PRIMARY KEY,
image_id INT NOT NULL,
user_id INT NULL,
author_name VARCHAR(100),
author_email VARCHAR(100),
comment TEXT NOT NULL,
status ENUM('pending', 'approved', 'spam') DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_image_id (image_id),
INDEX idx_status (status),
FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Processing queue for large images
CREATE TABLE IF NOT EXISTS image_processing_queue (
id INT AUTO_INCREMENT PRIMARY KEY,
image_id INT NOT NULL,
task ENUM('resize', 'optimize', 'watermark', 'convert') NOT NULL,
priority INT DEFAULT 0,
status ENUM('pending', 'processing', 'completed', 'failed') DEFAULT 'pending',
attempts INT DEFAULT 0,
error_message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_status (status),
INDEX idx_priority (priority),
FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Storage usage tracking
CREATE TABLE IF NOT EXISTS storage_usage (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NULL,
total_size BIGINT DEFAULT 0,
image_count INT DEFAULT 0,
last_calculated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Rate limiting for uploads
CREATE TABLE IF NOT EXISTS upload_rate_limit (
id INT AUTO_INCREMENT PRIMARY KEY,
ip_address VARCHAR(45) NOT NULL,
user_id INT NULL,
upload_count INT DEFAULT 1,
total_size BIGINT DEFAULT 0,
last_upload TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
blocked_until TIMESTAMP NULL,
INDEX idx_ip (ip_address),
INDEX idx_user (user_id),
INDEX idx_blocked (blocked_until)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
π§ File Contents
1. composer.json
{
"name": "blog-website/gallery-system",
"description": "Professional image upload and gallery system",
"type": "project",
"require": {
"php": ">=7.4",
"vlucas/phpdotenv": "^5.5",
"intervention/image": "^2.7",
"ext-gd": "*",
"ext-exif": "*",
"ext-json": "*",
"ext-fileinfo": "*"
},
"require-dev": {
"phpunit/phpunit": "^9.0"
},
"autoload": {
"psr-4": {
"Gallery\\": "src/"
}
},
"license": "MIT",
"authors": [
{
"name": "Your Name",
"email": "[email protected]"
}
]
}
2. .env (Environment Variables)
# Database Configuration DB_HOST=localhost DB_NAME=gallery_db DB_USER=root DB_PASS= # Application Settings APP_NAME="My Blog Gallery" APP_URL=http://localhost/blog-website UPLOAD_MAX_SIZE=10485760 # 10MB in bytes ALLOWED_EXTENSIONS=jpg,jpeg,png,gif,webp,bmp ALLOWED_MIME_TYPES=image/jpeg,image/png,image/gif,image/webp,image/bmp # Image Processing IMAGE_QUALITY=85 THUMBNAIL_SIZE=300x300 MEDIUM_SIZE=800x600 LARGE_SIZE=1920x1080 GENERATE_WEBP=true PRESERVE_EXIF=false AUTO_ORIENT=true # Storage Settings STORAGE_DRIVER=local # local, s3, wasabi MAX_STORAGE_PER_USER=1073741824 # 1GB in bytes IMAGE_EXPIRY_DAYS=0 # 0 = never expire # CDN Settings (optional) CDN_ENABLED=false CDN_URL=https://cdn.yourdomain.com CDN_KEY=your_cdn_key # Watermark Settings WATERMARK_ENABLED=false WATERMARK_IMAGE=watermark.png WATERMARK_POSITION=bottom-right WATERMARK_OPACITY=50 # Security RATE_LIMIT_ENABLED=true MAX_UPLOADS_PER_HOUR=50 MAX_UPLOAD_SIZE_PER_HOUR=104857600 # 100MB REQUIRE_IMAGE_MODERATION=false # Face Detection (optional) FACE_DETECTION_ENABLED=false FACE_DETECTION_API_KEY=your_key_here # Backup Settings BACKUP_ENABLED=true BACKUP_INTERVAL=86400 # 24 hours in seconds BACKUP_RETENTION_DAYS=30
3. includes/gallery-config.php
<?php
/**
* Gallery System Configuration
*/
require_once __DIR__ . '/../vendor/autoload.php';
use Dotenv\Dotenv;
use Intervention\Image\ImageManager;
// Load environment variables
$dotenv = Dotenv::createImmutable(__DIR__ . '/../');
$dotenv->load();
// Database configuration
define('DB_HOST', $_ENV['DB_HOST']);
define('DB_USER', $_ENV['DB_USER']);
define('DB_PASS', $_ENV['DB_PASS']);
define('DB_NAME', $_ENV['DB_NAME']);
// Upload settings
define('UPLOAD_MAX_SIZE', (int)$_ENV['UPLOAD_MAX_SIZE']);
define('ALLOWED_EXTENSIONS', explode(',', $_ENV['ALLOWED_EXTENSIONS']));
define('ALLOWED_MIME_TYPES', explode(',', $_ENV['ALLOWED_MIME_TYPES']));
// Image processing
define('IMAGE_QUALITY', (int)$_ENV['IMAGE_QUALITY']);
define('THUMBNAIL_SIZE', $_ENV['THUMBNAIL_SIZE']);
define('MEDIUM_SIZE', $_ENV['MEDIUM_SIZE']);
define('LARGE_SIZE', $_ENV['LARGE_SIZE']);
define('GENERATE_WEBP', filter_var($_ENV['GENERATE_WEBP'], FILTER_VALIDATE_BOOLEAN));
define('PRESERVE_EXIF', filter_var($_ENV['PRESERVE_EXIF'], FILTER_VALIDATE_BOOLEAN));
define('AUTO_ORIENT', filter_var($_ENV['AUTO_ORIENT'], FILTER_VALIDATE_BOOLEAN));
// Storage paths
define('UPLOAD_PATH', __DIR__ . '/../uploads/');
define('IMAGE_PATH', UPLOAD_PATH . 'images/');
define('THUMBNAIL_PATH', UPLOAD_PATH . 'thumbnails/');
define('MEDIUM_PATH', UPLOAD_PATH . 'medium/');
define('LARGE_PATH', UPLOAD_PATH . 'large/');
define('TEMP_PATH', UPLOAD_PATH . 'temp/');
// Create directories if they don't exist
foreach ([IMAGE_PATH, THUMBNAIL_PATH, MEDIUM_PATH, LARGE_PATH, TEMP_PATH] as $path) {
if (!file_exists($path)) {
mkdir($path, 0777, true);
}
}
// Start session
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// Initialize Image Manager
function getImageManager() {
return new ImageManager(['driver' => 'gd']);
}
// Database connection (PDO)
function getPDOConnection() {
static $pdo = null;
if ($pdo === null) {
try {
$dsn = "mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4";
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false
]);
} catch (PDOException $e) {
error_log("PDO Connection error: " . $e->getMessage());
return null;
}
}
return $pdo;
}
4. includes/gallery-functions.php
<?php
/**
* Core Gallery Functions
*/
require_once __DIR__ . '/gallery-config.php';
/**
* Generate a unique filename
*/
function generateUniqueFilename($originalName) {
$extension = pathinfo($originalName, PATHINFO_EXTENSION);
$hash = md5(uniqid() . time() . $originalName);
return substr($hash, 0, 20) . '_' . time() . '.' . $extension;
}
/**
* Validate uploaded file
*/
function validateImage($file) {
$errors = [];
// Check if file was uploaded
if (!isset($file) || $file['error'] !== UPLOAD_ERR_OK) {
$uploadErrors = [
UPLOAD_ERR_INI_SIZE => 'File exceeds upload_max_filesize',
UPLOAD_ERR_FORM_SIZE => 'File exceeds MAX_FILE_SIZE directive',
UPLOAD_ERR_PARTIAL => 'File was only partially uploaded',
UPLOAD_ERR_NO_FILE => 'No file was uploaded',
UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder',
UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk',
UPLOAD_ERR_EXTENSION => 'File upload stopped by extension'
];
$errorCode = $file['error'];
$errors[] = $uploadErrors[$errorCode] ?? 'Unknown upload error';
return ['valid' => false, 'errors' => $errors];
}
// Check file size
if ($file['size'] > UPLOAD_MAX_SIZE) {
$maxSizeMB = UPLOAD_MAX_SIZE / 1048576;
$errors[] = "File size exceeds maximum allowed size ({$maxSizeMB}MB)";
}
// Check file extension
$extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($extension, ALLOWED_EXTENSIONS)) {
$errors[] = "File type not allowed. Allowed types: " . implode(', ', ALLOWED_EXTENSIONS);
}
// Check MIME type
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mimeType, ALLOWED_MIME_TYPES)) {
$errors[] = "Invalid file MIME type";
}
// Verify it's actually an image
if (!getimagesize($file['tmp_name'])) {
$errors[] = "File is not a valid image";
}
return [
'valid' => empty($errors),
'errors' => $errors,
'mime' => $mimeType,
'extension' => $extension
];
}
/**
* Process and save image
*/
function processImage($file, $options = []) {
$manager = getImageManager();
try {
// Load image
$image = $manager->make($file['tmp_name']);
// Auto-orient based on EXIF
if (AUTO_ORIENT) {
$image->orientate();
}
// Get image dimensions
$width = $image->width();
$height = $image->height();
// Generate paths
$year = date('Y');
$month = date('m');
$day = date('d');
$subPath = "{$year}/{$month}/{$day}/";
$filename = generateUniqueFilename($file['name']);
// Create subdirectories
$imageSubPath = IMAGE_PATH . $subPath;
$thumbSubPath = THUMBNAIL_PATH . $subPath;
$mediumSubPath = MEDIUM_PATH . $subPath;
$largeSubPath = LARGE_PATH . $subPath;
foreach ([$imageSubPath, $thumbSubPath, $mediumSubPath, $largeSubPath] as $path) {
if (!file_exists($path)) {
mkdir($path, 0777, true);
}
}
// Save original
$originalPath = $imageSubPath . $filename;
$image->save($originalPath, IMAGE_QUALITY);
// Generate thumbnail
list($thumbWidth, $thumbHeight) = explode('x', THUMBNAIL_SIZE);
$thumbnail = clone $image;
$thumbnail->fit($thumbWidth, $thumbHeight, function ($constraint) {
$constraint->upsize();
});
$thumbPath = $thumbSubPath . $filename;
$thumbnail->save($thumbPath, IMAGE_QUALITY);
// Generate medium size
list($medWidth, $medHeight) = explode('x', MEDIUM_SIZE);
$medium = clone $image;
$medium->resize($medWidth, $medHeight, function ($constraint) {
$constraint->aspectRatio();
$constraint->upsize();
});
$mediumPath = $mediumSubPath . $filename;
$medium->save($mediumPath, IMAGE_QUALITY);
// Generate large size
list($largeWidth, $largeHeight) = explode('x', LARGE_SIZE);
$large = clone $image;
$large->resize($largeWidth, $largeHeight, function ($constraint) {
$constraint->aspectRatio();
$constraint->upsize();
});
$largePath = $largeSubPath . $filename;
$large->save($largePath, IMAGE_QUALITY);
// Generate WebP version if enabled
$webpPath = null;
if (GENERATE_WEBP) {
$webpFilename = pathinfo($filename, PATHINFO_FILENAME) . '.webp';
$webpPath = $imageSubPath . $webpFilename;
$image->encode('webp', IMAGE_QUALITY)->save($webpPath);
}
// Extract EXIF data if enabled
$exifData = null;
if (PRESERVE_EXIF && function_exists('exif_read_data')) {
$exif = @exif_read_data($originalPath);
if ($exif) {
// Filter sensitive data
$exifData = json_encode([
'make' => $exif['Make'] ?? null,
'model' => $exif['Model'] ?? null,
'exposure' => $exif['ExposureTime'] ?? null,
'aperture' => $exif['FNumber'] ?? null,
'iso' => $exif['ISOSpeedRatings'] ?? null,
'focal' => $exif['FocalLength'] ?? null,
'datetime' => $exif['DateTime'] ?? null
]);
}
}
return [
'success' => true,
'original' => "uploads/images/{$subPath}{$filename}",
'thumbnail' => "uploads/thumbnails/{$subPath}{$filename}",
'medium' => "uploads/medium/{$subPath}{$filename}",
'large' => "uploads/large/{$subPath}{$filename}",
'webp' => $webpPath ? "uploads/images/{$subPath}" . pathinfo($filename, PATHINFO_FILENAME) . '.webp' : null,
'width' => $width,
'height' => $height,
'size' => $file['size'],
'mime' => $file['type'],
'exif' => $exifData,
'filename' => $filename
];
} catch (Exception $e) {
error_log("Image processing error: " . $e->getMessage());
return [
'success' => false,
'error' => 'Failed to process image: ' . $e->getMessage()
];
}
}
/**
* Save image to database
*/
function saveImageToDatabase($imageData, $metadata = []) {
$pdo = getPDOConnection();
$sql = "INSERT INTO images (
user_id, album_id, filename, original_filename, title, description,
alt_text, tags, file_size, width, height, mime_type, exif_data,
storage_path, thumbnail_path, medium_path, large_path, webp_path, status
) VALUES (
:user_id, :album_id, :filename, :original_filename, :title, :description,
:alt_text, :tags, :file_size, :width, :height, :mime_type, :exif_data,
:storage_path, :thumbnail_path, :medium_path, :large_path, :webp_path, :status
)";
$stmt = $pdo->prepare($sql);
$params = [
'user_id' => $_SESSION['user_id'] ?? null,
'album_id' => $metadata['album_id'] ?? null,
'filename' => $imageData['filename'],
'original_filename' => $metadata['original_name'] ?? $imageData['filename'],
'title' => $metadata['title'] ?? null,
'description' => $metadata['description'] ?? null,
'alt_text' => $metadata['alt_text'] ?? null,
'tags' => json_encode($metadata['tags'] ?? []),
'file_size' => $imageData['size'],
'width' => $imageData['width'],
'height' => $imageData['height'],
'mime_type' => $imageData['mime'],
'exif_data' => $imageData['exif'],
'storage_path' => $imageData['original'],
'thumbnail_path' => $imageData['thumbnail'],
'medium_path' => $imageData['medium'],
'large_path' => $imageData['large'],
'webp_path' => $imageData['webp'],
'status' => REQUIRE_IMAGE_MODERATION ? 'pending' : 'active'
];
if ($stmt->execute($params)) {
$imageId = $pdo->lastInsertId();
// Handle tags
if (!empty($metadata['tags'])) {
foreach ($metadata['tags'] as $tag) {
addTagToImage($imageId, $tag);
}
}
// Update storage usage
updateStorageUsage($_SESSION['user_id'] ?? null, $imageData['size']);
// Add to processing queue if needed
if (!empty($metadata['watermark']) && WATERMARK_ENABLED) {
addToProcessingQueue($imageId, 'watermark');
}
return $imageId;
}
return false;
}
/**
* Add tag to image
*/
function addTagToImage($imageId, $tagName) {
$pdo = getPDOConnection();
// Clean tag
$tagName = trim($tagName);
$tagSlug = strtolower(preg_replace('/[^a-z0-9]+/', '-', $tagName));
// Check if tag exists
$stmt = $pdo->prepare("SELECT id FROM image_tags WHERE slug = ?");
$stmt->execute([$tagSlug]);
$tag = $stmt->fetch();
if ($tag) {
$tagId = $tag['id'];
// Increment count
$pdo->prepare("UPDATE image_tags SET count = count + 1 WHERE id = ?")->execute([$tagId]);
} else {
// Create new tag
$stmt = $pdo->prepare("INSERT INTO image_tags (name, slug, count) VALUES (?, ?, 1)");
$stmt->execute([$tagName, $tagSlug]);
$tagId = $pdo->lastInsertId();
}
// Create relation
$stmt = $pdo->prepare("INSERT IGNORE INTO image_tag_relations (image_id, tag_id) VALUES (?, ?)");
$stmt->execute([$imageId, $tagId]);
}
/**
* Get images with filters
*/
function getImages($filters = [], $page = 1, $perPage = 20) {
$pdo = getPDOConnection();
$where = ["status = 'active'"];
$params = [];
// Apply filters
if (!empty($filters['album_id'])) {
$where[] = "album_id = :album_id";
$params['album_id'] = $filters['album_id'];
}
if (!empty($filters['user_id'])) {
$where[] = "user_id = :user_id";
$params['user_id'] = $filters['user_id'];
}
if (!empty($filters['tag'])) {
$where[] = "id IN (SELECT image_id FROM image_tag_relations WHERE tag_id =
(SELECT id FROM image_tags WHERE slug = :tag))";
$params['tag'] = $filters['tag'];
}
if (!empty($filters['search'])) {
$where[] = "(title LIKE :search OR description LIKE :search OR alt_text LIKE :search)";
$params['search'] = '%' . $filters['search'] . '%';
}
if (!empty($filters['featured'])) {
$where[] = "is_featured = 1";
}
// Count total
$countSql = "SELECT COUNT(*) FROM images WHERE " . implode(' AND ', $where);
$countStmt = $pdo->prepare($countSql);
$countStmt->execute($params);
$total = $countStmt->fetchColumn();
// Get images
$offset = ($page - 1) * $perPage;
$sql = "SELECT * FROM images WHERE " . implode(' AND ', $where) .
" ORDER BY " . ($filters['sort'] ?? 'uploaded_at') . " DESC
LIMIT :offset, :perPage";
$stmt = $pdo->prepare($sql);
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value);
}
$stmt->bindValue('offset', $offset, PDO::PARAM_INT);
$stmt->bindValue('perPage', $perPage, PDO::PARAM_INT);
$stmt->execute();
return [
'images' => $stmt->fetchAll(),
'total' => $total,
'page' => $page,
'perPage' => $perPage,
'totalPages' => ceil($total / $perPage)
];
}
/**
* Get single image
*/
function getImage($id) {
$pdo = getPDOConnection();
$stmt = $pdo->prepare("SELECT * FROM images WHERE id = ?");
$stmt->execute([$id]);
$image = $stmt->fetch();
if ($image) {
// Get tags
$stmt = $pdo->prepare("
SELECT t.name, t.slug
FROM image_tags t
JOIN image_tag_relations r ON t.id = r.tag_id
WHERE r.image_id = ?
");
$stmt->execute([$id]);
$image['tags'] = $stmt->fetchAll();
// Get album
if ($image['album_id']) {
$stmt = $pdo->prepare("SELECT * FROM albums WHERE id = ?");
$stmt->execute([$image['album_id']]);
$image['album'] = $stmt->fetch();
}
// Increment views
$pdo->prepare("UPDATE images SET views = views + 1 WHERE id = ?")->execute([$id]);
}
return $image;
}
/**
* Create album
*/
function createAlbum($data) {
$pdo = getPDOConnection();
$slug = strtolower(preg_replace('/[^a-z0-9]+/', '-', $data['name']));
// Ensure unique slug
$baseSlug = $slug;
$counter = 1;
while (true) {
$stmt = $pdo->prepare("SELECT id FROM albums WHERE slug = ?");
$stmt->execute([$slug]);
if (!$stmt->fetch()) {
break;
}
$slug = $baseSlug . '-' . $counter;
$counter++;
}
$sql = "INSERT INTO albums (user_id, name, slug, description, privacy, password)
VALUES (:user_id, :name, :slug, :description, :privacy, :password)";
$stmt = $pdo->prepare($sql);
$success = $stmt->execute([
'user_id' => $_SESSION['user_id'] ?? null,
'name' => $data['name'],
'slug' => $slug,
'description' => $data['description'] ?? null,
'privacy' => $data['privacy'] ?? 'public',
'password' => !empty($data['password']) ? password_hash($data['password'], PASSWORD_DEFAULT) : null
]);
if ($success) {
return $pdo->lastInsertId();
}
return false;
}
/**
* Check rate limit for uploads
*/
function checkUploadRateLimit() {
if (!RATE_LIMIT_ENABLED) {
return true;
}
$pdo = getPDOConnection();
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$userId = $_SESSION['user_id'] ?? null;
// Clean old entries
$cleanup = $pdo->prepare("DELETE FROM upload_rate_limit WHERE last_upload < DATE_SUB(NOW(), INTERVAL 1 HOUR)");
$cleanup->execute();
// Check if IP is blocked
$stmt = $pdo->prepare("SELECT * FROM upload_rate_limit WHERE ip_address = ? OR user_id = ?");
$stmt->execute([$ip, $userId]);
$record = $stmt->fetch();
if ($record) {
if ($record['blocked_until'] && strtotime($record['blocked_until']) > time()) {
return false;
}
if ($record['upload_count'] >= MAX_UPLOADS_PER_HOUR) {
// Block for an hour
$blockUntil = date('Y-m-d H:i:s', strtotime('+1 hour'));
$update = $pdo->prepare("UPDATE upload_rate_limit SET blocked_until = ? WHERE id = ?");
$update->execute([$blockUntil, $record['id']]);
return false;
}
if ($record['total_size'] >= MAX_UPLOAD_SIZE_PER_HOUR) {
$blockUntil = date('Y-m-d H:i:s', strtotime('+1 hour'));
$update = $pdo->prepare("UPDATE upload_rate_limit SET blocked_until = ? WHERE id = ?");
$update->execute([$blockUntil, $record['id']]);
return false;
}
}
return true;
}
/**
* Update rate limit counter
*/
function incrementUploadRateLimit($fileSize) {
if (!RATE_LIMIT_ENABLED) {
return;
}
$pdo = getPDOConnection();
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$userId = $_SESSION['user_id'] ?? null;
$sql = "INSERT INTO upload_rate_limit (ip_address, user_id, upload_count, total_size)
VALUES (?, ?, 1, ?)
ON DUPLICATE KEY UPDATE
upload_count = upload_count + 1,
total_size = total_size + ?,
last_upload = CURRENT_TIMESTAMP";
$stmt = $pdo->prepare($sql);
$stmt->execute([$ip, $userId, $fileSize, $fileSize]);
}
/**
* Update storage usage
*/
function updateStorageUsage($userId, $additionalSize) {
$pdo = getPDOConnection();
$stmt = $pdo->prepare("
INSERT INTO storage_usage (user_id, total_size, image_count)
VALUES (?, ?, 1)
ON DUPLICATE KEY UPDATE
total_size = total_size + ?,
image_count = image_count + 1
");
$stmt->execute([$userId, $additionalSize, $additionalSize]);
}
/**
* Add image to processing queue
*/
function addToProcessingQueue($imageId, $task, $priority = 0) {
$pdo = getPDOConnection();
$stmt = $pdo->prepare("
INSERT INTO image_processing_queue (image_id, task, priority)
VALUES (?, ?, ?)
");
return $stmt->execute([$imageId, $task, $priority]);
}
/**
* Process queue (run via cron)
*/
function processImageQueue() {
$pdo = getPDOConnection();
// Get next pending item
$stmt = $pdo->prepare("
SELECT * FROM image_processing_queue
WHERE status = 'pending'
ORDER BY priority DESC, created_at ASC
LIMIT 1
");
$stmt->execute();
$job = $stmt->fetch();
if (!$job) {
return;
}
// Mark as processing
$pdo->prepare("UPDATE image_processing_queue SET status = 'processing' WHERE id = ?")
->execute([$job['id']]);
try {
// Get image
$image = getImage($job['image_id']);
switch ($job['task']) {
case 'watermark':
applyWatermark($image);
break;
case 'optimize':
optimizeImage($image);
break;
case 'convert':
convertToWebP($image);
break;
}
// Mark as completed
$pdo->prepare("UPDATE image_processing_queue SET status = 'completed' WHERE id = ?")
->execute([$job['id']]);
} catch (Exception $e) {
// Mark as failed
$pdo->prepare("
UPDATE image_processing_queue
SET status = 'failed', error_message = ?, attempts = attempts + 1
WHERE id = ?
")->execute([$e->getMessage(), $job['id']]);
}
}
/**
* Apply watermark to image
*/
function applyWatermark($image) {
if (!WATERMARK_ENABLED) {
return;
}
$manager = getImageManager();
$watermarkPath = __DIR__ . '/../uploads/' . WATERMARK_IMAGE;
if (!file_exists($watermarkPath)) {
return;
}
// Load image and watermark
$img = $manager->make($image['storage_path']);
$watermark = $manager->make($watermarkPath);
// Resize watermark if too large
if ($watermark->width() > $img->width() / 3) {
$watermark->resize($img->width() / 3, null, function ($constraint) {
$constraint->aspectRatio();
});
}
// Position watermark
$position = explode('-', WATERMARK_POSITION);
$x = $position[1] ?? 'right';
$y = $position[0] ?? 'bottom';
// Apply watermark
$img->insert($watermark, WATERMARK_POSITION, 10, 10);
// Save with opacity
$img->save($image['storage_path'], IMAGE_QUALITY);
}
/**
* Get user storage usage
*/
function getUserStorageUsage($userId) {
$pdo = getPDOConnection();
$stmt = $pdo->prepare("SELECT total_size, image_count FROM storage_usage WHERE user_id = ?");
$stmt->execute([$userId]);
$usage = $stmt->fetch();
if (!$usage) {
return [
'total_size' => 0,
'image_count' => 0,
'percent_used' => 0,
'formatted_used' => '0 B',
'formatted_total' => formatBytes(MAX_STORAGE_PER_USER)
];
}
$percentUsed = ($usage['total_size'] / MAX_STORAGE_PER_USER) * 100;
return [
'total_size' => $usage['total_size'],
'image_count' => $usage['image_count'],
'percent_used' => round($percentUsed, 2),
'formatted_used' => formatBytes($usage['total_size']),
'formatted_total' => formatBytes(MAX_STORAGE_PER_USER)
];
}
/**
* Format bytes to human readable
*/
function formatBytes($bytes, $precision = 2) {
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= pow(1024, $pow);
return round($bytes, $precision) . ' ' . $units[$pow];
}
/**
* Delete image
*/
function deleteImage($imageId, $permanent = false) {
$pdo = getPDOConnection();
$image = getImage($imageId);
if (!$image) {
return false;
}
if ($permanent) {
// Delete files
$files = [
$image['storage_path'],
$image['thumbnail_path'],
$image['medium_path'],
$image['large_path'],
$image['webp_path']
];
foreach ($files as $file) {
if ($file && file_exists(__DIR__ . '/../' . $file)) {
unlink(__DIR__ . '/../' . $file);
}
}
// Delete from database
$pdo->prepare("DELETE FROM images WHERE id = ?")->execute([$imageId]);
$pdo->prepare("DELETE FROM image_tag_relations WHERE image_id = ?")->execute([$imageId]);
// Update storage usage
if ($image['user_id']) {
$pdo->prepare("
UPDATE storage_usage
SET total_size = total_size - ?, image_count = image_count - 1
WHERE user_id = ?
")->execute([$image['file_size'], $image['user_id']]);
}
} else {
// Move to trash
$pdo->prepare("UPDATE images SET status = 'trashed' WHERE id = ?")->execute([$imageId]);
}
return true;
}
5. upload.php (Image Upload Interface)
<?php
require_once 'includes/config.php';
require_once 'includes/gallery-functions.php';
$page_title = 'Upload Images - ' . APP_NAME;
$userId = $_SESSION['user_id'] ?? null;
// Check rate limit
if (!checkUploadRateLimit()) {
$rateLimitError = "Upload limit exceeded. Please try again later.";
}
// Get user's albums
$pdo = getPDOConnection();
$albums = [];
if ($userId) {
$stmt = $pdo->prepare("SELECT * FROM albums WHERE user_id = ? ORDER BY name");
$stmt->execute([$userId]);
$albums = $stmt->fetchAll();
}
// Get storage usage
$storageUsage = $userId ? getUserStorageUsage($userId) : null;
include 'includes/header.php';
?>
<link rel="stylesheet" href="css/gallery.css">
<div class="upload-container">
<div class="upload-header">
<h1>π€ Upload Images</h1>
<p>Drag and drop your images or click to select files. Supported formats: JPG, PNG, GIF, WebP</p>
</div>
<?php if (isset($rateLimitError)): ?>
<div class="alert error"><?php echo $rateLimitError; ?></div>
<?php endif; ?>
<?php if ($storageUsage && $storageUsage['percent_used'] > 90): ?>
<div class="alert warning">
β οΈ You're running low on storage space.
Used: <?php echo $storageUsage['formatted_used']; ?> of <?php echo $storageUsage['formatted_total']; ?>
</div>
<?php endif; ?>
<div class="upload-grid">
<!-- Upload Area -->
<div class="upload-card">
<div class="upload-area" id="dropArea">
<div class="upload-icon">π</div>
<h3>Drag & Drop Images Here</h3>
<p>or</p>
<input type="file" id="fileInput" multiple accept="image/*" style="display: none;">
<button class="btn-select" onclick="document.getElementById('fileInput').click()">
Choose Files
</button>
<p class="upload-limit">
Max file size: <?php echo round(UPLOAD_MAX_SIZE / 1048576); ?>MB
</p>
</div>
<!-- Upload Options -->
<div class="upload-options">
<h4>Upload Options</h4>
<div class="form-group">
<label for="albumSelect">Add to Album (optional)</label>
<select id="albumSelect" class="form-control">
<option value="">-- Select Album --</option>
<?php foreach ($albums as $album): ?>
<option value="<?php echo $album['id']; ?>">
<?php echo htmlspecialchars($album['name']); ?>
</option>
<?php endforeach; ?>
<option value="new">+ Create New Album</option>
</select>
</div>
<div id="newAlbumFields" style="display: none;">
<div class="form-group">
<label for="newAlbumName">Album Name</label>
<input type="text" id="newAlbumName" class="form-control"
placeholder="My Album">
</div>
<div class="form-group">
<label for="albumPrivacy">Privacy</label>
<select id="albumPrivacy" class="form-control">
<option value="public">Public</option>
<option value="private">Private</option>
<option value="password">Password Protected</option>
</select>
</div>
<div id="albumPasswordField" style="display: none;">
<div class="form-group">
<label for="albumPassword">Password</label>
<input type="password" id="albumPassword" class="form-control">
</div>
</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="generateTags" checked>
Auto-generate tags from filename
</label>
</div>
<?php if (WATERMARK_ENABLED): ?>
<div class="form-group">
<label>
<input type="checkbox" id="applyWatermark">
Apply watermark
</label>
</div>
<?php endif; ?>
</div>
</div>
<!-- Upload Queue -->
<div class="queue-card">
<h3>Upload Queue</h3>
<div id="uploadQueue" class="upload-queue">
<p class="empty-queue">No files selected</p>
</div>
<div class="queue-actions">
<button class="btn-upload" id="startUploadBtn" disabled>
Start Upload
</button>
<button class="btn-clear" id="clearQueueBtn" disabled>
Clear Queue
</button>
</div>
</div>
</div>
<!-- Recent Uploads -->
<div class="recent-uploads">
<h2>Recent Uploads</h2>
<?php
$recentImages = getImages(['user_id' => $userId], 1, 12);
if (!empty($recentImages['images'])):
?>
<div class="image-grid">
<?php foreach ($recentImages['images'] as $image): ?>
<div class="image-card">
<a href="image.php?id=<?php echo $image['id']; ?>">
<img src="<?php echo htmlspecialchars($image['thumbnail_path']); ?>"
alt="<?php echo htmlspecialchars($image['title'] ?: $image['original_filename']); ?>"
loading="lazy">
</a>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<p class="no-images">No uploads yet</p>
<?php endif; ?>
</div>
</div>
<!-- Upload Templates -->
<template id="queue-item-template">
<div class="queue-item" data-file-id="{id}">
<div class="file-info">
<span class="file-name">{name}</span>
<span class="file-size">{size}</span>
</div>
<div class="file-status">
<span class="status-text">Pending</span>
<div class="progress-bar">
<div class="progress-fill" style="width: 0%"></div>
</div>
<button class="remove-file" title="Remove from queue">β</button>
</div>
</div>
</template>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="js/upload.js"></script>
<?php include 'includes/footer.php'; ?>
6. js/upload.js
/**
* Image Upload Handler
*/
class ImageUploader {
constructor() {
this.files = [];
this.currentUploads = 0;
this.maxConcurrent = 3;
this.queue = [];
this.albums = [];
this.init();
}
init() {
this.dropArea = document.getElementById('dropArea');
this.fileInput = document.getElementById('fileInput');
this.uploadQueue = document.getElementById('uploadQueue');
this.startBtn = document.getElementById('startUploadBtn');
this.clearBtn = document.getElementById('clearQueueBtn');
this.albumSelect = document.getElementById('albumSelect');
this.template = document.getElementById('queue-item-template');
this.setupEventListeners();
}
setupEventListeners() {
// Drag & drop events
this.dropArea.addEventListener('dragover', (e) => {
e.preventDefault();
this.dropArea.classList.add('dragover');
});
this.dropArea.addEventListener('dragleave', () => {
this.dropArea.classList.remove('dragover');
});
this.dropArea.addEventListener('drop', (e) => {
e.preventDefault();
this.dropArea.classList.remove('dragover');
this.handleFiles(e.dataTransfer.files);
});
// File input change
this.fileInput.addEventListener('change', (e) => {
this.handleFiles(e.target.files);
this.fileInput.value = ''; // Reset for next selection
});
// Start upload button
this.startBtn.addEventListener('click', () => {
this.startUpload();
});
// Clear queue button
this.clearBtn.addEventListener('click', () => {
this.clearQueue();
});
// Album selection
this.albumSelect.addEventListener('change', (e) => {
this.toggleNewAlbumFields(e.target.value === 'new');
});
// Privacy selection for new album
const albumPrivacy = document.getElementById('albumPrivacy');
if (albumPrivacy) {
albumPrivacy.addEventListener('change', (e) => {
document.getElementById('albumPasswordField').style.display =
e.target.value === 'password' ? 'block' : 'none';
});
}
}
handleFiles(fileList) {
const maxSize = <?php echo UPLOAD_MAX_SIZE; ?>;
const allowedTypes = <?php echo json_encode(ALLOWED_MIME_TYPES); ?>;
for (let file of fileList) {
// Check file type
if (!allowedTypes.includes(file.type)) {
this.showError(`File type not allowed: ${file.name}`);
continue;
}
// Check file size
if (file.size > maxSize) {
const maxSizeMB = Math.round(maxSize / 1048576);
this.showError(`File too large (max ${maxSizeMB}MB): ${file.name}`);
continue;
}
// Check for duplicates
if (this.files.some(f => f.name === file.name && f.size === file.size)) {
this.showError(`Duplicate file: ${file.name}`);
continue;
}
// Add to queue
this.addToQueue(file);
}
this.updateQueueDisplay();
this.updateButtons();
}
addToQueue(file) {
const id = 'file_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
this.files.push({
id: id,
file: file,
name: file.name,
size: this.formatSize(file.size),
status: 'pending',
progress: 0
});
}
updateQueueDisplay() {
if (this.files.length === 0) {
this.uploadQueue.innerHTML = '<p class="empty-queue">No files selected</p>';
return;
}
let html = '';
this.files.forEach(file => {
const itemHtml = this.template.innerHTML
.replace('{id}', file.id)
.replace('{name}', this.escapeHtml(file.name))
.replace('{size}', file.size);
html += itemHtml;
});
this.uploadQueue.innerHTML = html;
// Add remove handlers
document.querySelectorAll('.remove-file').forEach((btn, index) => {
btn.addEventListener('click', () => {
this.removeFromQueue(index);
});
});
}
removeFromQueue(index) {
this.files.splice(index, 1);
this.updateQueueDisplay();
this.updateButtons();
}
clearQueue() {
this.files = [];
this.updateQueueDisplay();
this.updateButtons();
}
updateButtons() {
this.startBtn.disabled = this.files.length === 0;
this.clearBtn.disabled = this.files.length === 0;
}
async startUpload() {
if (this.files.length === 0) return;
// Disable controls during upload
this.startBtn.disabled = true;
this.clearBtn.disabled = true;
this.fileInput.disabled = true;
// Get upload options
const options = this.getUploadOptions();
// Upload files in batches
const batches = this.chunkArray(this.files, this.maxConcurrent);
for (let batch of batches) {
const promises = batch.map(file => this.uploadFile(file, options));
await Promise.all(promises);
}
// Re-enable controls
this.startBtn.disabled = false;
this.clearBtn.disabled = false;
this.fileInput.disabled = false;
// Clear successful uploads
this.files = this.files.filter(f => f.status !== 'completed');
this.updateQueueDisplay();
this.updateButtons();
// Show success message
this.showSuccess('Upload completed!');
// Reload recent images
setTimeout(() => {
location.reload();
}, 2000);
}
async uploadFile(fileData, options) {
const formData = new FormData();
formData.append('image', fileData.file);
formData.append('album_id', options.albumId || '');
formData.append('generate_tags', options.generateTags);
formData.append('apply_watermark', options.applyWatermark);
// Create new album if needed
if (options.newAlbum) {
formData.append('new_album_name', options.newAlbum.name);
formData.append('new_album_privacy', options.newAlbum.privacy);
formData.append('new_album_password', options.newAlbum.password || '');
}
const fileElement = document.querySelector(`[data-file-id="${fileData.id}"]`);
const statusEl = fileElement.querySelector('.status-text');
const progressFill = fileElement.querySelector('.progress-fill');
try {
statusEl.textContent = 'Uploading...';
fileData.status = 'uploading';
const response = await axios.post('includes/upload-handler.php', formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
onUploadProgress: (progressEvent) => {
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
progressFill.style.width = percent + '%';
fileData.progress = percent;
}
});
if (response.data.success) {
statusEl.textContent = 'Completed β';
progressFill.style.width = '100%';
fileData.status = 'completed';
// Change color to green
progressFill.style.background = '#28a745';
} else {
throw new Error(response.data.error || 'Upload failed');
}
} catch (error) {
statusEl.textContent = 'Failed β';
progressFill.style.background = '#dc3545';
fileData.status = 'failed';
this.showError(`Failed to upload ${fileData.name}: ${error.message}`);
}
}
getUploadOptions() {
const options = {
albumId: this.albumSelect.value,
generateTags: document.getElementById('generateTags').checked,
applyWatermark: document.getElementById('applyWatermark')?.checked || false,
newAlbum: null
};
if (options.albumId === 'new') {
options.newAlbum = {
name: document.getElementById('newAlbumName').value,
privacy: document.getElementById('albumPrivacy').value,
password: document.getElementById('albumPassword')?.value || ''
};
// Clear album ID for new album creation
options.albumId = null;
}
return options;
}
toggleNewAlbumFields(show) {
const fields = document.getElementById('newAlbumFields');
fields.style.display = show ? 'block' : 'none';
if (show) {
document.getElementById('newAlbumName').focus();
}
}
formatSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
chunkArray(array, size) {
const chunks = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
showError(message) {
const alert = document.createElement('div');
alert.className = 'alert error';
alert.textContent = message;
alert.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
animation: slideIn 0.3s;
`;
document.body.appendChild(alert);
setTimeout(() => {
alert.style.animation = 'slideOut 0.3s';
setTimeout(() => alert.remove(), 300);
}, 5000);
}
showSuccess(message) {
const alert = document.createElement('div');
alert.className = 'alert success';
alert.textContent = message;
alert.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
animation: slideIn 0.3s;
`;
document.body.appendChild(alert);
setTimeout(() => {
alert.style.animation = 'slideOut 0.3s';
setTimeout(() => alert.remove(), 300);
}, 3000);
}
}
// Initialize uploader when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
window.uploader = new ImageUploader();
});
// Add CSS animations
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
`;
document.head.appendChild(style);
7. includes/upload-handler.php
<?php
/**
* Upload Handler - Processes image uploads via AJAX
*/
require_once 'gallery-functions.php';
header('Content-Type: application/json');
// Check authentication
if (!isset($_SESSION['user_id'])) {
echo json_encode(['success' => false, 'error' => 'Please login to upload images']);
exit;
}
// Check rate limit
if (!checkUploadRateLimit()) {
echo json_encode(['success' => false, 'error' => 'Upload limit exceeded. Please try again later.']);
exit;
}
// Check if file was uploaded
if (!isset($_FILES['image'])) {
echo json_encode(['success' => false, 'error' => 'No file uploaded']);
exit;
}
// Validate image
$validation = validateImage($_FILES['image']);
if (!$validation['valid']) {
echo json_encode(['success' => false, 'error' => implode(', ', $validation['errors'])]);
exit;
}
// Process image
$processed = processImage($_FILES['image']);
if (!$processed['success']) {
echo json_encode(['success' => false, 'error' => $processed['error']]);
exit;
}
// Prepare metadata
$metadata = [
'original_name' => $_FILES['image']['name'],
'album_id' => $_POST['album_id'] ?: null,
'title' => pathinfo($_FILES['image']['name'], PATHINFO_FILENAME),
'generate_tags' => isset($_POST['generate_tags']) && $_POST['generate_tags'] === 'true'
];
// Create new album if requested
if (isset($_POST['new_album_name']) && !empty($_POST['new_album_name'])) {
$albumData = [
'name' => $_POST['new_album_name'],
'description' => '',
'privacy' => $_POST['new_album_privacy'] ?? 'public',
'password' => $_POST['new_album_password'] ?? null
];
$albumId = createAlbum($albumData);
if ($albumId) {
$metadata['album_id'] = $albumId;
}
}
// Generate tags from filename
if ($metadata['generate_tags']) {
$tags = [];
$filename = pathinfo($_FILES['image']['name'], PATHINFO_FILENAME);
$words = preg_split('/[^a-zA-Z0-9]+/', $filename);
foreach ($words as $word) {
$word = trim($word);
if (strlen($word) > 2 && !is_numeric($word)) {
$tags[] = strtolower($word);
}
}
$metadata['tags'] = array_slice($tags, 0, 5); // Limit to 5 tags
}
// Save to database
$imageId = saveImageToDatabase($processed, $metadata);
if ($imageId) {
// Add to processing queue for watermark if needed
if (isset($_POST['apply_watermark']) && $_POST['apply_watermark'] === 'true' && WATERMARK_ENABLED) {
addToProcessingQueue($imageId, 'watermark', 1);
}
// Update rate limit
incrementUploadRateLimit($processed['size']);
echo json_encode([
'success' => true,
'image_id' => $imageId,
'thumbnail' => $processed['thumbnail'],
'message' => 'Image uploaded successfully'
]);
} else {
echo json_encode(['success' => false, 'error' => 'Failed to save image to database']);
}
8. gallery.php (Main Gallery Page)
<?php
require_once 'includes/config.php';
require_once 'includes/gallery-functions.php';
$page_title = 'Image Gallery - ' . APP_NAME;
// Get filters
$filters = [
'album_id' => $_GET['album'] ?? null,
'tag' => $_GET['tag'] ?? null,
'search' => $_GET['search'] ?? null,
'sort' => $_GET['sort'] ?? 'uploaded_at'
];
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
$perPage = 24;
// Get images
$images = getImages($filters, $page, $perPage);
// Get albums for sidebar
$pdo = getPDOConnection();
$stmt = $pdo->query("SELECT * FROM albums WHERE privacy = 'public' ORDER BY name");
$albums = $stmt->fetchAll();
// Get popular tags
$stmt = $pdo->query("SELECT * FROM image_tags ORDER BY count DESC LIMIT 20");
$popularTags = $stmt->fetchAll();
include 'includes/header.php';
?>
<link rel="stylesheet" href="css/gallery.css">
<link rel="stylesheet" href="css/lightbox.css">
<div class="gallery-container">
<!-- Gallery Header -->
<div class="gallery-header">
<h1>πΌοΈ Image Gallery</h1>
<div class="gallery-actions">
<a href="upload.php" class="btn-upload">
<span>π€</span> Upload Images
</a>
<div class="search-box">
<form method="GET" class="search-form">
<input type="text"
name="search"
placeholder="Search images..."
value="<?php echo htmlspecialchars($filters['search'] ?? ''); ?>">
<button type="submit">π</button>
</form>
</div>
</div>
</div>
<div class="gallery-layout">
<!-- Sidebar -->
<aside class="gallery-sidebar">
<!-- Albums -->
<div class="sidebar-section">
<h3>π Albums</h3>
<ul class="album-list">
<li class="<?php echo !isset($filters['album_id']) ? 'active' : ''; ?>">
<a href="gallery.php">All Images</a>
</li>
<?php foreach ($albums as $album): ?>
<li class="<?php echo $filters['album_id'] == $album['id'] ? 'active' : ''; ?>">
<a href="?album=<?php echo $album['id']; ?>">
<?php echo htmlspecialchars($album['name']); ?>
</a>
</li>
<?php endforeach; ?>
</ul>
</div>
<!-- Popular Tags -->
<div class="sidebar-section">
<h3>π·οΈ Popular Tags</h3>
<div class="tag-cloud">
<?php foreach ($popularTags as $tag): ?>
<a href="?tag=<?php echo $tag['slug']; ?>"
class="tag"
style="font-size: <?php echo min(24, max(12, 12 + $tag['count'])); ?>px">
<?php echo htmlspecialchars($tag['name']); ?>
<span class="tag-count">(<?php echo $tag['count']; ?>)</span>
</a>
<?php endforeach; ?>
</div>
</div>
<!-- Sort Options -->
<div class="sidebar-section">
<h3>π Sort By</h3>
<select class="sort-select" onchange="window.location.href=this.value">
<option value="?sort=uploaded_at<?php echo $filters['album_id'] ? '&album=' . $filters['album_id'] : ''; ?>"
<?php echo $filters['sort'] == 'uploaded_at' ? 'selected' : ''; ?>>
Newest First
</option>
<option value="?sort=views<?php echo $filters['album_id'] ? '&album=' . $filters['album_id'] : ''; ?>"
<?php echo $filters['sort'] == 'views' ? 'selected' : ''; ?>>
Most Viewed
</option>
<option value="?sort=downloads<?php echo $filters['album_id'] ? '&album=' . $filters['album_id'] : ''; ?>"
<?php echo $filters['sort'] == 'downloads' ? 'selected' : ''; ?>>
Most Downloaded
</option>
</select>
</div>
</aside>
<!-- Main Content -->
<main class="gallery-main">
<!-- Image Grid -->
<?php if (empty($images['images'])): ?>
<div class="no-images">
<p>No images found</p>
<a href="upload.php" class="btn-primary">Upload Your First Image</a>
</div>
<?php else: ?>
<div class="image-grid">
<?php foreach ($images['images'] as $image): ?>
<div class="image-card" data-image-id="<?php echo $image['id']; ?>">
<a href="image.php?id=<?php echo $image['id']; ?>" class="image-link">
<img src="<?php echo htmlspecialchars($image['thumbnail_path']); ?>"
alt="<?php echo htmlspecialchars($image['alt_text'] ?: $image['title'] ?: $image['original_filename']); ?>"
loading="lazy"
class="gallery-image">
<?php if ($image['is_featured']): ?>
<span class="featured-badge">β</span>
<?php endif; ?>
<div class="image-overlay">
<h4><?php echo htmlspecialchars($image['title'] ?: $image['original_filename']); ?></h4>
<div class="image-stats">
<span>ποΈ <?php echo number_format($image['views']); ?></span>
<span>β¬οΈ <?php echo number_format($image['downloads']); ?></span>
</div>
</div>
</a>
<?php if ($image['tags']): ?>
<div class="image-tags">
<?php
$tags = json_decode($image['tags'], true);
if (is_array($tags)):
foreach (array_slice($tags, 0, 3) as $tag):
?>
<a href="?tag=<?php echo urlencode($tag); ?>" class="tag">#<?php echo $tag; ?></a>
<?php
endforeach;
endif;
?>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<!-- Pagination -->
<?php if ($images['totalPages'] > 1): ?>
<div class="pagination">
<?php if ($page > 1): ?>
<a href="?page=<?php echo $page - 1; ?><?php echo http_build_query($filters); ?>"
class="page-link">« Previous</a>
<?php endif; ?>
<?php for ($i = 1; $i <= $images['totalPages']; $i++): ?>
<?php if ($i == $page): ?>
<span class="page-current"><?php echo $i; ?></span>
<?php elseif ($i == 1 || $i == $images['totalPages'] || abs($i - $page) <= 2): ?>
<a href="?page=<?php echo $i; ?><?php echo http_build_query($filters); ?>"
class="page-link"><?php echo $i; ?></a>
<?php elseif ($i == $page - 3 || $i == $page + 3): ?>
<span class="page-dots">...</span>
<?php endif; ?>
<?php endfor; ?>
<?php if ($page < $images['totalPages']): ?>
<a href="?page=<?php echo $page + 1; ?><?php echo http_build_query($filters); ?>"
class="page-link">Next »</a>
<?php endif; ?>
</div>
<?php endif; ?>
<?php endif; ?>
</main>
</div>
</div>
<!-- Lightbox for image preview (hidden by default) -->
<div id="lightbox" class="lightbox" style="display: none;">
<span class="lightbox-close">×</span>
<span class="lightbox-prev">❮</span>
<span class="lightbox-next">❯</span>
<div class="lightbox-content">
<img src="" alt="" id="lightbox-img">
<div class="lightbox-caption"></div>
</div>
</div>
<script src="js/gallery.js"></script>
<script src="js/lightbox.js"></script>
<?php include 'includes/footer.php'; ?>
9. css/gallery.css
/* Gallery System Styles */
.gallery-container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem 1rem;
}
/* Gallery Header */
.gallery-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
flex-wrap: wrap;
gap: 1rem;
}
.gallery-header h1 {
font-size: 2.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin: 0;
}
.gallery-actions {
display: flex;
gap: 1rem;
align-items: center;
}
.btn-upload {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.8rem 1.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-decoration: none;
border-radius: 5px;
font-weight: 500;
transition: transform 0.3s, box-shadow 0.3s;
}
.btn-upload:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(102,126,234,0.4);
}
/* Search Box */
.search-box {
flex: 1;
max-width: 300px;
}
.search-form {
display: flex;
border: 1px solid #ddd;
border-radius: 5px;
overflow: hidden;
}
.search-form input {
flex: 1;
padding: 0.8rem;
border: none;
outline: none;
font-size: 1rem;
}
.search-form button {
padding: 0 1rem;
background: #f8f9fa;
border: none;
cursor: pointer;
font-size: 1.2rem;
}
.search-form button:hover {
background: #e9ecef;
}
/* Gallery Layout */
.gallery-layout {
display: grid;
grid-template-columns: 250px 1fr;
gap: 2rem;
}
/* Sidebar */
.gallery-sidebar {
background: white;
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
padding: 1.5rem;
height: fit-content;
position: sticky;
top: 20px;
}
.sidebar-section {
margin-bottom: 2rem;
}
.sidebar-section h3 {
color: #333;
font-size: 1.1rem;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #f0f0f0;
}
/* Album List */
.album-list {
list-style: none;
padding: 0;
margin: 0;
}
.album-list li {
margin-bottom: 0.5rem;
}
.album-list a {
display: block;
padding: 0.5rem;
color: #666;
text-decoration: none;
border-radius: 3px;
transition: all 0.3s;
}
.album-list a:hover {
background: #f8f9fa;
color: #667eea;
padding-left: 1rem;
}
.album-list li.active a {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
/* Tag Cloud */
.tag-cloud {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag {
display: inline-block;
padding: 0.3rem 0.8rem;
background: #f0f0f0;
color: #666;
text-decoration: none;
border-radius: 20px;
transition: all 0.3s;
}
.tag:hover {
background: #667eea;
color: white;
transform: translateY(-2px);
}
.tag-count {
font-size: 0.8rem;
opacity: 0.7;
}
/* Sort Select */
.sort-select {
width: 100%;
padding: 0.8rem;
border: 1px solid #ddd;
border-radius: 5px;
background: white;
cursor: pointer;
font-size: 0.95rem;
}
.sort-select:focus {
outline: none;
border-color: #667eea;
}
/* Image Grid */
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1.5rem;
}
.image-card {
background: white;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
transition: transform 0.3s, box-shadow 0.3s;
position: relative;
}
.image-card:hover {
transform: translateY(-5px);
box-shadow: 0 6px 20px rgba(0,0,0,0.15);
}
.image-link {
display: block;
position: relative;
aspect-ratio: 1 / 1;
overflow: hidden;
}
.gallery-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.image-card:hover .gallery-image {
transform: scale(1.05);
}
.featured-badge {
position: absolute;
top: 10px;
right: 10px;
background: rgba(255,215,0,0.9);
padding: 0.3rem 0.5rem;
border-radius: 20px;
font-size: 0.9rem;
z-index: 2;
}
.image-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0,0,0,0.8), transparent);
color: white;
padding: 1rem;
transform: translateY(100%);
transition: transform 0.3s;
}
.image-card:hover .image-overlay {
transform: translateY(0);
}
.image-overlay h4 {
margin: 0 0 0.5rem;
font-size: 1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.image-stats {
display: flex;
gap: 1rem;
font-size: 0.9rem;
opacity: 0.9;
}
.image-tags {
padding: 0.8rem;
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.image-tags .tag {
font-size: 0.8rem;
background: #f0f0f0;
color: #666;
text-decoration: none;
padding: 0.2rem 0.5rem;
border-radius: 3px;
}
/* No Images */
.no-images {
text-align: center;
padding: 4rem;
background: white;
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
}
.no-images p {
color: #888;
font-size: 1.2rem;
margin-bottom: 2rem;
}
.btn-primary {
display: inline-block;
padding: 0.8rem 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-decoration: none;
border-radius: 5px;
font-weight: 500;
transition: opacity 0.3s;
}
.btn-primary:hover {
opacity: 0.9;
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
margin-top: 3rem;
flex-wrap: wrap;
}
.page-link, .page-current, .page-dots {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 40px;
height: 40px;
padding: 0 0.5rem;
border-radius: 5px;
text-decoration: none;
font-size: 0.95rem;
}
.page-link {
background: white;
color: #667eea;
border: 1px solid #e9ecef;
transition: all 0.3s;
}
.page-link:hover {
background: #667eea;
color: white;
border-color: #667eea;
}
.page-current {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
}
.page-dots {
color: #999;
}
/* Upload Page Styles */
.upload-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1rem;
}
.upload-header {
text-align: center;
margin-bottom: 2rem;
}
.upload-header h1 {
font-size: 2.5rem;
color: #333;
margin-bottom: 1rem;
}
.upload-header p {
color: #666;
font-size: 1.1rem;
}
.upload-grid {
display: grid;
grid-template-columns: 1fr 350px;
gap: 2rem;
margin-bottom: 2rem;
}
/* Upload Card */
.upload-card {
background: white;
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
padding: 2rem;
}
.upload-area {
border: 2px dashed #ddd;
border-radius: 10px;
padding: 3rem;
text-align: center;
cursor: pointer;
transition: all 0.3s;
margin-bottom: 2rem;
}
.upload-area:hover {
border-color: #667eea;
background: #f8f9fa;
}
.upload-area.dragover {
border-color: #667eea;
background: #e8f0fe;
}
.upload-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.upload-area h3 {
margin-bottom: 0.5rem;
color: #333;
}
.upload-area p {
color: #666;
margin-bottom: 1rem;
}
.btn-select {
padding: 0.8rem 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 5px;
font-size: 1rem;
cursor: pointer;
transition: opacity 0.3s;
}
.btn-select:hover {
opacity: 0.9;
}
.upload-limit {
margin-top: 1rem;
color: #888;
font-size: 0.9rem;
}
/* Upload Options */
.upload-options {
border-top: 1px solid #eee;
padding-top: 2rem;
}
.upload-options h4 {
color: #333;
margin-bottom: 1.5rem;
}
/* Queue Card */
.queue-card {
background: white;
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
padding: 1.5rem;
}
.queue-card h3 {
color: #333;
margin-bottom: 1.5rem;
}
.upload-queue {
max-height: 400px;
overflow-y: auto;
margin-bottom: 1.5rem;
}
.empty-queue {
text-align: center;
padding: 2rem;
color: #888;
font-style: italic;
}
.queue-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.8rem;
border: 1px solid #eee;
border-radius: 5px;
margin-bottom: 0.5rem;
animation: slideIn 0.3s;
}
.file-info {
flex: 1;
}
.file-name {
display: block;
font-weight: 500;
color: #333;
margin-bottom: 0.2rem;
}
.file-size {
font-size: 0.8rem;
color: #888;
}
.file-status {
width: 150px;
position: relative;
}
.status-text {
display: block;
font-size: 0.85rem;
margin-bottom: 0.2rem;
text-align: right;
}
.progress-bar {
height: 4px;
background: #eee;
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
transition: width 0.3s;
}
.remove-file {
position: absolute;
top: 50%;
right: -25px;
transform: translateY(-50%);
background: none;
border: none;
color: #999;
cursor: pointer;
font-size: 1.2rem;
transition: color 0.3s;
}
.remove-file:hover {
color: #dc3545;
}
.queue-actions {
display: flex;
gap: 1rem;
}
.btn-upload, .btn-clear {
flex: 1;
padding: 0.8rem;
border: none;
border-radius: 5px;
font-size: 1rem;
cursor: pointer;
transition: opacity 0.3s;
}
.btn-upload {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-upload:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-clear {
background: #6c757d;
color: white;
}
.btn-clear:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-upload:hover:not(:disabled),
.btn-clear:hover:not(:disabled) {
opacity: 0.9;
}
/* Recent Uploads */
.recent-uploads {
margin-top: 3rem;
}
.recent-uploads h2 {
color: #333;
margin-bottom: 1.5rem;
}
/* Alerts */
.alert {
padding: 1rem;
border-radius: 5px;
margin-bottom: 1rem;
animation: slideIn 0.3s;
}
.alert.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.alert.warning {
background: #fff3cd;
color: #856404;
border: 1px solid #ffeeba;
}
/* Responsive Design */
@media (max-width: 1024px) {
.gallery-layout {
grid-template-columns: 200px 1fr;
}
}
@media (max-width: 768px) {
.gallery-header {
flex-direction: column;
align-items: stretch;
}
.gallery-actions {
flex-direction: column;
}
.search-box {
max-width: none;
}
.gallery-layout {
grid-template-columns: 1fr;
}
.gallery-sidebar {
position: static;
margin-bottom: 2rem;
}
.upload-grid {
grid-template-columns: 1fr;
}
.image-grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
}
@media (max-width: 480px) {
.image-grid {
grid-template-columns: 1fr;
}
.queue-actions {
flex-direction: column;
}
}
10. admin/gallery-manager.php
<?php
require_once '../includes/config.php';
require_once '../includes/gallery-functions.php';
requireAdmin();
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
$perPage = 30;
$status = $_GET['status'] ?? 'all';
// Build filters
$filters = [];
if ($status !== 'all') {
$filters['status'] = $status;
}
$images = getImages($filters, $page, $perPage);
// Get statistics
$pdo = getPDOConnection();
$stats = [
'total' => $pdo->query("SELECT COUNT(*) FROM images")->fetchColumn(),
'pending' => $pdo->query("SELECT COUNT(*) FROM images WHERE status = 'pending'")->fetchColumn(),
'active' => $pdo->query("SELECT COUNT(*) FROM images WHERE status = 'active'")->fetchColumn(),
'trashed' => $pdo->query("SELECT COUNT(*) FROM images WHERE status = 'trashed'")->fetchColumn(),
'total_size' => $pdo->query("SELECT SUM(file_size) FROM images")->fetchColumn(),
'total_views' => $pdo->query("SELECT SUM(views) FROM images")->fetchColumn()
];
$page_title = 'Gallery Manager - Admin';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?php echo $page_title; ?></title>
<link rel="stylesheet" href="../css/style.css">
<link rel="stylesheet" href="../css/gallery.css">
<style>
.admin-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
text-align: center;
}
.stat-card h3 {
color: #666;
font-size: 0.9rem;
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 1px;
}
.stat-number {
font-size: 2rem;
font-weight: bold;
color: #333;
}
.stat-number small {
font-size: 1rem;
color: #888;
}
.filter-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
background: white;
padding: 1rem;
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
}
.filter-tab {
padding: 0.5rem 1rem;
background: #f8f9fa;
color: #666;
text-decoration: none;
border-radius: 5px;
transition: all 0.3s;
}
.filter-tab:hover {
background: #e9ecef;
}
.filter-tab.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.admin-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
.admin-image-card {
background: white;
border-radius: 5px;
overflow: hidden;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
position: relative;
}
.admin-image-card img {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
}
.image-status {
position: absolute;
top: 5px;
right: 5px;
padding: 0.2rem 0.5rem;
border-radius: 3px;
font-size: 0.8rem;
font-weight: 500;
}
.status-pending { background: #f39c12; color: white; }
.status-active { background: #27ae60; color: white; }
.status-trashed { background: #95a5a6; color: white; }
.image-actions {
padding: 0.5rem;
display: flex;
gap: 0.3rem;
justify-content: center;
background: #f8f9fa;
}
.image-actions button,
.image-actions a {
padding: 0.3rem 0.5rem;
border: none;
border-radius: 3px;
font-size: 0.8rem;
cursor: pointer;
text-decoration: none;
color: white;
}
.btn-approve { background: #27ae60; }
.btn-reject { background: #e74c3c; }
.btn-delete { background: #dc3545; }
.btn-restore { background: #3498db; }
.btn-view { background: #95a5a6; }
.batch-actions {
margin-top: 2rem;
padding: 1rem;
background: white;
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
display: flex;
gap: 1rem;
align-items: center;
}
.batch-actions select,
.batch-actions button {
padding: 0.5rem 1rem;
border: 1px solid #ddd;
border-radius: 3px;
}
.batch-actions button {
background: #667eea;
color: white;
border: none;
cursor: pointer;
}
.batch-actions button:hover {
opacity: 0.9;
}
</style>
</head>
<body>
<header>
<nav class="navbar">
<div class="container">
<h1 class="logo">Gallery Manager</h1>
<ul class="nav-links">
<li><a href="index.php">Dashboard</a></li>
<li><a href="../gallery.php">View Gallery</a></li>
<li><a href="logout.php">Logout</a></li>
</ul>
</div>
</nav>
</header>
<main class="container">
<!-- Statistics -->
<div class="admin-stats">
<div class="stat-card">
<h3>Total Images</h3>
<div class="stat-number"><?php echo number_format($stats['total']); ?></div>
</div>
<div class="stat-card">
<h3>Pending Approval</h3>
<div class="stat-number"><?php echo number_format($stats['pending']); ?></div>
</div>
<div class="stat-card">
<h3>Total Storage</h3>
<div class="stat-number"><?php echo formatBytes($stats['total_size']); ?></div>
</div>
<div class="stat-card">
<h3>Total Views</h3>
<div class="stat-number"><?php echo number_format($stats['total_views']); ?></div>
</div>
</div>
<!-- Filter Tabs -->
<div class="filter-tabs">
<a href="?status=all" class="filter-tab <?php echo $status == 'all' ? 'active' : ''; ?>">
All (<?php echo $stats['total']; ?>)
</a>
<a href="?status=pending" class="filter-tab <?php echo $status == 'pending' ? 'active' : ''; ?>">
Pending (<?php echo $stats['pending']; ?>)
</a>
<a href="?status=active" class="filter-tab <?php echo $status == 'active' ? 'active' : ''; ?>">
Active (<?php echo $stats['active']; ?>)
</a>
<a href="?status=trashed" class="filter-tab <?php echo $status == 'trashed' ? 'active' : ''; ?>">
Trashed (<?php echo $stats['trashed']; ?>)
</a>
</div>
<!-- Batch Actions -->
<div class="batch-actions">
<select id="batchAction">
<option value="">Select Action</option>
<option value="approve">Approve Selected</option>
<option value="reject">Reject Selected</option>
<option value="delete">Delete Selected</option>
<option value="feature">Mark as Featured</option>
</select>
<button onclick="batchAction()">Apply to Selected</button>
<label style="margin-left: auto;">
<input type="checkbox" id="selectAll" onclick="toggleSelectAll()">
Select All
</label>
</div>
<!-- Image Grid -->
<div class="admin-grid">
<?php foreach ($images['images'] as $image): ?>
<div class="admin-image-card" data-id="<?php echo $image['id']; ?>">
<img src="<?php echo htmlspecialchars($image['thumbnail_path']); ?>"
alt="<?php echo htmlspecialchars($image['title']); ?>">
<span class="image-status status-<?php echo $image['status']; ?>">
<?php echo ucfirst($image['status']); ?>
</span>
<div class="image-actions">
<input type="checkbox" class="image-select" value="<?php echo $image['id']; ?>">
<?php if ($image['status'] == 'pending'): ?>
<button class="btn-approve" onclick="updateStatus(<?php echo $image['id']; ?>, 'approve')">
β
</button>
<button class="btn-reject" onclick="updateStatus(<?php echo $image['id']; ?>, 'reject')">
β
</button>
<?php elseif ($image['status'] == 'trashed'): ?>
<button class="btn-restore" onclick="updateStatus(<?php echo $image['id']; ?>, 'restore')">
βΊ
</button>
<?php endif; ?>
<?php if ($image['status'] != 'trashed'): ?>
<button class="btn-delete" onclick="deleteImage(<?php echo $image['id']; ?>)">
ποΈ
</button>
<?php endif; ?>
<?php if ($image['status'] == 'active'): ?>
<button class="btn-feature" onclick="toggleFeature(<?php echo $image['id']; ?>)">
<?php echo $image['is_featured'] ? 'β
' : 'β'; ?>
</button>
<?php endif; ?>
<a href="../image.php?id=<?php echo $image['id']; ?>" class="btn-view" target="_blank">
ποΈ
</a>
</div>
<div style="padding: 0.5rem; font-size: 0.8rem; color: #666;">
Views: <?php echo number_format($image['views']); ?>
</div>
</div>
<?php endforeach; ?>
</div>
<!-- Pagination -->
<?php if ($images['totalPages'] > 1): ?>
<div class="pagination">
<?php if ($page > 1): ?>
<a href="?page=<?php echo $page - 1; ?>&status=<?php echo $status; ?>" class="page-link">
«
</a>
<?php endif; ?>
<?php for ($i = 1; $i <= $images['totalPages']; $i++): ?>
<a href="?page=<?php echo $i; ?>&status=<?php echo $status; ?>"
class="page-link <?php echo $i == $page ? 'page-current' : ''; ?>">
<?php echo $i; ?>
</a>
<?php endfor; ?>
<?php if ($page < $images['totalPages']): ?>
<a href="?page=<?php echo $page + 1; ?>&status=<?php echo $status; ?>" class="page-link">
»
</a>
<?php endif; ?>
</div>
<?php endif; ?>
</main>
<script>
function updateStatus(imageId, action) {
if (!confirm('Are you sure?')) return;
fetch('update-image-status.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `id=${imageId}&action=${action}`
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Error: ' + data.error);
}
});
}
function deleteImage(imageId) {
if (!confirm('Permanently delete this image?')) return;
fetch('delete-image.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `id=${imageId}`
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Error: ' + data.error);
}
});
}
function toggleFeature(imageId) {
fetch('toggle-feature.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `id=${imageId}`
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Error: ' + data.error);
}
});
}
function toggleSelectAll() {
const checkboxes = document.querySelectorAll('.image-select');
const selectAll = document.getElementById('selectAll');
checkboxes.forEach(cb => cb.checked = selectAll.checked);
}
function batchAction() {
const action = document.getElementById('batchAction').value;
if (!action) {
alert('Please select an action');
return;
}
const selected = [];
document.querySelectorAll('.image-select:checked').forEach(cb => {
selected.push(cb.value);
});
if (selected.length === 0) {
alert('Please select images');
return;
}
if (!confirm(`Apply ${action} to ${selected.length} images?`)) return;
fetch('batch-image-action.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: action,
ids: selected
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Error: ' + data.error);
}
});
}
</script>
</body>
</html>
π How to Use This Project Step by Step
Step 1: Environment Setup
- Ensure PHP 7.4+ with GD/Imagick extensions
- Install Composer
- Enable required PHP extensions:
gd,exif,fileinfo
Step 2: Install Dependencies
composer install
Step 3: Database Setup
- Create database:
gallery_db - Import
database/gallery.sql - Update
.envwith database credentials
Step 4: Directory Permissions
chmod -R 777 uploads/ chmod -R 777 uploads/images/ chmod -R 777 uploads/thumbnails/ chmod -R 777 uploads/medium/ chmod -R 777 uploads/large/ chmod -R 777 uploads/temp/
Step 5: Configure .env
Update all settings in .env file:
- Upload limits
- Image sizes
- Watermark settings
- CDN configuration (optional)
Step 6: Test Upload
- Navigate to
/upload.php - Try uploading different image types
- Check generated thumbnails
Step 7: Configure Cron Job (Optional)
For background processing, add to crontab:
* * * * * php /path/to/blog-website/includes/process-queue.php
π Security Features
Upload Security:
- β File type validation (MIME & extension)
- β File size limits
- β Virus scanning (optional)
- β Image re-encoding to remove malware
- β EXIF data stripping (optional)
Access Control:
- β Album privacy settings
- β Password protection
- β User-based permissions
- β Admin moderation queue
Rate Limiting:
- β Max uploads per hour
- β Max storage per user
- β IP-based restrictions
π Performance Optimizations
- Image Optimization:
- Automatic compression
- WebP generation
- Lazy loading
- CDN integration
- Caching:
- Redis/Memcached for thumbnails
- Browser caching headers
- Database query caching
- Queue Processing:
- Background image processing
- Watermark application
- Format conversion
π Future Enhancements
- AI Features:
- Automatic tagging
- Face recognition
- Similar image search
- Content moderation
- Video Support:
- Video upload and encoding
- Thumbnail generation
- Streaming support
- Social Features:
- Image sharing
- User galleries
- Comments and likes
- Collections
- Advanced Editing:
- Online image editor
- Filters and effects
- Crop and rotate
- Color adjustment
π Conclusion
This Image Upload & Gallery System provides a complete solution for managing images on your blog website. With features like drag-and-drop upload, automatic optimization, album organization, and comprehensive admin controls, it's perfect for photographers, bloggers, and content creators. The system is built with security and performance in mind, ensuring your images are safe and load quickly for visitors.