feat: front end for login
This commit was merged in pull request #5.
This commit is contained in:
+380
-59
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user