Repository: ve1zy/effective1
Pull request #3: Laba2
Author: login: ve1zy
Created: 2025-04-12T10:12:33Z | Merged: 2025-04-21T07:57:14Z



Diff:
diff --git a/.env b/.env
new file mode 100644
index 0000000..8372d88
--- /dev/null
+++ b/.env
@@ -0,0 +1,2 @@
+VITE_MARVEL_PUBLIC_KEY= b599b2cea5c8b794ba83cadf41a70e67
+VITE_MARVEL_PRIVATE_KEY=768333402f2317458d0ead51c300c9f3947a0b64
\ No newline at end of file
diff --git a/src/api/marvel.ts b/src/api/marvel.ts
new file mode 100644
index 0000000..36e2392
--- /dev/null
+++ b/src/api/marvel.ts
@@ -0,0 +1,121 @@
+import axios, { AxiosResponse } from 'axios';
+import md5 from 'md5';
+
+const publicKey = import.meta.env.VITE_MARVEL_PUBLIC_KEY;
+const privateKey = import.meta.env.VITE_MARVEL_PRIVATE_KEY;
+const baseUrl = 'https://gateway.marvel.com/v1/public';
+
+  export interface Comic {
+    id: number;
+    title: string;
+    thumbnail: string;
+    description?: string;
+    issueNumber?: number;
+    series?: {
+      resourceURI: string;
+      name: string;
+    };
+  }
+interface MarvelResponseData<T> {
+  offset: number;
+  limit: number;
+  total: number;
+  count: number;
+  results: T[];
+}
+
+interface MarvelApiResponse<T> {
+  code: number;
+  status: string;
+  data: MarvelResponseData<T>;
+}
+
+interface ComicApiResponse {
+  id: number;
+  title: string;
+  description: string;
+  thumbnail: {
+    path: string;
+    extension: string;
+  };
+  issueNumber: number;
+  series: {
+    resourceURI: string;
+    name: string;
+  };
+}
+
+export interface ComicsResponse {
+  results: Comic[];
+  total: number;
+}
+
+const getAuthParams = () => {
+  const ts = Date.now();
+  const hash = md5(ts + privateKey + publicKey);
+  return { ts, apikey: publicKey, hash };
+};
+
+const api = axios.create({
+  baseURL: baseUrl,
+});
+
+api.interceptors.response.use(
+  (response: AxiosResponse<MarvelApiResponse<ComicApiResponse>>) => {
+    return {
+      ...response,
+      data: response.data.data
+    };
+  },
+  error => {
+    const message = error.response?.data?.message || 'API request failed';
+    return Promise.reject(new Error(message));
+  }
+);
+
+export const getComics = async (
+  offset: number = 0,
+  limit: number = 10,
+  additionalParams: Record<string, any> = {}
+): Promise<ComicsResponse> => {
+  const params = {
+    ...getAuthParams(),
+    offset,
+    limit,
+    orderBy: 'title',
+    ...additionalParams,
+  };
+  
+  const response = await api.get<MarvelResponseData<ComicApiResponse>>('/comics', { params });
+  
+  return {
+    results: response.data.results.map(comic => ({
+      id: comic.id,
+      title: comic.title,
+      thumbnail: `${comic.thumbnail.path}.${comic.thumbnail.extension}`,
+      description: comic.description || 'No description available',
+      issueNumber: comic.issueNumber,
+      series: comic.series,
+    })),
+    total: response.data.total,
+  };
+};
+export const getComicById = async (id: number): Promise<Comic> => {
+  const response = await api.get<MarvelResponseData<ComicApiResponse>>(`/comics/${id}`, { 
+    params: getAuthParams() 
+  });
+
+  if (response.data.results.length === 0) {
+    throw new Error('Comic not found');
+  }
+
+  const comic = response.data.results[0];
+  return {
+    id: comic.id,
+    title: comic.title,
+    thumbnail: `${comic.thumbnail.path}.${comic.thumbnail.extension}`,
+    description: comic.description || 'No description available',
+    issueNumber: comic.issueNumber,
+    series: comic.series,
+  };
+};
\ No newline at end of file
diff --git a/src/components/ComicCard/ComicCard.module.scss b/src/components/ComicCard/ComicCard.module.scss
index 01ff384..23534d3 100644
--- a/src/components/ComicCard/ComicCard.module.scss
+++ b/src/components/ComicCard/ComicCard.module.scss
@@ -1,53 +1,76 @@
 .card {
   position: relative;
-  border: 1px solid #ccc;
-  border-radius: 8px;
+  border: 1px solid #eee;
+  border-radius: 10px;
   overflow: hidden;
-  transition: box-shadow 0.2s;
+  transition: all 0.3s ease;
   width: 100%;
-  box-sizing: border-box;
+  height: 100%; 
+  display: flex;
+  flex-direction: column;
 
   &:hover {
-    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
+    transform: translateY(-5px);
+    box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
   }
 
   .link {
+    flex-grow: 1;
+    display: flex;
+    flex-direction: column;
     text-decoration: none;
     color: inherit;
-    display: block;
   }
 
-  img {
-    width: 100%;
-    height: auto;
-    border-radius: 8px;
-    transition: transform 0.2s;
-
-    &:hover {
-      transform: scale(1.05);
+  .imageWrapper {
+    flex-grow: 1;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    overflow: hidden;
+    
+    img {
+      width: 100%;
+      height: 300px;
+      object-fit: cover;
+      transition: transform 0.3s ease;
+      
+      &:hover {
+        transform: scale(1.05);
+      }
     }
   }
 
-  h3 {
-    margin: 0.5rem;
-    font-size: 1.2rem;
+  .title {
+    padding: 1rem;
+    margin: 0;
+    font-size: 1.1rem;
+    text-align: center;
+    background: white;
+    border-top: 1px solid #eee;
   }
 
   .favoriteButton {
     position: absolute;
     top: 10px;
     right: 10px;
-    background: none;
+    background: rgba(255, 255, 255, 0.8);
     border: none;
+    border-radius: 50%;
+    width: 36px;
+    height: 36px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
     cursor: pointer;
-    font-size: 1.5rem;
+    font-size: 1.2rem;
     color: red;
     z-index: 1;
+    transition: all 0.3s ease;
 
-    @media (max-width: 600px) {
-      top: 5px;
-      right: 5px;
-      font-size: 1.2rem;
+    &:hover {
+      background: white;
+      transform: scale(1.1);
     }
   }
-} 
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/src/components/ComicCard/ComicCard.tsx b/src/components/ComicCard/ComicCard.tsx
index 86ff4ca..d98b839 100644
--- a/src/components/ComicCard/ComicCard.tsx
+++ b/src/components/ComicCard/ComicCard.tsx
@@ -1,19 +1,26 @@
-import { useState } from "react";
-import { Link } from "react-router-dom";
-import styles from "./ComicCard.module.scss";
+import { observer } from 'mobx-react-lite';
+import { useState } from 'react';
+import { Link } from 'react-router-dom';
+import { comicsStore } from '../../stores/comicsStore';
+import { Comic } from '../../api/marvel';
+import styles from './ComicCard.module.scss';
 
 interface ComicCardProps {
-  comic: {
-    id: number;
-    title: string;
-    thumbnail: string;
-  };
-  isFavorite: boolean;
-  onToggleFavorite: (id: number) => void;
+  comic: Comic;
+  showFavoriteButton?: boolean;
+  isFavorite?: boolean; // Переименовали isFavoriteDefault в isFavorite
 }
 
-const ComicCard = ({ comic, isFavorite, onToggleFavorite }: ComicCardProps) => {
+const ComicCard = observer(({ 
+  comic, 
+  showFavoriteButton = true,
+  isFavorite: isFavoriteProp
+}: ComicCardProps) => {
   const [isHovered, setIsHovered] = useState(false);
+  const { toggleFavorite } = comicsStore;
+
+  // Используем переданное значение или получаем из store
+  const isFavorite = isFavoriteProp ?? comicsStore.isFavorite(comic.id);
 
   return (
     <div
@@ -25,16 +32,16 @@ const ComicCard = ({ comic, isFavorite, onToggleFavorite }: ComicCardProps) => {
         <img src={comic.thumbnail} alt={comic.title} />
         <h3>{comic.title}</h3>
       </Link>
-      {isHovered && (
+      {showFavoriteButton && isHovered && (
         <button
           className={styles.favoriteButton}
-          onClick={() => onToggleFavorite(comic.id)}
+          onClick={() => toggleFavorite(comic)}
         >
           {isFavorite ? "❤️" : "♡"}
         </button>
       )}
     </div>
   );
-};
+});
 
 export default ComicCard;
\ No newline at end of file
diff --git a/src/hooks/useFavorites.ts b/src/hooks/useFavorites.ts
deleted file mode 100644
index 2212d9f..0000000
--- a/src/hooks/useFavorites.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { useState, useEffect } from "react";
-
-const useFavorites = () => {
-  const [favorites, setFavorites] = useState<number[]>(() => {
-    const savedFavorites = localStorage.getItem("favorites");
-    return savedFavorites ? JSON.parse(savedFavorites) : [];
-  });
-
-  useEffect(() => {
-    localStorage.setItem("favorites", JSON.stringify(favorites));
-  }, [favorites]);
-
-  const toggleFavorite = (id: number) => {
-    setFavorites((prev) =>
-      prev.includes(id) ? prev.filter((favId) => favId !== id) : [...prev, id]
-    );
-  };
-
-  return { favorites, toggleFavorite };
-};
-
-export default useFavorites;
\ No newline at end of file
diff --git a/src/main.tsx b/src/main.tsx
index beed7a4..a3adea3 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,12 +1,15 @@
-import React from "react";
-import ReactDOM from "react-dom/client";
-import { BrowserRouter } from "react-router-dom";
-import App from "./App";
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { BrowserRouter } from 'react-router-dom';
+import { ToastContainer } from 'react-toastify';
+import 'react-toastify/dist/ReactToastify.css';
+import App from './App';
 
-ReactDOM.createRoot(document.getElementById("root")!).render(
+ReactDOM.createRoot(document.getElementById('root')!).render(
   <React.StrictMode>
     <BrowserRouter>
       <App />
+      <ToastContainer position="top-right" autoClose={5000} />
     </BrowserRouter>
   </React.StrictMode>
 );
\ No newline at end of file
diff --git a/src/mocks/comics.ts b/src/mocks/comics.ts
deleted file mode 100644
index e240876..0000000
--- a/src/mocks/comics.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-export interface Comic {
-    id: number;
-    title: string;
-    thumbnail: string;
-    description: string;
-  }
-  
-  export const comics: Comic[] = [
-    {
-      id: 1,
-      title: "Comic 1",
-      thumbnail: "https://cdn.marvel.com/u/prod/marvel/i/mg/c/c0/67d9b60b3e7fb/clean.jpg",
-      description: "This is the first comic.",  
-    },
-    {
-      id: 2,
-      title: "Comic 2",
-      thumbnail: "https://cdn.marvel.com/u/prod/marvel/i/mg/c/c0/67d9b60b3e7fb/clean.jpg",
-      description: "This is the second comic.",
-    },
-    {
-      id: 3,
-      title: "Comic 3",
-      thumbnail: "https://cdn.marvel.com/u/prod/marvel/i/mg/a/90/6791563821035/clean.jpg",
-      description: "This is the third comic.",
-    },
-    {
-      id: 4,
-      title: "Comic 4",
-      thumbnail: "https://cdn.marvel.com/u/prod/marvel/i/mg/a/90/6791563821035/clean.jpg",
-      description: "This is the 4 comic.",
-    },
-    {
-      id: 5,
-      title: "Comic 5",
-      thumbnail: "https://cdn.marvel.com/u/prod/marvel/i/mg/a/90/6791563821035/clean.jpg",
-      description: "This is the 5 comic.",
-    },
-    {
-      id: 6,
-      title: "Comic 6",
-      thumbnail: "https://cdn.marvel.com/u/prod/marvel/i/mg/a/90/6791563821035/clean.jpg",
-      description: "This is the 6 comic.",
-    },
-    {
-      id: 7,
-      title: "Comic 7",
-      thumbnail: "https://cdn.marvel.com/u/prod/marvel/i/mg/a/90/6791563821035/clean.jpg",
-      description: "This is the 7 comic.",
-    },
-    {
-      id: 8,
-      title: "Comic 8",
-      thumbnail: "https://cdn.marvel.com/u/prod/marvel/i/mg/a/90/6791563821035/clean.jpg",
-      description: "This is the 8 comic.",
-    },
-  ];
\ No newline at end of file
diff --git a/src/pages/ComicDetails/ComicDetails.module.scss b/src/pages/ComicDetails/ComicDetails.module.scss
index 2030d15..86d00d8 100644
--- a/src/pages/ComicDetails/ComicDetails.module.scss
+++ b/src/pages/ComicDetails/ComicDetails.module.scss
@@ -1,82 +1,116 @@
-.details {
-  padding: 1rem;
-  max-width: 800px;
+.detailsContainer {
+  max-width: 1200px;
   margin: 0 auto;
-  background-color: white;
-  border-radius: 8px;
-  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+  padding: 2rem 1rem;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.comicMain {
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin-bottom: 3rem;
+}
 
+.comicHeader {
+  text-align: center;
+  margin-bottom: 2rem;
+  
   h1 {
-    font-size: 2rem;
-    margin-bottom: 1rem;
+    font-size: 2.5rem;
+    margin-bottom: 0.5rem;
     color: #dc3545;
   }
+  
+  .issueNumber {
+    color: #6c757d;
+    font-size: 1.2rem;
+  }
+}
 
+.comicContent {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  width: 100%;
+  gap: 2rem;
+}
+
+.comicImageContainer {
+  width: 100%;
+  max-width: 500px;
+  display: flex;
+  justify-content: center;
+  
   img {
     width: 100%;
-    height: auto;
+    max-height: 600px;
+    object-fit: contain;
     border-radius: 8px;
-    margin-bottom: 1rem;
+    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
   }
+}
 
-  p {
-    font-size: 1.2rem;
-    line-height: 1.5;
+.comicDescription {
+  width: 100%;
+  max-width: 800px;
+  text-align: center;
+  padding: 0 1rem;
+  
+  h2 {
     color: #333;
+    margin-bottom: 1rem;
   }
-
-  .navigation {
-    display: flex;
-    justify-content: space-between;
-    margin: 2rem 0;
-
-    .navLink {
-      text-decoration: none;
-      color: #dc3545;
-      font-size: 1.1rem;
-
-      &:hover {
-        text-decoration: underline;
-      }
-    }
+  
+  p {
+    line-height: 1.8;
+    color: #555;
+    font-size: 1.1rem;
   }
+}
 
-  .related {
-    margin-top: 2rem;
-
-    h2 {
-      font-size: 1.5rem;
-      margin-bottom: 1rem;
-      color: #dc3545;
-    }
-
-    .relatedList {
-      display: grid;
-      grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
-      gap: 1rem;
-
-      .relatedLink {
-        text-decoration: none;
-        color: inherit;
-
-        img {
-          width: 100%;
-          height: auto;
-          border-radius: 8px;
-          transition: transform 0.2s;
+.relatedSection {
+  width: 100%;
+  text-align: center;
+  margin-top: 3rem;
+  
+  h2 {
+    font-size: 2rem;
+    color: #dc3545;
+    margin-bottom: 2rem;
+  }
+}
 
-          &:hover {
-            transform: scale(1.05);
-          }
-        }
+.relatedGrid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+  gap: 2rem;
+  width: 100%;
+  justify-items: center;
+}
 
-        p {
-          font-size: 1rem;
-          margin-top: 0.5rem;
-          text-align: center;
-          color: #333;
-        }
-      }
-    }
+.backButton {
+  margin-top: 3rem;
+  padding: 0.8rem 2rem;
+  background-color: #dc3545;
+  color: white;
+  border: none;
+  border-radius: 6px;
+  font-size: 1.1rem;
+  cursor: pointer;
+  transition: all 0.3s ease;
+  
+  &:hover {
+    background-color: #c82333;
+    transform: translateY(-2px);
   }
+}
+
+.noRelated {
+  font-size: 1.2rem;
+  color: #6c757d;
+  margin: 2rem 0;
 }
\ No newline at end of file
diff --git a/src/pages/ComicDetails/ComicDetails.tsx b/src/pages/ComicDetails/ComicDetails.tsx
index dfce2a3..004aeab 100644
--- a/src/pages/ComicDetails/ComicDetails.tsx
+++ b/src/pages/ComicDetails/ComicDetails.tsx
@@ -1,60 +1,107 @@
-import { useParams, Link } from "react-router-dom";
-import { comics } from "../../mocks/comics";
-import styles from "./ComicDetails.module.scss";
+import { observer } from 'mobx-react-lite';
+import { useEffect } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import ComicCard from '../../components/ComicCard/ComicCard';
+import { comicsStore } from '../../stores/comicsStore';
+import styles from './ComicDetails.module.scss';
 
-const ComicDetails = () => {
+const ComicDetails = observer(() => {
   const { id } = useParams<{ id: string }>();
-  const comicId = Number(id);
-  const comic = comics.find((c) => c.id === comicId);
+  const navigate = useNavigate();
+  const { 
+    currentComic, 
+    relatedComics,
+    loading,
+    error
+  } = comicsStore;
 
-  const prevComic = comics.find((c) => c.id === comicId - 1);
-  const nextComic = comics.find((c) => c.id === comicId + 1);
+  useEffect(() => {
+    if (id) {
+      comicsStore.loadComicDetails(Number(id));
+    }
+  }, [id]);
 
-  const randomComics = comics
-    .filter((c) => c.id !== comicId)
-    .sort(() => Math.random() - 0.5) 
-    .slice(0, 2);
+  if (loading && !currentComic) {
+    return <div className={styles.detailsContainer}>Loading...</div>;
+  }
 
-  if (!comic) {
-    return <div>Comic not found</div>;
+  if (error) {
+    return (
+      <div className={styles.detailsContainer}>
+        <div className={styles.errorMessage}>{error}</div>
+        <button 
+          onClick={() => navigate('/comics')} 
+          className={styles.backButton}
+        >
+          Back to Comics
+        </button>
+      </div>
+    );
   }
 
-  return (
-    <div className={styles.details}>
-      <h1>{comic.title}</h1>
-      <img src={comic.thumbnail} alt={comic.title} />
-      <p>{comic.description}</p>
-
-      <div className={styles.navigation}>
-        {prevComic && (
-          <Link to={`/comic/${prevComic.id}`} className={styles.navLink}>
-            ← Предыдущий: {prevComic.title}
-          </Link>
-        )}
-        {nextComic && (
-          <Link to={`/comic/${nextComic.id}`} className={styles.navLink}>
-            Следующий: {nextComic.title} →
-          </Link>
-        )}
+  if (!currentComic) {
+    return (
+      <div className={styles.detailsContainer}>
+        <h2>Comic not found</h2>
+        <button 
+          onClick={() => navigate('/comics')} 
+          className={styles.backButton}
+        >
+          Back to Comics
+        </button>
       </div>
+    );
+  }
+
+  return (
+    <div className={styles.detailsContainer}>
+      <div className={styles.comicMain}>
+        <div className={styles.comicHeader}>
+          <h1>{currentComic.title}</h1>
+          {currentComic.issueNumber && (
+            <p className={styles.issueNumber}>Issue #{currentComic.issueNumber}</p>
+          )}
+        </div>
 
-      <div className={styles.related}>
-        <h2>Другие комиксы</h2>
-        <div className={styles.relatedList}>
-          {randomComics.map((randomComic) => (
-            <Link
-              key={randomComic.id}
-              to={`/comic/${randomComic.id}`}
-              className={styles.relatedLink}
-            >
-              <img src={randomComic.thumbnail} alt={randomComic.title} />
-              <p>{randomComic.title}</p>
-            </Link>
-          ))}
+        <div className={styles.comicContent}>
+          <div className={styles.comicImageContainer}>
+            <img
+              src={currentComic.thumbnail}
+              alt={currentComic.title}
+              onError={(e) => {
+                (e.target as HTMLImageElement).src = '/placeholder-comic.jpg';
+              }}
+            />
+          </div>
+
+          <div className={styles.comicDescription}>
+            <h2>Description</h2>
+            <p>{currentComic.description || "No description available"}</p>
+          </div>
         </div>
       </div>
+
+      <div className={styles.relatedSection}>
+        <h2>You Might Also Like</h2>
+        {relatedComics.length > 0 ? (
+          <div className={styles.relatedGrid}>
+            {relatedComics.map(comic => (
+              <ComicCard key={comic.id} comic={comic} />
+            ))}
+          </div>
+        ) : (
+          !loading && <p className={styles.noRelated}>No related comics found</p>
+        )}
+      </div>
+
+      <button 
+        onClick={() => navigate('/comics')} 
+        className={styles.backButton}
+      >
+        Back to Comics
+      </button>
     </div>
   );
-};
+});
 
 export default ComicDetails;
\ No newline at end of file
diff --git a/src/pages/Comics/Comics.module.scss b/src/pages/Comics/Comics.module.scss
index fafbda9..fd44a18 100644
--- a/src/pages/Comics/Comics.module.scss
+++ b/src/pages/Comics/Comics.module.scss
@@ -1,49 +1,68 @@
 .comics {
-  padding: 1rem;
+  padding: 2rem 1rem;
   max-width: 1200px;
   margin: 0 auto;
+  min-height: calc(100vh - 200px);
 
   h1 {
-    font-size: 2rem;
-    margin-bottom: 1rem;
+    text-align: center;
+    font-size: 2.5rem;
+    margin-bottom: 2rem;
     color: #dc3545;
   }
 
-  .list {
+  .comicsGrid {
     display: grid;
-    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
-    gap: 1rem;
+    grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+    gap: 2rem;
+    justify-items: center;
+    
+    .comicCardWrapper {
+      width: 100%;
+      height: 100%;
+    }
   }
 
   .pagination {
+    margin-top: 3rem;
     display: flex;
     justify-content: center;
-    align-items: center;
-    margin-top: 2rem;
-    gap: 0.5rem;
+    gap: 0.8rem;
+    flex-wrap: wrap;
 
     button {
-      padding: 0.5rem 1rem;
-      border: 1px solid #dc3545;
-      border-radius: 4px;
-      background-color: #fff;
+      padding: 0.6rem 1.2rem;
+      border: 2px solid #dc3545;
+      border-radius: 6px;
+      background-color: white;
+      color: #dc3545;
+      font-weight: bold;
       cursor: pointer;
-      transition: background-color 0.2s;
+      transition: all 0.3s ease;
+      min-width: 50px;
 
       &:hover:not(:disabled) {
-        background-color: #f8d7da;
+        background-color: #dc3545;
+        color: white;
       }
 
       &:disabled {
-        cursor: not-allowed;
         opacity: 0.5;
+        cursor: not-allowed;
       }
 
       &.active {
         background-color: #dc3545;
         color: white;
-        border-color: #dc3545;
       }
     }
   }
+
+  .loadingMessage,
+  .emptyMessage {
+    text-align: center;
+    font-size: 1.2rem;
+    margin: 3rem 0;
+    color: #6c757d;
+  }
 }
\ No newline at end of file
diff --git a/src/pages/Comics/Comics.tsx b/src/pages/Comics/Comics.tsx
index c88eb00..aadbb3d 100644
--- a/src/pages/Comics/Comics.tsx
+++ b/src/pages/Comics/Comics.tsx
@@ -1,84 +1,103 @@
-import { useState } from "react";
-import ComicCard from "../../components/ComicCard/ComicCard";
-import { comics } from "../../mocks/comics";
-import useFavorites from "../../hooks/useFavorites";
-import styles from "./Comics.module.scss";
+import { observer } from 'mobx-react-lite';
+import { useEffect } from 'react';
+import ComicCard from '../../components/ComicCard/ComicCard';
+import { comicsStore } from '../../stores/comicsStore';
+import styles from './Comics.module.scss';
 
-const Comics = () => {
-  const { favorites, toggleFavorite } = useFavorites();
-  const [currentPage, setCurrentPage] = useState(1);
-  const comicsPerPage = 4;
+const Comics = observer(() => {
+  const { 
+    comics, 
+    currentPage, 
+    total, 
+    limit, 
+    loading, 
+    loadComics, 
+    setCurrentPage 
+  } = comicsStore;
 
-  const indexOfLastComic = currentPage * comicsPerPage;
-  const indexOfFirstComic = indexOfLastComic - comicsPerPage;
-  const currentComics = comics.slice(indexOfFirstComic, indexOfLastComic);
+  useEffect(() => {
+    loadComics(currentPage);
+  }, []);
 
-  const paginate = (pageNumber: number) => setCurrentPage(pageNumber);
+  const handlePageChange = (newPage: number) => {
+    setCurrentPage(newPage);
+    loadComics(newPage);
+    window.scrollTo(0, 0);
+  };
 
-  const totalPages = Math.ceil(comics.length / comicsPerPage);
-  const maxPagesToShow = 5;
-  let startPage = Math.max(1, currentPage - Math.floor(maxPagesToShow / 2));
-  let endPage = Math.min(totalPages, startPage + maxPagesToShow - 1);
+  const renderPagination = () => {
+    const pageCount = Math.ceil(total / limit);
+    const pagesToShow = 5;
 
-  if (endPage - startPage + 1 < maxPagesToShow) {
-    startPage = Math.max(1, endPage - maxPagesToShow + 1);
-  }
+    let startPage = Math.max(1, currentPage - Math.floor(pagesToShow / 2));
+    let endPage = Math.min(pageCount, startPage + pagesToShow - 1);
 
-  const pageNumbers = [];
-  for (let i = startPage; i <= endPage; i++) {
-    pageNumbers.push(i);
-  }
+    if (endPage - startPage + 1 < pagesToShow) {
+      startPage = Math.max(1, endPage - pagesToShow + 1);
+    }
 
-  return (
-    <div className={styles.comics}>
-      <h1>Comics</h1>
-      <div className={styles.list}>
-        {currentComics.map((comic) => (
-          <ComicCard
-            key={comic.id}
-            comic={comic}
-            isFavorite={favorites.includes(comic.id)}
-            onToggleFavorite={toggleFavorite}
-          />
-        ))}
-      </div>
+    return (
       <div className={styles.pagination}>
         <button
-          onClick={() => paginate(1)}
+          onClick={() => handlePageChange(1)}
           disabled={currentPage === 1}
         >
-          Первая
+          « First
         </button>
         <button
-          onClick={() => paginate(currentPage - 1)}
+          onClick={() => handlePageChange(currentPage - 1)}
           disabled={currentPage === 1}
         >
-          Назад
+          ‹ Prev
         </button>
-        {pageNumbers.map((number) => (
+        {Array.from({ length: endPage - startPage + 1 }).map((_, i) => (
           <button
-            key={number}
-            onClick={() => paginate(number)}
-            className={currentPage === number ? styles.active : ""} 
+            key={startPage + i}
+            onClick={() => handlePageChange(startPage + i)}
+            className={currentPage === startPage + i ? styles.active : ''}
           >
-            {number}
+            {startPage + i}
           </button>
         ))}
         <button
-          onClick={() => paginate(currentPage + 1)}
-          disabled={currentPage === totalPages} 
+          onClick={() => handlePageChange(currentPage + 1)}
+          disabled={currentPage === pageCount}
         >
-          Вперед
+          Next ›
         </button>
         <button
-          onClick={() => paginate(totalPages)}
-          disabled={currentPage === totalPages} 
+          onClick={() => handlePageChange(pageCount)}
+          disabled={currentPage === pageCount}
         >
-          Последняя
+          Last »
         </button>
       </div>
+    );
+  };
+
+  return (
+    <div className={styles.comics}>
+      <h1>Marvel Comics</h1>
+      
+      {loading && !comics.length ? (
+        <div className={styles.loadingMessage}>Loading comics...</div>
+      ) : comics.length === 0 ? (
+        <div className={styles.emptyMessage}>No comics found</div>
+      ) : (
+        <>
+          <div className={styles.comicsGrid}>
+            {comics.map(comic => (
+              <div key={comic.id} className={styles.comicCardWrapper}>
+                <ComicCard comic={comic} />
+              </div>
+            ))}
+          </div>
+          
+          {total > limit && renderPagination()}
+        </>
+      )}
     </div>
   );
-};
+});
 
 export default Comics;
\ No newline at end of file
diff --git a/src/pages/Favorite/Favorite.module.scss b/src/pages/Favorite/Favorite.module.scss
index 671f845..ee42f8e 100644
--- a/src/pages/Favorite/Favorite.module.scss
+++ b/src/pages/Favorite/Favorite.module.scss
@@ -1,19 +1,19 @@
 .favorite {
-    padding: 1rem;
-    max-width: 1200px;
-    margin: 0 auto;
-    font-family: 'Roboto', sans-serif;
-  
-    h1 {
-      font-size: 2rem;
-      margin-bottom: 1rem;
-      color: #dc3545; 
-      font-family: 'Montserrat', sans-serif;
-    }
-  
-    .list {
-      display: grid;
-      grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
-      gap: 1rem;
-    }
-  }
\ No newline at end of file
+  padding: 1rem;
+  max-width: 1200px;
+  margin: 0 auto;
+  font-family: 'Roboto', sans-serif;
+
+  h1 {
+    font-size: 2rem;
+    margin-bottom: 1rem;
+    color: #dc3545; 
+    font-family: 'Montserrat', sans-serif;
+  }
+
+  .list {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+    gap: 1rem;
+  }
+}
\ No newline at end of file
diff --git a/src/pages/Favorite/Favorite.tsx b/src/pages/Favorite/Favorite.tsx
index 71892a2..447cd29 100644
--- a/src/pages/Favorite/Favorite.tsx
+++ b/src/pages/Favorite/Favorite.tsx
@@ -1,28 +1,41 @@
-import ComicCard from "../../components/ComicCard/ComicCard";
-import { comics } from "../../mocks/comics";
-import useFavorites from "../../hooks/useFavorites";
-import styles from "./Favorite.module.scss";
+import { observer } from 'mobx-react-lite';
+import { useEffect } from 'react';
+import ComicCard from '../../components/ComicCard/ComicCard';
+import { comicsStore } from '../../stores/comicsStore';
+import { Comic } from '../../api/marvel';
+import styles from './Favorite.module.scss';
 
-const Favorite = () => {
-  const { favorites, toggleFavorite } = useFavorites();
+const Favorite = observer(() => {
+  const { favorites} = comicsStore;
 
-  const favoriteComics = comics.filter((comic) => favorites.includes(comic.id));
+  useEffect(() => {
+    comicsStore.loadFavorites();
+  }, []);
+
+  if (favorites.length === 0) {
+    return (
+      <div className={styles.favorite}>
+        <h1>Favorite Comics</h1>
+        <p>No favorites yet. Add some comics to your favorites!</p>
+      </div>
+    );
+  }
 
   return (
     <div className={styles.favorite}>
-      <h1>Favorite</h1>
+      <h1>Favorite Comics</h1>
       <div className={styles.list}>
-        {favoriteComics.map((comic) => (
-          <ComicCard
+        {favorites.map((comic: Comic) => (
+          <ComicCard 
             key={comic.id}
             comic={comic}
-            isFavorite={true} 
-            onToggleFavorite={() => toggleFavorite(comic.id)} 
+            showFavoriteButton={true}
+            isFavorite={true}
           />
         ))}
       </div>
     </div>
   );
-};
+});
 
 export default Favorite;
\ No newline at end of file
diff --git a/src/stores/comicsStore.ts b/src/stores/comicsStore.ts
new file mode 100644
index 0000000..e3f5d2f
--- /dev/null
+++ b/src/stores/comicsStore.ts
@@ -0,0 +1,201 @@
+import { makeAutoObservable, reaction, runInAction } from 'mobx';
+import { toast } from 'react-toastify';
+import { getComics, getComicById } from '../api/marvel';
+import { Comic } from '../api/marvel';
+
+class ComicsStore {
+  comics: Comic[] = [];
+  currentComic: Comic | null = null;
+  relatedComics: Comic[] = [];
+  favorites: Comic[] = [];
+  offset = 0;
+  total = 0;
+  limit = 8;
+  loading = false;
+  error: string | null = null;
+  currentPage = 1;
+  constructor() {
+    makeAutoObservable(this);
+    this.initStore();
+     this.loadFavorites();
+    
+    reaction(
+      () => this.favorites.slice(),
+      (favorites) => {
+        try {
+          const data = JSON.stringify(favorites.map(c => ({
+            id: c.id,
+            title: c.title,
+            thumbnail: c.thumbnail
+          })));
+          localStorage.setItem('marvel_favorites', data);
+          console.log('Saved favorites:', data);
+        } catch (error) {
+          console.error('Save error:', error);
+        }
+      }
+    );
+  }
+  setCurrentPage = (page: number) => {
+    this.currentPage = page;
+  };
+  private initStore() {
+    this.setupReactions();
+    this.loadFavorites();
+  }
+
+  private setupReactions() {
+    reaction(
+      () => this.favorites,
+      (favorites) => {
+        localStorage.setItem('favorites', JSON.stringify(favorites));
+      }
+    );
+
+    reaction(
+      () => this.error,
+      (error) => error && toast.error(error)
+    );
+  }
+  
+  loadComics = async (page: number = this.currentPage) => {
+    const offset = (page - 1) * this.limit; 
+    try {
+      runInAction(() => {
+        this.loading = true;
+        this.error = null;
+      });
+  
+      const response = await getComics(offset, this.limit);
+      
+      runInAction(() => {
+        this.comics = response.results;
+        this.total = response.total;
+        this.offset = offset;
+        this.currentPage = page;
+      });
+    } catch (err) {
+    }
+  };
+  loadComicDetails = async (id: number) => {
+    try {
+      runInAction(() => {
+        this.loading = true;
+        this.error = null;
+        this.relatedComics = [];
+      });
+  
+      const comic = await getComicById(id);
+      
+      runInAction(() => {
+        this.currentComic = comic;
+      });
+  
+      await this.loadRelatedComics(comic);
+    } catch (err) {
+      runInAction(() => {
+        this.error = err instanceof Error ? err.message : 'Failed to load comic details';
+      });
+    } finally {
+      runInAction(() => {
+        this.loading = false;
+      });
+    }
+  };
+
+  loadRelatedComics = async (comic: Comic) => {
+    try {
+      runInAction(() => {
+        this.loading = true;
+        this.relatedComics = [];
+      });
+  
+      if (comic.series) {
+        const seriesId = comic.series.resourceURI.split('/').pop();
+        const seriesResponse = await getComics(0, 20, {
+          series: seriesId,
+          orderBy: '-modified',
+          limit: 8
+        });
+  
+        const seriesComics = seriesResponse.results.filter(c => c.id !== comic.id);
+        
+        if (seriesComics.length > 0) {
+          runInAction(() => {
+            this.relatedComics = seriesComics.slice(0, 4);
+          });
+          return;
+        }
+      }
+  
+      const baseTitle = comic.title.replace(/#\d+.*/, '').trim();
+      if (baseTitle) {
+        const titleResponse = await getComics(0, 20, {
+          titleStartsWith: baseTitle,
+          orderBy: '-modified',
+          limit: 10
+        });
+  
+        const titleComics = titleResponse.results.filter(c => c.id !== comic.id);
+        
+        if (titleComics.length > 0) {
+          runInAction(() => {
+            this.relatedComics = titleComics.slice(0, 4);
+          });
+          return;
+        }
+      }
+  
+      const randomResponse = await getComics(Math.floor(Math.random() * 100), 4, {
+        orderBy: '-modified',
+        limit: 4
+      });
+  
+      runInAction(() => {
+        this.relatedComics = randomResponse.results
+          .filter(c => c.id !== comic.id)
+          .slice(0, 4);
+      });
+  
+    } catch (err) {
+      console.error('Failed to load related comics:', err);
+      runInAction(() => {
+        this.relatedComics = [];
+      });
+    } finally {
+      runInAction(() => {
+        this.loading = false;
+      });
+    }
+  };
+  
+
+  loadFavorites = () => {
+    try {
+      const data = localStorage.getItem('marvel_favorites');
+      if (data) {
+        this.favorites = JSON.parse(data);
+        console.log('Loaded favorites:', this.favorites);
+      }
+    } catch (error) {
+      console.error('Load error:', error);
+    }
+  };
+
+  toggleFavorite = (comic: Comic) => {
+    const exists = this.favorites.some(c => c.id === comic.id);
+    if (exists) {
+      this.favorites = this.favorites.filter(c => c.id !== comic.id);
+      toast.info('Removed from favorites');
+    } else {
+      this.favorites.push({ ...comic });
+      toast.success('Added to favorites');
+    }
+  };
+
+  isFavorite = (id: number) => {
+    return this.favorites.some(c => c.id === id);
+  };
+}
+
+export const comicsStore = new ComicsStore();
\ No newline at end of file


Reviews:
user: login: ArtemKohanenko
body: 7 баллов

Немного комментариев дополнительно к тем, что в коде:

Советую разделять фичи на отдельные коммиты (отдельным коммитом добавил стор, отдельно localStorage, отдельно пофиксил что-то и тд) и давать коммитам говорящие названия. Вот хороший гайд по именованию коммитов - https://www.conventionalcommits.org/en/v1.0.0/.

Библиотеки не указаны в зависимостях package.json. Возможно, ты устанавливал их глобально (с флагом `-g`). Нужно устанавливать зависимости локально (`npm i`), чтобы они записывались в package.json и другие разработчики могли их загрузить их оттуда.

Можно добавить toast-уведомления для ошибок при запросах к API

Советую периодически запускать линтер (`npm run lint`), он будет подсвечивать места в коде, которые можно написать лучше
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-18T13:22:43Z
user: login: ArtemKohanenko
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-18T13:36:39Z

Review comments:
diff_hunk: @@ -0,0 +1,121 @@
+import axios, { AxiosResponse } from 'axios';
+import md5 from 'md5';
+
+const publicKey = import.meta.env.VITE_MARVEL_PUBLIC_KEY;
+const privateKey = import.meta.env.VITE_MARVEL_PRIVATE_KEY;
+const baseUrl = 'https://gateway.marvel.com/v1/public';
+
+  export interface Comic {
+    id: number;
+    title: string;
+    thumbnail: string;
+    description?: string;
+    issueNumber?: number;
+    series?: {
+      resourceURI: string;
+      name: string;
+    };
+  }
+interface MarvelResponseData<T> {
+  offset: number;
+  limit: number;
+  total: number;
+  count: number;
+  results: T[];
+}
+
+interface MarvelApiResponse<T> {
+  code: number;
+  status: string;
+  data: MarvelResponseData<T>;
+}
+
+interface ComicApiResponse {
+  id: number;
+  title: string;
+  description: string;
+  thumbnail: {
+    path: string;
+    extension: string;
+  };
+  issueNumber: number;
+  series: {
+    resourceURI: string;
+    name: string;
+  };
+}
+
+export interface ComicsResponse {
+  results: Comic[];
+  total: number;
+}
+
+const getAuthParams = () => {
+  const ts = Date.now();
+  const hash = md5(ts + privateKey + publicKey);
+  return { ts, apikey: publicKey, hash };
+};
+
+const api = axios.create({
+  baseURL: baseUrl,
+});
+
+api.interceptors.response.use(
+  (response: AxiosResponse<MarvelApiResponse<ComicApiResponse>>) => {
+    return {
+      ...response,
+      data: response.data.data
+    };
+  },
+  error => {
+    const message = error.response?.data?.message || 'API request failed';
+    return Promise.reject(new Error(message));
+  }
+);
+
+export const getComics = async (
+  offset: number = 0,
+  limit: number = 10,
+  additionalParams: Record<string, any> = {}
+): Promise<ComicsResponse> => {
+  const params = {
+    ...getAuthParams(),
+    offset,
+    limit,
+    orderBy: 'title',
+    ...additionalParams,
+  };
+  
+  const response = await api.get<MarvelResponseData<ComicApiResponse>>('/comics', { params });
+  
+  return {
+    results: response.data.results.map(comic => ({
+      id: comic.id,
+      title: comic.title,
+      thumbnail: `${comic.thumbnail.path}.${comic.thumbnail.extension}`,
+      description: comic.description || 'No description available',
+      issueNumber: comic.issueNumber,
+      series: comic.series,
+    })),
+    total: response.data.total,
+  };
+};
+export const getComicById = async (id: number): Promise<Comic> => {
+  const response = await api.get<MarvelResponseData<ComicApiResponse>>(`/comics/${id}`, { 
+    params: getAuthParams() 
+  });
+
+  if (response.data.results.length === 0) {
+    throw new Error('Comic not found');
+  }
+
+  const comic = response.data.results[0];
+  return {
+    id: comic.id,
+    title: comic.title,
+    thumbnail: `${comic.thumbnail.path}.${comic.thumbnail.extension}`,
+    description: comic.description || 'No description available',
+    issueNumber: comic.issueNumber,
+    series: comic.series,
+  };
path: src/api/marvel.ts
user: login: ArtemKohanenko
body: Этот код дублируется в getComicById и getComics. Можно вынести в отдельную функцию
created_at: 2025-04-18T13:16:48Z
author_association: NONE
diff_hunk: @@ -0,0 +1,121 @@
+import axios, { AxiosResponse } from 'axios';
+import md5 from 'md5';
+
+const publicKey = import.meta.env.VITE_MARVEL_PUBLIC_KEY;
+const privateKey = import.meta.env.VITE_MARVEL_PRIVATE_KEY;
+const baseUrl = 'https://gateway.marvel.com/v1/public';
+
+  export interface Comic {
+    id: number;
+    title: string;
+    thumbnail: string;
+    description?: string;
+    issueNumber?: number;
+    series?: {
+      resourceURI: string;
+      name: string;
+    };
+  }
+interface MarvelResponseData<T> {
+  offset: number;
+  limit: number;
+  total: number;
+  count: number;
+  results: T[];
+}
path: src/api/marvel.ts
user: login: ArtemKohanenko
body: Советую выносить типы/интерфейсы в отдельные файлы, особенно когда их так много
created_at: 2025-04-18T13:20:31Z
author_association: NONE
diff_hunk: @@ -0,0 +1,201 @@
+import { makeAutoObservable, reaction, runInAction } from 'mobx';
+import { toast } from 'react-toastify';
+import { getComics, getComicById } from '../api/marvel';
+import { Comic } from '../api/marvel';
+
+class ComicsStore {
+  comics: Comic[] = [];
+  currentComic: Comic | null = null;
+  relatedComics: Comic[] = [];
+  favorites: Comic[] = [];
+  offset = 0;
+  total = 0;
+  limit = 8;
+  loading = false;
+  error: string | null = null;
+  currentPage = 1;
+  constructor() {
+    makeAutoObservable(this);
+    this.initStore();
+     this.loadFavorites();
+    
+    reaction(
+      () => this.favorites.slice(),
+      (favorites) => {
+        try {
+          const data = JSON.stringify(favorites.map(c => ({
+            id: c.id,
+            title: c.title,
+            thumbnail: c.thumbnail
+          })));
+          localStorage.setItem('marvel_favorites', data);
+          console.log('Saved favorites:', data);
+        } catch (error) {
+          console.error('Save error:', error);
+        }
+      }
+    );
+  }
+  setCurrentPage = (page: number) => {
+    this.currentPage = page;
+  };
+  private initStore() {
+    this.setupReactions();
+    this.loadFavorites();
+  }
+
+  private setupReactions() {
+    reaction(
+      () => this.favorites,
+      (favorites) => {
+        localStorage.setItem('favorites', JSON.stringify(favorites));
+      }
+    );
path: src/stores/comicsStore.ts
user: login: ArtemKohanenko
body: Похоже, что эта реакция лишняя. В сторе уже есть сохранение favorites в localStorage, при этом к данным в 'favorites' код нигде не обращается
created_at: 2025-04-18T13:36:35Z
author_association: NONE

PR comments:
user: login: vercel[bot]
created_at: 2025-04-12T10:12:38Z
body: [vc]: #xRf9q6riuejxmjRT4ngRGSohqSNqoD97pmbUvJvh+HY=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJlZmZlY3RpdmUxIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6IiJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vdmUxenlzLXByb2plY3RzL2VmZmVjdGl2ZTEvM1JIelZHM21rQmtodGFmQW1NSFdiUnF6dmQyTSIsInByZXZpZXdVcmwiOiIiLCJuZXh0Q29tbWl0U3RhdHVzIjoiRkFJTEVEIn1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **effective1** | ❌ Failed ([Inspect](https://vercel.com/ve1zys-projects/effective1/3RHzVG3mkBkhtafAmMHWbRqzvd2M)) |  |  | Apr 12, 2025 10:12am |


author_association: NONE
user: login: ArtemKohanenko
created_at: 2025-04-18T13:26:35Z
body: Хочу еще продублировать совет по поводу использования SCSS-переменных для цветовой схемы

Для цветов в .scss лучше не указывать hex-код для каждого класса, а определить переменные с используемой во всем проекте цветовой схемой ($primary, $secondary, $background и т.д.) и вынести в отдельный файл.
Можно аналогично вынести размеры шрифта.
https://sass-lang.com/documentation/variables
author_association: NONE