diff --git a/src/App.jsx b/src/App.jsx index 71b12e5..07090a9 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,23 +1,16 @@ -import { useState } from "react"; - import TodosPage from "./features/Todos/TodosPage.jsx"; import Header from "./shared/Header.jsx"; import Logon from "./features/Logon.jsx"; - +import { useAuth } from "./contexts/AuthContext.jsx"; import "./App.css"; export default function App() { - const [email, setEmail] = useState(""); - const [token, setToken] = useState(""); + const { isAuthenticated } = useAuth(); return (
-
- {token ? ( - - ) : ( - - )} +
+ {isAuthenticated ? : }
); } diff --git a/src/contexts/AuthContext.jsx b/src/contexts/AuthContext.jsx new file mode 100644 index 0000000..baaa8dc --- /dev/null +++ b/src/contexts/AuthContext.jsx @@ -0,0 +1,95 @@ +import { createContext, useContext, useState } from "react"; + +const AuthContext = createContext(); + +export function useAuth() { + const context = useContext(AuthContext); + + if (!context) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} + +export function AuthProvider({ children }) { + const [email, setEmail] = useState(""); + const [token, setToken] = useState(""); + + const login = async (userEmail, password) => { + try { + const options = { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: userEmail, password }), + credentials: "include", + }; + + const res = await fetch("/api/users/logon", options); + const data = await res.json(); + + if (res.status === 200 && data.name && data.csrfToken) { + setEmail(data.name); + setToken(data.csrfToken); + return { success: true }; + } else { + return { + success: false, + error: `Authentication failed: ${data?.message}`, + }; + } + } catch { + return { + success: false, + error: "Network error during login", + }; + } + }; + + const logout = async () => { + try { + if (!token) { + setEmail(""); + setToken(""); + return { + success: true, + }; + } + const options = { + method: "POST", + headers: { + "X-CSRF-TOKEN": token, + }, + credentials: "include", + }; + const res = await fetch("/api/users/logoff", options); + setEmail(""); + setToken(""); + if (res.ok) { + return { + success: true, + }; + } + return { + success: false, + error: "Logout failed", + }; + } catch { + setEmail(""); + setToken(""); + return { + success: false, + error: "Network error during logout", + }; + } + }; + + const value = { + email: email, + token: token, + isAuthenticated: !!token, + login: login, + logout: logout, + }; + + return {children}; +} diff --git a/src/features/Logoff.jsx b/src/features/Logoff.jsx new file mode 100644 index 0000000..81fdbe0 --- /dev/null +++ b/src/features/Logoff.jsx @@ -0,0 +1,23 @@ +import { useState } from "react"; +import { useAuth } from "../contexts/AuthContext.jsx"; + +export default function Logoff() { + const { logout } = useAuth(); + const [error, setError] = useState(""); + + async function handleLogoff() { + const result = await logout(); + if (!result.success) { + setError(result.error); + } + } + + return ( + <> + {error &&

{error}

} + + + ); +} diff --git a/src/features/Logon.jsx b/src/features/Logon.jsx index 0059489..2c6444f 100644 --- a/src/features/Logon.jsx +++ b/src/features/Logon.jsx @@ -1,28 +1,20 @@ import { useState } from "react"; +import { useAuth } from "../contexts/AuthContext.jsx"; -export default function Logon({ onSetEmail, onSetToken }) { +export default function Logon() { + const { login } = useAuth(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [authError, setAuthError] = useState(""); const [isLoggingOn, setIsLoggingOn] = useState(false); - async function handleSubmit(event) { event.preventDefault(); setIsLoggingOn(true); try { - const response = await fetch("/api/users/logon", { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ email, password }), - }); - const data = await response.json(); - if (response.status === 200 && data.name && data.csrfToken) { - onSetEmail(data.name); - onSetToken(data.csrfToken); - } else { - setAuthError(`Authentication failed: ${data?.message}`); + const result = await login(email, password); + if (!result.success) { + setAuthError(result.error); } } catch (error) { setAuthError(`Error: ${error.name} | ${error.message}`); diff --git a/src/features/Todos/TodosPage.jsx b/src/features/Todos/TodosPage.jsx index f5f65c5..4292d33 100644 --- a/src/features/Todos/TodosPage.jsx +++ b/src/features/Todos/TodosPage.jsx @@ -1,33 +1,58 @@ -import { useState } from "react"; import { useEffect } from "react"; import { useCallback } from "react"; +import { useReducer } from "react"; +import { useAuth } from "../../contexts/AuthContext.jsx"; + +import { + todoReducer, + initialTodoState, + TODO_ACTIONS, +} from "../../reducers/todoReducer"; + import TodoList from "./TodoList/TodoList.jsx"; import TodoForm from "./TodoForm.jsx"; import SortBy from "../../shared/SortBy.jsx"; import FilterInput from "../../shared/FilterInput.jsx"; + import useDebounce from "../../utils/useDebounce.js"; -export default function TodosPage({ token }) { - const [todoList, setTodoList] = useState([]); - const [isTodoListLoading, setIsTodoListLoading] = useState(false); - const [error, setError] = useState(""); - const [filterError, setFilterError] = useState(""); - const [sortBy, setSortBy] = useState("creationDate"); - const [sortDirection, setSortDirection] = useState("desc"); - const [filterTerm, setFilterTerm] = useState(""); +export default function TodosPage() { + const { token } = useAuth(); + const [state, dispatch] = useReducer(todoReducer, initialTodoState); + + const { + todoList, + error, + filterError, + isTodoListLoading, + sortBy, + sortDirection, + filterTerm, + dataVersion, + } = state; + const debouncedFilterTerm = useDebounce(filterTerm, 300); + const handleFilterChange = (newTerm) => { - setFilterTerm(newTerm); + dispatch({ + type: TODO_ACTIONS.SET_FILTER, + payload: { + filterTerm: newTerm, + }, + }); }; - const [dataVersion, setDataVersion] = useState(0); const invalidateCache = useCallback(() => { - setDataVersion((prev) => prev + 1); + dispatch({ + type: TODO_ACTIONS.FETCH_START, + }); }, []); useEffect(() => { async function fetchTodos() { - setIsTodoListLoading(true); + dispatch({ + type: TODO_ACTIONS.FETCH_START, + }); try { const paramsObject = { sortBy, @@ -43,8 +68,12 @@ export default function TodosPage({ token }) { }); const data = await response.json(); if (response.ok) { - setTodoList(data.tasks); - setFilterError(""); + dispatch({ + type: TODO_ACTIONS.FETCH_SUCCESS, + payload: { + tasks: data.tasks, + }, + }); } else if (response.status === 401) { throw new Error("unauthorized"); } else { @@ -56,12 +85,22 @@ export default function TodosPage({ token }) { sortBy !== "creationDate" || sortDirection !== "desc" ) { - setFilterError(`Error filtering/sorting todos: ${error.message}`); + dispatch({ + type: TODO_ACTIONS.FETCH_ERROR, + payload: { + message: `Error filtering/sorting todos: ${error.message}`, + isFilterError: true, + }, + }); } else { - setError(`Error fetching todos: ${error.message}`); + dispatch({ + type: TODO_ACTIONS.FETCH_ERROR, + payload: { + message: `Error fetching todos: ${error.message}`, + isFilterError: false, + }, + }); } - } finally { - setIsTodoListLoading(false); } } fetchTodos(); @@ -73,7 +112,12 @@ export default function TodosPage({ token }) { title: todoTitle, isCompleted: false, }; - setTodoList((previous) => [newTodo, ...previous]); + dispatch({ + type: TODO_ACTIONS.ADD_TODO_START, + payload: { + newTodo, + }, + }); try { const response = await fetch("/api/tasks", { @@ -90,30 +134,38 @@ export default function TodosPage({ token }) { }); const data = await response.json(); if (response.ok) { - setTodoList((previous) => - previous.map((todo) => (todo.id === newTodo.id ? data : todo)), - ); + dispatch({ + type: TODO_ACTIONS.ADD_TODO_SUCCESS, + payload: { + tempId: newTodo.id, + savedTodo: data, + }, + }); invalidateCache(); } else { throw new Error("Unable to add task"); } } catch (error) { - setTodoList((previous) => - previous.filter((todo) => todo.id !== newTodo.id), - ); - setError(error.message); + dispatch({ + type: TODO_ACTIONS.ADD_TODO_ERROR, + payload: { + tempId: newTodo.id, + message: error.message, + }, + }); } } async function completeTodo(id) { const originTodo = todoList.find((todo) => todo.id === id); - const updatedTodoList = todoList.map((todo) => { - if (todo.id === id) { - return { ...todo, isCompleted: true }; - } - return todo; + + dispatch({ + type: TODO_ACTIONS.COMPLETE_TODO_START, + payload: { + id, + }, }); - setTodoList(updatedTodoList); + try { const response = await fetch(`/api/tasks/${id}`, { method: "PATCH", @@ -131,28 +183,32 @@ export default function TodosPage({ token }) { if (!response.ok) { throw new Error("Unable to complete task"); } - invalidateCache(); - } catch (error) { - setTodoList((previous) => - previous.map((todo) => (todo.id === id ? originTodo : todo)), - ); - setError(error.message); + dispatch({ + type: TODO_ACTIONS.COMPLETE_TODO_SUCCESS, + }); + } catch (error) { + dispatch({ + type: TODO_ACTIONS.COMPLETE_TODO_ERROR, + payload: { + id, + originTodo, + message: error.message, + }, + }); } } async function updateTodo(editedTodo) { const originTodo = todoList.find((todo) => todo.id === editedTodo.id); - const updatedTodos = todoList.map((todo) => { - if (todo.id === editedTodo.id) { - return { ...editedTodo }; - } - - return todo; + dispatch({ + type: TODO_ACTIONS.UPDATE_TODO_START, + payload: { + editedTodo, + }, }); - setTodoList(updatedTodos); try { const response = await fetch(`/api/tasks/${editedTodo.id}`, { method: "PATCH", @@ -171,13 +227,19 @@ export default function TodosPage({ token }) { if (!response.ok) { throw new Error("Unable to update task"); } - invalidateCache(); - } catch (error) { - setTodoList((previous) => - previous.map((todo) => (todo.id === editedTodo.id ? originTodo : todo)), - ); - setError(error.message); + dispatch({ + type: TODO_ACTIONS.UPDATE_TODO_SUCCESS, + }); + } catch (error) { + dispatch({ + type: TODO_ACTIONS.UPDATE_TODO_ERROR, + payload: { + id: editedTodo.id, + originTodo, + message: error.message, + }, + }); } } @@ -186,7 +248,14 @@ export default function TodosPage({ token }) { {error ? (

{error}

-
@@ -196,19 +265,24 @@ export default function TodosPage({ token }) {

{filterError}

- - + @@ -220,8 +294,24 @@ export default function TodosPage({ token }) { + dispatch({ + type: TODO_ACTIONS.SET_SORT, + payload: { + sortBy: newSortBy, + sortDirection: sortDirection, + }, + }) + } + onSortDirectionChange={(newSortDirection) => + dispatch({ + type: TODO_ACTIONS.SET_SORT, + payload: { + sortBy: sortBy, + sortDirection: newSortDirection, + }, + }) + } /> + + , ); diff --git a/src/reducers/todoReducer.js b/src/reducers/todoReducer.js new file mode 100644 index 0000000..69b4d82 --- /dev/null +++ b/src/reducers/todoReducer.js @@ -0,0 +1,201 @@ +export const TODO_ACTIONS = { + // Fetch operations + FETCH_START: "FETCH_START", + FETCH_SUCCESS: "FETCH_SUCCESS", + FETCH_ERROR: "FETCH_ERROR", + + // Add todo operations + ADD_TODO_START: "ADD_TODO_START", + ADD_TODO_SUCCESS: "ADD_TODO_SUCCESS", + ADD_TODO_ERROR: "ADD_TODO_ERROR", + + COMPLETE_TODO_START: "COMPLETE_TODO_START", + COMPLETE_TODO_SUCCESS: "COMPLETE_TODO_SUCCESS", + COMPLETE_TODO_ERROR: "COMPLETE_TODO_ERROR", + + UPDATE_TODO_START: "UPDATE_TODO_START", + UPDATE_TODO_SUCCESS: "UPDATE_TODO_SUCCESS", + UPDATE_TODO_ERROR: "UPDATE_TODO_ERROR", + + SET_SORT: "SET_SORT", + SET_FILTER: "SET_FILTER", + CLEAR_ERROR: "CLEAR_ERROR", + CLEAR_FILTER_ERROR: "CLEAR_FILTER_ERROR", + RESET_FILTERS: "RESET_FILTERS", +}; + +export const initialTodoState = { + todoList: [], + error: "", + filterError: "", + isTodoListLoading: true, + sortBy: "creationDate", + sortDirection: "desc", + filterTerm: "", + dataVersion: 0, +}; + +export function todoReducer(state, action) { + switch (action.type) { + case TODO_ACTIONS.FETCH_START: + return { + ...state, + isTodoListLoading: true, + error: "", + filterError: "", + }; + + case TODO_ACTIONS.FETCH_SUCCESS: + return { + ...state, + todoList: action.payload.tasks, + isTodoListLoading: false, + error: "", + filterError: "", + }; + + case TODO_ACTIONS.FETCH_ERROR: + if (action.payload.isFilterError) { + return { + ...state, + isTodoListLoading: false, + filterError: action.payload.message, + }; + } + return { + ...state, + isTodoListLoading: false, + error: action.payload.message, + }; + + case TODO_ACTIONS.ADD_TODO_START: + return { + ...state, + todoList: [action.payload.newTodo, ...state.todoList], + error: "", + }; + + case TODO_ACTIONS.ADD_TODO_SUCCESS: + return { + ...state, + todoList: state.todoList.map((todo) => { + if (todo.id === action.payload.tempId) { + return action.payload.savedTodo; + } + + return todo; + }), + dataVersion: state.dataVersion + 1, + }; + + case TODO_ACTIONS.ADD_TODO_ERROR: + return { + ...state, + todoList: state.todoList.filter((todo) => { + return todo.id !== action.payload.tempId; + }), + error: action.payload.message, + }; + + case TODO_ACTIONS.COMPLETE_TODO_START: + return { + ...state, + todoList: state.todoList.map((todo) => { + if (todo.id === action.payload.id) { + return { ...todo, isCompleted: true }; + } + return todo; + }), + error: "", + }; + + case TODO_ACTIONS.COMPLETE_TODO_SUCCESS: + return { + ...state, + dataVersion: state.dataVersion + 1, + }; + + case TODO_ACTIONS.COMPLETE_TODO_ERROR: + return { + ...state, + todoList: state.todoList.map((todo) => { + if (todo.id === action.payload.id) { + return action.payload.originTodo; + } + return todo; + }), + error: action.payload.message, + }; + + case TODO_ACTIONS.UPDATE_TODO_START: + return { + ...state, + todoList: state.todoList.map((todo) => { + if (todo.id === action.payload.editedTodo.id) { + return { + ...action.payload.editedTodo, + }; + } + return todo; + }), + error: "", + }; + + case TODO_ACTIONS.UPDATE_TODO_SUCCESS: + return { + ...state, + dataVersion: state.dataVersion + 1, + }; + + case TODO_ACTIONS.UPDATE_TODO_ERROR: + return { + ...state, + todoList: state.todoList.map((todo) => { + if (todo.id === action.payload.id) { + return action.payload.originTodo; + } + return todo; + }), + error: action.payload.message, + }; + + case TODO_ACTIONS.SET_SORT: + return { + ...state, + sortBy: action.payload.sortBy, + sortDirection: action.payload.sortDirection, + filterError: "", + }; + + case TODO_ACTIONS.SET_FILTER: + return { + ...state, + filterTerm: action.payload.filterTerm, + filterError: "", + }; + + case TODO_ACTIONS.CLEAR_ERROR: + return { + ...state, + error: "", + }; + + case TODO_ACTIONS.CLEAR_FILTER_ERROR: + return { + ...state, + filterError: "", + }; + + case TODO_ACTIONS.RESET_FILTERS: + return { + ...state, + filterTerm: "", + sortBy: "creationDate", + sortDirection: "desc", + filterError: "", + }; + + default: + throw new Error(`Unknown action type: ${action.type}`); + } +} diff --git a/src/shared/Header.jsx b/src/shared/Header.jsx index c805992..81f130d 100644 --- a/src/shared/Header.jsx +++ b/src/shared/Header.jsx @@ -1,3 +1,12 @@ +import { useAuth } from "../contexts/AuthContext.jsx"; +import Logoff from "../features/Logoff.jsx"; + export default function Header() { - return

Todo List

; + const { isAuthenticated } = useAuth(); + return ( + <> +

Todo List

+ {isAuthenticated ? : null} + + ); }