Skip to content

Kapitel 16: Projekt - Blog-System

In diesem Kapitel bauen wir ein Blog-System mit React Router und Axios.


16.1 Projektinitialisierung

📦 Pakete installieren

bash
# React Router
pnpm add react-router-dom

# Axios
pnpm add axios

# UUID (für ID-Generierung)
pnpm add uuid
pnpm add -D @types/uuid

📂 Projektstruktur

src/
├── App.jsx
├── main.jsx
├── components/
│   ├── Header.jsx
│   ├── Footer.jsx
│   └── BlogCard.jsx
├── pages/
│   ├── Home.jsx
│   ├── BlogDetail.jsx
│   ├── CreateBlog.jsx
│   └── Login.jsx
├── hooks/
│   └── useFetch.js
├── api/
│   └── blogApi.js
└── App.css

16.2 Routing konfigurieren

🌐 App.jsx (Router-Konfiguration)

jsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Header from './components/Header';
import Footer from './components/Footer';
import Home from './pages/Home';
import BlogDetail from './pages/BlogDetail';
import CreateBlog from './pages/CreateBlog';
import Login from './pages/Login';

function App() {
  return (
    <BrowserRouter>
      <div className="app">
        <Header />
        <main className="main-content">
          <Routes>
            <Route path="/" element={<Home />} />
            <Route path="/blog/:id" element={<BlogDetail />} />
            <Route path="/create" element={<CreateBlog />} />
            <Route path="/login" element={<Login />} />
          </Routes>
        </main>
        <Footer />
      </div>
    </BrowserRouter>
  );
}

export default App;

16.3 API-Modul erstellen

🔧 blogApi.js (Axios-Instanz)

javascript
import axios from 'axios';

const blogApi = axios.create({
  baseURL: 'https://jsonplaceholder.typicode.com',
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json'
  }
});

// Request Interceptor
blogApi.interceptors.request.use(
  config => {
    const token = localStorage.getItem('token');
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  error => Promise.reject(error)
);

// Response Interceptor
blogApi.interceptors.response.use(
  response => response,
  error => {
    if (error.response?.status === 401) {
      window.location.href = '/login';
    }
    return Promise.reject(error);
  }
);

export default blogApi;

16.4 Custom Hook: useFetch

🔄 hooks/useFetch.js

javascript
import { useState, useEffect } from 'react';

function useFetch(url, options = {}) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      try {
        setLoading(true);
        const response = await fetch(url, options);
        
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        
        const result = await response.json();
        setData(result);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, [url]);

  return { data, loading, error };
}

export default useFetch;

16.5 Seitenlayout

1️⃣ Header.jsx

jsx
import { Link, NavLink } from 'react-router-dom';

function Header() {
  return (
    <header className="header">
      <div className="header-content">
        <Link to="/" className="logo">
          Mein Blog
        </Link>
        
        <nav className="nav">
          <NavLink to="/" end>
            Startseite
          </NavLink>
          <NavLink to="/create">
            Neuen Blog erstellen
          </NavLink>
          <NavLink to="/login">
            Login
          </NavLink>
        </nav>
      </div>
    </header>
  );
}

export default Header;
jsx
function Footer() {
  return (
    <footer className="footer">
      <div className="footer-content">
        <p>&copy; 2024 Mein Blog. Alle Rechte vorbehalten.</p>
      </div>
    </footer>
  );
}

export default Footer;

3️⃣ BlogCard.jsx

jsx
import { Link } from 'react-router-dom';

function BlogCard({ blog }) {
  return (
    <div className="blog-card">
      <h3 className="blog-title">
        <Link to={`/blog/${blog.id}`}>{blog.title}</Link>
      </h3>
      <p className="blog-body">{blog.body.substring(0, 100)}...</p>
      <div className="blog-meta">
        <span>Autor: User {blog.userId}</span>
      </div>
    </div>
  );
}

export default BlogCard;

16.6 Seiten implementieren

1️⃣ Home.jsx (Blog-Liste)

jsx
import { useState, useEffect } from 'react';
import axios from 'axios';
import BlogCard from '../components/BlogCard';
import './Home.css';

function Home() {
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchPosts = async () => {
      try {
        const response = await axios.get('https://jsonplaceholder.typicode.com/posts');
        setPosts(response.data.slice(0, 10)); // Nur erste 10 Posts
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchPosts();
  }, []);

  if (loading) return <div className="loading">Lädt...</div>;
  if (error) return <div className="error">Fehler: {error}</div>;

  return (
    <div className="home">
      <h1>Alle Blogs</h1>
      <div className="blog-list">
        {posts.map(post => (
          <BlogCard key={post.id} blog={post} />
        ))}
      </div>
    </div>
  );
}

