Files
talks-site/frontend/src/App.tsx
T

721 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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. Im comfortable in many tech
stacks and learn new ones quicklynever 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;