This commit is contained in:
2026-06-15 00:27:35 +00:00
parent 675118faea
commit 9dac148b34
28 changed files with 5303 additions and 5303 deletions
+2 -2
View File
@@ -7,9 +7,9 @@
"mounts": [
"type=bind,source=${localEnv:HOME}/.codex,target=/home/node/.codex"
],
"forwardPorts": [5173, 5000, 8999, 27017],
"forwardPorts": [5174, 5000, 8999, 27017],
"portsAttributes": {
"5173": {
"5174": {
"label": "Vite frontend",
"onAutoForward": "notify"
},
+1 -3
View File
@@ -10,9 +10,7 @@ services:
DB_USERNAME: devroot
DB_PASSWORD: devroot
PORT: 5000
VITE_API_HOST: localhost
VITE_API_PORT: 5000
VITE_API_PROTOCOL: http
CORS_ORIGIN: http://localhost:5174
networks:
- mongo-network
volumes:
+2 -1
View File
@@ -1,3 +1,4 @@
/.vs
/.vscode
.env
.env
node_modules/
+3 -1
View File
@@ -30,6 +30,7 @@ DB_PORT=27017
DB_USERNAME=devroot
DB_PASSWORD=devroot
PORT=5000
CORS_ORIGIN=http://localhost:5174
```
Start the app from two terminals inside the container:
@@ -45,7 +46,7 @@ npm run dev
```
Forwarded services:
- Client: http://localhost:5173
- Client: http://localhost:5174
- API: http://localhost:5000
- Mongo Express: http://localhost:8999
- MongoDB: localhost:27017
@@ -73,6 +74,7 @@ DB_PASSWORD=<password>
DB_HOST=<hostname> //server
DB_PORT=<db port> //27017
PORT=<server port> //5000
CORS_ORIGIN=<allowed client origin> //http://localhost:5174
```
- Run `npm i` to install required node packages
+6 -3
View File
@@ -2,11 +2,14 @@ import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default [
{ ignores: ["dist"] },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["**/*.{js,jsx}"],
files: ["**/*.{js,jsx,ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
@@ -21,9 +24,9 @@ export default [
"react-refresh": reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
"no-unused-vars": ["error", { varsIgnorePattern: "^[A-Z_]" }],
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { varsIgnorePattern: "^[A-Z_]", argsIgnorePattern: "^_" }],
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
-3630
View File
File diff suppressed because it is too large Load Diff
+20 -17
View File
@@ -5,28 +5,31 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "tsc --noEmit && vite build",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.3",
"axios": "^1.8.4",
"color": "^5.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwindcss": "^4.1.3"
"@tailwindcss/vite": "^4.3.1",
"axios": "^1.18.0",
"color": "^5.0.3",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"tailwindcss": "^4.3.1"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.21.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"prettier-plugin-tailwindcss": "^0.6.11",
"vite": "^6.2.0"
"@eslint/js": "^10.0.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"eslint": "^10.5.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.6.0",
"prettier-plugin-tailwindcss": "^0.8.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.61.0",
"vite": "^8.0.16"
}
}
+9 -6
View File
@@ -1,11 +1,14 @@
import axios from "axios";
const config = {
SERVER_IP_ADDRESS: import.meta.env.VITE_API_HOST || "localhost",
SERVER_PORT: import.meta.env.VITE_API_PORT || "5000",
PROTOCOL: import.meta.env.VITE_API_PROTOCOL || "http",
};
const baseApiUrl = `${config.PROTOCOL}://${config.SERVER_IP_ADDRESS}:${config.SERVER_PORT}`;
const legacyApiHost = import.meta.env.VITE_API_HOST;
const legacyApiPort = import.meta.env.VITE_API_PORT;
const legacyApiProtocol = import.meta.env.VITE_API_PROTOCOL || "http";
const baseApiUrl =
import.meta.env.VITE_API_URL ||
(legacyApiHost
? `${legacyApiProtocol}://${legacyApiHost}${legacyApiPort ? `:${legacyApiPort}` : ""}`
: "");
export const api = axios.create({
baseURL: baseApiUrl,
+47 -14
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useReducer } from "react";
import { useEffect, useMemo, useState } from "react";
import { getAllBoxes, Box, BoxCard, BoxFormModal } from "./features/box";
import { Item, moveItem } from "./features/item";
@@ -6,8 +6,6 @@ import { Plus, Search } from "./icons";
const App = () => {
const [boxes, setBoxes] = useState<Box[]>([]);
const [filteredBoxes, setFilteredBoxes] = useState<Box[]>([]);
const [isItemMoving, setIsItemMoving] = useState(false);
const [itemToBeMoved, setItemToBeMoved] = useState<Item>();
@@ -15,18 +13,24 @@ const App = () => {
const handleSelectMoveToBox = async (targetBox: Box) => {
if (!itemToBeMoved?._id || !targetBox._id || !itemToBeMovedSourceBox) {
setIsItemMoving(false);
return;
}
setIsItemMoving(false);
await moveItem(itemToBeMoved?._id!, targetBox._id!);
const response = await moveItem(itemToBeMoved._id, targetBox._id);
if (!response.success) {
console.error(response.error ?? "Could not move item");
setIsItemMoving(false);
return;
}
setBoxes(prev => {
const updatedBoxes = prev.map(box => {
if (box._id === itemToBeMovedSourceBox) {
return {
...box,
items: box.items?.filter(item => item._id !== itemToBeMoved?._id) ?? []
items: box.items?.filter(item => item._id !== itemToBeMoved._id) ?? []
};
}
if (box._id === targetBox._id && itemToBeMoved) {
@@ -40,7 +44,9 @@ const App = () => {
return updatedBoxes;
});
setIsItemMoving(false);
setItemToBeMoved(undefined);
setItemToBeMovedSourceBox(undefined);
}
const handleMoveItem = (item: Item, boxId: string) => {
@@ -52,9 +58,33 @@ const App = () => {
const handleCreateBox = (newBox: Box) => {
setBoxes((boxes) => [...boxes, newBox]);
};
const handleUpdateBox = (updatedBox: Box) => {
setBoxes((boxes) => boxes.map((box) => box._id === updatedBox._id ? updatedBox : box));
};
const handleDeleteBox = (id: string) => {
setBoxes((boxes) => boxes.filter((box) => box._id !== id));
};
const handleCreateItem = (boxId: string, newItem: Item) => {
setBoxes((boxes) => boxes.map((box) => box._id === boxId
? { ...box, items: [...(box.items ?? []), newItem] }
: box
));
};
const handleUpdateItem = (boxId: string, updatedItem: Item) => {
setBoxes((boxes) => boxes.map((box) => box._id === boxId
? {
...box,
items: box.items?.map((item) => item._id === updatedItem._id ? updatedItem : item) ?? [],
}
: box
));
};
const handleDeleteItem = (boxId: string, itemId: string) => {
setBoxes((boxes) => boxes.map((box) => box._id === boxId
? { ...box, items: box.items?.filter((item) => item._id !== itemId) ?? [] }
: box
));
};
const [isBoxFormModalOpen, setBoxFormModalOpen] = useState(false);
const handleBoxFormModalOpen = () => {
setBoxFormModalOpen(true);
@@ -70,9 +100,9 @@ const App = () => {
};
useEffect(() => {
const filteredBoxes = useMemo(() => {
const ss = searchString.toLowerCase();
const result = boxes.filter((box) => {
return boxes.filter((box) => {
const matchingItems = box.items?.filter(
(item) =>
@@ -86,7 +116,6 @@ const App = () => {
(matchingItems && matchingItems.length > 0)
);
})
setFilteredBoxes(result);
}, [boxes, searchString]);
useEffect(() => {
@@ -106,12 +135,12 @@ const App = () => {
return (
<>
<BoxFormModal
{isBoxFormModalOpen && <BoxFormModal
mode="create"
isOpen={isBoxFormModalOpen}
onSubmitBox={handleCreateBox}
onClose={handleBoxFormModalClose}
/>
/>}
<div className='page-content'>
<div className="utility-container">
@@ -142,7 +171,11 @@ const App = () => {
<BoxCard
key={box._id}
initialBox={box}
onUpdateBox={handleUpdateBox}
onDeleteBox={handleDeleteBox}
onCreateItem={handleCreateItem}
onUpdateItem={handleUpdateItem}
onDeleteItem={handleDeleteItem}
onMoveItem={handleMoveItem}
onClickMoveToBox={handleSelectMoveToBox}
isItemMoving={box._id !== itemToBeMovedSourceBox && isItemMoving}
-26
View File
@@ -1,26 +0,0 @@
import React, { useState } from "react";
const ColorSelect = ({ colorData, handler, boxColor }) => {
const [style, setStyle] = useState(colorData.defaultStyle);
const handleHover = (isHover) => {
setStyle(isHover ? colorData.lightStyle : colorData.defaultStyle);
};
return (
<label
onMouseEnter={() => handleHover(true)}
onMouseLeave={() => handleHover(false)}
style={style}
>
<input
checked={boxColor === colorData.title}
onChange={handler}
type="radio"
value={colorData.title}
></input>
</label>
);
};
export default ColorSelect;
+5 -1
View File
@@ -29,6 +29,10 @@ export const GenericMenu = ({
const menuRef = useRef<HTMLDivElement>(null);
const menuButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!isOpen) {
return;
}
const handleClickOutside = (event: MouseEvent) => {
if (
menuRef.current &&
@@ -44,7 +48,7 @@ export const GenericMenu = ({
return () => {
document.removeEventListener("click", handleClickOutside);
};
}, []);
}, [isOpen, onToggle]);
return (
<div className="menu">
+4
View File
@@ -5,4 +5,8 @@ export interface Box {
name: string;
description: string;
items?: Item[];
sort?: number;
color?: string;
createdAt?: string;
updatedAt?: string;
}
+42 -35
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from "react";
import { useState } from "react";
import { Box, BoxFormModal, deleteBox } from "../";
import { Item, ItemCard, ItemFormModal } from "../../item";
import { Plus, Ellipsis, Garbage, Pencil } from "../../../icons";
@@ -6,24 +6,33 @@ import { GenericMenu } from '../../../components/GenericMenu';
interface BoxCardProps {
initialBox: Box;
onUpdateBox: (box: Box) => void;
onDeleteBox: (id: string) => void;
onCreateItem: (boxId: string, item: Item) => void;
onUpdateItem: (boxId: string, item: Item) => void;
onDeleteItem: (boxId: string, itemId: string) => void;
onMoveItem: (item: Item, boxId: string) => void;
isItemMoving: boolean;
onClickMoveToBox: (box: Box) => void;
}
export const BoxCard: React.FC<BoxCardProps> = ({ initialBox, onDeleteBox, onMoveItem, onClickMoveToBox, isItemMoving }) => {
const [box, setBox] = useState<Box>(initialBox);
export const BoxCard: React.FC<BoxCardProps> = ({
initialBox: box,
onUpdateBox,
onDeleteBox,
onCreateItem,
onUpdateItem,
onDeleteItem,
onMoveItem,
onClickMoveToBox,
isItemMoving,
}) => {
const boxId = box._id;
const [isBoxFormModalOpen, setBoxFormModalOpen] = useState(false);
const [isItemFormModalOpen, setItemFormModalOpen] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
useEffect(() => {
setBox(initialBox);
}, [initialBox]);
const toggleMenu = () => {
setIsMenuOpen((prevState) => !prevState);
};
@@ -35,26 +44,21 @@ export const BoxCard: React.FC<BoxCardProps> = ({ initialBox, onDeleteBox, onMov
setItemFormModalOpen(false);
};
const handleOpenBoxFormModal = () => {
setBoxFormModalOpen(true);
};
const handleCloseBoxFormModal = () => {
setBoxFormModalOpen(false);
};
const handleCreateItem = (newItem: Item) => {
const updatedBox: Box = {
...box,
items: [...(box.items ?? []), newItem],
};
setBox(updatedBox);
if (boxId) {
onCreateItem(boxId, newItem);
}
};
const handleClickEditBox = () => {
setBoxFormModalOpen(true);
};
const handleUpdateBox = (updatedBox: Box) => {
setBox(updatedBox);
onUpdateBox(updatedBox);
};
const handleDeleteBox = async () => {
if (box._id != null) {
@@ -66,15 +70,16 @@ export const BoxCard: React.FC<BoxCardProps> = ({ initialBox, onDeleteBox, onMov
}
}
};
const handleUpdateItem = (updatedItem: Item) => {
if (boxId) {
onUpdateItem(boxId, updatedItem);
}
};
const handleDeleteItem = (id: string) => {
const updatedItems = (box.items ?? []).filter(item => item._id !== id);
const updatedBox: Box = {
...box,
items: updatedItems,
};
setBox(updatedBox);
if (boxId) {
onDeleteItem(boxId, id);
}
};
const menuItems = [
{
@@ -93,20 +98,20 @@ export const BoxCard: React.FC<BoxCardProps> = ({ initialBox, onDeleteBox, onMov
return (
<>
<BoxFormModal
{isBoxFormModalOpen && <BoxFormModal
mode="edit"
initialData={box}
isOpen={isBoxFormModalOpen}
onSubmitBox={handleUpdateBox}
onClose={handleCloseBoxFormModal}
/>
<ItemFormModal
/>}
{isItemFormModalOpen && boxId && <ItemFormModal
mode="create"
isOpen={isItemFormModalOpen}
onSubmitItem={handleCreateItem}
onClose={handleCloseItemFormModal}
boxId={box._id!}
/>
boxId={boxId}
/>}
<div className={`box-card-container relative ${isItemMoving ? 'target' : ''}`} >
{isItemMoving && <div onClick={() => onClickMoveToBox(box)} className="overlay"></div>}
<div className={`box-card`}>
@@ -126,10 +131,11 @@ export const BoxCard: React.FC<BoxCardProps> = ({ initialBox, onDeleteBox, onMov
<div className="item-card-list-container scrollbar-minimal">
<ul className="item-card-list">
{box.items?.map((item) => (
<ItemCard
boxId && <ItemCard
key={item._id}
boxId={box._id!}
boxId={boxId}
initialItem={item}
onUpdateItem={handleUpdateItem}
onDeleteItem={handleDeleteItem}
onMoveItem={onMoveItem}
/>
@@ -137,10 +143,11 @@ export const BoxCard: React.FC<BoxCardProps> = ({ initialBox, onDeleteBox, onMov
</ul>
</div>
<div className="actions">
<button
className="group button-icon"
onClick={handleOpenItemFormModal}
>
<button
className="group button-icon"
onClick={handleOpenItemFormModal}
disabled={!boxId}
>
<Plus height="24" width="24" />
</button>
</div>
@@ -1,6 +1,7 @@
import { useState } from "react";
import { Modal } from '../../../components/Modal';
import { Box, createBox, updateBox } from "..";
import { ApiResponse } from "../../../api";
interface BoxFormModalProps {
mode: string;
@@ -31,9 +32,18 @@ export const BoxFormModal: React.FC<BoxFormModalProps> = ({
const isEdit = mode === "edit";
try {
const response = isEdit
? await updateBox(initialData?._id!, boxData)
: await createBox(boxData);
let response: ApiResponse<Box>;
if (isEdit) {
const boxId = initialData?._id;
if (!boxId) {
console.error("Cannot update box without an ID");
return;
}
response = await updateBox(boxId, boxData);
} else {
response = await createBox(boxData);
}
if (!response.success || !response.data) {
console.error(
@@ -44,10 +54,9 @@ export const BoxFormModal: React.FC<BoxFormModalProps> = ({
}
onSubmitBox(response.data);
onClose();
} catch (err) {
console.error(`Error during ${isEdit ? "update" : "create"}:`, err);
} finally {
onClose();
}
};
@@ -55,7 +64,7 @@ export const BoxFormModal: React.FC<BoxFormModalProps> = ({
<Modal isOpen={isOpen}>
<form onSubmit={handleSubmit} id="form">
<input value="" id="id" name="id" readOnly={true} hidden={true} />
<h1 className="modal-header">Edit Box</h1>
<h1 className="modal-header">{mode === "edit" ? "Edit" : "Create"} Box</h1>
<fieldset>
<label className="input-label" htmlFor="box-name">
name
@@ -47,7 +47,7 @@ const updateBox = async (
};
const deleteBox = async (id: string): Promise<ApiResponse<null>> => {
try {
const response = await api.delete<null>(`boxes/${id}`);
await api.delete<null>(`/boxes/${id}`);
return {
success: true,
};
@@ -6,18 +6,19 @@ import { GenericMenu } from "../../../components/GenericMenu";
interface ItemCardProps {
boxId: string;
initialItem: Item;
onUpdateItem: (item: Item) => void;
onDeleteItem: (id: string) => void;
onMoveItem: (item: Item, boxId: string) => void;
}
export const ItemCard: React.FC<ItemCardProps> = ({
boxId,
initialItem,
initialItem: item,
onUpdateItem,
onDeleteItem,
onMoveItem
}) => {
const [isItemFormModalOpen, setIsItemFormModalOpen] = useState(false);
const [item, setItem] = useState(initialItem);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [formModalMode, setFormModalMode] = useState("edit");
@@ -38,7 +39,7 @@ export const ItemCard: React.FC<ItemCardProps> = ({
}
const handleUpdateItem = (updatedItem: Item) => {
setItem(updatedItem);
onUpdateItem(updatedItem);
};
const handleDeleteItem = async () => {
@@ -62,14 +63,14 @@ export const ItemCard: React.FC<ItemCardProps> = ({
];
return (
<>
<ItemFormModal
{isItemFormModalOpen && <ItemFormModal
mode={formModalMode}
isOpen={isItemFormModalOpen}
onSubmitItem={handleUpdateItem}
onClose={handleCloseItemFormModal}
boxId={boxId}
initialData={item}
/>
/>}
<li>
<div className="item-container">
<div className='item' onClick={handleClickViewItem}>
@@ -1,6 +1,7 @@
import { useState } from "react";
import { Modal } from '../../../components/Modal';
import { Item, createItem, updateItem } from "..";
import { ApiResponse } from "../../../api";
interface ItemFormModalProps {
mode: string;
@@ -33,18 +34,30 @@ export const ItemFormModal: React.FC<ItemFormModalProps> = ({
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (mode === "readonly") {
return;
}
const itemData: Item = {
name: itemName as string,
description: itemDescription as string,
quantity: itemQuantity as number,
name: itemName,
description: itemDescription,
quantity: Number.isFinite(itemQuantity) ? itemQuantity : 0,
};
const isEdit = mode === 'edit';
try {
const response = isEdit
? await updateItem(boxId!, initialData?._id!, itemData)
: await createItem(itemData, boxId);
let response: ApiResponse<Item>;
if (isEdit) {
const itemId = initialData?._id;
if (!itemId) {
console.error("Cannot update item without an ID");
return;
}
response = await updateItem(boxId, itemId, itemData);
} else {
response = await createItem(itemData, boxId);
}
if (!response.success || !response.data) {
console.error(
@@ -55,10 +68,9 @@ export const ItemFormModal: React.FC<ItemFormModalProps> = ({
}
onSubmitItem(response.data);
onClose();
} catch (err) {
console.error(`Error during ${isEdit ? "update" : "create"}:`, err);
} finally {
onClose();
}
};
@@ -102,7 +114,7 @@ export const ItemFormModal: React.FC<ItemFormModalProps> = ({
className="input"
value={itemQuantity}
onChange={(e) =>
setItemQuantity(parseInt(e.currentTarget.value, 10))
setItemQuantity(e.currentTarget.valueAsNumber)
}
id="item-quantity"
name="quantity"
+2
View File
@@ -3,4 +3,6 @@ export interface Item {
name: string;
description: string;
quantity: number;
createdAt?: string;
updatedAt?: string;
}
@@ -46,7 +46,7 @@ const deleteItem = async (
itemId: string,
): Promise<ApiResponse<null>> => {
try {
const response = await api.delete<null>(`boxes/${boxId}/items/${itemId}`);
await api.delete<null>(`/boxes/${boxId}/items/${itemId}`);
return {
success: true,
};
@@ -63,7 +63,7 @@ const deleteItem = async (
const moveItem = async (itemId: string, boxId: string): Promise<ApiResponse<null>> => {
try {
const response = await api.patch<null>(`items/${itemId}`, { boxId });
await api.patch<null>(`/items/${itemId}`, { boxId });
return {
success: true,
};
+1 -1
View File
@@ -3,7 +3,7 @@
"jsx": "react-jsx",
"module": "esnext",
"target": "esnext",
"moduleResolution": "node",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true
+1
View File
@@ -1,6 +1,7 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
VITE_API_URL: string;
VITE_API_HOST: string;
VITE_API_PORT: string;
VITE_API_PROTOCOL: string;
+1 -1
View File
@@ -3,7 +3,7 @@
"jsx": "react-jsx",
"module": "esnext",
"target": "esnext",
"moduleResolution": "node",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true
+9
View File
@@ -5,4 +5,13 @@ import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
host: "0.0.0.0",
port: 5174,
strictPort: true,
proxy: {
"/boxes": "http://localhost:5000",
"/items": "http://localhost:5000",
},
},
});
+4876 -1
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "storage-manager",
"private": true,
"scripts": {
"dev": "concurrently -n server,client -c blue,green \"npm run dev --workspace server\" \"npm run dev --workspace client\"",
"build": "npm run build --workspace client",
"lint": "npm run lint --workspace client",
"typecheck": "npm run typecheck --workspace client",
"start": "npm run start --workspace server"
},
"workspaces": [
"client",
"server"
],
"devDependencies": {
"concurrently": "^10.0.3"
}
}
-1380
View File
File diff suppressed because it is too large Load Diff
+8 -7
View File
@@ -4,17 +4,18 @@
"description": "",
"main": "server.js",
"scripts": {
"dev": "nodemon server.js",
"start": "node server.js",
"test": "echo \"Error: no test specified\" && exit 1",
"start": "nodemon --inspect server.js"
"start:inspect": "nodemon --inspect server.js"
},
"author": "",
"license": "ISC",
"dependencies": {
"body-parser": "^2.2.0",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^5.1.0",
"mongoose": "^8.13.2",
"nodemon": "^3.1.9"
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"mongoose": "^9.7.0",
"nodemon": "^3.1.14"
}
}
+201 -151
View File
@@ -1,216 +1,275 @@
require('dotenv').config();
require('dotenv').config({ quiet: true });
const express = require('express');
const mongoose = require('mongoose');
const app = express();
const cors = require('cors');
app.use(cors(), express.json());
const corsOrigins = (process.env.CORS_ORIGIN || 'http://localhost:5174')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
const isAllowedCorsOrigin = (origin) => {
if (!origin) {
return true;
}
if (corsOrigins.includes(origin)) {
return true;
}
try {
const { hostname } = new URL(origin);
return hostname === 'localhost' || hostname === '127.0.0.1';
} catch {
return false;
}
};
app.use(cors({
origin: (origin, callback) => {
callback(null, isAllowedCorsOrigin(origin));
},
}), express.json());
const port = process.env.PORT || 5000;
const { ObjectId } = mongoose.Types;
const DB_HOST = process.env.DB_HOST || 'server';
const DB_HOST = process.env.DB_HOST || 'localhost';
const DB_PORT = process.env.DB_PORT || 27017;
const DB_USERNAME = process.env.DB_USERNAME;
const DB_PASSWORD = process.env.DB_PASSWORD;
const DB_NAME = process.env.DB_NAME;
const connectionString = `mongodb://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}?authMechanism=DEFAULT`;
mongoose.connect(connectionString);
const credentials =
DB_USERNAME && DB_PASSWORD
? `${encodeURIComponent(DB_USERNAME)}:${encodeURIComponent(DB_PASSWORD)}@`
: '';
const database = DB_NAME ? `/${DB_NAME}` : '';
const authSource = credentials && DB_NAME ? '?authSource=admin' : '';
const connectionString = `mongodb://${credentials}${DB_HOST}:${DB_PORT}${database}${authSource}`;
mongoose.connect(connectionString).catch((error) => {
console.error('Unable to connect to MongoDB.');
console.error(`Check DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, and DB_NAME. Current host: ${DB_HOST}:${DB_PORT}`);
console.error(error);
process.exit(1);
});
const db = mongoose.connection;
db.on('error', console.error.bind(console, 'connection error:'));
db.once('open', function () {
console.log(`connected to mongodb: ${DB_HOST}`);
});
const asyncHandler = (handler) => async (request, response, next) => {
try {
await handler(request, response, next);
} catch (error) {
next(error);
}
};
const isValidObjectId = (id) => ObjectId.isValid(id);
const removeUndefinedValues = (payload) => Object.fromEntries(
Object.entries(payload).filter(([, value]) => value !== undefined)
);
const getBoxPayload = (body) => removeUndefinedValues({
name: body.name,
description: body.description,
color: body.color,
});
const getItemPayload = (body) => removeUndefinedValues({
name: body.name,
description: body.description,
quantity: body.quantity,
});
const validateId = (id, response, label = 'ID') => {
if (isValidObjectId(id)) {
return true;
}
response.status(400).json({ error: `Invalid ${label}` });
return false;
};
/*------------MODELS------------*/
//create schema
var itemSchema = new mongoose.Schema({
name: String,
description: String,
quantity: Number
});
const itemSchema = new mongoose.Schema({
name: { type: String, required: true, trim: true },
description: { type: String, default: '', trim: true },
quantity: { type: Number, required: true, min: 0, default: 1 },
}, { timestamps: true });
var boxSchema = new mongoose.Schema({
name: String,
description: String,
const boxSchema = new mongoose.Schema({
name: { type: String, required: true, trim: true },
description: { type: String, default: '', trim: true },
items: [itemSchema],
sort: Number,
color: String,
});
sort: { type: Number, required: true, min: 0 },
color: { type: String, default: '#ffffff', trim: true },
}, { timestamps: true });
//compile schema into model
var Box = mongoose.model('box', boxSchema, 'box');
var Item = mongoose.model('item', itemSchema, 'item');
const Box = mongoose.model('box', boxSchema, 'box');
/*------------/MODELS------------*/
/*------------CONTROLLER------------*/
app.listen(port, () => console.log(`Listening on port ${port}`));
// get all boxes
app.get('/boxes', async (request, response) => {
app.get('/boxes', asyncHandler(async (request, response) => {
const boxes = await getAllBoxes();
response.send(boxes);
});
}));
// create box
app.post('/boxes', async (request, response) => {
try {
const numBoxes = await Box.countDocuments();
const newBox = await Box.create({
...request.body,
sort: numBoxes,
});
app.post('/boxes', asyncHandler(async (request, response) => {
const lastBox = await Box.findOne().sort({ sort: -1 }).select('sort');
const newBox = await Box.create({
...getBoxPayload(request.body),
sort: lastBox ? lastBox.sort + 1 : 0,
});
response.status(201).json(newBox);
}
catch (error) {
console.error('Error creating box:', error);
response.status(500).json({ error: 'Failed to create box' });
}
});
response.status(201).json(newBox);
}));
// update box
app.put('/boxes/:boxId', async (request, response) => {
try {
const { boxId } = request.params;
const updatedBox = await Box.findByIdAndUpdate(
boxId,
{ ...request.body },
{ new: true, runValidators: true }
);
if (!updatedBox) {
return response.status(404).json({ error: 'Box not found' });
}
response.status(200).json(updatedBox);
app.put('/boxes/:boxId', asyncHandler(async (request, response) => {
const { boxId } = request.params;
if (!validateId(boxId, response, 'box ID')) {
return;
}
catch (error) {
console.error('Error updating box:', error);
response.status(500).json({ error: 'Failed to update box' });
const updatedBox = await Box.findByIdAndUpdate(
boxId,
getBoxPayload(request.body),
{ returnDocument: 'after', runValidators: true }
);
if (!updatedBox) {
return response.status(404).json({ error: 'Box not found' });
}
});
response.status(200).json(updatedBox);
}));
// delete box
app.delete('/boxes/:boxId', async (request, response) => {
try {
const { boxId } = request.params;
const result = await Box.findByIdAndDelete(boxId);
if (!result) {
return response.status(404).json({ success: false, message: 'Box not found' });
}
response.status(200).json({ success: true, message: 'Box deleted', data: result });
app.delete('/boxes/:boxId', asyncHandler(async (request, response) => {
const { boxId } = request.params;
if (!validateId(boxId, response, 'box ID')) {
return;
}
catch (error) {
console.error('Error deleting box:', error);
response.status(500).json({ success: false, error: 'Failed to delete box' });
const result = await Box.findByIdAndDelete(boxId);
if (!result) {
return response.status(404).json({ success: false, message: 'Box not found' });
}
});
response.status(200).json({ success: true, message: 'Box deleted', data: result });
}));
// create item
app.post('/boxes/:boxId/items', async (request, response) => {
try {
const item = new Item(request.body);
const savedItem = await item.save();
const box = await Box.findById(request.params.boxId);
if (!box) {
throw new Error('Box not found');
}
box.items.push(savedItem);
await box.save();
response.send(item);
} catch (error) {
console.error('Error adding item to Box:', error);
throw error;
app.post('/boxes/:boxId/items', asyncHandler(async (request, response) => {
const { boxId } = request.params;
if (!validateId(boxId, response, 'box ID')) {
return;
}
});
const box = await Box.findById(boxId);
if (!box) {
return response.status(404).json({ error: 'Box not found' });
}
const item = box.items.create(getItemPayload(request.body));
box.items.push(item);
await box.save();
response.status(201).json(item);
}));
// edit item
app.put('/boxes/:boxId/items/:itemId', async (request, response) => {
app.put('/boxes/:boxId/items/:itemId', asyncHandler(async (request, response) => {
const { boxId, itemId } = request.params;
const { name, description, quantity } = request.body;
try {
const updatedBox = await Box.findOneAndUpdate(
{ _id: boxId, 'items._id': itemId },
{
$set: {
'items.$.name': name,
'items.$.description': description,
'items.$.quantity': quantity,
},
},
{ new: true }
);
const updatedItem = updatedBox.items.find(item => item._id.toString() === itemId);
if (!updatedItem) {
return response.status(404).json({ error: 'Box or item not found' });
}
response.status(200).json(updatedItem);
if (!validateId(boxId, response, 'box ID') || !validateId(itemId, response, 'item ID')) {
return;
}
catch (error) {
console.error('Error updating item:', error);
response.status(500).json({ error: 'Failed to update item' });
const box = await Box.findById(boxId);
if (!box) {
return response.status(404).json({ error: 'Box not found' });
}
});
const item = box.items.id(itemId);
if (!item) {
return response.status(404).json({ error: 'Item not found' });
}
item.set(getItemPayload(request.body));
await box.save();
response.status(200).json(item);
}));
// delete item from box
app.delete('/boxes/:boxId/items/:itemId', async (request, response) => {
app.delete('/boxes/:boxId/items/:itemId', asyncHandler(async (request, response) => {
const { boxId, itemId } = request.params;
try {
const updatedBox = await Box.findByIdAndUpdate(
boxId,
{
$pull: { items: { _id: itemId } }
},
{ new: true }
);
if (!updatedBox) {
return response.status(404).json({ error: 'Box not found' });
}
response.json(updatedBox);
if (!validateId(boxId, response, 'box ID') || !validateId(itemId, response, 'item ID')) {
return;
}
catch (error) {
console.error('Error deleting item:', error);
response.status(500).json({ error: 'Failed to delete item' });
const box = await Box.findById(boxId);
if (!box) {
return response.status(404).json({ error: 'Box not found' });
}
});
const item = box.items.id(itemId);
if (!item) {
return response.status(404).json({ error: 'Item not found' });
}
item.deleteOne();
await box.save();
response.json(box);
}));
//move item
app.patch('/items/:itemId', async (req, res) => {
app.patch('/items/:itemId', asyncHandler(async (req, res) => {
const { itemId } = req.params;
const { boxId: targetBoxId } = req.body;
if (!targetBoxId) return res.status(400).json({ error: 'Target boxId is required' });
if (!validateId(itemId, res, 'item ID') || !validateId(targetBoxId, res, 'target box ID')) {
return;
}
const item = await Item.findById(itemId);
if (!item) return res.status(404).json({ error: 'Item not found' });
console.log(item);
const sourceBox = await Box.findOne({ "items._id": item._id });
const sourceBox = await Box.findOne({ "items._id": itemId });
const targetBox = await Box.findById(targetBoxId);
if (!targetBox) return res.status(404).json({ error: 'Target box not found' });
if (!sourceBox) return res.status(404).json({ error: 'Source box not found' });
if (sourceBox._id.equals(targetBox._id)) return res.status(204).send();
const item = sourceBox.items.id(itemId);
if (!item) return res.status(404).json({ error: 'Item not found' });
const itemToMove = item.toObject();
await Box.updateOne(
{ _id: sourceBox._id },
{ $pull: { items: item } }
);
await Box.updateOne(
{ _id: targetBoxId },
{ $push: { items: item } }
);
item.deleteOne();
targetBox.items.push(itemToMove);
await sourceBox.save();
await targetBox.save();
res.status(204).send();
}));
app.use((error, request, response, next) => {
console.error(error);
if (error.name === 'ValidationError') {
return response.status(400).json({ error: error.message });
}
response.status(500).json({ error: 'Internal server error' });
});
/*--------/CONTROLLER------------*/
@@ -220,12 +279,3 @@ const getAllBoxes = async (queryParameters) => {
const data = await Box.find(queryParameters).sort({ sort: 1 });
return data;
}
const getBox = async (id) => {
const data = await Box.findById(id);
return data;
}
const getItem = async (id) => {
const data = await Item.findById(id);
return data;
}