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/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..3b9efef --- /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 existTags = new URLSearchParams(window.location.search).get("tags"); + + // if the tag is already in the query string, remove it + let newTags = ""; + + if (existTags) { + const tagArr = existTags.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..298cfab 100644 --- a/src/controllers/lessons.js +++ b/src/controllers/lessons.js @@ -8,196 +8,230 @@ 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"]); + + if (!lesson?.tags) { + 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..fc3fb84 --- /dev/null +++ b/src/views/filteredLessons.pug @@ -0,0 +1,43 @@ +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 + 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..02f7d5a 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 + .tags.flex.flex-wrap.py-2 + each tag in lesson.tags + a.text-sm.mr-2(href=`/class/filter?tags=${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..5765d0b 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 + .tags.flex.flex-wrap.py-2 + each tag in lesson.tags + a.text-xs.mr-2(href=`/class/filter?tags=${tag}`) + = tag a.absolute.bottom-3(href=`/class/${lesson.permalink}`) More Info if lesson.videoId && !lesson.twitchVideo