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
6 changes: 6 additions & 0 deletions app/Filament/Resources/CourseLessonResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ public static function form(Schema $schema): Schema
Forms\Components\Textarea::make('description')
->rows(3),

Forms\Components\MarkdownEditor::make('notes')
->label('Lesson notes')
->helperText('Markdown — commands, code samples, links, etc. Shown beneath the video.')
->inlineLabel(false)
->columnSpanFull(),

Forms\Components\TextInput::make('vimeo_id')
->label('Vimeo ID')
->maxLength(255),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ public function form(Schema $schema): Schema
Forms\Components\Textarea::make('description')
->rows(3),

Forms\Components\MarkdownEditor::make('notes')
->label('Lesson notes')
->helperText('Markdown — commands, code samples, links, etc. Shown beneath the video.')
->columnSpanFull(),

Forms\Components\TextInput::make('vimeo_id')
->label('Vimeo ID')
->maxLength(255),
Expand Down
16 changes: 12 additions & 4 deletions app/Livewire/Customer/Course/Index.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,7 @@ public function course(): ?Course
->with(['modules' => function ($query) {
$query->when(! $this->isAdmin, fn ($query) => $query->where('is_published', true))
->orderBy('sort_order')
->with(['lessons' => function ($query) {
$query->when(! $this->isAdmin, fn ($query) => $query->where('is_published', true))->orderBy('sort_order');
}]);
->with(['lessons' => fn ($query) => $query->orderBy('sort_order')]);
}])
->first();
}
Expand Down Expand Up @@ -94,14 +92,24 @@ public function completedLessonIds(): array
->all();
}

#[Computed]
public function hasVideoLessons(): bool
{
return (bool) $this->course?->modules
->flatMap(fn ($module) => $module->lessons)
->contains(fn ($lesson) => filled($lesson->vimeo_id) && ($lesson->is_published || $this->isAdmin));
}

#[Computed]
public function totalLessons(): int
{
if (! $this->course) {
return 0;
}

return $this->course->modules->sum(fn ($module) => $module->lessons->count());
return $this->course->modules->sum(
fn ($module) => $module->lessons->filter(fn ($lesson) => $lesson->is_published)->count()
);
}

#[Computed]
Expand Down
52 changes: 33 additions & 19 deletions app/Livewire/Customer/Course/LessonShow.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,41 @@
use App\Models\CourseLesson;
use App\Models\LessonProgress;
use App\Models\Product;
use Illuminate\Database\Eloquent\Collection;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Renderless;
use Livewire\Component;

#[Layout('components.layouts.dashboard')]
class LessonShow extends Component
{
public const INTRO_SECONDS = 9;

public const OUTRO_SECONDS = 10;

private const VIDEO_PLAYED_SESSION_KEY = 'course_video_played';

public CourseLesson $lesson;

public bool $skipIntroOutro = false;

public function mount(CourseLesson $lesson): void
{
$this->lesson = $lesson->load('module.course');

abort_unless($this->lesson->is_published || $this->isAdmin, 404);
abort_unless($this->isAdmin || ($this->lesson->is_published && $this->lesson->module->is_published), 404);

if (! $this->lesson->is_free && ! $this->hasPurchased && ! $this->isAdmin) {
abort(403, 'You need Pro access to view this lesson.');
}

$this->skipIntroOutro = session()->has(self::VIDEO_PLAYED_SESSION_KEY);
}

#[Renderless]
public function markVideoPlayed(): void
{
session()->put(self::VIDEO_PLAYED_SESSION_KEY, true);
}

#[Computed]
Expand All @@ -39,9 +55,7 @@ public function course(): Course
return $this->lesson->module->course->load(['modules' => function ($query) {
$query->when(! $this->isAdmin, fn ($query) => $query->where('is_published', true))
->orderBy('sort_order')
->with(['lessons' => function ($query) {
$query->when(! $this->isAdmin, fn ($query) => $query->where('is_published', true))->orderBy('sort_order');
}]);
->with(['lessons' => fn ($query) => $query->orderBy('sort_order')]);
}]);
}

Expand All @@ -53,17 +67,6 @@ public function hasPurchased(): bool
return $product && $product->isOwnedBy(auth()->user());
}

/**
* @return Collection<int, CourseLesson>
*/
#[Computed]
public function moduleLessons(): Collection
{
return $this->lesson->module->lessons()
->when(! $this->isAdmin, fn ($query) => $query->where('is_published', true))
->get();
}

#[Computed]
public function completedLessonIds(): array
{
Expand Down Expand Up @@ -117,12 +120,23 @@ public function toggleComplete(): void

public function render()
{
return view('livewire.customer.course.lesson-show')
->title($this->lesson->title);
return view('livewire.customer.course.lesson-show', [
'introSkipSeconds' => self::INTRO_SECONDS,
'outroSkipSeconds' => self::OUTRO_SECONDS,
])->title($this->lesson->title);
}

private function orderedLessons()
{
return $this->course->modules->flatMap(fn ($m) => $m->lessons);
return $this->course->modules
->flatMap(fn ($module) => $module->lessons)
->filter(fn (CourseLesson $lesson) => $this->canNavigateTo($lesson))
->values();
}

private function canNavigateTo(CourseLesson $lesson): bool
{
return ($lesson->is_published || $this->isAdmin)
&& ($lesson->is_free || $this->hasPurchased || $this->isAdmin);
}
}
1 change: 1 addition & 0 deletions database/factories/CourseLessonFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public function definition(): array
'title' => $title,
'slug' => Str::slug($title),
'description' => fake()->paragraph(),
'notes' => null,
'vimeo_id' => null,
'duration_in_seconds' => fake()->numberBetween(60, 1800),
'is_free' => false,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('course_lessons', function (Blueprint $table) {
$table->text('notes')->nullable()->after('description');
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('course_lessons', function (Blueprint $table) {
$table->dropColumn('notes');
});
}
};
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

