Урок 30: Создание приложения «Персональная галерея изображений»

Я рад приветствовать вас на финальном, тридцатом уроке нашего большого курса по React. Если вы дошли до этого момента, то вы просто молодцы. Вы проделали огромный путь от простых console.log до понимания компонентов, состояния, хуков и маршрутизации. Сегодня мы закрепим все эти знания, собрав полноценное, красивое и функциональное приложение «Персональную галерею изображений».

Это будет наш выпускной проект, наша визитная карточка. Мы создадим приложение в котором можно будет загружать новые изображения (прямо с вашего компьютера), давать им названия и описания, просматривать их в удобной сетке, а также открывать каждое изображение на отдельной странице для детального просмотра. В этом проекте мы применим управление состоянием, работу с формами и файлами, маршрутизацию между страницами и, конечно же, стилизацию, чтобы все выглядело современно и приятно. Давайте же без лишних слов погрузимся в архитектуру нашего будущего приложения.

Архитектура и ключевые решения

Перед тем как писать код, всегда полезно сесть и подумать: «Что я вообще хочу сделать?» и «Как это лучше всего организовать?». Это как чертеж перед постройкой дома. Для нашей галереи мы определим несколько ключевых моментов.

Во-первых, какие данные нам нужны для каждого изображения? Я предлагаю хранить для каждой картинки объект со следующими полями: id (уникальный идентификатор), title (название), description (описание) и url (сама картинка, вернее, ее данные в формате Data URL, чтобы хранить все прямо в состоянии приложения). Во-вторых, нам нужно продумать, какие «экраны» или страницы будут в нашем приложении. Я вижу три основных маршрута: главная страница со списком всех изображений, страница добавления новой картинки и страница детального просмотра одного изображения. Это идеально ложится на React Router, который мы изучили ранее.

И наконец, самое главное, это управление состоянием. Где хранить наш массив изображений? Поскольку наше приложение не очень большое и состояние (массив картинок) нужно будет использовать на нескольких страницах (главной и детальной), мы будем хранить его в состоянии родительского компонента App и передавать вниз через пропсы. В более сложных приложениях вы бы использовали Context API или Redux, но для нашей цели пропсов будет достаточно. Мы также используем useState для управления формами и useParams из React Router для получения ID изображения на странице детального просмотра. Итак, план ясен, пришло время воплощать его в код!

Настройка проекта и базовая структура

Давайте начнем с создания нового React-проекта. Если вы забыли, как это делается, откройте терминал и выполните команду npx create-react-app personal-gallery. После того как проект создался, откройте папку в вашем редакторе кода. Первым делом, мы установим React Router, так как он не входит в стандартную поставку Create React App. В терминале, находясь в папке проекта, выполните: npm install react-router-dom.

Теперь зачистим файл App.js и создадим нашу базовую структуру с маршрутизацией. Мы импортируем необходимые компоненты из react-router-dom и определим три маршрута. Пока что мы просто создадим «заглушки» для наших будущих компонентов, чтобы убедиться, что маршрутизация работает.

jsx
// App.js
import React, { useState } from 'react';
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
import './App.css';

// Временно заглушки для компонентов
const Gallery = () => <div>Галерея</div>;
const AddImage = () => <div>Добавить изображение</div>;
const ImageDetail = () => <div>Детали изображения</div>;

function App() {
  // Состояние для хранения массива изображений
  const [images, setImages] = useState([]);

  return (
    <Router>
      <div className="App">
        {/* Навигация */}
        <nav>
          <Link to="/">Галерея</Link> | 
          <Link to="/add">Добавить изображение</Link>
        </nav>

        {/* Определение маршрутов */}
        <Routes>
          <Route path="/" element={<Gallery images={images} />} />
          <Route path="/add" element={<AddImage setImages={setImages} />} />
          <Route path="/image/:id" element={<ImageDetail images={images} />} />
        </Routes>
      </div>
    </Router>
  );
}

export default App;

Обратите внимание, как мы передаем состояние images и функцию для его обновления setImages в компоненты через пропсы. В компонент Gallery мы передаем images для отображения. В AddImage мы передаем setImages, чтобы иметь возможность добавлять новые картинки. А в ImageDetail мы передаем весь массив images, чтобы потом найти нужную картинку по ID из URL. Параметр :id в пути /image/:id это динамический сегмент, который мы будем использовать для идентификации картинки.

Практическая задача 1: Убедитесь, что проект запускается без ошибок (npm start) и вы видите навигацию. При клике на ссылки «Галерея» и «Добавить изображение» содержимое экрана должно меняться на соответствующие заглушки.

