- Initialize Git repository with main branch - Create comprehensive .gitignore for Node.js, React, and environment files - Set up directory structure (frontend/, backend/, docs/) - Create detailed README.md with project overview and setup instructions - Add .env.example with all required environment variables - Configure Prettier for consistent code formatting All acceptance criteria met: ✅ Git repository initialized with appropriate .gitignore ✅ Directory structure matches Technical Assumptions ✅ README.md created with project overview and setup docs ✅ .env.example file with all required environment variables ✅ Prettier config files added for code formatting consistency 🤖 Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
390 lines
9.8 KiB
Markdown
390 lines
9.8 KiB
Markdown
# Frontend Architecture
|
|
|
|
## Component Architecture
|
|
|
|
### Component Organization
|
|
|
|
```
|
|
frontend/src/
|
|
├── components/
|
|
│ ├── layout/
|
|
│ │ ├── Header.jsx
|
|
│ │ ├── Footer.jsx
|
|
│ │ └── Layout.jsx
|
|
│ ├── books/
|
|
│ │ ├── BookCard.jsx
|
|
│ │ ├── BookList.jsx
|
|
│ │ ├── BookSearchResults.jsx
|
|
│ │ └── AddBookForm.jsx
|
|
│ ├── progress/
|
|
│ │ ├── LogProgressModal.jsx
|
|
│ │ ├── ProgressDisplay.jsx
|
|
│ │ └── StatusIndicator.jsx
|
|
│ ├── calendar/
|
|
│ │ ├── Calendar.jsx
|
|
│ │ ├── CalendarDay.jsx
|
|
│ │ └── CalendarLegend.jsx
|
|
│ └── common/
|
|
│ ├── Button.jsx
|
|
│ ├── Input.jsx
|
|
│ ├── Modal.jsx
|
|
│ ├── Loading.jsx
|
|
│ └── ErrorMessage.jsx
|
|
├── pages/
|
|
│ ├── Home.jsx
|
|
│ ├── AddBook.jsx
|
|
│ ├── BookDetail.jsx
|
|
│ └── CalendarView.jsx
|
|
├── hooks/
|
|
│ ├── useBooks.js
|
|
│ ├── useProgress.js
|
|
│ └── useLogs.js
|
|
├── services/
|
|
│ ├── api.js (API client setup)
|
|
│ ├── booksService.js
|
|
│ └── logsService.js
|
|
├── context/
|
|
│ └── AppContext.jsx
|
|
├── utils/
|
|
│ ├── dateUtils.js
|
|
│ ├── paceUtils.js
|
|
│ └── validation.js
|
|
├── styles/
|
|
│ └── index.css (Tailwind directives)
|
|
└── App.jsx
|
|
```
|
|
|
|
### Component Template
|
|
|
|
```typescript
|
|
// Example: BookCard.jsx
|
|
import React from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import StatusIndicator from '../progress/StatusIndicator';
|
|
import { formatDate } from '../../utils/dateUtils';
|
|
|
|
/**
|
|
* BookCard - Displays a single book with progress metrics
|
|
* @param {Object} book - Book object with progress data
|
|
* @param {Function} onLogProgress - Handler for log progress action
|
|
*/
|
|
function BookCard({ book, onLogProgress }) {
|
|
const navigate = useNavigate();
|
|
|
|
const handleCardClick = () => {
|
|
navigate(`/books/${book.id}`);
|
|
};
|
|
|
|
const handleLogClick = (e) => {
|
|
e.stopPropagation(); // Prevent navigation when clicking log button
|
|
onLogProgress(book);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
onClick={handleCardClick}
|
|
className="bg-white rounded-lg shadow-md p-4 cursor-pointer hover:shadow-lg transition-shadow"
|
|
>
|
|
{/* Book cover and title */}
|
|
<div className="flex gap-4">
|
|
{book.coverUrl ? (
|
|
<img
|
|
src={book.coverUrl}
|
|
alt={book.title}
|
|
className="w-16 h-24 object-cover rounded"
|
|
/>
|
|
) : (
|
|
<div className="w-16 h-24 bg-gray-200 rounded flex items-center justify-center">
|
|
<span className="text-gray-400 text-xs">No cover</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex-1">
|
|
<h3 className="font-bold text-lg">{book.title}</h3>
|
|
{book.author && <p className="text-gray-600 text-sm">{book.author}</p>}
|
|
<p className="text-gray-500 text-xs mt-1">
|
|
Due: {formatDate(book.deadlineDate)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress metrics */}
|
|
<div className="mt-4 space-y-2">
|
|
<StatusIndicator status={book.status} />
|
|
<div className="text-sm">
|
|
<p><strong>Target:</strong> {book.requiredPace.toFixed(1)} pages/day</p>
|
|
<p>
|
|
<strong>Your pace:</strong> {' '}
|
|
{book.actualPace ? `${book.actualPace.toFixed(1)} pages/day` : 'No data yet'}
|
|
</p>
|
|
<p className="text-gray-600">
|
|
{book.pagesRemaining} pages left, {book.daysRemaining} days
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Log progress button */}
|
|
<button
|
|
onClick={handleLogClick}
|
|
className="mt-4 w-full bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600 transition-colors"
|
|
>
|
|
Log Progress
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default BookCard;
|
|
```
|
|
|
|
## State Management Architecture
|
|
|
|
### State Structure
|
|
|
|
```typescript
|
|
// context/AppContext.jsx
|
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
|
import { booksService } from '../services/booksService';
|
|
|
|
const AppContext = createContext();
|
|
|
|
export function AppProvider({ children }) {
|
|
const [books, setBooks] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
|
|
// Fetch books on mount
|
|
useEffect(() => {
|
|
loadBooks();
|
|
}, []);
|
|
|
|
const loadBooks = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const data = await booksService.getActiveBooks();
|
|
setBooks(data.books);
|
|
} catch (err) {
|
|
setError(err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const addBook = async (bookData) => {
|
|
const newBook = await booksService.addBook(bookData);
|
|
setBooks(prev => [...prev, newBook]);
|
|
return newBook;
|
|
};
|
|
|
|
const deleteBook = async (bookId) => {
|
|
await booksService.deleteBook(bookId);
|
|
setBooks(prev => prev.filter(b => b.id !== bookId));
|
|
};
|
|
|
|
const logProgress = async (bookId, currentPage, logDate) => {
|
|
await booksService.logProgress(bookId, currentPage, logDate);
|
|
// Refresh books to get updated progress
|
|
await loadBooks();
|
|
};
|
|
|
|
const value = {
|
|
books,
|
|
loading,
|
|
error,
|
|
loadBooks,
|
|
addBook,
|
|
deleteBook,
|
|
logProgress,
|
|
};
|
|
|
|
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
|
}
|
|
|
|
export function useApp() {
|
|
const context = useContext(AppContext);
|
|
if (!context) {
|
|
throw new Error('useApp must be used within AppProvider');
|
|
}
|
|
return context;
|
|
}
|
|
```
|
|
|
|
### State Management Patterns
|
|
|
|
- **Global State:** Books list, loading states, errors (via React Context)
|
|
- **Local Component State:** Form inputs, modal open/closed, UI-only state (via useState)
|
|
- **Server State:** Fetched data cached in Context, refetch on mutations
|
|
- **No External State Library:** Context API sufficient for MVP (books list + UI state)
|
|
- **State Updates:** Optimistic UI updates followed by data refetch for consistency
|
|
|
|
## Routing Architecture
|
|
|
|
### Route Organization
|
|
|
|
```
|
|
Routes:
|
|
/ → Home (Book List Screen)
|
|
/add-book → Add Book Screen
|
|
/books/:bookId → Book Detail Screen
|
|
/calendar → Calendar View Screen (optional, may be in Book Detail)
|
|
/* → 404 Not Found
|
|
```
|
|
|
|
### Protected Route Pattern
|
|
|
|
```typescript
|
|
// App.jsx with React Router
|
|
import React from 'react';
|
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
|
import { AppProvider } from './context/AppContext';
|
|
import Layout from './components/layout/Layout';
|
|
import Home from './pages/Home';
|
|
import AddBook from './pages/AddBook';
|
|
import BookDetail from './pages/BookDetail';
|
|
import CalendarView from './pages/CalendarView';
|
|
import NotFound from './pages/NotFound';
|
|
|
|
function App() {
|
|
return (
|
|
<BrowserRouter>
|
|
<AppProvider>
|
|
<Layout>
|
|
<Routes>
|
|
<Route path="/" element={<Home />} />
|
|
<Route path="/add-book" element={<AddBook />} />
|
|
<Route path="/books/:bookId" element={<BookDetail />} />
|
|
<Route path="/calendar" element={<CalendarView />} />
|
|
<Route path="*" element={<NotFound />} />
|
|
</Routes>
|
|
</Layout>
|
|
</AppProvider>
|
|
</BrowserRouter>
|
|
);
|
|
}
|
|
|
|
export default App;
|
|
```
|
|
|
|
**Note:** No protected routes needed for MVP (single-user, no authentication). Add in v1.1 if multi-user support is needed.
|
|
|
|
## Frontend Services Layer
|
|
|
|
### API Client Setup
|
|
|
|
```typescript
|
|
// services/api.js
|
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
|
|
|
|
class ApiClient {
|
|
async request(endpoint, options = {}) {
|
|
const url = `${API_BASE_URL}${endpoint}`;
|
|
const config = {
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...options.headers,
|
|
},
|
|
...options,
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(url, config);
|
|
|
|
// Handle non-JSON responses (like 204 No Content)
|
|
if (response.status === 204) {
|
|
return null;
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
throw new Error(data.error || 'Request failed');
|
|
}
|
|
|
|
return data;
|
|
} catch (error) {
|
|
console.error('API request failed:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
get(endpoint) {
|
|
return this.request(endpoint, { method: 'GET' });
|
|
}
|
|
|
|
post(endpoint, body) {
|
|
return this.request(endpoint, {
|
|
method: 'POST',
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
put(endpoint, body) {
|
|
return this.request(endpoint, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
delete(endpoint) {
|
|
return this.request(endpoint, { method: 'DELETE' });
|
|
}
|
|
}
|
|
|
|
export const apiClient = new ApiClient();
|
|
```
|
|
|
|
### Service Example
|
|
|
|
```typescript
|
|
// services/booksService.js
|
|
import { apiClient } from './api';
|
|
|
|
export const booksService = {
|
|
// Search books via Open Library
|
|
async searchBooks(query) {
|
|
return apiClient.get(`/books/search?q=${encodeURIComponent(query)}`);
|
|
},
|
|
|
|
// Get all active books with progress
|
|
async getActiveBooks() {
|
|
return apiClient.get('/books');
|
|
},
|
|
|
|
// Add a new book
|
|
async addBook(bookData) {
|
|
return apiClient.post('/books', bookData);
|
|
},
|
|
|
|
// Get single book
|
|
async getBook(bookId) {
|
|
return apiClient.get(`/books/${bookId}`);
|
|
},
|
|
|
|
// Delete book
|
|
async deleteBook(bookId) {
|
|
return apiClient.delete(`/books/${bookId}`);
|
|
},
|
|
|
|
// Get book progress
|
|
async getProgress(bookId) {
|
|
return apiClient.get(`/books/${bookId}/progress`);
|
|
},
|
|
|
|
// Get reading logs
|
|
async getLogs(bookId) {
|
|
return apiClient.get(`/books/${bookId}/logs`);
|
|
},
|
|
|
|
// Log progress
|
|
async logProgress(bookId, currentPage, logDate = null) {
|
|
return apiClient.post(`/books/${bookId}/logs`, {
|
|
currentPage,
|
|
...(logDate && { logDate }),
|
|
});
|
|
},
|
|
};
|
|
```
|
|
|
|
---
|