Literate comments for Editor.jsx

This commit is contained in:
2024-12-01 18:00:36 +00:00
parent 225cae73b5
commit 1ba29ce6cd

View File

@@ -1,87 +1,327 @@
// src/components/Editor.jsx
/**
* @fileoverview Editor Component - A comprehensive blog post editor implementation
*
* This file implements a full-featured Markdown blog post editor using React hooks and modern web APIs.
* It serves as an example of several important React patterns and concepts:
*
* 1. Complex Form State Management:
* - Multiple interconnected form fields
* - Derived state calculations
* - Form validation and error handling
*
* 2. Side Effects and Data Fetching:
* - useEffect for data loading
* - API integration with axios
* - Error boundary implementation
*
* 3. File Handling:
* - Drag and drop API implementation
* - File upload with FormData
* - Image processing and markdown integration
*
* 4. State Management Patterns:
* - Multiple useState hooks for granular updates
* - Derived state calculations
* - State update batching
*
* The component demonstrates best practices for:
* - React hooks usage
* - Error handling
* - API integration
* - User input processing
* - Real-time updates
* - Form state management
*/
import React, { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import MDEditor from '@uiw/react-md-editor';
import axios from 'axios';
export default function Editor() {
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [status, setStatus] = useState('draft');
const [publishDate, setPublishDate] = useState('');
const [tags, setTags] = useState([]);
const [tagInput, setTagInput] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const navigate = useNavigate();
const { id } = useParams();
/**
* @typedef {Object} Post - Represents a blog post in our system
* @property {string} title - The post's title
* @property {string} markdown_content - The post's content in Markdown format
* @property {('draft'|'published')} status - Current status of the post
* @property {string} published_at - ISO timestamp of publication
* @property {Array<{name: string}>} tags - Array of tag objects
*
* Understanding the Post Type:
* - title: User-provided string, no length restrictions enforced in type
* - markdown_content: Raw markdown string, can include images, code blocks, etc.
* - status: Union type restricting values to either 'draft' or 'published'
* - published_at: ISO8601 timestamp string (e.g., "2024-01-01T12:00:00Z")
* - tags: Array of objects, each with a name property
*/
/**
* Editor Component Implementation
*
* This component manages the entire editing experience for a blog post.
* It handles both creation of new posts and editing of existing ones through
* a single interface, determining its mode based on the presence of an ID parameter.
*
* State Management Overview:
* - Form Fields: title, content, status, publishDate
* - UI State: saving, error
* - Tag Management: tags, tagInput
*
* The component uses URL parameters to determine if it's editing an existing post,
* with all state initialized to empty/default values and populated via useEffect
* if an ID is present.
*
* Key Technical Concepts:
* 1. React Router Integration:
* - useNavigate: Programmatic navigation
* - useParams: URL parameter extraction
*
* 2. State Management:
* - Multiple useState hooks for granular updates
* - Derived state calculations
* - Optimistic updates
*
* 3. Side Effects:
* - Data fetching
* - Error handling
* - Cleanup
*
* @component
*/
export default function Editor() {
/**
* State Hooks Section
*
* This component uses multiple useState hooks rather than a single state object.
* This decision was made to:
* 1. Allow for granular updates without re-rendering unrelated parts
* 2. Simplify state updates by avoiding spread operator usage
* 3. Make state dependencies clearer in useEffect hooks
*/
// Content Management States
const [title, setTitle] = useState(''); // Main post title
const [content, setContent] = useState(''); // Markdown content
const [status, setStatus] = useState('draft'); // Publication status
const [publishDate, setPublishDate] = useState(''); // Scheduled publish date
// Tag Management States
const [tags, setTags] = useState([]); // Array of current tags
const [tagInput, setTagInput] = useState(''); // Current tag input value
// UI State Management
const [saving, setSaving] = useState(false); // Tracks save operation status
const [error, setError] = useState(''); // Holds error messages
// Router Hooks
const navigate = useNavigate(); // Programmatic navigation
const { id } = useParams(); // Extract post ID from URL if editing
/**
* Post Loading Effect
*
* This effect runs when the component mounts and whenever the ID changes.
* It's responsible for loading existing post data when in edit mode.
*
* Key Concepts:
* 1. Conditional Execution:
* Only runs if an ID is present (edit mode)
*
* 2. Dependency Array:
* [id] ensures the effect reruns if the URL parameter changes
*
* 3. Error Handling:
* Errors during loading are caught and displayed to the user
*/
useEffect(() => {
if (id) {
loadPost();
}
}, [id]);
/**
* Post Loading Implementation
*
* Fetches and populates form data for an existing post.
*
* Technical Implementation Details:
* 1. Authentication:
* - Sends Bearer token in Authorization header
* - Token is retrieved from localStorage
*
* 2. Data Transformation:
* - Converts API response format to component state format
* - Handles potential missing data gracefully
*
* 3. Error Handling:
* - Catches and logs API errors
* - Updates UI error state for user feedback
*
* @async
* @throws {Error} When API request fails
*/
const loadPost = async () => {
try {
const response = await axios.get(`${import.meta.env.VITE_API_URL}/api/posts/${id}`, {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
});
// API Request Configuration
const response = await axios.get(
`${import.meta.env.VITE_API_URL}/api/posts/${id}`,
{
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`
}
}
);
// Destructure and Transform API Response
const post = response.data;
// Update Component State
// Each setter is called individually to:
// 1. Maintain clear data flow
// 2. Allow React to batch updates
// 3. Keep transformations clear and separated
setTitle(post.title);
setContent(post.markdown_content);
setStatus(post.status);
setPublishDate(post.published_at ? new Date(post.published_at).toISOString().slice(0, 16) : '');
// Transform publication date
// - Converts ISO string to local datetime-local input format
// - Handles null/undefined cases with empty string
setPublishDate(
post.published_at
? new Date(post.published_at).toISOString().slice(0, 16)
: ''
);
// Transform tags from API format to component format
// - Maps array of objects to array of strings
// - Handles null/undefined with empty array fallback
setTags(post.tags?.map(tag => tag.name) || []);
} catch (err) {
// Error Handling
console.error('Error loading post:', err);
setError('Failed to load post');
}
};
/**
* Post Saving Implementation
*
* Handles both creating new posts and updating existing ones.
*
* Technical Design:
* 1. State Management:
* - Sets saving flag for UI feedback
* - Clears previous errors before attempt
* - Updates UI state after completion
*
* 2. Data Preparation:
* - Constructs API payload
* - Handles publication date logic
* - Manages tags format
*
* 3. API Integration:
* - Determines POST vs PUT based on ID presence
* - Handles authentication
* - Processes response
*
* @param {string} newStatus - Target status ('draft' or 'published')
* @throws {Error} When API request fails
*/
const handleSave = async (newStatus = status) => {
setSaving(true);
setError('');
// Update UI State
setSaving(true); // Disable save buttons
setError(''); // Clear previous errors
// Prepare API Payload
const data = {
title,
content,
status: newStatus,
publishedAt: newStatus === 'published' ? (publishDate || new Date().toISOString()) : null,
// Publication Date Logic:
// 1. If publishing, use provided date or current time
// 2. If draft, set to null
publishedAt: newStatus === 'published'
? (publishDate || new Date().toISOString())
: null,
tags
};
try {
// API Request Configuration
const headers = {
Authorization: `Bearer ${localStorage.getItem('token')}`
};
// Determine API Action:
// - PUT request for existing posts (id present)
// - POST request for new posts (no id)
if (id) {
await axios.put(`${import.meta.env.VITE_API_URL}/api/posts/${id}`, data, {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
});
await axios.put(
`${import.meta.env.VITE_API_URL}/api/posts/${id}`,
data,
{ headers }
);
} else {
await axios.post(`${import.meta.env.VITE_API_URL}/api/posts`, data, {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
});
await axios.post(
`${import.meta.env.VITE_API_URL}/api/posts`,
data,
{ headers }
);
}
// Success: Navigate to dashboard
navigate('/dashboard');
} catch (err) {
// Error Handling
console.error('Error saving post:', err);
setError('Failed to save post');
} finally {
// Reset UI State
setSaving(false);
}
};
/**
* Image Upload Handler
*
* Manages the process of uploading images to the server and
* returning the URL for markdown insertion.
*
* Technical Implementation:
* 1. FormData Usage:
* - Constructs multipart/form-data payload
* - Handles file data properly
*
* 2. Error Handling:
* - Validates post existence
* - Handles API errors
* - Provides user feedback
*
* 3. Security:
* - Sends authentication token
* - Uses proper content type headers
*
* @param {File} file - The image file to upload
* @returns {Promise<string|null>} The uploaded image URL or null if failed
*/
const handleImageUpload = async (file) => {
// Validate Post Existence
// Images must be attached to an existing post
if (!id) {
setError('Please save the post first to upload images');
return null;
}
// Prepare FormData
const formData = new FormData();
formData.append('file', file);
try {
// Configure API Request
const response = await axios.post(
`${import.meta.env.VITE_API_URL}/api/posts/${id}/images`,
`${import.meta.env.VITE_API_URL}/api/posts/${id}/images`,
formData,
{
headers: {
@@ -90,117 +330,311 @@ export default function Editor() {
}
}
);
// Return Image URL
return response.data.url;
} catch (err) {
// Error Handling
console.error('Error uploading image:', err);
setError('Failed to upload image');
return null;
}
};
/**
* Drag and Drop Handler
*
* Implements the HTML5 Drag and Drop API for image uploads.
*
* Technical Details:
* 1. Event Handling:
* - Prevents default browser behavior
* - Extracts file from drop event
*
* 2. File Processing:
* - Validates file type
* - Triggers upload
* - Inserts markdown
*
* 3. Content Updates:
* - Maintains existing content
* - Adds image markdown at cursor position
*
* @param {DragEvent} event - The drop event object
*/
const handleDrop = async (event) => {
// Prevent default browser handling
event.preventDefault();
// Extract and validate file
const file = event.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) {
// Upload image and get URL
const imageUrl = await handleImageUpload(file);
if (imageUrl) {
// Create and insert markdown
const imageMarkdown = `![${file.name}](${imageUrl})`;
setContent(prev => prev + '\n' + imageMarkdown);
}
}
};
/**
* Tag Addition Handler
*
* Manages the addition of new tags to the post.
*
* Implementation Details:
* 1. Input Validation:
* - Checks for Enter key
* - Validates tag content
* - Prevents duplicates
*
* 2. State Updates:
* - Adds new tag to array
* - Clears input field
*
* @param {KeyboardEvent} e - The keyboard event object
*/
const handleAddTag = (e) => {
if (e.key === 'Enter' && tagInput.trim()) {
// Check for duplicate tags
if (!tags.includes(tagInput.trim())) {
// Update tags array
setTags([...tags, tagInput.trim()]);
}
// Clear input field
setTagInput('');
}
};
/**
* Tag Removal Handler
*
* Manages the removal of tags from the post.
*
* Implementation Details:
* 1. Array Filtering:
* - Creates new array without removed tag
* - Maintains tag order
*
* 2. State Update:
* - Updates tags array immutably
*
* @param {string} tagToRemove - The tag to remove
*/
const handleRemoveTag = (tagToRemove) => {
setTags(tags.filter(tag => tag !== tagToRemove));
};
/**
* Component Render Method
*
* Renders the editor interface with all its features.
* The JSX is organized into logical sections:
* 1. Header with title and actions
* 2. Error display
* 3. Publication settings
* 4. Tag management
* 5. Markdown editor
*
* Key Design Decisions:
* 1. Semantic HTML:
* - Proper heading hierarchy
* - Semantic grouping
* - Accessibility considerations
*
* 2. Component Organization:
* - Logical grouping of related elements
* - Clear visual hierarchy
* - Consistent spacing
*
* 3. Event Handling:
* - Centralized handlers
* - Proper event delegation
* - Performance considerations
*/
return (
<div className="editor">
{/* Header Section
Groups title input and action buttons
Uses flexbox for layout control */}
<div className="editor-header">
<input
{/* Title Input
Full-width input for post title
Updates state directly on change
<input
type="text"
placeholder="Post title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="title-input"
/* Input Properties Explained:
* - type="text": Standard text input for single-line content
* - placeholder: Provides user guidance when empty
* - value: Controlled component pattern implementation
* - onChange: Direct state updates for immediate UI feedback
* - className: Styling hook for CSS customization
*/
/>
{/* Action Buttons Section
Groups all post management buttons
Uses flex container for button alignment */}
<div className="actions">
{/* Cancel Button
Returns user to dashboard without saving
Immediate navigation with no confirmation */}
<button
className="button cancel"
onClick={() => navigate('/dashboard')}
/* Button Properties:
* - className: Combines generic and specific styles
* - onClick: Direct navigation without state cleanup
* Navigation is immediate since we don't persist drafts
*/
>
Cancel
</button>
{/* Draft Save Button
Saves current state as draft
Disabled during save operations */}
<button
className="button draft"
onClick={() => handleSave('draft')}
disabled={saving}
/* Button Properties:
* - onClick: Calls handleSave with explicit 'draft' status
* - disabled: Prevents double-submission during save
* Uses 'draft' status to ensure proper API handling
*/
>
Save as Draft
</button>
{/* Publish/Update Button
Context-aware label based on current status
Handles both initial publish and updates */}
<button
className="button publish"
onClick={() => handleSave('published')}
disabled={saving}
/* Button Properties:
* - onClick: Calls handleSave with 'published' status
* - disabled: Prevents double-submission
* Label changes based on current status for clear UX
*/
>
{status === 'published' ? 'Update' : 'Publish'}
</button>
</div>
</div>
{error && <div className="error-message">{error}</div>}
{/* Error Display Section
Conditionally renders error messages
Uses dedicated styling for error states */}
{error && <div className="error-message">
{/* Error message display
* - Conditional rendering with && operator
* - Direct error state display
* - Semantically marked as error for accessibility
*/}
{error}
</div>}
{/* Publication Settings Section
Only visible for published posts
Handles scheduled publishing functionality */}
{status === 'published' && (
<div className="publish-settings">
{/* DateTime Input Field
Allows scheduling of publication
Uses HTML5 datetime-local input */}
<label>
Publication Date:
<input
type="datetime-local"
value={publishDate}
onChange={(e) => setPublishDate(e.target.value)}
/* Input Properties:
* - type: HTML5 datetime-local for native date/time picking
* - value: Controlled component pattern
* - onChange: Direct state updates
* Format: YYYY-MM-DDThh:mm (HTML5 standard)
*/
/>
</label>
</div>
)}
{/* Tags Management Section
Handles both tag display and input
Implements add/remove tag functionality */}
<div className="tags-input">
{/* Tags Display List
Shows all current tags with remove buttons
Uses flex layout for responsive tag flow */}
<div className="tags-list">
{tags.map((tag, index) => (
<span key={index} className="tag">
{/* Individual Tag Display
* - key: Uses index as fallback unique identifier
* - className: Styling hook for tag appearance
* Structure: tag text + remove button
*/}
{tag}
<button
onClick={() => handleRemoveTag(tag)}
className="remove-tag"
/* Button Properties:
* - onClick: Calls removal handler with specific tag
* - className: Styling for remove button
* Uses × symbol for clear delete action
*/
>
×
</button>
</span>
))}
</div>
{/* Tag Input Field
Handles new tag entry
Implements enter-to-add functionality */}
<input
type="text"
placeholder="Add tags (press Enter)"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleAddTag}
/* Input Properties:
* - type: Standard text input for tag entry
* - placeholder: User instruction for tag addition
* - value: Controlled component pattern
* - onChange: Direct state updates
* - onKeyDown: Handles Enter key for tag addition
*/
/>
</div>
{/* Markdown Editor Section
Main content editing area
Implements drag-and-drop image upload */}
<div
className="editor-container"
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
/* Container Properties:
* - className: Styling hook for editor container
* - onDrop: Handles image file drops
* - onDragOver: Prevents default drag behavior
* Implements HTML5 drag and drop API
*/
>
{/* MDEditor Component
Third-party markdown editor with preview
Implements GitHub-flavored markdown */}
<MDEditor
value={content}
onChange={setContent}
@@ -209,6 +643,14 @@ export default function Editor() {
textareaProps={{
placeholder: 'Write your post in Markdown...'
}}
/* Editor Properties:
* - value: Current markdown content
* - onChange: Direct content updates
* - preview: Live markdown preview mode
* - height: Fixed editor height
* - textareaProps: Configuration for input area
* Uses controlled component pattern
*/
/>
</div>
</div>