Реализация состояния и загрузки изображений

Теперь пришло время заняться самой интересной частью, компонентом AddImage. Именно здесь пользователь будет загружать свои изображения. Нам нужно создать форму с тремя полями: файл (обязательный), название и описание (необязательные). Самая большая сложность, это работа с файлом. Мы не можем просто сохранить файл в состояние, нам нужно его прочитать и преобразовать в строку, чтобы потом можно было легко отобразить как картинку. Для этого мы используем FileReader.

Давайте создадим новый файл AddImage.js и начнем его наполнение. Мы будем использовать useState для управления каждым полем формы.

jsx
// components/AddImage.js
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';

const AddImage = ({ setImages }) => {
  // Состояния для полей формы
  const [title, setTitle] = useState('');
  const [description, setDescription] = useState('');
  const [file, setFile] = useState(null);
  const navigate = useNavigate(); // Хук для навигации

  const handleSubmit = (e) => {
    e.preventDefault();
    if (!file) {
      alert('Пожалуйста, выберите файл');
      return;
    }

    const reader = new FileReader();
    reader.onloadend = () => {
      // Создаем новый объект изображения
      const newImage = {
        id: Date.now(), // Простой способ получить уникальный ID
        title: title || 'Без названия', // Если название пустое, используем заглушку
        description: description,
        url: reader.result // Результат чтения файла - Data URL
      };

      // Добавляем новое изображение в состояние (функциональное обновление)
      setImages(prevImages => [...prevImages, newImage]);

      // Очищаем форму и перенаправляем пользователя в галерею
      setTitle('');
      setDescription('');
      setFile(null);
      navigate('/');
    };
    reader.readAsDataURL(file); // Запускаем чтение файла
  };

  const handleFileChange = (e) => {
    const selectedFile = e.target.files[0];
    if (selectedFile && selectedFile.type.startsWith('image/')) {
      setFile(selectedFile);
    } else {
      alert('Пожалуйста, выберите файл изображения');
      e.target.value = ''; // Сбрасываем значение input
    }
  };

  return (
    <div className="add-image">
      <h2>Добавить новое изображение</h2>
      <form onSubmit={handleSubmit}>
        <div>
          <label>Файл изображения (обязательно):</label>
          <input type="file" accept="image/*" onChange={handleFileChange} />
        </div>
        <div>
          <label>Название:</label>
          <input 
            type="text" 
            value={title} 
            onChange={(e) => setTitle(e.target.value)} 
            placeholder="Введите название" 
          />
        </div>
        <div>
          <label>Описание:</label>
          <textarea 
            value={description} 
            onChange={(e) => setDescription(e.target.value)} 
            placeholder="Введите описание"
          />
        </div>
        <button type="submit">Загрузить в галерею</button>
      </form>
    </div>
  );
};

export default AddImage;

Давайте разберем ключевые моменты. В handleFileChange мы проверяем, что пользователь выбрал именно файл изображения. В handleSubmit мы предотвращаем стандартное поведение формы (перезагрузку страницы) и проверяем, что файл выбран. Далее мы создаем экземпляр FileReader и вешаем на его событие onloadend колбэк. Этот колбэк выполнится, когда чтение файла будет завершено. Внутри него мы формируем новый объект изображения и с помощью функции setImages добавляем его в массив. Обратите внимание на использование функционального обновления prevImages => [...prevImages, newImage],  это лучшая практика, так как мы всегда работаем с актуальным предыдущим состоянием. После успешного добавления мы сбрасываем форму и с помощью хука useNavigate перенаправляем пользователя на главную страницу.

Практическая задача 2: Импортируйте компонент AddImage в App.js и замените им заглушку в маршруте /add. Проверьте, что форма работает: выберите изображение, заполните поля, нажмите кнопку и вас должно перебросить на главную страницу. Пока что мы не видим там картинок, но в следующем разделе мы это исправим!

Создание галереи для отображения изображений

Теперь, когда у нас есть механизм добавления изображений, давайте создадим красивую галерею для их отображения. Компонент Gallery будет получать массив images через пропсы и отображать его в виде сетки. Каждое изображение будет представлять собой карточку с превью, названием и ссылкой на страницу детального просмотра.

Создадим файл Gallery.js. Для отображения сетки мы воспользуемся CSS Grid Layout, это современный и мощный инструмент.

jsx
// components/Gallery.js
import React from 'react';
import { Link } from 'react-router-dom';

