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}
/>
-
);
}
\ 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
+ window.location.reload()}
+ variant="outlined"
+ size="small"
+ sx={{ ml: 2 }}
+ >
+ Reload Page
+
+
+
+ );
+ }
+ 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 && (
-
+ setImageOverlayVisible(false)}>
Close
)}
+
setName(e.target.value)}
+ disabled={savingItem}
/>
setDescription(e.target.value)}
+ disabled={savingItem}
/>
- setImagePath(e.target.value)}
- />
-
-
-
-
-
- Upload Image
- {
- // You can handle image preview here if needed
- setImagePath(e.target.files[0].name);
- }}
+
+ {loadingBoxes ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {uploadingImage ? : 'Upload Image'}
+ setImagePath(e.target.files[0]?.name || '')}
/>
-
- Save Changes
+
+ {savingItem ? : 'Save Changes'}
+
+
+
+ Close
- Close
);
}
\ 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)}
/>
-
- Add Item
+ Items in Box: {boxName === "Unknown Box" ? "All Boxes" : `${boxName} (${items.length} items)`}
+
+
+ {creatingItem ? : 'Add Item'}
- {/* Dialog for adding new item */}
- {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}
+
)}
);
-}
+}
\ 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
- Login
- Boxes
- Items
+ Login
+ Boxes
+ Items
+ Admin
Logout
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."