diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..faccad0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.git +*.log +build +.DS_Store diff --git a/.env b/.env index 23d4457..2aa934b 100644 --- a/.env +++ b/.env @@ -1 +1,2 @@ -REACT_APP_API_URL=http://127.0.0.1:8080 +REACT_APP_API_URL=http://nebula1:8080 + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dc24a16 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM node:18-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY .env . +COPY . . +CMD ["npm", "run", "build"] diff --git a/Dockerfile.build b/Dockerfile.build new file mode 100644 index 0000000..48475aa --- /dev/null +++ b/Dockerfile.build @@ -0,0 +1,23 @@ +# Stage 1: Install dependencies +FROM node:14 AS build-stage + +WORKDIR /app + +# Copy package.json and package-lock.json first to leverage caching +COPY package*.json ./ + +# Install dependencies +RUN npm install + +# Copy the rest of your application code +COPY .env . +COPY . . + +# Build the application +RUN npm run build + +# Final stage: Use a minimal base image +FROM alpine:latest + +# Copy the build output from the previous stage +COPY --from=build-stage /app/build ./build \ No newline at end of file diff --git a/Dockerfile.create b/Dockerfile.create new file mode 100644 index 0000000..2be1d90 --- /dev/null +++ b/Dockerfile.create @@ -0,0 +1,20 @@ +# Dockerfile.create +FROM node:14 AS builder + +# Set working directory +WORKDIR /app + +# Copy package.json and package-lock.json +COPY package*.json ./ + +# Install dependencies +RUN npm install +RUN npm install -g serve + +# Copy the rest of the application code +COPY . . +COPY .env . + +RUN npm build . + +CMD ["serve", "-s", "build"] diff --git a/Dockerfile.nginx b/Dockerfile.nginx new file mode 100644 index 0000000..ea26fc5 --- /dev/null +++ b/Dockerfile.nginx @@ -0,0 +1,4 @@ +FROM nginx:alpine +COPY build /usr/share/nginx/html +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/Dockerfile.update b/Dockerfile.update new file mode 100644 index 0000000..f7f009d --- /dev/null +++ b/Dockerfile.update @@ -0,0 +1,18 @@ +# Dockerfile.update +FROM node:14 + +# Set working directory +WORKDIR /app + +# Copy package.json and package-lock.json +COPY package*.json ./ +ENV REACT_APP_API_URL=http://zbox.local:8080 + +# Install dependencies +RUN npm install + +# Copy the rest of the application code +COPY . . +COPY .env . + +CMD ["npm", "run", "build"] diff --git a/README.md b/README.md index 6def6f0..b10df65 100644 --- a/README.md +++ b/README.md @@ -27,3 +27,21 @@ This is the frontend application for the Boxes App, built with React. It allows 1. **Clone the repository:** ```bash git clone git@gitea.r8z.us:stwhite/boxes-fe.git + +2. create the docker builder + ```bash + docker build -f Dockerfile.build -t boxes-fe-builder . + ``` + +3. build the application using the docker builder + ```bash + docker run box-builder:latest + docker container create --name tmp-con boxes-fe-builder -- sleep 1200 + docker cp tmp-con:/app/build ./boxes-fe/build + docker rm tmp-con + ``` + +4. copy the appliction to the boxes-api /build directory + ```bash + cp -r ./boxes-fe/build/* ../boxes-api/build/ + ``` \ No newline at end of file diff --git a/build.bash b/build.bash new file mode 100755 index 0000000..9f0369a --- /dev/null +++ b/build.bash @@ -0,0 +1,7 @@ +#!/bin/bash +# Create teh react-builder image +docker build -f Dockerfile.create -t react-builder:latest . +# Run the react-builder image with local build/ dir mounted +docker run -v $(pwd)/build:/app/build react-builder:latest +# create the nginx container: +docker build --no-cache -f Dockerfile.nginx -t boxes-fe:latest . diff --git a/public/index.html b/public/index.html index aa069f2..64d1a54 100644 --- a/public/index.html +++ b/public/index.html @@ -24,7 +24,7 @@ work correctly both with client-side routing and a non-root public URL. Learn how to configure a non-root public URL by running `npm run build`. --> - React App + Boxes diff --git a/src/App.js b/src/App.js index 2c12277..52848b8 100644 --- a/src/App.js +++ b/src/App.js @@ -6,7 +6,9 @@ import Login from './components/Login'; import Boxes from './components/Boxes'; import Items from './components/Items'; import Navbar from './components/Navbar'; // Correct import here +import Admin from './components/Admin'; // Correct import here import { createContext } from 'react'; +import ErrorBoundary from './components/ErrorBoundary'; import './styles.css' export const AppContext = createContext(); @@ -25,10 +27,12 @@ function App() { return ( - - - - + + + + + + ); } @@ -39,20 +43,24 @@ function AppRoutes({ token, setToken }) { return ( <> - } /> + } /> : } + path="/api/v1/boxes" + element={token ? : } /> : } + path="/api/v1/items" + element={token ? : } /> : } + path="/api/v1/boxes/:id/items" + element={token ? : } /> - } /> + : } + /> + } /> ); diff --git a/src/components/Admin.css b/src/components/Admin.css new file mode 100644 index 0000000..9ec3156 --- /dev/null +++ b/src/components/Admin.css @@ -0,0 +1,15 @@ +/* Admin.css */ +table { + border-collapse: collapse; + width: 100%; +} + +th, td { + border: 1px solid #ddd; + padding: 10px; + text-align: left; +} + +th { + background-color: #f0f0f0; +} \ No newline at end of file diff --git a/src/components/Admin.js b/src/components/Admin.js new file mode 100644 index 0000000..4d50596 --- /dev/null +++ b/src/components/Admin.js @@ -0,0 +1,221 @@ +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 { + 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); + + 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 fetchUsers(() => + api.admin.getUsers(localStorage.getItem('token')) + ); + setUsers(response.data); + } catch (err) {} + }; + 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 Dashboard + + + {(usersError || createError || deleteError || backupError || restoreError) && ( + + {usersError?.message || createError?.message || deleteError?.message || + backupError?.message || restoreError?.message} + + )} + + + + Add New User + + 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} + /> + + + + {loadingUsers ? ( + + ) : ( + + + + + ID + Username + Email + Actions + + + + {users.map(user => ( + + {user.ID} + {user.username} + {user.email} + + + + + ))} + +
+
+ )} + + + + + + +
+ ); +} \ No newline at end of file diff --git a/src/components/Boxes.js b/src/components/Boxes.js index 888ceda..1b6f0c9 100644 --- a/src/components/Boxes.js +++ b/src/components/Boxes.js @@ -1,83 +1,123 @@ -// src/components/Boxes.js import React, { useEffect, useState } from 'react'; -import { Container, Button, TextField, List, ListItem, ListItemText, IconButton } from '@mui/material'; +import { + Container, + Button, + TextField, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Alert, + CircularProgress +} from '@mui/material'; import { Delete as DeleteIcon } from '@mui/icons-material'; -import { Link as RouterLink } from 'react-router-dom'; // Import Link from react-router-dom -import axios from 'axios'; +import { Link as RouterLink } from 'react-router-dom'; import { PRIMARY_COLOR, SECONDARY_COLOR } from '../App'; +import { useApiCall } from './hooks/useApiCall'; +import { api } from '../services/api'; export default function Boxes({ token }) { const [boxes, setBoxes] = useState([]); const [newBoxName, setNewBoxName] = useState(''); - const apiUrl = `${process.env.REACT_APP_API_URL}/boxes`; - const debugApi = () => { - if (process.env.DEBUG_API) { - console.log("URL is " + apiUrl); - } - }; - debugApi(); + const { execute: fetchBoxes, loading: loadingBoxes, error: boxesError } = useApiCall(); + const { execute: createBox, loading: creatingBox, error: createError } = useApiCall(); + const { execute: deleteBox, loading: deletingBox, error: deleteError } = useApiCall(); useEffect(() => { - //console.log('Token:' + token); - axios.get(`${process.env.REACT_APP_API_URL}/boxes`, { - headers: { Authorization: `Bearer ${token}` } - }).then(response => { - setBoxes(response.data); - }); - }, [token]); + const getBoxes = async () => { + try { + const response = await fetchBoxes(() => + api.boxes.getAll(token) + ); + setBoxes(response.data); + } catch (err) {} + }; + getBoxes(); + }, [token]); // Remove fetchBoxes from dependencies - // Log boxes state changes outside the useEffect - useEffect(() => { - //console.log('Boxes updated:', boxes); - }, [boxes]); + const handleCreateBox = async () => { + if (!newBoxName.trim()) return; - const handleCreateBox = () => { - axios.post(`${process.env.REACT_APP_API_URL}/boxes`, { name: newBoxName }, { - headers: { Authorization: `Bearer ${token}` } - }).then(response => { + try { + const response = await createBox(() => + api.boxes.create(token, { name: newBoxName }) + ); setBoxes([...boxes, response.data]); setNewBoxName(''); - }); + } catch (err) {} }; - const handleDeleteBox = (id) => { - axios.delete(`${process.env.REACT_APP_API_URL}/boxes/${id}`, { - headers: { Authorization: `Bearer ${token}` } - }).then(() => { - setBoxes(boxes.filter(box => box.id !== id)); - }); + const handleDeleteBox = async (id) => { + try { + await deleteBox(() => + api.boxes.delete(token, id) + ); + setBoxes(boxes.filter(box => box.ID !== id)); + } catch (err) {} }; return ( + {(boxesError || createError || deleteError) && ( + + {boxesError?.message || createError?.message || deleteError?.message} + + )} + + {loadingBoxes ? ( + + ) : ( + + + + + Box Name + Actions + + + + {boxes.map((box) => ( + + + + {box.name} + + + + + + + ))} + +
+
+ )} + setNewBoxName(e.target.value)} + disabled={creatingBox} /> - - - {boxes.map((box) => ( - handleDeleteBox(box.ID)}> - - - }> - {/* Use Link component */} - {box.name} - - } - /> - - ))} -
); } \ No newline at end of file diff --git a/src/components/ErrorBoundary.js b/src/components/ErrorBoundary.js new file mode 100644 index 0000000..09bb2d1 --- /dev/null +++ b/src/components/ErrorBoundary.js @@ -0,0 +1,36 @@ +import React from 'react'; +import { Alert, Button, Container } from '@mui/material'; + +class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error) { + return { hasError: true, error }; + } + + render() { + if (this.state.hasError) { + return ( + + + Something went wrong + + + + ); + } + return this.props.children; + } +} + +export default ErrorBoundary; \ No newline at end of file diff --git a/src/components/ItemDetails.js b/src/components/ItemDetails.js index 22a43d5..2c37f5f 100644 --- a/src/components/ItemDetails.js +++ b/src/components/ItemDetails.js @@ -1,187 +1,123 @@ -// src/components/ItemDetails.js import React, { useState, useEffect, useRef } from 'react'; -import { TextField, Button, Container, Avatar, Typography } from '@mui/material'; -import axios from 'axios'; +import { TextField, Button, Container, Avatar, Tooltip, Alert, CircularProgress } from '@mui/material'; import { PRIMARY_COLOR, SECONDARY_COLOR } from '../App'; -//import { useNavigate } from 'react-router-dom'; // Import useNavigate +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'); // Initial default image - const fileInputRef = useRef(null); // Add this line to define fileInputRef + 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 navigate = useNavigate(); // Initialize useNavigate - const [boxName, setBoxName] = useState(''); const [boxes, setBoxes] = useState([]); - const [selectedBoxId, setSelectedBoxId] = useState(boxId); + 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 axios.get(`${process.env.REACT_APP_API_URL}/boxes`, { - headers: { Authorization: `Bearer ${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) => { - setSelectedBoxId(event.target.value); - console.log('Selected box ID:', event.target.value); - }; - useEffect(() => { - // Function to fetch box details - const getBoxDetails = async (boxId) => { + const loadImage = async () => { + setLoading(true); + setError(null); try { - const boxIdNumber = +boxId; - if (isNaN(boxIdNumber)) { - console.error('Invalid boxId:', boxId); - return; - } - const response = await axios.get(`${process.env.REACT_APP_API_URL}/boxes/${+boxId}`, { - headers: { Authorization: `Bearer ${token}` } - }); - setBoxName(response.data.name); - } catch (error) { - console.error('Error fetching box details:', error); + const response = await api.items.getImage(token, item.ID); + const reader = new FileReader(); + reader.onload = () => { + setImageSrc(reader.result); + setLoading(false); + }; + reader.readAsDataURL(response.data); + } catch (err) { + setImageSrc('/default.jpg'); + setError(err); + setLoading(false); } }; - - // Fetch the box details when the component mounts or the selectedBoxId changes - if (selectedBoxId !== null) { - getBoxDetails(selectedBoxId); - } - }, [selectedBoxId, token, boxId]); - - useEffect(() => { - // Function to fetch image similar to getImageSrc in Items.js - const getImageSrc = (itemId) => { - return axios.get(`${process.env.REACT_APP_API_URL}/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 the data URL of the default image if image fetch fails - 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); - const dataURL = canvas.toDataURL(); - resolve(dataURL); - }; - img.onerror = reject; - }); - }); - }; - - // Fetch the image when the component mounts or the item changes - getImageSrc(item.ID).then(dataUrl => setImageSrc(dataUrl)); + loadImage(); }, [item.ID, token]); - // const handleCloseItemDetails = () => { - // onClose(); // Call the onClose prop to close the modal - // navigate(`/boxes/${boxId}/items`); // Navigate back to the items list - // }; - 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 axios.post(`${process.env.REACT_APP_API_URL}/items/${item.ID}/upload`, formData, { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'multipart/form-data' - } - }); - // Handle successful upload (e.g., show a success message) - console.log('Image uploaded successfully!', response.data.imagePath); - return response.data.imagePath; // Indicate successful upload - } catch (error) { - // Handle upload error (e.g., show an error message) - console.error('Image upload failed:', error); + const response = await uploadImage(() => + api.items.uploadImage(token, item.ID, formData) + ); + return response.data.imagePath; + } catch (err) { + return null; } }; const handleSave = async () => { - let imagePath; - // 1. Handle image upload first if a new image is selected - if (fileInputRef.current.files[0]) { - imagePath = await handleImageUpload(); + if (fileInputRef.current?.files?.[0]) { + await handleImageUpload(); } - console.log(selectedBoxId) - // 2. Update item details (name, description, etc.) try { - await axios.put(`${process.env.REACT_APP_API_URL}/items/${item.ID}`, - { name, description, image_path: imagePath, BoxID: selectedBoxId }, // Use teh uploaded image path - { - headers: { Authorization: `Bearer ${token}` } - } - ); - onSave(); // Notify parent to refresh items - } catch (error) { - // Handle update error - console.error('Item update failed:', error); - } + await updateItem(() => api.items.update(token, item.ID, { + name, + description, + box_id: +selectedBoxId, + })); + onSave(); + } catch (err) {} }; - const handleImageError = (e) => { - e.target.src = '/images/default.jpg'; // Fallback to default image on error - }; - - 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)} + /> + + )} - {/* Display the item image as an avatar */} - {imageOverlayVisible && (
{name} -
)} + setName(e.target.value)} + disabled={savingItem} /> setDescription(e.target.value)} + disabled={savingItem} /> - setImagePath(e.target.value)} - /> - - -
-
- - + + -
); } \ No newline at end of file diff --git a/src/components/Items.js b/src/components/Items.js index 4966b6d..8af44ee 100644 --- a/src/components/Items.js +++ b/src/components/Items.js @@ -1,54 +1,111 @@ -import React, { useEffect, useState, useCallback, useRef } from 'react'; +import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import { Container, - List, - ListItem, - ListItemText, - TextField, Button, - IconButton, - Typography, + IconButton, + TextField, + TableContainer, + Table, + TableHead, + TableRow, + TableCell, + TableBody, + Box, + Tooltip, Avatar, - ListItemAvatar, 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'; + +const Item = React.memo(({ item, onDelete, onEdit, itemImages }) => ( + + + + + {item.name} + {item.description} + + + + onEdit(item)} size="large" sx={{ mr: 1 }}> + + + + + onDelete(item.ID)} size="large" color="error"> + + + + + + +)); 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}/items` : `${process.env.REACT_APP_API_URL}/boxes/${boxId}/items`; + const [openAddItemDialog, setOpenAddItemDialog] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + + 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 url = boxId ? + `${process.env.REACT_APP_API_URL}/api/v1/boxes/${boxId}/items` : + `${process.env.REACT_APP_API_URL}/api/v1/items`; - const debugLog = (message) => { - if (process.env.DEBUG_API) { - console.log(message); - } - }; - debugLog("Box ID: " + boxID); + useEffect(() => { + const getItems = async () => { + try { + const response = await fetchItems(() => + boxId ? api.items.getByBox(token, boxId) : api.items.getAll(token) + ); + setItems(response.data); + + // Fetch images for each item + response.data.forEach(item => { + api.items.getImage(token, item.ID) + .then(response => { + const reader = new FileReader(); + reader.onload = () => { + setItemImages(prev => ({ + ...prev, + [item.ID]: reader.result + })); + }; + reader.readAsDataURL(response.data); + }) + .catch(() => { + setItemImages(prev => ({ + ...prev, + [item.ID]: '/default.jpg' + })); + }); + }); + } catch (err) {} + }; + getItems(); + }, [token, boxId]); - // const handleSelectItem = (item) => { - // setSelectedItem(item); - // }; const handleAddItem = () => { setOpenAddItemDialog(true); }; @@ -57,213 +114,124 @@ export default function Items({ token }) { setOpenAddItemDialog(false); setNewItemName(''); setNewItemDescription(''); - // setNewItemImagePath(''); if (fileInputRef.current) { fileInputRef.current.value = ''; } }; - const handleImageUpload = async (itemId, imageFile) => { - const formData = new FormData(); - formData.append('image', imageFile); + const generateUniqueImageName = (imageName) => { + if (imageName.toLowerCase() === 'image.jpg') { + const randomString = Math.random().toString(36).substr(2, 9); + return `image_${randomString}.jpg`; + } + return imageName; + }; + const handleSaveNewItem = useCallback(async () => { try { - const response = await axios.post(`${process.env.REACT_APP_API_URL}/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 - } - }; - const handleSaveNewItem = async () => { - try { - // 1. Create the item first - const newItemResponse = await axios.post(`${process.env.REACT_APP_API_URL}/items`, { - name: newItemName, - description: newItemDescription, - box_id: parseInt(boxId, 10) - }, { - headers: { - Authorization: `Bearer ${token}` - } - }); - //console.log('New item created:', newItemResponse.status); - - // 2. If item creation is successful, upload the image - if (newItemResponse.status === 200 && fileInputRef.current.files[0]) { - const newItemId = newItemResponse.data.id; - const uploadedImagePath = await handleImageUpload(newItemId, fileInputRef.current.files[0]); - - if (uploadedImagePath) { - // console.log("Image path to save:", uploadedImagePath); - - // You might want to update your item in the backend with the image path - // For example: - // await axios.put(...); - - } else { - // Handle image upload failure - console.error('Failed to upload image for the new item.'); - } - } - - 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}/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(() => { - axios.get( url, { - headers: { Authorization: `Bearer ${token}` } - }).then(response => { - setItems(response.data); - - // Fetch images for each item - response.data.forEach(item => { - getImageSrc(item.ID).then(imageDataUrl => { - setItemImages(prevItemImages => ({ - ...prevItemImages, - [item.ID]: imageDataUrl + 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) + ); + if (newItemResponse.data.id) { + try { + const imageResponse = await api.items.getImage(token, newItemResponse.data.id); + const reader = new FileReader(); + reader.onload = () => { + setItemImages(prev => ({ + ...prev, + [newItemResponse.data.id]: reader.result + })); + }; + reader.readAsDataURL(imageResponse.data); + } catch (err) { + setItemImages(prev => ({ + ...prev, + [newItemResponse.data.id]: '/default.jpg' })); - }); - }); - }); - }, [token, getImageSrc, url]); - // lint says I don't need boxId here + } + } + } + + handleCloseAddItemDialog(); + + const response = await fetchItems(() => + boxId ? api.items.getByBox(token, boxId) : api.items.getAll(token) + ); + setItems(response.data); + } catch (err) {} + }, [token, boxId, newItemName, newItemDescription, createItem, uploadImage, fetchItems]); - useEffect(() => { - fetchItems(); - }, [boxId, token, fetchItems]); + const handleDeleteItem = useCallback(async (itemId) => { + try { + await deleteItem(() => api.items.delete(token, itemId)); + setItems(prev => prev.filter(item => item.ID !== itemId)); + } catch (err) {} + }, [token, deleteItem]); - // 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]); - // } - - // 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 handleDeleteItem = (itemId) => { - axios.delete(`${process.env.REACT_APP_API_URL}/items/${itemId}`, { - headers: { Authorization: `Bearer ${token}` } - }).then(() => { - fetchItems(); - }); - }; - - const handleEditItem = (item) => { + const handleEditItem = useCallback((item) => { setEditingItem(item); + }, []); + + const handleSaveEdit = async () => { + setEditingItem(null); + const response = await fetchItems(() => + api.items.getAll(token, boxId) + ); + setItems(response.data); }; - const handleSaveEdit = () => { - setEditingItem(null); - fetchItems(); - }; + const filteredItems = useMemo(() => + items.filter(item => + item.name.toLowerCase().includes(searchQuery.toLowerCase()) || + item.description.toLowerCase().includes(searchQuery.toLowerCase()) + ), + [items, searchQuery] + ); return ( -

