feat(nx-dev): add video course page (#28736)
https://nx-dev-git-video-course-nrwl.vercel.app/courses
This commit is contained in:
parent
6a3d864ba1
commit
a1fe42b158
7
docs/courses/explore-nx/course.md
Normal file
7
docs/courses/explore-nx/course.md
Normal file
@ -0,0 +1,7 @@
|
||||
---
|
||||
title: 'Introduction to Nx'
|
||||
description: 'New to Nx? Then this is where you should start.'
|
||||
authors: [Juri Strumpflohner]
|
||||
---
|
||||
|
||||
This course gives you a quick high-level overview of Nx, how running tasks works, task caching, how Nx provides code scaffolding functionality and how you can use `nx migrate` to automatically update your workspace dependencies and code across breaking changes.
|
||||
19
docs/courses/explore-nx/lessons/01-why-nx.md
Normal file
19
docs/courses/explore-nx/lessons/01-why-nx.md
Normal file
@ -0,0 +1,19 @@
|
||||
---
|
||||
title: 'Soo..what is Nx?'
|
||||
videoUrl: 'https://youtu.be/-_4WMl-Fn0w'
|
||||
duration: '9:28'
|
||||
---
|
||||
|
||||
This video gives you a birds-eye view of Nx in ~10 minutes. It covers topics such as:
|
||||
|
||||
- What is Nx
|
||||
- Nx Architecture
|
||||
- Add Nx to an arbitrary project
|
||||
- Why would adding Nx be useful?
|
||||
- Nx in a PNPM monorepo
|
||||
- Why use Nx Plugins
|
||||
- Setting up a new Nx Integrated Monorepo
|
||||
- Abstracting low-level configs
|
||||
- Automated Code Updates
|
||||
|
||||
Read more [in our docs](/getting-started/why-nx)
|
||||
14
docs/courses/explore-nx/lessons/02-run-tasks.md
Normal file
14
docs/courses/explore-nx/lessons/02-run-tasks.md
Normal file
@ -0,0 +1,14 @@
|
||||
---
|
||||
title: 'Run Tasks with Nx'
|
||||
videoUrl: 'https://youtu.be/aEdfYiA5U34'
|
||||
duration: '4:19'
|
||||
---
|
||||
|
||||
Learn how Nx provides a powerful task runner that allows you to:
|
||||
|
||||
- easily run multiple targets for multiple projects in parallel
|
||||
- define task pipelines to run tasks in the correct order
|
||||
- only run tasks for projects affected by a given change
|
||||
- speed up task execution with caching
|
||||
|
||||
Read more [in our docs](/features/run-tasks)
|
||||
12
docs/courses/explore-nx/lessons/03-cache-task-results.md
Normal file
12
docs/courses/explore-nx/lessons/03-cache-task-results.md
Normal file
@ -0,0 +1,12 @@
|
||||
---
|
||||
title: 'Cache Task Results'
|
||||
videoUrl: 'https://youtu.be/o-6jb78uuP0'
|
||||
duration: '8:50'
|
||||
---
|
||||
|
||||
Learn how Nx's sophisticated caching system ensures code is never rebuilt twice. This:
|
||||
|
||||
- drastically speeds up your task execution times while developing locally and in CI
|
||||
- saves you money on CI/CD costs by reducing the number of tasks that need to be executed
|
||||
|
||||
Read more [in our docs](/features/cache-task-results)
|
||||
13
docs/courses/explore-nx/lessons/04-generate-code.md
Normal file
13
docs/courses/explore-nx/lessons/04-generate-code.md
Normal file
@ -0,0 +1,13 @@
|
||||
---
|
||||
title: 'Generate Code'
|
||||
videoUrl: 'https://youtu.be/hSM6MgWOYr8'
|
||||
duration: '4:11'
|
||||
---
|
||||
|
||||
Learn how Nx's code generators help boost your productivity by:
|
||||
|
||||
- Allowing you to scaffold new projects or augment existing projects with new features
|
||||
- Automating repetitive tasks in your development workflow
|
||||
- Ensuring your code is consistent and follows best practices
|
||||
|
||||
Read more [in our docs](/features/generate-code)
|
||||
@ -0,0 +1,13 @@
|
||||
---
|
||||
title: 'Automate Updating Dependencies'
|
||||
videoUrl: 'https://youtu.be/A0FjwsTlZ8A'
|
||||
duration: '4:45'
|
||||
---
|
||||
|
||||
Learn how Nx migrate functionality helps you:
|
||||
|
||||
- automatically update your package.json dependencies
|
||||
- migrate your configuration files (e.g. Jest, ESLint, Nx config)
|
||||
- adjust your source code to match the new versions of packages
|
||||
|
||||
Read more [in our docs](/features/automate-updating-dependencies)
|
||||
17
docs/courses/pnpm-nx-next/course.md
Normal file
17
docs/courses/pnpm-nx-next/course.md
Normal file
@ -0,0 +1,17 @@
|
||||
---
|
||||
title: 'From PNPM Workspaces to Distributed CI'
|
||||
description: 'Learn how to transform a PNPM workspace monorepo into a high-performance distributed CI setup using Nx.'
|
||||
authors: [Juri Strumpflohner]
|
||||
repository: 'https://github.com/nrwl/nx-course-pnpm-nx'
|
||||
---
|
||||
|
||||
In this course, we'll walk through a step-by-step guide using the Tasker application as our example. Tasker is a task management app built with Next.js, structured as a PNPM workspace monorepo. The monorepo contains the Next.js application which is modularized into packages that handle data access via Prisma to a local DB, UI components, and more.
|
||||
|
||||
Throughout the course, we'll take incremental steps to enhance the monorepo:
|
||||
|
||||
1. Adding Nx
|
||||
2. Configuring and fine-tuning local caching
|
||||
3. Defining task pipelines to ensure correct task execution order
|
||||
4. Optimizing CI configuration with remote caching
|
||||
5. Adjusting the current CI configuration to enable task distribution
|
||||
6. Splitting and parallelizing Playwright e2e tests to reduce execution time from 20 minutes to 9 minutes
|
||||
BIN
docs/courses/pnpm-nx-next/images/e2e-splitting-anim.gif
Normal file
BIN
docs/courses/pnpm-nx-next/images/e2e-splitting-anim.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 146 KiB |
BIN
docs/courses/pnpm-nx-next/images/implicit-dependencies.avif
Normal file
BIN
docs/courses/pnpm-nx-next/images/implicit-dependencies.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
16
docs/courses/pnpm-nx-next/lessons/00-overview.md
Normal file
16
docs/courses/pnpm-nx-next/lessons/00-overview.md
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
title: 'Course Intro'
|
||||
videoUrl: 'https://youtu.be/VJ1v5dktwwI'
|
||||
duration: '1:01'
|
||||
---
|
||||
|
||||
In this course, we'll walk through a step-by-step guide using the Tasker application as our example. Tasker is a task management app built with Next.js, structured as a PNPM workspace monorepo. The monorepo contains the Next.js application which is modularized into packages that handle data access via Prisma to a local DB, UI components, and more.
|
||||
|
||||
Throughout the course, we'll take incremental steps to enhance the monorepo:
|
||||
|
||||
1. Adding Nx
|
||||
2. Configuring and fine-tuning local caching
|
||||
3. Defining task pipelines to ensure correct task execution order
|
||||
4. Optimizing CI configuration with remote caching
|
||||
5. Implementing distribution across machines
|
||||
6. Optimizing Playwright e2e tests to reduce execution time from 20 minutes to 9 minutes
|
||||
18
docs/courses/pnpm-nx-next/lessons/01-nx-init.md
Normal file
18
docs/courses/pnpm-nx-next/lessons/01-nx-init.md
Normal file
@ -0,0 +1,18 @@
|
||||
---
|
||||
title: 'Initialize Nx in Your Project with nx init'
|
||||
videoUrl: 'https://youtu.be/3hW53b1IJ84'
|
||||
duration: '3:42'
|
||||
---
|
||||
|
||||
In this lesson, we'll explore how to add Nx to our existing PNPM workspace. You can either add just the `nx` package to your `package.json` and then create a `nx.json` [configuration file](/reference/nx-json), or simply run:
|
||||
|
||||
```shell
|
||||
nx init
|
||||
```
|
||||
|
||||
This process will analyze your repository and ask you a couple of questions to properly set up Nx while maintaining your existing PNPM workspace structure.
|
||||
|
||||
## Relevant Links
|
||||
|
||||
- [Adopting Nx](/recipes/adopting-nx)
|
||||
- [Import an Existing Project into an Nx Workspace](/recipes/adopting-nx/import-project)
|
||||
23
docs/courses/pnpm-nx-next/lessons/02-run-tasks.md
Normal file
23
docs/courses/pnpm-nx-next/lessons/02-run-tasks.md
Normal file
@ -0,0 +1,23 @@
|
||||
---
|
||||
title: 'Run and Manage Tasks Efficiently Using Nx'
|
||||
videoUrl: 'https://youtu.be/CJLRkzRrcjg'
|
||||
duration: '1:56'
|
||||
---
|
||||
|
||||
In this lesson, you'll learn how to use Nx to run your PNPM workspace's `package.json` scripts. So rather than running:
|
||||
|
||||
```shell
|
||||
pnpm --filter @tasker/web build
|
||||
```
|
||||
|
||||
you would run:
|
||||
|
||||
```shell
|
||||
pnpm nx build @tasker/web
|
||||
```
|
||||
|
||||
We'll cover the syntax for running both single tasks and multiple tasks, helping you understand how to leverage Nx's task execution capabilities.
|
||||
|
||||
## Relevant Links
|
||||
|
||||
- [Run Tasks with Nx](/features/run-tasks)
|
||||
13
docs/courses/pnpm-nx-next/lessons/03-configure-cache.md
Normal file
13
docs/courses/pnpm-nx-next/lessons/03-configure-cache.md
Normal file
@ -0,0 +1,13 @@
|
||||
---
|
||||
title: 'Configure Cache Outputs to Handle the .next Folder'
|
||||
videoUrl: 'https://youtu.be/t8lOa__TD7o'
|
||||
duration: '2:07'
|
||||
---
|
||||
|
||||
By default Nx captures common folders like `dist` or `build` and automatically restores them from the local cache. However, it doesn't capture the `.next` folder by default.
|
||||
|
||||
In this lesson, you'll learn how to fine-tune local caching to ensure proper handling of the `.next` folder. We'll configure the cache outputs to make sure the Next.js build artifacts are properly restored from cache when needed.
|
||||
|
||||
## Relevant Links
|
||||
|
||||
- [Cache Task Results](/features/cache-task-results)
|
||||
28
docs/courses/pnpm-nx-next/lessons/04-task-pipelines.md
Normal file
28
docs/courses/pnpm-nx-next/lessons/04-task-pipelines.md
Normal file
@ -0,0 +1,28 @@
|
||||
---
|
||||
title: 'Automate Task Pipelines to Build Before next start'
|
||||
videoUrl: 'https://youtu.be/_U4hu6SuBaY'
|
||||
duration: '3:07'
|
||||
---
|
||||
|
||||
All Next.js projects usually come with these `package.json` scripts:
|
||||
|
||||
```json {% fileName="package.json" %}
|
||||
{
|
||||
...
|
||||
"scripts": {
|
||||
...
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Running `next start` will only work if the `.next` folder is present in the project's root. This folder is created when running `next build`.
|
||||
|
||||
This is a very simple use case of a [task pipeline](/concepts/task-pipeline-configuration), which defines dependencies among tasks.
|
||||
|
||||
In this lesson we're going to create a simple task pipeline such that whenever you run `next start`, Nx will automatically run `next build` (or restore it from the cache).
|
||||
|
||||
## Relevant Links
|
||||
|
||||
- [Defining a Task Pipeline](/recipes/running-tasks/defining-task-pipeline)
|
||||
@ -0,0 +1,27 @@
|
||||
---
|
||||
title: 'Link an e2e Project with Its Web App Through Implicit Dependencies'
|
||||
videoUrl: 'https://youtu.be/-iUHY27qUfE'
|
||||
duration: '2:38'
|
||||
---
|
||||
|
||||
One of the main capabilities of Nx is that it builds a project graph behind the scenes which it uses optimize how it runs your tasks. You can visualize the graph using:
|
||||
|
||||
```shell
|
||||
pnpm nx graph
|
||||
```
|
||||
|
||||
{% callout type="info" title="Install Nx Console" %}
|
||||
|
||||
You can also install **Nx Console** which is an extension for VSCode and IntelliJ that enhances the DX when working with Nx monorepos among which there's also the ability to visualize the project graph right in your editor window. Read more [about it here](/getting-started/editor-setup).
|
||||
|
||||
{% /callout %}
|
||||
|
||||
While most of the relationships are discovered by Nx automatically via `package.json` dependencies or JS/TypeScript imports, some cannot be detected. E2E projects such as the Playwright project in our workspace doesn't directly depend on our Next.js application. There is a dependency at runtime though, because Playwright needs to serve our Next application in order to be ablet to run its e2e tests.
|
||||
|
||||

|
||||
|
||||
In this lesson, you'll learn how to define such dependencies using the `implicitDependencies` property.
|
||||
|
||||
## Relevant Links
|
||||
|
||||
- [Project configuration: implicitDependencies](/reference/project-configuration#implicitdependencies)
|
||||
13
docs/courses/pnpm-nx-next/lessons/06-nx-cloud-setup.md
Normal file
13
docs/courses/pnpm-nx-next/lessons/06-nx-cloud-setup.md
Normal file
@ -0,0 +1,13 @@
|
||||
---
|
||||
title: 'Connect Your Workspace to Nx Cloud'
|
||||
videoUrl: 'https://youtu.be/8mqHXYIl_qI'
|
||||
duration: '4:00'
|
||||
---
|
||||
|
||||
Nx powers the “Smart Monorepo,” while Nx Cloud brings “Fast CI” into the mix. Designed to extend Nx’s efficiency into the CI pipeline, Nx Cloud ensures that even large monorepos stay fast and optimized in CI.
|
||||
|
||||
In this lesson, we’ll take the Tasker monorepo, push it to GitHub, set up an Nx Cloud workspace, and link it with your GitHub repository. By the end, your Nx workspace will be fully connected to Nx Cloud, ready to leverage its remote caching and distributed CI capabilities.
|
||||
|
||||
## Relevant Links
|
||||
|
||||
- [Connect to Nx Cloud](/ci/intro/connect-to-nx-cloud)
|
||||
20
docs/courses/pnpm-nx-next/lessons/07-optimize-ci.md
Normal file
20
docs/courses/pnpm-nx-next/lessons/07-optimize-ci.md
Normal file
@ -0,0 +1,20 @@
|
||||
---
|
||||
title: 'Use Nx Commands on CI'
|
||||
videoUrl: 'https://youtu.be/ywlilx9-jNk'
|
||||
duration: '4:43'
|
||||
---
|
||||
|
||||
The Tasker project already uses a CI script on GitHub Actions, but in this lesson, we’ll enhance it by replacing the existing `pnpm --filter` commands with optimized Nx commands for a more efficient CI pipeline.
|
||||
|
||||
We’ll cover how to scaffold a new CI configuration with:
|
||||
|
||||
```shell
|
||||
pnpm nx g ci-workflow
|
||||
```
|
||||
|
||||
We’ll also take a quick detour to discuss `namedInputs` in `nx.json`, ensuring the cache invalidates appropriately whenever the CI config is updated.
|
||||
|
||||
## Relevant Links
|
||||
|
||||
- [Run Only Tasks Affected by a PR](/ci/features/affected)
|
||||
- [Tutorial: Github Actions with Nx](/ci/intro/tutorials/github-actions#create-a-ci-workflow)
|
||||
13
docs/courses/pnpm-nx-next/lessons/08-remote-caching.md
Normal file
13
docs/courses/pnpm-nx-next/lessons/08-remote-caching.md
Normal file
@ -0,0 +1,13 @@
|
||||
---
|
||||
title: 'Configure CI to Access Remote Caching'
|
||||
videoUrl: 'https://youtu.be/vBokLJ_F8qs'
|
||||
duration: '1:45'
|
||||
---
|
||||
|
||||
Nx Cloud comes with powerful built-in [remote caching capabilities](/ci/features/remote-cache). Security and access control for such a cache is crucial, which is why Nx Cloud provides [various controls for managing read and write access to the remote cache](/ci/recipes/security/access-tokens).
|
||||
|
||||
In this lesson, we'll create an access token in our Nx Cloud workspace configuration to enable read/write access to our Github actions.
|
||||
|
||||
## Relevant Links
|
||||
|
||||
- [Nx CLI and CI Access Tokens](/ci/recipes/security/access-tokens)
|
||||
15
docs/courses/pnpm-nx-next/lessons/09-debug-cache.md
Normal file
15
docs/courses/pnpm-nx-next/lessons/09-debug-cache.md
Normal file
@ -0,0 +1,15 @@
|
||||
---
|
||||
title: 'Debug Remote Cache misses with Nx Cloud'
|
||||
videoUrl: 'https://youtu.be/zJmhW1iIxpc'
|
||||
duration: '0:53'
|
||||
---
|
||||
|
||||
Understanding what causes remote cache misses versus cache hits is crucial for optimization.
|
||||
|
||||

|
||||
|
||||
In this lesson, we'll explore how Nx Cloud enables you to compare runs and identify what changes led to cache misses.
|
||||
|
||||
## Relevant Links
|
||||
|
||||
- [Troubleshoot cache misses](/troubleshooting/troubleshoot-cache-misses)
|
||||
23
docs/courses/pnpm-nx-next/lessons/10-nx-login.md
Normal file
23
docs/courses/pnpm-nx-next/lessons/10-nx-login.md
Normal file
@ -0,0 +1,23 @@
|
||||
---
|
||||
title: 'Enable Remote Caching for your Developer Machine with Nx Login'
|
||||
videoUrl: 'https://youtu.be/vX-wgI1zlao'
|
||||
duration: '1:38'
|
||||
---
|
||||
|
||||
Do you want to allow your developers working on the Tasker monorepo
|
||||
|
||||
- to benefit from remote cache results (read-only access)
|
||||
- to also contribute to the remote cache (read/write access)
|
||||
|
||||
It really depends on your use case. Nx Cloud uses Personal Access Tokens (PAT) to give you a fine-grained control mechanism how local workspaces should access the remote cache.
|
||||
|
||||
In this lesson, we'll dive into how to configure your Personal Access Token permissions on Nx Cloud and how developers can authenticate with the Nx Cloud workspace using:
|
||||
|
||||
```shell
|
||||
pnpm nx login
|
||||
```
|
||||
|
||||
## Relevant Links
|
||||
|
||||
- [Nx Cloud and Personal Access Tokens](/ci/recipes/security/personal-access-tokens)
|
||||
- [Blog: Better security with Personal Access Tokens](/blog/personal-access-tokens)
|
||||
19
docs/courses/pnpm-nx-next/lessons/11-nx-agents.md
Normal file
19
docs/courses/pnpm-nx-next/lessons/11-nx-agents.md
Normal file
@ -0,0 +1,19 @@
|
||||
---
|
||||
title: 'Run Tasks in Parallel on Different Machines on CI'
|
||||
videoUrl: 'https://youtu.be/lO_p4tA6IZI'
|
||||
duration: '2:08'
|
||||
---
|
||||
|
||||
While remote caching is powerful, it may not be enough when core packages change frequently, invalidating the cache for large portions of your workspace.
|
||||
|
||||
Nx Cloud comes with a built-in feature called [Nx Agents](/ci/features/distribute-task-execution) that allows to automatically distribute tasks across multiple machines.
|
||||
|
||||
In this lesson we're going to update the existing CI configuration to enable Nx Agents. Which mostly can be done by adding the following line:
|
||||
|
||||
```plaintext
|
||||
nx-cloud start-ci-run --distribute-on="5 linux-medium-js"
|
||||
```
|
||||
|
||||
## Relevant Links
|
||||
|
||||
- [Distribute Task Execution](/ci/features/distribute-task-execution)
|
||||
15
docs/courses/pnpm-nx-next/lessons/12-playwright-split.md
Normal file
15
docs/courses/pnpm-nx-next/lessons/12-playwright-split.md
Normal file
@ -0,0 +1,15 @@
|
||||
---
|
||||
title: 'Split Playwright e2e Tests for a Faster CI'
|
||||
videoUrl: 'https://youtu.be/42XnmzxEXM8'
|
||||
duration: '5:47'
|
||||
---
|
||||
|
||||
Running e2e tests on CI can be quite a painful experience. You want them to run on each PR to get immediate feedback, but then you don't want to wait for 30 minutes.
|
||||
|
||||
In this lesson, we'll optimize the existing Playwright end-to-end tests that currently take up to 20 minutes on CI. We'll leverage the Nx Playwright plugin to automatically split the Playwright tests into individual runs per test, allowing for optimal distribution across Nx agents and significantly improving CI execution time.
|
||||
|
||||

|
||||
|
||||
## Relevant Links
|
||||
|
||||
- [Automatically Split E2E Tasks](/ci/features/split-e2e-tasks)
|
||||
7
docs/courses/pnpm-nx-next/lessons/13-outro.md
Normal file
7
docs/courses/pnpm-nx-next/lessons/13-outro.md
Normal file
@ -0,0 +1,7 @@
|
||||
---
|
||||
title: 'Course Outro'
|
||||
videoUrl: 'https://youtu.be/a_pfLrvf88E'
|
||||
duration: '0:39'
|
||||
---
|
||||
|
||||
Thank you for completing this course on optimizing your PNPM workspace with Nx. You've learned how to implement and configure Nx, set up efficient caching, optimize CI processes, and improve e2e test execution times.
|
||||
@ -53,7 +53,7 @@ Also, here are some recipes that give you more details based on the technology s
|
||||
|
||||
{% link-card title="What is Nx Cloud?" type="video" url="https://youtu.be/4VI-q943J3o" icon="nxcloud" /%}
|
||||
|
||||
{% link-card title="PNPM Monorepos with Nx" type="video" url="https://youtu.be/ngdoUQBvAjo" icon="pnpm" /%}
|
||||
{% link-card title="PNPM Workspaces to Distributed CI" type="course" url="/courses/pnpm-nx-next" icon="pnpm" /%}
|
||||
|
||||
{% link-card title="More On Youtube" type="video" url="https://www.youtube.com/@nxdevtools" icon="youtube" /%}
|
||||
|
||||
|
||||
8
nx-dev/data-access-courses/project.json
Normal file
8
nx-dev/data-access-courses/project.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "data-access-courses",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "nx-dev/data-access-courses/src",
|
||||
"projectType": "library",
|
||||
"targets": {},
|
||||
"tags": []
|
||||
}
|
||||
2
nx-dev/data-access-courses/src/index.ts
Normal file
2
nx-dev/data-access-courses/src/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './lib/courses.api';
|
||||
export * from './lib/course.types';
|
||||
22
nx-dev/data-access-courses/src/lib/course.types.ts
Normal file
22
nx-dev/data-access-courses/src/lib/course.types.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import type { BlogAuthor } from '@nx/nx-dev/data-access-documents/node-only';
|
||||
|
||||
export interface Course {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
content: string;
|
||||
authors: BlogAuthor[];
|
||||
repository?: string;
|
||||
lessons: Lesson[];
|
||||
filePath: string;
|
||||
totalDuration: string;
|
||||
}
|
||||
|
||||
export interface Lesson {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
videoUrl: string;
|
||||
duration: string;
|
||||
filePath: string;
|
||||
}
|
||||
92
nx-dev/data-access-courses/src/lib/courses.api.ts
Normal file
92
nx-dev/data-access-courses/src/lib/courses.api.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { readFile, readdir } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { extractFrontmatter } from '@nx/nx-dev/ui-markdoc';
|
||||
import { readFileSync } from 'fs';
|
||||
import { Course, Lesson } from './course.types';
|
||||
import { calculateTotalDuration } from './duration.utils';
|
||||
|
||||
export class CoursesApi {
|
||||
// TODO: move to shared lib
|
||||
private readonly blogRoot = 'public/documentation/blog';
|
||||
|
||||
constructor(
|
||||
private readonly options: {
|
||||
coursesRoot: string;
|
||||
}
|
||||
) {
|
||||
if (!options.coursesRoot) {
|
||||
throw new Error('courses root cannot be undefined');
|
||||
}
|
||||
}
|
||||
|
||||
async getAllCourses(): Promise<Course[]> {
|
||||
const courseFolders = await readdir(this.options.coursesRoot);
|
||||
const courses = await Promise.all(
|
||||
courseFolders.map((folder) => this.getCourse(folder))
|
||||
);
|
||||
return courses;
|
||||
}
|
||||
|
||||
async getCourse(folderName: string): Promise<Course> {
|
||||
const authors = JSON.parse(
|
||||
readFileSync(join(this.blogRoot, 'authors.json'), 'utf8')
|
||||
);
|
||||
const coursePath = join(this.options.coursesRoot, folderName);
|
||||
const courseFilePath = join(coursePath, 'course.md');
|
||||
|
||||
const content = await readFile(courseFilePath, 'utf-8');
|
||||
const frontmatter = extractFrontmatter(content);
|
||||
|
||||
const lessonFolders = await readdir(coursePath);
|
||||
const lessons = await Promise.all(
|
||||
lessonFolders
|
||||
.filter((folder) => folder !== 'course.md')
|
||||
.map((folder) => this.getLessons(folderName, folder))
|
||||
);
|
||||
const flattenedLessons = lessons.flat();
|
||||
|
||||
return {
|
||||
id: folderName,
|
||||
title: frontmatter.title,
|
||||
description: frontmatter.description,
|
||||
content,
|
||||
authors: authors.filter((author: { name: string }) =>
|
||||
frontmatter.authors.includes(author.name)
|
||||
),
|
||||
repository: frontmatter.repository,
|
||||
lessons: flattenedLessons,
|
||||
filePath: courseFilePath,
|
||||
totalDuration: calculateTotalDuration(flattenedLessons),
|
||||
};
|
||||
}
|
||||
|
||||
private async getLessons(
|
||||
courseId: string,
|
||||
lessonFolder: string
|
||||
): Promise<Lesson[]> {
|
||||
const lessonPath = join(this.options.coursesRoot, courseId, lessonFolder);
|
||||
const lessonFiles = await readdir(lessonPath);
|
||||
|
||||
const lessons = await Promise.all(
|
||||
lessonFiles.map(async (file) => {
|
||||
if (!file.endsWith('.md')) return null;
|
||||
const filePath = join(lessonPath, file);
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
const frontmatter = extractFrontmatter(content);
|
||||
if (!frontmatter || !frontmatter.title) {
|
||||
throw new Error(`Lesson ${lessonFolder}/${file} has no title`);
|
||||
}
|
||||
return {
|
||||
id: `${lessonFolder}-${file.replace('.md', '')}`,
|
||||
title: frontmatter.title,
|
||||
description: content,
|
||||
videoUrl: frontmatter.videoUrl || null,
|
||||
duration: frontmatter.duration || null,
|
||||
filePath,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return lessons.filter((lesson): lesson is Lesson => lesson !== null);
|
||||
}
|
||||
}
|
||||
17
nx-dev/data-access-courses/src/lib/duration.utils.ts
Normal file
17
nx-dev/data-access-courses/src/lib/duration.utils.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { Lesson } from './course.types';
|
||||
|
||||
export function calculateTotalDuration(lessons: Lesson[]): string {
|
||||
const totalMinutes = lessons.reduce((total, lesson) => {
|
||||
if (!lesson.duration) return total;
|
||||
const [minutes, seconds] = lesson.duration.split(':').map(Number);
|
||||
return total + minutes + seconds / 60;
|
||||
}, 0);
|
||||
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = Math.round(totalMinutes % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
}
|
||||
17
nx-dev/data-access-courses/tsconfig.json
Normal file
17
nx-dev/data-access-courses/tsconfig.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": false,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
}
|
||||
],
|
||||
"extends": "../../tsconfig.base.json"
|
||||
}
|
||||
9
nx-dev/data-access-courses/tsconfig.lib.json
Normal file
9
nx-dev/data-access-courses/tsconfig.lib.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"types": ["node"]
|
||||
},
|
||||
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"],
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
51
nx-dev/nx-dev/app/courses/[courseId]/[lessonId]/page.tsx
Normal file
51
nx-dev/nx-dev/app/courses/[courseId]/[lessonId]/page.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { coursesApi } from '../../../../lib/courses.api';
|
||||
import { DefaultLayout } from '@nx/nx-dev/ui-common';
|
||||
import { LessonPlayer } from '@nx/nx-dev/ui-courses';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
interface LessonPageProps {
|
||||
params: { courseId: string; lessonId: string };
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: LessonPageProps): Promise<Metadata> {
|
||||
const course = await coursesApi.getCourse(params.courseId);
|
||||
const lesson = course.lessons.find((l) => l.id === params.lessonId);
|
||||
|
||||
if (!lesson) {
|
||||
return {
|
||||
title: 'Lesson Not Found',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${lesson.title} | ${course.title} | Nx Courses`,
|
||||
description: lesson.description.substring(0, 160),
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const courses = await coursesApi.getAllCourses();
|
||||
return courses.flatMap((course) =>
|
||||
course.lessons.map((lesson) => ({
|
||||
courseId: course.id,
|
||||
lessonId: lesson.id,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
export default async function LessonPage({ params }: LessonPageProps) {
|
||||
const course = await coursesApi.getCourse(params.courseId);
|
||||
const lesson = course.lessons.find((l) => l.id === params.lessonId);
|
||||
|
||||
if (!lesson) {
|
||||
return <div>Lesson not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DefaultLayout hideHeader hideFooter>
|
||||
<LessonPlayer course={course} lesson={lesson} />
|
||||
</DefaultLayout>
|
||||
);
|
||||
}
|
||||
58
nx-dev/nx-dev/app/courses/[courseId]/page.tsx
Normal file
58
nx-dev/nx-dev/app/courses/[courseId]/page.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import type { Metadata, ResolvingMetadata } from 'next';
|
||||
import { coursesApi } from '../../../lib/courses.api';
|
||||
import { CourseDetails } from '@nx/nx-dev/ui-courses';
|
||||
import { DefaultLayout } from '@nx/nx-dev/ui-common';
|
||||
|
||||
interface CourseDetailProps {
|
||||
params: { courseId: string };
|
||||
}
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params: { courseId } }: CourseDetailProps,
|
||||
parent: ResolvingMetadata
|
||||
): Promise<Metadata> {
|
||||
const course = await coursesApi.getCourse(courseId);
|
||||
const previousImages = (await parent).openGraph?.images ?? [];
|
||||
|
||||
return {
|
||||
title: `${course.title} | Nx Courses`,
|
||||
description: course.description,
|
||||
openGraph: {
|
||||
url: `https://nx.dev/courses/${courseId}`,
|
||||
title: course.title,
|
||||
description: course.description,
|
||||
images: [
|
||||
{
|
||||
url: '/path/to/default/course/image.png', // Add a default course image
|
||||
width: 800,
|
||||
height: 421,
|
||||
alt: 'Nx Course: ' + course.title,
|
||||
type: 'image/png',
|
||||
},
|
||||
...previousImages,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const courses = await coursesApi.getAllCourses();
|
||||
return courses.map((course) => {
|
||||
return { courseId: course.id };
|
||||
});
|
||||
}
|
||||
|
||||
export default async function CourseDetail({
|
||||
params: { courseId },
|
||||
}: CourseDetailProps) {
|
||||
const course = await coursesApi.getCourse(courseId);
|
||||
return course ? (
|
||||
<>
|
||||
{/* This empty div is necessary as app router does not automatically scroll on route changes */}
|
||||
<div></div>
|
||||
<DefaultLayout>
|
||||
<CourseDetails course={course} />
|
||||
</DefaultLayout>
|
||||
</>
|
||||
) : null;
|
||||
}
|
||||
41
nx-dev/nx-dev/app/courses/page.tsx
Normal file
41
nx-dev/nx-dev/app/courses/page.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { DefaultLayout } from '@nx/nx-dev/ui-common';
|
||||
import { CourseOverview, CourseHero } from '@nx/nx-dev/ui-video-courses';
|
||||
import { coursesApi } from '../../lib/courses.api';
|
||||
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Nx Video Courses',
|
||||
description:
|
||||
'Master Nx with expert-led video courses from the core team. Boost your skills and productivity.',
|
||||
openGraph: {
|
||||
url: 'https://nx.dev/courses',
|
||||
title: 'Nx Video Courses',
|
||||
description:
|
||||
'Master Nx with expert-led video courses from the core team. Boost your skills and productivity.',
|
||||
images: [
|
||||
{
|
||||
url: 'https://nx.dev/socials/nx-courses-media.png',
|
||||
width: 800,
|
||||
height: 421,
|
||||
alt: 'Nx Video Courses',
|
||||
type: 'image/jpeg',
|
||||
},
|
||||
],
|
||||
siteName: 'Nx',
|
||||
type: 'website',
|
||||
},
|
||||
};
|
||||
|
||||
export default async function CoursesPage(): Promise<JSX.Element> {
|
||||
const courses = await coursesApi.getAllCourses();
|
||||
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<CourseHero />
|
||||
<div className="mt-8">
|
||||
<CourseOverview courses={courses} />
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
);
|
||||
}
|
||||
5
nx-dev/nx-dev/lib/courses.api.ts
Normal file
5
nx-dev/nx-dev/lib/courses.api.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { CoursesApi } from '@nx/nx-dev/data-access-courses';
|
||||
|
||||
export const coursesApi = new CoursesApi({
|
||||
coursesRoot: 'public/documentation/courses',
|
||||
});
|
||||
@ -94,6 +94,9 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
},
|
||||
screens: {
|
||||
wide: '1800px',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
||||
@ -6,10 +6,16 @@ import cx from 'classnames';
|
||||
export function DefaultLayout({
|
||||
isHome = false,
|
||||
children,
|
||||
}: { isHome?: boolean } & PropsWithChildren): JSX.Element {
|
||||
hideHeader = false,
|
||||
hideFooter = false,
|
||||
}: {
|
||||
isHome?: boolean;
|
||||
hideHeader?: boolean;
|
||||
hideFooter?: boolean;
|
||||
} & PropsWithChildren): JSX.Element {
|
||||
return (
|
||||
<div className="w-full overflow-hidden dark:bg-slate-950">
|
||||
<Header />
|
||||
{!hideHeader && <Header />}
|
||||
<div className="relative isolate">
|
||||
<div
|
||||
className="absolute inset-x-0 -top-40 -z-10 h-full transform-gpu overflow-hidden blur-3xl sm:-top-80"
|
||||
@ -23,9 +29,11 @@ export function DefaultLayout({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<main className={isHome ? '' : 'py-24 sm:py-32'}>{children}</main>
|
||||
<main className={isHome || hideHeader ? '' : 'py-24 sm:py-32'}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
<Footer />
|
||||
<Footer className={hideFooter ? 'hidden' : ''} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,7 +3,6 @@ import { ThemeSwitcher } from '@nx/nx-dev/ui-theme';
|
||||
import Link from 'next/link';
|
||||
import { DiscordIcon } from './discord-icon';
|
||||
|
||||
export function Footer(): JSX.Element {
|
||||
const navigation = {
|
||||
nx: [
|
||||
{ name: 'Status', href: 'https://status.nx.app' },
|
||||
@ -118,9 +117,13 @@ export function Footer(): JSX.Element {
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function Footer({
|
||||
className = '',
|
||||
}: { className?: string } = {}): JSX.Element {
|
||||
return (
|
||||
<footer
|
||||
className="bg-white dark:bg-slate-950"
|
||||
className={`bg-white dark:bg-slate-950 ${className}`}
|
||||
aria-labelledby="footer-heading"
|
||||
>
|
||||
<h2 id="footer-heading" className="sr-only">
|
||||
|
||||
@ -189,9 +189,9 @@ export const learnItems: MenuItem[] = [
|
||||
isHighlight: false,
|
||||
},
|
||||
{
|
||||
name: 'Video tutorials',
|
||||
name: 'Nx Video Courses',
|
||||
description: null,
|
||||
href: 'https://www.youtube.com/@nxdevtools',
|
||||
href: '/courses',
|
||||
icon: PlayCircleIcon,
|
||||
isNew: false,
|
||||
isHighlight: false,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Schema } from '@markdoc/markdoc';
|
||||
import { cx } from '@nx/nx-dev/ui-primitives';
|
||||
|
||||
export const youtube: Schema = {
|
||||
render: 'YouTube',
|
||||
@ -53,9 +54,10 @@ export function computeEmbedURL(youtubeURL: string) {
|
||||
|
||||
export function YouTube(props: {
|
||||
title: string;
|
||||
caption: string;
|
||||
caption?: string;
|
||||
src: string;
|
||||
width?: string;
|
||||
disableRoundedCorners?: boolean;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="text-center">
|
||||
@ -67,7 +69,9 @@ export function YouTube(props: {
|
||||
width={props.width || '100%'}
|
||||
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture; fullscreen"
|
||||
loading="lazy"
|
||||
className="mb-1 rounded-lg shadow-lg"
|
||||
className={cx({
|
||||
'rounded-lg shadow-lg': !props.disableRoundedCorners,
|
||||
})}
|
||||
/>
|
||||
{props.caption && (
|
||||
<p className="mx-auto pt-0 text-slate-500 md:w-1/2 dark:text-slate-400">
|
||||
|
||||
18
nx-dev/ui-courses/.eslintrc.json
Normal file
18
nx-dev/ui-courses/.eslintrc.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": ["../../.eslintrc.json"],
|
||||
"ignorePatterns": ["!**/*"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
9
nx-dev/ui-courses/project.json
Normal file
9
nx-dev/ui-courses/project.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "ui-courses",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "nx-dev/ui-courses/src",
|
||||
"projectType": "library",
|
||||
"tags": [],
|
||||
"// targets": "to see all targets run: nx show project ui-courses --web",
|
||||
"targets": {}
|
||||
}
|
||||
2
nx-dev/ui-courses/src/index.ts
Normal file
2
nx-dev/ui-courses/src/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './lib/course-details';
|
||||
export * from './lib/lesson-player';
|
||||
89
nx-dev/ui-courses/src/lib/course-details.tsx
Normal file
89
nx-dev/ui-courses/src/lib/course-details.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import { Course } from '@nx/nx-dev/data-access-courses';
|
||||
import { ButtonLink } from '@nx/nx-dev/ui-common';
|
||||
import { renderMarkdown } from '@nx/nx-dev/ui-markdoc';
|
||||
import Link from 'next/link';
|
||||
import { BlogAuthors } from '@nx/nx-dev/ui-blog';
|
||||
import type { BlogAuthor } from '@nx/nx-dev/data-access-documents/node-only';
|
||||
import { LessonsList } from './lessons-list';
|
||||
import { GitHubIcon, GithubIcon } from '@nx/nx-dev/ui-icons';
|
||||
|
||||
export interface CourseDetailsProps {
|
||||
course: Course;
|
||||
}
|
||||
|
||||
export function CourseDetails({ course }: CourseDetailsProps) {
|
||||
const { node } = renderMarkdown(course.content, {
|
||||
filePath: course.filePath ?? '',
|
||||
headingClass: 'scroll-mt-20',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-4">
|
||||
<Link
|
||||
href="/courses"
|
||||
className="group inline-flex items-center text-sm leading-6 text-slate-950 dark:text-white"
|
||||
prefetch={false}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mr-1 inline-block transition group-hover:-translate-x-1"
|
||||
>
|
||||
←
|
||||
</span>
|
||||
All courses
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div className="course-title-section">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-slate-950 sm:text-5xl dark:text-white">
|
||||
{course.title}
|
||||
</h1>
|
||||
<div className="mt-4 flex items-center gap-x-2">
|
||||
<BlogAuthors authors={course.authors as BlogAuthor[]} />
|
||||
<span className="text-sm">{course.authors[0].name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex items-center gap-x-6">
|
||||
{course.lessons && course.lessons.length > 0 && (
|
||||
<ButtonLink
|
||||
href={`/courses/${course.id}/${course.lessons[0].id}`}
|
||||
title="Start the course"
|
||||
variant="primary"
|
||||
size="default"
|
||||
>
|
||||
Start Learning
|
||||
</ButtonLink>
|
||||
)}
|
||||
{course.repository && (
|
||||
<ButtonLink
|
||||
href={course.repository}
|
||||
title="Code"
|
||||
variant="contrast"
|
||||
size="default"
|
||||
>
|
||||
<GitHubIcon className="mr-2 inline-block h-5 w-5" />
|
||||
Code
|
||||
</ButtonLink>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-col gap-8 md:flex-row">
|
||||
<div className="course-description md:w-2/3">
|
||||
<div className="prose prose-lg prose-slate dark:prose-invert w-full max-w-none 2xl:max-w-4xl">
|
||||
{node}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{course.lessons && course.lessons.length > 0 && (
|
||||
<div className="course-lessons md:w-1/3">
|
||||
<LessonsList course={course} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
210
nx-dev/ui-courses/src/lib/lesson-player.tsx
Normal file
210
nx-dev/ui-courses/src/lib/lesson-player.tsx
Normal file
@ -0,0 +1,210 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Course, Lesson } from '@nx/nx-dev/data-access-courses';
|
||||
import { renderMarkdown } from '@nx/nx-dev/ui-markdoc';
|
||||
import { YouTube } from '@nx/nx-dev/ui-common';
|
||||
import { cx } from '@nx/nx-dev/ui-primitives';
|
||||
import { Header } from '@nx/nx-dev/ui-common';
|
||||
|
||||
interface LessonPlayerProps {
|
||||
course: Course;
|
||||
lesson: Lesson;
|
||||
}
|
||||
|
||||
function formatDuration(duration: string): string {
|
||||
const [minutes, seconds] = duration.split(':').map(Number);
|
||||
return `${minutes}m ${seconds.toString().padStart(2, '0')}s`;
|
||||
}
|
||||
|
||||
const LessonsList = ({
|
||||
course,
|
||||
lesson,
|
||||
}: {
|
||||
course: Course;
|
||||
lesson: Lesson;
|
||||
}) => (
|
||||
<ul className="space-y-2">
|
||||
{course.lessons.map((item, index) => (
|
||||
<li key={item.id}>
|
||||
<Link
|
||||
href={`/courses/${course.id}/${item.id}`}
|
||||
className={cx(
|
||||
'flex w-full items-center px-3 py-2 transition-colors',
|
||||
{
|
||||
'bg-blue-50 text-blue-900 dark:bg-blue-900/20 dark:text-blue-100':
|
||||
item.id === lesson.id,
|
||||
'hover:bg-slate-100 dark:hover:bg-slate-800':
|
||||
item.id !== lesson.id,
|
||||
}
|
||||
)}
|
||||
prefetch={false}
|
||||
>
|
||||
<span
|
||||
className={cx('inline-block min-w-[2rem] flex-shrink-0 text-sm', {
|
||||
'text-blue-600 dark:text-blue-400': item.id === lesson.id,
|
||||
'text-slate-400 dark:text-slate-500': item.id !== lesson.id,
|
||||
})}
|
||||
>
|
||||
{(index + 1).toString()}
|
||||
</span>
|
||||
<span className="flex-1 text-sm leading-normal">
|
||||
<span
|
||||
className={cx('', {
|
||||
'font-semibold': item.id === lesson.id,
|
||||
'text-slate-700 dark:text-slate-300': item.id !== lesson.id,
|
||||
})}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
</span>
|
||||
{item.duration && (
|
||||
<span className="ml-2 flex items-center gap-1 text-xs text-slate-400 dark:text-slate-500">
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{formatDuration(item.duration)}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
export function LessonPlayer({ course, lesson }: LessonPlayerProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const { node: lessonContent } = renderMarkdown(lesson.description, {
|
||||
filePath: lesson.filePath,
|
||||
headingClass: 'scroll-mt-20',
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<div className="flex h-screen w-full pt-20">
|
||||
{/* Left Panel - Course Info & Lessons (Hidden on mobile) */}
|
||||
<div className="hidden lg:flex lg:w-80 lg:flex-none lg:flex-col lg:border-r lg:border-slate-200 lg:dark:border-slate-700">
|
||||
<div className="border-b border-slate-200 p-6 dark:border-slate-700">
|
||||
<div className="text-sm font-medium text-slate-500 dark:text-slate-400">
|
||||
Course by {course.authors[0].name}
|
||||
</div>
|
||||
<Link
|
||||
href={`/courses/${course.id}`}
|
||||
className="mt-1 block text-xl font-bold text-slate-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400"
|
||||
>
|
||||
{course.title}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto py-4">
|
||||
<LessonsList course={course} lesson={lesson} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center/Right Panel - Video & Content */}
|
||||
<div className="wide:w-[45%] wide:flex-none wide:overflow-y-visible wide:border-r wide:border-slate-200 wide:dark:border-slate-700 flex flex-1 flex-col overflow-y-auto">
|
||||
{/* Video Section */}
|
||||
<div className="flex-none border-b border-slate-200 dark:border-slate-700">
|
||||
{lesson.videoUrl ? (
|
||||
<div className="aspect-video">
|
||||
<YouTube
|
||||
src={lesson.videoUrl}
|
||||
title={lesson.title}
|
||||
width="100%"
|
||||
disableRoundedCorners
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex aspect-video items-center justify-center bg-slate-100 dark:bg-slate-800">
|
||||
<p className="text-slate-500 dark:text-slate-400">
|
||||
No video available for this lesson
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile Course Title and Lessons List */}
|
||||
<div className="flex-none border-b border-slate-200 lg:hidden dark:border-slate-700">
|
||||
<div className="p-4">
|
||||
<div className="text-sm font-medium text-slate-500 dark:text-slate-400">
|
||||
Course by {course.authors[0].name}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
href={`/courses/${course.id}`}
|
||||
className="mt-1 block text-xl font-bold text-slate-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400"
|
||||
>
|
||||
{course.title}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200"
|
||||
>
|
||||
{isExpanded ? 'Hide' : 'Show'} lessons
|
||||
<svg
|
||||
className={cx(
|
||||
'ml-1 h-4 w-4 transform transition-transform',
|
||||
{
|
||||
'rotate-180': isExpanded,
|
||||
}
|
||||
)}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="border-t border-slate-200 bg-slate-50 p-4 dark:border-slate-700 dark:bg-slate-800/50">
|
||||
<LessonsList />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Section (Hidden on wide screens) */}
|
||||
<div className="wide:hidden flex-1">
|
||||
<div className="mx-auto max-w-4xl px-8 py-6">
|
||||
<h1 className="mb-6 text-3xl font-bold text-slate-900 dark:text-white">
|
||||
{lesson.title}
|
||||
</h1>
|
||||
<div className="prose prose-slate prose-lg dark:prose-invert max-w-none">
|
||||
{lessonContent}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Lesson Content (wide screens Only) */}
|
||||
<div className="wide:block wide:flex-1 wide:overflow-y-auto hidden">
|
||||
<div className="mx-auto max-w-3xl px-8 py-6">
|
||||
<h1 className="mb-6 text-3xl font-bold text-slate-900 dark:text-white">
|
||||
{lesson.title}
|
||||
</h1>
|
||||
<div className="prose prose-slate prose-lg dark:prose-invert max-w-none">
|
||||
{lessonContent}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
66
nx-dev/ui-courses/src/lib/lessons-list.tsx
Normal file
66
nx-dev/ui-courses/src/lib/lessons-list.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { Course, Lesson } from '@nx/nx-dev/data-access-courses';
|
||||
import Link from 'next/link';
|
||||
import { cx } from '@nx/nx-dev/ui-primitives';
|
||||
|
||||
export function LessonsList({
|
||||
course,
|
||||
lesson,
|
||||
}: {
|
||||
course: Course;
|
||||
lesson?: Lesson;
|
||||
}) {
|
||||
return (
|
||||
<nav className="toc">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xl font-semibold text-slate-900 dark:text-slate-100">
|
||||
Contents
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<span>{course.lessons.length} Lessons</span>
|
||||
<span className="text-slate-300 dark:text-slate-600">•</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{course.totalDuration}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 rounded-lg border border-slate-200 bg-white p-4 dark:border-slate-700/50 dark:bg-slate-900">
|
||||
<ul className="flex flex-col space-y-3">
|
||||
{course.lessons.map((courseLesson, index) => (
|
||||
<li key={courseLesson.id}>
|
||||
<Link
|
||||
href={`/courses/${course.id}/${courseLesson.id}`}
|
||||
className={cx('group flex transition', {
|
||||
'text-slate-900 dark:text-slate-100':
|
||||
lesson && courseLesson.id === lesson.id,
|
||||
'text-slate-700 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200':
|
||||
!(lesson && courseLesson.id === lesson.id),
|
||||
})}
|
||||
prefetch={false}
|
||||
>
|
||||
<span className="inline-block min-w-[2rem] flex-shrink-0 text-sm font-medium text-slate-400 dark:text-slate-600">
|
||||
{(index + 1).toString().padStart(1, '0')}
|
||||
</span>
|
||||
<span className="text-[15px] font-medium leading-normal">
|
||||
{courseLesson.title}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
20
nx-dev/ui-courses/tsconfig.json
Normal file
20
nx-dev/ui-courses/tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
15
nx-dev/ui-courses/tsconfig.lib.json
Normal file
15
nx-dev/ui-courses/tsconfig.lib.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.tsx"
|
||||
]
|
||||
}
|
||||
18
nx-dev/ui-video-courses/.eslintrc.json
Normal file
18
nx-dev/ui-video-courses/.eslintrc.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": ["plugin:@nx/react", "../../.eslintrc.json"],
|
||||
"ignorePatterns": ["!**/*"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
8
nx-dev/ui-video-courses/project.json
Normal file
8
nx-dev/ui-video-courses/project.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "ui-video-courses",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "nx-dev/ui-video-courses/src",
|
||||
"projectType": "library",
|
||||
"tags": [],
|
||||
"targets": {}
|
||||
}
|
||||
2
nx-dev/ui-video-courses/src/index.ts
Normal file
2
nx-dev/ui-video-courses/src/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './lib/course-hero';
|
||||
export * from './lib/course-overview';
|
||||
18
nx-dev/ui-video-courses/src/lib/course-hero.tsx
Normal file
18
nx-dev/ui-video-courses/src/lib/course-hero.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { SectionHeading } from '@nx/nx-dev/ui-common';
|
||||
import { JSX } from 'react';
|
||||
|
||||
export function CourseHero(): JSX.Element {
|
||||
return (
|
||||
<section className="relative overflow-hidden py-16 dark:from-slate-900 dark:to-slate-950">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<SectionHeading as="h1" variant="display">
|
||||
Nx Video Courses
|
||||
</SectionHeading>
|
||||
<SectionHeading as="p" variant="subtitle" className="mt-6">
|
||||
Master Nx with expert-led video courses from the core team. Boost your
|
||||
skills and productivity.
|
||||
</SectionHeading>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
69
nx-dev/ui-video-courses/src/lib/course-overview.tsx
Normal file
69
nx-dev/ui-video-courses/src/lib/course-overview.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import Link from 'next/link';
|
||||
import { Course } from '@nx/nx-dev/data-access-courses';
|
||||
import { cx } from '@nx/nx-dev/ui-primitives';
|
||||
import { ClockIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface CourseOverviewProps {
|
||||
courses: Course[];
|
||||
}
|
||||
|
||||
export function CourseOverview({ courses }: CourseOverviewProps): JSX.Element {
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-2xl lg:max-w-none">
|
||||
<div className="mt-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{courses.map((course) => (
|
||||
<Link
|
||||
key={course.id}
|
||||
href={`/courses/${course.id}`}
|
||||
className="block h-full transform-gpu"
|
||||
prefetch={false}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
'group relative h-full w-full overflow-hidden rounded-2xl border border-slate-200 bg-white p-8',
|
||||
'dark:border-slate-800 dark:bg-slate-900 dark:hover:border-blue-900 dark:hover:shadow-blue-900/20',
|
||||
'before:absolute before:inset-0 before:z-0 before:bg-gradient-to-br before:from-blue-50 before:to-transparent before:opacity-0 before:transition-opacity',
|
||||
'transition-all duration-300 ease-out',
|
||||
'hover:-translate-y-2 hover:border-blue-200 hover:shadow-xl hover:shadow-blue-100/50',
|
||||
'hover:before:opacity-100 dark:before:from-blue-950'
|
||||
)}
|
||||
>
|
||||
<div className="relative z-10">
|
||||
<p className="text-2xl font-semibold text-slate-900 transition-colors duration-200 dark:text-slate-100">
|
||||
{course.title}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex items-center gap-2 text-sm font-medium text-slate-400 dark:text-slate-500">
|
||||
{course.authors?.[0]?.name && (
|
||||
<>
|
||||
<span>{course.authors[0].name}</span>
|
||||
<span className="text-slate-300 dark:text-slate-600">
|
||||
•
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span>{course.lessons.length} lessons</span>
|
||||
<span className="text-slate-300 dark:text-slate-600">
|
||||
•
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<ClockIcon className="h-3 w-3" />
|
||||
{course.totalDuration}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<p className="text-[15px] leading-relaxed text-slate-600 transition-colors duration-200 dark:text-slate-400">
|
||||
{course.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
nx-dev/ui-video-courses/src/server.ts
Normal file
1
nx-dev/ui-video-courses/src/server.ts
Normal file
@ -0,0 +1 @@
|
||||
// Use this file to export React server components
|
||||
17
nx-dev/ui-video-courses/tsconfig.json
Normal file
17
nx-dev/ui-video-courses/tsconfig.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": false,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
}
|
||||
],
|
||||
"extends": "../../tsconfig.base.json"
|
||||
}
|
||||
23
nx-dev/ui-video-courses/tsconfig.lib.json
Normal file
23
nx-dev/ui-video-courses/tsconfig.lib.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"types": [
|
||||
"node",
|
||||
"@nx/react/typings/cssmodule.d.ts",
|
||||
"@nx/react/typings/image.d.ts"
|
||||
]
|
||||
},
|
||||
"exclude": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.jsx",
|
||||
"src/**/*.test.jsx"
|
||||
],
|
||||
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
@ -13,7 +13,14 @@ const basePath = 'docs';
|
||||
const sharedFilesPattern = 'shared/cli';
|
||||
|
||||
const readmePathList: string[] = glob
|
||||
.sync(`${basePath}/**/!(blog|documents|changelog)/!(README).md`)
|
||||
.sync(`${basePath}/**/!(README).md`, {
|
||||
ignore: [
|
||||
`${basePath}/**/blog/**`,
|
||||
`${basePath}/**/documents/**`,
|
||||
`${basePath}/**/changelog/**`,
|
||||
`${basePath}/**/courses/**`,
|
||||
],
|
||||
})
|
||||
.map((path: string) => path.split(basePath)[1])
|
||||
.map((path: string) => path.slice(1, -3)) // Removing first `/` and `.md`
|
||||
.filter((path: string) => !path.startsWith(sharedFilesPattern));
|
||||
|
||||
@ -57,6 +57,9 @@
|
||||
"@nx/nx-dev/data-access-careers/node-only": [
|
||||
"nx-dev/data-access-careers/src/node.index.ts"
|
||||
],
|
||||
"@nx/nx-dev/data-access-courses": [
|
||||
"nx-dev/data-access-courses/src/index.ts"
|
||||
],
|
||||
"@nx/nx-dev/data-access-documents": [
|
||||
"nx-dev/data-access-documents/src/index.ts"
|
||||
],
|
||||
@ -98,6 +101,7 @@
|
||||
"@nx/nx-dev/ui-company": ["nx-dev/ui-company/src/index.ts"],
|
||||
"@nx/nx-dev/ui-conference": ["nx-dev/ui-conference/src/index.ts"],
|
||||
"@nx/nx-dev/ui-contact": ["nx-dev/ui-contact/src/index.ts"],
|
||||
"@nx/nx-dev/ui-courses": ["nx-dev/ui-courses/src/index.ts"],
|
||||
"@nx/nx-dev/ui-customers": ["nx-dev/ui-customers/src/index.ts"],
|
||||
"@nx/nx-dev/ui-enterprise": ["nx-dev/ui-enterprise/src/index.ts"],
|
||||
"@nx/nx-dev/ui-fence": ["nx-dev/ui-fence/src/index.ts"],
|
||||
@ -112,6 +116,7 @@
|
||||
"@nx/nx-dev/ui-references": ["nx-dev/ui-references/src/index.ts"],
|
||||
"@nx/nx-dev/ui-sponsor-card": ["nx-dev/ui-sponsor-card/src/index.ts"],
|
||||
"@nx/nx-dev/ui-theme": ["nx-dev/ui-theme/src/index.ts"],
|
||||
"@nx/nx-dev/ui-video-courses": ["nx-dev/ui-video-courses/src/index.ts"],
|
||||
"@nx/nx-dev/util-ai": ["nx-dev/util-ai/src/index.ts"],
|
||||
"@nx/playwright": ["packages/playwright/index.ts"],
|
||||
"@nx/playwright/*": ["packages/playwright/*"],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user