Загрузка данных


Пошаговая инструкция для лабораторной работы «Shelf»

Часть 1. Настройка проекта в Codespace

Шаг 1. Создание React-приложения через Vite

Откройте терминал в Codespace (Ctrl+~) и выполните:

```bash
npm create vite@latest . -- --template react
```

Если папка не пустая и Vite ругается — выберите «Ignore files and continue» либо создайте проект в подпапке, а затем перенесите содержимое. Далее:

```bash
npm install
```

Шаг 2. Установка зависимостей и запуск

```bash
npm run dev -- --host 0.0.0.0
```

Vite выдаст ссылку — Codespace автоматически предложит открыть порт. Если нет — вкладка Ports → откройте порт 5173 → «Open in Browser».

Шаг 3. Подготовка структуры папок

Внутри src/ создайте:

```
src/
├── components/
│   ├── BookForm/
│   │   ├── BookForm.jsx
│   │   └── BookForm.css
│   ├── BookItem/
│   │   ├── BookItem.jsx
│   │   └── BookItem.css
│   ├── BookList/
│   │   ├── BookList.jsx
│   │   └── BookList.css
│   ├── FilterChip/
│   │   ├── FilterChip.jsx
│   │   └── FilterChip.css
│   ├── Input/
│   │   ├── Input.jsx
│   │   └── Input.css
│   ├── Checkbox/
│   │   ├── Checkbox.jsx
│   │   └── Checkbox.css
│   └── Button/
│       ├── Button.jsx
│       └── Button.css
├── pages/
│   └── ShelfScreen/
│       ├── ShelfScreen.jsx
│       └── ShelfScreen.css
├── App.jsx
├── App.css
├── index.css
└── main.jsx
```

Удалите лишнее из шаблона: assets/hero.png, assets/react.svg, App.css можно оставить пустым или переписать.

Шаг 4. Перенос стилей

Содержимое вашего index.css (которое вы прислали) положите в src/index.css — оно глобальное. Классы типа .book-row, .book-cover, .filter-chip понадобятся внутри компонентов, но можно оставить их в глобальном файле (так проще). Убедитесь, что main.jsx импортирует ./index.css.

---

Часть 2. Реализация компонентов

Пойдём снизу вверх по дереву — от листьев к корню.

Шаг 5. Компонент Button

src/components/Button/Button.jsx:

```jsx
import './Button.css'

export default function Button({ children, onClick, variant = 'primary', type = 'button' }) {
  const className = variant === 'ghost' ? 'btn ghost' : 'btn'
  return (
    <button className={className} type={type} onClick={onClick}>
      {children}
    </button>
  )
}
```

Button.css — можно оставить пустым, стили .btn уже в index.css.

Шаг 6. Компонент Input

src/components/Input/Input.jsx:

```jsx
import './Input.css'

export default function Input({ value, onChange, onKeyDown, placeholder }) {
  return (
    <input
      className="input"
      value={value}
      onChange={onChange}
      onKeyDown={onKeyDown}
      placeholder={placeholder}
    />
  )
}
```

Шаг 7. Компонент Checkbox

src/components/Checkbox/Checkbox.jsx:

```jsx
import './Checkbox.css'

export default function Checkbox({ id, checked, onChange, children }) {
  return (
    <div className="filter-chip">
      <input type="checkbox" id={id} checked={checked} onChange={onChange} />
      <label htmlFor={id}>
        <span className="dot" />
        {children}
      </label>
    </div>
  )
}
```

Шаг 8. Компонент BookForm

src/components/BookForm/BookForm.jsx:

```jsx
import { useState } from 'react'
import Input from '../Input/Input'
import Button from '../Button/Button'
import './BookForm.css'

export default function BookForm({ onAdd }) {
  const [draft, setDraft] = useState('')

  function handleAdd() {
    const title = draft.trim()
    if (title === '') return
    onAdd(title)
    setDraft('')
  }

  function handleKeyDown(e) {
    if (e.key === 'Enter') handleAdd()
  }

  return (
    <div className="add-book-row">
      <Input
        value={draft}
        onChange={(e) => setDraft(e.target.value)}
        onKeyDown={handleKeyDown}
        placeholder="Название книги..."
      />
      <Button onClick={handleAdd}>Добавить на полку</Button>
    </div>
  )
}
```

Ключевые моменты:

· Контролируемый input со своим useState.
· .trim() защищает от пробельных строк.
· onAdd(title) — колбэк из родителя, форма ничего не знает о массиве книг.

Шаг 9. Компонент BookItem

src/components/BookItem/BookItem.jsx:

