Загрузка изображений с превью AJAX + PHP + MySQL

Загрузка изображений с превью AJAX + PHP + MySQL

В данной статье представлена упрощенная реализация загрузки изображений с превью через AJAX с сохранением в базу данных MySQL, а также дальнейший их вывод на примере модуля отзывов.

В примерах используется следующая структура файлов и директорий, находящихся в корне сайта:

index.php
upload_image.php
save_reviews.php
reviews.php
jquery.min.js
uploads/
├── tmp/

Первое что понадобится: HTML форма и JS скрипт, который после выбора одного или несколькольких файлов отправит их на upload_image.php через AJAX.

index.php

<form method="post" action="/save_reviews.php">
	<h3>Отправить отзыв:</h3>
	<div class="form-row">
		<label>Ваше имя:</label>
		<input type="text" name="name" required>
	</div>
	<div class="form-row">
		<label>Комментарий:</label>
		<input type="text" name="text" required>
	</div>
	<div class="form-row">
		<label>Изображения:</label>
		<div class="img-list" id="js-file-list"></div>
		<input id="js-file" type="file" name="file[]" multiple accept=".jpg,.jpeg,.png,.gif">
	</div>
	<div class="form-submit">
		<input type="submit" name="send" value="Отправить">
	</div>
</form>

<script src="/jquery.min.js"></script>
<script>
$("#js-file").change(function(){
	if (window.FormData === undefined) {
		alert('В вашем браузере загрузка файлов не поддерживается');
	} else {
		var formData = new FormData();
		$.each($("#js-file")[0].files, function(key, input){
			formData.append('file[]', input);
		});
 
		$.ajax({
			type: 'POST',
			url: '/upload_image.php',
			cache: false,
			contentType: false,
			processData: false,
			data: formData,
			dataType : 'json',
			success: function(msg){
				msg.forEach(function(row) {
					if (row.error == '') {
						$('#js-file-list').append(row.data);
					} else {
						alert(row.error);
					}
				});
				$("#js-file").val(''); 
			}
		});
	}
});

/* Удаление загруженной картинки */
function remove_img(target){
	$(target).parent().remove();
}
</script>
HTML

CSS-стили для формы и вывода загруженных файлов:

.form-row {
	margin-bottom: 15px;
}
.form-row label {
	display: block;
	color: #777;
	margin-bottom: 5px;
}
.form-row input[type="text"] {
	width: 100%;
	padding: 5px;
	box-sizing: border-box;
}

/* Стили для вывода превью */
.img-item {
	display: inline-block;
	margin: 0 20px 20px 0;
	position: relative;
	user-select: none;
}
.img-item img {
	border: 1px solid #767676;
}
.img-item a {
	display: inline-block;
	background: url(/remove.png) 0 0 no-repeat;
	position: absolute;
	top: -5px;
	right: -9px;
	width: 20px;
	height: 20px;
	cursor: pointer;
}
CSS

Файлы отправляются на сервер до отправки основной формы (возможно пользователь вовсе её не отправит), поэтому PHP-скрипт сохранит полученные файлы во временную директорию /uploads/tmp/ с новым именем и создаст картинку-превью с префиксом «thumb» в конце названия файла, далее вернёт контент для вставки обратно в форму в формате JSON.

В целях безопасности, во временной директории /uploads/ должно быть отключено выполнение PHP-скриптов и выключен листинг каталогов.

Тек же временную директорию /uploads/tmp/ будет нужно периодически очищать от старых файлов.

Скрипт upload_image.php

<?php
// Локаль.
setlocale(LC_ALL, 'ru_RU.utf8');
date_default_timezone_set('Europe/Moscow');
mb_internal_encoding('UTF-8');
mb_regex_encoding('UTF-8');
mb_http_output('UTF-8');
mb_language('uni');

//ini_set('display_errors', 1); 

// Название <input type="file">
$input_name = 'file';

if (!isset($_FILES[$input_name])) {
	exit;
}

// Разрешенные расширения файлов.
$allow = array('jpg', 'jpeg', 'png', 'gif');

// URL до временной директории.
$url_path = '/uploads/tmp/';

// Полный путь до временной директории.
$tmp_path = $_SERVER['DOCUMENT_ROOT'] . $url_path;

if (!is_dir($tmp_path)) {
	mkdir($tmp_path, 0777, true);
}

// Преобразуем массив $_FILES в удобный вид для перебора в foreach.
$files = array();
$diff = count($_FILES[$input_name]) - count($_FILES[$input_name], COUNT_RECURSIVE);
if ($diff == 0) {
	$files = array($_FILES[$input_name]);
} else {
	foreach($_FILES[$input_name] as $k => $l) {
		foreach($l as $i => $v) {
			$files[$i][$k] = $v;
		}
	}		
}	
 
