feat: front end for login

This commit was merged in pull request #5.
This commit is contained in:
2025-06-14 10:02:02 -04:00
parent df5b247cdd
commit 8db73f113c
19 changed files with 531 additions and 68 deletions
+17
View File
@@ -12,6 +12,7 @@
"fs": "^0.0.1-security",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.57.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.6.0"
},
@@ -4392,6 +4393,22 @@
"react": "^19.1.0"
}
},
"node_modules/react-hook-form": {
"version": "7.57.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.57.0.tgz",
"integrity": "sha512-RbEks3+cbvTP84l/VXGUZ+JMrKOS8ykQCRYdm5aYsxnDquL0vspsyNhGRO7pcH6hsZqWlPOjLye7rJqdtdAmlg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
+1
View File
@@ -14,6 +14,7 @@
"fs": "^0.0.1-security",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.57.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.6.0"
},
+380 -59
View File
@@ -3,78 +3,191 @@ import {
Routes,
Route,
Link,
useParams,
useNavigate,
} from "react-router-dom";
import { useState, useEffect } from "react";
import ReactMarkdown from "react-markdown";
import { useState, useContext, useEffect, createContext, useRef } from "react";
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";
// Use Vite environment variable for API base URL
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";
// Auth Context
interface AuthContextProps {
isAuthenticated: boolean;
login: (token: string) => 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) => {
localStorage.setItem("token", token);
setIsAuthenticated(true);
};
const logout = () => {
localStorage.removeItem("token");
setIsAuthenticated(false);
navigate("/signin");
};
return (
<AuthContext.Provider value={{ isAuthenticated, login, logout }}>
{children}
</AuthContext.Provider>
);
};
function App() {
const [darkMode, setDarkMode] = useState(true);
return (
<div className={darkMode ? "dark" : ""}>
<Router>
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<AppBar toggleDarkMode={() => setDarkMode(!darkMode)} />
<div className="p-4">
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/blog" element={<BlogList />} />
<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 />} />
</Routes>
<Router>
<AuthProvider>
<div className={darkMode ? "dark" : ""}>
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<AppBar toggleDarkMode={() => setDarkMode(!darkMode)} />
<div className="p-4">
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/blog" element={<BlogList />} />
<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 />} />
</Routes>
</div>
</div>
</div>
</Router>
</div>
</AuthProvider>
</Router>
);
}
function AppBar({ toggleDarkMode }: { 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 space-x-4 items-center">
<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="text-gray-800 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400"
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="text-gray-800 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400"
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="text-gray-800 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400"
className="ml-4 text-gray-800 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400"
>
Contact
</Link>
<button
onClick={toggleDarkMode}
className="ml-auto text-sm bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded"
>
Toggle Dark Mode
</button>
<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-300 dark:bg-gray-600 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-sm bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded"
>
Toggle Dark Mode
</button>
</div>
</nav>
);
}
@@ -90,41 +203,249 @@ function LandingPage() {
);
}
export const BlogPost = () => {
const { slug } = useParams<{ slug: string }>();
const [content, setContent] = useState<string>("");
useEffect(() => {
if (!slug) return;
fetch(`../public/blogs/${slug}.md`)
.then((res) => res.text())
.then((text) => setContent(text));
}, [slug]);
if (!content) return <div>Loading...</div>;
// Profile placeholder
function Profile() {
return (
<article className="prose dark:prose-invert max-w-none p-4">
<ReactMarkdown>{content}</ReactMarkdown>
</article>
<div className="text-center py-10">
<h2 className="text-2xl font-bold mb-4">Profile</h2>
<p>Profile page coming soon.</p>
</div>
);
};
}
function About() {
return (
<div>
<div className="text-center py-10">
<h2 className="text-2xl font-bold mb-4">About Me</h2>
<p>This is a placeholder for information about me.</p>
<p>About page content coming soon.</p>
</div>
);
}
function Contact() {
return (
<div>
<div className="text-center py-10">
<h2 className="text-2xl font-bold mb-4">Contact</h2>
<p>This is a placeholder for contact information or a form.</p>
<p>Contact page content 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();
login(tokenData.access_token);
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>
);
}