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 {
|
||||
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