$response = array(); 
foreach ($files as $file) {
	$error = $data  = '';
 
	// Проверим на ошибки загрузки.
	$ext = mb_strtolower(mb_substr(mb_strrchr(@$file['name'], '.'), 1));
	if (!empty($file['error']) || empty($file['tmp_name']) || $file['tmp_name'] == 'none') {
		$error = 'Не удалось загрузить файл.';
	} elseif (empty($file['name']) || !is_uploaded_file($file['tmp_name'])) {
		$error = 'Не удалось загрузить файл.';
	} elseif (empty($ext) || !in_array($ext, $allow)) {
		$error = 'Недопустимый тип файла';
	} else {
		$info = @getimagesize($file['tmp_name']);
		if (empty($info[0]) || empty($info[1]) || !in_array($info[2], array(1, 2, 3))) {
			$error = 'Недопустимый тип файла';
		} else {
			// Перемещаем файл в директорию с новым именем.
			$name  = time() . '-' . mt_rand(1, 9999999999);
			$src   = $tmp_path . $name . '.' . $ext;
			$thumb = $tmp_path . $name . '-thumb.' . $ext;
			
			if (move_uploaded_file($file['tmp_name'], $src)) {
				// Создание миниатюры.
				switch ($info[2]) { 
					case 1: 
						$im = imageCreateFromGif($src);
						imageSaveAlpha($im, true);
						break;					
					case 2: 
						$im = imageCreateFromJpeg($src);
						break;
					case 3: 
						$im = imageCreateFromPng($src); 
						imageSaveAlpha($im, true);
						break;
				}

				$width  = $info[0];
				$height = $info[1];

				// Высота превью 100px, ширина рассчитывается автоматически.
				$h = 100; 
				$w = ($h > $height) ? $width : ceil($h / ($height / $width));
				$tw = ceil($h / ($height / $width));
				$th = ceil($w / ($width / $height)); 				

				$new_im = imageCreateTrueColor($w, $h);
				if ($info[2] == 1 || $info[2] == 3) {
					imagealphablending($new_im, true); 
					imageSaveAlpha($new_im, true);
					$transparent = imagecolorallocatealpha($new_im, 0, 0, 0, 127); 
					imagefill($new_im, 0, 0, $transparent); 
					imagecolortransparent($new_im, $transparent);    
				}   

				if ($w >= $width && $h >= $height) {
					$xy = array(ceil(($w - $width) / 2), ceil(($h - $height) / 2), $width, $height);
				} elseif ($w >= $width) {
					$xy = array(ceil(($w - $tw) / 2), 0, ceil($h / ($height / $width)), $h);
				} elseif ($h >= $height) {
					$xy = array(0, ceil(($h - $th) / 2), $w, ceil($w / ($width / $height)));
				} elseif ($tw < $w) {
					$xy = array(ceil(($w - $tw) / 2), ceil(($h - $h) / 2), $tw, $h);		
				} else {
					$xy = array(0, ceil(($h - $th) / 2), $w, $th);	
				} 					

				imageCopyResampled($new_im, $im, $xy[0], $xy[1], 0, 0, $xy[2], $xy[3], $width, $height);        
 
				// Сохранение.
				switch ($info[2]) {
					case 1: imageGif($new_im, $thumb); break;			
					case 2: imageJpeg($new_im, $thumb, 100); break;			
					case 3: imagePng($new_im, $thumb); break;
				}
 
				imagedestroy($im);
				imagedestroy($new_im);
					
				// Вывод в форму: превью, кнопка для удаления и скрытое поле.
				$data = '
				<div class="img-item">
					<img src="' . $url_path . $name . '-thumb.' . $ext . '">
					<a herf="#" onclick="remove_img(this); return false;"></a>
					<input type="hidden" name="images[]" value="' . $name . '.' . $ext . '">
				</div>';
			} else {
				$error = 'Не удалось загрузить файл.';
			}
		}
	}
		
	$response[] = array('error' => $error, 'data'  => $data);
}

// Ответ в JSON.
header('Content-Type: application/json');
echo json_encode($response, JSON_UNESCAPED_UNICODE);
exit();
PHP

Пример ответа AJAX запроса в случаи успешной загрузки файла:

[{
	"error": "",
	"data": "
    	<div class="img-item">
			<img src="/uploads/tmp/1610809179-108359805-thumb.jpg">
			<a herf="#" onclick="remove_img(this); return false;"></a>
			<input type="hidden" name="images[]" value="1610809179-108359805.jpg">
		</div>"
}]

