diff --git a/src/components/Admin.js b/src/components/Admin.js index 101f15a..4d50596 100644 --- a/src/components/Admin.js +++ b/src/components/Admin.js @@ -1,113 +1,120 @@ -// src/components/Admin.js import React, { useState, useEffect, useRef } from 'react'; +import axios from 'axios'; import { Link, useNavigate } from 'react-router-dom'; import { PRIMARY_COLOR, SECONDARY_COLOR } from '../App'; +import './Admin.css'; +import { useApiCall } from './hooks/useApiCall'; import { api } from '../services/api'; -import './Admin.css'; // Import the CSS file import { - Typography, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Paper, - Button, - Alert, - Container, - Box, - TextField, - Tab - } from '@mui/material'; + Typography, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Button, + Alert, + Container, + Box, + TextField, + CircularProgress +} from '@mui/material'; +export default function Admin() { + const [users, setUsers] = useState([]); + const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const navigate = useNavigate(); + const fileInputRef = useRef(null); - export default function Admin() { - const [users, setUsers] = useState([]); - const [username, setUsername] = useState(''); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const navigate = useNavigate(); - const fileInputRef = useRef(null); - - useEffect(() => { - fetchUsers(); - }, []); - - const fetchUsers = async () => { + const { execute: fetchUsers, loading: loadingUsers, error: usersError } = useApiCall(); + const { execute: createUser, loading: creatingUser, error: createError } = useApiCall(); + const { execute: deleteUser, loading: deletingUser, error: deleteError } = useApiCall(); + const { execute: backupDB, loading: backingUp, error: backupError } = useApiCall(); + const { execute: restoreDB, loading: restoring, error: restoreError } = useApiCall(); + + useEffect(() => { + const getUsers = async () => { try { - const response = await api.admin.getUsers(localStorage.getItem('token')); + const response = await fetchUsers(() => + api.admin.getUsers(localStorage.getItem('token')) + ); setUsers(response.data); - } catch (error) { - console.error('Error fetching users:', error); - } + } catch (err) {} }; - - const handleCreateUser = async (e) => { - e.preventDefault(); - try { - const response = await api.admin.createUser(localStorage.getItem('token'), { - username, - password, - email - }); - setUsers([...users, response.data]); - setUsername(''); - setPassword(''); - setEmail(''); - } catch (error) { - console.error(error); - } - }; - - const handleDeleteUser = async (id) => { - try { - await api.admin.deleteUser(localStorage.getItem('token'), id); - setUsers(users.filter(user => user.id !== id)); - fetchUsers(); - } catch (error) { - console.error(error); - } - }; - - const handleBackupDatabase = async () => { - try { - const response = await api.admin.backupDb(localStorage.getItem('token')); - const blob = new Blob([response.data], { type: 'application/x-sqlite3' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'database.db'; - a.click(); - } catch (error) { - console.error(error); - } - }; - - const handleRestoreDatabase = async (e) => { - e.preventDefault(); - try { - const file = fileInputRef.current.files[0]; - const formData = new FormData(); - formData.append('database', file); - - const response = await api.admin.restoreDb(localStorage.getItem('token'), formData); - - if (response.status === 200) { - alert('Database restored successfully'); - navigate('/admin'); - } - } catch (error) { - console.error(error); - } - }; - + getUsers(); + }, []); + + const handleCreateUser = async (e) => { + e.preventDefault(); + try { + const response = await createUser(() => + api.admin.createUser( + localStorage.getItem('token'), + { username, password, email } + ) + ); + setUsers([...users, response.data]); + setUsername(''); + setPassword(''); + setEmail(''); + } catch (err) {} + }; + + const handleDeleteUser = async (id) => { + try { + await deleteUser(() => + api.admin.deleteUser(localStorage.getItem('token'), id) + ); + setUsers(users.filter(user => user.id !== id)); + } catch (err) {} + }; + + const handleBackupDatabase = async () => { + try { + const response = await backupDB(() => + api.admin.backupDB(localStorage.getItem('token')) + ); + const blob = new Blob([response.data], { type: 'application/x-sqlite3' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'database.db'; + a.click(); + } catch (err) {} + }; + + const handleRestoreDatabase = async (e) => { + e.preventDefault(); + if (!fileInputRef.current?.files?.[0]) return; + + try { + const formData = new FormData(); + formData.append('database', fileInputRef.current.files[0]); + + await restoreDB(() => + api.admin.restoreDB(localStorage.getItem('token'), formData) + ); + alert('Database restored successfully'); + navigate('/admin'); + } catch (err) {} + }; return ( - Admin + Admin Dashboard + + {(usersError || createError || deleteError || backupError || restoreError) && ( + + {usersError?.message || createError?.message || deleteError?.message || + backupError?.message || restoreError?.message} + + )} @@ -119,6 +126,7 @@ import { value={username} onChange={(e) => setUsername(e.target.value)} sx={{ mr: 2 }} + disabled={creatingUser} /> setPassword(e.target.value)} sx={{ mr: 2 }} + disabled={creatingUser} /> setEmail(e.target.value)} sx={{ mr: 2 }} + disabled={creatingUser} /> - - - - - - ID - Username - Email - Actions - - - - {users.map(user => ( - - {user.ID} - {user.username} - {user.email} - - - + {loadingUsers ? ( + + ) : ( + +
+ + + ID + Username + Email + Actions - ))} - -
-
+ + + {users.map(user => ( + + {user.ID} + {user.username} + {user.email} + + + + + ))} + + + + )} + + + ))} + + + + )} + setNewBoxName(e.target.value)} + disabled={creatingBox} /> -
); diff --git a/src/components/ItemDetails.js b/src/components/ItemDetails.js index 2659a19..2c37f5f 100644 --- a/src/components/ItemDetails.js +++ b/src/components/ItemDetails.js @@ -1,141 +1,123 @@ -// src/components/ItemDetails.js -import React, { useState, useEffect, useRef, useCallback } from 'react'; -import { TextField, Button, Container, Avatar, Tooltip } from '@mui/material'; +import React, { useState, useEffect, useRef } from 'react'; +import { TextField, Button, Container, Avatar, Tooltip, Alert, CircularProgress } from '@mui/material'; import { PRIMARY_COLOR, SECONDARY_COLOR } from '../App'; +import { useApiCall } from './hooks/useApiCall'; import { api } from '../services/api'; -export default function ItemDetails({ item, token, onSave, onClose, boxId }) { +export default function ItemDetails({ item, token, onSave, onClose }) { const [name, setName] = useState(item.name); const [description, setDescription] = useState(item.description); const [imagePath, setImagePath] = useState(item.image_path || ''); const [imageSrc, setImageSrc] = useState('/images/default.jpg'); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); const fileInputRef = useRef(null); const [imageOverlayVisible, setImageOverlayVisible] = useState(false); - const [boxName, setBoxName] = useState(''); const [boxes, setBoxes] = useState([]); const [selectedBoxId, setSelectedBoxId] = useState(item.box_id); + const { execute: fetchBoxes, loading: loadingBoxes, error: boxesError } = useApiCall(); + const { execute: updateItem, loading: savingItem, error: saveError } = useApiCall(); + const { execute: uploadImage, loading: uploadingImage, error: uploadError } = useApiCall(); + useEffect(() => { - const fetchBoxes = async () => { + const getBoxes = async () => { try { - const response = await api.boxes.getAll(token); + const response = await fetchBoxes(() => api.boxes.getAll(token)); setBoxes(response.data); - } catch (error) { - console.error('Error fetching boxes:', error); - } + } catch (err) {} }; - fetchBoxes(); + getBoxes(); }, [token]); - const handleBoxChange = (event) => { - const newBoxId = event.target.value; - setSelectedBoxId(newBoxId); - }; - useEffect(() => { - const getBoxDetails = async (boxId) => { - try { - const boxIdNumber = +boxId; - if (isNaN(boxIdNumber)) { - console.error('Invalid boxId:', boxId); - return; - } - const response = await api.boxes.getAll(token); - const box = response.data.find(b => b.ID === boxIdNumber); - if (box) { - setBoxName(box.name); - } - } catch (error) { - console.error('Error fetching box details:', error); - } - }; - - if (selectedBoxId !== item.box_id) { - getBoxDetails(selectedBoxId); - } else if (item.box_id) { - getBoxDetails(item.box_id); - } - }, [selectedBoxId, token, item.box_id]); - - useEffect(() => { - const fetchItemImage = async () => { + const loadImage = async () => { + setLoading(true); + setError(null); try { const response = await api.items.getImage(token, item.ID); const reader = new FileReader(); - reader.onload = () => setImageSrc(reader.result); + reader.onload = () => { + setImageSrc(reader.result); + setLoading(false); + }; reader.readAsDataURL(response.data); - } catch (error) { + } catch (err) { setImageSrc('/default.jpg'); + setError(err); + setLoading(false); } }; - fetchItemImage(); + loadImage(); }, [item.ID, token]); - const handleImageUpload = useCallback(async () => { - if (!fileInputRef.current.files[0]) return; + const handleImageUpload = async () => { + if (!fileInputRef.current?.files?.[0]) return null; const formData = new FormData(); formData.append('image', fileInputRef.current.files[0]); try { - const response = await api.items.uploadImage(token, item.ID, formData); + const response = await uploadImage(() => + api.items.uploadImage(token, item.ID, formData) + ); return response.data.imagePath; - } catch (error) { - console.error('Image upload failed:', error); + } catch (err) { + return null; } - }, [item.ID, token]); + }; - const handleSave = useCallback(async () => { - let imagePath; - if (fileInputRef.current.files[0]) { - imagePath = await handleImageUpload(); + const handleSave = async () => { + if (fileInputRef.current?.files?.[0]) { + await handleImageUpload(); } try { - await api.items.update(token, item.ID, { + await updateItem(() => api.items.update(token, item.ID, { name, description, box_id: +selectedBoxId, - }); + })); onSave(); - } catch (error) { - console.error('Item update failed:', error); - } - }, [item.ID, name, description, selectedBoxId, token, onSave, handleImageUpload]); - - const handleImageError = (e) => { - e.target.src = '/images/default.jpg'; + } catch (err) {} }; - const handleAvatarClick = () => { - setImageOverlayVisible(true); - }; - - const handleCloseOverlay = () => { - setImageOverlayVisible(false); + const handleBoxChange = (event) => { + setSelectedBoxId(event.target.value); }; return (

