Dashboard and Editor complete

This commit is contained in:
2024-12-01 17:21:46 +00:00
parent 0ba95b0dab
commit d1d469d16d
3 changed files with 632 additions and 0 deletions

View 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>
);
}

View 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 = `![${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 (
<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>
);
}

View File

@@ -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;
}