diff --git a/client/src/pages/Flashcards.jsx b/client/src/pages/Flashcards.jsx
index 4a067ca9..b46e8ced 100644
--- a/client/src/pages/Flashcards.jsx
+++ b/client/src/pages/Flashcards.jsx
@@ -1,10 +1,372 @@
-import { useState, useEffect } from "react";
-import Modal from "../components/Modal";
+import React, { useState, useEffect } from "react";
+// Import icons from lucide-react, which is available
+import { Edit, Trash2, X } from "lucide-react";
+// Import the global theme hook (this will work in your local project)
import { useTheme } from "../context/ThemeContext";
-import { fetchFlashcards, addFlashcard } from "../utils/api";
-export default function Flashcards() {
- const { theme } = useTheme();
+// --- API Functions (from ../utils/api) ---
+// We place all API logic directly in this file.
+const API_URL = "http://localhost:5050/api/flashcards";
+
+// CREATE
+const addFlashcard = async (flashcard) => {
+ const res = await fetch(API_URL, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(flashcard),
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({})); // Try to parse error
+ throw new Error(err.message || "Failed to add flashcard");
+ }
+ return await res.json();
+};
+
+// READ (All)
+const fetchFlashcards = async () => {
+ const res = await fetch(API_URL);
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error(err.message || "Failed to fetch flashcards");
+ }
+ return await res.json();
+};
+
+// UPDATE
+const updateFlashcard = async (id, flashcard) => {
+ const res = await fetch(`${API_URL}/${id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(flashcard),
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error(err.message || "Failed to update flashcard");
+ }
+ return await res.json();
+};
+
+// DELETE
+const deleteFlashcard = async (id) => {
+ const res = await fetch(`${API_URL}/${id}`, {
+ method: "DELETE",
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error(err.message || "Failed to delete flashcard");
+ }
+ return await res.json();
+};
+// --- End API Functions ---
+
+// --- Base Modal Component (from ../components/Modal) ---
+// A generic, self-contained Modal component.
+function Modal({ open, onClose, title, children }) {
+ if (!open) return null;
+
+ return (
+
+
e.stopPropagation()} // Prevent click from closing modal
+ className="relative m-4 w-full max-w-lg rounded-lg bg-white p-6 shadow-2xl dark:bg-gray-800"
+ >
+ {/* Header */}
+
+
+ {title}
+
+
+
+ {/* Body */}
+
{children}
+
+
+ );
+}
+// --- End Modal Component ---
+
+// --- FlashcardModal Component (from ../components/FlashcardModal) ---
+function FlashcardModal({
+ open,
+ onClose,
+ mode = "add", // 'add' or 'edit'
+ cardToEdit, // The card data to pre-fill the form
+ onCardAdded, // Callback for when a new card is created
+ onCardUpdated, // Callback for when a card is updated
+ topics = [], // New prop to receive the topics list
+}) {
+ const [form, setForm] = useState({ term: "", definition: "", topic: "" });
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [topicChoice, setTopicChoice] = useState("select"); // State for dropdown
+
+ useEffect(() => {
+ if (open) {
+ if (mode === "edit" && cardToEdit) {
+ const cardTopic = cardToEdit.topic || "";
+ // Check if the card's topic is in the predefined list
+ const isCustomTopic = !topics.includes(cardTopic) && cardTopic !== "";
+
+ setForm({
+ term: cardToEdit.term || "",
+ definition: cardToEdit.definition || "",
+ topic: cardTopic,
+ });
+ // Set dropdown state accordingly
+ setTopicChoice(isCustomTopic ? "custom" : "select");
+ } else {
+ // Reset for 'add' mode
+ setForm({ term: "", definition: "", topic: "" });
+ setTopicChoice("select");
+ }
+ setError(null);
+ }
+ }, [open, mode, cardToEdit, topics]); // Added 'topics' to dependency array
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (isLoading) return;
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const cardData = {
+ term: form.term.trim(),
+ definition: form.definition.trim(),
+ topic:
+ form.topic.trim().charAt(0).toUpperCase() +
+ form.topic.trim().slice(1),
+ };
+
+ if (!cardData.term || !cardData.definition || !cardData.topic) {
+ setError("All fields are required.");
+ setIsLoading(false);
+ return;
+ }
+
+ if (mode === "edit") {
+ const updatedCard = await updateFlashcard(cardToEdit._id, cardData);
+ if (onCardUpdated) onCardUpdated(updatedCard);
+ } else {
+ const newCard = await addFlashcard(cardData);
+ if (onCardAdded) onCardAdded(newCard);
+ }
+ onClose();
+ } catch (err) {
+ console.error(
+ mode === "edit"
+ ? "Error updating flashcard:"
+ : "Error adding flashcard:",
+ err
+ );
+ setError(err.message || "An unexpected error occurred.");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const title = mode === "edit" ? "Edit Flashcard" : "Add Flashcard";
+ const buttonText = mode === "edit" ? "Save Changes" : "Add Card";
+
+ return (
+
+
+
+ );
+}
+// --- End FlashcardModal Component ---
+
+// --- Flashcard Component (from ../components/Flashcard) ---
+function Flashcard({ card, onReview, onEdit, onDelete }) {
+ const [flipped, setFlipped] = useState(false);
+ const [hasReviewed, setHasReviewed] = useState(false);
+
+ const { _id, term, definition } = card;
+
+ const handleFlip = () => {
+ const nextFlipped = !flipped;
+ setFlipped(nextFlipped);
+ if (nextFlipped && !hasReviewed) {
+ setHasReviewed(true);
+ if (typeof onReview === "function") onReview();
+ }
+ };
+
+ const handleEditClick = (e) => {
+ e.stopPropagation();
+ onEdit(card);
+ };
+
+ const handleDeleteClick = (e) => {
+ e.stopPropagation();
+ onDelete(_id);
+ };
+
+ return (
+
+
+ {/* Front */}
+
+
+ {term}
+
+
+ (Click to flip)
+
+
+
+
+
+
+ {/* Back */}
+
+
+ Definition
+
+
+ {definition}
+
+
+
+
+
+
+
+
+ );
+}
+// --- End Flashcard Component ---
+
+// --- Main Flashcards Component ---
+// This is now the default export and will be rendered by your app's router
+export default function FlashcardsPage() {
+ const { theme } = useTheme(); // This will now use the imported global hook
const isDark = theme === "dark";
const defaultTopics = [
@@ -33,28 +395,26 @@ export default function Flashcards() {
const [selectedTopic, setSelectedTopic] = useState("All");
const [reviewCount, setReviewCount] = useState(0);
const [currentIndex, setCurrentIndex] = useState(0);
- const [flipped, setFlipped] = useState(false); // single-card flip state
- const [isModalOpen, setModalOpen] = useState(false);
- const [form, setForm] = useState({ term: "", definition: "", topic: "" });
- const [topicChoice, setTopicChoice] = useState("select");
const [dropdownOpen, setDropdownOpen] = useState(false);
const [reviewedCards, setReviewedCards] = useState(new Set());
+ const [isAddModalOpen, setAddModalOpen] = useState(false);
+ const [isEditModalOpen, setEditModalOpen] = useState(false);
+ const [currentCardToEdit, setCurrentCardToEdit] = useState(null);
- // Filtered flashcards based on selected topic
const filteredFlashcards =
selectedTopic === "All"
? flashcards
: flashcards.filter((f) => f.topic === selectedTopic);
- // Load flashcards from backend
const loadFlashcards = async () => {
try {
const data = await fetchFlashcards();
setFlashcards(data || []);
- // reset currentIndex safely
+ // Dynamically populate topics from fetched cards
+ const fetchedTopics = [...new Set(data.map((c) => c.topic))];
+ setTopics((prev) => [...new Set([...prev, ...fetchedTopics])].sort());
setCurrentIndex(0);
- setFlipped(false);
} catch (err) {
console.error("Error loading flashcards:", err);
}
@@ -62,97 +422,74 @@ export default function Flashcards() {
useEffect(() => {
loadFlashcards();
- // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
- // Handle adding a new flashcard
- const handleAddCard = async (e) => {
- e.preventDefault();
- const topicFinal = (form.topic || "").trim();
- if (!topicFinal || !form.term.trim() || !form.definition.trim()) return;
-
- const newCard = {
- term: form.term.trim(),
- definition: form.definition.trim(),
- topic: topicFinal.charAt(0).toUpperCase() + topicFinal.slice(1),
- };
+ const handleCardAdded = (newCard) => {
+ setFlashcards((prev) => [newCard, ...prev]);
+ if (!topics.includes(newCard.topic)) {
+ setTopics((prev) => [...prev, newCard.topic].sort());
+ }
+ setSelectedTopic("All");
+ setCurrentIndex(0);
+ };
- try {
- // Save to backend
- const savedCard = await addFlashcard(newCard);
+ const handleCardUpdated = (updatedCard) => {
+ setFlashcards((prev) =>
+ prev.map((c) => (c._id === updatedCard._id ? updatedCard : c))
+ );
+ if (!topics.includes(updatedCard.topic)) {
+ setTopics((prev) => [...prev, updatedCard.topic].sort());
+ }
+ };
- // add saved card to local state (show newest first)
- setFlashcards((prev) => [savedCard, ...prev]);
+ const handleOpenEditModal = (card) => {
+ setCurrentCardToEdit(card);
+ setEditModalOpen(true);
+ };
- // Update topics if new
- if (!topics.includes(savedCard.topic)) {
- setTopics((prev) => [...prev, savedCard.topic]);
+ const handleDeleteCard = async (id) => {
+ // NOTE: You should replace this with a custom, non-blocking modal
+ if (window.confirm("Are you sure you want to delete this card?")) {
+ try {
+ await deleteFlashcard(id);
+ setFlashcards((prev) => prev.filter((c) => c._id !== id));
+ setCurrentIndex(0);
+ } catch (err) {
+ console.error("Error deleting card:", err);
}
+ }
+ };
- // Reset form & close modal
- setForm({ term: "", definition: "", topic: "" });
- setTopicChoice("select");
- setModalOpen(false);
- // Ensure the new card is visible (set topic filter to All or to its topic)
- setSelectedTopic("All");
- setCurrentIndex(0);
- setFlipped(false);
- } catch (err) {
- console.error("Error adding flashcard:", err);
+ const handleReview = () => {
+ const currentCard = filteredFlashcards[currentIndex];
+ if (currentCard && !reviewedCards.has(currentCard._id)) {
+ setReviewCount((c) => c + 1);
+ setReviewedCards((prevSet) => new Set(prevSet).add(currentCard._id));
}
};
- // Flashcard navigation
const nextCard = () => {
if (filteredFlashcards.length > 0) {
- setFlipped(false);
setCurrentIndex((i) => (i + 1) % filteredFlashcards.length);
}
};
const prevCard = () => {
if (filteredFlashcards.length > 0) {
- setFlipped(false);
setCurrentIndex(
(i) => (i - 1 + filteredFlashcards.length) % filteredFlashcards.length
);
}
};
- // Toggle flip
- const toggleFlip = () => {
- setFlipped((prev) => {
- const newFlip = !prev;
-
- // If flipping to the back and the card wasn't reviewed before → increment count
- if (newFlip) {
- const currentCard = filteredFlashcards[currentIndex];
- if (currentCard && !reviewedCards.has(currentCard._id)) {
- setReviewCount((c) => c + 1);
- setReviewedCards((prevSet) => new Set(prevSet).add(currentCard._id));
- }
- }
-
- return newFlip;
- });
-};
-
-
-
- const handleReview = () => setReviewCount((c) => c + 1);
const resetFilter = () => {
setSelectedTopic("All");
setReviewCount(0);
setCurrentIndex(0);
- setFlipped(false);
+ setReviewedCards(new Set());
};
- const handleOpenModal = () => {
- setForm({ term: "", definition: "", topic: "" });
- setTopicChoice("select");
- setModalOpen(true);
- };
- const handleCloseModal = () => setModalOpen(false);
- // Safety: current card or placeholder
+ const handleOpenAddModal = () => setAddModalOpen(true);
+
const hasCards = filteredFlashcards.length > 0;
const currentCard = hasCards ? filteredFlashcards[currentIndex] : null;
@@ -167,9 +504,13 @@ export default function Flashcards() {
className="absolute top-0 left-0 w-full h-full -z-10"
style={{
backgroundImage: `
- linear-gradient(to right, rgba(168, 85, 247, 0.08) 1px, transparent 1.5px),
- linear-gradient(to bottom, rgba(168, 85, 247, 0.08) 1px, transparent 1.5px)
- `,
+ linear-gradient(to right, ${
+ isDark ? "rgba(255, 255, 255, 0.05)" : "rgba(168, 85, 247, 0.08)"
+ } 1px, transparent 1.5px),
+ linear-gradient(to bottom, ${
+ isDark ? "rgba(255, 255, 255, 0.05)" : "rgba(168, 85, 247, 0.08)"
+ } 1px, transparent 1.5px)
+ `,
backgroundSize: "30px 30px",
}}
>
@@ -211,7 +552,6 @@ export default function Flashcards() {
setSelectedTopic("All");
setDropdownOpen(false);
setCurrentIndex(0);
- setFlipped(false);
}}
className="block w-full text-left px-4 py-2 hover:bg-primary-100 dark:hover:bg-gray-800 rounded-t-lg"
>
@@ -224,7 +564,6 @@ export default function Flashcards() {
setSelectedTopic(t);
setDropdownOpen(false);
setCurrentIndex(0);
- setFlipped(false);
}}
className="block w-full text-left px-4 py-2 hover:bg-primary-100 dark:hover:bg-gray-800"
>
@@ -241,13 +580,13 @@ export default function Flashcards() {
@@ -255,8 +594,8 @@ export default function Flashcards() {
@@ -266,54 +605,30 @@ export default function Flashcards() {
- {/* Flashcard */}
+ {/* Flashcard Display Area */}
{hasCards ? (
<>
-
-
{
- if (e.key === "Enter" || e.key === " ") toggleFlip();
- }}
- >
-
-
-
{currentCard.term}
-
(Click to flip)
-
-
-
-
Definition
-
{currentCard.definition}
-
-
-
+
+
{/* Navigation */}
@@ -324,83 +639,30 @@ export default function Flashcards() {
>
) : (
-
No flashcards for this topic yet.
+
+ No flashcards for this topic yet.
+
)}
- {/* Modal */}
-
-
-
+ {/* --- Modal for ADDING a card --- */}
+ setAddModalOpen(false)}
+ mode="add"
+ onCardAdded={handleCardAdded}
+ topics={topics}
+ />
+
+ {/* --- Modal for EDITING a card --- */}
+ setEditModalOpen(false)}
+ mode="edit"
+ cardToEdit={currentCardToEdit}
+ onCardUpdated={handleCardUpdated}
+ topics={topics}
+ />
{/* Styles */}
);
}
+
diff --git a/server/controllers/flashcardController.js b/server/controllers/flashcardController.js
index 37539483..382a09f0 100644
--- a/server/controllers/flashcardController.js
+++ b/server/controllers/flashcardController.js
@@ -1,14 +1,20 @@
import Flashcard from "../models/Flashcard.js";
+import { validationResult } from "express-validator";
+import mongoose from "mongoose";
-// Add a new flashcard
+// Helper to check for valid MongoDB ObjectId
+const isValidId = (id) => mongoose.Types.ObjectId.isValid(id);
+
+// CREATE a new flashcard
export const addFlashcard = async (req, res) => {
+ // Check for validation errors from the router
+ const errors = validationResult(req);
+ if (!errors.isEmpty()) {
+ return res.status(400).json({ errors: errors.array() });
+ }
+
try {
const { term, definition, topic } = req.body;
-
- if (!term || !definition || !topic) {
- return res.status(400).json({ message: "All fields are required" });
- }
-
const flashcard = await Flashcard.create({ term, definition, topic });
return res.status(201).json(flashcard);
} catch (error) {
@@ -20,7 +26,7 @@ export const addFlashcard = async (req, res) => {
}
};
-// Get all flashcards
+// READ all flashcards
export const getFlashcards = async (req, res) => {
try {
const flashcards = await Flashcard.find().sort({ createdAt: -1 });
@@ -33,3 +39,76 @@ export const getFlashcards = async (req, res) => {
});
}
};
+
+// READ a single flashcard by ID
+export const getFlashcardById = async (req, res) => {
+ try {
+ const { id } = req.params;
+ if (!isValidId(id)) {
+ return res.status(400).json({ message: "Invalid flashcard ID" });
+ }
+
+ const flashcard = await Flashcard.findById(id);
+ if (!flashcard) {
+ return res.status(404).json({ message: "Flashcard not found" });
+ }
+ return res.status(200).json(flashcard);
+ } catch (error) {
+ console.error("Error fetching flashcard by ID:", error);
+ return res.status(500).json({
+ message: "Failed to fetch flashcard",
+ error: error.message,
+ });
+ }
+};
+
+// UPDATE a flashcard by ID
+export const updateFlashcard = async (req, res) => {
+ try {
+ const { id } = req.params;
+ if (!isValidId(id)) {
+ return res.status(400).json({ message: "Invalid flashcard ID" });
+ }
+
+ const updatedFlashcard = await Flashcard.findByIdAndUpdate(
+ id,
+ req.body,
+ { new: true, runValidators: true } // {new: true} returns the updated doc
+ );
+
+ if (!updatedFlashcard) {
+ return res.status(404).json({ message: "Flashcard not found" });
+ }
+ return res.status(200).json(updatedFlashcard);
+ } catch (error) {
+ console.error("Error updating flashcard:", error);
+ return res.status(500).json({
+ message: "Failed to update flashcard",
+ error: error.message,
+ });
+ }
+};
+
+// DELETE a flashcard by ID
+export const deleteFlashcard = async (req, res) => {
+ try {
+ const { id } = req.params;
+ if (!isValidId(id)) {
+ return res.status(400).json({ message: "Invalid flashcard ID" });
+ }
+
+ const deletedFlashcard = await Flashcard.findByIdAndDelete(id);
+
+ if (!deletedFlashcard) {
+ return res.status(404).json({ message: "Flashcard not found" });
+ }
+ return res.status(200).json({ message: "Flashcard deleted successfully" });
+ } catch (error) {
+ console.error("Error deleting flashcard:", error);
+ return res.status(500).json({
+ message: "Failed to delete flashcard",
+ error: error.message,
+ });
+ }
+};
+
diff --git a/server/package-lock.json b/server/package-lock.json
index d29affff..99da9bd1 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -16,6 +16,7 @@
"dotenv": "^16.6.1",
"express": "^4.19.2",
"express-session": "^1.18.2",
+ "express-validator": "^7.3.0",
"firebase": "^11.0.1",
"jsonwebtoken": "^9.0.2",
"mongoose": "^8.5.1",
@@ -1205,6 +1206,19 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
+ "node_modules/express-validator": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.0.tgz",
+ "integrity": "sha512-ujK2BX5JUun5NR4JuBo83YSXoDDIpoGz3QxgHTzQcHFevkKnwV1in4K7YNuuXQ1W3a2ObXB/P4OTnTZpUyGWiw==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash": "^4.17.21",
+ "validator": "~13.15.15"
+ },
+ "engines": {
+ "node": ">= 8.0.0"
+ }
+ },
"node_modules/faye-websocket": {
"version": "0.11.4",
"license": "Apache-2.0",
@@ -1623,6 +1637,12 @@
"node": ">=12.0.0"
}
},
+ "node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "license": "MIT"
+ },
"node_modules/lodash.camelcase": {
"version": "4.3.0",
"license": "MIT"
@@ -2574,6 +2594,15 @@
"node": ">= 0.4.0"
}
},
+ "node_modules/validator": {
+ "version": "13.15.20",
+ "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.20.tgz",
+ "integrity": "sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/vary": {
"version": "1.1.2",
"license": "MIT",
diff --git a/server/package.json b/server/package.json
index 13a8cab7..433776bc 100644
--- a/server/package.json
+++ b/server/package.json
@@ -20,6 +20,7 @@
"dotenv": "^16.6.1",
"express": "^4.19.2",
"express-session": "^1.18.2",
+ "express-validator": "^7.3.0",
"firebase": "^11.0.1",
"jsonwebtoken": "^9.0.2",
"mongoose": "^8.5.1",
diff --git a/server/routes/flashcardRoutes.js b/server/routes/flashcardRoutes.js
index 53f03ea8..4737bfdf 100644
--- a/server/routes/flashcardRoutes.js
+++ b/server/routes/flashcardRoutes.js
@@ -1,9 +1,35 @@
import express from "express";
-import { addFlashcard, getFlashcards } from "../controllers/flashcardController.js";
+import { body } from "express-validator";
+import {
+ addFlashcard,
+ getFlashcards,
+ getFlashcardById,
+ updateFlashcard,
+ deleteFlashcard,
+} from "../controllers/flashcardController.js";
const router = express.Router();
-router.post("/", addFlashcard);
-router.get("/", getFlashcards);
+// Define validation rules
+const addFlashcardRules = [
+ body("term")
+ .notEmpty()
+ .trim()
+ .escape()
+ .withMessage("Term cannot be empty."),
+ body("definition")
+ .notEmpty()
+ .trim()
+ .escape()
+ .withMessage("Definition cannot be empty."),
+ body("topic").optional().trim().escape(),
+];
+
+router.post("/", addFlashcardRules, addFlashcard); // CREATE
+router.get("/", getFlashcards); // READ (All)
+router.get("/:id", getFlashcardById); // READ (One)
+router.patch("/:id", updateFlashcard); // UPDATE
+router.delete("/:id", deleteFlashcard); // DELETE
export default router;
+