diff --git a/client/src/components/Dashboard.jsx b/client/src/components/Dashboard.jsx new file mode 100644 index 0000000..91d863e --- /dev/null +++ b/client/src/components/Dashboard.jsx @@ -0,0 +1,124 @@ +// src/components/Dashboard.jsx +import React, { useState, useEffect } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import axios from 'axios'; +import { format } from 'date-fns'; +import { useAuth } from '../contexts/AuthContext'; + +export default function Dashboard() { + const [posts, setPosts] = useState([]); + const [filter, setFilter] = useState('all'); // all, draft, published + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const { logout } = useAuth(); + const navigate = useNavigate(); + + useEffect(() => { + loadPosts(); + }, []); + + const loadPosts = async () => { + try { + const response = await axios.get(`${import.meta.env.VITE_API_URL}/api/posts`, { + headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } + }); + setPosts(response.data); + setError(null); + } catch (err) { + setError('Failed to load posts'); + console.error('Error loading posts:', err); + if (err.response?.status === 401) { + logout(); + navigate('/login'); + } + } finally { + setLoading(false); + } + }; + + const handleLogout = () => { + logout(); + navigate('/login'); + }; + + const filteredPosts = posts.filter(post => { + if (filter === 'all') return true; + return post.status === filter; + }); + + if (loading) return
Loading...
; + + return ( +
+
+

My Blog Posts

+
+ New Post + +
+
+ + {error &&
{error}
} + +
+ + + +
+ +
+ {filteredPosts.length === 0 ? ( +
+ No posts found. {filter === 'all' ? 'Create your first post!' : 'Try a different filter.'} +
+ ) : ( + filteredPosts.map(post => ( +
+
+

+ {post.title} +

+ + {post.status} + +
+ +
+ {post.published_at ? ( + + Published: {format(new Date(post.published_at), 'MMM d, yyyy')} + + ) : ( + Draft + )} +
+ + {post.tags && post.tags.length > 0 && ( +
+ {post.tags.map((tag, index) => ( + {tag.name} + ))} +
+ )} +
+ )) + )} +
+
+ ); +} \ No newline at end of file diff --git a/client/src/components/Editor.jsx b/client/src/components/Editor.jsx new file mode 100644 index 0000000..2f51e5e --- /dev/null +++ b/client/src/components/Editor.jsx @@ -0,0 +1,216 @@ +// src/components/Editor.jsx +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(); + + useEffect(() => { + if (id) { + loadPost(); + } + }, [id]); + + const loadPost = async () => { + try { + const response = await axios.get(`${import.meta.env.VITE_API_URL}/api/posts/${id}`, { + headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } + }); + const post = response.data; + setTitle(post.title); + setContent(post.markdown_content); + setStatus(post.status); + setPublishDate(post.published_at ? new Date(post.published_at).toISOString().slice(0, 16) : ''); + setTags(post.tags?.map(tag => tag.name) || []); + } catch (err) { + console.error('Error loading post:', err); + setError('Failed to load post'); + } + }; + + const handleSave = async (newStatus = status) => { + setSaving(true); + setError(''); + + const data = { + title, + content, + status: newStatus, + publishedAt: newStatus === 'published' ? (publishDate || new Date().toISOString()) : null, + tags + }; + + try { + if (id) { + await axios.put(`${import.meta.env.VITE_API_URL}/api/posts/${id}`, data, { + headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } + }); + } else { + await axios.post(`${import.meta.env.VITE_API_URL}/api/posts`, data, { + headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } + }); + } + navigate('/dashboard'); + } catch (err) { + console.error('Error saving post:', err); + setError('Failed to save post'); + } finally { + setSaving(false); + } + }; + + const handleImageUpload = async (file) => { + if (!id) { + setError('Please save the post first to upload images'); + return null; + } + + const formData = new FormData(); + formData.append('file', file); + + try { + const response = await axios.post( + `${import.meta.env.VITE_API_URL}/api/posts/${id}/images`, + formData, + { + headers: { + Authorization: `Bearer ${localStorage.getItem('token')}`, + 'Content-Type': 'multipart/form-data' + } + } + ); + return response.data.url; + } catch (err) { + console.error('Error uploading image:', err); + setError('Failed to upload image'); + return null; + } + }; + + const handleDrop = async (event) => { + event.preventDefault(); + const file = event.dataTransfer.files[0]; + if (file && file.type.startsWith('image/')) { + const imageUrl = await handleImageUpload(file); + if (imageUrl) { + const imageMarkdown = `![${file.name}](${imageUrl})`; + setContent(prev => prev + '\n' + imageMarkdown); + } + } + }; + + const handleAddTag = (e) => { + if (e.key === 'Enter' && tagInput.trim()) { + if (!tags.includes(tagInput.trim())) { + setTags([...tags, tagInput.trim()]); + } + setTagInput(''); + } + }; + + const handleRemoveTag = (tagToRemove) => { + setTags(tags.filter(tag => tag !== tagToRemove)); + }; + + return ( +
+
+ setTitle(e.target.value)} + className="title-input" + /> + +
+ + + +
+
+ + {error &&
{error}
} + + {status === 'published' && ( +
+ +
+ )} + +
+
+ {tags.map((tag, index) => ( + + {tag} + + + ))} +
+ setTagInput(e.target.value)} + onKeyPress={handleAddTag} + /> +
+ +
e.preventDefault()} + > + +
+
+ ); +} \ No newline at end of file diff --git a/client/src/styles/main.scss b/client/src/styles/main.scss index 8919b99..53f09e1 100644 --- a/client/src/styles/main.scss +++ b/client/src/styles/main.scss @@ -89,4 +89,296 @@ body { &:hover { background: darken($primary-color, 10%); } +} + + +.dashboard { + padding: 20px; + max-width: 1200px; + margin: 0 auto; +} + +.dashboard-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 30px; + + h1 { + margin: 0; + color: $primary-color; + } + + .actions { + display: flex; + gap: 10px; + + .button { + width: auto; + padding: 8px 16px; + + &.logout { + background-color: $error-color; + + &:hover { + background-color: darken($error-color, 10%); + } + } + } + } +} + +.filters { + margin-bottom: 20px; + display: flex; + gap: 10px; + + .filter-button { + padding: 8px 16px; + border: 1px solid $border-color; + background: white; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; + + &.active { + background: $secondary-color; + color: white; + border-color: $secondary-color; + } + + &:hover { + border-color: $secondary-color; + } + } +} + +.posts-list { + display: grid; + gap: 20px; +} + +.post-card { + background: white; + border-radius: 8px; + padding: 20px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + + .post-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 10px; + + h2 { + margin: 0; + font-size: 1.5rem; + + a { + color: $primary-color; + text-decoration: none; + + &:hover { + color: $secondary-color; + } + } + } + + .status { + padding: 4px 8px; + border-radius: 4px; + font-size: 0.875rem; + text-transform: capitalize; + + &.draft { + background: #f1f1f1; + color: #666; + } + + &.published { + background: #e3f2fd; + color: #1976d2; + } + } + } + + .post-meta { + color: #666; + font-size: 0.875rem; + margin-bottom: 10px; + } + + .tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + + .tag { + background: #f1f1f1; + padding: 4px 8px; + border-radius: 12px; + font-size: 0.875rem; + color: #666; + } + } +} + +.loading { + text-align: center; + padding: 40px; + color: $text-color; +} + +.error-message { + background: lighten($error-color, 35%); + color: $error-color; + padding: 10px; + border-radius: 4px; + margin-bottom: 20px; + text-align: center; +} + +.no-posts { + text-align: center; + padding: 40px; + background: white; + border-radius: 8px; + color: #666; +} + + +.editor { + max-width: 1200px; + margin: 0 auto; + padding: 20px; + + .editor-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + + .title-input { + flex: 1; + font-size: 1.5rem; + padding: 10px; + border: 1px solid $border-color; + border-radius: 4px; + margin-right: 20px; + + &:focus { + outline: none; + border-color: $secondary-color; + } + } + + .actions { + display: flex; + gap: 10px; + + .button { + width: auto; + padding: 8px 16px; + + &.cancel { + background-color: #95a5a6; + } + + &.draft { + background-color: $secondary-color; + } + + &.publish { + background-color: #27ae60; + } + + &:disabled { + opacity: 0.7; + cursor: not-allowed; + } + } + } + } + + .publish-settings { + margin-bottom: 20px; + padding: 15px; + background: #f8f9fa; + border-radius: 4px; + + label { + display: flex; + align-items: center; + gap: 10px; + + input { + padding: 8px; + border: 1px solid $border-color; + border-radius: 4px; + } + } + } + + .tags-input { + margin-bottom: 20px; + + .tags-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 10px; + } + + .tag { + display: inline-flex; + align-items: center; + background: $secondary-color; + color: white; + padding: 4px 8px; + border-radius: 16px; + font-size: 0.875rem; + + .remove-tag { + background: none; + border: none; + color: white; + margin-left: 6px; + cursor: pointer; + padding: 0 4px; + font-size: 1.2rem; + line-height: 1; + + &:hover { + opacity: 0.8; + } + } + } + + input { + width: 100%; + padding: 8px; + border: 1px solid $border-color; + border-radius: 4px; + + &:focus { + outline: none; + border-color: $secondary-color; + } + } + } + + .editor-container { + border: 1px solid $border-color; + border-radius: 4px; + overflow: hidden; + + .w-md-editor { + border: none; + } + } +} + +// Override some MD Editor default styles +.w-md-editor { + --md-editor-box-shadow: none !important; } \ No newline at end of file