grocery-app/frontend/src/components/items/GroceryListItem.jsx
Nico ee94853084
All checks were successful
Build & Deploy Costco Grocery List / build (push) Successful in 1m7s
Build & Deploy Costco Grocery List / verify-images (push) Successful in 3s
Build & Deploy Costco Grocery List / deploy (push) Successful in 12s
Build & Deploy Costco Grocery List / notify (push) Successful in 1s
fix(list): restore added-by attribution with display name fallback
2026-02-21 00:07:22 -08:00

203 lines
5.9 KiB
JavaScript

import { memo, useRef, useState } from "react";
import AddImageModal from "../modals/AddImageModal";
import ConfirmBuyModal from "../modals/ConfirmBuyModal";
function GroceryListItem({ item, onClick, onImageAdded, onLongPress, allItems = [], compact = false }) {
const [showAddImageModal, setShowAddImageModal] = useState(false);
const [showConfirmBuyModal, setShowConfirmBuyModal] = useState(false);
const [currentItem, setCurrentItem] = useState(item);
const longPressTimer = useRef(null);
const pressStartPos = useRef({ x: 0, y: 0 });
const handleTouchStart = (e) => {
const touch = e.touches[0];
pressStartPos.current = { x: touch.clientX, y: touch.clientY };
longPressTimer.current = setTimeout(() => {
if (onLongPress) {
onLongPress(item);
}
}, 500); // 500ms for long press
};
const handleTouchMove = (e) => {
// Cancel long press if finger moves too much
const touch = e.touches[0];
const moveDistance = Math.sqrt(
Math.pow(touch.clientX - pressStartPos.current.x, 2) +
Math.pow(touch.clientY - pressStartPos.current.y, 2)
);
if (moveDistance > 10) {
clearTimeout(longPressTimer.current);
}
};
const handleTouchEnd = () => {
clearTimeout(longPressTimer.current);
};
const handleMouseDown = () => {
longPressTimer.current = setTimeout(() => {
if (onLongPress) {
onLongPress(item);
}
}, 500);
};
const handleMouseUp = () => {
clearTimeout(longPressTimer.current);
};
const handleMouseLeave = () => {
clearTimeout(longPressTimer.current);
};
const handleItemClick = () => {
if (onClick) {
setCurrentItem(item);
setShowConfirmBuyModal(true);
}
};
const handleConfirmBuy = (quantity) => {
if (onClick) {
onClick(currentItem.id, quantity);
}
setShowConfirmBuyModal(false);
};
const handleCancelBuy = () => {
setShowConfirmBuyModal(false);
};
const handleNavigate = (newItem) => {
setCurrentItem(newItem);
};
const handleImageClick = (e) => {
e.stopPropagation(); // Prevent triggering the bought action
if (item.item_image) {
// Open buy modal which now shows the image
setCurrentItem(item);
setShowConfirmBuyModal(true);
} else {
setShowAddImageModal(true);
}
};
const handleAddImage = async (imageFile) => {
if (onImageAdded) {
await onImageAdded(item.id, item.item_name, item.quantity, imageFile);
}
setShowAddImageModal(false);
};
const imageUrl = item.item_image && item.image_mime_type
? `data:${item.image_mime_type};base64,${item.item_image}`
: null;
const addedByUsers = Array.isArray(item.added_by_users)
? item.added_by_users.filter(
(name) => typeof name === "string" && name.trim().length > 0
)
: [];
const getTimeAgo = (dateString) => {
if (!dateString) return null;
const addedDate = new Date(dateString);
const now = new Date();
const diffMs = now - addedDate;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays < 7) {
return `${diffDays}d ago`;
} else if (diffDays < 30) {
const weeks = Math.floor(diffDays / 7);
return `${weeks}w ago`;
} else {
const months = Math.floor(diffDays / 30);
return `${months}m ago`;
}
};
return (
<>
<li
className={`glist-li ${compact ? 'compact' : ''}`}
onClick={handleItemClick}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
>
<div className="glist-item-layout">
<div
className={`glist-item-image ${item.item_image ? "has-image" : ""}`}
onClick={handleImageClick}
style={{ cursor: "pointer" }}
>
{item.item_image ? (
<img src={imageUrl} alt={item.item_name} />
) : (
<span>📦</span>
)}
<span className="glist-item-quantity">x{item.quantity}</span>
</div>
<div className="glist-item-content">
<div className="glist-item-header">
<span className="glist-item-name">{item.item_name}</span>
</div>
{addedByUsers.length > 0 && (
<div className="glist-item-users">
{item.last_added_on && `${getTimeAgo(item.last_added_on)} -- `}
{addedByUsers.join(" | ")}
</div>
)}
</div>
</div>
</li>
{showAddImageModal && (
<AddImageModal
itemName={item.item_name}
onClose={() => setShowAddImageModal(false)}
onAddImage={handleAddImage}
/>
)}
{showConfirmBuyModal && (
<ConfirmBuyModal
item={currentItem}
onConfirm={handleConfirmBuy}
onCancel={handleCancelBuy}
allItems={allItems}
onNavigate={handleNavigate}
/>
)}
</>
);
}
// Memoize component to prevent re-renders when props haven't changed
export default memo(GroceryListItem, (prevProps, nextProps) => {
// Only re-render if the item data or handlers have changed
return (
prevProps.item.id === nextProps.item.id &&
prevProps.item.item_name === nextProps.item.item_name &&
prevProps.item.quantity === nextProps.item.quantity &&
prevProps.item.item_image === nextProps.item.item_image &&
prevProps.item.bought === nextProps.item.bought &&
prevProps.item.last_added_on === nextProps.item.last_added_on &&
prevProps.item.zone === nextProps.item.zone &&
prevProps.item.added_by_users?.join(',') === nextProps.item.added_by_users?.join(',') &&
prevProps.onClick === nextProps.onClick &&
prevProps.onImageAdded === nextProps.onImageAdded &&
prevProps.onLongPress === nextProps.onLongPress &&
prevProps.allItems?.length === nextProps.allItems?.length
);
});