diff --git a/backend/src/controllers/notesController.js b/backend/src/controllers/notesController.js index aad32a3..60b878c 100644 --- a/backend/src/controllers/notesController.js +++ b/backend/src/controllers/notesController.js @@ -3,10 +3,31 @@ import Note from "../models/Note.js"; /** * Get all notes belonging to the authenticated user. + * Supports optional search query and tag filtering. */ export async function getAllNotes(req, res) { try { - const notes = await Note.find({ userId: req.user._id }).sort({ createdAt: -1 }); + const { search, tag } = req.query; + const filter = { userId: req.user._id }; + + // Search filter: match title or content (case-insensitive) + if (search && typeof search === "string" && search.trim()) { + const regex = new RegExp(search.trim(), "i"); + filter.$or = [ + { title: regex }, + { content: regex }, + ]; + } + + // Tag filter: match any of the provided tags (comma-separated) + if (tag && typeof tag === "string" && tag.trim()) { + const tags = tag.split(",").map((t) => t.trim()).filter(Boolean); + if (tags.length > 0) { + filter.tags = { $in: tags }; + } + } + + const notes = await Note.find(filter).sort({ createdAt: -1 }); res.status(200).json(notes); } catch (error) { @@ -51,10 +72,11 @@ export async function getNoteById(req, res) { /** * Create a new note associated with the authenticated user's ID. + * Supports an optional tags array. */ export async function createNote(req, res) { try { - const { title, content } = req.body; + const { title, content, tags } = req.body; if (typeof title !== "string" || typeof content !== "string") { return res.status(400).json({ message: "Title and content must be strings" }); @@ -64,10 +86,22 @@ export async function createNote(req, res) { return res.status(400).json({ message: "Title is required" }); } + // Validate tags if provided + let parsedTags = []; + if (tags !== undefined) { + if (!Array.isArray(tags)) { + return res.status(400).json({ message: "Tags must be an array of strings" }); + } + parsedTags = tags + .map((t) => (typeof t === "string" ? t.trim().toLowerCase() : "")) + .filter((t) => t.length > 0); + } + const note = new Note({ userId: req.user._id, title: title.trim(), content: content.trim(), + tags: parsedTags, }); const savedNote = await note.save(); @@ -84,11 +118,12 @@ export async function createNote(req, res) { /** * Update a specific note after verifying ownership by the authenticated user. + * Supports updating title, content, and tags. */ export async function updateNote(req, res) { try { const { id } = req.params; - const { title, content } = req.body; + const { title, content, tags } = req.body; if (process.env.MONGO_URI && !mongoose.Types.ObjectId.isValid(id)) { return res.status(400).json({ @@ -102,14 +137,26 @@ export async function updateNote(req, res) { if (!title.trim()) { return res.status(400).json({ message: "Title is required" }); } - + + // Build update object + const updateData = { + title: title.trim(), + content: content.trim(), + }; + + // Handle tags update if provided + if (tags !== undefined) { + if (!Array.isArray(tags)) { + return res.status(400).json({ message: "Tags must be an array of strings" }); + } + updateData.tags = tags + .map((t) => (typeof t === "string" ? t.trim().toLowerCase() : "")) + .filter((t) => t.length > 0); + } const updatedNote = await Note.findOneAndUpdate( { _id: id, userId: req.user._id }, - { - title: title.trim(), - content: content.trim(), - }, + updateData, { new: true, runValidators: true, @@ -164,3 +211,29 @@ export async function deleteNote(req, res) { }); } } + +/** + * Get all unique tags used by the authenticated user. + */ +export async function getUserTags(req, res) { + try { + const result = await Note.aggregate([ + { $match: { userId: req.user._id } }, + { $unwind: "$tags" }, + { $group: { _id: "$tags", count: { $sum: 1 } } }, + { $sort: { count: -1, _id: 1 } }, + ]); + + const tags = result.map((t) => ({ + name: t._id, + count: t.count, + })); + + res.status(200).json(tags); + } catch (error) { + console.error("Error in getUserTags controller:", error); + res.status(500).json({ + message: "Internal server error", + }); + } +} \ No newline at end of file diff --git a/backend/src/models/Note.js b/backend/src/models/Note.js index 7beb664..2fe2152 100644 --- a/backend/src/models/Note.js +++ b/backend/src/models/Note.js @@ -18,6 +18,11 @@ const noteSchema = new mongoose.Schema({ type: String, default: "" }, + tags: { + type: [String], + default: [], + index: true + }, isGroup: { type: Boolean, default: false diff --git a/backend/src/routes/notesRoutes.js b/backend/src/routes/notesRoutes.js index 1160275..30bae10 100644 --- a/backend/src/routes/notesRoutes.js +++ b/backend/src/routes/notesRoutes.js @@ -1,5 +1,5 @@ import express from "express"; -import { getAllNotes, getNoteById, createNote, updateNote, deleteNote } from "../controllers/notesController.js"; +import { getAllNotes, getNoteById, createNote, updateNote, deleteNote, getUserTags } from "../controllers/notesController.js"; import { authenticateUser } from "../middleware/authMiddleware.js"; const router = express.Router(); @@ -9,6 +9,8 @@ router.use(authenticateUser); router.get("/", getAllNotes); +router.get("/tags", getUserTags); + router.get("/:id", getNoteById); router.post("/", createNote); @@ -17,4 +19,4 @@ router.put("/:id", updateNote); router.delete("/:id", deleteNote); -export default router; \ No newline at end of file +export default router; diff --git a/frontend/src/components/NoteCard.jsx b/frontend/src/components/NoteCard.jsx index 4148f0d..593262a 100644 --- a/frontend/src/components/NoteCard.jsx +++ b/frontend/src/components/NoteCard.jsx @@ -4,6 +4,7 @@ import { Link, useNavigate } from "react-router-dom"; import { formatDate } from "../lib/utils"; import api from "../lib/axios"; import toast from "react-hot-toast"; +import TagBadge from "./TagBadge"; const NoteCard = ({ note, @@ -118,9 +119,18 @@ const NoteCard = ({ {/* Content Preview */} -

+

{note.content || "No content"}

+ + {/* Tags */} + {note.tags && note.tags.length > 0 && ( +
+ {note.tags.map((tag) => ( + + ))} +
+ )} {/* Footer */}
diff --git a/frontend/src/components/NotesNotFound.jsx b/frontend/src/components/NotesNotFound.jsx index 77edf9a..e53063c 100644 --- a/frontend/src/components/NotesNotFound.jsx +++ b/frontend/src/components/NotesNotFound.jsx @@ -1,7 +1,37 @@ -import { NotebookIcon } from "lucide-react"; +import { NotebookIcon, SearchXIcon } from "lucide-react"; import { Link } from "react-router-dom"; -const NotesNotFound = () => { +const NotesNotFound = ({ isFiltered = false }) => { + if (isFiltered) { + return ( +
+ {/* Icon Circle */} +
+ +
+ + {/* Title */} +

+ No matching notes +

+ + {/* Description */} +

+ Try adjusting your search query or clearing the filters to see all your notes. +

+ + {/* Button */} + + + Create New Note + +
+ ); + } + return (
{/* Icon Circle */} diff --git a/frontend/src/components/SearchBar.jsx b/frontend/src/components/SearchBar.jsx new file mode 100644 index 0000000..ba2df90 --- /dev/null +++ b/frontend/src/components/SearchBar.jsx @@ -0,0 +1,26 @@ +import { SearchIcon, XIcon } from "lucide-react"; + +const SearchBar = ({ value, onChange, placeholder = "Search notes..." }) => { + return ( +
+ + onChange(e.target.value)} + className="w-full pl-10 pr-10 py-2.5 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl text-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all" + /> + {value && ( + + )} +
+ ); +}; + +export default SearchBar; \ No newline at end of file diff --git a/frontend/src/components/TagBadge.jsx b/frontend/src/components/TagBadge.jsx new file mode 100644 index 0000000..90be6a1 --- /dev/null +++ b/frontend/src/components/TagBadge.jsx @@ -0,0 +1,54 @@ +import { XIcon } from "lucide-react"; + +const TAG_COLORS = [ + "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300", + "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300", + "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300", + "bg-pink-100 text-pink-700 dark:bg-pink-900/40 dark:text-pink-300", + "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300", + "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300", + "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300", + "bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300", + "bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300", + "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-300", +]; + +const getTagColor = (tag) => { + let hash = 0; + for (let i = 0; i < tag.length; i++) { + hash = tag.charCodeAt(i) + ((hash << 5) - hash); + } + return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length]; +}; + +const TagBadge = ({ tag, onRemove, onClick, active = false, size = "sm" }) => { + const sizeClasses = size === "sm" + ? "text-xs px-2 py-0.5" + : "text-sm px-3 py-1"; + + const activeClasses = active + ? "ring-2 ring-blue-500 ring-offset-1 dark:ring-offset-gray-800" + : ""; + + return ( + { e.stopPropagation(); onClick(tag); } : undefined} + > + #{tag} + {onRemove && ( + + )} + + ); +}; + +export default TagBadge; \ No newline at end of file diff --git a/frontend/src/components/TagFilter.jsx b/frontend/src/components/TagFilter.jsx new file mode 100644 index 0000000..e0f7600 --- /dev/null +++ b/frontend/src/components/TagFilter.jsx @@ -0,0 +1,55 @@ +import { useState, useEffect } from "react"; +import api from "../lib/axios"; +import TagBadge from "./TagBadge"; +import { TagsIcon, XIcon } from "lucide-react"; + +const TagFilter = ({ selectedTag, onSelectTag }) => { + const [tags, setTags] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchTags = async () => { + try { + const res = await api.get("/notes/tags"); + setTags(res.data); + } catch (error) { + console.log("Error fetching tags:", error); + } finally { + setLoading(false); + } + }; + fetchTags(); + }, []); + + if (loading) return null; + + if (tags.length === 0) return null; + + return ( +
+ + + {/* "All" button */} + {selectedTag && ( + + )} + + {tags.map((tag) => ( + onSelectTag(selectedTag === tag.name ? null : tag.name)} + /> + ))} +
+ ); +}; + +export default TagFilter; \ No newline at end of file diff --git a/frontend/src/pages/CreatePage.jsx b/frontend/src/pages/CreatePage.jsx index a9c6c62..3b188ad 100644 --- a/frontend/src/pages/CreatePage.jsx +++ b/frontend/src/pages/CreatePage.jsx @@ -1,17 +1,48 @@ - -import { ArrowLeftIcon } from "lucide-react"; +import { ArrowLeftIcon, PlusIcon } from "lucide-react"; import { useState } from "react"; import toast from "react-hot-toast"; import { Link, useNavigate } from "react-router-dom"; import api from "../lib/axios"; +import TagBadge from "../components/TagBadge"; const CreatePage = () => { const [title, setTitle] = useState(""); const [content, setContent] = useState(""); + const [tags, setTags] = useState([]); + const [tagInput, setTagInput] = useState(""); const [loading, setLoading] = useState(false); const navigate = useNavigate(); + const handleAddTag = () => { + const trimmed = tagInput.trim().toLowerCase(); + if (!trimmed) return; + if (tags.includes(trimmed)) { + toast.error("Tag already added"); + return; + } + if (tags.length >= 10) { + toast.error("Maximum 10 tags allowed"); + return; + } + setTags([...tags, trimmed]); + setTagInput(""); + }; + + const handleTagKeyDown = (e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddTag(); + } else if (e.key === "," || e.key === "Tab") { + e.preventDefault(); + handleAddTag(); + } + }; + + const handleRemoveTag = (tagToRemove) => { + setTags(tags.filter((t) => t !== tagToRemove)); + }; + const handleSubmit = async (e) => { e.preventDefault(); @@ -25,6 +56,7 @@ const CreatePage = () => { await api.post("/notes", { title, content, + tags, }); toast.success("Note created successfully!"); @@ -49,7 +81,7 @@ const CreatePage = () => {
{/* Back Button */} - + Back to Notes @@ -78,6 +110,46 @@ const CreatePage = () => { />
+ {/* Tags Input */} +
+ +
+ setTagInput(e.target.value)} + onKeyDown={handleTagKeyDown} + /> + +
+ {tags.length > 0 && ( +
+ {tags.map((tag) => ( + + ))} +
+ )} +

+ Press Enter or comma to add a tag. Maximum 10 tags. +

+
+ {/* Content Textarea */}