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 && ( -
- {name} - -
- )} - - 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} + /> + )) + )} + + + + + + setOpenDialog(false)}> + Create New Tag + + setNewTagName(e.target.value)} + fullWidth + margin="normal" + /> + setNewTagDescription(e.target.value)} + fullWidth + margin="normal" + /> + setNewTagColor(e.target.value)} + fullWidth + margin="normal" + /> + + + + + + + + ); +}); + +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) =>