Полученный из AJAX запроса контент вставляется в конец дива id="js-file-list" с помощью jQuery метода append().

Скрытое поле «images» передает названия загруженных файлов следующему скрипту для сохранения в базе данных.

Промежуточный результат (только загрузка изображений):

После нажатия на кнопку «Отправить», форма отправляется методом POST на обработчик save_reviews.php. В нём полученные данные сохраняются в базе данных, а файлы переносятся в постоянную директорию хранения.

Понадобятся две таблицы, `reviews`:

CREATE TABLE `reviews` (
  `id` int(11) UNSIGNED NOT NULL,
  `name` varchar(255) NOT NULL,
  `text` text NOT NULL,
  `date_add` int(11) UNSIGNED NOT NULL DEFAULT '0'
) ENGINE=MyISAM DEFAULT CHARSET=utf8;

ALTER TABLE `reviews` ADD PRIMARY KEY (`id`);
ALTER TABLE `reviews` MODIFY `id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT;
SQL

И таблица `reviews_images` для хранения названий файлов:

CREATE TABLE `reviews_images` (
  `id` int(11) UNSIGNED NOT NULL,
  `reviews_id` int(11) NOT NULL DEFAULT '0',
  `filename` varchar(255) NOT NULL
) ENGINE=MyISAM DEFAULT CHARSET=utf8;

ALTER TABLE `reviews_images` ADD PRIMARY KEY (`id`);
ALTER TABLE `reviews_images` MODIFY `id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT;
SQL

Скрипт save_reviews.php

<?php
//ini_set('display_errors', 1); 

// Временная директория.
$tmp_path = $_SERVER['DOCUMENT_ROOT'] . '/uploads/tmp/';

// Постоянная директория.
$path = $_SERVER['DOCUMENT_ROOT'] . '/uploads/';

// Подключение к БД.
$dbh = new PDO('mysql:dbname=db_name;host=localhost', 'логин', 'пароль');

if (isset($_POST['send'])) {
	$name = htmlspecialchars($_POST['name'], ENT_QUOTES);	
	$text = htmlspecialchars($_POST['text'], ENT_QUOTES);
	
	$sth = $dbh->prepare("INSERT INTO `reviews` SET `name` = ?, `text` = ?, `date_add` = UNIX_TIMESTAMP()");
	$sth->execute(array($name, $text));
 
	// Получаем id вставленной записи.
	$insert_id = $dbh->lastInsertId();
	
	// Сохранение изображений в БД и перенос в постоянную папку.
	if (!empty($_POST['images'])) {
		foreach ($_POST['images'] as $row) {
			$filename = preg_replace("/[^a-z0-9\.-]/i", '', $row);
			if (!empty($filename) && is_file($tmp_path . $filename)) {
				$sth = $dbh->prepare("INSERT INTO `reviews_images` SET `reviews_id` = ?, `filename` = ?");
				$sth->execute(array($insert_id, $filename));
				
				// Перенос оригинального файла
				rename($tmp_path . $filename, $path . $filename);
				
				// Перенос превью
				$file_name = pathinfo($filename, PATHINFO_FILENAME);				
				$file_ext = pathinfo($filename, PATHINFO_EXTENSION);
				$thumb = $file_name . '-thumb.' . $file_ext;
				rename($tmp_path . $thumb, $path . $thumb);
			}
		}
	}
}

// Редирект, чтобы предотвратить повторную отправку по F5.
header('Location: /reviews.php', true, 301);
exit();
PHP

В результате отправок форм, в базе данных будут следующие записи:

Заполненная таблица `reviews`
Таблица `reviews`
Заполненная таблица `reviews_images`
Таблица `reviews_images`

И последнее, PHP-скрипт для вывода отзывов с фотографиями из базы данных.

Скрипт reviews.php

<?php
// Подключение к БД.
$dbh = new PDO('mysql:dbname=db_name;host=localhost', 'логин', 'пароль');

$sth = $dbh->prepare("SELECT * FROM `reviews` ORDER BY `date_add` DESC");
$sth->execute();
$items = $sth->fetchAll(PDO::FETCH_ASSOC);

