diff --git a/.gitignore b/.gitignore index 639880c..840550e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules -dbdata \ No newline at end of file +dbdata +.env \ No newline at end of file diff --git a/backend/logs/errors.log b/backend/logs/errors.log new file mode 100644 index 0000000..e69de29 diff --git a/backend/package-lock.json b/backend/package-lock.json index 2955fd4..109db0a 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,9 +9,11 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@google/generative-ai": "^0.24.1", "bcrypt": "^6.0.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", + "dotenv": "^17.2.1", "express": "^5.1.0", "jsonwebtoken": "^9.0.2", "passport": "^0.7.0", @@ -96,6 +98,15 @@ "license": "MIT", "optional": true }, + "node_modules/@google/generative-ai": { + "version": "0.24.1", + "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz", + "integrity": "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jsdevtools/ono": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", @@ -167,12 +178,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.0.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.7.tgz", - "integrity": "sha512-YIEUUr4yf8q8oQoXPpSlnvKNVKDQlPMWrmOcgzoduo7kvA2UF0/BwJ/eMKFTiTtkNL17I0M6Xe2tvwFU7be6iw==", + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", + "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", "license": "MIT", "dependencies": { - "undici-types": "~7.8.0" + "undici-types": "~7.10.0" } }, "node_modules/@types/triple-beam": { @@ -258,9 +269,9 @@ } }, "node_modules/aproba": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", - "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", "license": "ISC", "optional": true }, @@ -331,15 +342,6 @@ "node": ">= 18" } }, - "node_modules/bcrypt/node_modules/node-addon-api": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.4.0.tgz", - "integrity": "sha512-D9DI/gXHvVmjHS08SVch0Em8G5S1P+QWtU31appcKT/8wFSPRcdHadIFSAntdMMVM5zz+/DL+bL/gz3UDppqtg==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -633,21 +635,12 @@ "node": ">= 0.8.0" } }, - "node_modules/cookie-parser/node_modules/cookie-signature": { + "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -739,6 +732,18 @@ "node": ">=6.0.0" } }, + "node_modules/dotenv": { + "version": "17.2.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz", + "integrity": "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dottie": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.6.tgz", @@ -937,6 +942,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/fecha": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", @@ -1322,15 +1336,11 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", - "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", "license": "MIT", "optional": true, - "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - }, "engines": { "node": ">= 12" } @@ -1404,13 +1414,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsbn": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", - "license": "MIT", - "optional": true - }, "node_modules/jsonwebtoken": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", @@ -1848,10 +1851,13 @@ } }, "node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "license": "MIT" + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } }, "node_modules/node-gyp": { "version": "8.4.1", @@ -2703,13 +2709,13 @@ } }, "node_modules/socks": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.5.tgz", - "integrity": "sha512-iF+tNDQla22geJdTyJB1wM/qrX9DMRwWrciEPwWLPRWAUEM8sQiyxgckLxWT1f7+9VabJS0jTGGr4QgBuvi6Ww==", + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", "license": "MIT", "optional": true, "dependencies": { - "ip-address": "^9.0.5", + "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" }, "engines": { @@ -2741,13 +2747,6 @@ "node": ">= 10.x" } }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "license": "BSD-3-Clause", - "optional": true - }, "node_modules/sqlite3": { "version": "5.1.7", "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", @@ -2772,6 +2771,12 @@ } } }, + "node_modules/sqlite3/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/ssri": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", @@ -2903,9 +2908,9 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.25.3", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.25.3.tgz", - "integrity": "sha512-mqWJAhfl8mhVKJezwszUqRJAlrvKG/22am5xRUWzr7ya0MFaFBAAd7Nm+tD4BdKnVx7KRWkWYJMYRkFm5a8iTg==", + "version": "5.28.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.28.0.tgz", + "integrity": "sha512-I9ibQtr77BPzT28WFWMVktzQOtWzoSS2J99L0Att8gDar1atl1YTRI7NUFSr4kj8VvWICgylanYHIoHjITc7iA==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" @@ -3043,9 +3048,9 @@ } }, "node_modules/undici-types": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", - "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", "license": "MIT" }, "node_modules/unique-filename": { diff --git a/backend/package.json b/backend/package.json index d90a1a5..4e947de 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,9 +11,11 @@ "license": "ISC", "description": "API for a simple notes web app", "dependencies": { + "@google/generative-ai": "^0.24.1", "bcrypt": "^6.0.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", + "dotenv": "^17.2.1", "express": "^5.1.0", "jsonwebtoken": "^9.0.2", "passport": "^0.7.0", diff --git a/backend/src/app.js b/backend/src/app.js index 3b71cfd..f99374a 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -1,3 +1,4 @@ +import 'dotenv/config'; import express from 'express'; import cors from 'cors'; import { notesRouter } from './routes/notesRouter.js'; @@ -12,23 +13,30 @@ import { addLogger } from './config/logger.config.js'; import { sequelize } from './config/database.js'; import './models/models.js'; import { createAdminUserIfNotExists } from './utils.js'; +import { aiRouter } from './routes/aiRouter.js'; const PORT = process.env.PORT || 3000; const app = express(); +const allowedOrigins = [`http://localhost:${PORT}`, 'http://localhost:3001', 'http://localhost:3004', 'http://localhost:8080']; + +console.log("CORS is configured for these origins1:", allowedOrigins); + +app.use(cors({ + origin: allowedOrigins, + credentials: true +})); + + + app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(cookieParser()); initializePassport(); app.use(passport.initialize()); -const allowedOrigins = [`http://localhost:${PORT}`, `http://localhost:3001`]; -app.use(cors({ - origin: allowedOrigins, - credentials: true -})); app.use(addLogger); @@ -36,6 +44,7 @@ app.use('/api/notes', notesRouter.getRouter()); app.use('/api/categories', categoriesRouter.getRouter()); app.use('/api/sessions', sessionsRouter.getRouter()); app.use('/api/docs', swaggerUiExpress.serve, swaggerUiExpress.setup(docsSpecs, { swaggerOptions: { withCredentials: true } })); +app.use('/api/ai', aiRouter.getRouter()); sequelize.authenticate() .then(async () => { @@ -49,4 +58,5 @@ sequelize.authenticate() .catch((error) => { console.error('Unable to connect to the database:', error); process.exit(1); - }); \ No newline at end of file + }); + diff --git a/backend/src/controllers/ai.controller.js b/backend/src/controllers/ai.controller.js new file mode 100644 index 0000000..a282c23 --- /dev/null +++ b/backend/src/controllers/ai.controller.js @@ -0,0 +1,21 @@ +import { aiService } from '../services/ai.service.js'; + +class AIController { + async generate(req, res) { + const { prompt } = req.body; + + if (!prompt) { + return res.sendBadRequestError("'prompt' is required in the request body."); + } + + try { + const generatedText = await aiService.generateContent(prompt); + res.sendOk({ content: generatedText }); + } catch (error) { + req.logger.error(`AI content generation failed: ${error.message}`); + res.sendInternalServerError("Error generating content with AI."); + } + } +} + +export const aiController = new AIController(); \ No newline at end of file diff --git a/backend/src/routes/aiRouter.js b/backend/src/routes/aiRouter.js new file mode 100644 index 0000000..815b987 --- /dev/null +++ b/backend/src/routes/aiRouter.js @@ -0,0 +1,11 @@ +import Router from './router.js'; +import { aiController } from '../controllers/ai.controller.js'; + +class AIRouter extends Router { + init() { + // Only authenticated users can access the AI feature. + this.post('/generate', ["USER"], async (req, res) => this.controller.generate(req, res)); + } +} + +export const aiRouter = new AIRouter(aiController); \ No newline at end of file diff --git a/backend/src/routes/router.js b/backend/src/routes/router.js index 45c7f1f..37adb58 100644 --- a/backend/src/routes/router.js +++ b/backend/src/routes/router.js @@ -29,63 +29,24 @@ export default class Router { generateCustomResponses = (req, res, next) => { res.setHeader('Content-Type', 'application/json'); - - res.sendOk = payload => res.status(200).send({ - status: "success", - payload - }); - - res.sendCreated = payload => res.status(201).send({ - status: "success", - payload - }); - - res.sendBadRequestError = error => res.status(400).send({ - status: "error", - error - }); - - res.sendUnauthorizedError = error => res.status(401).send({ - status: "error", - error - }); - - res.sendForbiddenError = error => res.status(403).send({ - status: "error", - error - }); - - res.sendNotFoundError = error => res.status(404).send({ - status: "error", - error - }); - - res.sendInternalServerError = error => res.status(500).send({ - status: "error", - error - }); - - res.sendNotImplementedError = error => res.status(501).send({ - status: "error", - error - }); - + res.sendOk = payload => res.status(200).send({ status: "success", payload }); + res.sendCreated = payload => res.status(201).send({ status: "success", payload }); + res.sendBadRequestError = error => res.status(400).send({ status: "error", error }); + res.sendUnauthorizedError = error => res.status(401).send({ status: "error", error }); + res.sendForbiddenError = error => res.status(403).send({ status: "error", error }); + res.sendNotFoundError = error => res.status(404).send({ status: "error", error }); + res.sendInternalServerError = error => res.status(500).send({ status: "error", error }); + res.sendNotImplementedError = error => res.status(501).send({ status: "error", error }); next(); } - handlePolicies = policies => (req, res, next) => { if (policies[0] === "PUBLIC") return next(); - const authHeader = req.headers.authorization; - const token = authHeader && authHeader.startsWith('Bearer ') - ? authHeader.substring(7) - : null; - + const token = authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : null; if (!token) { return res.sendUnauthorizedError('Access token not provided'); } - try { jwt.verify(token, PRIVATE_KEY, async (err, decoded) => { if (err) { @@ -94,37 +55,34 @@ export default class Router { } return res.sendUnauthorizedError('Invalid access token'); } - if (!policies.includes(decoded.user.role.toUpperCase())) { return res.sendForbiddenError('User not allowed to access this resource'); } - req.user = decoded.user; - next(); }); } catch (error) { return res.sendUnauthorizedError(error.message); } - } + } get(path, policies, ...callbacks) { - this.router.get(path, this.generateCustomResponses, this.handlePolicies(policies), this.applyCallbacks(callbacks)); + this.router.get(path, this.generateCustomResponses, this.handlePolicies(policies), ...this.applyCallbacks(callbacks)); } post(path, policies, ...callbacks) { - this.router.post(path, this.generateCustomResponses, this.handlePolicies(policies), this.applyCallbacks(callbacks)); + this.router.post(path, this.generateCustomResponses, this.handlePolicies(policies), ...this.applyCallbacks(callbacks)); } put(path, policies, ...callbacks) { - this.router.put(path, this.generateCustomResponses, this.handlePolicies(policies), this.applyCallbacks(callbacks)); + this.router.put(path, this.generateCustomResponses, this.handlePolicies(policies), ...this.applyCallbacks(callbacks)); } delete(path, policies, ...callbacks) { - this.router.delete(path, this.generateCustomResponses, this.handlePolicies(policies), this.applyCallbacks(callbacks)); + this.router.delete(path, this.generateCustomResponses, this.handlePolicies(policies), ...this.applyCallbacks(callbacks)); } patch(path, policies, ...callbacks) { - this.router.patch(path, this.generateCustomResponses, this.handlePolicies(policies), this.applyCallbacks(callbacks)); + this.router.patch(path, this.generateCustomResponses, this.handlePolicies(policies), ...this.applyCallbacks(callbacks)); } } \ No newline at end of file diff --git a/backend/src/services/ai.service.js b/backend/src/services/ai.service.js new file mode 100644 index 0000000..b135884 --- /dev/null +++ b/backend/src/services/ai.service.js @@ -0,0 +1,31 @@ +import { GoogleGenerativeAI } from '@google/generative-ai'; +import 'dotenv/config'; + +class AIService { + constructor() { + if (!process.env.GOOGLE_API_KEY) { + throw new Error("Google API Key not found. Please check your .env file."); + } + this.genAI = new GoogleGenerativeAI(process.env.GOOGLE_API_KEY); + this.model = this.genAI.getGenerativeModel({ model: "gemini-1.5-flash" }); + } + + /** + * Generates content based on a user prompt. + * @param {string} prompt - The user's request for the AI. + * @returns {Promise} The generated text. + */ + async generateContent(prompt) { + try { + const result = await this.model.generateContent(prompt); + const response = await result.response; + const text = response.text(); + return text; + } catch (error) { + console.error("Error generating content with AI:", error); + throw new Error("Failed to generate content from AI service."); + } + } +} + +export const aiService = new AIService(); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index d84f9ce..2171b5d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,7 @@ services: DB_PASS: postgres DB_NAME: notes PRIVATE_KEY: thisIsASecretKey + GOOGLE_API_KEY: ${GOOGLE_API_KEY} depends_on: - db diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..28defe8 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,13 @@ +# Ignore dependencies +node_modules + +# Ignore build output +dist + +# Ignore environment variables +.env + +# Ignore logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* \ No newline at end of file diff --git a/frontend/src/components/NoteForm.jsx b/frontend/src/components/NoteForm.jsx index 62efd72..42e8dc1 100644 --- a/frontend/src/components/NoteForm.jsx +++ b/frontend/src/components/NoteForm.jsx @@ -1,4 +1,6 @@ import { useState, useEffect } from 'react'; +import { aiAPI } from '../services/api'; + const NoteForm = ({ note, categories, onSubmit, onCancel }) => { const [formData, setFormData] = useState({ @@ -8,6 +10,10 @@ const NoteForm = ({ note, categories, onSubmit, onCancel }) => { }); const [errors, setErrors] = useState({}); + const [aiPrompt, setAiPrompt] = useState(''); + const [isGenerating, setIsGenerating] = useState(false); + const [aiError, setAiError] = useState(''); + useEffect(() => { if (note) { setFormData({ @@ -65,6 +71,31 @@ const NoteForm = ({ note, categories, onSubmit, onCancel }) => { } }; + const handleGenerateContent = async () => { + if (!aiPrompt.trim()) { + setAiError('Please enter a prompt for the AI.'); + return; + } + setIsGenerating(true); + setAiError(''); + try { + const response = await aiAPI.generateContent(aiPrompt); + const generatedText = response.payload.content; + + // 智能填充:第一行作为标题,其余作为内容 + const lines = generatedText.split('\n'); + const title = lines[0]; + const content = lines.slice(1).join('\n').trim(); + + setFormData(prev => ({ ...prev, title, content })); + } catch (error) { + setAiError('Failed to generate content. Please try again.'); + console.error('AI Generation Error:', error); + } finally { + setIsGenerating(false); + } + }; + return (
@@ -81,6 +112,46 @@ const NoteForm = ({ note, categories, onSubmit, onCancel }) => {
+ + + +
+ +