Compare commits
3 Commits
cd06dbd9fc
...
8d5b2d3ea3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d5b2d3ea3 | ||
|
|
2838cb5806 | ||
|
|
a38c29b5b5 |
@ -40,4 +40,7 @@ app.use("/admin", adminRoutes);
|
|||||||
const usersRoutes = require("./routes/users.routes");
|
const usersRoutes = require("./routes/users.routes");
|
||||||
app.use("/users", usersRoutes);
|
app.use("/users", usersRoutes);
|
||||||
|
|
||||||
|
const configRoutes = require("./routes/config.routes");
|
||||||
|
app.use("/config", configRoutes);
|
||||||
|
|
||||||
module.exports = app;
|
module.exports = app;
|
||||||
15
backend/config/constants.js
Normal file
15
backend/config/constants.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Application-wide constants
|
||||||
|
* These are non-secret configuration values shared across the application
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// File upload limits
|
||||||
|
MAX_FILE_SIZE_MB: 20,
|
||||||
|
MAX_FILE_SIZE_BYTES: 20 * 1024 * 1024,
|
||||||
|
|
||||||
|
// Image processing
|
||||||
|
MAX_IMAGE_DIMENSION: 800,
|
||||||
|
IMAGE_QUALITY: 85,
|
||||||
|
IMAGE_FORMAT: 'jpeg'
|
||||||
|
};
|
||||||
14
backend/controllers/config.controller.js
Normal file
14
backend/controllers/config.controller.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Configuration endpoints
|
||||||
|
* Public endpoints that provide application configuration to clients
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { MAX_FILE_SIZE_MB, MAX_IMAGE_DIMENSION, IMAGE_QUALITY } = require("../config/constants");
|
||||||
|
|
||||||
|
exports.getConfig = (req, res) => {
|
||||||
|
res.json({
|
||||||
|
maxFileSizeMB: MAX_FILE_SIZE_MB,
|
||||||
|
maxImageDimension: MAX_IMAGE_DIMENSION,
|
||||||
|
imageQuality: IMAGE_QUALITY
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -1,11 +1,12 @@
|
|||||||
const multer = require("multer");
|
const multer = require("multer");
|
||||||
const sharp = require("sharp");
|
const sharp = require("sharp");
|
||||||
|
const { MAX_FILE_SIZE_BYTES, MAX_IMAGE_DIMENSION, IMAGE_QUALITY } = require("../config/constants");
|
||||||
|
|
||||||
// Configure multer for memory storage (we'll process before saving to DB)
|
// Configure multer for memory storage (we'll process before saving to DB)
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage: multer.memoryStorage(),
|
storage: multer.memoryStorage(),
|
||||||
limits: {
|
limits: {
|
||||||
fileSize: 10 * 1024 * 1024, // 10MB max file size
|
fileSize: MAX_FILE_SIZE_BYTES,
|
||||||
},
|
},
|
||||||
fileFilter: (req, file, cb) => {
|
fileFilter: (req, file, cb) => {
|
||||||
// Only accept images
|
// Only accept images
|
||||||
@ -24,13 +25,13 @@ const processImage = async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Compress and resize image to 800x800px, JPEG quality 85
|
// Compress and resize image using constants
|
||||||
const processedBuffer = await sharp(req.file.buffer)
|
const processedBuffer = await sharp(req.file.buffer)
|
||||||
.resize(800, 800, {
|
.resize(MAX_IMAGE_DIMENSION, MAX_IMAGE_DIMENSION, {
|
||||||
fit: "inside",
|
fit: "inside",
|
||||||
withoutEnlargement: true,
|
withoutEnlargement: true,
|
||||||
})
|
})
|
||||||
.jpeg({ quality: 85 })
|
.jpeg({ quality: IMAGE_QUALITY })
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|
||||||
// Attach processed image to request
|
// Attach processed image to request
|
||||||
|
|||||||
@ -10,18 +10,24 @@ exports.getUnboughtItems = async () => {
|
|||||||
gl.bought,
|
gl.bought,
|
||||||
ENCODE(gl.item_image, 'base64') as item_image,
|
ENCODE(gl.item_image, 'base64') as item_image,
|
||||||
gl.image_mime_type,
|
gl.image_mime_type,
|
||||||
ARRAY_AGG(DISTINCT COALESCE(gh_user.name, creator.name)) FILTER (WHERE COALESCE(gh_user.name, creator.name) IS NOT NULL) as added_by_users,
|
(
|
||||||
|
SELECT ARRAY_AGG(u.name ORDER BY gh.added_on DESC)
|
||||||
|
FROM (
|
||||||
|
SELECT gh.added_by, gh.added_on,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY gh.list_item_id ORDER BY gh.added_on DESC) as rn
|
||||||
|
FROM grocery_history gh
|
||||||
|
WHERE gh.list_item_id = gl.id
|
||||||
|
) gh
|
||||||
|
JOIN users u ON gh.added_by = u.id
|
||||||
|
WHERE gh.rn <= gl.quantity
|
||||||
|
) as added_by_users,
|
||||||
gl.modified_on as last_added_on,
|
gl.modified_on as last_added_on,
|
||||||
ic.item_type,
|
ic.item_type,
|
||||||
ic.item_group,
|
ic.item_group,
|
||||||
ic.zone
|
ic.zone
|
||||||
FROM grocery_list gl
|
FROM grocery_list gl
|
||||||
LEFT JOIN users creator ON gl.added_by = creator.id
|
|
||||||
LEFT JOIN grocery_history gh ON gl.id = gh.list_item_id
|
|
||||||
LEFT JOIN users gh_user ON gh.added_by = gh_user.id
|
|
||||||
LEFT JOIN item_classification ic ON gl.id = ic.id
|
LEFT JOIN item_classification ic ON gl.id = ic.id
|
||||||
WHERE gl.bought = FALSE
|
WHERE gl.bought = FALSE
|
||||||
GROUP BY gl.id, gl.item_name, gl.quantity, gl.bought, gl.item_image, gl.image_mime_type, gl.modified_on, ic.item_type, ic.item_group, ic.zone
|
|
||||||
ORDER BY gl.id ASC`
|
ORDER BY gl.id ASC`
|
||||||
);
|
);
|
||||||
return result.rows;
|
return result.rows;
|
||||||
@ -119,15 +125,21 @@ exports.getRecentlyBoughtItems = async () => {
|
|||||||
gl.bought,
|
gl.bought,
|
||||||
ENCODE(gl.item_image, 'base64') as item_image,
|
ENCODE(gl.item_image, 'base64') as item_image,
|
||||||
gl.image_mime_type,
|
gl.image_mime_type,
|
||||||
ARRAY_AGG(DISTINCT COALESCE(gh_user.name, creator.name)) FILTER (WHERE COALESCE(gh_user.name, creator.name) IS NOT NULL) as added_by_users,
|
(
|
||||||
|
SELECT ARRAY_AGG(u.name ORDER BY gh.added_on DESC)
|
||||||
|
FROM (
|
||||||
|
SELECT gh.added_by, gh.added_on,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY gh.list_item_id ORDER BY gh.added_on DESC) as rn
|
||||||
|
FROM grocery_history gh
|
||||||
|
WHERE gh.list_item_id = gl.id
|
||||||
|
) gh
|
||||||
|
JOIN users u ON gh.added_by = u.id
|
||||||
|
WHERE gh.rn <= gl.quantity
|
||||||
|
) as added_by_users,
|
||||||
gl.modified_on as last_added_on
|
gl.modified_on as last_added_on
|
||||||
FROM grocery_list gl
|
FROM grocery_list gl
|
||||||
LEFT JOIN users creator ON gl.added_by = creator.id
|
|
||||||
LEFT JOIN grocery_history gh ON gl.id = gh.list_item_id
|
|
||||||
LEFT JOIN users gh_user ON gh.added_by = gh_user.id
|
|
||||||
WHERE gl.bought = TRUE
|
WHERE gl.bought = TRUE
|
||||||
AND gl.modified_on >= NOW() - INTERVAL '24 hours'
|
AND gl.modified_on >= NOW() - INTERVAL '24 hours'
|
||||||
GROUP BY gl.id, gl.item_name, gl.quantity, gl.bought, gl.item_image, gl.image_mime_type, gl.modified_on
|
|
||||||
ORDER BY gl.modified_on DESC`
|
ORDER BY gl.modified_on DESC`
|
||||||
);
|
);
|
||||||
return result.rows;
|
return result.rows;
|
||||||
|
|||||||
8
backend/routes/config.routes.js
Normal file
8
backend/routes/config.routes.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const router = express.Router();
|
||||||
|
const configController = require("../controllers/config.controller");
|
||||||
|
|
||||||
|
// Public endpoint - no authentication required
|
||||||
|
router.get("/", configController.getConfig);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||||
import { ROLES } from "./constants/roles";
|
import { ROLES } from "./constants/roles";
|
||||||
import { AuthProvider } from "./context/AuthContext.jsx";
|
import { AuthProvider } from "./context/AuthContext.jsx";
|
||||||
|
import { ConfigProvider } from "./context/ConfigContext.jsx";
|
||||||
|
|
||||||
import AdminPanel from "./pages/AdminPanel.jsx";
|
import AdminPanel from "./pages/AdminPanel.jsx";
|
||||||
import GroceryList from "./pages/GroceryList.jsx";
|
import GroceryList from "./pages/GroceryList.jsx";
|
||||||
@ -15,6 +16,7 @@ import RoleGuard from "./utils/RoleGuard.jsx";
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
|
<ConfigProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
@ -46,6 +48,7 @@ function App() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
10
frontend/src/api/config.js
Normal file
10
frontend/src/api/config.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import api from "./axios";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch application configuration
|
||||||
|
* @returns {Promise<Object>} Configuration object with maxFileSizeMB, maxImageDimension, etc.
|
||||||
|
*/
|
||||||
|
export const getConfig = async () => {
|
||||||
|
const response = await api.get("/config");
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { useRef } from "react";
|
import { useRef, useState, useContext } from "react";
|
||||||
|
import { ConfigContext } from "../../context/ConfigContext";
|
||||||
import "../../styles/components/ImageUploadSection.css";
|
import "../../styles/components/ImageUploadSection.css";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -17,10 +18,25 @@ export default function ImageUploadSection({
|
|||||||
}) {
|
}) {
|
||||||
const cameraInputRef = useRef(null);
|
const cameraInputRef = useRef(null);
|
||||||
const galleryInputRef = useRef(null);
|
const galleryInputRef = useRef(null);
|
||||||
|
const [sizeError, setSizeError] = useState(null);
|
||||||
|
const { config } = useContext(ConfigContext);
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = config ? config.maxFileSizeMB * 1024 * 1024 : 20 * 1024 * 1024;
|
||||||
|
const MAX_FILE_SIZE_MB = config ? config.maxFileSizeMB : 20;
|
||||||
|
|
||||||
const handleFileChange = (e) => {
|
const handleFileChange = (e) => {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
|
// Check file size
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
|
||||||
|
setSizeError(`Image size (${sizeMB}MB) exceeds the ${MAX_FILE_SIZE_MB}MB limit. Please choose a smaller image.`);
|
||||||
|
// Reset the input
|
||||||
|
e.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Clear any previous error
|
||||||
|
setSizeError(null);
|
||||||
onImageChange(file);
|
onImageChange(file);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -36,6 +52,11 @@ export default function ImageUploadSection({
|
|||||||
return (
|
return (
|
||||||
<div className="image-upload-section">
|
<div className="image-upload-section">
|
||||||
<h3 className="image-upload-title">{title}</h3>
|
<h3 className="image-upload-title">{title}</h3>
|
||||||
|
{sizeError && (
|
||||||
|
<div className="image-upload-error">
|
||||||
|
{sizeError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="image-upload-content">
|
<div className="image-upload-content">
|
||||||
{!imagePreview ? (
|
{!imagePreview ? (
|
||||||
<div className="image-upload-options">
|
<div className="image-upload-options">
|
||||||
|
|||||||
44
frontend/src/context/ConfigContext.jsx
Normal file
44
frontend/src/context/ConfigContext.jsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { createContext, useState, useEffect } from 'react';
|
||||||
|
import { getConfig } from '../api/config';
|
||||||
|
|
||||||
|
export const ConfigContext = createContext({
|
||||||
|
config: null,
|
||||||
|
loading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ConfigProvider = ({ children }) => {
|
||||||
|
const [config, setConfig] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchConfig = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getConfig();
|
||||||
|
setConfig(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch config:', error);
|
||||||
|
// Set default fallback values
|
||||||
|
setConfig({
|
||||||
|
maxFileSizeMB: 20,
|
||||||
|
maxImageDimension: 800,
|
||||||
|
imageQuality: 85
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
config,
|
||||||
|
loading
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</ConfigContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -10,6 +10,17 @@
|
|||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.image-upload-error {
|
||||||
|
background: #f8d7da;
|
||||||
|
border: 1px solid #f5c2c7;
|
||||||
|
color: #842029;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
font-size: 0.9em;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
.image-upload-content {
|
.image-upload-content {
|
||||||
border: 2px dashed #ccc;
|
border: 2px dashed #ccc;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user