```jsx
import './BookItem.css'

const COVER_PALETTE = ['#4f6b52', '#8a3a3a', '#5b4a7a', '#3a6b7a', '#7a5b3a']

export default function BookItem({ book, onToggleRead, onDelete }) {
  const coverColor = COVER_PALETTE[book.id % COVER_PALETTE.length]
  const initial = book.title.charAt(0).toUpperCase()

  return (
    <div className="book-row">
      <div className="book-cover" style={{ background: coverColor }}>
        {initial}
      </div>
      <div className="book-info">
        <p className={`book-title ${book.read ? 'done' : ''}`}>{book.title}</p>
        <div className="book-author">{book.author}</div>
      </div>
      <div
        className={`read-check ${book.read ? 'checked' : ''}`}
        onClick={() => onToggleRead(book.id)}
      >
        <span className="check-circle">✓</span>
        <span className="read-label">Прочитано</span>
      </div>
      <button
        className="delete-btn"
        onClick={() => onDelete(book.id)}
        title="Убрать с полки"
      >
        ✕
      </button>
    </div>
  )
}
```

Компонент не знает ничего о фильтрации — просто рендерит то, что получил.

Шаг 10. Компонент BookList

src/components/BookList/BookList.jsx:

```jsx
import BookItem from '../BookItem/BookItem'
import './BookList.css'

export default function BookList({ books, onToggleRead, onDelete }) {
  if (books.length === 0) {
    return <div className="empty-note">На полке пока пусто</div>
  }

  return (
    <div className="book-list">
      {books.map((book) => (
        <BookItem
          key={book.id}
          book={book}
          onToggleRead={onToggleRead}
          onDelete={onDelete}
        />
      ))}
    </div>
  )
}
```

BookList тоже не фильтрует — принимает уже отфильтрованный массив.

Шаг 11. Компонент FilterChip

src/components/FilterChip/FilterChip.jsx:

```jsx
import Checkbox from '../Checkbox/Checkbox'
import './FilterChip.css'

export default function FilterChip({ checked, onChange }) {
  return (
    <Checkbox id="filterCheckbox" checked={checked} onChange={onChange}>
      Только непрочитанные
    </Checkbox>
  )
}
```

Шаг 12. Компонент ShelfScreen

src/pages/ShelfScreen/ShelfScreen.jsx:

```jsx
import BookForm from '../../components/BookForm/BookForm'
import BookList from '../../components/BookList/BookList'
import FilterChip from '../../components/FilterChip/FilterChip'
import './ShelfScreen.css'

export default function ShelfScreen({
  books,
  showOnlyUnread,
  onToggleFilter,
  onAdd,
  onToggleRead,
  onDelete,
}) {
  const visibleBooks = showOnlyUnread
    ? books.filter((book) => !book.read)
    : books

  return (
    <section className="screen active">
      <p className="greeting">Добрый вечер</p>

      <BookForm onAdd={onAdd} />

      <div className="list-toolbar">
        <span className="toolbar-title">Книги</span>
        <FilterChip checked={showOnlyUnread} onChange={onToggleFilter} />
      </div>

      <BookList
        books={visibleBooks}
        onToggleRead={onToggleRead}
        onDelete={onDelete}
      />
    </section>
  )
}
```

Здесь живёт логика фильтрации (books.filter(...)), но не сам массив — он приходит через проп.

Шаг 13. Компонент App — единственное место с useState

src/App.jsx:

```jsx
import { useState } from 'react'
import ShelfScreen from './pages/ShelfScreen/ShelfScreen'
import './App.css'

const INITIAL_BOOKS = [
  { id: 1, title: 'Клара и Солнце', author: 'Кадзуо Исигуро', read: true },
  { id: 2, title: 'Норвежский лес', author: 'Харуки Мураками', read: false },
]

export default function App() {
  const [books, setBooks] = useState(INITIAL_BOOKS)
  const [showOnlyUnread, setShowOnlyUnread] = useState(false)

  function handleAdd(title) {
    const newBook = {
      id: Date.now(),
      title,
      author: 'Неизвестный автор',
      read: false,
    }
    setBooks((prev) => [...prev, newBook])
  }

  function handleToggleRead(id) {
    setBooks((prev) =>
      prev.map((book) =>
        book.id === id ? { ...book, read: !book.read } : book
      )
    )
  }

  function handleDelete(id) {
    setBooks((prev) => prev.filter((book) => book.id !== id))
  }

  function handleToggleFilter() {
    setShowOnlyUnread((prev) => !prev)
  }

  return (
    <div className="app">
      <div className="app-header">
        <div className="brand">
          <div className="brand-mark">S</div>
          <div className="brand-name">Shelf</div>
        </div>
      </div>

      <ShelfScreen
        books={books}
        showOnlyUnread={showOnlyUnread}
        onToggleFilter={handleToggleFilter}
        onAdd={handleAdd}
        onToggleRead={handleToggleRead}
        onDelete={handleDelete}
      />
    </div>
  )
}
```

