721 lines
21 KiB
TypeScript
721 lines
21 KiB
TypeScript
import React, {
|
||
createContext,
|
||
useContext,
|
||
useState,
|
||
useEffect,
|
||
useRef,
|
||
} from "react";
|
||
import {
|
||
BrowserRouter as Router,
|
||
Routes,
|
||
Route,
|
||
Link,
|
||
useNavigate,
|
||
} from "react-router-dom";
|
||
import { useForm } from "react-hook-form";
|
||
import { BlogViewer } from "./utils/BlogViewer";
|
||
import { RequireAdmin } from "./utils/RouteGuard";
|
||
import { AdminPage } from "./utils/AdminPage";
|
||
import Unauthorized from "./utils/UnauthorizedPage";
|
||
import {
|
||
FaGithub,
|
||
FaGitAlt,
|
||
FaLinkedin,
|
||
FaTwitter,
|
||
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";
|
||
|
||
// Auth Context
|
||
interface AuthContextProps {
|
||
isAuthenticated: boolean;
|
||
login: (token: string, user_id: number) => void;
|
||
logout: () => void;
|
||
}
|
||
const AuthContext = createContext<AuthContextProps>({
|
||
isAuthenticated: false,
|
||
login: () => {},
|
||
logout: () => {},
|
||
});
|
||
export const useAuth = () => useContext(AuthContext);
|
||
|
||
const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
|
||
children,
|
||
}) => {
|
||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||
const navigate = useNavigate();
|
||
|
||
useEffect(() => {
|
||
const token = localStorage.getItem("token");
|
||
setIsAuthenticated(!!token);
|
||
}, []);
|
||
|
||
const login = (token: string, user_id: number) => {
|
||
localStorage.setItem("token", token);
|
||
localStorage.setItem("user_id", user_id.toString());
|
||
setIsAuthenticated(true);
|
||
};
|
||
|
||
const logout = () => {
|
||
localStorage.removeItem("token");
|
||
setIsAuthenticated(false);
|
||
navigate("/signin");
|
||
};
|
||
|
||
return (
|
||
<AuthContext.Provider value={{ isAuthenticated, login, logout }}>
|
||
{children}
|
||
</AuthContext.Provider>
|
||
);
|
||
};
|
||
|
||
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>(() => {
|
||
return localStorage.getItem("theme") === "dark";
|
||
});
|
||
|
||
// Sync dark mode state with <html> class and localStorage
|
||
useEffect(() => {
|
||
if (darkMode) {
|
||
document.documentElement.classList.add("dark");
|
||
localStorage.setItem("theme", "dark");
|
||
} else {
|
||
document.documentElement.classList.remove("dark");
|
||
localStorage.setItem("theme", "light");
|
||
}
|
||
}, [darkMode]);
|
||
|
||
return (
|
||
<Router>
|
||
<AuthProvider>
|
||
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors duration-300">
|
||
<AppBar
|
||
darkMode={darkMode}
|
||
toggleDarkMode={() => setDarkMode((prev) => !prev)}
|
||
/>
|
||
<div className="p-4">
|
||
<Routes>
|
||
<Route path="/" element={<LandingPage />} />
|
||
<Route path="/blog" element={<BlogPage />} />
|
||
<Route path="/blog/:slug" element={<BlogViewer />} />
|
||
<Route path="/about" element={<About />} />
|
||
<Route path="/contact" element={<Contact />} />
|
||
<Route
|
||
path="/admin"
|
||
element={
|
||
<RequireAdmin>
|
||
<AdminPage />
|
||
</RequireAdmin>
|
||
}
|
||
/>
|
||
<Route path="/unauthorized" element={<Unauthorized />} />
|
||
<Route path="/register" element={<Register />} />
|
||
<Route path="/signin" element={<SignIn />} />
|
||
<Route path="/profile" element={<Profile />} />
|
||
<Route path="/create" element={<CreateBlog />} />
|
||
</Routes>
|
||
</div>
|
||
</div>
|
||
</AuthProvider>
|
||
</Router>
|
||
);
|
||
}
|
||
|
||
function AppBar({
|
||
darkMode,
|
||
toggleDarkMode,
|
||
}: {
|
||
darkMode: boolean;
|
||
toggleDarkMode: () => void;
|
||
}) {
|
||
const { isAuthenticated, logout } = useAuth();
|
||
const [menuOpen, setMenuOpen] = useState(false);
|
||
const menuRef = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
const handleClickOutside = (event: MouseEvent) => {
|
||
if (
|
||
menuOpen &&
|
||
menuRef.current &&
|
||
!menuRef.current.contains(event.target as Node)
|
||
) {
|
||
setMenuOpen(false);
|
||
}
|
||
};
|
||
document.addEventListener("mousedown", handleClickOutside);
|
||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||
}, [menuOpen]);
|
||
|
||
return (
|
||
<nav className="bg-white dark:bg-gray-800 shadow p-4 flex items-center relative">
|
||
<Link to="/" className="font-bold text-blue-600 dark:text-blue-400">
|
||
Home
|
||
</Link>
|
||
<Link
|
||
to="/blog"
|
||
className="ml-4 text-gray-800 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400"
|
||
>
|
||
Blog
|
||
</Link>
|
||
<Link
|
||
to="/about"
|
||
className="ml-4 text-gray-800 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400"
|
||
>
|
||
About Me
|
||
</Link>
|
||
<Link
|
||
to="/contact"
|
||
className="ml-4 text-gray-800 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400"
|
||
>
|
||
Contact
|
||
</Link>
|
||
<div className="ml-auto flex items-center space-x-4">
|
||
{!isAuthenticated ? (
|
||
<>
|
||
<Link
|
||
to="/register"
|
||
className="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||
>
|
||
Register
|
||
</Link>
|
||
<Link
|
||
to="/signin"
|
||
className="px-3 py-1 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600"
|
||
>
|
||
Sign In
|
||
</Link>
|
||
</>
|
||
) : (
|
||
<div className="relative" ref={menuRef}>
|
||
<button
|
||
onClick={() => setMenuOpen(!menuOpen)}
|
||
className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center"
|
||
>
|
||
<span className="text-gray-700 dark:text-gray-200">U</span>
|
||
</button>
|
||
{menuOpen && (
|
||
<div className="absolute right-0 mt-2 w-32 bg-white dark:bg-gray-700 shadow rounded">
|
||
<Link
|
||
to="/profile"
|
||
className="block px-4 py-2 text-gray-800 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600"
|
||
>
|
||
Profile
|
||
</Link>
|
||
<button
|
||
onClick={logout}
|
||
className="w-full text-left block px-4 py-2 text-gray-800 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600"
|
||
>
|
||
Sign Out
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
<button
|
||
onClick={toggleDarkMode}
|
||
className="text-xl p-1 bg-gray-200 dark:bg-gray-700 rounded-full"
|
||
>
|
||
{darkMode ? <FaMoon /> : <FaSun />}
|
||
</button>
|
||
</div>
|
||
</nav>
|
||
);
|
||
}
|
||
|
||
// Pages and forms
|
||
function LandingPage() {
|
||
return (
|
||
<div className="text-center py-10">
|
||
<h1 className="text-4xl font-bold mb-4">Welcome to My Site</h1>
|
||
<p className="text-lg text-gray-700 dark:text-gray-300">
|
||
Explore my blog, learn about me, or get in touch.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function About() {
|
||
return (
|
||
<div className="py-10">
|
||
<div className="flex justify-center items-end space-x-4">
|
||
<img
|
||
src="/images/photo1.jpg"
|
||
alt="Photo 1"
|
||
className="w-24 h-24 rounded-full"
|
||
/>
|
||
<img
|
||
src="/images/photo2.jpg"
|
||
alt="Photo 2"
|
||
className="w-24 h-24 rounded-full transform translate-y-4"
|
||
/>
|
||
<img
|
||
src="/images/photo3.jpg"
|
||
alt="Photo 3"
|
||
className="w-24 h-24 rounded-full"
|
||
/>
|
||
</div>
|
||
<div className="text-center mt-8 px-4">
|
||
<h2 className="text-3xl font-bold mb-4">About Me</h2>
|
||
<p className="text-lg text-gray-700 dark:text-gray-300 max-w-2xl mx-auto">
|
||
I am a software engineer at Whisker who designs and implements
|
||
whatever is highest priority. I work with every team in the
|
||
engineering organization to coordinate mission-critical projects
|
||
across backend, mobile, and firmware. I’m comfortable in many tech
|
||
stacks and learn new ones quickly—never afraid to jump in the deep end
|
||
and adapt on the fly.
|
||
</p>
|
||
</div>
|
||
<div className="mt-8 flex flex-col items-center space-y-2">
|
||
<a
|
||
href="https://github.com/amuszyn"
|
||
className="flex items-center space-x-2 text-blue-600 hover:underline"
|
||
>
|
||
<FaGithub />
|
||
<span>github.com/amuszyn</span>
|
||
</a>
|
||
<a
|
||
href="https://gitea.muszyn.dev/"
|
||
className="flex items-center space-x-2 text-blue-600 hover:underline"
|
||
>
|
||
<FaGitAlt />
|
||
<span>gitea.muszyn.dev/</span>
|
||
</a>
|
||
<a
|
||
href="https://www.linkedin.com/in/almuszynski/"
|
||
className="flex items-center space-x-2 text-blue-600 hover:underline"
|
||
>
|
||
<FaLinkedin />
|
||
<span>linkedin.com/in/almuszynski</span>
|
||
</a>
|
||
<a
|
||
href="https://x.com/Muszynlol"
|
||
className="flex items-center space-x-2 text-blue-600 hover:underline"
|
||
>
|
||
<FaTwitter />
|
||
<span>x.com/Muszynlol</span>
|
||
</a>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Contact() {
|
||
return (
|
||
<div className="text-center py-10">
|
||
<h2 className="text-2xl font-bold mb-4">Contact</h2>
|
||
<p>Contact page content coming soon.</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Profile() {
|
||
return (
|
||
<div className="text-center py-10">
|
||
<h2 className="text-2xl font-bold mb-4">Profile</h2>
|
||
<p>Profile page coming soon.</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Registration form with API integration
|
||
interface RegisterForm {
|
||
username: string;
|
||
email: string;
|
||
password: string;
|
||
confirmPassword: string;
|
||
}
|
||
|
||
function Register() {
|
||
const navigate = useNavigate();
|
||
const {
|
||
register,
|
||
handleSubmit,
|
||
watch,
|
||
formState: { errors },
|
||
} = useForm<RegisterForm>();
|
||
|
||
const onSubmit = async (data: RegisterForm) => {
|
||
try {
|
||
const response = await fetch(`${API_URL}/register`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
username: data.username,
|
||
email: data.email,
|
||
password: data.password,
|
||
}),
|
||
});
|
||
if (!response.ok) {
|
||
const err = await response.json();
|
||
throw new Error(err.detail || "Registration failed");
|
||
}
|
||
navigate("/signin");
|
||
} catch (err: any) {
|
||
alert(err.message);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="max-w-md mx-auto py-10">
|
||
<h2 className="text-2xl font-bold mb-6 text-center">Register</h2>
|
||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||
<div>
|
||
<label htmlFor="username" className="block text-sm font-medium mb-1">
|
||
Username
|
||
</label>
|
||
<input
|
||
id="username"
|
||
{...register("username", { required: true })}
|
||
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2"
|
||
/>
|
||
{errors.username && (
|
||
<span className="text-red-600 text-sm">Username is required</span>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label htmlFor="email" className="block text-sm font-medium mb-1">
|
||
Email
|
||
</label>
|
||
<input
|
||
id="email"
|
||
type="email"
|
||
{...register("email", {
|
||
required: true,
|
||
pattern: /^\S+@\S+\.\S+$/,
|
||
})}
|
||
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2"
|
||
/>
|
||
{errors.email && (
|
||
<span className="text-red-600 text-sm">
|
||
Valid email is required
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label htmlFor="password" className="block text-sm font-medium mb-1">
|
||
Password
|
||
</label>
|
||
<input
|
||
id="password"
|
||
type="password"
|
||
{...register("password", {
|
||
required: true,
|
||
pattern: /(?=.*[A-Z])(?=.*\d).+/,
|
||
})}
|
||
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2"
|
||
/>
|
||
{errors.password && (
|
||
<span className="text-red-600 text-sm">
|
||
Password must include uppercase and number
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label
|
||
htmlFor="confirmPassword"
|
||
className="block text-sm font-medium mb-1"
|
||
>
|
||
Confirm Password
|
||
</label>
|
||
<input
|
||
id="confirmPassword"
|
||
type="password"
|
||
{...register("confirmPassword", {
|
||
validate: (value) =>
|
||
value === watch("password") || "Passwords do not match",
|
||
})}
|
||
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2"
|
||
/>
|
||
{errors.confirmPassword && (
|
||
<span className="text-red-600 text-sm">
|
||
{errors.confirmPassword.message}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<button
|
||
type="submit"
|
||
className="w-full bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
|
||
>
|
||
Register
|
||
</button>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Sign-in form with API integration
|
||
interface SignInForm {
|
||
identifier: string;
|
||
password: string;
|
||
}
|
||
|
||
function SignIn() {
|
||
const navigate = useNavigate();
|
||
const {
|
||
register,
|
||
handleSubmit,
|
||
formState: { errors },
|
||
} = useForm<SignInForm>();
|
||
const { login } = useAuth();
|
||
|
||
const onSubmit = async (data: SignInForm) => {
|
||
try {
|
||
const body = new URLSearchParams();
|
||
body.append("username", data.identifier);
|
||
body.append("password", data.password);
|
||
|
||
const response = await fetch(`${API_URL}/login`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||
body: body.toString(),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const err = await response.json();
|
||
throw new Error(err.detail || "Login failed");
|
||
}
|
||
|
||
const tokenData = await response.json();
|
||
alert(response);
|
||
login(tokenData.access_token, tokenData.user_id);
|
||
navigate("/");
|
||
} catch (err: any) {
|
||
alert(err.message);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="max-w-md mx-auto py-10">
|
||
<h2 className="text-2xl font-bold mb-6 text-center">Sign In</h2>
|
||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||
<div>
|
||
<label
|
||
htmlFor="identifier"
|
||
className="block text-sm font-medium mb-1"
|
||
>
|
||
Username or Email
|
||
</label>
|
||
<input
|
||
id="identifier"
|
||
{...register("identifier", { required: true })}
|
||
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2"
|
||
/>
|
||
{errors.identifier && (
|
||
<span className="text-red-600 text-sm">This field is required</span>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label
|
||
htmlFor="signinPassword"
|
||
className="block text-sm font-medium mb-1"
|
||
>
|
||
Password
|
||
</label>
|
||
<input
|
||
id="signinPassword"
|
||
type="password"
|
||
{...register("password", { required: true })}
|
||
className="w-full border border-gray-300 dark:border-gray-600 rounded px-3 py-2"
|
||
/>
|
||
{errors.password && (
|
||
<span className="text-red-600 text-sm">Password is required</span>
|
||
)}
|
||
</div>
|
||
|
||
<button
|
||
type="submit"
|
||
className="w-full bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
|
||
>
|
||
Sign In
|
||
</button>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default App;
|