diff --git a/src/blog/Blog.tsx b/src/blog/Blog.tsx index 7bd502b..397a3ad 100644 --- a/src/blog/Blog.tsx +++ b/src/blog/Blog.tsx @@ -2,16 +2,28 @@ import * as React from 'react'; import CssBaseline from '@mui/material/CssBaseline'; import Container from '@mui/material/Container'; import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; import AppTheme from '../shared-theme/AppTheme'; import MainContent from './components/MainContent'; import Article from './components/Article'; import Latest from './components/Latest'; import Footer from './components/Footer'; +import Login from './components/Login'; +import Register from './components/Register'; +import Profile from './components/Profile'; import { useArticles } from './providers/Article'; +import { useAuth } from './providers/Author'; + +type View = 'home' | 'login' | 'register' | 'article' | 'profile'; export default function Blog(props: { disableCustomTheme?: boolean }) { const { articles, loading, error } = useArticles(); + const { currentUser } = useAuth(); + const [selectedArticle, setSelectedArticle] = React.useState(null); + const [showLogin, setShowLogin] = React.useState(false); + const [showRegister, setShowRegister] = React.useState(false); + const [showProfile, setShowProfile] = React.useState(false); const handleSelectArticle = (index: number) => { setSelectedArticle(index); @@ -20,6 +32,99 @@ export default function Blog(props: { disableCustomTheme?: boolean }) { const handleBack = () => setSelectedArticle(null); + const handleShowLogin = () => { + setShowLogin(true); + setShowRegister(false); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + const handleShowRegister = () => { + setShowRegister(true); + setShowLogin(false); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + const handleHideAuth = () => { + setShowLogin(false); + setShowRegister(false); + }; + const handleShowProfile = () => { + setShowProfile(true); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + const handleHideProfile = () => { + setShowProfile(false); + }; + + // derive a single source of truth for view + const view: View = React.useMemo(() => { + if (selectedArticle !== null) return 'article'; + if (showRegister) return 'register'; + if (showLogin) return 'login'; + if (showProfile) return 'profile'; + return 'home'; + }, [selectedArticle, showLogin, showRegister, showProfile]); + + // render function keeps JSX tidy + const renderView = () => { + switch (view) { + case 'register': + return ; + case 'login': + return ( + { + handleShowRegister(); + }} + /> + ); + case 'profile': + return ( + + ); + case 'article': + if (selectedArticle == null || !articles[selectedArticle]) return null; + return
; + case 'home': + default: + return ( + <> + + {!currentUser ? ( + <> + + + ) : ( + <> + + + )} + + + + {}} + /> + + ); + } + }; + if (loading) { return ( @@ -64,13 +169,7 @@ export default function Blog(props: { disableCustomTheme?: boolean }) { - + - {selectedArticle === null ? ( - <> - - { - // Optional pagination call - }} - /> - - ) : ( -
- )} + {renderView()} - {selectedArticle === null && ( + {view === 'home' && ( void; + onRegister: () => void; +} + +export default function Login({ onBack, onRegister }: LoginProps) { + const { login, loading, error, currentUser } = useAuth(); + const [username, setUsername] = React.useState(''); + const [password, setPassword] = React.useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + await login(username, password); + }; + + // ✅ Auto-return if already logged in + React.useEffect(() => { + if (currentUser) onBack(); + }, [currentUser]); + + return ( + + + + + + + Sign In + + + + Please log in to continue + + +
+ setUsername(e.target.value)} + required + /> + setPassword(e.target.value)} + required + /> + + {error && ( + + {error} + + )} + + + + + + Don’t have an account?{' '} + + Register + + +
+ ); +} diff --git a/src/blog/components/Profile.tsx b/src/blog/components/Profile.tsx new file mode 100644 index 0000000..ddaf023 --- /dev/null +++ b/src/blog/components/Profile.tsx @@ -0,0 +1,166 @@ +import * as React from 'react'; +import { + Box, + TextField, + Button, + Typography, + IconButton, + CircularProgress, + Avatar, + Alert, +} from '@mui/material'; +import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; +import { useAuth } from '../providers/Author'; + +interface ProfileProps { + onBack: () => void; +} + +export default function Profile({ onBack }: ProfileProps) { + const { currentUser, loading, error, token, refreshAuthors, updateProfile } = useAuth(); + const [formData, setFormData] = React.useState({ + username: currentUser?.username || '', + name: currentUser?.name || '', + email: currentUser?.email || '', + avatar: currentUser?.avatar || '', + }); + const [success, setSuccess] = React.useState(null); + const [saving, setSaving] = React.useState(false); + + React.useEffect(() => { + if (currentUser) setFormData(currentUser); + }, [currentUser]); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + const handleSave = async () => { + if (!currentUser) return; + + try { + setSaving(true); + setSuccess(null); + + const updatedUser = { ...currentUser, ...formData }; + console.log('updatedUser'); + console.log(updatedUser); + const updated = await updateProfile(updatedUser); + + if (updated) setSuccess('Profile updated successfully'); + } catch (err: any) { + console.error('Failed to update profile:', err); + } finally { + setSaving(false); + } + }; + + if (!currentUser) { + return ( + + + You must be logged in to view your profile. + + + + ); + } + + return ( + + + + + + + Profile + + + + + + + + + + + + {error && ( + + {error} + + )} + {success && ( + + {success} + + )} + + + + ); +} diff --git a/src/blog/components/Register.tsx b/src/blog/components/Register.tsx new file mode 100644 index 0000000..9359516 --- /dev/null +++ b/src/blog/components/Register.tsx @@ -0,0 +1,114 @@ +import * as React from 'react'; +import { Box, TextField, Button, Typography, IconButton, CircularProgress, Alert, } from '@mui/material'; +import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; +import { useAuth } from '../providers/Author'; + +interface RegisterProps { + onBack: () => void; +} + +export default function Register({ onBack }: RegisterProps) { + const { register, loading, error, currentUser } = useAuth(); + const [username, setUsername] = React.useState(''); + const [password1, setPassword1] = React.useState(''); + const [password2, setPassword2] = React.useState(''); + const [localError, setLocalError] = React.useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLocalError(null); + + // ✅ Local validation + if (password1 !== password2) { + setLocalError("Passwords don't match"); + return; + } + + if (password1.length < 6) { + setLocalError('Password must be at least 6 characters long'); + return; + } + + // ✅ Call backend + await register(username, password1); + }; + + if (currentUser) { + // ✅ if logged in, auto-return to the article list + onBack(); + return null; + } + + return ( + + + + + + + Sign Up + + + + Please sign up to continue + + +
+ setUsername(e.target.value)} + required + /> + setPassword1(e.target.value)} + required + /> + setPassword2(e.target.value)} + required + /> + + {(localError || error) && ( + + {localError || error} + + )} + + + +
+ ); +} diff --git a/src/blog/providers/Article.tsx b/src/blog/providers/Article.tsx index 4ecc5ef..9809215 100644 --- a/src/blog/providers/Article.tsx +++ b/src/blog/providers/Article.tsx @@ -1,45 +1,44 @@ import React, { createContext, useState, useContext, useEffect } from 'react'; -import axios from 'axios'; -import { ArticleModel } from "../types/models"; -import { ArticleContextModel } from "../types/contexts"; +import { api } from '../utils/api'; +import { ArticleModel } from '../types/models'; +import { ArticleContextModel } from '../types/contexts'; +import { useAuth } from './Author'; const ArticleContext = createContext(undefined); -const API_BASE = import.meta.env.VITE_API_BASE_URL; - export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [articles, setArticles] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const { token } = useAuth(); // ✅ access token if needed + /** 🔹 Fetch articles (JWT automatically attached by api.ts interceptor) */ const fetchArticles = async () => { try { setLoading(true); setError(null); - // ✅ Use correct full endpoint from OpenAPI spec - const res = await axios.get(`${API_BASE}/articles`, { - params: { skip: 0, limit: 10 }, - }); - - // ✅ Normalize if backend sends _id instead of id - const formatted = res.data.map((a) => ({ - ...a, - id: a._id || undefined, - })); - + const res = await api.get('/articles', { params: { skip: 0, limit: 10 } }); + const formatted = res.data.map((a) => ({ ...a, id: a._id || undefined })); setArticles(formatted); } catch (err: any) { console.error('Failed to fetch articles:', err); - setError(err.message || 'Failed to fetch articles'); + setError(err.response?.data?.detail || 'Failed to fetch articles'); } finally { setLoading(false); } }; + /** 🔹 Auto-fetch articles whenever user logs in/out */ useEffect(() => { - fetchArticles(); - }, []); + // Always load once on mount + // If endpoint requires JWT, fallback safely + if (!token) { + fetchArticles().catch(() => setLoading(false)); // try anyway (handles both public/protected) + } else { + fetchArticles(); + } + }, [token]); return ( diff --git a/src/blog/providers/Author.tsx b/src/blog/providers/Author.tsx index 4cd8ac6..e5d1dad 100644 --- a/src/blog/providers/Author.tsx +++ b/src/blog/providers/Author.tsx @@ -1,12 +1,10 @@ import React, { createContext, useState, useEffect, useContext } from 'react'; -import axios from 'axios'; -import { AuthorModel } from "../types/models"; -import { AuthContextModel } from "../types/contexts"; +import { api } from '../utils/api'; +import { AuthorModel } from '../types/models'; +import { AuthContextModel } from '../types/contexts'; const AuthContext = createContext(undefined); -const API_BASE = import.meta.env.VITE_API_BASE_URL; - export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [currentUser, setCurrentUser] = useState(null); const [authors, setAuthors] = useState([]); @@ -14,13 +12,29 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - /** 🔹 Login and store JWT token */ - const login = async (email: string, password: string) => { + /** 🔹 Register new user */ + const register = async (username: string, password: string) => { try { setLoading(true); setError(null); - const res = await axios.post(`${API_BASE}/auth/login`, { email, password }); + const res = await api.post('/auth/register', { username, password }); + return res.data; // returns PublicUser from backend + } catch (err: any) { + console.error('Registration failed:', err); + setError(err.response?.data?.detail || 'Registration failed'); + } finally { + setLoading(false); + } + }; + + /** 🔹 Login and store JWT token */ + const login = async (username: string, password: string) => { + try { + setLoading(true); + setError(null); + + const res = await api.post('/auth/login', { username, password }); const { access_token, user } = res.data; if (access_token) { @@ -44,40 +58,62 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children setAuthors([]); }; - /** 🔹 Fetch all authors (requires valid JWT) */ + /** 🔹 Fetch all authors (JWT handled by api interceptor) */ const refreshAuthors = async () => { - if (!token) return; try { setLoading(true); setError(null); - const res = await axios.get(`${API_BASE}/authors`, { - headers: { Authorization: `Bearer ${token}` }, - }); - + const res = await api.get('/authors'); setAuthors(res.data); } catch (err: any) { console.error('Failed to fetch authors:', err); - setError(err.message || 'Failed to fetch authors'); + setError(err.response?.data?.detail || 'Failed to fetch authors'); } finally { setLoading(false); } }; + /** 🔹 Update current user (full model) */ + const updateProfile = async (userData: AuthorModel) => { + if (!userData._id) { + console.error('updateProfile called without _id'); + return; + } + try { + setLoading(true); + setError(null); + + const res = await api.put(`/authors/${userData._id}`, userData); + setCurrentUser(res.data); + return res.data; + } catch (err: any) { + console.error('Profile update failed:', err); + setError(err.response?.data?.detail || 'Failed to update profile'); + } finally { + setLoading(false); + } + }; + + /** 🔹 Auto-load current user if token exists */ const fetchCurrentUser = async () => { if (!token) return; try { - const res = await axios.get(`${API_BASE}/auth/me`, { - headers: { Authorization: `Bearer ${token}` }, - }); - setCurrentUser(res.data); - } catch (err: any) { + const me = await api.get<{ _id: string; username: string; email: string }>('/auth/me'); + + const author = await api.get(`/authors/${me.data._id}`); + + const fullUser = { ...me.data, ...author.data }; + + setCurrentUser(fullUser); + } catch (err) { console.error('Failed to fetch current user:', err); logout(); // invalid/expired token } }; + /** 🔹 On mount, try to fetch user if token exists */ useEffect(() => { if (token) fetchCurrentUser(); }, [token]); @@ -92,7 +128,9 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children error, login, logout, + register, refreshAuthors, + updateProfile, }} > {children} diff --git a/src/blog/types/contexts.ts b/src/blog/types/contexts.ts index 2f12ed6..0aab367 100644 --- a/src/blog/types/contexts.ts +++ b/src/blog/types/contexts.ts @@ -13,7 +13,9 @@ export interface AuthContextModel { token: string | null; loading: boolean; error: string | null; - login: (email: string, password: string) => Promise; + login: (username: string, password: string) => Promise; + register: (username: string, password: string) => Promise; logout: () => void; refreshAuthors: () => Promise; + updateProfile: (user: AuthorModel) => Promise; } diff --git a/src/blog/utils/api.ts b/src/blog/utils/api.ts new file mode 100644 index 0000000..671417f --- /dev/null +++ b/src/blog/utils/api.ts @@ -0,0 +1,33 @@ +// src/utils/api.ts +import axios from 'axios'; + +const API_BASE = import.meta.env.VITE_API_BASE_URL; + +export const api = axios.create({ + baseURL: API_BASE, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// 🔹 Attach token from localStorage before each request +api.interceptors.request.use((config) => { + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +// 🔹 Handle expired or invalid tokens globally +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + console.warn('Token expired or invalid. Logging out...'); + localStorage.removeItem('token'); + // Optionally: trigger a redirect or event + } + return Promise.reject(error); + } +); diff --git a/src/main.jsx b/src/main.jsx index 2ef2626..d93e38c 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -2,14 +2,17 @@ import * as React from 'react'; import { createRoot } from 'react-dom/client'; import Blog from './blog/Blog'; import { ArticleProvider } from './blog/providers/Article'; +import { AuthProvider } from './blog/providers/Author'; const rootElement = document.getElementById('root'); const root = createRoot(rootElement); root.render( - - - + + + + + , );