Add shopping feature to dashboard
This commit is contained in:
@@ -15,6 +15,15 @@ export default defineConfig({
|
|||||||
'@renderer': resolve('src/renderer/src')
|
'@renderer': resolve('src/renderer/src')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: [react()]
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@@ -1,9 +1,379 @@
|
|||||||
const Shopping = () => {
|
import React, { useState, useEffect } from 'react';
|
||||||
return (
|
import { FaPlus, FaTrash, FaCheck, FaEdit, FaShoppingCart, FaClock } from 'react-icons/fa';
|
||||||
<div>
|
import shoppingService from '../services/ShoppingService';
|
||||||
<h1>Einkaufen</h1>
|
import './Shopping.sass';
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Shopping
|
const Shopping = () => {
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [initialLoad, setInitialLoad] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [newItem, setNewItem] = useState({ name: '', amount: '1' });
|
||||||
|
const [editingItem, setEditingItem] = useState(null);
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
|
|
||||||
|
// Load shopping items on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadItems();
|
||||||
|
|
||||||
|
// Set up auto-refresh every 5 seconds
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
loadItems();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// Cleanup interval on component unmount
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadItems = async () => {
|
||||||
|
try {
|
||||||
|
if (initialLoad) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
const data = await shoppingService.getItems();
|
||||||
|
setItems(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to load shopping items');
|
||||||
|
console.error('Error loading items:', err);
|
||||||
|
} finally {
|
||||||
|
if (initialLoad) {
|
||||||
|
setLoading(false);
|
||||||
|
setInitialLoad(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddItem = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newItem.name.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const createdItem = await shoppingService.createItem({
|
||||||
|
name: newItem.name.trim(),
|
||||||
|
amount: newItem.amount.trim() || '1'
|
||||||
|
});
|
||||||
|
setItems([createdItem, ...items]);
|
||||||
|
setNewItem({ name: '', amount: '1' });
|
||||||
|
setShowAddForm(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to add item');
|
||||||
|
console.error('Error adding item:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleItem = async (id) => {
|
||||||
|
try {
|
||||||
|
const updatedItem = await shoppingService.toggleItem(id);
|
||||||
|
setItems(items.map(item =>
|
||||||
|
item.id === id ? updatedItem : item
|
||||||
|
));
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to update item');
|
||||||
|
console.error('Error toggling item:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteItem = async (id) => {
|
||||||
|
try {
|
||||||
|
await shoppingService.deleteItem(id);
|
||||||
|
setItems(items.filter(item => item.id !== id));
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to delete item');
|
||||||
|
console.error('Error deleting item:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateItem = async (id, updatedData) => {
|
||||||
|
try {
|
||||||
|
const updatedItem = await shoppingService.updateItem(id, updatedData);
|
||||||
|
setItems(items.map(item =>
|
||||||
|
item.id === id ? updatedItem : item
|
||||||
|
));
|
||||||
|
setEditingItem(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to update item');
|
||||||
|
console.error('Error updating item:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteChecked = async () => {
|
||||||
|
try {
|
||||||
|
await shoppingService.deleteCheckedItems();
|
||||||
|
setItems(items.filter(item => !item.checked));
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to delete checked items');
|
||||||
|
console.error('Error deleting checked items:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTimeUntilDeletion = (checkedAt) => {
|
||||||
|
if (!checkedAt) return null;
|
||||||
|
const twoHoursLater = new Date(new Date(checkedAt).getTime() + 2 * 60 * 60 * 1000);
|
||||||
|
const now = new Date();
|
||||||
|
const timeDiff = twoHoursLater - now;
|
||||||
|
|
||||||
|
if (timeDiff <= 0) return 'Wird gelöscht...';
|
||||||
|
|
||||||
|
const minutes = Math.floor(timeDiff / (1000 * 60));
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const remainingMinutes = minutes % 60;
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${remainingMinutes}m`;
|
||||||
|
}
|
||||||
|
return `${minutes}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const uncheckedItems = items.filter(item => !item.checked);
|
||||||
|
const checkedItems = items.filter(item => item.checked);
|
||||||
|
|
||||||
|
if (loading && initialLoad) {
|
||||||
|
return (
|
||||||
|
<div className="shopping-container">
|
||||||
|
<div className="loading">
|
||||||
|
<FaShoppingCart className="loading-icon" />
|
||||||
|
<p>Einkaufsliste wird geladen...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shopping-container">
|
||||||
|
<div className="shopping-header">
|
||||||
|
<h1>
|
||||||
|
<FaShoppingCart />
|
||||||
|
Einkaufsliste
|
||||||
|
</h1>
|
||||||
|
<div className="header-actions">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => setShowAddForm(!showAddForm)}
|
||||||
|
>
|
||||||
|
<FaPlus />
|
||||||
|
Artikel hinzufügen
|
||||||
|
</button>
|
||||||
|
{checkedItems.length > 0 && (
|
||||||
|
<button
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={handleDeleteChecked}
|
||||||
|
>
|
||||||
|
<FaTrash />
|
||||||
|
Erledigte löschen ({checkedItems.length})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="error-message">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError(null)}>×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAddForm && (
|
||||||
|
<form className="add-item-form" onSubmit={handleAddItem}>
|
||||||
|
<div className="form-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Artikelname..."
|
||||||
|
value={newItem.name}
|
||||||
|
onChange={(e) => setNewItem({ ...newItem, name: e.target.value })}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Menge"
|
||||||
|
value={newItem.amount}
|
||||||
|
onChange={(e) => setNewItem({ ...newItem, amount: e.target.value })}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
<FaPlus />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setShowAddForm(false);
|
||||||
|
setNewItem({ name: '', amount: '1' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="shopping-lists">
|
||||||
|
{/* Unchecked Items */}
|
||||||
|
<div className="shopping-section">
|
||||||
|
<h2>Zu kaufen ({uncheckedItems.length})</h2>
|
||||||
|
<div className="items-list">
|
||||||
|
{uncheckedItems.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<FaShoppingCart />
|
||||||
|
<p>Keine Artikel auf der Einkaufsliste</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
uncheckedItems.map(item => (
|
||||||
|
<ShoppingItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onToggle={handleToggleItem}
|
||||||
|
onDelete={handleDeleteItem}
|
||||||
|
onUpdate={handleUpdateItem}
|
||||||
|
editingItem={editingItem}
|
||||||
|
setEditingItem={setEditingItem}
|
||||||
|
formatDate={formatDate}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Checked Items */}
|
||||||
|
{checkedItems.length > 0 && (
|
||||||
|
<div className="shopping-section">
|
||||||
|
<h2>Erledigt ({checkedItems.length})</h2>
|
||||||
|
<div className="items-list">
|
||||||
|
{checkedItems.map(item => (
|
||||||
|
<ShoppingItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onToggle={handleToggleItem}
|
||||||
|
onDelete={handleDeleteItem}
|
||||||
|
onUpdate={handleUpdateItem}
|
||||||
|
editingItem={editingItem}
|
||||||
|
setEditingItem={setEditingItem}
|
||||||
|
formatDate={formatDate}
|
||||||
|
getTimeUntilDeletion={getTimeUntilDeletion}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Shopping Item Component
|
||||||
|
const ShoppingItem = ({
|
||||||
|
item,
|
||||||
|
onToggle,
|
||||||
|
onDelete,
|
||||||
|
onUpdate,
|
||||||
|
editingItem,
|
||||||
|
setEditingItem,
|
||||||
|
formatDate,
|
||||||
|
getTimeUntilDeletion
|
||||||
|
}) => {
|
||||||
|
const [editData, setEditData] = useState({ name: item.name, amount: item.amount });
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
setEditingItem(item.id);
|
||||||
|
setEditData({ name: item.name, amount: item.amount });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onUpdate(item.id, editData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setEditingItem(null);
|
||||||
|
setEditData({ name: item.name, amount: item.amount });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEditing = editingItem === item.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`shopping-item ${item.checked ? 'checked' : ''}`}>
|
||||||
|
<button
|
||||||
|
className="check-btn"
|
||||||
|
onClick={() => onToggle(item.id)}
|
||||||
|
>
|
||||||
|
<FaCheck />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="item-content">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="edit-form">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editData.name}
|
||||||
|
onChange={(e) => setEditData({ ...editData, name: e.target.value })}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSave();
|
||||||
|
if (e.key === 'Escape') handleCancel();
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editData.amount}
|
||||||
|
onChange={(e) => setEditData({ ...editData, amount: e.target.value })}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSave();
|
||||||
|
if (e.key === 'Escape') handleCancel();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="item-main">
|
||||||
|
<span className="item-name">{item.name}</span>
|
||||||
|
<span className="item-amount">{item.amount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="item-meta">
|
||||||
|
<span className="item-date">
|
||||||
|
{formatDate(item.date)}
|
||||||
|
</span>
|
||||||
|
{item.checked && item.checkedAt && getTimeUntilDeletion && (
|
||||||
|
<span className="deletion-timer">
|
||||||
|
<FaClock />
|
||||||
|
{getTimeUntilDeletion(item.checkedAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="item-actions">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<button className="btn btn-primary btn-small" onClick={handleSave}>
|
||||||
|
<FaCheck />
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary btn-small" onClick={handleCancel}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button className="btn btn-secondary btn-small" onClick={handleEdit}>
|
||||||
|
<FaEdit />
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-danger btn-small" onClick={() => onDelete(item.id)}>
|
||||||
|
<FaTrash />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Shopping;
|
||||||
|
477
dashboard/src/renderer/src/pages/Shopping.sass
Normal file
477
dashboard/src/renderer/src/pages/Shopping.sass
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
// Shopping Page - Single Unified Glassmorphism Container
|
||||||
|
.shopping-container
|
||||||
|
position: absolute
|
||||||
|
top: 7rem
|
||||||
|
left: 0.5rem
|
||||||
|
right: 0.5rem
|
||||||
|
bottom: 0.5rem
|
||||||
|
background: rgba(255, 255, 255, 0.35)
|
||||||
|
border-radius: 24px
|
||||||
|
backdrop-filter: blur(40px)
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.4)
|
||||||
|
box-shadow: 0 16px 64px rgba(0, 0, 0, 0.1)
|
||||||
|
padding: 2rem
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif
|
||||||
|
overflow: hidden
|
||||||
|
|
||||||
|
.loading
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
flex: 1
|
||||||
|
color: #1e293b
|
||||||
|
|
||||||
|
.loading-icon
|
||||||
|
font-size: 3rem
|
||||||
|
margin-bottom: 1rem
|
||||||
|
color: #4CAF50
|
||||||
|
animation: spin 2s linear infinite
|
||||||
|
|
||||||
|
p
|
||||||
|
font-size: 1.1rem
|
||||||
|
margin: 0
|
||||||
|
font-weight: 600
|
||||||
|
|
||||||
|
.shopping-header
|
||||||
|
display: flex
|
||||||
|
justify-content: space-between
|
||||||
|
align-items: center
|
||||||
|
margin-bottom: 2rem
|
||||||
|
padding-bottom: 1.5rem
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.3)
|
||||||
|
flex-wrap: wrap
|
||||||
|
gap: 1.5rem
|
||||||
|
flex-shrink: 0
|
||||||
|
|
||||||
|
h1
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 0.75rem
|
||||||
|
margin: 0
|
||||||
|
color: #1e293b
|
||||||
|
font-size: 2.25rem
|
||||||
|
font-weight: 700
|
||||||
|
|
||||||
|
svg
|
||||||
|
color: #4CAF50
|
||||||
|
font-size: 2rem
|
||||||
|
|
||||||
|
.header-actions
|
||||||
|
display: flex
|
||||||
|
gap: 0.75rem
|
||||||
|
flex-wrap: wrap
|
||||||
|
|
||||||
|
.error-message
|
||||||
|
background: rgba(244, 67, 54, 0.1)
|
||||||
|
border: 1px solid rgba(244, 67, 54, 0.3)
|
||||||
|
color: #dc2626
|
||||||
|
padding: 1rem 1.5rem
|
||||||
|
border-radius: 16px
|
||||||
|
margin-bottom: 1.5rem
|
||||||
|
display: flex
|
||||||
|
justify-content: space-between
|
||||||
|
align-items: center
|
||||||
|
border-left: 4px solid #f44336
|
||||||
|
font-weight: 600
|
||||||
|
flex-shrink: 0
|
||||||
|
|
||||||
|
button
|
||||||
|
background: rgba(244, 67, 54, 0.1)
|
||||||
|
border: 1px solid rgba(244, 67, 54, 0.3)
|
||||||
|
font-size: 1.2rem
|
||||||
|
cursor: pointer
|
||||||
|
color: #dc2626
|
||||||
|
padding: 0.5rem
|
||||||
|
border-radius: 8px
|
||||||
|
transition: all 0.3s ease
|
||||||
|
font-weight: 600
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background: rgba(244, 67, 54, 0.2)
|
||||||
|
transform: scale(1.1)
|
||||||
|
|
||||||
|
.add-item-form
|
||||||
|
margin-bottom: 2rem
|
||||||
|
flex-shrink: 0
|
||||||
|
transition: all 0.3s ease
|
||||||
|
|
||||||
|
.form-group
|
||||||
|
display: flex
|
||||||
|
gap: 1rem
|
||||||
|
align-items: center
|
||||||
|
flex-wrap: wrap
|
||||||
|
|
||||||
|
input
|
||||||
|
flex: 1
|
||||||
|
min-width: 200px
|
||||||
|
padding: 1rem 1.25rem
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.5)
|
||||||
|
border-radius: 12px
|
||||||
|
background: rgba(255, 255, 255, 0.6)
|
||||||
|
backdrop-filter: blur(20px)
|
||||||
|
font-size: 1rem
|
||||||
|
color: #1e293b
|
||||||
|
font-weight: 500
|
||||||
|
transition: all 0.3s ease
|
||||||
|
|
||||||
|
&:focus
|
||||||
|
outline: none
|
||||||
|
border-color: rgba(76, 175, 80, 0.7)
|
||||||
|
background: rgba(255, 255, 255, 0.8)
|
||||||
|
box-shadow: 0 8px 32px rgba(76, 175, 80, 0.2)
|
||||||
|
transform: translateY(-1px)
|
||||||
|
|
||||||
|
&::placeholder
|
||||||
|
color: rgba(30, 41, 59, 0.7)
|
||||||
|
font-weight: 500
|
||||||
|
|
||||||
|
.shopping-lists
|
||||||
|
flex: 1
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
gap: 2rem
|
||||||
|
overflow-y: auto
|
||||||
|
padding-right: 0.5rem
|
||||||
|
|
||||||
|
&::-webkit-scrollbar
|
||||||
|
width: 6px
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track
|
||||||
|
background: rgba(255, 255, 255, 0.1)
|
||||||
|
border-radius: 3px
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb
|
||||||
|
background: rgba(255, 255, 255, 0.3)
|
||||||
|
border-radius: 3px
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background: rgba(255, 255, 255, 0.5)
|
||||||
|
|
||||||
|
.shopping-section
|
||||||
|
flex-shrink: 0
|
||||||
|
|
||||||
|
h2
|
||||||
|
color: #1e293b
|
||||||
|
margin: 0 0 1.5rem 0
|
||||||
|
font-size: 1.5rem
|
||||||
|
font-weight: 700
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 0.5rem
|
||||||
|
padding-bottom: 1rem
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.2)
|
||||||
|
|
||||||
|
.empty-state
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
align-items: center
|
||||||
|
padding: 3rem 2rem
|
||||||
|
color: #64748b
|
||||||
|
text-align: center
|
||||||
|
background: rgba(255, 255, 255, 0.2)
|
||||||
|
border-radius: 16px
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3)
|
||||||
|
margin-top: 1rem
|
||||||
|
|
||||||
|
svg
|
||||||
|
font-size: 3rem
|
||||||
|
margin-bottom: 1rem
|
||||||
|
opacity: 0.6
|
||||||
|
|
||||||
|
p
|
||||||
|
font-size: 1.1rem
|
||||||
|
margin: 0
|
||||||
|
font-weight: 600
|
||||||
|
|
||||||
|
.items-list
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
gap: 1rem
|
||||||
|
margin-top: 1rem
|
||||||
|
|
||||||
|
.shopping-item
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 1.25rem
|
||||||
|
padding: 1.5rem
|
||||||
|
background: rgba(255, 255, 255, 0.4)
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.6)
|
||||||
|
border-radius: 16px
|
||||||
|
transition: all 0.3s ease
|
||||||
|
backdrop-filter: blur(20px)
|
||||||
|
min-height: 80px
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
transform: translateY(-2px)
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15)
|
||||||
|
background: rgba(255, 255, 255, 0.55)
|
||||||
|
border-color: rgba(255, 255, 255, 0.8)
|
||||||
|
|
||||||
|
&.checked
|
||||||
|
background: rgba(76, 175, 80, 0.15)
|
||||||
|
border-color: rgba(76, 175, 80, 0.4)
|
||||||
|
|
||||||
|
.item-name
|
||||||
|
text-decoration: line-through
|
||||||
|
color: #64748b
|
||||||
|
|
||||||
|
.check-btn
|
||||||
|
background: linear-gradient(135deg, #4CAF50, #388e3c)
|
||||||
|
border-color: #4CAF50
|
||||||
|
color: white
|
||||||
|
box-shadow: 0 6px 20px rgba(76, 175, 80, 0.4)
|
||||||
|
|
||||||
|
.check-btn
|
||||||
|
width: 48px
|
||||||
|
height: 48px
|
||||||
|
border-radius: 50%
|
||||||
|
border: 2px solid rgba(76, 175, 80, 0.5)
|
||||||
|
background: rgba(255, 255, 255, 0.6)
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
cursor: pointer
|
||||||
|
transition: all 0.3s ease
|
||||||
|
flex-shrink: 0
|
||||||
|
backdrop-filter: blur(10px)
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
border-color: #4CAF50
|
||||||
|
background: rgba(76, 175, 80, 0.2)
|
||||||
|
transform: scale(1.1)
|
||||||
|
box-shadow: 0 6px 20px rgba(76, 175, 80, 0.3)
|
||||||
|
|
||||||
|
svg
|
||||||
|
font-size: 1.1rem
|
||||||
|
font-weight: 700
|
||||||
|
|
||||||
|
.item-content
|
||||||
|
flex: 1
|
||||||
|
min-width: 0
|
||||||
|
|
||||||
|
.item-main
|
||||||
|
display: flex
|
||||||
|
justify-content: space-between
|
||||||
|
align-items: center
|
||||||
|
margin-bottom: 0.75rem
|
||||||
|
|
||||||
|
.item-name
|
||||||
|
font-weight: 700
|
||||||
|
color: #1e293b
|
||||||
|
font-size: 1.15rem
|
||||||
|
flex: 1
|
||||||
|
min-width: 0
|
||||||
|
overflow: hidden
|
||||||
|
text-overflow: ellipsis
|
||||||
|
white-space: nowrap
|
||||||
|
|
||||||
|
.item-amount
|
||||||
|
background: rgba(59, 130, 246, 0.2)
|
||||||
|
color: #1e40af
|
||||||
|
padding: 0.5rem 1rem
|
||||||
|
border-radius: 10px
|
||||||
|
font-size: 0.9rem
|
||||||
|
font-weight: 700
|
||||||
|
white-space: nowrap
|
||||||
|
margin-left: 1rem
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.4)
|
||||||
|
backdrop-filter: blur(10px)
|
||||||
|
|
||||||
|
.item-meta
|
||||||
|
display: flex
|
||||||
|
justify-content: space-between
|
||||||
|
align-items: center
|
||||||
|
gap: 1rem
|
||||||
|
|
||||||
|
.item-date
|
||||||
|
color: #64748b
|
||||||
|
font-size: 0.9rem
|
||||||
|
font-weight: 600
|
||||||
|
|
||||||
|
.deletion-timer
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 0.5rem
|
||||||
|
color: #f59e0b
|
||||||
|
font-size: 0.9rem
|
||||||
|
font-weight: 700
|
||||||
|
background: rgba(245, 158, 11, 0.15)
|
||||||
|
padding: 0.375rem 0.75rem
|
||||||
|
border-radius: 8px
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.4)
|
||||||
|
backdrop-filter: blur(10px)
|
||||||
|
|
||||||
|
svg
|
||||||
|
font-size: 0.8rem
|
||||||
|
|
||||||
|
.edit-form
|
||||||
|
display: flex
|
||||||
|
gap: 1rem
|
||||||
|
width: 100%
|
||||||
|
|
||||||
|
input
|
||||||
|
padding: 1rem
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.6)
|
||||||
|
border-radius: 10px
|
||||||
|
font-size: 1rem
|
||||||
|
background: rgba(255, 255, 255, 0.7)
|
||||||
|
backdrop-filter: blur(10px)
|
||||||
|
color: #1e293b
|
||||||
|
font-weight: 600
|
||||||
|
|
||||||
|
&:focus
|
||||||
|
outline: none
|
||||||
|
border-color: rgba(76, 175, 80, 0.7)
|
||||||
|
background: rgba(255, 255, 255, 0.85)
|
||||||
|
box-shadow: 0 4px 16px rgba(76, 175, 80, 0.2)
|
||||||
|
|
||||||
|
input:first-child
|
||||||
|
flex: 2
|
||||||
|
|
||||||
|
input:last-child
|
||||||
|
flex: 1
|
||||||
|
min-width: 100px
|
||||||
|
|
||||||
|
.item-actions
|
||||||
|
display: flex
|
||||||
|
gap: 0.75rem
|
||||||
|
flex-shrink: 0
|
||||||
|
|
||||||
|
.btn
|
||||||
|
padding: 0.875rem 1.75rem
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.5)
|
||||||
|
border-radius: 12px
|
||||||
|
cursor: pointer
|
||||||
|
font-size: 0.95rem
|
||||||
|
font-weight: 700
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 0.75rem
|
||||||
|
transition: all 0.3s ease
|
||||||
|
white-space: nowrap
|
||||||
|
backdrop-filter: blur(20px)
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
transform: translateY(-2px)
|
||||||
|
|
||||||
|
&.btn-primary
|
||||||
|
background: linear-gradient(135deg, rgba(76, 175, 80, 0.4), rgba(56, 142, 60, 0.4))
|
||||||
|
color: white
|
||||||
|
box-shadow: 0 6px 20px rgba(76, 175, 80, 0.2)
|
||||||
|
border-color: rgba(76, 175, 80, 0.5)
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background: linear-gradient(135deg, rgba(76, 175, 80, 0.6), rgba(56, 142, 60, 0.6))
|
||||||
|
box-shadow: 0 12px 40px rgba(76, 175, 80, 0.3)
|
||||||
|
|
||||||
|
&.btn-secondary
|
||||||
|
background: linear-gradient(135deg, rgba(100, 116, 139, 0.4), rgba(71, 85, 105, 0.4))
|
||||||
|
color: white
|
||||||
|
border-color: rgba(100, 116, 139, 0.5)
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background: linear-gradient(135deg, rgba(100, 116, 139, 0.6), rgba(71, 85, 105, 0.6))
|
||||||
|
box-shadow: 0 12px 40px rgba(100, 116, 139, 0.3)
|
||||||
|
|
||||||
|
&.btn-danger
|
||||||
|
background: linear-gradient(135deg, rgba(244, 67, 54, 0.4), rgba(211, 47, 47, 0.4))
|
||||||
|
color: white
|
||||||
|
border-color: rgba(244, 67, 54, 0.5)
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background: linear-gradient(135deg, rgba(244, 67, 54, 0.6), rgba(211, 47, 47, 0.6))
|
||||||
|
box-shadow: 0 12px 40px rgba(244, 67, 54, 0.3)
|
||||||
|
|
||||||
|
&.btn-small
|
||||||
|
padding: 0.625rem 1rem
|
||||||
|
font-size: 0.85rem
|
||||||
|
min-width: 44px
|
||||||
|
justify-content: center
|
||||||
|
|
||||||
|
svg
|
||||||
|
margin: 0
|
||||||
|
font-size: 0.9rem
|
||||||
|
|
||||||
|
@keyframes spin
|
||||||
|
from
|
||||||
|
transform: rotate(0deg)
|
||||||
|
to
|
||||||
|
transform: rotate(360deg)
|
||||||
|
|
||||||
|
// Enhanced Responsive Design
|
||||||
|
@media (max-width: 768px)
|
||||||
|
.shopping-container
|
||||||
|
top: 6.5rem
|
||||||
|
left: 0.375rem
|
||||||
|
right: 0.375rem
|
||||||
|
bottom: 0.375rem
|
||||||
|
padding: 1.5rem
|
||||||
|
|
||||||
|
.shopping-header
|
||||||
|
flex-direction: column
|
||||||
|
align-items: stretch
|
||||||
|
text-align: center
|
||||||
|
padding-bottom: 1rem
|
||||||
|
|
||||||
|
h1
|
||||||
|
justify-content: center
|
||||||
|
margin-bottom: 1rem
|
||||||
|
font-size: 2rem
|
||||||
|
|
||||||
|
.header-actions
|
||||||
|
justify-content: center
|
||||||
|
|
||||||
|
.add-item-form
|
||||||
|
.form-group
|
||||||
|
flex-direction: column
|
||||||
|
|
||||||
|
input
|
||||||
|
min-width: auto
|
||||||
|
width: 100%
|
||||||
|
|
||||||
|
.shopping-item
|
||||||
|
padding: 1.25rem
|
||||||
|
|
||||||
|
.item-content .item-main
|
||||||
|
flex-direction: column
|
||||||
|
align-items: flex-start
|
||||||
|
gap: 0.75rem
|
||||||
|
|
||||||
|
.item-amount
|
||||||
|
margin-left: 0
|
||||||
|
align-self: flex-start
|
||||||
|
|
||||||
|
.item-actions
|
||||||
|
flex-direction: column
|
||||||
|
|
||||||
|
@media (max-width: 480px)
|
||||||
|
.shopping-container
|
||||||
|
top: 6rem
|
||||||
|
left: 0.25rem
|
||||||
|
right: 0.25rem
|
||||||
|
bottom: 0.25rem
|
||||||
|
padding: 1rem
|
||||||
|
|
||||||
|
.shopping-item
|
||||||
|
flex-direction: column
|
||||||
|
align-items: stretch
|
||||||
|
gap: 1.25rem
|
||||||
|
padding: 1rem
|
||||||
|
|
||||||
|
.check-btn
|
||||||
|
align-self: flex-start
|
||||||
|
|
||||||
|
.item-actions
|
||||||
|
flex-direction: row
|
||||||
|
justify-content: flex-end
|
||||||
|
gap: 0.5rem
|
||||||
|
|
||||||
|
.btn
|
||||||
|
padding: 0.75rem 1rem
|
||||||
|
font-size: 0.85rem
|
||||||
|
|
||||||
|
&.btn-small
|
||||||
|
padding: 0.5rem 0.75rem
|
@@ -1,4 +1,5 @@
|
|||||||
// Pages Styles - Light Mode with Glass Effects
|
// Import individual page styles
|
||||||
|
@import './Shopping.sass'
|
||||||
|
|
||||||
// Common page container
|
// Common page container
|
||||||
.page-container
|
.page-container
|
||||||
@@ -121,158 +122,6 @@
|
|||||||
background: linear-gradient(135deg, rgba(168, 85, 247, 0.3), rgba(147, 51, 234, 0.3))
|
background: linear-gradient(135deg, rgba(168, 85, 247, 0.3), rgba(147, 51, 234, 0.3))
|
||||||
color: white
|
color: white
|
||||||
|
|
||||||
// Shopping Styles
|
|
||||||
.shopping-container
|
|
||||||
max-width: 800px
|
|
||||||
margin: 0 auto
|
|
||||||
|
|
||||||
.shopping-header
|
|
||||||
display: flex
|
|
||||||
justify-content: space-between
|
|
||||||
align-items: center
|
|
||||||
margin-bottom: 2rem
|
|
||||||
|
|
||||||
h1
|
|
||||||
display: flex
|
|
||||||
align-items: center
|
|
||||||
gap: 0.75rem
|
|
||||||
font-size: 2rem
|
|
||||||
font-weight: 700
|
|
||||||
color: #1e293b
|
|
||||||
margin: 0
|
|
||||||
|
|
||||||
.shopping-stats
|
|
||||||
display: flex
|
|
||||||
gap: 1rem
|
|
||||||
|
|
||||||
span
|
|
||||||
background: rgba(255, 255, 255, 0.3)
|
|
||||||
padding: 0.5rem 1rem
|
|
||||||
border-radius: 12px
|
|
||||||
font-size: 0.875rem
|
|
||||||
font-weight: 500
|
|
||||||
color: #475569
|
|
||||||
|
|
||||||
.add-item-form
|
|
||||||
background: rgba(255, 255, 255, 0.25)
|
|
||||||
border-radius: 20px
|
|
||||||
padding: 1.5rem
|
|
||||||
margin-bottom: 2rem
|
|
||||||
backdrop-filter: blur(40px)
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3)
|
|
||||||
|
|
||||||
.input-group
|
|
||||||
display: flex
|
|
||||||
gap: 1rem
|
|
||||||
|
|
||||||
input
|
|
||||||
flex: 1
|
|
||||||
padding: 0.75rem 1rem
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.4)
|
|
||||||
border-radius: 12px
|
|
||||||
background: rgba(255, 255, 255, 0.3)
|
|
||||||
color: #1e293b
|
|
||||||
font-size: 1rem
|
|
||||||
backdrop-filter: blur(20px)
|
|
||||||
|
|
||||||
&::placeholder
|
|
||||||
color: rgba(30, 41, 59, 0.6)
|
|
||||||
|
|
||||||
&:focus
|
|
||||||
outline: none
|
|
||||||
border-color: rgba(59, 130, 246, 0.5)
|
|
||||||
background: rgba(255, 255, 255, 0.4)
|
|
||||||
|
|
||||||
.add-btn
|
|
||||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.3), rgba(5, 150, 105, 0.3))
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.4)
|
|
||||||
border-radius: 12px
|
|
||||||
padding: 0.75rem 1rem
|
|
||||||
color: white
|
|
||||||
cursor: pointer
|
|
||||||
transition: all 0.3s ease
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.4), rgba(5, 150, 105, 0.4))
|
|
||||||
transform: translateY(-2px)
|
|
||||||
|
|
||||||
.shopping-section
|
|
||||||
margin-bottom: 2rem
|
|
||||||
|
|
||||||
h3
|
|
||||||
font-size: 1.25rem
|
|
||||||
font-weight: 600
|
|
||||||
color: #1e293b
|
|
||||||
margin-bottom: 1rem
|
|
||||||
|
|
||||||
.items-list
|
|
||||||
background: rgba(255, 255, 255, 0.25)
|
|
||||||
border-radius: 20px
|
|
||||||
padding: 1.5rem
|
|
||||||
backdrop-filter: blur(40px)
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3)
|
|
||||||
|
|
||||||
.shopping-item
|
|
||||||
display: flex
|
|
||||||
align-items: center
|
|
||||||
gap: 1rem
|
|
||||||
padding: 1rem 0
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2)
|
|
||||||
|
|
||||||
&:last-child
|
|
||||||
border-bottom: none
|
|
||||||
|
|
||||||
&.completed
|
|
||||||
opacity: 0.6
|
|
||||||
|
|
||||||
.item-name
|
|
||||||
text-decoration: line-through
|
|
||||||
|
|
||||||
.check-btn
|
|
||||||
width: 32px
|
|
||||||
height: 32px
|
|
||||||
border-radius: 50%
|
|
||||||
border: 2px solid rgba(16, 185, 129, 0.5)
|
|
||||||
background: transparent
|
|
||||||
color: transparent
|
|
||||||
cursor: pointer
|
|
||||||
transition: all 0.3s ease
|
|
||||||
|
|
||||||
&.checked
|
|
||||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.8), rgba(5, 150, 105, 0.8))
|
|
||||||
color: white
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
border-color: rgba(16, 185, 129, 0.8)
|
|
||||||
background: rgba(16, 185, 129, 0.1)
|
|
||||||
|
|
||||||
.item-details
|
|
||||||
flex: 1
|
|
||||||
display: flex
|
|
||||||
flex-direction: column
|
|
||||||
gap: 0.25rem
|
|
||||||
|
|
||||||
.item-name
|
|
||||||
font-weight: 500
|
|
||||||
color: #1e293b
|
|
||||||
|
|
||||||
.item-quantity, .item-category
|
|
||||||
font-size: 0.875rem
|
|
||||||
color: #64748b
|
|
||||||
|
|
||||||
.delete-btn
|
|
||||||
background: transparent
|
|
||||||
border: none
|
|
||||||
color: #ef4444
|
|
||||||
cursor: pointer
|
|
||||||
padding: 0.5rem
|
|
||||||
border-radius: 8px
|
|
||||||
transition: all 0.3s ease
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
background: rgba(239, 68, 68, 0.1)
|
|
||||||
color: #dc2626
|
|
||||||
|
|
||||||
// Notes Styles
|
// Notes Styles
|
||||||
.notes-container
|
.notes-container
|
||||||
max-width: 1200px
|
max-width: 1200px
|
||||||
|
51
dashboard/src/renderer/src/services/ShoppingService.js
Normal file
51
dashboard/src/renderer/src/services/ShoppingService.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import requestUtil from '../utils/RequestUtil';
|
||||||
|
|
||||||
|
class ShoppingService {
|
||||||
|
constructor() {
|
||||||
|
this.endpoint = '/api/shopping';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all shopping items
|
||||||
|
async getItems() {
|
||||||
|
return requestUtil.get(this.endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a specific shopping item
|
||||||
|
async getItem(id) {
|
||||||
|
return requestUtil.get(`${this.endpoint}/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new shopping item
|
||||||
|
async createItem(item) {
|
||||||
|
return requestUtil.post(this.endpoint, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update a shopping item
|
||||||
|
async updateItem(id, item) {
|
||||||
|
return requestUtil.put(`${this.endpoint}/${id}`, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle checked status of an item
|
||||||
|
async toggleItem(id) {
|
||||||
|
return requestUtil.patch(`${this.endpoint}/${id}/toggle`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete a shopping item
|
||||||
|
async deleteItem(id) {
|
||||||
|
return requestUtil.delete(`${this.endpoint}/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all checked items
|
||||||
|
async deleteCheckedItems() {
|
||||||
|
return requestUtil.delete(`${this.endpoint}/checked/all`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
async healthCheck() {
|
||||||
|
return requestUtil.get('/api/health');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and export a singleton instance
|
||||||
|
const shoppingService = new ShoppingService();
|
||||||
|
export default shoppingService;
|
85
dashboard/src/renderer/src/utils/RequestUtil.js
Normal file
85
dashboard/src/renderer/src/utils/RequestUtil.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
class RequestUtil {
|
||||||
|
constructor() {
|
||||||
|
this.baseURL = this.getBaseURL();
|
||||||
|
}
|
||||||
|
|
||||||
|
getBaseURL() {
|
||||||
|
// In production, use the static endpoint, otherwise use proxy
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
return 'https://static.endpoint.com';
|
||||||
|
}
|
||||||
|
return ''; // Uses proxy in development
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(endpoint, options = {}) {
|
||||||
|
const url = `${this.baseURL}${endpoint}`;
|
||||||
|
|
||||||
|
const defaultOptions = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
...defaultOptions,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, config);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ error: 'Request failed' }));
|
||||||
|
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Request failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET request
|
||||||
|
async get(endpoint) {
|
||||||
|
return this.request(endpoint, {
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST request
|
||||||
|
async post(endpoint, data = {}) {
|
||||||
|
return this.request(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT request
|
||||||
|
async put(endpoint, data = {}) {
|
||||||
|
return this.request(endpoint, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH request
|
||||||
|
async patch(endpoint, data = {}) {
|
||||||
|
return this.request(endpoint, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE request
|
||||||
|
async delete(endpoint) {
|
||||||
|
return this.request(endpoint, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and export a singleton instance
|
||||||
|
const requestUtil = new RequestUtil();
|
||||||
|
export default requestUtil;
|
Reference in New Issue
Block a user