Dashboard and Editor complete
This commit is contained in:
124
client/src/components/Dashboard.jsx
Normal file
124
client/src/components/Dashboard.jsx
Normal file
@@ -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 <div className="loading">Loading...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dashboard">
|
||||||
|
<header className="dashboard-header">
|
||||||
|
<h1>My Blog Posts</h1>
|
||||||
|
<div className="actions">
|
||||||
|
<Link to="/new" className="button">New Post</Link>
|
||||||
|
<button onClick={handleLogout} className="button logout">Logout</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error && <div className="error-message">{error}</div>}
|
||||||
|
|
||||||
|
<div className="filters">
|
||||||
|
<button
|
||||||
|
className={`filter-button ${filter === 'all' ? 'active' : ''}`}
|
||||||
|
onClick={() => setFilter('all')}
|
||||||
|
>
|
||||||
|
All Posts
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`filter-button ${filter === 'draft' ? 'active' : ''}`}
|
||||||
|
onClick={() => setFilter('draft')}
|
||||||
|
>
|
||||||
|
Drafts
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`filter-button ${filter === 'published' ? 'active' : ''}`}
|
||||||
|
onClick={() => setFilter('published')}
|
||||||
|
>
|
||||||
|
Published
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="posts-list">
|
||||||
|
{filteredPosts.length === 0 ? (
|
||||||
|
<div className="no-posts">
|
||||||
|
No posts found. {filter === 'all' ? 'Create your first post!' : 'Try a different filter.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredPosts.map(post => (
|
||||||
|
<div key={post.id} className="post-card">
|
||||||
|
<div className="post-header">
|
||||||
|
<h2>
|
||||||
|
<Link to={`/edit/${post.id}`}>{post.title}</Link>
|
||||||
|
</h2>
|
||||||
|
<span className={`status ${post.status}`}>
|
||||||
|
{post.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="post-meta">
|
||||||
|
{post.published_at ? (
|
||||||
|
<span className="date">
|
||||||
|
Published: {format(new Date(post.published_at), 'MMM d, yyyy')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="date">Draft</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{post.tags && post.tags.length > 0 && (
|
||||||
|
<div className="tags">
|
||||||
|
{post.tags.map((tag, index) => (
|
||||||
|
<span key={index} className="tag">{tag.name}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
216
client/src/components/Editor.jsx
Normal file
216
client/src/components/Editor.jsx
Normal file
@@ -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 = ``;
|
||||||
|
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 (
|
||||||
|
<div className="editor">
|
||||||
|
<div className="editor-header">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Post title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="title-input"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="actions">
|
||||||
|
<button
|
||||||
|
className="button cancel"
|
||||||
|
onClick={() => navigate('/dashboard')}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button draft"
|
||||||
|
onClick={() => handleSave('draft')}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Save as Draft
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button publish"
|
||||||
|
onClick={() => handleSave('published')}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{status === 'published' ? 'Update' : 'Publish'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error-message">{error}</div>}
|
||||||
|
|
||||||
|
{status === 'published' && (
|
||||||
|
<div className="publish-settings">
|
||||||
|
<label>
|
||||||
|
Publication Date:
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={publishDate}
|
||||||
|
onChange={(e) => setPublishDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="tags-input">
|
||||||
|
<div className="tags-list">
|
||||||
|
{tags.map((tag, index) => (
|
||||||
|
<span key={index} className="tag">
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveTag(tag)}
|
||||||
|
className="remove-tag"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Add tags (press Enter)"
|
||||||
|
value={tagInput}
|
||||||
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
|
onKeyPress={handleAddTag}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="editor-container"
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<MDEditor
|
||||||
|
value={content}
|
||||||
|
onChange={setContent}
|
||||||
|
preview="live"
|
||||||
|
height={500}
|
||||||
|
textareaProps={{
|
||||||
|
placeholder: 'Write your post in Markdown...'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -89,4 +89,296 @@ body {
|
|||||||
&:hover {
|
&:hover {
|
||||||
background: darken($primary-color, 10%);
|
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;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user