export default Home;

2️⃣ BlogDetail.jsx (Blog-Details)

jsx
import { useParams, useNavigate } from 'react-router-dom';
import { useState, useEffect } from 'react';
import axios from 'axios';
import './BlogDetail.css';

function BlogDetail() {
  const { id } = useParams();
  const navigate = useNavigate();
  const [post, setPost] = useState(null);
  const [comments, setComments] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const fetchData = async () => {
      try {
        const [postRes, commentsRes] = await Promise.all([
          axios.get(`https://jsonplaceholder.typicode.com/posts/${id}`),
          axios.get(`https://jsonplaceholder.typicode.com/posts/${id}/comments`)
        ]);
        
        setPost(postRes.data);
        setComments(commentsRes.data);
      } catch (error) {
        console.error('Fehler:', error);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, [id]);

  if (loading) return <div>Lädt...</div>;
  if (!post) return <div>Blog nicht gefunden</div>;

  return (
    <div className="blog-detail">
      <button onClick={() => navigate(-1)} className="back-button">
        ← Zurück
      </button>
      
      <h1>{post.title}</h1>
      <p className="blog-content">{post.body}</p>
      
      <div className="comments-section">
        <h2>Kommentare ({comments.length})</h2>
        {comments.map(comment => (
          <div key={comment.id} className="comment">
            <h4>{comment.name}</h4>
            <p>{comment.body}</p>
          </div>
        ))}
      </div>
    </div>
  );
}

export default BlogDetail;

3️⃣ CreateBlog.jsx (Blog erstellen)

jsx
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import axios from 'axios';
import './CreateBlog.css';

function CreateBlog() {
  const navigate = useNavigate();
  const [formData, setFormData] = useState({
    title: '',
    body: '',
    userId: 1
  });
  const [loading, setLoading] = useState(false);

  const handleChange = (e) => {
    setFormData({
      ...formData,
      [e.target.name]: e.target.value
    });
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    setLoading(true);

    try {
      const response = await axios.post('https://jsonplaceholder.typicode.com/posts', formData);
      alert('Blog erfolgreich erstellt!');
      navigate(`/blog/${response.data.id}`);
    } catch (error) {
      alert('Fehler beim Erstellen des Blogs');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="create-blog">
      <h1>Neuen Blog erstellen</h1>
      
      <form onSubmit={handleSubmit} className="blog-form">
        <div className="form-group">
          <label htmlFor="title">Titel</label>
          <input
            type="text"
            id="title"
            name="title"
            value={formData.title}
            onChange={handleChange}
            required
          />
        </div>
        
        <div className="form-group">
          <label htmlFor="body">Inhalt</label>
          <textarea
            id="body"
            name="body"
            value={formData.body}
            onChange={handleChange}
            rows="10"
            required
          />
        </div>
        
        <button type="submit" disabled={loading}>
          {loading ? 'Wird gespeichert...' : 'Blog veröffentlichen'}
        </button>
      </form>
    </div>
  );
}

export default CreateBlog;

4️⃣ Login.jsx (Login-Seite)

jsx
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import './Login.css';

function Login() {
  const navigate = useNavigate();
  const [formData, setFormData] = useState({
    email: '',
    password: ''
  });

  const handleChange = (e) => {
    setFormData({
      ...formData,
      [e.target.name]: e.target.value
    });
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    
    // Simuliere Login
    if (formData.email && formData.password) {
      localStorage.setItem('token', 'fake-jwt-token');
      localStorage.setItem('user', JSON.stringify({ email: formData.email }));
      alert('Login erfolgreich!');
      navigate('/');
    } else {
      alert('Bitte alle Felder ausfüllen');
    }
  };

  return (
    <div className="login">
      <h1>Login</h1>
      
      <form onSubmit={handleSubmit} className="login-form">
        <div className="form-group">
          <label htmlFor="email">E-Mail</label>
          <input
            type="email"
            id="email"
            name="email"
            value={formData.email}
            onChange={handleChange}
            required
          />
        </div>
        
        <div className="form-group">
          <label htmlFor="password">Passwort</label>
          <input
            type="password"
            id="password"
            name="password"
            value={formData.password}
            onChange={handleChange}
            required
          />
        </div>
        
        <button type="submit">Einloggen</button>
      </form>
    </div>
  );
}

export default Login;

16.7 Stile hinzufügen

🎨 App.css (Hauptstiele)

css
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', sans-serif;
  line-height: 1.6;
  color: #333;
  background-color: #f5f5f5;
}

.app {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
}

.main-content {
  flex: 1;
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px;
  width: 100%;
}

/* Header */
.header {
  background-color: #282c34;
  color: white;
  padding: 1rem 0;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

.header-content {
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 20px;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.logo {
  font-size: 1.5rem;
  font-weight: bold;
  color: white;
  text-decoration: none;
}

.nav {
  display: flex;
  gap: 20px;
}

.nav a {
  color: white;
  text-decoration: none;
  padding: 5px 10px;
  border-radius: 4px;
  transition: background-color 0.2s;
}

.nav a:hover {
  background-color: rgba(255,255,255,0.1);
}

.nav a.active {
  background-color: rgba(255,255,255,0.2);
}

/* Footer */
.footer {
  background-color: #282c34;
  color: white;
  padding: 1rem 0;
  text-align: center;
  margin-top: auto;
}

/* Blog Card */
.blog-card {
  background-color: white;
  border-radius: 8px;
  padding: 20px;
  margin-bottom: 20px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  transition: transform 0.2s, box-shadow 0.2s;
}

.blog-card:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}

.blog-title a {
  color: #282c34;
  text-decoration: none;
}

.blog-title a:hover {
  color: #007bff;
}

.blog-body {
  color: #666;
  margin: 10px 0;
}

.blog-meta {
  font-size: 0.9rem;
  color: #999;
}

/* Blog List */
.blog-list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  gap: 20px;
  margin-top: 20px;
}

/* Blog Detail */
.blog-detail {
  background-color: white;
  padding: 30px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

.back-button {
  background-color: #6c757d;
  color: white;
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
  cursor: pointer;
  margin-bottom: 20px;
}

.back-button:hover {
  background-color: #5a6268;
}

.blog-content {
  font-size: 1.1rem;
  line-height: 1.8;
  margin: 20px 0;
}

.comments-section {
  margin-top: 40px;
  padding-top: 20px;
  border-top: 1px solid #eee;
}

.comment {
  background-color: #f8f9fa;
  padding: 15px;
  border-radius: 4px;
  margin-bottom: 10px;
}

/* Forms */
.blog-form,
.login-form {
  background-color: white;
  padding: 30px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  max-width: 600px;
  margin: 0 auto;
}

.form-group {
  margin-bottom: 20px;
}

.form-group label {
  display: block;
  margin-bottom: 5px;
  font-weight: bold;
}

.form-group input,
.form-group textarea {
  width: 100%;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
}

.form-group textarea {
  resize: vertical;
}

button[type="submit"] {
  background-color: #007bff;
  color: white;
  border: none;
  padding: 12px 24px;
  border-radius: 4px;
  font-size: 1rem;
  cursor: pointer;
  width: 100%;
}

button[type="submit"]:hover {
  background-color: #0056b3;
}

button[type="submit"]:disabled {
  background-color: #ccc;
  cursor: not-allowed;
}

/* Loading & Error */
.loading,
.error {
  text-align: center;
  padding: 40px;
  font-size: 1.2rem;
}

.error {
  color: #dc3545;
}

16.8 Performance-Optimierung

⚡ memo für BlogCard

jsx
import { memo } from 'react';

const BlogCard = memo(function BlogCard({ blog }) {
  return (
    <div className="blog-card">
      <h3>{blog.title}</h3>
      <p>{blog.body.substring(0, 100)}...</p>
    </div>
  );
});

export default BlogCard;

⚡ useMemo für gefilterte Liste

jsx
import { useMemo } from 'react';

function Home() {
  const [posts, setPosts] = useState([]);
  const [search, setSearch] = useState('');

  const filteredPosts = useMemo(() => {
    return posts.filter(post =>
      post.title.toLowerCase().includes(search.toLowerCase())
    );
  }, [posts, search]);

  return (
    <div>
      <input 
        type="text" 
        value={search}
        onChange={(e) => setSearch(e.target.value)}
        placeholder="Blogs durchsuchen..."
      />
      
      <div className="blog-list">
        {filteredPosts.map(post => (
          <BlogCard key={post.id} blog={post} />
        ))}
      </div>
    </div>
  );
}

📝 Zusammenfassung

In diesem Kapitel haben wir gelernt:

  • ✅ Blog-System Projekt zu initialisieren
  • ✅ React Router für Navigation zu verwenden
  • ✅ Axios für API-Aufrufe zu verwenden
  • ✅ Custom Hooks zu erstellen (useFetch)
  • ✅ Verschiedene Seiten zu implementieren (Home, Detail, Create, Login)
  • ✅ Performance-Optimierung mit memo und useMemo

🎯 Nächste Schritte

Im nächsten Kapitel werden wir lernen:

  • Häufige Fehler und Fallstricke in React
  • Debugging-Techniken
  • Best Practices für Produktionscode

Bereit für Fehlerbehebung? → Kapitel 17: Häufige Fehler

Frei für alle Anfänger