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}
-
setFilterError("")}>
+
+ dispatch({
+ type: TODO_ACTIONS.CLEAR_FILTER_ERROR,
+ })
+ }
+ >
Clear filter error
-
+
{
- setFilterTerm("");
- setSortBy("creationDate");
- setSortDirection("desc");
- setFilterError("");
- }}
+ onClick={() =>
+ dispatch({
+ type: TODO_ACTIONS.RESET_FILTERS,
+ })
+ }
>
Reset filters
@@ -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}
+ >
+ );
}