Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/assets/images/icons/panel-left.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
32 changes: 32 additions & 0 deletions app/assets/stylesheets/custom_styles/layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,40 @@
@apply max-w-7xl mx-auto py-10 px-4 sm:py-14 sm:px-6 lg:px-8;
}

html:has([data-controller~="lessons--sidebar-toggle"]) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the ~= intentional here or supposed to be =?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good eye! this is a future proofing thing. Having = here would work and be ok, but it could be brittle. The data-controller attribute can take multiple space-separated controllers like:
data-controller="lessons--sidebar-toggle toc pin").

If we were to add another controller to this element in the future it would stop matching with =, whereas ~= will keep on matching no matter how many controllers we have attached.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's fair 👍

scroll-padding-top: 3.5rem;
}

@utility highlight-white {
@layer utilities {
box-shadow: inset 0 1px 0 0 hsl(0deg 0% 100% / 5%);
}
}

@utility scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: var(--color-gray-300) transparent;
scrollbar-gutter: stable;

&::-webkit-scrollbar {
width: 6px;
height: 6px;
}

&::-webkit-scrollbar-track {
background: transparent;
}

&::-webkit-scrollbar-thumb {
background-color: var(--color-gray-300);
border-radius: 9999px;
}

.dark & {
scrollbar-color: var(--color-gray-700) transparent;
}

