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 */}