diff --git a/package-lock.json b/package-lock.json
index 2520035..481daf2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,6 +16,7 @@
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"axios": "^1.7.7",
+ "lucide-react": "^0.454.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
@@ -13981,6 +13982,15 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/lucide-react": {
+ "version": "0.454.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.454.0.tgz",
+ "integrity": "sha512-hw7zMDwykCLnEzgncEEjHeA6+45aeEzRYuKHuyRSOPkhko+J3ySGjGIzu+mmMfDFG1vazHepMaYFYHbTFAZAAQ==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
+ }
+ },
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
diff --git a/package.json b/package.json
index e09f994..9ca125b 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"axios": "^1.7.7",
+ "lucide-react": "^0.454.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
diff --git a/src/App.js b/src/App.js
index 52848b8..ae69acd 100644
--- a/src/App.js
+++ b/src/App.js
@@ -10,6 +10,7 @@ import Admin from './components/Admin'; // Correct import here
import { createContext } from 'react';
import ErrorBoundary from './components/ErrorBoundary';
import './styles.css'
+import { EnhancedThemeProvider } from './components/themeProvider';
export const AppContext = createContext();
export const PRIMARY_COLOR = '#333';
@@ -26,14 +27,16 @@ function App() {
}, [token]);
return (
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/src/components/Boxes.js b/src/components/Boxes.js
index 1b6f0c9..7762edb 100644
--- a/src/components/Boxes.js
+++ b/src/components/Boxes.js
@@ -10,9 +10,11 @@ import {
TableHead,
TableRow,
Alert,
- CircularProgress
+ CircularProgress,
+ Avatar,
+ Box
} from '@mui/material';
-import { Delete as DeleteIcon } from '@mui/icons-material';
+import { Delete as DeleteIcon, Inventory as InventoryIcon } from '@mui/icons-material';
import { Link as RouterLink } from 'react-router-dom';
import { PRIMARY_COLOR, SECONDARY_COLOR } from '../App';
import { useApiCall } from './hooks/useApiCall';
@@ -36,7 +38,7 @@ export default function Boxes({ token }) {
} catch (err) {}
};
getBoxes();
- }, [token]); // Remove fetchBoxes from dependencies
+ }, [token]);
const handleCreateBox = async () => {
if (!newBoxName.trim()) return;
@@ -74,23 +76,50 @@ export default function Boxes({ token }) {
+
Box Name
- Actions
+ Actions
{boxes.map((box) => (
-
- {box.name}
-
+
+
+
+
+ {box.name}
+
+
+
@@ -102,22 +131,31 @@ export default function Boxes({ token }) {
)}
- setNewBoxName(e.target.value)}
- disabled={creatingBox}
- />
-
+
+ setNewBoxName(e.target.value)}
+ disabled={creatingBox}
+ sx={{ mb: 1 }}
+ />
+
+
);
}
\ No newline at end of file
diff --git a/src/components/ItemDetails.js b/src/components/ItemDetails.js
index 2c37f5f..311d06c 100644
--- a/src/components/ItemDetails.js
+++ b/src/components/ItemDetails.js
@@ -1,55 +1,76 @@
import React, { useState, useEffect, useRef } from 'react';
-import { TextField, Button, Container, Avatar, Tooltip, Alert, CircularProgress } from '@mui/material';
+import {
+ TextField,
+ Button,
+ Container,
+ Avatar,
+ Tooltip,
+ Alert,
+ CircularProgress,
+ Box,
+ Typography
+} from '@mui/material';
import { PRIMARY_COLOR, SECONDARY_COLOR } from '../App';
import { useApiCall } from './hooks/useApiCall';
import { api } from '../services/api';
+import TagManager from './tagManager';
-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 || '');
+export default function ItemDetails({ item: initialItem, token, onSave, onClose }) {
+ const [item, setItem] = useState(initialItem);
+ const [name, setName] = useState(initialItem.name);
+ const [description, setDescription] = useState(initialItem.description);
+ const [imagePath, setImagePath] = useState(initialItem.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 [boxes, setBoxes] = useState([]);
- const [selectedBoxId, setSelectedBoxId] = useState(item.box_id);
+ const [selectedBoxId, setSelectedBoxId] = useState(initialItem.box_id);
const { execute: fetchBoxes, loading: loadingBoxes, error: boxesError } = useApiCall();
+ const { execute: fetchItem, loading: loadingItem } = useApiCall();
const { execute: updateItem, loading: savingItem, error: saveError } = useApiCall();
const { execute: uploadImage, loading: uploadingImage, error: uploadError } = useApiCall();
+ // Single effect to load initial data
useEffect(() => {
- const getBoxes = async () => {
+ const loadInitialData = async () => {
try {
- const response = await fetchBoxes(() => api.boxes.getAll(token));
- setBoxes(response.data);
- } catch (err) {}
- };
- getBoxes();
- }, [token]);
+ // Fetch item and boxes in parallel
+ const [itemResponse, boxesResponse] = await Promise.all([
+ fetchItem(() => api.items.getOne(token, initialItem.ID)),
+ fetchBoxes(() => api.boxes.getAll(token))
+ ]);
- useEffect(() => {
- 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);
+ const updatedItem = itemResponse.data;
+ setItem(updatedItem);
+ setName(updatedItem.name);
+ setDescription(updatedItem.description);
+ setImagePath(updatedItem.image_path || '');
+ setBoxes(boxesResponse.data);
+
+ // Load image
+ try {
+ const imageResponse = await api.items.getImage(token, initialItem.ID);
+ const reader = new FileReader();
+ reader.onload = () => {
+ setImageSrc(reader.result);
+ setLoading(false);
+ };
+ reader.readAsDataURL(imageResponse.data);
+ } catch (err) {
+ setImageSrc('/default.jpg');
setLoading(false);
- };
- reader.readAsDataURL(response.data);
+ }
} catch (err) {
- setImageSrc('/default.jpg');
setError(err);
setLoading(false);
}
};
- loadImage();
- }, [item.ID, token]);
+
+ loadInitialData();
+ }, []); // Empty dependency array - only run once on mount
const handleImageUpload = async () => {
if (!fileInputRef.current?.files?.[0]) return null;
@@ -86,9 +107,13 @@ export default function ItemDetails({ item, token, onSave, onClose }) {
setSelectedBoxId(event.target.value);
};
+ const handleTagsChange = (newTags) => {
+ setItem(prev => ({...prev, tags: newTags}));
+ };
+
return (
- Edit Item: {item.name}
+ Edit Item: {name}
{(error || boxesError || saveError || uploadError) && (
@@ -99,97 +124,107 @@ export default function ItemDetails({ item, token, onSave, onClose }) {
{loading ? (
) : (
-
- setImageOverlayVisible(true)}
+ /* Rest of your JSX remains the same */
+ // ... existing JSX code ...
+
+ {/* Image section */}
+
+ setImageOverlayVisible(true)}
+ />
+
+
+ {/* Form fields */}
+ setName(e.target.value)}
+ disabled={savingItem}
/>
-
+
+ setDescription(e.target.value)}
+ disabled={savingItem}
+ />
+
+ {/* Box selection */}
+ {loadingBoxes ? (
+
+ ) : (
+
+ )}
+
+ {/* Tags section */}
+
+ Tags
+
+
+
+ {/* Buttons */}
+
+
+
+
+
+
+
+
)}
-
- {imageOverlayVisible && (
-
-
-
-
- )}
-
- setName(e.target.value)}
- disabled={savingItem}
- />
- setDescription(e.target.value)}
- disabled={savingItem}
- />
-
- {loadingBoxes ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
-
-
-
);
}
\ No newline at end of file
diff --git a/src/components/Items.js b/src/components/Items.js
index 8af44ee..56e6ffe 100644
--- a/src/components/Items.js
+++ b/src/components/Items.js
@@ -18,7 +18,8 @@ import {
DialogContent,
DialogActions,
Alert,
- CircularProgress
+ CircularProgress,
+ Chip
} from '@mui/material';
import { Delete as DeleteIcon, Edit as EditIcon } from '@mui/icons-material';
import { useParams, useLocation } from 'react-router-dom';
@@ -26,7 +27,10 @@ import { PRIMARY_COLOR, SECONDARY_COLOR } from '../App';
import { useApiCall } from './hooks/useApiCall';
import { api } from '../services/api';
import ItemDetails from './ItemDetails';
+import TagManager from './tagManager';
+
+// In Items.js, modify the Item component
const Item = React.memo(({ item, onDelete, onEdit, itemImages }) => (
@@ -34,6 +38,20 @@ const Item = React.memo(({ item, onDelete, onEdit, itemImages }) => (
{item.name}
{item.description}
+ {/* Add this cell */}
+ {item.tags?.map(tag => (
+
+ ))}
+
@@ -190,10 +208,13 @@ export default function Items({ token }) {
const handleSaveEdit = async () => {
setEditingItem(null);
- const response = await fetchItems(() =>
- api.items.getAll(token, boxId)
- );
- setItems(response.data);
+ // Refresh the items list after editing
+ try {
+ const response = await fetchItems(() =>
+ boxId ? api.items.getByBox(token, boxId) : api.items.getAll(token)
+ );
+ setItems(response.data);
+ } catch (err) {}
};
const filteredItems = useMemo(() =>
@@ -290,6 +311,7 @@ export default function Items({ token }) {
Image
Name
Description
+ Tags
Actions
diff --git a/src/components/tagManager.js b/src/components/tagManager.js
new file mode 100644
index 0000000..71ecd45
--- /dev/null
+++ b/src/components/tagManager.js
@@ -0,0 +1,181 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import {
+ Chip,
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ TextField,
+ Button,
+ Box,
+ CircularProgress,
+ Alert
+} from '@mui/material';
+import { LocalOffer as TagIcon } from '@mui/icons-material';
+import { useApiCall } from './hooks/useApiCall';
+import { api } from '../services/api';
+
+const TagManager = React.memo(({ token, itemId, initialTags = [], onTagsChange }) => {
+ const [tags, setTags] = useState([]);
+ const [itemTags, setItemTags] = useState(initialTags);
+ const [openDialog, setOpenDialog] = useState(false);
+ const [newTagName, setNewTagName] = useState('');
+ const [newTagDescription, setNewTagDescription] = useState('');
+ const [newTagColor, setNewTagColor] = useState('#3366ff');
+
+ const { execute: fetchTags, loading: loadingTags, error: tagsError } = useApiCall();
+ const { execute: createTag, loading: creatingTag, error: createError } = useApiCall();
+ const { execute: addTags, loading: addingTags, error: addError } = useApiCall();
+ const { execute: removeTag, loading: removingTag, error: removeError } = useApiCall();
+
+ // Only fetch tags once when component mounts
+ useEffect(() => {
+ const getTags = async () => {
+ try {
+ const response = await fetchTags(() => api.tags.getAll(token));
+ setTags(response.data);
+ } catch (err) {}
+ };
+ getTags();
+ }, []); // Empty dependency array - only run once on mount
+
+ // Update itemTags when initialTags prop changes
+ useEffect(() => {
+ setItemTags(initialTags);
+ }, [initialTags]);
+
+ const handleCreateTag = async () => {
+ try {
+ const response = await createTag(() => api.tags.create(token, {
+ name: newTagName,
+ description: newTagDescription,
+ color: newTagColor
+ }));
+ setTags(prevTags => [...prevTags, response.data]);
+ setOpenDialog(false);
+ setNewTagName('');
+ setNewTagDescription('');
+ setNewTagColor('#3366ff');
+ } catch (err) {}
+ };
+
+ const handleAddTag = async (tagId) => {
+ if (!itemId) return;
+ try {
+ await addTags(() => api.items.addTags(token, itemId, [tagId]));
+ const newTag = tags.find(t => t.ID === tagId);
+ if (newTag) {
+ const updatedTags = [...itemTags, newTag];
+ setItemTags(updatedTags);
+ onTagsChange?.(updatedTags);
+ }
+ } catch (err) {}
+ };
+
+ const handleRemoveTag = async (tagId) => {
+ if (!itemId) return;
+ try {
+ await removeTag(() => api.items.removeTag(token, itemId, tagId));
+ const updatedTags = itemTags.filter(tag => tag.ID !== tagId);
+ setItemTags(updatedTags);
+ onTagsChange?.(updatedTags);
+ } catch (err) {}
+ };
+
+ return (
+
+ {(tagsError || createError || addError || removeError) && (
+
+ {tagsError?.message || createError?.message ||
+ addError?.message || removeError?.message}
+
+ )}
+
+
+ {itemTags.map(tag => (
+ handleRemoveTag(tag.ID)}
+ style={{
+ backgroundColor: tag.color,
+ color: '#fff'
+ }}
+ disabled={removingTag}
+ />
+ ))}
+
+
+ {loadingTags ? (
+
+ ) : (
+ tags
+ .filter(tag => !itemTags.find(it => it.ID === tag.ID))
+ .map(tag => (
+ handleAddTag(tag.ID)}
+ style={{
+ backgroundColor: tag.color,
+ color: '#fff',
+ margin: '0 4px'
+ }}
+ disabled={addingTags}
+ />
+ ))
+ )}
+
+
+ }
+ variant="outlined"
+ size="small"
+ onClick={() => setOpenDialog(true)}
+ sx={{ ml: 1 }}
+ >
+ New Tag
+
+
+
+
+
+ );
+});
+
+export default TagManager;
\ No newline at end of file
diff --git a/src/components/themeProvider.js b/src/components/themeProvider.js
new file mode 100644
index 0000000..997216a
--- /dev/null
+++ b/src/components/themeProvider.js
@@ -0,0 +1,134 @@
+import React from 'react';
+import { createTheme, ThemeProvider, CssBaseline } from '@mui/material';
+import { grey, blue } from '@mui/material/colors';
+
+// Enhanced theme with better visual hierarchy and modern styling
+const theme = createTheme({
+ palette: {
+ mode: 'light',
+ primary: {
+ main: blue[700],
+ dark: blue[900],
+ light: blue[500],
+ contrastText: '#fff'
+ },
+ background: {
+ default: grey[100],
+ paper: '#fff'
+ }
+ },
+ typography: {
+ h1: {
+ fontSize: '2.5rem',
+ fontWeight: 600,
+ marginBottom: '1.5rem'
+ },
+ h2: {
+ fontSize: '2rem',
+ fontWeight: 500,
+ marginBottom: '1.25rem'
+ },
+ h3: {
+ fontSize: '1.75rem',
+ fontWeight: 500,
+ marginBottom: '1rem'
+ }
+ },
+ components: {
+ MuiButton: {
+ styleOverrides: {
+ root: {
+ textTransform: 'none',
+ borderRadius: 8,
+ padding: '8px 16px',
+ fontWeight: 500
+ },
+ contained: {
+ boxShadow: 'none',
+ '&:hover': {
+ boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
+ }
+ }
+ }
+ },
+ MuiCard: {
+ styleOverrides: {
+ root: {
+ borderRadius: 12,
+ boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
+ '&:hover': {
+ boxShadow: '0 4px 12px rgba(0,0,0,0.12)'
+ }
+ }
+ }
+ },
+ MuiTextField: {
+ styleOverrides: {
+ root: {
+ '& .MuiOutlinedInput-root': {
+ borderRadius: 8,
+ backgroundColor: '#fff',
+ '&:hover fieldset': {
+ borderColor: blue[400]
+ }
+ }
+ }
+ }
+ },
+ MuiTable: {
+ styleOverrides: {
+ root: {
+ backgroundColor: '#fff',
+ borderRadius: 12,
+ overflow: 'hidden'
+ }
+ }
+ },
+ MuiTableHead: {
+ styleOverrides: {
+ root: {
+ backgroundColor: grey[50]
+ }
+ }
+ },
+ MuiTableCell: {
+ styleOverrides: {
+ head: {
+ fontWeight: 600,
+ color: grey[900]
+ }
+ }
+ }
+ }
+});
+
+// Layout component for consistent padding and max-width
+const Layout = ({ children }) => (
+
+ {children}
+
+);
+
+// Enhanced page container with proper spacing and background
+const PageContainer = ({ children, title }) => (
+
+
+ {title && {title}
}
+
+ {children}
+
+
+
+);
+
+export function EnhancedThemeProvider({ children }) {
+ return (
+
+
+ {children}
+
+ );
+ }
+
+ export { Layout, PageContainer };
+ export default EnhancedThemeProvider;
diff --git a/src/services/api.js b/src/services/api.js
index 50caa6f..b595c12 100644
--- a/src/services/api.js
+++ b/src/services/api.js
@@ -27,7 +27,11 @@ const createApiClient = () => {
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' }
+ headers: { ...authHeader(token), 'Content-Type': 'multipart/form-data' },
+ addTags: (token, id, tagIds) =>
+ client.post(`/api/v1/items/${id}/tags`, tagIds, { headers: authHeader(token) }),
+ removeTag: (token, id, tagId) =>
+ client.delete(`/api/v1/items/${id}/tags/${tagId}`, { headers: authHeader(token) }),
}),
getImage: (token, id) =>
client.get(`/api/v1/items/${id}/image`, {
@@ -48,6 +52,20 @@ const createApiClient = () => {
client.delete(`/api/v1/boxes/${id}`, { headers: authHeader(token) }),
},
+ tags: {
+ getAll: (token) =>
+ client.get('/api/v1/tags', { headers: authHeader(token) }),
+ create: (token, tagData) =>
+ client.post('/api/v1/tags', tagData, { headers: authHeader(token) }),
+ update: (token, id, tagData) =>
+ client.put(`/api/v1/tags/${id}`, tagData, { headers: authHeader(token) }),
+ delete: (token, id) =>
+ client.delete(`/api/v1/tags/${id}`, { headers: authHeader(token) }),
+ getItems: (token, id) =>
+ client.get(`/api/v1/tags/${id}/items`, { headers: authHeader(token) }),
+ },
+
+
// Admin
admin: {
getUsers: (token) =>