const Gallery = ({ images }) => {
  if (images.length === 0) {
    return (
      <div className="gallery-empty">
        <p>Ваша галерея пуста. <Link to="/add">Добавьте</Link> первое изображение!</p>
      </div>
    );
  }

  return (
    <div className="gallery">
      <h2>Моя галерея</h2>
      <div className="gallery-grid">
        {images.map(image => (
          <div key={image.id} className="image-card">
            <Link to={`/image/${image.id}`}>
              <img src={image.url} alt={image.title} />
              <h3>{image.title}</h3>
            </Link>
          </div>
        ))}
      </div>
    </div>
  );
};

export default Gallery;

Код компонента довольно простой. Сначала мы проверяем, пуст ли массив images. Если да, то показываем сообщение с предложением добавить первую картинку. Если нет, то отображаем заголовок и сетку карточек. Для каждой карточки мы используем Link из React Router, который ведет на динамический маршрут /image/:id, подставляя уникальный идентификатор изображения. В качестве превью мы используем сам url (наш Data URL), который отображается в теге img.

Теперь давайте добавим базовые стили для галереи в наш App.css. Сетка будет адаптивной и будет подстраиваться под разную ширину экрана.

css
/* App.css */
.gallery-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  gap: 20px;
  padding: 20px;
}

.image-card {
  border: 1px solid #ddd;
  border-radius: 8px;
  overflow: hidden;
  transition: transform 0.2s ease-in-out;
  background: white;
}

.image-card:hover {
  transform: translateY(-5px);
  box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}

.image-card img {
  width: 100%;
  height: 200px;
  object-fit: cover; /* Это свойство обрежет изображение, чтобы оно заполнило область */
}

.image-card h3 {
  padding: 10px;
  margin: 0;
  font-size: 1.1em;
  color: #333;
}

.gallery-empty {
  text-align: center;
  padding: 50px;
  font-size: 1.2em;
}

nav {
  padding: 20px;
  background-color: #f8f9fa;
  border-bottom: 1px solid #e9ecef;
}

nav a {
  margin: 0 15px;
  text-decoration: none;
  color: #007bff;
  font-weight: bold;
}

nav a:hover {
  text-decoration: underline;
}

Практическая задача 3: Импортируйте компонент Gallery в App.js. Теперь, после добавления изображения через форму, вы должны видеть его карточку в галерее на главной странице. Попробуйте добавить несколько изображений и убедитесь, что сетка адаптируется корректно.

Страница детального просмотра изображения

Последний крупный кусок нашего приложения, это страница, где можно посмотреть одно изображение в большем размере и прочитать его описание. Для этого мы используем компонент ImageDetail и динамический маршрут /image/:id. Наша задача из URL получить параметр id, найти в массиве images изображение с таким ID и отобразить его.

Создадим файл ImageDetail.js. Здесь нам на помощь приходит хук useParams из React Router.

jsx
// components/ImageDetail.js
import React from 'react';
import { useParams, Link } from 'react-router-dom';

const ImageDetail = ({ images }) => {
  const { id } = useParams(); // Получаем ID из URL
  const image = images.find(img => img.id === parseInt(id)); // Ищем изображение по ID

  // Если изображение не найдено (например, неверный ID)
  if (!image) {
    return (
      <div className="image-detail">
        <h2>Изображение не найдено</h2>
        <Link to="/">Вернуться в галерею</Link>
      </div>
    );
  }

  return (
    <div className="image-detail">
      <Link to="/" className="back-link">← Назад к галерее</Link>
      <div className="detail-container">
        <img src={image.url} alt={image.title} />
        <div className="detail-info">
          <h1>{image.title}</h1>
          <p>{image.description || 'Описание отсутствует.'}</p>
        </div>
      </div>
    </div>
  );
};

export default ImageDetail;

В этом компоненте мы используем useParams() чтобы получить объект с параметрами из URL. Поскольку наш параметр называется id, мы деструктурируем его. Важно помнить, что параметр из URL это всегда строка, а ID у нас число (мы использовали Date.now()). Поэтому мы используем parseInt(id) для преобразования строки в число при поиске. Если изображение с таким ID не найдено, мы показываем пользователю сообщение об ошибке. Если найдено, то отображаем его в большом размере и всю сопутствующую информацию.

Добавим стили для страницы детального просмотра, чтобы все выглядело аккуратно.

css
/* В App.css */
.image-detail {
  padding: 20px;
  max-width: 1000px;
  margin: 0 auto;
}

.back-link {
  display: inline-block;
  margin-bottom: 20px;
  color: #007bff;
  text-decoration: none;
  font-weight: bold;
}

.back-link:hover {
  text-decoration: underline;
}

.detail-container {
  display: flex;
  flex-direction: column;
  gap: 20px;
  background: white;
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}

