Created server and client
This commit is contained in:
47
client/src/App.js
Normal file
47
client/src/App.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import Login from './components/Login';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import Editor from './components/Editor';
|
||||
|
||||
function PrivateRoute({ children }) {
|
||||
const token = localStorage.getItem('token');
|
||||
return token ? children : <Navigate to="/login" />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Router>
|
||||
<div className="container">
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Dashboard />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/new"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Editor />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/edit/:id"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Editor />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to="/dashboard" />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
82
client/src/components/Dashboard.js
Normal file
82
client/src/components/Dashboard.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
export default function Dashboard() {
|
||||
const [posts, setPosts] = useState([]);
|
||||
const [filter, setFilter] = useState('all'); // all, draft, published
|
||||
|
||||
useEffect(() => {
|
||||
loadPosts();
|
||||
}, []);
|
||||
|
||||
const loadPosts = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/posts', {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
setPosts(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error loading posts:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredPosts = posts.filter(post => {
|
||||
if (filter === 'all') return true;
|
||||
return post.status === filter;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<div className="dashboard-header">
|
||||
<h1>My Posts</h1>
|
||||
<Link to="/new" className="button">New Post</Link>
|
||||
</div>
|
||||
|
||||
<div className="filters">
|
||||
<button
|
||||
className={`filter ${filter === 'all' ? 'active' : ''}`}
|
||||
onClick={() => setFilter('all')}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
className={`filter ${filter === 'draft' ? 'active' : ''}`}
|
||||
onClick={() => setFilter('draft')}
|
||||
>
|
||||
Drafts
|
||||
</button>
|
||||
<button
|
||||
className={`filter ${filter === 'published' ? 'active' : ''}`}
|
||||
onClick={() => setFilter('published')}
|
||||
>
|
||||
Published
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="posts-list">
|
||||
{filteredPosts.map(post => (
|
||||
<div key={post.id} className="post-item">
|
||||
<div className="post-title">
|
||||
<Link to={`/edit/${post.id}`}>{post.title}</Link>
|
||||
<span className={`status ${post.status}`}>{post.status}</span>
|
||||
</div>
|
||||
<div className="post-meta">
|
||||
<span className="date">
|
||||
{post.published_at
|
||||
? format(new Date(post.published_at), 'MMM d, yyyy')
|
||||
: 'Not published'}
|
||||
</span>
|
||||
<div className="tags">
|
||||
{post.tags.map((tag, index) => (
|
||||
<span key={index} className="tag">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
client/src/components/Editor.js
Normal file
154
client/src/components/Editor.js
Normal file
@@ -0,0 +1,154 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import MDEditor from '@uiw/react-md-editor';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
|
||||
export default function Editor() {
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [tags, setTags] = useState([]);
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const [publishDate, setPublishDate] = useState('');
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
loadPost();
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const loadPost = async () => {
|
||||
const response = await axios.get(`/api/posts/${id}`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
const { title, markdown_content, tags, published_at } = response.data;
|
||||
setTitle(title);
|
||||
setContent(markdown_content);
|
||||
setTags(tags);
|
||||
setPublishDate(published_at);
|
||||
};
|
||||
|
||||
const handleSave = async (status) => {
|
||||
const data = {
|
||||
title,
|
||||
content,
|
||||
tags,
|
||||
status,
|
||||
publishedAt: publishDate || new Date().toISOString()
|
||||
};
|
||||
|
||||
try {
|
||||
if (id) {
|
||||
await axios.put(`/api/posts/${id}`, data, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
} else {
|
||||
await axios.post('/api/posts', data, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
}
|
||||
navigate('/dashboard');
|
||||
} catch (error) {
|
||||
console.error('Error saving post:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageUpload = async (file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
try {
|
||||
const response = await axios.post(`/api/images/${id}`, formData, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
return response.data.url;
|
||||
} catch (error) {
|
||||
console.error('Error uploading image:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="post-editor">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Post title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={publishDate}
|
||||
onChange={(e) => setPublishDate(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="tags-input">
|
||||
{tags.map((tag, index) => (
|
||||
<span key={index} className="tag">
|
||||
{tag}
|
||||
<span
|
||||
className="remove"
|
||||
onClick={() => setTags(tags.filter((_, i) => i !== index))}
|
||||
>
|
||||
×
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add a tag"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter' && tagInput.trim()) {
|
||||
setTags([...tags, tagInput.trim()]);
|
||||
setTagInput('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MDEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
preview="edit-preview"
|
||||
height={500}
|
||||
commands={[
|
||||
{
|
||||
name: 'image',
|
||||
keyCommand: 'image',
|
||||
buttonProps: { 'aria-label': 'Insert image' },
|
||||
execute: async (state, api) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.onchange = async () => {
|
||||
const file = input.files[0];
|
||||
const imageUrl = await handleImageUpload(file);
|
||||
if (imageUrl) {
|
||||
const imageMarkdown = ``;
|
||||
api.replaceSelection(imageMarkdown);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="buttons">
|
||||
<button className="button draft" onClick={() => handleSave('draft')}>
|
||||
Save as Draft
|
||||
</button>
|
||||
<button className="button" onClick={() => handleSave('published')}>
|
||||
Publish
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
client/src/components/Login.js
Normal file
52
client/src/components/Login.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
|
||||
export default function Login() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const response = await axios.post('/api/auth/login', {
|
||||
username,
|
||||
password
|
||||
});
|
||||
localStorage.setItem('token', response.data.token);
|
||||
navigate('/dashboard');
|
||||
} catch (error) {
|
||||
setError('Invalid credentials');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<form onSubmit={handleSubmit} className="login-form">
|
||||
<h2>Login</h2>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<div className="form-group">
|
||||
<label>Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="button">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
client/src/styles/main.scss
Normal file
66
client/src/styles/main.scss
Normal file
@@ -0,0 +1,66 @@
|
||||
$primary-color: #2c3e50;
|
||||
$secondary-color: #3498db;
|
||||
$background-color: #f5f6fa;
|
||||
$text-color: #2c3e50;
|
||||
$border-color: #dcdde1;
|
||||
|
||||
body {
|
||||
background-color: $background-color;
|
||||
color: $text-color;
|
||||
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.post-editor {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tags-input {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
background: $secondary-color;
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 15px;
|
||||
margin-right: 10px;
|
||||
margin-bottom: 5px;
|
||||
|
||||
.remove {
|
||||
margin-left: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
background: $primary-color;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-right: 10px;
|
||||
|
||||
&.draft {
|
||||
background: $secondary-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user