diff --git a/.env.example b/.env.example index 67057f9..f3502b0 100644 --- a/.env.example +++ b/.env.example @@ -25,5 +25,6 @@ GOOGLE_ID = x GOOGLE_SECRET = x # Only needed if you are using Github login +# For local development, use http://localhost:3000 as the homepage URL and http://localhost:3000/oauth/github/callback as the callback URL GITHUB_ID = x GITHUB_SECRET = x diff --git a/cypress.config.js b/cypress.config.js index 79c0575..922d140 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -1,12 +1,12 @@ import { defineConfig } from "cypress"; export default defineConfig({ - e2e: { - baseUrl: "http://0.0.0.0:3000", - video: false, - chromeWebSecurity: false, - setupNodeEvents(on, config) { - // implement node event listeners here - }, - }, + e2e: { + baseUrl: "http://0.0.0.0:3000", + video: false, + chromeWebSecurity: false, + setupNodeEvents(on, config) { + // implement node event listeners here + }, + }, }); diff --git a/cypress/e2e/filters.cy.js b/cypress/e2e/filters.cy.js new file mode 100644 index 0000000..e7fbb32 --- /dev/null +++ b/cypress/e2e/filters.cy.js @@ -0,0 +1,203 @@ +/// + +import { login } from "./utils.js"; + +const domain = Cypress.config().baseUrl; + +beforeEach(() => { + cy.visit(domain); +}); + +describe("All class page", () => { + describe("when clicking on a tag", () => { + beforeEach(() => { + cy.visit(`${domain}/class/all`); + cy.get(".tag-link").first().as("firstTag"); + cy.get("@firstTag").invoke("data", "id").as("dataId"); + cy.get("@firstTag").click({ force: true }); + }); + it("it redirects to the filter page", () => { + cy.url().should("include", "/class/filter"); + cy.get("h1").should("have.text", "Filter Results"); + }); + it("it adds the tag to the url", () => { + cy.get("@dataId").then((dataId) => { + cy.url().should("include", `tags=${encodeURIComponent(dataId)}`); + }); + }); + }); +}); + +describe("Individual class page", () => { + beforeEach(() => { + cy.visit(`${domain}/class/1`); + cy.get(".tag-link").first().as("firstTag"); + cy.get("@firstTag").invoke("data", "id").as("dataId"); + cy.get("@firstTag").click({ force: true }); + }); + describe("when clicking on a tag", () => { + it("it redirects to the filter page", () => { + cy.url().should("include", "/class/filter"); + cy.get("h1").should("have.text", "Filter Results"); + }); + it("it adds the tag to the url", () => { + cy.get("@dataId").then((dataId) => { + cy.url().should("include", `tags=${encodeURIComponent(dataId)}`); + }); + }); + }); +}); + +describe("filter page", () => { + describe("when not logged in", () => { + it("displays correctly", () => { + cy.visit(`${domain}/class/filter`); + cy.get("h1").should("have.text", "Filter Results"); + }); + + it("should load filterTags.js file", () => { + cy.visit(`${domain}/class/filter`); + cy.get("script[src='/js/filterTags.js']").should("exist"); + }); + }); + + describe("when logged in", () => { + beforeEach(() => { + login(); + cy.visit(`${domain}/class/filter`); + }); + it("displays correctly", () => { + cy.get("h1").should("have.text", "Filter Results"); + }); + + it("should load filterTags.js file", () => { + cy.get("script[src='/js/filterTags.js']").should("exist"); + }); + }); + + describe("when clicking on a filter", () => { + beforeEach(() => { + cy.visit(`${domain}/class/filter`); + cy.get(".filter-tag").first().as("firstFilter"); + cy.get("@firstFilter").invoke("data", "id").as("dataId"); + cy.get("@firstFilter") + .click({ force: true }, { log: true, timeout: 10000 }) + + }); + + it("it adds the filter to the url", { scrollBehavior: false }, () => { + cy.get("@dataId").then((dataId) => { + cy.url() + .should("include", `tags=${encodeURIComponent(dataId)}`, { + log: true, + timeout: 10000, + }) + + }); + }); + + describe("when clicking on a second filter", () => { + beforeEach(() => { + cy.get(".filter-tag").eq(1).as("secondFilter"); + cy.get("@secondFilter").invoke("data", "id").as("dataId"); + cy.get("@secondFilter").click({ force: true }); + }); + it("it should add to the url as a query param of tags", () => { + cy.get("@dataId").then((dataId) => { + + cy.url().should("include", `${encodeURIComponent(dataId)}`); + }); + }); + + it("it should have both filters in the url", () => { + cy.get("@dataId").then((dataId) => { + cy.url().should("include", `${encodeURIComponent(dataId)}`); + }); + cy.get("@firstFilter").invoke("data", "id").as("dataId"); + cy.get("@dataId").then((dataId) => { + cy.url().should("include", `${encodeURIComponent(dataId)}`); + }); + }); + }); + + describe("when clicking on a selected filter", () => { + beforeEach(() => { + cy.get("@firstFilter").click({ force: true }); + }); + it("it should remove the filter from the url", () => { + cy.get("@dataId").then((dataId) => { + cy.url().should("not.include", `${encodeURIComponent(dataId)}`); + }); + }); + + it("it should have the other filter in the url", () => { + cy.get(".filter-tag").eq(1).as("secondFilter"); + cy.get("@secondFilter").invoke("data", "id").as("dataId"); + cy.get("@secondFilter").click({ force: true }); + + cy.get("@dataId").then((dataId) => { + cy.url().should("include", `${encodeURIComponent(dataId)}`); + }); + }); + }); + + describe("when clicking on the clear button", () => { + beforeEach(() => { + cy.get("#filter-tag-clear").as("clearBtn"); + cy.get("@clearBtn").click({ force: true }); + }); + it("it should clear the url", () => { + cy.url().should("not.include", "tags="); + }); + }); + }); + + describe("when using the filters", () => { + describe("when a single filter is selected", () => { + beforeEach(() => { + cy.visit(`${domain}/class/filter`); + cy.get(".filter-tag").as("filterTags"); + + cy.get("@filterTags") + .filter((i, el) => !el.value.includes(" ")) + .then((el) => el[Math.floor(Math.random() * el.length)]) + .as("firstFilter"); + + cy.get("@firstFilter").invoke("data", "id").as("dataId"); + cy.get("@firstFilter").click({ force: true }); + + cy.get("@filterTags") + .filter((i, el) => !el.value.includes(" ")) + .filter((i, el) => + // text-white is the class that is added when a filter is selected + !Cypress.$(el).hasClass("text-white") + ) + .then((el) => el[Math.floor(Math.random() * el.length)]) + .as("secondFilter"); + + cy.get("@secondFilter").invoke("data", "id").as("secondDataId"); + }); + it("it should only show classes with that tag", () => { + cy.get(".lesson-card").each((card) => { + cy.get("@dataId").then((dataId) => { + cy.wrap(card).find(`[data-id="${dataId}"]`).should("exist"); + }); + }); + }); + describe("when multiple filters are selected", () => { + it("it should only show classes with both tags", () => { + cy.get("@secondFilter").click({ force: true }); + cy.get(".lesson-card").each((card) => { + cy.get("@dataId").then((dataId) => { + cy.get("@secondDataId").then((secondDataId) => { + cy.wrap(card) + .find(`[data-id="${dataId}"],[data-id="${secondDataId}"]`) + .should("exist"); + }); + }); + }); + }); + }); + }); + }); +}); diff --git a/src/assets/css/index.css b/src/assets/css/index.css index b68f0f4..39a1343 100644 --- a/src/assets/css/index.css +++ b/src/assets/css/index.css @@ -902,6 +902,7 @@ h3 { } #timestamps, + #tags, #hw-items, #pw-items { margin-top: 0.75rem; @@ -1243,6 +1244,10 @@ dd ol { top: 0px; } +.left-10 { + left: 2.5rem; +} + .top-12 { top: 3rem; } @@ -1259,34 +1264,6 @@ dd ol { right: 0.25rem; } -.top-10 { - top: 2.5rem; -} - -.top-8 { - top: 2rem; -} - -.-left-20 { - left: -5rem; -} - -.left-0 { - left: 0px; -} - -.left-10 { - left: 2.5rem; -} - -.left-5 { - left: 1.25rem; -} - -.left-9 { - left: 2.25rem; -} - .z-10 { z-index: 10; } @@ -1315,6 +1292,10 @@ dd ol { margin: 0.5rem; } +.m-1 { + margin: 0.25rem; +} + .m-0 { margin: 0px; } @@ -1365,6 +1346,14 @@ dd ol { margin-top: 0.75rem; } +.-ml-2 { + margin-left: -0.5rem; +} + +.-mt-2 { + margin-top: -0.5rem; +} + .mb-4 { margin-bottom: 1rem; } @@ -1373,6 +1362,10 @@ dd ol { margin-right: auto; } +.mr-1 { + margin-right: 0.25rem; +} + .mb-1 { margin-bottom: 0.25rem; } @@ -1441,8 +1434,8 @@ dd ol { height: auto; } -.min-h-screen { - min-height: 100vh; +.h-24 { + height: 6rem; } .w-full { @@ -1566,6 +1559,10 @@ dd ol { white-space: nowrap; } +.rounded { + border-radius: 0.25rem; +} + .border { border-width: 1px; } @@ -1597,6 +1594,11 @@ dd ol { border-color: rgb(231 229 228 / var(--tw-border-opacity)); } +.bg-white { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); +} + .bg-pink-800 { --tw-bg-opacity: 1; background-color: rgb(157 23 77 / var(--tw-bg-opacity)); @@ -1612,11 +1614,6 @@ dd ol { background-color: rgb(250 250 249 / var(--tw-bg-opacity)); } -.bg-white { - --tw-bg-opacity: 1; - background-color: rgb(255 255 255 / var(--tw-bg-opacity)); -} - .p-6 { padding: 1.5rem; } @@ -1625,6 +1622,21 @@ dd ol { padding: 0px; } +.px-1 { + padding-left: 0.25rem; + padding-right: 0.25rem; +} + +.px-2 { + padding-left: 0.5rem; + padding-right: 0.5rem; +} + +.py-1 { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} + .py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; @@ -1689,14 +1701,15 @@ dd ol { text-align: right; } -.font-main { - font-family: Poppins, sans-serif; -} - .font-logo { font-family: Orienta, serif; } +.text-xs { + font-size: 0.75rem; + line-height: 1rem; +} + .text-xl { font-size: 1.25rem; line-height: 1.75rem; @@ -1707,11 +1720,6 @@ dd ol { line-height: 1.5rem; } -.text-lg { - font-size: 1.125rem; - line-height: 1.75rem; -} - .text-sm { font-size: 0.875rem; line-height: 1.25rem; @@ -1722,11 +1730,6 @@ dd ol { line-height: 2rem; } -.text-xs { - font-size: 0.75rem; - line-height: 1rem; -} - .text-3xl { font-size: 1.875rem; line-height: 2.25rem; @@ -1766,14 +1769,14 @@ dd ol { color: rgb(239 68 68 / var(--tw-text-opacity)); } -.text-twilight-900 { +.text-gray-500 { --tw-text-opacity: 1; - color: rgb(21 63 81 / var(--tw-text-opacity)); + color: rgb(107 114 128 / var(--tw-text-opacity)); } -.text-stone-500 { +.text-twilight-900 { --tw-text-opacity: 1; - color: rgb(120 113 108 / var(--tw-text-opacity)); + color: rgb(21 63 81 / var(--tw-text-opacity)); } .text-white { @@ -1786,14 +1789,14 @@ dd ol { color: rgb(157 23 77 / var(--tw-text-opacity)); } -.text-twilight-800 { +.text-stone-500 { --tw-text-opacity: 1; - color: rgb(34 101 129 / var(--tw-text-opacity)); + color: rgb(120 113 108 / var(--tw-text-opacity)); } -.text-stone-800 { +.text-twilight-800 { --tw-text-opacity: 1; - color: rgb(41 37 36 / var(--tw-text-opacity)); + color: rgb(34 101 129 / var(--tw-text-opacity)); } .text-red-800 { @@ -1805,15 +1808,15 @@ dd ol { color: rgb(168 162 158 / 0.8); } -.text-pink-800\/80 { - color: rgb(157 23 77 / 0.8); -} - .text-red-700 { --tw-text-opacity: 1; color: rgb(185 28 28 / var(--tw-text-opacity)); } +.text-pink-800\/80 { + color: rgb(157 23 77 / 0.8); +} + .no-underline { text-decoration-line: none; } @@ -1842,6 +1845,18 @@ dd ol { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); } +.outline { + outline-style: solid; +} + +.outline-1 { + outline-width: 1px; +} + +.outline-pink-800 { + outline-color: #9d174d; +} + .filter { filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); } @@ -1883,10 +1898,6 @@ dd ol { } @media (min-width: 780px) { - .sm\:left-8 { - left: 2rem; - } - .sm\:left-12 { left: 3rem; } @@ -1955,14 +1966,6 @@ dd ol { } @media (min-width: 1024px) { - .lg\:left-14 { - left: 3.5rem; - } - - .lg\:left-16 { - left: 4rem; - } - .lg\:left-20 { left: 5rem; } diff --git a/src/assets/js/addLesson.js b/src/assets/js/addLesson.js index 1e223fb..5b8070c 100644 --- a/src/assets/js/addLesson.js +++ b/src/assets/js/addLesson.js @@ -11,7 +11,7 @@ addTsButton.addEventListener('click', addTimestamp); function addPermalink() { const permalink = title.value - + .split('') .map(c => c.toLowerCase()) .filter(c => { diff --git a/src/assets/js/filterTags.js b/src/assets/js/filterTags.js new file mode 100644 index 0000000..c957c86 --- /dev/null +++ b/src/assets/js/filterTags.js @@ -0,0 +1,34 @@ +const filterTags = document.querySelectorAll(".filter-tag"); +const clearFilterBtn = document.getElementById("filter-tag-clear"); + +// when clicking on a filter tag, add it to the url query string like /class/filter?tags=tag1,tag2 +filterTags.forEach((tag) => { + tag.addEventListener("click", (e) => { + const tag = e.target.value; + + //url encode the tag + const encodedTag = encodeURIComponent(tag); + + const tags = new URLSearchParams(window.location.search).get("tags"); + + // if the tag is already in the query string, remove it + let newTags = ""; + + if (tags) { + const tagArr = tags.split(","); + if (tagArr.includes(tag)) { + tagArr.splice(tagArr.indexOf(tag), 1); + } else { + tagArr.push(encodedTag); + } + newTags = tagArr.join(","); + } else { + newTags = tag; + } + window.location.href = `/class/filter?tags=${newTags}`; + }); +}); + +clearFilterBtn.addEventListener("click", (e) => { + window.location.href = `/class/filter`; +}); diff --git a/src/controllers/lessons.js b/src/controllers/lessons.js index f37ab42..fec5945 100644 --- a/src/controllers/lessons.js +++ b/src/controllers/lessons.js @@ -8,196 +8,228 @@ import Homework from "../models/Homework.js"; import { getHwProgress } from "./homework.js"; export const addEditLessonForm = async (req, res) => { - if (!req.isAuthenticated() || !req.user.admin) return res.redirect("/"); - const edit = !!req.params.id; - let lesson = null; - if (edit) { - lesson = await Lesson.findById(req.params.id).lean(); - lesson.classNo = lesson.classNo.join(","); - lesson.checkin = lesson.checkin.join(","); - lesson.slides = lesson.slides.join(","); - lesson.dates = lesson.dates - .map((date) => { - return date.toISOString().split("T")[0]; - }) - .join(","); - } - res.render("addLesson", { edit, lesson }); + if (!req.isAuthenticated() || !req.user.admin) return res.redirect("/"); + const edit = !!req.params.id; + let lesson = null; + if (edit) { + lesson = await Lesson.findById(req.params.id).lean(); + lesson.classNo = lesson.classNo.join(","); + lesson.checkin = lesson.checkin.join(","); + lesson.slides = lesson.slides.join(","); + lesson.dates = lesson.dates + .map((date) => { + return date.toISOString().split("T")[0]; + }) + .join(","); + } + res.render("addLesson", { edit, lesson }); }; export const addEditLesson = async (req, res) => { - if (!req.isAuthenticated() || !req.user.admin) return res.redirect("/"); - try { - let dates = []; - if (req.body.date) { - dates = req.body.date.split(",").map((date) => new Date(date)); - } - let slides = []; - const timestamps = []; - for (let i = 0; i < req.body.tsTime.length; i++) { - timestamps.push({ - time: Number(req.body.tsTime[i]), - title: req.body.tsTitle[i], - }); - } - const lessonData = { - videoId: req.body.videoId, - twitchVideo: !!req.body.twitch ? true : false, - title: req.body.videoTitle, - dates: dates, - permalink: req.body.permalink, - thumbnail: req.body.thumbnail, - classNo: req.body.number ? req.body.number.split(",") : [], - slides: req.body.slides ? req.body.slides.split(",") : [], - materials: req.body.materials, - checkin: req.body.checkin ? req.body.checkin.split(",") : [], - motivationLink: req.body.motivationLink, - motivationTitle: req.body.motivationTitle, - cohort: req.body.cohort, - note: req.body.note, - timestamps: timestamps, - }; - const lesson = await Lesson.findByIdAndUpdate( - req.params.id || mongoose.Types.ObjectId(), - lessonData, - { upsert: true, new: true } - ); - - // if this is a new class, update all users with current class = null so this is now their current class - if (!req.params.id) { - await User.updateMany( - { currentClass: null }, - { currentClass: lesson._id } - ); - } - req.session.flash = { - type: "success", - message: [`Class ${!!req.params.id ? "updated" : "added"}`], - }; - } catch (err) { - console.log(err); - req.session.flash = { - type: "error", - message: [`Class not ${!!req.params.id ? "updated" : "added"}`], - }; - } finally { - res.redirect("/class/add"); - } + if (!req.isAuthenticated() || !req.user.admin) return res.redirect("/"); + try { + let dates = []; + if (req.body.date) { + dates = req.body.date.split(",").map((date) => new Date(date)); + } + let slides = []; + const timestamps = []; + for (let i = 0; i < req.body.tsTime.length; i++) { + timestamps.push({ + time: Number(req.body.tsTime[i]), + title: req.body.tsTitle[i], + }); + } + const lessonData = { + videoId: req.body.videoId, + twitchVideo: !!req.body.twitch ? true : false, + title: req.body.videoTitle, + dates: dates, + permalink: req.body.permalink, + thumbnail: req.body.thumbnail, + classNo: req.body.number ? req.body.number.split(",") : [], + slides: req.body.slides ? req.body.slides.split(",") : [], + materials: req.body.materials, + checkin: req.body.checkin ? req.body.checkin.split(",") : [], + motivationLink: req.body.motivationLink, + motivationTitle: req.body.motivationTitle, + cohort: req.body.cohort, + note: req.body.note, + tags: req.body.tags + ? req.body.tags + .split(",") + .map((tag) => tag.trim().toLowerCase()) + // add space after comma + .map((tag) => tag.replace(/,/g, ", ")) + : [], + timestamps: timestamps, + }; + const lesson = await Lesson.findByIdAndUpdate( + req.params.id || mongoose.Types.ObjectId(), + lessonData, + { upsert: true, new: true } + ); + + // if this is a new class, update all users with current class = null so this is now their current class + if (!req.params.id) { + await User.updateMany( + { currentClass: null }, + { currentClass: lesson._id } + ); + } + req.session.flash = { + type: "success", + message: [`Class ${!!req.params.id ? "updated" : "added"}`], + }; + } catch (err) { + console.log(err); + req.session.flash = { + type: "error", + message: [`Class not ${!!req.params.id ? "updated" : "added"}`], + }; + } finally { + res.redirect(`/class/edit/${req.params.id}`); + } }; export const getAllLessonsProgress = async (userId, lessons) => { - const lessonProgress = await LessonProgress.find({ user: userId }).lean(); - const lessonsObj = {}; - lessonProgress.forEach((p) => { - lessonsObj[p.lesson] = { - watched: p.watched, - checkedIn: p.checkedIn, - }; - }); - lessons.forEach((lesson) => { - const prog = lessonsObj[lesson._id]; - lesson.watched = prog ? !!prog.watched : false; - lesson.checkedIn = prog ? !!prog.checkedIn : false; - }); - return lessons; + const lessonProgress = await LessonProgress.find({ user: userId }).lean(); + const lessonsObj = {}; + lessonProgress.forEach((p) => { + lessonsObj[p.lesson] = { + watched: p.watched, + checkedIn: p.checkedIn, + }; + }); + lessons.forEach((lesson) => { + const prog = lessonsObj[lesson._id]; + lesson.watched = prog ? !!prog.watched : false; + lesson.checkedIn = prog ? !!prog.checkedIn : false; + }); + return lessons; }; export const allLessons = async (req, res) => { - let lessons = await Lesson.find().lean().sort({ _id: 1 }); - if (req.isAuthenticated()) { - lessons = await getAllLessonsProgress(req.user.id, lessons); - } - res.render("allLessons", { lessons }); + let lessons = await Lesson.find().lean().sort({ _id: 1 }); + if (req.isAuthenticated()) { + lessons = await getAllLessonsProgress(req.user.id, lessons); + } + res.render("allLessons", { lessons }); }; export const getLessonProgress = async (userId, lesson) => { - const progress = await LessonProgress.findOne({ - user: userId, - lesson: lesson._id, - }); - lesson.watched = progress ? progress.watched : false; - lesson.checkedIn = progress ? progress.checkedIn : false; - return lesson; + const progress = await LessonProgress.findOne({ + user: userId, + lesson: lesson._id, + }); + lesson.watched = progress ? progress.watched : false; + lesson.checkedIn = progress ? progress.checkedIn : false; + return lesson; }; export const showLesson = async (req, res) => { - try { - let lesson = await Lesson.findOne({ - permalink: req.params.permalink, - }).lean(); - let next = await Lesson.find({ _id: { $gt: lesson._id } }) - .sort({ _id: 1 }) - .limit(1); - next = next.length ? next[0].permalink : null; - let prev = await Lesson.find({ _id: { $lt: lesson._id } }) - .sort({ _id: -1 }) - .limit(1); - prev = prev.length ? prev[0].permalink : null; - let assigned = await Homework.find({ classNo: { $in: lesson.classNo } }) - .lean() - .sort({ _id: 1 }) - .populate(["items", "extras"]); - let due = await Homework.find({ dueNo: { $in: lesson.classNo } }) - .lean() - .sort({ _id: 1 }) - .populate(["items", "extras"]); - - if (req.isAuthenticated()) { - lesson = await getLessonProgress(req.user.id, lesson); - if (assigned.length) - assigned = await getHwProgress(req.user.id, assigned); - if (due.length) due = await getHwProgress(req.user.id, due); - } - res.render("lesson", { lesson, next, prev, assigned, due }); - } catch (err) { - console.log(err); - res.redirect("/class/all"); - } + try { + let lesson = await Lesson.findOne({ + permalink: req.params.permalink, + }).lean(); + let next = await Lesson.find({ _id: { $gt: lesson._id } }) + .sort({ _id: 1 }) + .limit(1); + next = next.length ? next[0].permalink : null; + let prev = await Lesson.find({ _id: { $lt: lesson._id } }) + .sort({ _id: -1 }) + .limit(1); + prev = prev.length ? prev[0].permalink : null; + let assigned = await Homework.find({ classNo: { $in: lesson.classNo } }) + .lean() + .sort({ _id: 1 }) + .populate(["items", "extras"]); + let due = await Homework.find({ dueNo: { $in: lesson.classNo } }) + .lean() + .sort({ _id: 1 }) + .populate(["items", "extras"]); + + lesson?.tags ? lesson.tags.sort() : (lesson.tags = []); + + if (req.isAuthenticated()) { + lesson = await getLessonProgress(req.user.id, lesson); + if (assigned.length) + assigned = await getHwProgress(req.user.id, assigned); + if (due.length) due = await getHwProgress(req.user.id, due); + } + res.render("lesson", { lesson, next, prev, assigned, due }); + } catch (err) { + console.log(err); + res.redirect("/class/all"); + } +}; + +export const filterByTags = async (req, res) => { + try { + const tags = req.query.tags?.split(",").map((tag) => tag.trim()); + let lessons = await Lesson.find(tags ? { tags: { $in: tags } } : {}) + .sort({ _id: 1 }) + .lean(); + + const allTags = await Lesson.aggregate([ + { $unwind: "$tags" }, + { $group: { _id: "$tags", count: { $sum: 1 } } }, + { $sort: { _id: 1 } }, + ]); + + if (req.isAuthenticated()) { + lessons = await getAllLessonsProgress(req.user.id, lessons); + } + res.render("filteredLessons", { lessons, allTags, selectedTags: tags }); + } catch (err) { + console.log(err); + res.redirect("/class/all"); + } }; export const deleteLesson = async (req, res) => { - if (!req.user?.admin) return res.redirect("/"); - try { - const lessonId = req.params.id; - let next = await Lesson.find({ _id: { $gt: lessonId } }) - .sort({ _id: 1 }) - .limit(1); - const nextId = next[0] ? next[0]._id : null; - const res = await User.updateMany( - { currentClass: lessonId }, - { currentClass: nextId } - ); - await LessonProgress.deleteMany({ lesson: lessonId }); - await Lesson.deleteOne({ _id: lessonId }); - } catch (err) { - console.log(err); - } finally { - res.redirect("/class/all"); - } + if (!req.user?.admin) return res.redirect("/"); + try { + const lessonId = req.params.id; + let next = await Lesson.find({ _id: { $gt: lessonId } }) + .sort({ _id: 1 }) + .limit(1); + const nextId = next[0] ? next[0]._id : null; + const res = await User.updateMany( + { currentClass: lessonId }, + { currentClass: nextId } + ); + await LessonProgress.deleteMany({ lesson: lessonId }); + await Lesson.deleteOne({ _id: lessonId }); + } catch (err) { + console.log(err); + } finally { + res.redirect("/class/all"); + } }; export const toggleWatched = async (req, res) => { - if (!req.user) return res.status(401).json({ msg: "not logged in" }); - try { - const userId = req.user.id; - const lessonId = req.params.id; - await LessonProgress.toggleWatched(lessonId, userId); - res.json({ msg: "toggled lesson watched" }); - } catch (err) { - console.log(err); - res.status(err.status || 500).json({ error: err.message }); - } + if (!req.user) return res.status(401).json({ msg: "not logged in" }); + try { + const userId = req.user.id; + const lessonId = req.params.id; + await LessonProgress.toggleWatched(lessonId, userId); + res.json({ msg: "toggled lesson watched" }); + } catch (err) { + console.log(err); + res.status(err.status || 500).json({ error: err.message }); + } }; export const toggleCheckedIn = async (req, res) => { - if (!req.user) return res.status(401).json({ msg: "not logged in" }); - try { - const userId = req.user.id; - const lessonId = req.params.id; - await LessonProgress.toggleCheckedIn(lessonId, req.user.id); - res.json({ msg: "toggled lesson checked in" }); - } catch (err) { - console.log(err); - res.status(err.status || 500).json({ error: err.message }); - } + if (!req.user) return res.status(401).json({ msg: "not logged in" }); + try { + const userId = req.user.id; + const lessonId = req.params.id; + await LessonProgress.toggleCheckedIn(lessonId, req.user.id); + res.json({ msg: "toggled lesson checked in" }); + } catch (err) { + console.log(err); + res.status(err.status || 500).json({ error: err.message }); + } }; diff --git a/src/models/Lesson.js b/src/models/Lesson.js index 39edb50..134944e 100644 --- a/src/models/Lesson.js +++ b/src/models/Lesson.js @@ -49,6 +49,9 @@ const lessonSchema = new Schema({ motivationTitle: { type: String }, + tags: { + type: [String] + }, timestamps: [TimestampSchema], cohort: Number, note: String diff --git a/src/routes/lessonRouter.js b/src/routes/lessonRouter.js index d589e64..9ea9de9 100644 --- a/src/routes/lessonRouter.js +++ b/src/routes/lessonRouter.js @@ -11,6 +11,7 @@ router.post("/add", lessons.addEditLesson); router.post("/edit/:id", lessons.addEditLesson); router.get("/all", lessons.allLessons); +router.get("/filter", lessons.filterByTags); router.get("/:permalink", lessons.showLesson); router.put("/watched/:id", lessons.toggleWatched); diff --git a/src/tailwind.css b/src/tailwind.css index 72f6497..4c51e49 100644 --- a/src/tailwind.css +++ b/src/tailwind.css @@ -85,6 +85,7 @@ @apply text-right; } #timestamps, + #tags, #hw-items, #pw-items { @apply mt-3 w-full border px-5 py-3; diff --git a/src/views/addLesson.pug b/src/views/addLesson.pug index 8f96ba1..50ad590 100644 --- a/src/views/addLesson.pug +++ b/src/views/addLesson.pug @@ -53,6 +53,13 @@ block content input#ts-time-0(type="number", name="tsTime") label(for="ts-title-0") Title input#ts-title-0(type="text", name="tsTitle") + #tags.col-span-4 + h2.col-span-4.mb-3 Tags + label(for="tags" class="absolute -ml-2 -mt-2 px-1 text-xs bg-white text-gray-500 rounded") Tags (separated by commas. e.g. html, css, job hunt) + textarea.col-span-4(type="text", name="tags" + placeholder="html, css, javascript, js, career help,ejs" + class="h-24 w-full" + ) #{edit? lesson.tags : ""} .col-span-4.mt-3.text-center button(class="btn primary max-w-1/3 mx-auto" type="submit") #{action} Class diff --git a/src/views/filteredLessons.pug b/src/views/filteredLessons.pug new file mode 100644 index 0000000..2bec73e --- /dev/null +++ b/src/views/filteredLessons.pug @@ -0,0 +1,44 @@ +extends layouts/default.pug +include ./mixins/lessonCard.pug + +block variables + - title = "Classes" + - active = "Classes" + +block content + if !loggedIn + include ./partials/flash-nologin.pug + + h1.mb-4 Filter Results + + + #tags + h3.mt-2.mb-2 Filters Available: + .flex.flex-wrap.mb-4 + each tag in allTags || [] + button.text-xs.m-1( + type="button" + value=tag._id + data-id=tag._id + class=`filter-tag px-2 py-1 rounded ${ + selectedTags && selectedTags.includes(tag._id) ? "outline outline-1 outline-pink-800 bg-pink-800 text-white" : "outline outline-1 text-pink-800" + }`) #{tag._id} (#{tag.count}) + + button.mr-1.text-xs.mb-1( + type="button" + id="filter-tag-clear" + class=`px-2 py-1 rounded bg-pink-800 text-white`) Clear Filters + + h3.mt-2.mb-4 Results: #{lessons.length} + + #lessons.grid.gap-y-12.gap-x-16(class="sm:gap-y-24 grid-cols-[repeat(auto-fill,_minmax(285px,_1fr))]") + each lesson in lessons + +lessonCard(lesson) + + include ./partials/to-top.pug + +append scripts + script(src="/js/filterTags.js") + if loggedIn + script(src="/js/lessonProgress.js") + script(src="/js/lessonDone.js") diff --git a/src/views/lesson.pug b/src/views/lesson.pug index 25bb9bc..1c4e30e 100644 --- a/src/views/lesson.pug +++ b/src/views/lesson.pug @@ -68,6 +68,15 @@ block content h2 Motivation a(href=lesson.motivationLink) #{lesson.motivationTitle} + if lesson.tags && lesson.tags.length + h2 Tags + .flex.flex-wrap.py-2 + each tag in lesson.tags + a.tag-link.text-sm.mr-2(href=`/class/filter?tags=${tag}` data-id=tag) + = tag + + + if lesson.videoId && !lesson.twitchVideo h3.mt-8 Feed the algorithm and spread the word! p #[a(href=`https://www.youtube.com/watch?v=${lesson.videoId}` target="_blank") Like, comment and subscribe on Youtube] @@ -84,6 +93,7 @@ block content .mb-6(class="shadow-[0_2px_10px_0_rgba(0,0,0,0.1)]") +homework(homework) + append scripts if loggedIn script(src="/js/hwDone.js") diff --git a/src/views/mixins/lessonCard.pug b/src/views/mixins/lessonCard.pug index ce065f7..5390dd8 100644 --- a/src/views/mixins/lessonCard.pug +++ b/src/views/mixins/lessonCard.pug @@ -30,6 +30,12 @@ mixin lessonCard(lesson) a(href=`/class/${lesson.permalink}` class="hover:no-underline") img.w-full.mb-2(src=lesson.thumbnail class="group-hover:shadow-[0_0_10px_rgba(0,_0,_0,_0.2)]") h2(class="group-hover:text-pink-900") #{lesson.title} + + if lesson.tags && lesson.tags.length + .tag-links.flex.flex-wrap.py-2 + each tag in lesson.tags + a.tag-link.text-xs.mr-2(href=`/class/filter?tags=${tag}` data-id=tag) + = tag a.absolute.bottom-3(href=`/class/${lesson.permalink}`) More Info if lesson.videoId && !lesson.twitchVideo