Almost finish implementing client side

This commit is contained in:
2025-11-06 20:22:16 +02:00
parent a847779582
commit a891a686fd
42 changed files with 6450 additions and 57 deletions

View File

@@ -0,0 +1,9 @@
export default function Footer() {
return (
<footer className="bg-white mt-auto">
<div className="container mx-auto py-4 px-4 text-center text-gray-500">
&copy; {new Date().getFullYear()} ImageBoard. All rights reserved.
</div>
</footer>
)
}

View File

@@ -0,0 +1,304 @@
import { useState, type FormEventHandler } from "react";
import Icon, { ICONS } from "./Icon";
import Modal from "./Modal";
import { useLocation, useNavigate } from "react-router";
import { API } from "~/api/api";
import { useAuth } from "~/context/AuthContext";
export default function Header() {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const { user, login, logout, isLoading } = useAuth();
const navigate = useNavigate();
const onBack = () => { navigate("/") };
const onUploadClick= () => setIsUploadModalOpen(true)
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
const [isRegisterModalOpen, setIsRegisterModalOpen] = useState(false);
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
const onTagsClick = () => { navigate("/tags") }
const onLoginClick = () => { setIsLoginModalOpen(true); }
const onRegisterClick = () => { setIsRegisterModalOpen(true); }
const onLogout = () => { logout(); if(isMobileMenuOpen) setIsMobileMenuOpen(false); }
type NavButtonProps = {
onClick: () => void;
icon: string;
children: React.ReactNode;
className?: string;
};
const NavButton = ({ onClick, icon, children, className = '' }: NavButtonProps) => (
<button onClick={onClick} className={`flex items-center space-x-2 px-4 py-2 font-semibold rounded-lg transition-colors ${className}`}>
<Icon path={icon} />
<span className="hidden md:inline">{children}</span>
</button>
);
type MobileNavLinkProps = {
onClick: () => void;
icon: string;
children: React.ReactNode;
className?: string;
};
const MobileNavLink = ({ onClick, icon, children, className = '' }: MobileNavLinkProps) => (
<a href="#" onClick={(e) => { e.preventDefault(); onClick(); setIsMobileMenuOpen(false); }} className={`flex items-center space-x-3 px-3 py-2 text-base font-medium rounded-md ${className}`}>
<Icon path={icon} />
<span>{children}</span>
</a>
);
const UploadModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void; }) => {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [tags, setTags] = useState('');
const [imageFile, setImageFile] = useState<File | null>(null);
const [error, setError] = useState('');
const handleSubmit: FormEventHandler = async (e) => {
e.preventDefault();
setError('');
if (!user) return;
const enteredTags = tags.split(' ').filter(t => t);
if (!title || !imageFile || !description) {
setError('Please fill in all fields and select an image.');
return;
}
const response = await API.createPostRequest(
title, description,
enteredTags, imageFile.name,
imageFile.type, imageFile.size,
user
);
const data = await response.json();
if (!response.ok) {
setError(data.detail || `Upload failed with status ${response.status}.`);
return;
}
const uploadUrl = data.fileUrl;
if (!uploadUrl) {
setError('Upload URL not provided by server.');
return;
}
const uploadResponse = await API.patchFileUploadUrl(uploadUrl, imageFile, user);
if (!uploadResponse.ok) {
setError(`Image upload failed with status ${uploadResponse.status}.`);
return;
}
navigate(`/posts/${data.id}`);
onClose();
};
return (
<Modal isOpen={isOpen} onClose={onClose}>
<h2 className="text-2xl font-bold mb-4">Upload a Post</h2>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="post-title">Title</label>
<input id="post-title" type="text" value={title} onChange={(e) => setTitle(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" placeholder="e.g., My Awesome Picture"/>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="post-description">Description</label>
<textarea id="post-description" rows={3} value={description} onChange={(e) => setDescription(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" placeholder="A short description of your image..."/>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="post-tags">Tags (space-separated)</label>
<input id="post-tags" type="text" value={tags} onChange={(e) => setTags(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" placeholder="e.g., high-res"/>
</div>
<div className="mb-6">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="post-image">Image</label>
<input id="post-image" type="file" accept="image/*" onChange={(e) => {
const files = e.target.files
if (files && files.length > 0) setImageFile(files[0])
}} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline"/>
</div>
{error && <p className="text-red-500 text-xs italic mb-4">{error}</p>}
<div className="flex items-center justify-end">
<button type="submit" className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline disabled:bg-blue-300" disabled={!title || !imageFile || !description}>Upload</button>
</div>
</form>
</Modal>
);
};
const RegisterModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void; }) => {
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit: FormEventHandler = async (e) => {
e.preventDefault();
setError('');
if (password !== confirmPassword) {
setError("Passwords do not match.");
return;
}
const response = await API.register(username, email, password);
if (!response.ok) {
const data = await response.json();
setError(data.detail || "Registration failed. Please try again.");
return;
}
// Automatically log in the user after successful registration
const loginResponse = await API.login(email, password);
if (loginResponse.ok) {
const loginData = await loginResponse.json();
login(loginData.accessToken, loginData.refreshToken);
}
onClose();
setUsername('');
setEmail('');
setPassword('');
setConfirmPassword('');
};
return (
<Modal isOpen={isOpen} onClose={onClose}>
<h2 className="text-2xl font-bold mb-4">Register</h2>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="register-username">Username</label>
<input id="register-username" type="text" value={username} onChange={(e) => setUsername(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" placeholder="Enter username" required/>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="register-email">Email</label>
<input id="register-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" placeholder="Enter email" required/>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="register-password">Password</label>
<input id="register-password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" placeholder="Enter password" required/>
</div>
<div className="mb-6">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="confirm-password">Confirm Password</label>
<input id="confirm-password" type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline" placeholder="Confirm password" required/>
</div>
{error && <p className="text-red-500 text-xs italic mb-4">{error}</p>}
<div className="flex items-center justify-between">
<button type="submit" className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline">Register</button>
</div>
</form>
</Modal>
);
};
const LoginModal = ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void; }) => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit: FormEventHandler = async (e) => {
e.preventDefault();
const response = await API.login(username, password);
if (!response.ok) {
setError("Login failed. Please check your credentials.");
return;
}
const data = await response.json();
login(data.accessToken, data.refreshToken);
onClose();
setUsername('');
setPassword('');
};
return (
<Modal isOpen={isLoginModalOpen} onClose={onClose}>
<h2 className="text-2xl font-bold mb-4">Login</h2>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="username">Email</label>
<input id="username" type="email" value={username} onChange={(e) => setUsername(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" placeholder="Enter email"/>
</div>
<div className="mb-6">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="password">Password</label>
<input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline" placeholder="Enter password"/>
</div>
{error && <p className="text-red-500 text-xs italic mb-4">{error}</p>}
<div className="flex items-center justify-between">
<button type="submit" className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline">Sign In</button>
<a href="#" onClick={(e) => { e.preventDefault(); onClose(); onRegisterClick(); }} className="inline-block align-baseline font-bold text-sm text-blue-500 hover:text-blue-800">
Create an Account
</a>
</div>
</form>
</Modal>
);
};
return (
<>
<header className="bg-white shadow-md sticky top-0 z-10">
<div className="container mx-auto px-4">
<div className="flex items-center justify-between h-16">
<div className="flex items-center">
<h1 className="text-2xl font-bold text-gray-800 cursor-pointer" onClick={onBack}>ImgBoard</h1>
</div>
{/* Desktop Nav */}
<div className="hidden md:flex items-center space-x-2">
<NavButton onClick={onTagsClick} icon={ICONS.tags} className="text-gray-600 hover:bg-gray-100">Tags</NavButton>
{isLoading ? (
<div className="px-4 py-2 font-semibold">Loading...</div>
) : user ? (
<>
<NavButton onClick={onUploadClick} icon={ICONS.upload} className="text-gray-600 hover:bg-gray-100">Upload</NavButton>
<NavButton onClick={onLogout} icon={ICONS.logout} className="text-gray-600 hover:bg-gray-100">Logout</NavButton>
</>
) : (
<>
<NavButton onClick={onLoginClick} icon={ICONS.login} className="bg-green-500 text-white hover:bg-green-600">Login</NavButton>
<NavButton onClick={onRegisterClick} icon={ICONS.register} className="bg-blue-500 text-white hover:bg-blue-600">Register</NavButton>
</>
)}
</div>
{/* Mobile Menu Button */}
<div className="md:hidden">
<button onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)} className="p-2 rounded-md text-gray-600 hover:text-gray-900 hover:bg-gray-100">
<Icon path={isMobileMenuOpen ? ICONS.close : ICONS.menu} />
</button>
</div>
</div>
</div>
{/* Mobile Menu */}
{isMobileMenuOpen && (
<div className="md:hidden absolute top-16 left-0 right-0 bg-white shadow-lg p-2 z-20">
<div className="space-y-1">
<MobileNavLink onClick={onTagsClick} icon={ICONS.tags} className="text-gray-600 hover:bg-gray-100">Tags</MobileNavLink>
{isLoading ? (
<div className="px-3 py-2">Loading...</div>
) : user ? (
<>
<MobileNavLink onClick={onUploadClick} icon={ICONS.upload} className="text-gray-700 hover:bg-gray-100">Upload Image</MobileNavLink>
<MobileNavLink onClick={onLogout} icon={ICONS.logout} className="text-red-600 hover:bg-red-50">Logout</MobileNavLink>
</>
) : (
<>
<MobileNavLink onClick={onLoginClick} icon={ICONS.login} className="text-green-600 hover:bg-green-50">Login</MobileNavLink>
<MobileNavLink onClick={onRegisterClick} icon={ICONS.register} className="text-blue-600 hover:bg-blue-50">Register</MobileNavLink>
</>
)}
</div>
</div>
)}
</header>
<LoginModal isOpen={isLoginModalOpen} onClose={() => setIsLoginModalOpen(false)}/>
<RegisterModal isOpen={isRegisterModalOpen} onClose={() => setIsRegisterModalOpen(false)}/>
<UploadModal isOpen={isUploadModalOpen} onClose={() => setIsUploadModalOpen(false)}/>
</>
);
}

View File

@@ -0,0 +1,29 @@
export const ICONS = {
back: "M15 19l-7-7 7-7",
close: "M6 18L18 6M6 6l12 12",
menu: "M4 6h16M4 12h16M4 18h16",
search: "M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z",
edit: "M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.5L15.232 5.232z",
delete: "M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16",
viewRaw: "M15 12a3 3 0 11-6 0 3 3 0 016 0zM2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z",
admin: "M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.186 0-6.149 2.08-7.938 5.124z",
upload: "M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12",
add: "M12 6v12m6-6H6",
login: "M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75",
logout: "M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9",
tags: "M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3zM11.25 9a1.5 1.5 0 100-3 1.5 1.5 0 000 3z",
register: "M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
};
type IconProps = {
path: string;
className?: string;
};
export default function Icon({ path, className = "h-6 w-6" }: IconProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={path} />
</svg>
)
}

View File

@@ -0,0 +1,5 @@
export default function Loading() {
return <div className="flex justify-center items-center h-64">
<div className="loader ease-linear rounded-full border-8 border-t-8 border-gray-200 h-16 w-16"></div>
</div>;
}

View File

@@ -0,0 +1,21 @@
import Icon, { ICONS } from "./Icon";
interface ModalProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}
export default function Modal({ isOpen, onClose, children }: ModalProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex justify-center items-center p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6 relative">
<button onClick={onClose} className="absolute top-3 right-3 text-gray-400 hover:text-gray-600">
<Icon path={ICONS.close} />
</button>
{children}
</div>
</div>
);
};

View File

@@ -0,0 +1,9 @@
export function NotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-100">
<h1 className="text-6xl font-bold text-gray-800 mb-4">404</h1>
<p className="text-xl text-gray-600 mb-8">The page you are looking for does not exist.</p>
<a href="/" className="text-blue-500 hover:underline">Go back to Home</a>
</div>
);
}