Edit Item: {item.name}

+ + {(error || boxesError || saveError || uploadError) && ( + + {error?.message || boxesError?.message || saveError?.message || uploadError?.message} + + )} + + {loading ? ( + + ) : ( + + setImageOverlayVisible(true)} + /> + + )} - - - {imageOverlayVisible && (
{name} -
)} + setName(e.target.value)} + disabled={savingItem} /> setDescription(e.target.value)} + disabled={savingItem} /> - setImagePath(e.target.value)} - sx={{ display: 'none' }} - /> - - -
-
- - + + -
); } \ No newline at end of file diff --git a/src/components/Items.js b/src/components/Items.js index 461d825..74a6025 100644 --- a/src/components/Items.js +++ b/src/components/Items.js @@ -1,19 +1,9 @@ -import React, { useEffect, useState, useCallback, useRef } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { Container, - List, - ListItem, - ListItemText, - TextField, Button, - IconButton, - Typography, - Avatar, - ListItemAvatar, - Dialog, - DialogTitle, - DialogContent, - DialogActions, + IconButton, + TextField, TableContainer, Table, TableHead, @@ -21,213 +11,52 @@ import { TableCell, TableBody, Box, - Tooltip + Tooltip, + Avatar, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Alert, + CircularProgress } from '@mui/material'; - import { Delete as DeleteIcon, Edit as EditIcon } from '@mui/icons-material'; -import axios from 'axios'; -import { useParams, useLocation } from 'react-router-dom'; -import ItemDetails from './ItemDetails'; +import { useParams, useLocation } from 'react-router-dom'; import { PRIMARY_COLOR, SECONDARY_COLOR } from '../App'; +import { useApiCall } from './hooks/useApiCall'; import { api } from '../services/api'; +import ItemDetails from './ItemDetails'; export default function Items({ token }) { - const { id: boxId } = useParams(); + const { id: boxId } = useParams(); const [items, setItems] = useState([]); const [newItemName, setNewItemName] = useState(''); const [newItemDescription, setNewItemDescription] = useState(''); - // const [newItemImagePath, setNewItemImagePath] = useState('/images/default.jpg'); const [editingItem, setEditingItem] = useState(null); const location = useLocation(); const boxName = location.state?.boxName || 'Unknown Box'; - // const boxID = location.state?.boxId; // used in handleClose function const [itemImages, setItemImages] = useState({}); const fileInputRef = useRef(null); - const [openAddItemDialog, setOpenAddItemDialog] = useState(false); // For Add Item dialog - const { id } = useParams(); - const boxID = id; - const url = boxId === undefined ? `${process.env.REACT_APP_API_URL}/api/v1/items` : `${process.env.REACT_APP_API_URL}/api/v1/boxes/${boxId}/items`; + const [openAddItemDialog, setOpenAddItemDialog] = useState(false); const [searchQuery, setSearchQuery] = useState(''); - - const debugLog = (message) => { - if (process.env.DEBUG_API) { - console.log(message); - } - }; - debugLog("Box ID: " + boxID); - - // const handleSelectItem = (item) => { - // setSelectedItem(item); - // }; - const handleAddItem = () => { - setOpenAddItemDialog(true); - }; - - const handleCloseAddItemDialog = () => { - setOpenAddItemDialog(false); - setNewItemName(''); - setNewItemDescription(''); - // setNewItemImagePath(''); - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - }; - - /** - * This function takes an image name and returns a unique image name. - * The purpose of this function is to prevent overwriting of images with the same name. - * If the image name is 'image.jpg', a random string is generated and appended to the image name. - * This is used to prevent overwriting of images with the same name. - * For example, if an image named 'image.jpg' is uploaded, the function will return 'image_8xgu6hcu.jpg' - * This ensures that the image will not overwrite any existing image with the same name. - * @param {string} imageName - The name of the image - * @return {string} - The unique image name - */ - const generateUniqueImageName = (imageName) => { - if (imageName.toLowerCase() === 'image.jpg') { - // Generate a random string - const randomString = Math.random().toString(36).substr(2, 9); - // Append the random string to the image name - return `image_${randomString}.jpg`; - } - // Return the original image name if it's not 'image.jpg' - return imageName; - }; + const { execute: fetchItems, loading: loadingItems, error: itemsError } = useApiCall(); + const { execute: createItem, loading: creatingItem, error: createError } = useApiCall(); + const { execute: deleteItem, loading: deletingItem, error: deleteError } = useApiCall(); + const { execute: uploadImage, loading: uploadingImage, error: uploadError } = useApiCall(); - const handleImageUpload = async (itemId, imageFile, newImageName) => { - const formData = new FormData(); - //const imageFile = fileInputRef.current.files[0]; - //const newImageName = generateUniqueImageName(imageFile.name); - formData.append('image', new File([imageFile], newImageName, { - type: imageFile.type, - })); + const url = boxId ? + `${process.env.REACT_APP_API_URL}/api/v1/boxes/${boxId}/items` : + `${process.env.REACT_APP_API_URL}/api/v1/items`; - // Create a new file with the unique name - // eslint-disable-next-line - const newImageFile = new File([imageFile], newImageName, { - type: imageFile.type, - }); - - try { - const response = await axios.post(`${process.env.REACT_APP_API_URL}/api/v1/items/${itemId}/upload`, formData, { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'multipart/form-data' - } - }); - // console.log('Image uploaded successfully!'); - return response.data.imagePath; // Indicate successful upload - } catch (error) { - console.error('Image upload failed:', error); - return null; // Indicate upload failure - } - }; - - /** - * Handle saving a new item. - * - * This function first creates the item without the image, then if the item creation is successful, - * it uploads the image associated with the item. - * - * @return {Promise} - */ - - const handleSaveNewItem = async () => { - try { - const newItemResponse = await api.items.create(token, { - name: newItemName, - description: newItemDescription, - box_id: parseInt(boxId, 10) - }); - - if (newItemResponse.status === 200 && fileInputRef.current.files[0]) { - const newItemId = newItemResponse.data.id; - const imageFile = fileInputRef.current.files[0]; - const newImageName = generateUniqueImageName(imageFile.name); - const formData = new FormData(); - formData.append('image', new File([imageFile], newImageName, { - type: imageFile.type, - })); - - await api.items.uploadImage(token, newItemId, formData); - } - - handleCloseAddItemDialog(); - fetchItems(); - } catch (error) { - console.error('Error adding item:', error); - } - }; - - - - - - //const [selectedItem, setSelectedItem] = React.useState(null); - - const handleCloseItemDetails = () => { - setEditingItem(null); // Close the ItemDetails modal - }; - - const handleImageError = (e) => { - if (e.target.src.startsWith('data:image/')) { - console.error("Default image failed to load. Check the file path."); - return; - } - const reader = new FileReader(); - reader.onload = () => { - e.target.onerror = null; - e.target.src = reader.result; - }; - fetch('/default.jpg') - .then(res => res.blob()) - .then(blob => reader.readAsDataURL(blob)) - .catch(error => console.error("Error loading default image:", error)); - }; - - const getImageSrc = useCallback((itemId) => { - return axios.get(`${process.env.REACT_APP_API_URL}/api/v1/items/${itemId}/image`, { - headers: { Authorization: `Bearer ${token}` }, - responseType: 'blob' - }) - .then(response => { - if (response.status === 200) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result); - reader.onerror = reject; - reader.readAsDataURL(response.data); - }); - } else { - throw new Error('Image fetch failed'); - } - }) - .catch(() => { - return new Promise((resolve, reject) => { - const img = new Image(); - img.src = '/default.jpg'; - img.onload = () => { - const canvas = document.createElement('canvas'); - canvas.width = img.width; - canvas.height = img.height; - const ctx = canvas.getContext('2d'); - ctx.drawImage(img, 0, 0); - resolve(canvas.toDataURL()); - }; - img.onerror = reject; - }); - }); - }, [token]); - - const fetchItems = useCallback(() => { - const fetchData = boxId ? - api.items.getByBox(token, boxId) : - api.items.getAll(token); - - fetchData - .then(response => { + useEffect(() => { + const getItems = async () => { + try { + const response = await fetchItems(() => + api.items.getAll(token, boxId) + ); setItems(response.data); + // Fetch images for each item response.data.forEach(item => { api.items.getImage(token, item.ID) @@ -240,61 +69,107 @@ export default function Items({ token }) { })); }; reader.readAsDataURL(response.data); + }) + .catch(() => { + setItemImages(prev => ({ + ...prev, + [item.ID]: '/default.jpg' + })); }); }); - }); + } catch (err) {} + }; + getItems(); }, [token, boxId]); - // lint says I don't need boxId here - useEffect(() => { - fetchItems(); - }, [boxId, token, fetchItems]); + const handleAddItem = () => { + setOpenAddItemDialog(true); + }; - // const handleAddItem = () => { - // const formData = new FormData(); - // formData.append('name', newItemName); - // formData.append('description', newItemDescription); - // formData.append('box_id', parseInt(boxId, 10)); - // // Append image only if a new one is selected - // if (fileInputRef.current.files[0]) { - // formData.append('image', fileInputRef.current.files[0]); - // } + const handleCloseAddItemDialog = () => { + setOpenAddItemDialog(false); + setNewItemName(''); + setNewItemDescription(''); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; - // axios.post(`${process.env.REACT_APP_API_URL}/items`, formData, { - // headers: { - // Authorization: `Bearer ${token}`, - // 'Content-Type': 'multipart/form-data' // Important for file uploads - // } - // }).then(() => { - // setNewItemName(''); - // setNewItemDescription(''); - // setNewItemImagePath(''); - // // Clear the file input - // if (fileInputRef.current) { - // fileInputRef.current.value = ''; - // } - // fetchItems(); - // }); - // }; + const generateUniqueImageName = (imageName) => { + if (imageName.toLowerCase() === 'image.jpg') { + const randomString = Math.random().toString(36).substr(2, 9); + return `image_${randomString}.jpg`; + } + return imageName; + }; - const handleDeleteItem = (itemId) => { - api.items.delete(token, itemId) - .then(() => { - fetchItems(); - }); + const handleSaveNewItem = async () => { + try { + const newItemResponse = await createItem(() => + api.items.create(token, { + name: newItemName, + description: newItemDescription, + box_id: parseInt(boxId, 10) + }) + ); + + if (newItemResponse.status === 200 && fileInputRef.current?.files?.[0]) { + const imageFile = fileInputRef.current.files[0]; + const newImageName = generateUniqueImageName(imageFile.name); + const formData = new FormData(); + formData.append('image', new File([imageFile], newImageName, { + type: imageFile.type, + })); + + await uploadImage(() => + api.items.uploadImage(token, newItemResponse.data.id, formData) + ); + } + + handleCloseAddItemDialog(); + + // Refresh items list + const response = await fetchItems(() => + api.items.getAll(token, boxId) + ); + setItems(response.data); + } catch (err) {} + }; + + const handleDeleteItem = async (itemId) => { + try { + await deleteItem(() => + api.items.delete(token, itemId) + ); + setItems(items.filter(item => item.ID !== itemId)); + } catch (err) {} }; const handleEditItem = (item) => { setEditingItem(item); }; - const handleSaveEdit = () => { + const handleSaveEdit = async () => { setEditingItem(null); - fetchItems(); + const response = await fetchItems(() => + api.items.getAll(token, boxId) + ); + setItems(response.data); }; + const filteredItems = items.filter(item => + item.name.toLowerCase().includes(searchQuery.toLowerCase()) || + item.description.toLowerCase().includes(searchQuery.toLowerCase()) + ); + return ( + {(itemsError || createError || deleteError || uploadError) && ( + + {itemsError?.message || createError?.message || deleteError?.message || uploadError?.message} + + )} + setSearchQuery(e.target.value)} /> -

Items in Box: {boxName === "Unknown Box" ? "All Boxes" : `${boxName} (${items.length} items)`}

- - - {/* Dialog for adding new item */} Add New Item @@ -340,75 +213,80 @@ export default function Items({ token }) { type="file" accept="image/*" ref={fileInputRef} - // capture="environment" // Capture the image from the user's camera - style={{ display: 'block', margin: '10px 0' }} // Style as needed - id="newItemImageUpload" + style={{ display: 'block', margin: '10px 0' }} /> - - {editingItem ? ( + + {editingItem && ( setEditingItem(null)} boxId={boxId} - /> + /> + )} + + {loadingItems ? ( + ) : ( - - - - Image - Name - Description - Actions - - - - {items - .filter(item => - item.name.toLowerCase().includes(searchQuery.toLowerCase()) || - item.description.toLowerCase().includes(searchQuery.toLowerCase()) - ) - .map((item) => ( +
+ + + Image + Name + Description + Actions + + + + {filteredItems.map((item) => ( {item.name} {item.description} - - - handleEditItem(item)} - size="large" - sx={{ mr: 1 }} - > - - - - - handleDeleteItem(item.ID)} - size="large" - color="error" - > - - - - + + + + handleEditItem(item)} + size="large" + sx={{ mr: 1 }} + > + + + + + handleDeleteItem(item.ID)} + size="large" + color="error" + disabled={deletingItem} + > + {deletingItem ? : } + + + + ))} - -
-
+ + + )}
); diff --git a/src/components/Login.js b/src/components/Login.js index e94e171..e5a8b45 100644 --- a/src/components/Login.js +++ b/src/components/Login.js @@ -1,27 +1,26 @@ // src/components/Login.js import React, { useState } from 'react'; -import { Button, TextField, Container, Typography, Alert } from '@mui/material'; -import { useNavigate } from 'react-router-dom'; // Import useNavigate +import { Button, TextField, Container, Typography, Alert, CircularProgress } from '@mui/material'; +import { useNavigate } from 'react-router-dom'; import { PRIMARY_COLOR, SECONDARY_COLOR } from '../App'; import { api } from '../services/api'; - +import { useApiCall } from './hooks/useApiCall'; export default function Login({ setToken }) { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); - const [loginError, setLoginError] = useState(false); const navigate = useNavigate(); + const { execute, loading, error } = useApiCall(); const handleLogin = async (e) => { e.preventDefault(); - setLoginError(false); try { - const response = await api.login({ username, password }); + const response = await execute(() => api.login({ username, password })); setToken(response.data.token); navigate('/boxes'); - } catch (error) { - console.error('Login failed', error); - setLoginError(true); + } catch (err) { + // Error handling is now managed by useApiCall + console.error('Login attempt failed'); } }; @@ -29,9 +28,10 @@ export default function Login({ setToken }) { Login - {/* Display error message if loginError is true */} - {loginError && ( - Login Failed + {error && ( + + {error.status === 401 ? 'Invalid username or password' : error.message} + )}
@@ -52,10 +52,17 @@ export default function Login({ setToken }) { value={password} onChange={(e) => setPassword(e.target.value)} /> -
); -} +} \ No newline at end of file diff --git a/src/components/hooks/useApiCall.js b/src/components/hooks/useApiCall.js new file mode 100644 index 0000000..eaa1bc7 --- /dev/null +++ b/src/components/hooks/useApiCall.js @@ -0,0 +1,43 @@ +import { useState } from 'react'; + +/** + * useApiCall hook. + * + * This hook helps to wrap API calls in a way that makes it easier to manage + * loading state and errors. + * + * @returns {Object} An object with `execute`, `loading`, and `error` + * properties. `execute` is a function that wraps the API call and sets the + * `loading` and `error` states accordingly. `loading` is a boolean that is + * `true` while the API call is in progress and `false` otherwise. `error` is + * `null` if the API call was successful and an error object if the API call + * failed. + * + * @example + * const { execute, loading, error } = useApiCall(); + * + * const fetchData = async () => { + * const response = await execute(api.items.getAll()); + * // Do something with the response + * }; + */ +export const useApiCall = () => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const execute = async (apiCall) => { + setLoading(true); + setError(null); + try { + const result = await apiCall(); + return result; + } catch (err) { + setError(err); + throw err; + } finally { + setLoading(false); + } + }; + + return { execute, loading, error }; + }; \ No newline at end of file diff --git a/src/services/errorHandler.js b/src/services/errorHandler.js new file mode 100644 index 0000000..96cd27f --- /dev/null +++ b/src/services/errorHandler.js @@ -0,0 +1,36 @@ +// src/utils/errorHandler.js +import axios from 'axios'; + +export class ApiError extends Error { + constructor(message, status, details = {}) { + super(message); + this.status = status; + this.details = details; + } + } + + // Enhanced API client with error handling + export const createApiClient = () => { + const client = axios.create({ + baseURL: process.env.REACT_APP_API_URL + }); + + client.interceptors.response.use( + response => response, + error => { + if (error.response?.status === 401) { + localStorage.removeItem('token'); + window.location.href = '/login'; + return Promise.reject(new ApiError('Session expired', 401)); + } + + return Promise.reject(new ApiError( + error.response?.data?.message || 'An error occurred', + error.response?.status, + error.response?.data + )); + } + ); + + return client; + };