diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b0c564dc..af3391f5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "bootstrap": "^5.3.6", "bootstrap-icons": "^1.10.5", "react": "^19.1.0", + "react-bootstrap": "^2.10.10", "react-dom": "^19.1.0", "react-router-dom": "^7.6.2", "react-scripts": "5.0.1", @@ -3095,12 +3096,80 @@ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" } }, + "node_modules/@react-aria/ssr": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.9.tgz", + "integrity": "sha512-2P5thfjfPy/np18e5wD4WPt8ydNXhij1jwA8oehxZTFqlgVMGXzcWKxTb4RtJrLFsqPO7RUQTiY8QJk0M4Vy2g==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@restart/hooks": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", + "integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@restart/ui": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.9.4.tgz", + "integrity": "sha512-N4C7haUc3vn4LTwVUPlkJN8Ach/+yIMvRuTVIhjilNHqegY60SGLrzud6errOMNJwSnmYFnt1J0H/k8FE3A4KA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@popperjs/core": "^2.11.8", + "@react-aria/ssr": "^3.5.0", + "@restart/hooks": "^0.5.0", + "@types/warning": "^3.0.3", + "dequal": "^2.0.3", + "dom-helpers": "^5.2.0", + "uncontrollable": "^8.0.4", + "warning": "^4.0.3" + }, + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + } + }, + "node_modules/@restart/ui/node_modules/@restart/hooks": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.5.1.tgz", + "integrity": "sha512-EMoH04NHS1pbn07iLTjIjgttuqb7qu4+/EyhAx27MHpoENcB2ZdSsLTNxmKD+WEPnZigo62Qc8zjGnNxoSE/5Q==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@restart/ui/node_modules/uncontrollable": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-8.0.4.tgz", + "integrity": "sha512-ulRWYWHvscPFc0QQXvyJjY6LIXU56f0h8pQFvhxiKk5V1fcI8gp9Ht9leVAhrVjzqMw0BgjspBINx9r6oyJUvQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.14.0" + } + }, "node_modules/@rollup/plugin-babel": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", @@ -3449,6 +3518,15 @@ "url": "https://github.com/sponsors/gregberge" } }, + "node_modules/@swc/helpers": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", + "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", @@ -3826,6 +3904,12 @@ "integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==", "license": "MIT" }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, "node_modules/@types/q": { "version": "1.5.8", "resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.8.tgz", @@ -3844,6 +3928,24 @@ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "license": "MIT" }, + "node_modules/@types/react": { + "version": "19.1.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", + "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/resolve": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", @@ -3916,6 +4018,12 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, + "node_modules/@types/warning": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", + "integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -5651,6 +5759,12 @@ "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", "license": "MIT" }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/clean-css": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", @@ -6413,6 +6527,12 @@ "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", "license": "MIT" }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -6739,6 +6859,16 @@ "utila": "~0.4" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dom-serializer": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", @@ -9320,6 +9450,15 @@ "node": ">= 0.4" } }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/ipaddr.js": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", @@ -13664,6 +13803,25 @@ "react-is": "^16.13.1" } }, + "node_modules/prop-types-extra": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", + "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==", + "license": "MIT", + "dependencies": { + "react-is": "^16.3.2", + "warning": "^4.0.0" + }, + "peerDependencies": { + "react": ">=0.14.0" + } + }, + "node_modules/prop-types-extra/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/prop-types/node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -13845,6 +14003,37 @@ "node": ">=14" } }, + "node_modules/react-bootstrap": { + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.10.tgz", + "integrity": "sha512-gMckKUqn8aK/vCnfwoBpBVFUGT9SVQxwsYrp9yDHt0arXMamxALerliKBxr1TPbntirK/HGrUAHYbAeQTa9GHQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@restart/hooks": "^0.4.9", + "@restart/ui": "^1.9.4", + "@types/prop-types": "^15.7.12", + "@types/react-transition-group": "^4.4.6", + "classnames": "^2.3.2", + "dom-helpers": "^5.2.1", + "invariant": "^2.2.4", + "prop-types": "^15.8.1", + "prop-types-extra": "^1.1.0", + "react-transition-group": "^4.4.5", + "uncontrollable": "^7.2.1", + "warning": "^4.0.3" + }, + "peerDependencies": { + "@types/react": ">=16.14.8", + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-dev-utils": { "version": "12.0.1", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz", @@ -13974,6 +14163,12 @@ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "license": "MIT" }, + "node_modules/react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", @@ -14103,6 +14298,22 @@ } } }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -16494,6 +16705,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/uncontrollable": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", + "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.6.3", + "@types/react": ">=16.9.11", + "invariant": "^2.2.4", + "react-lifecycles-compat": "^3.0.4" + }, + "peerDependencies": { + "react": ">=15.0.0" + } + }, "node_modules/underscore": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz", @@ -16746,6 +16972,15 @@ "makeerror": "1.0.12" } }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/watchpack": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5a07a82e..18b90365 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "bootstrap": "^5.3.6", "bootstrap-icons": "^1.10.5", "react": "^19.1.0", + "react-bootstrap": "^2.10.10", "react-dom": "^19.1.0", "react-router-dom": "^7.6.2", "react-scripts": "5.0.1", diff --git a/frontend/src/api/labelsApi.js b/frontend/src/api/labelsApi.js index c38528a8..25cc2251 100644 --- a/frontend/src/api/labelsApi.js +++ b/frontend/src/api/labelsApi.js @@ -87,4 +87,4 @@ export async function createLabelUnderParent(parentId, name) { }); if (!res.ok) throw new Error(`createLabelUnderParent failed: ${res.status}`); return res.json(); -} +} \ No newline at end of file diff --git a/frontend/src/api/mailApi.js b/frontend/src/api/mailApi.js index 81c88a6e..0aaffca6 100644 --- a/frontend/src/api/mailApi.js +++ b/frontend/src/api/mailApi.js @@ -245,7 +245,8 @@ export async function updateMail(mailId, { subject, body, sentTo, - saveAsDraft = true + saveAsDraft = true, + files = [] }) { const url = `${API_BASE}/mails/${mailId}`; const token = localStorage.getItem("token"); @@ -256,7 +257,7 @@ export async function updateMail(mailId, { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" }, - body: JSON.stringify({subject, body, sentTo, saveAsDraft}) + body: JSON.stringify({subject, body, sentTo, saveAsDraft, files}) }); if (!res.ok) throw new Error(`updateMail failed: ${res.status}`); @@ -285,3 +286,29 @@ export async function getMailsByLabel(labelId, page = 1) { } return res.json(); } + +/** + * Edits a mail with the new labels picked for it + * @param {number} mailId id of the mail to edit its labels + * @param {any[]} labels list of label object + * @returns {Promise} + */ +export async function applyLabelsToMail(mailId, labels) { + console.log(labels) + const token = localStorage.getItem("token"); + const url = `${API_BASE}/mails/${mailId}`; + + const res = await fetch(url, { + method: "PATCH", + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json" + + }, + body: JSON.stringify({labels}) + }) + if (!res.ok) { + throw new Error(`getMailsByLabel failed: ${res.status}`); + } + return res.json(); +} \ No newline at end of file diff --git a/frontend/src/components/StarButton/StarButton.css b/frontend/src/components/StarButton/StarButton.css index 9ab189e6..a327a03f 100644 --- a/frontend/src/components/StarButton/StarButton.css +++ b/frontend/src/components/StarButton/StarButton.css @@ -1,6 +1,6 @@ /* star button */ .star-icon { - display: flex; + display: inline-flex; align-items: center; justify-content: center; background: none; @@ -8,8 +8,9 @@ border-radius: 4px; border: none; cursor: pointer; - width: 16px; - height: 16px; + width: auto; + height: auto; + line-height: 1; color: #888888; transition: all 0.2s; } @@ -20,6 +21,10 @@ box-shadow: 0 4px 12px rgba(240, 240, 240, 0.15); } +.star-icon.dark { + color: white; +} + .star-icon.starred { color: #ffc107; fill: #ffc107; diff --git a/frontend/src/main_page/ComposeEmail/ComposeEmail.css b/frontend/src/main_page/ComposeEmail/ComposeEmail.css index 539a43dc..a86274f0 100644 --- a/frontend/src/main_page/ComposeEmail/ComposeEmail.css +++ b/frontend/src/main_page/ComposeEmail/ComposeEmail.css @@ -1,3 +1,4 @@ +/* ── Base drawer with transitions & overflow hidden ── */ .compose-email { position: fixed; bottom: 2vh; @@ -8,20 +9,31 @@ background: #fff; border: 1px solid #dadce0; border-radius: 8px 8px 0 0; - box-shadow: 0 1px 2px rgba(60,64,67,0.15), - 0 2px 6px rgba(60,64,67,0.15); + box-shadow: + 0 1px 2px rgba(60,64,67,0.15), + 0 2px 6px rgba(60,64,67,0.15); display: flex; flex-direction: column; font-family: 'Roboto', sans-serif; z-index: 1000; - color: #202124; /* default text color */ + color: #202124; + transition: all 0.2s ease; + overflow: hidden; } +/* ── Minimized: only header shows ── */ +.compose-email.minimized { + height: auto; /* shrink to header bar */ + width: 250px; /* Gmail‑style small width */ + max-width: 100%; + bottom: 0; +} .compose-email.minimized .compose-body, .compose-email.minimized .compose-footer { display: none; } +/* ── Maximized: inset overlay with gutters ── */ .compose-email.maximized { top: 10vh !important; bottom: 10vh !important; @@ -29,10 +41,37 @@ right: 10vw !important; width: auto !important; height: auto !important; - max-width: none !important; - max-height: none !important; + max-width: 80vw !important; + max-height: 80vh !important; border-radius: 8px !important; -} + overflow: hidden; + + /* inherit whatever the root .compose-email is set to */ + background: inherit !important; + border-color: inherit !important; +} + +/* ── Responsive auto‑minimize under 600px ── */ +@media (max-width: 600px) { + .compose-email { + left: 0 !important; + right: 0 !important; + width: 100% !important; + max-width: none; + } + /* collapse unless user manually maximized */ + .compose-email:not(.maximized) { + height: auto; + bottom: 0; + } + /* if maximized, fill screen */ + .compose-email.maximized { + height: 100% !important; + width: 100% !important; + } +} + +/* --- existing styles below this line --- */ .compose-header { background: #f1f3f4; @@ -49,11 +88,10 @@ color: #202124; } -/* --- Only this part was added for dark mode title --- */ +/* Dark mode title override */ .compose-email.dark .compose-title { color: #f1f3f4 !important; } -/* ---------------------------------------- */ .compose-controls .control-btn { background: transparent; @@ -193,7 +231,6 @@ border-bottom-color: #1a73e8; } - .attachment-item { display: flex; align-items: center; @@ -232,18 +269,6 @@ color: #d93025; } -/* Optional: dark mode tweaks */ -.compose-email.dark .attachment-item { - color: #e8eaed; -} -.compose-email.dark .remove-btn { - color: #9aa0a6; -} -.compose-email.dark .remove-btn:hover { - color: #f28b82; -} - - .compose-footer { padding: 0.5em 1em; display: flex; diff --git a/frontend/src/main_page/ComposeEmail/ComposeEmail.jsx b/frontend/src/main_page/ComposeEmail/ComposeEmail.jsx index 3580456e..608295d1 100644 --- a/frontend/src/main_page/ComposeEmail/ComposeEmail.jsx +++ b/frontend/src/main_page/ComposeEmail/ComposeEmail.jsx @@ -56,7 +56,7 @@ export default function ComposeEmail({ editorRef.current.innerHTML = initial; } - setAttachments(draftMail.attachments || []); + setAttachments(draftMail.files || []); setToQuery(''); setSuggestions([]); }, [draftMail]); @@ -73,6 +73,14 @@ export default function ComposeEmail({ }, 300); return () => clearTimeout(timer); }, [toQuery]); + // ── auto‑minimize on narrow viewports ── + useEffect(() => { + const mql = window.matchMedia('(max-width: 600px)'); + const handler = e => setView(e.matches ? 'minimized' : 'normal'); + // modern API + mql.addEventListener('change', handler); + return () => mql.removeEventListener('change', handler); + }, []); // Add recipient if not already present const addRecipient = email => { diff --git a/frontend/src/main_page/EmailSideMenu/EmailSideMenu.css b/frontend/src/main_page/EmailSideMenu/EmailSideMenu.css index 9c8d0d1e..e752976e 100644 --- a/frontend/src/main_page/EmailSideMenu/EmailSideMenu.css +++ b/frontend/src/main_page/EmailSideMenu/EmailSideMenu.css @@ -123,6 +123,7 @@ transition: transform 0.3s ease-in-out; z-index: 1050; padding-top: 5rem; + overflow: visible; } .sidebar.show { transform: translateX(0); diff --git a/frontend/src/main_page/EmailSideMenu/EmailSideMenu.jsx b/frontend/src/main_page/EmailSideMenu/EmailSideMenu.jsx index 97166633..2200c1e9 100644 --- a/frontend/src/main_page/EmailSideMenu/EmailSideMenu.jsx +++ b/frontend/src/main_page/EmailSideMenu/EmailSideMenu.jsx @@ -32,7 +32,9 @@ export default function EmailSidebar({ setCurrentTab, // Callback to change tab onComposeClick, // Callback for "Compose" button showSidebar, // Boolean: is sidebar open (mobile) - setShowSidebar // Callback to toggle sidebar (mobile) + setShowSidebar, // Callback to toggle sidebar (mobile) + clearSelection, + refreshMails }) { const isMobile = useIsMobile(); const [labels, setLabels] = useState([]); // Flat list of all labels (from backend) @@ -55,16 +57,36 @@ export default function EmailSidebar({ const handleAddLabel = async () => { const name = prompt("New label name:"); if (!name) return; - await createLabel(name); - await reload(); + + try { + await createLabel(name); + await reload(); + } catch (err) { + if (err.message.includes("409")) { + alert(`A label named "${name}" already exists.`); + } else { + alert("An error occurred while creating the label."); + console.error(err); + } + } }; // Rename label const handleEditLabel = async lab => { const name = prompt("Rename label:", lab.name); if (!name || name === lab.name) return; - await editLabel(lab.id, name); - await reload(); + try { + await editLabel(lab.id, name); + await reload(); + await refreshMails(); + } catch (err) { + if (err.message.includes("409")) { + alert(`A label named "${name}" already exists.`); + } else { + alert("An error occurred while creating the label."); + console.error(err); + } + } }; // Delete label with confirmation @@ -72,14 +94,24 @@ export default function EmailSidebar({ if (!window.confirm(`Delete "${lab.name}"?`)) return; await deleteLabel(lab.id); await reload(); + await refreshMails(); }; // Add sublabel under parent const handleAddSublabel = async lab => { const name = prompt(`Sublabel name under "${lab.name}":`); if (!name) return; - await createLabelUnderParent(lab.id, name); - await reload(); + try { + await createLabelUnderParent(lab.id, name); + await reload(); + } catch (err) { + if (err.message.includes("409")) { + alert(`A label named "${name}" already exists.`); + } else { + alert("An error occurred while creating the label."); + console.error(err); + } + } }; // Open the context menu at the position of the clicked label @@ -94,6 +126,7 @@ export default function EmailSidebar({ // Navigate to mailbox tab or label tab const onClickTab = id => { setCurrentTab(id); + clearSelection(); if (isMobile) setShowSidebar(false); }; @@ -175,9 +208,9 @@ export default function EmailSidebar({ return (
{/* Compose button (desktop only) */} - + {/* Folders (Inbox, Sent, etc.) */}
    @@ -234,4 +267,4 @@ export default function EmailSidebar({ )}
); -} +} \ No newline at end of file diff --git a/frontend/src/main_page/MainPage.js b/frontend/src/main_page/MainPage.js index 0476a0aa..58e5c80e 100644 --- a/frontend/src/main_page/MainPage.js +++ b/frontend/src/main_page/MainPage.js @@ -61,14 +61,12 @@ const MainPage = () => { } = useMails(inboxType); const [selectedMails, setSelectedMails] = useState(new Set()); - const allSelected = selectedMails.size === emails.length; - const anySelected = selectedMails.size > 0; const handlers = useMailToolbarHandlers( selectedMails, setSelectedMails, emails, - refreshMails + refreshMails, ); const handleSelect = (mail, checked) => { @@ -85,9 +83,9 @@ const MainPage = () => { useEffect(() => { if (location.state?.inboxType) { setInboxType(location.state.inboxType); + setSelectedMails(new Set()); } }, [location.state?.inboxType]); - return (
{ onComposeClick={handleComposeClick} showSidebar={showSidebar} setShowSidebar={setShowSidebar} + clearSelection={handlers.clearSelection} + refreshMails={refreshMails} />
@@ -113,8 +113,8 @@ const MainPage = () => { { hasPrevPage={hasPrevPage} goToNextPage={goToNextPage} goToPrevPage={goToPrevPage} + refreshMails={refreshMails} /> {loading ? ( @@ -144,12 +145,12 @@ const MainPage = () => { {composes.map(c => ( { handleCloseCompose(c.id); - refreshMails(c.id);}} + refreshMails();}} onSend={() => { handleCloseCompose(c.id); refreshMails(); @@ -161,4 +162,4 @@ const MainPage = () => { ); }; -export default MainPage; +export default MainPage; \ No newline at end of file diff --git a/frontend/src/main_page/hooks/useMailToolbarHandlers.js b/frontend/src/main_page/hooks/useMailToolbarHandlers.js index e9c22bfe..34e778b4 100644 --- a/frontend/src/main_page/hooks/useMailToolbarHandlers.js +++ b/frontend/src/main_page/hooks/useMailToolbarHandlers.js @@ -91,6 +91,12 @@ export const useMailToolbarHandlers = (selectedMails, setSelectedMails, allEmail setSelectedMails(new Set()); }, [selectedMails, setSelectedMails]); + // Clears selection of mails + const clearSelection = useCallback(async () => { + setSelectedMails(new Set()); + refreshMails(); + }, []); + return { handleSelectAll, handleRefresh, @@ -98,5 +104,6 @@ export const useMailToolbarHandlers = (selectedMails, setSelectedMails, allEmail handleMarkAsRead, handleMarkSpam, handleRestore, + clearSelection, }; } \ No newline at end of file diff --git a/frontend/src/main_page/mail_row/MailRow.css b/frontend/src/main_page/mail_row/MailRow.css index 00ddb031..3aa89a51 100644 --- a/frontend/src/main_page/mail_row/MailRow.css +++ b/frontend/src/main_page/mail_row/MailRow.css @@ -89,6 +89,7 @@ .row-top-controls { display: flex; + flex-direction: row; gap: 12px; align-items: center; } diff --git a/frontend/src/main_page/mail_row/MailRow.jsx b/frontend/src/main_page/mail_row/MailRow.jsx index 1e35c01e..7cd3d65f 100644 --- a/frontend/src/main_page/mail_row/MailRow.jsx +++ b/frontend/src/main_page/mail_row/MailRow.jsx @@ -1,6 +1,6 @@ import "./MailRow.css"; -import { formatDate } from "../../utils/formatDate"; -import { useNavigate } from "react-router-dom"; +import {formatDate} from "../../utils/formatDate"; +import {useNavigate} from "react-router-dom"; import StarButton from "../../components/StarButton/StarButton"; import {markMailAsRead, stripHtml} from "../../utils/mailUtils"; //import useIsMobile from "../../utils/useIsMobile"; @@ -21,10 +21,10 @@ const MailRow = ({ isSelected, onSelect, onUpdate, - onOpenDraft + onOpenDraft, }) => { const navigate = useNavigate(); - + // Handle opening an email (navigate to full view or open draft) const handleMailOpen = async () => { await markMailAsRead(email, onUpdate); if (inboxType === "draft" && onOpenDraft) { @@ -33,7 +33,6 @@ const MailRow = ({ navigate(`/mails/${email.id}`, {state: {inboxType}}); } }; - return (
@@ -51,6 +50,13 @@ const MailRow = ({
{email.from.fullName}
{email.subject}
+
+ {(email.labels || []).map(label => ( + + {label.name} + + ))} +
{stripHtml(email.body)}
@@ -67,4 +73,4 @@ const MailRow = ({
); } - export default MailRow; +export default MailRow; \ No newline at end of file diff --git a/frontend/src/main_page/toolbar/ToolBar.css b/frontend/src/main_page/toolbar/ToolBar.css index 1f123af4..610ef5f7 100644 --- a/frontend/src/main_page/toolbar/ToolBar.css +++ b/frontend/src/main_page/toolbar/ToolBar.css @@ -61,4 +61,20 @@ } .paging-text.dark { color: #ffffff; +} + +.labels-dropdown { + overflow-y: auto; + max-height: 250px; +} +.labels-dropdown.light { + color: black; + background-color: #fff; +} +.labels-dropdown.dark { + color: white; + background-color: #2c2c2c; +} +.form-check.light .form-check-input { + color: black; } \ No newline at end of file diff --git a/frontend/src/main_page/toolbar/ToolBar.jsx b/frontend/src/main_page/toolbar/ToolBar.jsx index df8f4cf2..bd639773 100644 --- a/frontend/src/main_page/toolbar/ToolBar.jsx +++ b/frontend/src/main_page/toolbar/ToolBar.jsx @@ -2,12 +2,15 @@ import React, {useEffect, useRef, useState} from "react"; import './ToolBar.css' import {MAILS_PER_PAGE} from "../../utils/constants"; import useIsMobile from "../../utils/useIsMobile"; +import {fetchLabels} from "../../api/labelsApi"; +import {applyLabelsToMail} from "../../api/mailApi"; +import {Dropdown} from "react-bootstrap"; const ToolBar = ({ theme, inboxType, - allSelected, - anySelected, + mailsAmount, + selectedMails, btnHandlers, total, page, @@ -17,15 +20,77 @@ const ToolBar = ({ goToPrevPage, }) => { + // checks if mobile UI or desktop + const isMobile = useIsMobile(); + + let isAllSelected = selectedMails.size === mailsAmount; + let isIndeterminate = selectedMails.size > 0 && selectedMails.size < mailsAmount; + + // labels + const [labels, setLabels] = useState([]); + const [showLabelMenu, setShowLabelMenu] = useState(false); + // fetches labels + useEffect(() => { + const loadLabels = async () => { + try { + const response = await fetchLabels(); + setLabels(response); + } catch (e) { + console.error("Failed to fetch labels", e); + } + }; + loadLabels(); + }, []); + // Helper to reload labels after changes + const reload = async () => { + try { + const data = await fetchLabels(); + setLabels(data); + } catch (e) { + console.error("Failed to reload labels:", e); + } + }; + + // apply label selection and refresh mails + const handleLabelToggle = async (label) => { + try { + await Promise.all(Array.from(selectedMails).map(async mail => { + const hasLabel = mail.labels.some(l => l.id === label.id); + let newLabels; + + if (hasLabel) { + // Remove label + newLabels = mail.labels.filter(l => l.id !== label.id); + } else { + // add label - no duplicates + const labelMap = new Map(mail.labels.map(l => [l.id, l])); + labelMap.set(label.id, label); + newLabels = Array.from(labelMap.values()); + } + // update UI + mail.labels = newLabels + .map(newLabel => labels.find(l => l.id === newLabel.id)) + .filter(Boolean); + mail._forceUpdate = Date.now(); + // Send new label list to backend + await applyLabelsToMail(mail.id, newLabels); + + })); + isAllSelected = false; + setShowLabelMenu(false); + btnHandlers.clearSelection(); + } catch (e) { + console.error("Failed to update label:", e); + } + }; + // for cases where not all mails were selected but some do const selectAllRef = useRef(null); useEffect(() => { if (selectAllRef.current) { - selectAllRef.current.indeterminate = !allSelected && anySelected; + selectAllRef.current.indeterminate = isIndeterminate; } - }, [allSelected, anySelected]); - - const isMobile = useIsMobile(); + }, [isIndeterminate]); return (
@@ -34,7 +99,7 @@ const ToolBar = ({ select all @@ -46,7 +111,7 @@ const ToolBar = ({ onClick={btnHandlers.handleRefresh} /> {/* additional buttons - shown when mails selected */} - {anySelected && ( + {selectedMails.size > 0 && (
{/* mark read button */}
)} + {/* label picker for selected mails */} + + + + {labels.map(label => ( +
+ + Array.isArray(mail.labels) && + mail.labels.some(l => l.id === label.id) + )} + onChange={() => handleLabelToggle(label)} + /> + +
+ ))} +
+
)} {/* paging info and buttons - always shown */} diff --git a/frontend/src/reading_page/ReadingHeader/ReadingHeader.jsx b/frontend/src/reading_page/ReadingHeader/ReadingHeader.jsx index a89115ac..30c7c365 100644 --- a/frontend/src/reading_page/ReadingHeader/ReadingHeader.jsx +++ b/frontend/src/reading_page/ReadingHeader/ReadingHeader.jsx @@ -4,8 +4,10 @@ import {APP_NAME, DEFAULT_AVATAR} from "../../utils/constants"; import ProfileMenu from "../../main_page/top_menu/profile/ProfileMenu"; import {useProfile} from "../../main_page/hooks/useProfile"; import {useOutsideClick} from "../../main_page/hooks/useOutsideClick"; -import {useRef, useState} from "react"; -import {deleteMail, restoreMail, toggleSpamReport} from "../../api/mailApi"; +import React, {useEffect, useRef, useState} from "react"; +import {applyLabelsToMail, deleteMail, restoreMail, toggleSpamReport} from "../../api/mailApi"; +import {Dropdown} from "react-bootstrap"; +import {fetchLabels} from "../../api/labelsApi"; const ReadingHeader = ({theme, toggleTheme, email, inboxType}) => { const navigate = useNavigate() @@ -37,6 +39,48 @@ const ReadingHeader = ({theme, toggleTheme, email, inboxType}) => { navigate('/inbox', {state: {inboxType: inboxType}}); } + // labels + const [labels, setLabels] = useState([]); + const [showLabelMenu, setShowLabelMenu] = useState(false); + // fetches labels + useEffect(() => { + const loadLabels = async () => { + try { + const response = await fetchLabels(); + setLabels(response); + } catch (e) { + console.error("Failed to fetch labels", e); + } + }; + loadLabels(); + }, []); + + // Apply label toggle for a single mail object + const handleLabelToggle = async (label) => { + try { + const hasLabel = Array.isArray(email.labels) && email.labels.some(l => l.id === label.id); + let newLabels; + + if (hasLabel) { + // Remove label + newLabels = email.labels.filter(l => l.id !== label.id); + } else { + // Add label without duplicates + const labelMap = new Map((email?.labels || []).map(l => [l.id, l])); + labelMap.set(label.id, label); + newLabels = Array.from(labelMap.values()); + } + // Update UI + email.labels = newLabels; + // Apply to backend + await applyLabelsToMail(email.id, newLabels); + setShowLabelMenu(false); + } catch (e) { + console.error("Failed to update label:", e); + } + }; + + return (
@@ -82,6 +126,31 @@ const ReadingHeader = ({theme, toggleTheme, email, inboxType}) => { Restore mail )} + {/* label picker for current mail */} + + + + {labels.map(label => ( +
+ l.id === label.id)} + onChange={() => handleLabelToggle(label)} + /> + +
+ ))} +
+
{/* profile and profile menu for more actions */} diff --git a/frontend/src/reading_page/ReadingPage.js b/frontend/src/reading_page/ReadingPage.js index b4019f8f..c928e39b 100644 --- a/frontend/src/reading_page/ReadingPage.js +++ b/frontend/src/reading_page/ReadingPage.js @@ -22,7 +22,7 @@ const ReadingPage = () => { const [composeProps, setComposeProps] = useState(null); // Read inboxType from location state const location = useLocation(); - const { inboxType } = location.state || {}; + const {inboxType} = location.state || {}; // Load the mail when ID changes useEffect(() => { @@ -69,7 +69,7 @@ ${email.body}`, // Allow updating the "starred" state locally const handleStarToggle = (newStarValue) => { - setEmail(prev => ({ ...prev, isStarred: newStarValue })); + setEmail(prev => ({...prev, isStarred: newStarValue})); }; return ( @@ -78,10 +78,16 @@ ${email.body}`, {/* Main content */} {loading ? ( - + ) : (

{email.subject}

+
+ {email.labels?.map(label => ( + {label.name} + ))} +
+ {/* Sender info and date */}
@@ -90,13 +96,13 @@ ${email.body}`, {/* Email body with safe links */}
{/* Attachments if any */} {email.files && email.files.length > 0 && (
-
Attachments:
+
Attachments:
)} @@ -121,7 +127,7 @@ ${email.body}`, onSend={() => setComposeProps(null)} offset={0} draftMail={{ - sentTo: composeProps.recipients.map(mail => ({ mail })), + sentTo: composeProps.recipients.map(mail => ({mail})), subject: composeProps.subject, body: composeProps.body, attachments: composeProps.attachments diff --git a/web_server/controllers/labels.js b/web_server/controllers/labels.js index 6233cdc4..00b0c766 100644 --- a/web_server/controllers/labels.js +++ b/web_server/controllers/labels.js @@ -2,11 +2,12 @@ const Labels = require('../models/labels'); /** * GET /api/labels - * Return all labels (flat list), including sublabels, for the authenticated user. + * Return all labels (flat list), belonging only to the authenticated user. * Responds: [{ id, name, parent }] */ exports.getAllLabels = (req, res) => { - const all = Labels.getAllLabels(); + const userId = +req.user.id; + const all = Labels.getAllLabels(userId); // Only send fields required by frontend; "parent" is id of parent label (or null for root) res.status(200).json(all.map(l => ({ id: l.id, @@ -22,10 +23,12 @@ exports.getAllLabels = (req, res) => { * Responds: created label object, 201 status. */ exports.createNewLabel = (req, res) => { - const userId = +req.user.id; // User ID from authentication middleware + // User ID from authentication middleware + const userId = +req.user.id; const { name } = req.body; if (!name) return res.status(400).json({ error: 'Name required' }); const lab = Labels.createNewLabel(userId, name); + if (!lab) return res.status(409).json({ error: 'Label name already exists' }); res.status(201).location(`/api/labels/${lab.id}`).json(lab); }; @@ -43,6 +46,7 @@ exports.createSublabel = (req, res) => { // Model should handle parent existence/ownership const lab = Labels.createSublabel(userId, parentId, name); if (!lab) return res.status(404).json({ error: 'Parent not found' }); + else if (!lab) return res.status(409).json({ error: 'Label name already exists' }); res.status(201).location(`/api/labels/${lab.id}`).json(lab); }; @@ -54,10 +58,12 @@ exports.createSublabel = (req, res) => { */ exports.editLabel = (req, res) => { const id = +req.params.id; + const userId = +req.user.id; const { name } = req.body; if (!name) return res.status(400).json({ error: 'Name required' }); - const ok = Labels.editLabelById(id, name); + const ok = Labels.editLabelById(id,userId, name); if (!ok) return res.status(404).json({ error: 'Not found' }); + res.status(204).end(); }; @@ -67,8 +73,15 @@ exports.editLabel = (req, res) => { * Responds: 204 on success, 404 if not found. */ exports.deleteLabel = (req, res) => { - const id = +req.params.id; - if (!Labels.deleteLabelById(id)) - return res.status(404).json({ error: 'Not found' }); - res.status(204).end(); -}; + const labelId = +req.params.id; + const userId = +req.user.id; + + if (!(labelId)) + return res.status(400).json({ error: 'Invalid label ID' }); + + const deleted = Labels.deleteLabelById(labelId, userId); + if (!deleted) + return res.status(404).json({ error: 'Label not found or not owned by user' }); + + return res.status(204).end(); +} diff --git a/web_server/controllers/mails.js b/web_server/controllers/mails.js index 5b26ba71..0d2ad757 100644 --- a/web_server/controllers/mails.js +++ b/web_server/controllers/mails.js @@ -2,7 +2,7 @@ const Mails = require('../models/mails'); const Blacklist = require('../models/blacklist'); const {extractUrls} = require("../utils/mails"); const {convertMailsToIds, usersToFullElement} = require("../utils/users"); -const {convertLabelsToIds, labelsToFullElement, mailLabelNames} = require("../utils/labels"); +const {convertLabelsToIds, labelsToFullElement} = require("../utils/labels"); /** * Gets the last 50 mails of a user, ordered by the most recent (first) to least recent (last) @@ -19,7 +19,8 @@ const getLastMailsOrdered = (req, res) => { return res.status(400).json({error: 'User not authenticated - failed fetching last 50 mails'}); const inboxType = req.query.inboxType || 'all'; const page = Number(req.query.page) || 1; - const limit = Number(req.query.limit) || 50; // limit is 50 according to instructions + // limit is 50 according to instructions + const limit = Number(req.query.limit) || 50; const labelIdFilter = Number(req.query.label); let {paged, total} = Mails.getUserMails(userId, limit, inboxType, page); @@ -145,14 +146,14 @@ const updateMail = (req, res) => { if (isNaN(mailId)) return res.status(400).json({error: 'error no valid mail id was given'}); const mail = Mails.getMail(mailId); - if (!mailId || mail.owner !== userId) + if (!mailId || mail.owner != userId) return res.status(404).json({error: 'error mail not found'}); // edit it as a draft if (mail.isDraft) return editDraft(req, res, userId, mail); // otherwise it’s a mail already sent - only allow flags & labels - const {isRead, isStarred, isTrashed, isSpam, labels} = req.body; + const {isRead, isStarred, isTrashed, isSpam, labels} = req.body || {}; const labelsIds = convertLabelsToIds(userId, labels || []); const updated = Mails.editSentMail(mailId, isRead, isStarred, isTrashed, isSpam, labelsIds); if (updated) @@ -179,12 +180,12 @@ const editDraft = async (req, res, userId, mail) => { if (!mail.isDraft) return res.status(400).json({error: 'error only drafts can be updated'}); // Get the input params and edit the mail - const {subject, body, sentTo = [], saveAsDraft = true} = req.body; + const {subject, body, sentTo = [], saveAsDraft = true, files = []} = req.body; const sentToIds = convertMailsToIds(sentTo); // just update the fields in this draft if (saveAsDraft) { - const updated = Mails.updateDraft(mail.id, subject, body, sentToIds); + const updated = Mails.updateDraft(mail.id, subject, body, sentToIds, files); return res.status(200).json(updated); } // turn the draft to a new mail @@ -194,7 +195,7 @@ const editDraft = async (req, res, userId, mail) => { return res.status(403).json({error: 'error mail contains blacklisted URLs'}); // delete the draft and send a new mail Mails.deleteMail(userId, mail.id); - const ownerMail = Mails.sendNewMail(userId, subject, body, sentToIds); + const ownerMail = Mails.sendNewMail(userId, subject, body, sentToIds, files); return res.status(201).location(`/mails/${ownerMail.id}`).json(ownerMail); } @@ -256,4 +257,4 @@ const getMailsByQuery = (req, res) => { return res.status(200).json(fullMails); } -module.exports = {getLastMailsOrdered, getMailById, createNewMail, updateMail, deleteMailById, getMailsByQuery}; \ No newline at end of file +module.exports = {getLastMailsOrdered, getMailById, createNewMail, updateMail, deleteMailById, getMailsByQuery}; diff --git a/web_server/models/labels.js b/web_server/models/labels.js index c8ebe822..44af377b 100644 --- a/web_server/models/labels.js +++ b/web_server/models/labels.js @@ -1,27 +1,31 @@ // Initial example labels -let labels = [ +let labels = [ { id: 1, name: 'work', owner: 1, parent: null }, { id: 2, name: 'friends', owner: 1, parent: null }, + { id: 3, name: 'work', owner: 2, parent: null }, + { id: 4, name: 'work', owner: 3, parent: null } ]; // Simple incrementing ID let nextId = labels.length + 1; /** - * Get all labels (no user filter; in a real app, should filter by user) - * @returns {Array} All label objects + * Return all labels owned by a specific user. + * @param {number} userId - ID of the user requesting the labels + * @returns {Array} Filtered label objects owned by the user */ -function getAllLabels() { - return labels; +function getAllLabels(userId) { + return labels.filter(l => l.owner == userId); } /** - * Find a label by its ID - * @param {number} id + * Find a label by its ID and owner + * @param {number} id - ID of label + * @param {number} owner - ID of the user * @returns {object|null} Label object or null if not found */ -function getLabelById(id) { - return labels.find(l => l.id === id); +function getLabelById(id,owner) { + return labels.find(l => l.id == id && l.owner == owner) || null; } /** @@ -31,11 +35,31 @@ function getLabelById(id) { * @returns {object} The created label */ function createNewLabel(owner, name) { + if (isDuplicateLabel(owner, name, null)) return null; const lab = { id: nextId++, owner, name, parent: null }; labels.push(lab); return lab; } +/** + * Check if a label with the same name already exists + * for a given user and (optional) parent. + * Used to prevent duplicates on create/edit. + * @param {number} owner - User ID + * @param {string} name - Label name to check + * @param {number|null} parent - Parent label ID or null + * @param {number|null} excludeId - Optional: label ID to exclude from check (for editing) + * @returns {boolean} True if duplicate exists, false otherwise + */ +function isDuplicateLabel(owner, name, parent = null, excludeId = null) { + return labels.some(l => + l.owner === owner && + l.name === name && + l.parent === parent && + (excludeId === null || l.id !== excludeId) + ); +} + /** * Create a sublabel under an existing label * @param {number} owner - User ID @@ -45,12 +69,17 @@ function createNewLabel(owner, name) { */ function createSublabel(owner, parent, name) { // Check parent exists (could also check ownership) - if (!labels.find(l => l.id === parent)) return null; + const parentLabel = getLabelById(parent, owner); + if (!parentLabel) return null; + + // Prevent duplicate sublabel names under the same parent for the same user + if (isDuplicateLabel(owner, name, parent)) throw new Error("409") + const newLabel = { - id: nextId++, // Use global nextId for unique ID + id: nextId++, name, owner, - parent, // Link to parent label by id + parent, }; labels.push(newLabel); return newLabel; @@ -59,24 +88,39 @@ function createSublabel(owner, parent, name) { /** * Rename a label by ID * @param {number} id - * @param {string} name + * @param {string} newName + * @param {number} owner - User ID * @returns {object|null} Updated label or null if not found */ -function editLabelById(id, name) { - const lab = getLabelById(id); - if (!lab) return null; - lab.name = name; - return lab; +function editLabelById(id, owner,newName) { + // Check parent exists (could also check ownership) + const Label = getLabelById(id, owner); + if (!Label) return null; + // Check if another label with the same name already exists + if (isDuplicateLabel(owner, newName, Label.parent, id)) { + throw new Error("409"); + } + // Rename the label + Label.name = newName; + return Label; } /** * Delete a label by ID (does not cascade to sublabels) * @param {number} id + * @param {number} owner - User ID * @returns {boolean} True if deleted, false if not found */ -function deleteLabelById(id) { - const idx = labels.findIndex(l => l.id === id); +function deleteLabelById(id,owner) { + const idx = labels.findIndex(l => l.id == id && l.owner == owner); if (idx < 0) return false; + // Recursively delete children + const childLabels = labels.filter(l => l.parent == id && l.owner == owner); + childLabels.forEach(child => { + deleteLabelById(child.id, owner); + }); + + // Delete the label itself labels.splice(idx, 1); return true; } diff --git a/web_server/models/mails.js b/web_server/models/mails.js index 07e3e3a5..db51d105 100644 --- a/web_server/models/mails.js +++ b/web_server/models/mails.js @@ -65,7 +65,7 @@ const mails = [ body: "Don’t forget the team meeting at 9AM tomorrow.", createdAt: new Date('2025-06-03T03:15:27.000Z'), sentAt: new Date('2025-06-03T11:42:53.000Z'), - labels: [], + labels: [1,2], isDraft: false, isRead: true, isStarred: false, @@ -82,7 +82,7 @@ const mails = [ body: "Don’t forget the team meeting at 9AM tomorrow.", createdAt: new Date('2025-06-03T03:15:27.000Z'), sentAt: new Date('2025-06-03T11:42:53.000Z'), - labels: [1,2], + labels: [2], isDraft: false, isRead: false, isStarred: true, @@ -94,29 +94,12 @@ const mails = [ id: 5, owner: 1, from: 1, - sentTo: [2], - subject: "Your daily digest", - body: "Top tech news today: React 21.0 is out...", - createdAt: new Date('2025-06-02T18:55:04.000Z'), - sentAt: "", - labels: [], - isDraft: true, - isRead: false, - isStarred: false, - isTrashed: false, - isSpam: false, - files: [] - }, - { - id: 6, - owner: 1, - from: 1, sentTo: [1, 2, 3], subject: "My new picture", body: "Whats up guys look at my new picture!", createdAt: new Date('2025-06-03T20:27:11.000Z'), sentAt: new Date('2025-06-04T14:08:36.000Z'), - labels: [], + labels: [1], isDraft: false, isRead: true, isStarred: false, @@ -142,7 +125,7 @@ const mails = [ ] }, { - id: 7, + id: 6, owner: 2, from: 1, sentTo: [1, 2, 3], @@ -164,7 +147,7 @@ const mails = [ ] }, { - id: 8, + id: 7, owner: 3, from: 1, sentTo: [1, 2, 3], @@ -172,7 +155,7 @@ const mails = [ body: "Whats up guys look at my new picture!", createdAt: new Date('2025-06-03T20:27:11.000Z'), sentAt: new Date('2025-06-04T14:08:36.000Z'), - labels: [], + labels: [1], isDraft: false, isRead: false, isStarred: false, @@ -443,4 +426,4 @@ module.exports = { updateDraft, deleteMail, searchInInbox, -}; +}; \ No newline at end of file diff --git a/web_server/utils/labels.js b/web_server/utils/labels.js index d1026c7e..c7c7fce6 100644 --- a/web_server/utils/labels.js +++ b/web_server/utils/labels.js @@ -2,16 +2,15 @@ const Labels = require("../models/labels"); /** * Converts an array of label names to their ids - * @param userId owner of the label - * @param labelsByNames array of labels' names + * @param {string} userId owner of the label + * @param {Array} labelsObjects array of labels' objects * @returns {number[]} array of labels ids */ -function convertLabelsToIds(userId, labelsByNames) { - if (!labelsByNames) +function convertLabelsToIds(userId, labelsObjects) { + if (!labelsObjects) return []; - return labelsByNames.map(label => { - return Labels.getLabelByName(userId, label).id; - }) + const ids = labelsObjects.map(label => label.id); + return [...new Set(ids)]; } /** @@ -21,9 +20,12 @@ function convertLabelsToIds(userId, labelsByNames) { * @returns {*} array of labels elements */ function labelsToFullElement(userId, labelsIds) { - return labelsIds.map(label => { - return Labels.getLabelById(userId, label); - }) + if (!labelsIds) + return []; + const objects = labelsIds + .map(id => Labels.getLabelById(id, userId)) + .filter(label => label); + return [...new Set(objects)]; } /** @@ -33,9 +35,9 @@ function labelsToFullElement(userId, labelsIds) { */ const mailLabelNames = (userId, mail) => { return (mail.labels || []) - .map(labelId => Labels.getLabelById(userId, labelId).name) - .filter(Boolean) - .map(name => name.toLowerCase()); + .map(labelId => Labels.getLabelById(labelId, userId)) + .filter(label => label && label.name) + .map(label => label.name.toLowerCase()); }; module.exports = {convertLabelsToIds, labelsToFullElement, mailLabelNames} \ No newline at end of file