if (!empty($items)) {
	?>
	<h2>Отзывы</h2>
	<div class="reviews">
		<?php
		foreach ($items as $row) {
			$sth = $dbh->prepare("SELECT * FROM `reviews_images` WHERE `reviews_id` = ?");
			$sth->execute(array($row['id']));
			$images = $sth->fetchAll(PDO::FETCH_ASSOC);
			?>
			<div class="reviews_item">
				<div class="reviews_item-name"><?php echo $row['name']; ?></div>
				<div class="reviews_item-text"><?php echo $row['text']; ?></div>
				<?php if (!empty($images)): ?>
				<div class="reviews_item-images">
					<?php foreach($images as $img): ?>
					<div class="reviews_item-img">
						<?php 
						$name = pathinfo($img['filename'], PATHINFO_FILENAME);
						$ext = pathinfo($img['filename'], PATHINFO_EXTENSION);
						?>
						<a href="/uploads/<?php echo $img['filename']; ?>" target="_blank">
							<img src="/uploads/<?php echo $name . '-thumb.' . $ext; ?>">
						</a>
					</div>
					<?php endforeach; ?>
				</div>
				<?php endif; ?>
			</div>
			<?php
		}
		?>
	</div>
	<?php
}
?>
HTML

CSS-стили списка:

.reviews_item {
	background: #efefef;
	padding: 15px 30px 0px 30px;
	margin-bottom: 20px;
}
.reviews_item-name {
	font-weight: 900;
	font-size: 18px;
	margin-bottom: 5px;
}
.reviews_item-text {
	margin-bottom: 15px;
	font-size: 15px;
	line-height: 1.5;
}
.reviews_item-img {
	display: inline-block;
	margin: 0 15px 15px 0;
}
CSS

Полный пример (после отправки формы выведется ваш отзыв):

17.01.2021, обновлено 10.03.2024
31323
Предыдущая запись Figma – советы верстальщику
Следующая запись Алфавитный указатель на PHP

Комментарии 5

Davides1 Davides1
7 марта 2021 в 16:45
Автору большое спасибо. Внедрил в свой проект без проблем. Использую для добавления фотографий при создании товара в интернет-магазине. Доработал немного( при нажатии на крестик, фотография не только исчезает, но и удаляется на сервере. А так же добавил генерацию еще одной превью(большее по размеру)). Единственный ньюанс возник при редактировании товара. Я подгружаю фотографии которые уже есть в наличии и так же удаляю. Но при редактировании товара они(фотографии) находятся не во временной папке(tmp), а уже в основной.

И вопрос состоит в том, как определить в какой директории находится файл(временная или постоянная) для того что бы правильно удалить. Сейчас делаю так: удаляю сразу и во временной и в постоянной папке(даже если в одной из папок файлов нет), получается в любом случае выполняется лишнее действие(удаляется файл, которого не существует).
И еще, как можно реализовать индикатор во время загрузки? Хотя бы какой-нибудь крутящийся спиннер, что бы было видно что файлы загружаются. В идеале конечно с процентным индикатором.
Тилло Ташмухамедов Тилло Ташмухамедов
1 мая 2021 в 18:25
Здравствуйте!
Спасибо за скрипт. Все настроено. Но после некоторого времени перестал скрипт отрабатывать. Проявляется это в не загрузке фотографий, картинок. При этом нет ни каких ошибок. Текстовая часть при этом работает и сохраняется. Подскажите пожалуйста что я не увидел. Заранее благодарю за помощь.
Добавлю, что пути все перепроверял.
Разобрался, Все работает. ))) Спасибо.
Aksel Aksel
25 июня 2022 в 19:13
В чем была ошибка?
Андрей Мирный Андрей Мирный
20 ноября 2022 в 00:48
Спасибо большое! Немного доработал под свои нужды, все работает отлично.
Руслан Сидоренко Руслан Сидоренко
4 марта 2024 в 12:37
Благодарю очень крутой сайт. С такими програмистами нужно дружить))

, чтобы добавить комментарий.

Другие публикации

Массив $_FILES
В PHP-скрипте обработка загруженных через форму происходит через глобальный массив $_FILES, рассмотрим его содержимое...
29227
+8
Стилизация input file
Примеры изменения вида стандартного поля для загрузки файлов input type file с помощью CSS и JS.
49185
+7
Фильтр файлов по расширению у input file
Атрибут accept в устанавливает фильтр на типы файлов в окне выбора файла.
20219
-2
Примеры использования PDO MySQL
В статье приведены основные примеры работы с расширением PHP PDO. Такие как подключение к БД, получение, изменение и...
104321
+8
Примеры отправки AJAX JQuery
AJAX позволяет отправить и получить данные без перезагрузки страницы. Например, делать проверку форм, подгружать контент и т.д. А функции JQuery значительно упрощают работу.
274378
+36
Работа с JSON в PHP
JSON (JavaScript Object Notation) – текстовый формат обмена данными, основанный на JavaScript, который представляет собой набор пар {ключ: значение}. Значение может быть массивом, числом, строкой и...
114480
+15