.dark &::-webkit-scrollbar-thumb {
background-color: var(--color-gray-700);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<%= inline_svg_tag(
'icons/checkmark-circle-solid.svg',
class: "sidebar-completion-icon-#{lesson.id} h-5 w-5 shrink-0 #{color_class}",
aria: true,
title: title,
data: { test_id: 'lesson-sidebar-completion' }
) %>
21 changes: 21 additions & 0 deletions app/components/lessons/sidebar/completion_icon_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
module Lessons
module Sidebar
class CompletionIconComponent < ApplicationComponent
def initialize(lesson:)
@lesson = lesson
end

private

attr_reader :lesson

def color_class
lesson.completed? ? 'text-teal-600' : 'text-gray-300 dark:text-gray-700'
end

def title
lesson.completed? ? 'Lesson completed' : 'Lesson not completed'
end
end
end
end
15 changes: 15 additions & 0 deletions app/components/lessons/sidebar/lesson_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<%= link_to(
lesson_path(lesson),
class: 'flex items-center justify-between gap-2 px-2 py-1.5 rounded-md no-underline text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-200 aria-[current=page]:bg-gray-100 dark:aria-[current=page]:bg-gray-800 aria-[current=page]:font-semibold aria-[current=page]:text-gray-900 dark:aria-[current=page]:text-gray-100',
aria: { current: ('page' if current?) },
data: { test_id: 'lesson-sidebar-link', 'lessons--sidebar-toggle-target': 'item' }
) do %>
<span class="flex items-center gap-2 min-w-0">
<%= inline_svg_tag "icons/#{icon_name}.svg", class: 'h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500' %>
<span class="truncate"><%= lesson.display_title %></span>
</span>

<% if current_user %>
<%= render Lessons::Sidebar::CompletionIconComponent.new(lesson:) %>
<% end %>
<% end %>
23 changes: 23 additions & 0 deletions app/components/lessons/sidebar/lesson_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
module Lessons
module Sidebar
class LessonComponent < ApplicationComponent
def initialize(lesson:, current_lesson:, current_user: nil)
@lesson = lesson
@current_lesson = current_lesson
@current_user = current_user
end

private

attr_reader :lesson, :current_lesson, :current_user

def current?
lesson.id == current_lesson.id
end

def icon_name
lesson.is_project? ? 'wrench-screwdriver' : 'book'
end
end
end
end
16 changes: 16 additions & 0 deletions app/components/lessons/sidebar/progress_bar_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<div class="sidebar-progress-bar">
<div class="w-full bg-gray-200 dark:bg-gray-800 rounded-full h-1.5 overflow-hidden">
<div
class="bg-teal-600 dark:bg-teal-500 h-full rounded-full transition-all"
style="width: <%= percentage %>%;"
role="progressbar"
aria-valuenow="<%= percentage %>"
aria-valuemin="0"
aria-valuemax="100"
aria-label="Course progress">
</div>
</div>
<span class="mt-2 block text-xs text-gray-500 dark:text-gray-400">
<%= percentage %>% complete
</span>
</div>
13 changes: 13 additions & 0 deletions app/components/lessons/sidebar/progress_bar_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
module Lessons
module Sidebar
class ProgressBarComponent < ApplicationComponent
def initialize(percentage:)
@percentage = percentage
end

private

attr_reader :percentage
end
end
end
14 changes: 14 additions & 0 deletions app/components/lessons/sidebar/section_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<details class="group border-b border-gray-100 dark:border-gray-800" <%= 'open' if expanded? %>>
<summary class="flex items-center justify-between gap-2 py-3 px-2 cursor-pointer list-none text-sm font-semibold text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md">
<span class="truncate"><%= section.title %></span>
<%= inline_svg_tag 'icons/chevron-down.svg', class: 'h-4 w-4 shrink-0 text-gray-400 transition-transform group-open:rotate-180' %>
</summary>

<ul class="pb-2 space-y-1">
<% section.lessons.each do |lesson| %>
<li>
<%= render Lessons::Sidebar::LessonComponent.new(lesson:, current_lesson:, current_user:) %>
</li>
<% end %>
</ul>
</details>
19 changes: 19 additions & 0 deletions app/components/lessons/sidebar/section_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
module Lessons
module Sidebar
class SectionComponent < ApplicationComponent
def initialize(section:, current_lesson:, current_user: nil)
@section = section
@current_lesson = current_lesson
@current_user = current_user
end

private

attr_reader :section, :current_lesson, :current_user

def expanded?
section.id == current_lesson.section_id
end
end
end
end
90 changes: 90 additions & 0 deletions app/components/lessons/sidebar_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<%# Off-canvas drawer — below xl %>
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is the third place we use this pattern (we use it on the mobile nav and the admin sidebar as well), I've opened a PR to refactor it into a reusable component: #5312

<div
class="relative z-40 xl:hidden lesson-sidebar-drawer hidden"
role="dialog"
aria-modal="true"
aria-label="Course contents"
data-controller="visibility"
data-visibility-target="content"
data-visibility-visible-value="false">

<div
class="fixed inset-0 bg-gray-900/80 dark:bg-black/70 hidden"
data-visibility-target="content"
data-action="click->visibility#off"
aria-hidden="true"
data-transition-enter="transition-opacity ease-linear duration-200"
data-transition-enter-start="opacity-0"
data-transition-enter-end="opacity-100"
data-transition-leave="transition-opacity ease-linear duration-200"
data-transition-leave-start="opacity-100"
data-transition-leave-end="opacity-0"></div>

<div class="fixed inset-0 flex">
<div
class="relative mr-16 flex w-full max-w-xs flex-1 hidden"
data-visibility-target="content"
data-transition-enter="transition ease-in-out duration-200 transform"
data-transition-enter-start="-translate-x-full"
data-transition-enter-end="translate-x-0"
data-transition-leave="transition ease-in-out duration-200 transform"
data-transition-leave-start="translate-x-0"
data-transition-leave-end="-translate-x-full">

<div
class="absolute left-full top-0 flex w-16 justify-center pt-5 hidden"
data-visibility-target="content"
data-transition-enter="ease-in-out duration-200"
data-transition-enter-start="opacity-0"
data-transition-enter-end="opacity-100"
data-transition-leave="ease-in-out duration-200"
data-transition-leave-start="opacity-100"
data-transition-leave-end="opacity-0">

<button type="button" class="-m-2.5 p-2.5" data-action="click->visibility#off">
<span class="sr-only">Close course contents</span>
<%= inline_svg_tag 'icons/x-mark.svg', class: 'h-6 w-6 text-white', aria: true, title: 'Close course contents' %>
</button>
</div>

<div class="flex grow flex-col gap-y-4 overflow-y-auto scrollbar-thin bg-white dark:bg-gray-900 px-4 pb-6 pt-5">
<div class="flex flex-col gap-2">
<%= render Ui::BackLinkComponent.new(path: path_course_path(course.path, course), name: 'Back to course') %>

<%= link_to course.title, path_course_path(course.path, course), class: 'text-lg font-semibold text-gray-800 dark:text-gray-200 no-underline hover:underline' %>

<% if progress_percentage %>
<%= render Lessons::Sidebar::ProgressBarComponent.new(percentage: progress_percentage) %>
<% end %>
</div>

<nav aria-label="Course contents">
<% sections.each do |section| %>
<%= render Lessons::Sidebar::SectionComponent.new(section:, current_lesson:, current_user:) %>
<% end %>
</nav>
</div>
</div>
</div>
</div>

<%# Static desktop sidebar — xl+ %>
<aside class="hidden xl:block xl:h-full" data-test-id="lesson-sidebar">
<div class="sticky top-0 h-screen overflow-y-auto scrollbar-thin pt-4 pb-20">
<div class="mb-4 flex flex-col gap-2">
<%= render Ui::BackLinkComponent.new(path: path_course_path(course.path, course), name: 'Back to course') %>

<%= link_to course.title, path_course_path(course.path, course), class: 'text-lg font-semibold text-gray-800 dark:text-gray-200 no-underline hover:underline' %>

<% if progress_percentage %>
<%= render Lessons::Sidebar::ProgressBarComponent.new(percentage: progress_percentage) %>
<% end %>
</div>

<nav aria-label="Course contents">
<% sections.each do |section| %>
<%= render Lessons::Sidebar::SectionComponent.new(section:, current_lesson:, current_user:) %>
<% end %>
</nav>
</div>
</aside>
20 changes: 20 additions & 0 deletions app/components/lessons/sidebar_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
module Lessons
class SidebarComponent < ApplicationComponent
def initialize(course:, sections:, current_lesson:, current_user: nil)
@course = course
@sections = sections
@current_lesson = current_lesson
@current_user = current_user
end

private

attr_reader :course, :sections, :current_lesson, :current_user

def progress_percentage
return if current_user.blank?

course.progress_for(current_user).percentage
end
end
end
53 changes: 53 additions & 0 deletions app/components/lessons/sidebar_toggle_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Controller } from '@hotwired/stimulus'