Разбор иммутабельных обновлений:

Операция Как обновляем Почему так
Добавление [...prev, newBook] новый массив, старые объекты не тронуты
Удаление prev.filter(b => b.id !== id) новый массив без нужного элемента
Переключение read prev.map(b => b.id === id ? {...b, read: !b.read} : b) пересоздаём только нужный объект, остальные — те же самые ссылки

Шаг 14. Уборка

· Уберите всё лишнее из App.css (или очистите файл).
· В main.jsx должно остаться только:
  ```jsx
  import { StrictMode } from 'react'
  import { createRoot } from 'react-dom/client'
  import './index.css'
  import App from './App.jsx'
  
  createRoot(document.getElementById('root')).render(
    <StrictMode>
      <App />
    </StrictMode>
  )
  ```
· Убедитесь, что <div id="root"> есть в index.html.

Шаг 15. Проверка

1. npm run dev — приложение должно открыться без ошибок в консоли.
2. Добавьте книгу, переключите «Прочитано», удалите, включите фильтр «Только непрочитанные» — всё должно работать.
3. Откройте React DevTools (если есть) — убедитесь, что при переключении read у одной книги остальные объекты не меняются (в Components можно посмотреть ссылки).
4. Проверьте ESLint:
   ```bash
   npm run lint
   ```
   Исправьте предупреждения.

---

Часть 3. Сдача работы

Шаг 16. Форк и ветка

1. Форкните репозиторий в свою организацию, переименуйте в <имя-репо>-<ваша-фамилия>.
2. В Codespace привяжите удалённый репозиторий:
   ```bash
   git remote set-url origin <URL-вашего-форка>
   ```
3. Создайте ветку wip:
   ```bash
   git checkout -b wip
   ```

Шаг 17. Коммит и push

```bash
git add .
git commit -m "Лабораторная работа: хуки useState, useEffect — компоненты Shelf"
git push -u origin wip
```

Шаг 18. Хостинг

Самый простой вариант — GitHub Pages через gh-pages:

```bash
npm install --save-dev gh-pages
```

В vite.config.js добавьте:

```js
export default defineConfig({
  plugins: [react()],
  base: '/<имя-репозитория>/',
})
```

В package.json:

```json
"scripts": {
  "deploy": "npm run build && gh-pages -d dist"
}
```

```bash
npm run deploy
```

После этого в настройках репозитория (Settings → Pages) появится URL. Альтернатива — Vercel/Netlify, подключив репозиторий.

Шаг 19. Pull Request

1. На GitHub → Pull requests → New pull request.
2. Base: main (ваш форк), compare: wip.
3. В описании — что сделано, ссылка на задеплоенную версию.
4. В поле Reviewers добавьте ktkv419.
5. Не мержите самостоятельно.

---

Чек-лист по требованиям задания

☐ Имена файлов совпадают с именами компонентов, с большой буквы (BookItem.jsx).
☐ Каждый компонент — отдельный файл, экспорт по умолчанию.
☐ Везде деструктуризация пропсов: function X({ a, b }) {}.
☐ Ни одной мутации: только setBooks/setShowOnlyUnread.
☐ key={book.id}, не индекс.
☐ Нет document.getElementById внутри компонентов.
☐ Все useState — только в App.
☐ BookList не фильтрует, ShelfScreen фильтрует.
☐ Пустая строка в форме не отправляется.
☐ Enter в input добавляет книгу.
☐ useEffect — если не нужен, можно не использовать (по заданию он в теме, но конкретных требований в тексте нет — при желании можно добавить, например, сохранение books в localStorage и чтение при монтировании).

Если хотите добавить useEffect (для полноты темы) — вот пример в App.jsx:

```jsx
import { useState, useEffect } from 'react'

// ...

useEffect(() => {
  const saved = localStorage.getItem('shelf-books')
  if (saved) setBooks(JSON.parse(saved))
}, [])

useEffect(() => {
  localStorage.setItem('shelf-books', JSON.stringify(books))
}, [books])
```

Первый эффект сработает один раз при монтировании, второй — при каждом изменении books. Это бонус, не обязательное требование.