Items in Box: {boxName === "Unknown Box" ? "All Boxes" : boxName}

+ {(itemsError || createError || deleteError || uploadError) && ( + + {itemsError?.message || createError?.message || deleteError?.message || uploadError?.message} + + )} - setSearchQuery(e.target.value)} /> - - {/* Dialog for adding new item */} Add New Item @@ -287,59 +255,57 @@ export default function Items({ token }) { type="file" accept="image/*" ref={fileInputRef} - style={{ display: 'block', margin: '10px 0' }} // Style as needed - id="newItemImageUpload" + style={{ display: 'block', margin: '10px 0' }} /> - - {editingItem ? ( + + {editingItem && ( setEditingItem(null)} boxId={boxId} - /> + /> + )} + + {loadingItems ? ( + ) : ( - - {items.map((item) => ( - - handleEditItem(item)}> - - - handleDeleteItem(item.ID)}> - - - - }> - - - - - {item.description} - {item.image_path && ( - Image: {item.image_path} - )} - - } - /> - - ))} - + + + + + Image + Name + Description + Actions + + + + {filteredItems.map((item) => ( + + ))} + +
+
)}
); diff --git a/src/components/Login.js b/src/components/Login.js index fdce747..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 axios from 'axios'; -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); // State for login error - const navigate = useNavigate(); // Initialize useNavigate + const navigate = useNavigate(); + const { execute, loading, error } = useApiCall(); const handleLogin = async (e) => { e.preventDefault(); - setLoginError(false); // Reset error state on each login attempt try { - // eslint-disable-next-line no-template-curly-in-string - const response = await axios.post(`${process.env.REACT_APP_API_URL}/login`, { username, password }); - setToken(response.data.token); - navigate('/boxes'); - } catch (error) { - console.error('Login failed', error); - setLoginError(true); // Set error state if login fails + const response = await execute(() => api.login({ username, password })); + setToken(response.data.token); + navigate('/boxes'); + } 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/Navbar.js b/src/components/Navbar.js index 5ddb1af..8fcefab 100644 --- a/src/components/Navbar.js +++ b/src/components/Navbar.js @@ -33,9 +33,10 @@ export default function Navbar() { Boxes - - - + + + + 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/index.js b/src/index.js index d563c0f..ce25047 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,14 @@ import './index.css'; import App from './App'; import reportWebVitals from './reportWebVitals'; +const path = window.location.pathname; +if (path !== '/' && !path.startsWith('/api/v1/')) { + window.history.replaceState(null, '', '/'); +} + +// Log the environment variables +console.log('REACT_APP_API_URL:', process.env.REACT_APP_API_URL); + const root = ReactDOM.createRoot(document.getElementById('root')); root.render( diff --git a/src/services/api.js b/src/services/api.js new file mode 100644 index 0000000..50caa6f --- /dev/null +++ b/src/services/api.js @@ -0,0 +1,72 @@ +// src/services/api.js +import axios from 'axios'; + +const createApiClient = () => { + const client = axios.create({ + baseURL: process.env.REACT_APP_API_URL + }); + + const authHeader = (token) => ({ Authorization: `Bearer ${token}` }); + + return { + // Auth + login: (credentials) => + client.post('/api/v1/login', credentials), + + // Items + items: { + getAll: (token) => + client.get('/api/v1/items', { headers: authHeader(token) }), + getOne: (token, id) => + client.get(`/api/v1/items/${id}`, { headers: authHeader(token) }), + create: (token, itemData) => + client.post('/api/v1/items', itemData, { headers: authHeader(token) }), + update: (token, id, itemData) => + client.put(`/api/v1/items/${id}`, itemData, { headers: authHeader(token) }), + delete: (token, id) => + client.delete(`/api/v1/items/${id}`, { headers: authHeader(token) }), + uploadImage: (token, id, formData) => + client.post(`/api/v1/items/${id}/upload`, formData, { + headers: { ...authHeader(token), 'Content-Type': 'multipart/form-data' } + }), + getImage: (token, id) => + client.get(`/api/v1/items/${id}/image`, { + headers: authHeader(token), + responseType: 'blob' + }), + getByBox: (token, boxId) => + client.get(`/api/v1/boxes/${boxId}/items`, { headers: authHeader(token) }), + }, + + // Boxes + boxes: { + getAll: (token) => + client.get('/api/v1/boxes', { headers: authHeader(token) }), + create: (token, boxData) => + client.post('/api/v1/boxes', boxData, { headers: authHeader(token) }), + delete: (token, id) => + client.delete(`/api/v1/boxes/${id}`, { headers: authHeader(token) }), + }, + + // Admin + admin: { + getUsers: (token) => + client.get('/api/v1/admin/user', { headers: authHeader(token) }), + createUser: (token, userData) => + client.post('/api/v1/admin/user', userData, { headers: authHeader(token) }), + deleteUser: (token, id) => + client.delete(`/api/v1/admin/user/${id}`, { headers: authHeader(token) }), + backupDb: (token) => + client.get('/api/v1/admin/db', { + headers: authHeader(token), + responseType: 'blob' + }), + restoreDb: (token, formData) => + client.post('/api/v1/admin/db', formData, { + headers: { ...authHeader(token), 'Content-Type': 'multipart/form-data' } + }), + } + }; +}; + +export const api = createApiClient(); \ 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; + }; diff --git a/test.bash b/test.bash new file mode 100644 index 0000000..b551d03 --- /dev/null +++ b/test.bash @@ -0,0 +1,18 @@ +#!/bin/bash + +# Set the username and password +USERNAME="boxuser" +PASSWORD="boxuser" +EMAIL="boxuser@r8z.us" + +# Set the database file and table name +DB_FILE="../boxes-api/data/boxes.db" +TABLE_NAME="users" + +# Generate the bcrypt encrypted password using htpasswd +ENCRYPTED_PASSWORD=$(htpasswd -bnBC 10 "" "$PASSWORD" | tr -d ':\n') + +# Insert the username and encrypted password into the database +sqlite3 "$DB_FILE" "INSERT INTO $TABLE_NAME (username, password, email) VALUES ('$USERNAME', '$ENCRYPTED_PASSWORD', '$EMAIL')" + +echo "Password encrypted and stored in the database."