feat: ability to add and view blogs for a user
This commit is contained in:
@@ -1,42 +0,0 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
+182
-5
@@ -14,7 +14,6 @@ import {
|
||||
} from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { BlogViewer } from "./utils/BlogViewer";
|
||||
import { BlogList } from "./utils/BlogList";
|
||||
import { RequireAdmin } from "./utils/RouteGuard";
|
||||
import { AdminPage } from "./utils/AdminPage";
|
||||
import Unauthorized from "./utils/UnauthorizedPage";
|
||||
@@ -26,6 +25,8 @@ import {
|
||||
FaSun,
|
||||
FaMoon,
|
||||
} from "react-icons/fa";
|
||||
import type { Blog } from "./utils/types";
|
||||
import { countWords } from "./utils/countWords";
|
||||
|
||||
// Base API URL from env
|
||||
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";
|
||||
@@ -33,7 +34,7 @@ const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";
|
||||
// Auth Context
|
||||
interface AuthContextProps {
|
||||
isAuthenticated: boolean;
|
||||
login: (token: string) => void;
|
||||
login: (token: string, user_id: number) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
const AuthContext = createContext<AuthContextProps>({
|
||||
@@ -54,8 +55,9 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
setIsAuthenticated(!!token);
|
||||
}, []);
|
||||
|
||||
const login = (token: string) => {
|
||||
const login = (token: string, user_id: number) => {
|
||||
localStorage.setItem("token", token);
|
||||
localStorage.setItem("user_id", user_id.toString());
|
||||
setIsAuthenticated(true);
|
||||
};
|
||||
|
||||
@@ -72,6 +74,179 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
);
|
||||
};
|
||||
|
||||
function BlogPage() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [blogs, setBlogs] = useState<Blog[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_URL}/blogs`)
|
||||
.then((res) => res.json())
|
||||
.then((data: Blog[]) => setBlogs(data))
|
||||
.catch((err) => console.error(err));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-end mb-4">
|
||||
<button
|
||||
onClick={() =>
|
||||
isAuthenticated ? navigate("/create") : navigate("/signin")
|
||||
}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{blogs.map((blog) => (
|
||||
<Link
|
||||
key={blog.id}
|
||||
to={`/blog/${blog.id}`}
|
||||
className="block p-4 border rounded hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
<h3 className="text-xl font-semibold">{blog.title}</h3>
|
||||
<p className="text-sm text-gray-500">By {blog.author_id}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Page for creating a blog post
|
||||
function CreateBlog() {
|
||||
const [fileName, setFileName] = useState<string>("");
|
||||
const [title, setTitle] = useState<string>("");
|
||||
const username = localStorage.getItem("user_id") || "";
|
||||
const [author, setAuthor] = useState<string>(username);
|
||||
const [description, setDescription] = useState<string>("");
|
||||
const [content, setContent] = useState<string>("");
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const text = reader.result as string;
|
||||
setContent(text);
|
||||
setFileName(file.name);
|
||||
const baseName = file.name.replace(/\.[^.]+$/, "");
|
||||
setTitle(baseName);
|
||||
setAuthor(username);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Stubbed API call
|
||||
try {
|
||||
const body = {
|
||||
title: title,
|
||||
author_id: localStorage.getItem("user_id"),
|
||||
description: description,
|
||||
body: content,
|
||||
created_at: new Date(Date.now()).toISOString(),
|
||||
updated_at: new Date(Date.now()).toISOString(),
|
||||
published_at: new Date(Date.now()).toISOString(),
|
||||
word_count: countWords(content),
|
||||
version: 1,
|
||||
read_time: 0,
|
||||
language: "US",
|
||||
};
|
||||
const res = await fetch(`${API_URL}/blogs`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
alert(res);
|
||||
navigate("/blog");
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto py-10 space-y-6">
|
||||
<h2 className="text-2xl font-bold">Create Post</h2>
|
||||
<div className="flex space-x-4">
|
||||
<label className="px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-600">
|
||||
Upload File
|
||||
<input
|
||||
type="file"
|
||||
accept=".txt,.md"
|
||||
onChange={handleFile}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{fileName && (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="title" className="block text-sm font-medium mb-1">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="author" className="block text-sm font-medium mb-1">
|
||||
Author
|
||||
</label>
|
||||
<input
|
||||
id="author"
|
||||
type="text"
|
||||
value={author}
|
||||
onChange={(e) => setAuthor(e.target.value)}
|
||||
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="description"
|
||||
className="block text-sm font-medium mb-1"
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<input
|
||||
id="description"
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="content" className="block text-sm font-medium mb-1">
|
||||
Content
|
||||
</label>
|
||||
<textarea
|
||||
id="content"
|
||||
rows={10}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2 max-h-96 overflow-y-auto resize-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
// Initialize theme from localStorage or default to light
|
||||
const [darkMode, setDarkMode] = useState<boolean>(() => {
|
||||
@@ -100,7 +275,7 @@ function App() {
|
||||
<div className="p-4">
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/blog" element={<BlogList />} />
|
||||
<Route path="/blog" element={<BlogPage />} />
|
||||
<Route path="/blog/:slug" element={<BlogViewer />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="/contact" element={<Contact />} />
|
||||
@@ -116,6 +291,7 @@ function App() {
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/signin" element={<SignIn />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
<Route path="/create" element={<CreateBlog />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
@@ -483,7 +659,8 @@ function SignIn() {
|
||||
}
|
||||
|
||||
const tokenData = await response.json();
|
||||
login(tokenData.access_token);
|
||||
alert(response);
|
||||
login(tokenData.access_token, tokenData.user_id);
|
||||
navigate("/");
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App.tsx";
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/* markdown.css - Custom wrapper for Markdown content */
|
||||
|
||||
.markdown-body {
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
color: #1e293b;
|
||||
/* slate-800 */
|
||||
background-color: #ffffff;
|
||||
/* white */
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Dark mode override */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.markdown-body {
|
||||
color: #cbd5e1;
|
||||
/* slate-300 */
|
||||
background-color: #0f172a;
|
||||
/* slate-900 */
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
/* Headings */
|
||||
.markdown-body h1,
|
||||
.markdown-body h2,
|
||||
.markdown-body h3,
|
||||
.markdown-body h4 {
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
font-weight: 600;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.markdown-body h1 {
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
|
||||
.markdown-body h2 {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
|
||||
.markdown-body h3 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.markdown-body h4 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
/* Paragraphs */
|
||||
.markdown-body p {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.markdown-body a {
|
||||
color: #3b82f6;
|
||||
/* blue-500 */
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-body a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.markdown-body a {
|
||||
color: #60a5fa;
|
||||
/* blue-400 */
|
||||
}
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
.markdown-body ul,
|
||||
.markdown-body ol {
|
||||
margin: 1em 0 1em 1.5em;
|
||||
}
|
||||
|
||||
/* Blockquotes */
|
||||
.markdown-body blockquote {
|
||||
margin: 1em 0;
|
||||
padding: 0.75em 1em;
|
||||
border-left: 4px solid #e2e8f0;
|
||||
/* slate-200 */
|
||||
background-color: #f1f5f9;
|
||||
/* slate-100 */
|
||||
color: #475569;
|
||||
/* slate-600 */
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.markdown-body blockquote {
|
||||
border-color: #334155;
|
||||
/* slate-700 */
|
||||
background-color: #1e293b;
|
||||
/* slate-800 */
|
||||
color: #cbd5e1;
|
||||
/* slate-300 */
|
||||
}
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
.markdown-body code {
|
||||
background-color: #f5f5f5;
|
||||
color: #db2777;
|
||||
/* rose-600 */
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 0.25rem;
|
||||
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.markdown-body code {
|
||||
background-color: #1e293b;
|
||||
/* slate-800 */
|
||||
color: #f472b6;
|
||||
/* pink-400 */
|
||||
}
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
.markdown-body pre {
|
||||
background-color: #0f172a;
|
||||
/* slate-900 */
|
||||
color: #e2e8f0;
|
||||
/* slate-200 */
|
||||
padding: 1em;
|
||||
overflow: auto;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.markdown-body table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.markdown-body th,
|
||||
.markdown-body td {
|
||||
border: 1px solid #e2e8f0;
|
||||
/* slate-200 */
|
||||
padding: 0.75em 1em;
|
||||
}
|
||||
|
||||
.markdown-body th {
|
||||
background-color: #f1f5f9;
|
||||
/* slate-100 */
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
|
||||
.markdown-body th,
|
||||
.markdown-body td {
|
||||
border-color: #334155;
|
||||
/* slate-700 */
|
||||
}
|
||||
|
||||
.markdown-body th {
|
||||
background-color: #334155;
|
||||
/* slate-700 */
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,43 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { API_URL } from "./constants";
|
||||
import type { Blog } from "./types";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkRehype from "remark-rehype";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import "../styles/markdown.css";
|
||||
|
||||
export function BlogViewer() {
|
||||
const [content, setContent] = useState("");
|
||||
const { slug } = useParams();
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const [blog, setBlog] = useState<Blog | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`localhost:8000/get-blogs/${slug}`)
|
||||
.then((res) => res.text())
|
||||
.then(setContent);
|
||||
}, []);
|
||||
return <Markdown>{content}</Markdown>;
|
||||
if (!slug) return;
|
||||
fetch(`${API_URL}/blogs/${slug}`)
|
||||
.then((res) => res.json())
|
||||
.then((data: Blog) => setBlog(data))
|
||||
.catch((err) => console.error(err));
|
||||
}, [slug]);
|
||||
|
||||
if (!blog) {
|
||||
return <p>Loading...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto py-10 space-y-6">
|
||||
<h1 className="text-3xl font-bold">{blog.title.toUpperCase()}</h1>
|
||||
<p className="text-sm text-gray-500">By User {blog.author_id}</p>
|
||||
<p className="italic text-gray-600 dark:text-gray-400">
|
||||
{blog.description}
|
||||
</p>
|
||||
<div className="markdown-body mx-auto p-4">
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm, remarkRehype]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
children={blog.body}
|
||||
></Markdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";
|
||||
@@ -0,0 +1,9 @@
|
||||
export function countWords(text: string): number {
|
||||
// Trim leading/trailing whitespace, then split on one-or-more whitespace characters
|
||||
const words = text.trim().split(/\s+/);
|
||||
// If the string was empty or only whitespace, split() returns [''], so handle that
|
||||
if (words.length === 1 && words[0] === "") {
|
||||
return 0;
|
||||
}
|
||||
return words.length;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface Blog {
|
||||
id: number;
|
||||
title: string;
|
||||
author_id: number;
|
||||
description: string;
|
||||
body: string;
|
||||
}
|
||||
Reference in New Issue
Block a user