export default class SidebarToggleController extends Controller {
static outlets = ['visibility']
static classes = ['collapsed']
static targets = ['item']
static values = { storageKey: String }

connect () {
if (this.isDesktop() && window.localStorage.getItem(this.storageKeyValue) === 'true') {
this.element.classList.add(this.collapsedClass)
}
}

itemTargetConnected (item) {
if (item.getAttribute('href') === window.location.pathname) {
item.setAttribute('aria-current', 'page')
item.closest('details').open = true
} else {
item.removeAttribute('aria-current')
}
}

toggle () {
if (this.isDesktop()) {
this.isCollapsed() ? this.expand() : this.collapse()
} else if (this.hasVisibilityOutlet) {
this.visibilityOutlet.toggle()
}
}

collapse () {
this.element.classList.add(this.collapsedClass)
this.persist(true)
}

expand () {
this.element.classList.remove(this.collapsedClass)
this.persist(false)
}

isDesktop () {
return window.matchMedia('(min-width: 1280px)').matches
}

isCollapsed () {
return this.element.classList.contains(this.collapsedClass)
}

persist (collapsed) {
window.localStorage.setItem(this.storageKeyValue, String(collapsed))
}
}
18 changes: 18 additions & 0 deletions app/controllers/lessons/course_contents_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
module Lessons
class CourseContentsController < ApplicationController
before_action :set_cache_control_header_to_no_store

def show
@lesson = Lesson.find(params[:lesson_id])
@course = @lesson.course
@sections = @course.sections.includes(:lessons)

return unless user_signed_in?

Courses::MarkCompletedLessons.call(
user: current_user,
lessons: @sections.flat_map(&:lessons),
)
Comment thread
KevinMulhern marked this conversation as resolved.
end
end
end
24 changes: 24 additions & 0 deletions app/javascript/controllers/sticky_state_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Controller } from '@hotwired/stimulus'

const FULLY_VISIBLE = 1

export default class StickyStateController extends Controller {
static classes = ['stuck']

connect () {
this.observer = new window.IntersectionObserver(
([entry]) => this.toggleStuck(entry.intersectionRatio < FULLY_VISIBLE),
{ threshold: [FULLY_VISIBLE] }
)

this.observer.observe(this.element)
}

disconnect () {
this.observer?.disconnect()
}

toggleStuck (stuck) {
this.stuckClasses.forEach((cls) => this.element.classList.toggle(cls, stuck))
}
}
Loading
Loading