123 changes: 123 additions & 0 deletions resources/js/alpine/courseVideo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
const VIMEO_SDK_URL = 'https://player.vimeo.com/api/player.js'

let sdkPromise = null

function loadVimeoSdk() {
if (window.Vimeo?.Player) {
return Promise.resolve()
}

if (!sdkPromise) {
sdkPromise = new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = VIMEO_SDK_URL
script.async = true
script.onload = resolve
script.onerror = () =>
reject(new Error('Unable to load the Vimeo player SDK'))
document.head.appendChild(script)
})
}

return sdkPromise
}

export default (options = {}) => ({
videoId: options.videoId,
skip: options.skip ?? false,
introSeconds: options.introSeconds ?? 9,
outroSeconds: options.outroSeconds ?? 10,
marked: false,
cleanup: null,

init() {
loadVimeoSdk()
.then(() => this.setupPlayer())
.catch(() => {})
},

setupPlayer() {
if (!window.Vimeo?.Player || !this.videoId) {
return
}

// Keep the player in a plain closure variable, never a reactive Alpine
// property: the SDK keys its ready-promise and event registry off the raw
// instance via WeakMaps, and an Alpine proxy breaks every lookup
// ("Unknown player. Probably unloaded.").
const player = new window.Vimeo.Player(this.$el, {
id: Number(this.videoId),
autopause: false,
badge: false,
})

player.on('play', () => this.rememberFirstPlay())

if (this.skip) {
player.on('loaded', () =>
player.setCurrentTime(this.introSeconds).catch(() => {}),
)
player
.getDuration()
.then((duration) => {
const cutoff = duration - this.outroSeconds

if (cutoff <= this.introSeconds) {
return
}

player.on('timeupdate', ({ seconds }) => {
if (seconds >= cutoff) {
player.pause().catch(() => {})
}
})
})
.catch(() => {})
}

// Toggle play/pause with the space bar anywhere on the page. When the
// Vimeo iframe itself is focused the keydown fires inside it (and Vimeo
// handles space natively), so this never double-fires.
const onKeydown = (e) => {
if (e.code !== 'Space' || e.repeat) {
return
}

const tag = e.target?.tagName
if (
e.target?.isContentEditable ||
tag === 'INPUT' ||
tag === 'TEXTAREA' ||
tag === 'SELECT' ||
tag === 'BUTTON'
) {
return
}

e.preventDefault()
player
.getPaused()
.then((paused) => (paused ? player.play() : player.pause()))
.catch(() => {})
}
window.addEventListener('keydown', onKeydown)

this.cleanup = () => {
window.removeEventListener('keydown', onKeydown)
player.destroy().catch(() => {})
}
},

rememberFirstPlay() {
if (this.skip || this.marked) {
return
}

this.marked = true
this.$wire.markVideoPlayed()
},

destroy() {
this.cleanup?.()
},
})
2 changes: 2 additions & 0 deletions resources/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from '../../vendor/livewire/livewire/dist/livewire.esm.js'
import codeBlock from './alpine/codeBlock.js'
import copyMarkdown from './alpine/copyMarkdown.js'
import courseVideo from './alpine/courseVideo.js'
import sidebarGroup from './alpine/sidebarGroup.js'
import docsearch from '@docsearch/js'
import Atropos from 'atropos'
Expand Down Expand Up @@ -63,6 +64,7 @@ window.gsap = gsap
// Alpine
Alpine.data('codeBlock', codeBlock)
Alpine.data('copyMarkdown', copyMarkdown)
Alpine.data('courseVideo', courseVideo)
Alpine.data('sidebarGroup', sidebarGroup)
Alpine.magic('refAll', (el) => {
return (refName) => {
Expand Down
12 changes: 0 additions & 12 deletions resources/views/components/layouts/dashboard.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -186,22 +186,10 @@ class="min-h-screen bg-white font-poppins antialiased dark:bg-zinc-900 dark:text
@endfeature

@feature(App\Features\ShowMasterclass::class)
@php
$sidebarCourse = \App\Models\Course::where('is_published', true)
->with(['modules' => fn ($q) => $q->where('is_published', true)->orderBy('sort_order')])
->first();
@endphp
<flux:sidebar.group expandable :expanded="false" heading="Masterclass" class="mt-4 grid" x-data="sidebarGroup('masterclass')">
<flux:sidebar.item icon="device-phone-mobile" href="{{ route('customer.course.index') }}" :current="request()->routeIs('customer.course.*')">
Mobile
</flux:sidebar.item>
@if($sidebarCourse)
@foreach($sidebarCourse->modules as $sidebarModule)
<flux:sidebar.item class="!pl-8 !text-xs">
{{ $sidebarModule->title }}
</flux:sidebar.item>
@endforeach
@endif
</flux:sidebar.group>
@endfeature
</flux:sidebar.nav>
Expand Down
Loading
Loading