.detail-container img {
  width: 100%;
  max-height: 70vh;
  object-fit: contain; /* Показывает все изображение без обрезки */
  border-radius: 4px;
}

.detail-info h1 {
  margin-top: 0;
  color: #333;
}

.detail-info p {
  line-height: 1.6;
  color: #666;
}

@media (min-width: 768px) {
  .detail-container {
    flex-direction: row;
  }
  .detail-container img {
    width: 60%;
  }
  .detail-info {
    width: 40%;
  }
}

С помощью медиа-запроса @media мы сделали так, что на больших экранах изображение и информация о нем располагаются рядом, а на маленьких (мобильных телефонах) друг под другом. Это простой прием, который значительно улучшает пользовательский опыт.

Практическая задача 4: Импортируйте ImageDetail в App.js. Теперь, кликая на любую карточку в галерее, вы должны попадать на страницу детального просмотра. Убедитесь, что кнопка «Назад» работает корректно.

Финальные штрихи и улучшения

Наше основное приложение готово! Оно полностью функционально. Но хороший разработчик всегда думает о том, как можно улучшить свой продукт. Давайте добавим пару небольших, но важных фич.

1. Удаление изображения. Давайте добавим кнопку удаления на страницу детального просмотра. Для этого нам нужно передать функцию setImages в компонент ImageDetail.

В App.js изменим маршрут для ImageDetail:

jsx
<Route path="/image/:id" element={<ImageDetail images={images} setImages={setImages} />} />

В ImageDetail.js добавим функцию удаления:

jsx
const ImageDetail = ({ images, setImages }) => {
  // ... остальной код ...

  const handleDelete = () => {
    if (window.confirm('Вы уверены, что хотите удалить это изображение?')) {
      setImages(prevImages => prevImages.filter(img => img.id !== parseInt(id)));
      // После удаления можно перенаправить пользователя в галерею
      // Для этого нам понадобится useNavigate, не забудьте его импортировать!
      navigate('/');
    }
  };

  return (
    <div className="image-detail">
      <div className="detail-actions">
        <Link to="/" className="back-link">← Назад к галерее</Link>
        <button onClick={handleDelete} className="delete-btn">Удалить</button>
      </div>
      {/* ... остальной JSX ... */}
    </div>
  );
};

И добавим стили для кнопки и контейнера действий:

css
.detail-actions {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;
}

.delete-btn {
  background-color: #dc3545;
  color: white;
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
  cursor: pointer;
}

.delete-btn:hover {
  background-color: #c82333;
}

2. Сохранение состояния в Local Storage. Сейчас при перезагрузке страницы все наши изображения пропадают. Давайте это исправим, сохраняя массив images в локальное хранилище браузера.

В App.js мы можем использовать хук useEffect для сохранения и загрузки состояния:

jsx
// App.js
import React, { useState, useEffect } from 'react';

function App() {
  const [images, setImages] = useState([]);

  // Загрузка из Local Storage при монтировании компонента
  useEffect(() => {
    const savedImages = JSON.parse(localStorage.getItem('personal-gallery'));
    if (savedImages) {
      setImages(savedImages);
    }
  }, []);

  // Сохранение в Local Storage при каждом изменении images
  useEffect(() => {
    localStorage.setItem('personal-gallery', JSON.stringify(images));
  }, [images]);

  // ... остальной код ...
}

Теперь ваша галерея будет переживать перезагрузку страницы!

Практическая задача 5 (финальная): Реализуйте обе улучшения, удаление и сохранение в Local Storage. Убедитесь, что все работает корректно.

Итоги и ваш следующий шаг

Вы только что создали свое полноценное React-приложение. Оно включает в себя все ключевые аспекты современной фронтенд-разработки, функциональные компоненты с хуками, сложное управление состоянием, маршрутизацию между страницами, обработку пользовательского ввода (включая файлы) и адаптивную стилизацию. Вы можете с гордостью показывать эту галерею в своем портфолио.

Этот проект, отличная отправная точка. Что можно добавить еще? Можете реализовать редактирование названия и описания прямо на странице детального просмотра, добавить теги к изображениям и их фильтрацию или даже реализовать бэкенд на Node.js для хранения изображений на сервере. Возможности безграничны.

Наше изучение по основам React подошло к концу. Мы прошли путь от самых основ JSX до создания собственного приложения. Но это только начало. React это огромная и развивающаяся экосистема.

Спасибо, что были с нами на протяжении этого курса! Уверен, у вас все получится.

Полный курс с уроками по React для начинающих

Поделиться статьей:
Поддержать автора блога

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

Персональные рекомендации
Оставить комментарий