mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2026-03-14 02:32:00 +08:00
Compare commits
130 Commits
dashboard
...
feat/delet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9663ba019e | ||
|
|
f82eb09f60 | ||
|
|
a60bcb5a45 | ||
|
|
3cf2ad9b25 | ||
|
|
6449c24398 | ||
|
|
eaddc81383 | ||
|
|
b6e0d566a6 | ||
|
|
01f93d95fb | ||
|
|
fbd39e9079 | ||
|
|
3bc00b5b1a | ||
|
|
340ae002ca | ||
|
|
98d8510b60 | ||
|
|
a82a0e6efb | ||
|
|
b17ba1b009 | ||
|
|
a07a5af543 | ||
|
|
16db649baf | ||
|
|
017fe3e0a4 | ||
|
|
1f727d2e17 | ||
|
|
281f6f369c | ||
|
|
eb5e5fadcc | ||
|
|
4996d51340 | ||
|
|
ea944a001e | ||
|
|
6d28ab40a8 | ||
|
|
ebb88721b6 | ||
|
|
8878d04f98 | ||
|
|
1085c33dc4 | ||
|
|
6b9007c530 | ||
|
|
5ff89fa184 | ||
|
|
dfff959916 | ||
|
|
3ba9abe7e3 | ||
|
|
fbd149f955 | ||
|
|
d78fd6ccff | ||
|
|
2be8dbe0c2 | ||
|
|
79c6e2be53 | ||
|
|
cc5585171c | ||
|
|
38cd727e48 | ||
|
|
fda56a5d30 | ||
|
|
e27146d549 | ||
|
|
eb95da0bb0 | ||
|
|
554e61947b | ||
|
|
107ae1923b | ||
|
|
cb64894e49 | ||
|
|
faf43f7905 | ||
|
|
c9f450e166 | ||
|
|
3b6d620ed8 | ||
|
|
bd937f5dbe | ||
|
|
cf05610b86 | ||
|
|
8b61bbfcbb | ||
|
|
2c39611b47 | ||
|
|
475cb85600 | ||
|
|
37de8700d5 | ||
|
|
9db05bddbd | ||
|
|
4bda81bf52 | ||
|
|
00c5254ea9 | ||
|
|
bc97fc4c03 | ||
|
|
f953b96d52 | ||
|
|
40793efe4e | ||
|
|
22a29605d8 | ||
|
|
cbfd4f7fcb | ||
|
|
992cf82e5c | ||
|
|
ea89ac864c | ||
|
|
35a4a93ca7 | ||
|
|
2544d4b12d | ||
|
|
d6ee7ef76a | ||
|
|
daa2c03643 | ||
|
|
92a61e7c45 | ||
|
|
e6ad9b29d6 | ||
|
|
ea040b7912 | ||
|
|
28e874bdcd | ||
|
|
4125c618d7 | ||
|
|
088615d13f | ||
|
|
b02d07917d | ||
|
|
9f1d44e542 | ||
|
|
587e8a197f | ||
|
|
5023e962b3 | ||
|
|
28c41b02c0 | ||
|
|
7a80313f1d | ||
|
|
6bc1233253 | ||
|
|
f82ca986c5 | ||
|
|
d45b08c5d3 | ||
|
|
d0c6ae1835 | ||
|
|
be2d3a0d0e | ||
|
|
c81c799e44 | ||
|
|
0bcf1b09bd | ||
|
|
f7f0270f75 | ||
|
|
8033ccbe6d | ||
|
|
694c208ee3 | ||
|
|
41c7388f63 | ||
|
|
2f2a9b2d32 | ||
|
|
083ec6c0d7 | ||
|
|
32690e98da | ||
|
|
ed8c2f3168 | ||
|
|
1a9f53150b | ||
|
|
1779eef91b | ||
|
|
d7b3f82d18 | ||
|
|
fb34a7176c | ||
|
|
c5316929ba | ||
|
|
1ef1818327 | ||
|
|
472c7f415b | ||
|
|
c43d294ab4 | ||
|
|
06747df054 | ||
|
|
2aeb2ad65e | ||
|
|
3fd6e859df | ||
|
|
6d85bbe488 | ||
|
|
5bb1252795 | ||
|
|
dfaf120314 | ||
|
|
be5ccbbc0b | ||
|
|
12950051d6 | ||
|
|
03cd25b6c0 | ||
|
|
829615ffec | ||
|
|
6a24436418 | ||
|
|
c726a1a342 | ||
|
|
7d7835ee9c | ||
|
|
17d30c0e8d | ||
|
|
62bd5c339f | ||
|
|
111f702b9b | ||
|
|
3dd115dce1 | ||
|
|
75925cb53a | ||
|
|
de5bed02f8 | ||
|
|
72c670570b | ||
|
|
a450b4ed5a | ||
|
|
66be61efa3 | ||
|
|
caddd0f93d | ||
|
|
cc32cbe79c | ||
|
|
f9d39db24a | ||
|
|
203bbc6eae | ||
|
|
31a852113f | ||
|
|
66119e935b | ||
|
|
3374fafe5b | ||
|
|
8ed47a2e71 |
@@ -3,6 +3,6 @@
|
||||
"enabled": false
|
||||
},
|
||||
"_variables": {
|
||||
"lastUpdateCheck": 1738019390029
|
||||
"lastUpdateCheck": 1741697790683
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
PUBLIC_API_URL=https://api.roadmap.sh
|
||||
PUBLIC_AVATAR_BASE_URL=https://dodrc8eu8m09s.cloudfront.net/avatars
|
||||
PUBLIC_EDITOR_APP_URL=https://draw.roadmap.sh
|
||||
PUBLIC_COURSE_APP_URL=http://localhost:5173
|
||||
PUBLIC_COURSE_APP_URL=http://localhost:5173
|
||||
|
||||
PUBLIC_STRIPE_INDIVIDUAL_MONTHLY_PRICE_ID=
|
||||
PUBLIC_STRIPE_INDIVIDUAL_YEARLY_PRICE_ID=
|
||||
|
||||
PUBLIC_STRIPE_INDIVIDUAL_MONTHLY_PRICE_AMOUNT=10
|
||||
PUBLIC_STRIPE_INDIVIDUAL_YEARLY_PRICE_AMOUNT=100
|
||||
@@ -34,6 +34,7 @@
|
||||
"@astrojs/sitemap": "^3.2.0",
|
||||
"@astrojs/tailwind": "^5.1.2",
|
||||
"@fingerprintjs/fingerprintjs": "^4.5.0",
|
||||
"@microsoft/clarity": "^1.0.0",
|
||||
"@nanostores/react": "^0.8.0",
|
||||
"@napi-rs/image": "^1.9.2",
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
@@ -52,6 +53,7 @@
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.452.0",
|
||||
"luxon": "^3.5.0",
|
||||
"markdown-it-async": "^2.0.0",
|
||||
"nanoid": "^5.0.7",
|
||||
"nanostores": "^0.11.3",
|
||||
"node-html-parser": "^6.1.13",
|
||||
@@ -62,6 +64,7 @@
|
||||
"react-calendar-heatmap": "^1.9.0",
|
||||
"react-confetti": "^6.1.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-textarea-autosize": "^8.5.7",
|
||||
"react-tooltip": "^5.28.0",
|
||||
"reactflow": "^11.11.4",
|
||||
"rehype-external-links": "^3.0.0",
|
||||
@@ -71,15 +74,17 @@
|
||||
"satori": "^0.11.2",
|
||||
"satori-html": "^0.3.2",
|
||||
"sharp": "^0.33.5",
|
||||
"shiki": "^3.1.0",
|
||||
"slugify": "^1.6.6",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"tailwind-merge": "^2.5.3",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"turndown": "^7.2.0",
|
||||
"unified": "^11.0.5",
|
||||
"zustand": "^4.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ai-sdk/google": "^1.1.19",
|
||||
"@playwright/test": "^1.48.0",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@types/dom-to-image": "^2.6.7",
|
||||
@@ -90,6 +95,7 @@
|
||||
"@types/react-slick": "^0.23.13",
|
||||
"@types/sanitize-html": "^2.13.0",
|
||||
"@types/turndown": "^5.0.5",
|
||||
"ai": "^4.1.51",
|
||||
"csv-parser": "^3.0.0",
|
||||
"gh-pages": "^6.2.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
|
||||
482
pnpm-lock.yaml
generated
482
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Binary file not shown.
BIN
public/pdfs/roadmaps/cloudflare.pdf
Normal file
BIN
public/pdfs/roadmaps/cloudflare.pdf
Normal file
Binary file not shown.
Binary file not shown.
@@ -42,7 +42,7 @@
|
||||
]
|
||||
},
|
||||
"mwPJh33MEUQ4Co_LiVEOb": {
|
||||
"title": "Differential Calculus",
|
||||
"title": "Differential Calculus ",
|
||||
"description": "",
|
||||
"links": [
|
||||
{
|
||||
@@ -330,17 +330,17 @@
|
||||
"links": [
|
||||
{
|
||||
"title": "Advantages and Disadvantages of AI",
|
||||
"url": "https://towardsdatascience.com/advantages-and-disadvantages-of-artificial-intelligence-182a5ef6588c",
|
||||
"url": "https://medium.com/@laners.org/advantages-and-disadvantages-of-artificial-intelligence-cd6e42819b20",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Reinforcement Learning 101",
|
||||
"url": "https://towardsdatascience.com/reinforcement-learning-101-e24b50e1d292",
|
||||
"url": "https://medium.com/towards-data-science/reinforcement-learning-101-e24b50e1d292",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Understanding AUC-ROC Curve",
|
||||
"url": "https://towardsdatascience.com/understanding-auc-roc-curve-68b2303cc9c5",
|
||||
"url": "https://medium.com/towards-data-science/understanding-auc-roc-curve-68b2303cc9c5",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -458,8 +458,29 @@
|
||||
},
|
||||
"O7wjldZ3yTA2s_F-UnJw_": {
|
||||
"title": "Rate Limiting",
|
||||
"description": "Rate Limiting is a critical aspect of API Design that dictates the number of API calls a client can make within a specified timeframe. This helps in managing resource allocation, preventing abuse of the API, and maintaining the overall health of the API system. Proper rate limiting measures should be in place to ensure the API's stability, thereby delivering a consistent and reliable service to all consumers. It works primarily by setting a limit on the frequency of client requests, thereby preventing individual users from overloading the system. It is crucial to design and implement rate limiting carefully for maintaining API availability and performance.",
|
||||
"links": []
|
||||
"description": "Rate Limiting is a critical aspect of API Design that dictates the number of API calls a client can make within a specified timeframe. This helps in managing resource allocation, preventing abuse of the API, and maintaining the overall health of the API system. Proper rate limiting measures should be in place to ensure the API's stability, thereby delivering a consistent and reliable service to all consumers. It works primarily by setting a limit on the frequency of client requests, thereby preventing individual users from overloading the system. It is crucial to design and implement rate limiting carefully for maintaining API availability and performance.\n\nLearn more from the following resources:",
|
||||
"links": [
|
||||
{
|
||||
"title": "Rate limit",
|
||||
"url": "https://developer.mozilla.org/en-US/docs/Glossary/Rate_limit",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Throttle",
|
||||
"url": "https://developer.mozilla.org/en-US/docs/Glossary/Throttle",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Debounce",
|
||||
"url": "https://developer.mozilla.org/en-US/docs/Glossary/Debounce",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "What is rate limiting? | Rate limiting and bots",
|
||||
"url": "https://www.cloudflare.com/en-gb/learning/bots/what-is-rate-limiting/",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
},
|
||||
"20KEgZH6cu_UokqWpV-9I": {
|
||||
"title": "Idempotency",
|
||||
@@ -569,6 +590,11 @@
|
||||
"title": "Caching REST API Response",
|
||||
"url": "https://restfulapi.net/caching/",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "HTTP caching",
|
||||
"url": "https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
2854
public/roadmap-content/aspnet-core.json
Normal file
2854
public/roadmap-content/aspnet-core.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1690,8 +1690,8 @@
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Architectural Patterns in a nutshell",
|
||||
"url": "https://towardsdatascience.com/10-common-software-architectural-patterns-in-a-nutshell-a0b47a1e9013",
|
||||
"title": "10 Common Software Architectural Patterns in a nutshell",
|
||||
"url": "https://theiotacademy.medium.com/10-common-software-architectural-patterns-in-a-nutshell-1b1f6cf5036b",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
|
||||
1643
public/roadmap-content/cloudflare.json
Normal file
1643
public/roadmap-content/cloudflare.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -55,12 +55,12 @@
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Python Website",
|
||||
"title": "Python",
|
||||
"url": "https://www.python.org/",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Python Getting Started",
|
||||
"title": "Getting Started with Python",
|
||||
"url": "https://www.python.org/about/gettingstarted/",
|
||||
"type": "article"
|
||||
},
|
||||
@@ -182,6 +182,11 @@
|
||||
"title": "Rust",
|
||||
"description": "Rust is a modern systems programming language focusing on safety, speed, and concurrency. It accomplishes these goals by being memory safe without using garbage collection.\n\nVisit the following resources to learn more:",
|
||||
"links": [
|
||||
{
|
||||
"title": "Visit Dedicated Rust Roadmap",
|
||||
"url": "https://roadmap.sh/rust",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "The Rust Programming Language - online book",
|
||||
"url": "https://doc.rust-lang.org/book/",
|
||||
@@ -213,6 +218,11 @@
|
||||
"title": "C++",
|
||||
"description": "C++ is a powerful general-purpose programming language. It can be used to develop operating systems, browsers, games, and so on. C++ supports different ways of programming like procedural, object-oriented, functional, and so on. This makes C++ powerful as well as flexible.\n\nVisit the following resources to learn more:",
|
||||
"links": [
|
||||
{
|
||||
"title": "C++ Roadmap",
|
||||
"url": "https://roadmap.sh/cpp",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Learn C++",
|
||||
"url": "https://learncpp.com/",
|
||||
@@ -238,11 +248,6 @@
|
||||
"url": "https://www.w3schools.com/cpp/default.asp",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "C++ Roadmap",
|
||||
"url": "https://roadmap.sh/cpp",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Explore top posts about C++ Programming",
|
||||
"url": "https://app.daily.dev/tags/c++?ref=roadmapsh",
|
||||
@@ -326,6 +331,11 @@
|
||||
"url": "https://techdevguide.withgoogle.com/paths/data-structures-and-algorithms/",
|
||||
"type": "course"
|
||||
},
|
||||
{
|
||||
"title": "Visit Dedicated DSA Roadmap",
|
||||
"url": "https://roadmap.sh/datastructures-and-algorithms",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Data Structures and Algorithms",
|
||||
"url": "https://www.javatpoint.com/data-structure-tutorial",
|
||||
@@ -645,6 +655,11 @@
|
||||
"title": "Complete Binary Tree - Programiz",
|
||||
"url": "https://www.programiz.com/dsa/complete-binary-tree",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Complete Binary Trees",
|
||||
"url": "https://www.wscubetech.com/resources/dsa/complete-binary-tree",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -771,8 +786,14 @@
|
||||
},
|
||||
"HZ1kk0TQ13FLC9t13BZl5": {
|
||||
"title": "Adjacency Matrix",
|
||||
"description": "",
|
||||
"links": []
|
||||
"description": "An adjacency matrix is a square matrix used to represent a finite graph. It is used to represent the connections between vertices in a graph. The matrix is filled with 0s and 1s, where a 1 represents a connection between two vertices and a 0 represents no connection.\n\nVisit the following resources to learn more:",
|
||||
"links": [
|
||||
{
|
||||
"title": "Adjacency Matrix",
|
||||
"url": "https://en.wikipedia.org/wiki/Adjacency_matrix",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
},
|
||||
"rTnKJcPniUtqvfOyC88N0": {
|
||||
"title": "Adjacency List",
|
||||
@@ -1017,8 +1038,14 @@
|
||||
},
|
||||
"7a6-AnBI-3tAU1dkOvPkx": {
|
||||
"title": "Common Algorithms",
|
||||
"description": "Here are some common algorithms that you should know. You can find more information about them in the [Algorithms](https://www.khanacademy.org/computing/computer-science/algorithms) section of the Computer Science course.\n\n* Sorting\n* Recursion\n* Searching\n* Cache Algorithms\n* Tree Algorithms\n* Graph Algorithms\n* Greedy Algorithms\n* Backtracking\n* Substring Search\n* Suffix Arrays\n* Dynamic Programming",
|
||||
"links": []
|
||||
"description": "Here are some common algorithms that you should know. You can find more information about them in the Algorithms section of the Computer Science course.\n\n* Sorting\n* Recursion\n* Searching\n* Cache Algorithms\n* Tree Algorithms\n* Graph Algorithms\n* Greedy Algorithms\n* Backtracking\n* Substring Search\n* Suffix Arrays\n* Dynamic Programming\n\nVisit the following resources to learn more:",
|
||||
"links": [
|
||||
{
|
||||
"title": "Top Algorithms and Data Structures",
|
||||
"url": "https://towardsdatascience.com/top-algorithms-and-data-structures-you-really-need-to-know-ab9a2a91c7b5",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
},
|
||||
"0_qNhprnXU3i8koW3XTdD": {
|
||||
"title": "Tail Recursion",
|
||||
@@ -1143,20 +1170,26 @@
|
||||
"XwyqBK9rgP1MMcJrdIzm5": {
|
||||
"title": "Linear Search",
|
||||
"description": "Linear search is a very simple algorithm that is used to search for a value in an array. It sequentially checks each element of the array until a match is found or until all the elements have been searched.\n\nVisit the following resources to learn more:",
|
||||
"links": []
|
||||
"links": [
|
||||
{
|
||||
"title": "Linear Search",
|
||||
"url": "https://www.programiz.com/dsa/linear-search",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
},
|
||||
"4wGBYFZpcdTt97WTbSazx": {
|
||||
"title": "Bubble Sort",
|
||||
"description": "Bubble sort is a simple sorting algorithm that repeatedly steps through the list, compares adjacent elements and swaps them if they are in the wrong order. The pass through the list is repeated until the list is sorted.\n\nVisit the following resources to learn more:",
|
||||
"links": [
|
||||
{
|
||||
"title": "Bubble Sort",
|
||||
"url": "https://www.youtube.com/watch?v=P00xJgWzz2c&index=1&list=PL89B61F78B552C1AB",
|
||||
"type": "video"
|
||||
"title": "Bubble Sort Algorithm",
|
||||
"url": "https://www.programiz.com/dsa/bubble-sort",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Analyzing Bubble Sort",
|
||||
"url": "https://www.youtube.com/watch?v=ni_zk257Nqo&index=7&list=PL89B61F78B552C1AB",
|
||||
"title": "Bubble Sort",
|
||||
"url": "https://www.youtube.com/watch?v=P00xJgWzz2c&index=1&list=PL89B61F78B552C1AB",
|
||||
"type": "video"
|
||||
},
|
||||
{
|
||||
@@ -1375,7 +1408,13 @@
|
||||
"Yf5gOIe7oiL19MjEVcpdw": {
|
||||
"title": "Depth First Search",
|
||||
"description": "Depth first search is a graph traversal algorithm that starts at a root node and explores as far as possible along each branch before backtracking.\n\nVisit the following resources to learn more:",
|
||||
"links": []
|
||||
"links": [
|
||||
{
|
||||
"title": "Depth-first Search",
|
||||
"url": "https://en.wikipedia.org/wiki/Depth-first_search",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
},
|
||||
"eY4nK2lPYsrR-a_8y2sao": {
|
||||
"title": "Bellman Ford's Algorithm",
|
||||
@@ -1442,8 +1481,13 @@
|
||||
},
|
||||
"aBjBHpq_OajgQjxdCobXD": {
|
||||
"title": "Finding Hamiltonian Paths",
|
||||
"description": "Hamiltonian paths are paths that visit every node in a graph exactly once. They are named after the famous mathematician [Hamilton](https://en.wikipedia.org/wiki/William_Rowan_Hamilton). Hamiltonian paths are a special case of [Hamiltonian cycles](https://en.wikipedia.org/wiki/Hamiltonian_cycle), which are cycles that visit every node in a graph exactly once.\n\nVisit the following resources to learn more:",
|
||||
"description": "Hamiltonian paths are paths that visit every node in a graph exactly once. They are named after the famous mathematician Hamilton. Hamiltonian paths are a special case of Hamiltonian cycles, which are cycles that visit every node in a graph exactly once.\n\nVisit the following resources to learn more:",
|
||||
"links": [
|
||||
{
|
||||
"title": "Hamiltonian Cycles",
|
||||
"url": "https://en.wikipedia.org/wiki/Hamiltonian_cycle",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Hamiltonian Path",
|
||||
"url": "https://www.hackerearth.com/practice/algorithms/graphs/hamiltonian-path/tutorial/",
|
||||
@@ -1967,13 +2011,13 @@
|
||||
"description": "Class Diagrams are used to model the static structure of a system. They are used to show the classes, their attributes, operations (or methods), and the relationships between objects.\n\nVisit the following resources to learn more:",
|
||||
"links": [
|
||||
{
|
||||
"title": "UML Class Diagram Tutorial",
|
||||
"url": "https://www.youtube.com/watch?v=UI6lqHOVHic",
|
||||
"type": "video"
|
||||
"title": "Class Diagrams",
|
||||
"url": "https://www.visual-paradigm.com/guide/uml-unified-modeling-language/uml-class-diagram-tutorial/",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "UML Class Diagram Tutorial",
|
||||
"url": "https://www.youtube.com/watch?v=3cmzqZzwNDM&list=PLfoY2ARMh0hC2FcJKP5voAKCpk6PZXSd5&index=2",
|
||||
"url": "https://www.youtube.com/watch?v=UI6lqHOVHic",
|
||||
"type": "video"
|
||||
}
|
||||
]
|
||||
@@ -2354,26 +2398,6 @@
|
||||
"title": "NP Completeness IV",
|
||||
"url": "https://www.youtube.com/watch?v=NKLDp3Rch3M&list=PLFDnELG9dpVxQCxuD-9BSy2E7BWY3t5Sm&index=18",
|
||||
"type": "video"
|
||||
},
|
||||
{
|
||||
"title": "CSE373 2020 - Lecture 23 - NP-Completeness",
|
||||
"url": "https://www.youtube.com/watch?v=ItHp5laE1VE&list=PLOtl7M3yp-DX6ic0HGT0PUX_wiNmkWkXx&index=23",
|
||||
"type": "video"
|
||||
},
|
||||
{
|
||||
"title": "CSE373 2020 - Lecture 24 - Satisfiability",
|
||||
"url": "https://www.youtube.com/watch?v=inaFJeCzGxU&list=PLOtl7M3yp-DX6ic0HGT0PUX_wiNmkWkXx&index=24",
|
||||
"type": "video"
|
||||
},
|
||||
{
|
||||
"title": "CSE373 2020 - Lecture 25 - More NP-Completeness",
|
||||
"url": "https://www.youtube.com/watch?v=B-bhKxjZLlc&list=PLOtl7M3yp-DX6ic0HGT0PUX_wiNmkWkXx&index=25",
|
||||
"type": "video"
|
||||
},
|
||||
{
|
||||
"title": "CSE373 2020 - Lecture 26 - NP-Completeness Challenge",
|
||||
"url": "https://www.youtube.com/watch?v=_EzetTkG_Cc&list=PLOtl7M3yp-DX6ic0HGT0PUX_wiNmkWkXx&index=26",
|
||||
"type": "video"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2425,26 +2449,6 @@
|
||||
"title": "NP Completeness IV",
|
||||
"url": "https://www.youtube.com/watch?v=NKLDp3Rch3M&list=PLFDnELG9dpVxQCxuD-9BSy2E7BWY3t5Sm&index=18",
|
||||
"type": "video"
|
||||
},
|
||||
{
|
||||
"title": "CSE373 2020 - Lecture 23 - NP-Completeness",
|
||||
"url": "https://www.youtube.com/watch?v=ItHp5laE1VE&list=PLOtl7M3yp-DX6ic0HGT0PUX_wiNmkWkXx&index=23",
|
||||
"type": "video"
|
||||
},
|
||||
{
|
||||
"title": "CSE373 2020 - Lecture 24 - Satisfiability",
|
||||
"url": "https://www.youtube.com/watch?v=inaFJeCzGxU&list=PLOtl7M3yp-DX6ic0HGT0PUX_wiNmkWkXx&index=24",
|
||||
"type": "video"
|
||||
},
|
||||
{
|
||||
"title": "CSE373 2020 - Lecture 25 - More NP-Completeness",
|
||||
"url": "https://www.youtube.com/watch?v=B-bhKxjZLlc&list=PLOtl7M3yp-DX6ic0HGT0PUX_wiNmkWkXx&index=25",
|
||||
"type": "video"
|
||||
},
|
||||
{
|
||||
"title": "CSE373 2020 - Lecture 26 - NP-Completeness Challenge",
|
||||
"url": "https://www.youtube.com/watch?v=_EzetTkG_Cc&list=PLOtl7M3yp-DX6ic0HGT0PUX_wiNmkWkXx&index=26",
|
||||
"type": "video"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2496,26 +2500,6 @@
|
||||
"title": "NP Completeness IV",
|
||||
"url": "https://www.youtube.com/watch?v=NKLDp3Rch3M&list=PLFDnELG9dpVxQCxuD-9BSy2E7BWY3t5Sm&index=18",
|
||||
"type": "video"
|
||||
},
|
||||
{
|
||||
"title": "CSE373 2020 - Lecture 23 - NP-Completeness",
|
||||
"url": "https://www.youtube.com/watch?v=ItHp5laE1VE&list=PLOtl7M3yp-DX6ic0HGT0PUX_wiNmkWkXx&index=23",
|
||||
"type": "video"
|
||||
},
|
||||
{
|
||||
"title": "CSE373 2020 - Lecture 24 - Satisfiability",
|
||||
"url": "https://www.youtube.com/watch?v=inaFJeCzGxU&list=PLOtl7M3yp-DX6ic0HGT0PUX_wiNmkWkXx&index=24",
|
||||
"type": "video"
|
||||
},
|
||||
{
|
||||
"title": "CSE373 2020 - Lecture 25 - More NP-Completeness",
|
||||
"url": "https://www.youtube.com/watch?v=B-bhKxjZLlc&list=PLOtl7M3yp-DX6ic0HGT0PUX_wiNmkWkXx&index=25",
|
||||
"type": "video"
|
||||
},
|
||||
{
|
||||
"title": "CSE373 2020 - Lecture 26 - NP-Completeness Challenge",
|
||||
"url": "https://www.youtube.com/watch?v=_EzetTkG_Cc&list=PLOtl7M3yp-DX6ic0HGT0PUX_wiNmkWkXx&index=26",
|
||||
"type": "video"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2567,26 +2551,6 @@
|
||||
"title": "NP Completeness IV",
|
||||
"url": "https://www.youtube.com/watch?v=NKLDp3Rch3M&list=PLFDnELG9dpVxQCxuD-9BSy2E7BWY3t5Sm&index=18",
|
||||
"type": "video"
|
||||
},
|
||||
{
|
||||
"title": "CSE373 2020 - Lecture 23 - NP-Completeness",
|
||||
"url": "https://www.youtube.com/watch?v=ItHp5laE1VE&list=PLOtl7M3yp-DX6ic0HGT0PUX_wiNmkWkXx&index=23",
|
||||
"type": "video"
|
||||
},
|
||||
{
|
||||
"title": "CSE373 2020 - Lecture 24 - Satisfiability",
|
||||
"url": "https://www.youtube.com/watch?v=inaFJeCzGxU&list=PLOtl7M3yp-DX6ic0HGT0PUX_wiNmkWkXx&index=24",
|
||||
"type": "video"
|
||||
},
|
||||
{
|
||||
"title": "CSE373 2020 - Lecture 25 - More NP-Completeness",
|
||||
"url": "https://www.youtube.com/watch?v=B-bhKxjZLlc&list=PLOtl7M3yp-DX6ic0HGT0PUX_wiNmkWkXx&index=25",
|
||||
"type": "video"
|
||||
},
|
||||
{
|
||||
"title": "CSE373 2020 - Lecture 26 - NP-Completeness Challenge",
|
||||
"url": "https://www.youtube.com/watch?v=_EzetTkG_Cc&list=PLOtl7M3yp-DX6ic0HGT0PUX_wiNmkWkXx&index=26",
|
||||
"type": "video"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2643,26 +2607,6 @@
|
||||
"title": "NP Completeness IV",
|
||||
"url": "https://www.youtube.com/watch?v=NKLDp3Rch3M&list=PLFDnELG9dpVxQCxuD-9BSy2E7BWY3t5Sm&index=18",
|
||||
"type": "video"
|
||||
},
|
||||
{
|
||||
"title": "CSE373 2020 - Lecture 23 - NP-Completeness",
|
||||
"url": "https://www.youtube.com/watch?v=ItHp5laE1VE&list=PLOtl7M3yp-DX6ic0HGT0PUX_wiNmkWkXx&index=23",
|
||||
"type": "video"
|
||||
},
|
||||
{
|
||||
"title": "CSE373 2020 - Lecture 24 - Satisfiability",
|
||||
"url": "https://www.youtube.com/watch?v=inaFJeCzGxU&list=PLOtl7M3yp-DX6ic0HGT0PUX_wiNmkWkXx&index=24",
|
||||
"type": "video"
|
||||
},
|
||||
{
|
||||
"title": "CSE373 2020 - Lecture 25 - More NP-Completeness",
|
||||
"url": "https://www.youtube.com/watch?v=B-bhKxjZLlc&list=PLOtl7M3yp-DX6ic0HGT0PUX_wiNmkWkXx&index=25",
|
||||
"type": "video"
|
||||
},
|
||||
{
|
||||
"title": "CSE373 2020 - Lecture 26 - NP-Completeness Challenge",
|
||||
"url": "https://www.youtube.com/watch?v=_EzetTkG_Cc&list=PLOtl7M3yp-DX6ic0HGT0PUX_wiNmkWkXx&index=26",
|
||||
"type": "video"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2941,11 +2885,6 @@
|
||||
"url": "https://www.youtube.com/watch?v=svfnVhJOfMc&index=8&list=PLA5Lqm4uh9Bbq-E0ZnqTIa8LRaL77ica6",
|
||||
"type": "video"
|
||||
},
|
||||
{
|
||||
"title": "MIT 6.851 - Memory Hierarchy Models",
|
||||
"url": "https://www.youtube.com/watch?v=V3omVLzI0WE&index=7&list=PLUl4u3cNGP61hsJNdULdudlRL493b-XZf",
|
||||
"type": "video"
|
||||
},
|
||||
{
|
||||
"title": "B-Trees (playlist) in 26 minutes",
|
||||
"url": "https://www.youtube.com/playlist?list=PL9xmBV_5YoZNFPPv98DjTdD9X6UI9KMHz",
|
||||
@@ -3057,6 +2996,11 @@
|
||||
"title": "CDN",
|
||||
"description": "A CDN is a network of servers that are distributed geographically. The servers are connected to each other and to the internet. The servers are used to deliver content to users. The content is delivered to the user from the server that is closest to the user. This is done to reduce latency and improve the performance of the content delivery.\n\nVisit the following resources to learn more:",
|
||||
"links": [
|
||||
{
|
||||
"title": "What is a CDN?",
|
||||
"url": "https://www.cloudflare.com/learning/cdn/what-is-a-cdn/",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Content Delivery Network (CDN) - System Design",
|
||||
"url": "https://dev.to/karanpratapsingh/system-design-the-complete-course-10fo#content-delivery-network-cdn",
|
||||
@@ -3163,6 +3107,11 @@
|
||||
"title": "GraphQL",
|
||||
"description": "GraphQL is a query language for APIs and a runtime for fulfilling those queries with your existing data. GraphQL provides a complete and understandable description of the data in your API, gives clients the power to ask for exactly what they need and nothing more, makes it easier to evolve APIs over time, and enables powerful developer tools.\n\nVisit the following resources to learn more:",
|
||||
"links": [
|
||||
{
|
||||
"title": "Visit Dedicated GraphQL Roadmap",
|
||||
"url": "https://roadmap.sh/graphql",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Apollo GraphQL Tutorials",
|
||||
"url": "https://www.apollographql.com/tutorials/",
|
||||
@@ -3227,7 +3176,7 @@
|
||||
"description": "Long polling is a technique used to implement server push functionality over HTTP. It is a method of opening a request on the server and keeping it open until an event occurs, at which point the server responds. This is in contrast to a regular HTTP request, where the server responds immediately with whatever data is available at the time.\n\nVisit the following resources to learn more:",
|
||||
"links": [
|
||||
{
|
||||
"title": "Long polling",
|
||||
"title": "Long Polling",
|
||||
"url": "https://javascript.info/long-polling",
|
||||
"type": "article"
|
||||
},
|
||||
@@ -3252,7 +3201,18 @@
|
||||
"bVjI14VismTHNCyA0mEBP": {
|
||||
"title": "Web Sockets",
|
||||
"description": "Web sockets are a bidirectional communication protocol between a client and a server. They are used for real-time applications like chat, multiplayer games, and live data updates. Web sockets are also used to establish a connection between a server and a client. This connection is then used to send data in both directions.\n\nVisit the following resources to learn more:",
|
||||
"links": []
|
||||
"links": [
|
||||
{
|
||||
"title": "WebSockets",
|
||||
"url": "https://en.wikipedia.org/wiki/WebSocket",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Web Sockets API",
|
||||
"url": "https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
},
|
||||
"JckRqZA8C6IqQLPpTCgf4": {
|
||||
"title": "SSE",
|
||||
@@ -3280,12 +3240,12 @@
|
||||
"description": "A database is a collection of useful data of one or more related organizations structured in a way to make data an asset to the organization. A database management system is a software designed to assist in maintaining and extracting large collections of data in a timely fashion.\n\nVisit the following resources to learn more:",
|
||||
"links": [
|
||||
{
|
||||
"title": "Oracle: What is a Database?",
|
||||
"title": "What is a Database?",
|
||||
"url": "https://www.oracle.com/database/what-is-database/",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Prisma.io: What are Databases?",
|
||||
"title": "What are Databases?",
|
||||
"url": "https://www.prisma.io/dataguide/intro/what-are-databases",
|
||||
"type": "article"
|
||||
},
|
||||
@@ -3362,7 +3322,13 @@
|
||||
"ii1vF74u3yrFNlw_21b3B": {
|
||||
"title": "DDL",
|
||||
"description": "DDL or Data Definition Language actually consists of the SQL commands that can be used to define the database schema. It simply deals with descriptions of the database schema and is used to create and modify the structure of database objects in the database. DDL is a set of SQL commands used to create, modify, and delete database structures but not data. These commands are normally not used by a general user, who should be accessing the database via an application.\n\nVisit the following resources to learn more:",
|
||||
"links": []
|
||||
"links": [
|
||||
{
|
||||
"title": "DDL",
|
||||
"url": "https://en.wikipedia.org/wiki/Data_definition_language",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tcQSH-eAvJUZuePTDjAIb": {
|
||||
"title": "DML",
|
||||
@@ -3383,12 +3349,24 @@
|
||||
"05lkb3B86Won7Rkf-8DeD": {
|
||||
"title": "DQL",
|
||||
"description": "DQL statements are used for performing queries on the data within schema objects. The purpose of the DQL Command is to get some schema relation based on the query passed to it. We can define DQL as follows it is a component of SQL statement that allows getting data from the database and imposing order upon it. It includes the SELECT statement. This command allows getting the data out of the database to perform operations with it. When a SELECT is fired against a table or tables the result is compiled into a further temporary table, which is displayed or perhaps received by the program i.e. a front-end.\n\nVisit the following resources to learn more:",
|
||||
"links": []
|
||||
"links": [
|
||||
{
|
||||
"title": "Data Query Language",
|
||||
"url": "https://en.wikipedia.org/wiki/Data_query_language",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
},
|
||||
"4bUmfuP2qgcli8I2Vm9zh": {
|
||||
"title": "DCL",
|
||||
"description": "DCL includes commands such as GRANT and REVOKE which mainly deal with the rights, permissions, and other controls of the database system.\n\nVisit the following resources to learn more:",
|
||||
"links": []
|
||||
"links": [
|
||||
{
|
||||
"title": "DCL",
|
||||
"url": "https://en.wikipedia.org/wiki/Data_Control_Language",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
},
|
||||
"_sm63rZNKoibVndeNgOpW": {
|
||||
"title": "Locking",
|
||||
@@ -3434,7 +3412,7 @@
|
||||
},
|
||||
"q3nRhTYS5wg9tYnQe2sCF": {
|
||||
"title": "BASE",
|
||||
"description": "The rise in popularity of NoSQL databases provided a flexible and fluidity with ease to manipulate data and as a result, a new database model was designed, reflecting these properties. The acronym BASE is slightly more confusing than ACID but however, the words behind it suggest ways in which the BASE model is different and acronym BASE stands for:-\n\n* **B**asically **A**vailable\n* **S**oft state\n* **E**ventual consistency\n\nVisit the following resources to learn more:",
|
||||
"description": "The rise in popularity of NoSQL databases provided a flexible and fluidity with ease to manipulate data and as a result, a new database model was designed, reflecting these properties. The acronym BASE is slightly more confusing than ACID but however, the words behind it suggest ways in which the BASE model is different and acronym BASE stands for:-\n\n* **B**asically **A**vailable\n* **S**oft state\n* **E**ventual consistency",
|
||||
"links": []
|
||||
},
|
||||
"uqfeiQ9K--QkGNwks4kjk": {
|
||||
@@ -3804,7 +3782,7 @@
|
||||
"description": "Public-key cryptography, or asymmetric cryptography, is the field of cryptographic systems that use pairs of related keys. Each key pair consists of a public key and a corresponding private key. Key pairs are generated with cryptographic algorithms based on mathematical problems termed one-way functions.\n\nVisit the following resources to learn more:",
|
||||
"links": [
|
||||
{
|
||||
"title": "Public-key cryptography - Wikipedia",
|
||||
"title": "Public-key Cryptography",
|
||||
"url": "https://en.wikipedia.org/wiki/Public-key_cryptography",
|
||||
"type": "article"
|
||||
},
|
||||
@@ -3877,7 +3855,12 @@
|
||||
"type": "opensource"
|
||||
},
|
||||
{
|
||||
"title": "Wikipedia - OWASP",
|
||||
"title": "OWASP",
|
||||
"url": "https://owasp.org/",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "OWASP - Wiki",
|
||||
"url": "https://en.wikipedia.org/wiki/OWASP",
|
||||
"type": "article"
|
||||
},
|
||||
@@ -3926,7 +3909,7 @@
|
||||
},
|
||||
"1eglba39q426Nh0E0qcdj": {
|
||||
"title": "How CPU Executes Programs",
|
||||
"description": "Visit the following resources to learn more:",
|
||||
"description": "The CPU executes programs by repeatedly fetching instructions from memory, decoding them to understand the operation, and then executing those operations. This cycle, called the fetch-decode-execute cycle, continues for each instruction in the program, with the CPU using registers for temporary storage and a program counter to keep track of the next instruction. Modern CPUs use techniques like pipelining and caches to speed up this process, enabling them to execute complex programs efficiently.\n\nVisit the following resources to learn more:",
|
||||
"links": [
|
||||
{
|
||||
"title": "Explore top posts about Computing",
|
||||
@@ -3934,7 +3917,7 @@
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "How CPU executes a program",
|
||||
"title": "How CPU Executes a Program",
|
||||
"url": "https://www.youtube.com/watch?v=XM4lGflQFvA",
|
||||
"type": "video"
|
||||
}
|
||||
@@ -3964,8 +3947,13 @@
|
||||
},
|
||||
"AxiGqbteK7ZSXEUt_zckH": {
|
||||
"title": "Instructions and Programs",
|
||||
"description": "Visit the following resources to learn more:",
|
||||
"description": "Instructions are the most basic commands a CPU can understand, directing it to perform specific actions like adding numbers or moving data. A program, on the other hand, is a collection of these instructions, organized in a sequence to accomplish a particular task. Think of instructions as individual words and a program as a complete sentence or story; the CPU executes these instructions one by one, following the program's logic, to achieve the desired outcome.\n\nVisit the following resources to learn more:",
|
||||
"links": [
|
||||
{
|
||||
"title": "Instruction and Programs",
|
||||
"url": "https://nerdfighteria.info/v/zltgXvg6r3k/",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Instructions and Programs",
|
||||
"url": "https://youtu.be/zltgXvg6r3k",
|
||||
@@ -3975,8 +3963,13 @@
|
||||
},
|
||||
"DjTQjMbika4_yTzrBpcmB": {
|
||||
"title": "CPU Cache",
|
||||
"description": "Visit the following resources to learn more:",
|
||||
"description": "A CPU cache is a hardware cache used by the central processing unit of a computer to reduce the average cost to access data from the main memory. A cache is a smaller, faster memory, located closer to a processor core, which stores copies of the data from frequently used main memory locations.\n\nVisit the following resources to learn more:",
|
||||
"links": [
|
||||
{
|
||||
"title": "What is CPU Cache",
|
||||
"url": "https://www.howtogeek.com/854138/what-is-cpu-cache/",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Explore top posts about Computing",
|
||||
"url": "https://app.daily.dev/tags/computing?ref=roadmapsh",
|
||||
@@ -4070,10 +4063,10 @@
|
||||
},
|
||||
"xUo5Ox_HTgGyeQMDIkVyK": {
|
||||
"title": "Concurrency in Multiple Cores",
|
||||
"description": "Visit the following resources to learn more:",
|
||||
"description": "Concurrency or Parallelism is simultaneous execution of processes on a multiple cores per CPU or multiple CPUs (on a single motherboard). Concurrency is when Parallelism is achieved on a single core/CPU by using scheduling algorithms that divides the CPU's time (time-slice).\n\nVisit the following resources to learn more:",
|
||||
"links": [
|
||||
{
|
||||
"title": "What is the difference between multicore and concurrent programming?",
|
||||
"title": "Difference between Multi-core and concurrent Programming?",
|
||||
"url": "https://stackoverflow.com/questions/5372861/what-is-the-difference-between-multicore-and-concurrent-programming",
|
||||
"type": "article"
|
||||
},
|
||||
@@ -4081,34 +4074,18 @@
|
||||
"title": "Concurrency in Multicore systems",
|
||||
"url": "https://cs.stackexchange.com/questions/140793/concurrency-in-multiple-core",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Mastering Concurrency",
|
||||
"url": "https://www.harrisonclarke.com/blog/mastering-concurrency-a-guide-for-software-engineers",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Ge2nagN86ofa2y-yYR1lv": {
|
||||
"title": "Scheduling Algorithms",
|
||||
"description": "CPU Scheduling is the process of selecting a process from the ready queue and allocating the CPU to it. The selection of a process is based on a particular scheduling algorithm. The scheduling algorithm is chosen depending on the type of system and the requirements of the processes.\n\nHere is the list of some of the most commonly used scheduling algorithms:\n\n* **First Come First Serve (FCFS):** The process that arrives first is allocated the CPU first. It is a non-preemptive algorithm.\n* **Shortest Job First (SJF):** The process with the smallest execution time is allocated the CPU first. It is a non-preemptive algorithm.\n* **Shortest Remaining Time First (SRTF):** The process with the smallest remaining execution time is allocated the CPU first. It is a preemptive algorithm.\n* **Round Robin (RR):** The process is allocated the CPU for a fixed time slice. The time slice is usually 10 milliseconds. It is a preemptive algorithm.\n* **Priority Scheduling:** The process with the highest priority is allocated the CPU first. It is a preemptive algorithm.\n* **Multi-level Queue Scheduling:** The processes are divided into different queues based on their priority. The process with the highest priority is allocated the CPU first. It is a preemptive algorithm.\n* **Multi-level Feedback Queue Scheduling:** The processes are divided into different queues based on their priority. The process with the highest priority is allocated the CPU first. If a process is preempted, it is moved to the next queue. It is a preemptive algorithm.\n* **Highest Response Ratio Next(HRRN):** CPU is allotted to the next process which has the highest response ratio and not to the process having less burst time. It is a Non-Preemptive algorithm.\n* **Lottery Scheduling:** The process is allocated the CPU based on a lottery system. It is a preemptive algorithm.\n\nVisit the following resources to learn more :",
|
||||
"links": [
|
||||
{
|
||||
"title": "CPU Scheduling in Operating Systems - geeksforgeeks",
|
||||
"url": "https://www.geeksforgeeks.org/cpu-scheduling-in-operating-systems/",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Lottery Scheduling for Operating Systems - geeksforgeeks",
|
||||
"url": "https://www.geeksforgeeks.org/lottery-process-scheduling-in-operating-system/",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Program for Round Robin Scheduling for the same Arrival time - geeksforgeeks",
|
||||
"url": "https://www.geeksforgeeks.org/program-for-round-robin-scheduling-for-the-same-arrival-time/",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Introduction to CPU Scheduling",
|
||||
"url": "https://youtu.be/EWkQl0n0w5M?si=Lb-PxN_t-rDfn4JL",
|
||||
"type": "video"
|
||||
}
|
||||
]
|
||||
"description": "CPU Scheduling is the process of selecting a process from the ready queue and allocating the CPU to it. The selection of a process is based on a particular scheduling algorithm. The scheduling algorithm is chosen depending on the type of system and the requirements of the processes.\n\nHere is the list of some of the most commonly used scheduling algorithms:\n\n* **First Come First Serve (FCFS):** The process that arrives first is allocated the CPU first. It is a non-preemptive algorithm.\n* **Shortest Job First (SJF):** The process with the smallest execution time is allocated the CPU first. It is a non-preemptive algorithm.\n* **Shortest Remaining Time First (SRTF):** The process with the smallest remaining execution time is allocated the CPU first. It is a preemptive algorithm.\n* **Round Robin (RR):** The process is allocated the CPU for a fixed time slice. The time slice is usually 10 milliseconds. It is a preemptive algorithm.\n* **Priority Scheduling:** The process with the highest priority is allocated the CPU first. It is a preemptive algorithm.\n* **Multi-level Queue Scheduling:** The processes are divided into different queues based on their priority. The process with the highest priority is allocated the CPU first. It is a preemptive algorithm.\n* **Multi-level Feedback Queue Scheduling:** The processes are divided into different queues based on their priority. The process with the highest priority is allocated the CPU first. If a process is preempted, it is moved to the next queue. It is a preemptive algorithm.\n* **Highest Response Ratio Next(HRRN):** CPU is allotted to the next process which has the highest response ratio and not to the process having less burst time. It is a Non-Preemptive algorithm.\n* **Lottery Scheduling:** The process is allocated the CPU based on a lottery system. It is a preemptive algorithm.",
|
||||
"links": []
|
||||
},
|
||||
"cpQvB0qMDL3-NWret7oeA": {
|
||||
"title": "CPU Interrupts",
|
||||
@@ -4120,7 +4097,7 @@
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Video on Interrupts",
|
||||
"title": "Interrupts",
|
||||
"url": "https://youtu.be/iKlAWIKEyuw",
|
||||
"type": "video"
|
||||
}
|
||||
@@ -4222,26 +4199,6 @@
|
||||
"title": "NP Completeness IV",
|
||||
"url": "https://www.youtube.com/watch?v=NKLDp3Rch3M&list=PLFDnELG9dpVxQCxuD-9BSy2E7BWY3t5Sm&index=18",
|
||||
"type": "video"
|
||||
},
|
||||
{
|
||||
"title": "CSE373 2020 - Lecture 23 - NP-Completeness",
|
||||
"url": "https://www.youtube.com/watch?v=ItHp5laE1VE&list=PLOtl7M3yp-DX6ic0HGT0PUX_wiNmkWkXx&index=23",
|
||||
"type": "video"
|
||||
},
|
||||
{
|
||||
"title": "CSE373 2020 - Lecture 24 - Satisfiability",
|
||||
"url": "https://www.youtube.com/watch?v=inaFJeCzGxU&list=PLOtl7M3yp-DX6ic0HGT0PUX_wiNmkWkXx&index=24",
|
||||
"type": "video"
|
||||
},
|
||||
{
|
||||
"title": "CSE373 2020 - Lecture 25 - More NP-Completeness",
|
||||
"url": "https://www.youtube.com/watch?v=B-bhKxjZLlc&list=PLOtl7M3yp-DX6ic0HGT0PUX_wiNmkWkXx&index=25",
|
||||
"type": "video"
|
||||
},
|
||||
{
|
||||
"title": "CSE373 2020 - Lecture 26 - NP-Completeness Challenge",
|
||||
"url": "https://www.youtube.com/watch?v=_EzetTkG_Cc&list=PLOtl7M3yp-DX6ic0HGT0PUX_wiNmkWkXx&index=26",
|
||||
"type": "video"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -521,11 +521,6 @@
|
||||
"title": "Operating Systems",
|
||||
"description": "**Operating systems (OS)** are software that manage computer hardware and provide a platform for applications to run. They handle essential functions such as managing memory, processing tasks, controlling input and output devices, and facilitating file management. Key examples include **Windows**, **macOS**, **Linux**, and **Unix**. Each operating system offers different features and interfaces, tailored to specific user needs or system requirements, from desktop computing to server management and embedded systems.\n\nLearn more from the following resources:",
|
||||
"links": [
|
||||
{
|
||||
"title": "What is an operating system?",
|
||||
"url": "https://www.geeksforgeeks.org/what-is-an-operating-system/",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "What is an operating system as fast as possible",
|
||||
"url": "https://www.youtube.com/watch?v=pVzRTmdd9j0",
|
||||
@@ -708,11 +703,6 @@
|
||||
"title": "What are Network Protocols?",
|
||||
"url": "https://www.solarwinds.com/resources/it-glossary/network-protocols",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Types of Network Topology",
|
||||
"url": "https://www.geeksforgeeks.org/types-of-network-topology/",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -853,11 +843,6 @@
|
||||
"title": "loopback",
|
||||
"description": "**Loopback** refers to a special network interface used to send traffic back to the same device for testing and diagnostic purposes. The loopback address for IPv4 is `127.0.0.1`, while for IPv6 it is `::1`. When a device sends a request to the loopback address, the network data does not leave the local machine; instead, it is processed internally, allowing developers to test applications or network services without requiring external network access. Loopback is commonly used to simulate network traffic, check local services, or debug issues locally.\n\nLearn more from the following resources:",
|
||||
"links": [
|
||||
{
|
||||
"title": "What is a loopback address?",
|
||||
"url": "https://www.geeksforgeeks.org/what-is-a-loopback-address/",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Understanding the loopback address and loopback interfaces",
|
||||
"url": "https://study-ccna.com/loopback-interface-loopback-address/",
|
||||
@@ -1256,11 +1241,6 @@
|
||||
"title": "Star",
|
||||
"description": "A star network topology is a configuration where all devices (nodes) are connected directly to a central hub or switch. In this arrangement, each node has a dedicated point-to-point link to the central device, forming a star-like structure. This topology offers advantages such as easy installation and reconfiguration, centralized management, and fault isolation. If one connection fails, it doesn't affect others. However, the central hub is a single point of failure for the entire network. Star topologies are commonly used in local area networks (LANs) due to their reliability, scalability, and ease of maintenance, making them a popular choice in both small office and large enterprise environments.\n\nLearn more from the following resources:",
|
||||
"links": [
|
||||
{
|
||||
"title": "Advantages and Disadvantages of Star Topology",
|
||||
"url": "https://www.geeksforgeeks.org/advantages-and-disadvantages-of-star-topology/",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Star Topology",
|
||||
"url": "https://www.youtube.com/watch?v=EQ3rW22-Py0",
|
||||
@@ -1698,11 +1678,6 @@
|
||||
"title": "Protocol Analyzers",
|
||||
"description": "**Protocol analyzers**, also known as network analyzers or packet sniffers, are tools used to capture, inspect, and analyze network traffic. They help diagnose network issues, troubleshoot performance problems, and ensure security by providing detailed insights into the data packets transmitted across a network. Protocol analyzers decode and display various network protocols, such as TCP/IP, HTTP, and DNS, allowing users to understand communication patterns, detect anomalies, and identify potential vulnerabilities. Popular examples include Wireshark and tcpdump.\n\nLearn more from the following resources:",
|
||||
"links": [
|
||||
{
|
||||
"title": "What is a protocol analyzer?",
|
||||
"url": "https://www.geeksforgeeks.org/what-is-protocol-analyzer/",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Protocol Analyzers",
|
||||
"url": "https://www.youtube.com/watch?v=hTMhlB-o0Ow",
|
||||
@@ -1733,14 +1708,8 @@
|
||||
},
|
||||
"xFuWk7M-Vctk_xb7bHbWs": {
|
||||
"title": "route",
|
||||
"description": "The `route` command is a network utility used to view and manipulate the IP routing table on Unix-like and Windows systems. It allows users to display the current routes that data packets take, as well as add, modify, or delete routes for network traffic. This command is often used in network troubleshooting and configuration to control how data flows between different networks and subnets. By specifying routes manually, administrators can define specific paths for network traffic, bypassing default routes and optimizing performance or security.\n\nLearn more from the following resources:",
|
||||
"links": [
|
||||
{
|
||||
"title": "How to check the routing table in Linux",
|
||||
"url": "https://www.geeksforgeeks.org/route-command-in-linux-with-examples/",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
"description": "The `route` command is a network utility used to view and manipulate the IP routing table on Unix-like and Windows systems. It allows users to display the current routes that data packets take, as well as add, modify, or delete routes for network traffic. This command is often used in network troubleshooting and configuration to control how data flows between different networks and subnets. By specifying routes manually, administrators can define specific paths for network traffic, bypassing default routes and optimizing performance or security.",
|
||||
"links": []
|
||||
},
|
||||
"y8GaUNpaCT1Ai88wPOk6d": {
|
||||
"title": "tcpdump",
|
||||
@@ -2308,11 +2277,6 @@
|
||||
"title": "Understand Handshakes",
|
||||
"description": "In networking and cybersecurity, a handshake is a process of establishing a secure connection between two parties before data exchange begins. It typically involves a series of predefined messages exchanged to verify identities, agree on communication parameters, and sometimes establish encryption keys. The most common example is the TCP three-way handshake used to initiate a connection. In cryptographic protocols like TLS/SSL, handshakes are more complex, involving certificate verification and key exchange. Handshakes are crucial for ensuring secure, authenticated communications, preventing unauthorized access, and setting up the parameters for efficient data transfer in various network protocols and security systems.\n\nLearn more from the following resources:",
|
||||
"links": [
|
||||
{
|
||||
"title": "TCP 3-Way Handshake Process",
|
||||
"url": "https://www.geeksforgeeks.org/tcp-3-way-handshake-process/",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "TLS Handshake Explained",
|
||||
"url": "https://www.youtube.com/watch?v=86cQJ0MMses",
|
||||
@@ -3110,14 +3074,8 @@
|
||||
},
|
||||
"W7bcydXdwlubXF2PHKOuq": {
|
||||
"title": "Port Blocking",
|
||||
"description": "Port blocking is an essential practice in hardening the security of your network and devices. It involves restricting, filtering, or entirely denying access to specific network ports to minimize exposure to potential cyber threats. By limiting access to certain ports, you can effectively safeguard your systems against unauthorized access and reduce the likelihood of security breaches.\n\nLearn more from the following resources:",
|
||||
"links": [
|
||||
{
|
||||
"title": "What is port blocking with LAN?",
|
||||
"url": "https://www.geeksforgeeks.org/what-is-port-blocking-within-lan/",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
"description": "Port blocking is an essential practice in hardening the security of your network and devices. It involves restricting, filtering, or entirely denying access to specific network ports to minimize exposure to potential cyber threats. By limiting access to certain ports, you can effectively safeguard your systems against unauthorized access and reduce the likelihood of security breaches.",
|
||||
"links": []
|
||||
},
|
||||
"FxuMJmDoDkIsPFp2iocFg": {
|
||||
"title": "Group Policy",
|
||||
|
||||
@@ -19,11 +19,6 @@
|
||||
"title": "Types of Data Analytics",
|
||||
"description": "Data Analytics has proven to be a critical part of decision-making in modern business ventures. It is responsible for discovering, interpreting, and transforming data into valuable information. Different types of data analytics look at past, present, or predictive views of business operations.\n\nData Analysts, as ambassadors of this domain, employ these types, to answer various questions:\n\n* Descriptive Analytics _(what happened in the past?)_\n* Diagnostic Analytics _(why did it happened in the past?)_\n* Predictive Analytics _(what will happen in the future?)_\n* Prescriptive Analytics _(how can we make it happen?)_\n\nVisit the following resources to learn more:",
|
||||
"links": [
|
||||
{
|
||||
"title": "Data Analytics and its type",
|
||||
"url": "https://www.geeksforgeeks.org/data-analytics-and-its-type/",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "The 4 Types of Data Analysis: Ultimate Guide",
|
||||
"url": "https://careerfoundry.com/en/blog/data-analytics/different-types-of-data-analysis/",
|
||||
@@ -265,7 +260,7 @@
|
||||
"links": [
|
||||
{
|
||||
"title": "Replace Function",
|
||||
"url": "https://support.microsoft.com/en-us/office/replace-function-6acf209b-01b7-4078-b4b8-e0a4ef67d181",
|
||||
"url": "https://support.microsoft.com/en-us/office/replace-function-8d799074-2425-4a8a-84bc-82472868878a",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -162,8 +162,14 @@
|
||||
},
|
||||
"7PBmYoSmIgZT21a2Ip3_S": {
|
||||
"title": "Trust / Influence Building",
|
||||
"description": "Building trust and influence is crucial for any Engineering Manager. This involves establishing a solid reputation, delivering on promises and being an active listener to your team's ideas and issues. It's a manager's job to ensure there's an open, honest environment that promotes trust. Balancing delegation and taking charge, especially in difficult situations, is key to building influence.\n\nOne challenge in this area is building trust between team members of varying experiences and skills. Managers must not only show the team they're competent, but also that they value everyone's inputs. They can achieve this by promoting inclusivity and praising team contributions regularly.\n\nBeing patient, communicate clearly, and showing empathy are critical skills that can help an Engineering Manager in trust and influence building. By embodying these traits, managers can build a stronger, united, and more effective engineering team.",
|
||||
"links": []
|
||||
"description": "Building trust and influence is crucial for any Engineering Manager. This involves establishing a solid reputation, delivering on promises and being an active listener to your team's ideas and issues. It's a manager's job to ensure there's an open, honest environment that promotes trust. Balancing delegation and taking charge, especially in difficult situations, is key to building influence.\n\nOne challenge in this area is building trust between team members of varying experiences and skills. Managers must not only show the team they're competent, but also that they value everyone's inputs. They can achieve this by promoting inclusivity and praising team contributions regularly.\n\nBeing patient, communicate clearly, and showing empathy are critical skills that can help an Engineering Manager in trust and influence building. By embodying these traits, managers can build a stronger, united, and more effective engineering team.\n\nVisit the following resources to learn more:",
|
||||
"links": [
|
||||
{
|
||||
"title": "Understanding The Trust Equation",
|
||||
"url": "https://trustedadvisor.com/why-trust-matters/understanding-trust/understanding-the-trust-equation",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
},
|
||||
"b3qoH_LuW-Gz4N8WdGnZs": {
|
||||
"title": "One-on-One Meetings",
|
||||
|
||||
@@ -364,8 +364,13 @@
|
||||
"description": "Joints in game development primarily refer to the connections between two objects, often used in the context of physics simulations and character animations. These might simulate the physics of real-world joints like hinges or springs. Developers can control various characteristics of joints such as their constraints, forces, and reactions. The different types come with various properties suitable for specific needs. For example, Fixed joints keep objects together, Hinge joints allow rotation around an axis, and Spring joints apply a force to keep objects apart.\n\nVisit the following resources to learn more:",
|
||||
"links": [
|
||||
{
|
||||
"title": "Game Character Rigging Fundamentals",
|
||||
"url": "https://learn.unity.com/project/game-character-rigging-fundamentals",
|
||||
"title": "Introduction to joints",
|
||||
"url": "https://docs.unity3d.com/Manual/Joints.html",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Character Rigging for Video Games",
|
||||
"url": "https://game-ace.com/blog/character-rigging-for-video-games/",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
@@ -744,7 +749,7 @@
|
||||
},
|
||||
"7OffO2mBmfBKqPBTZ9ngI": {
|
||||
"title": "Godot",
|
||||
"description": "Godot is an open-source, multi-platform game engine that is known for being feature-rich and user-friendly. It is developed by hundreds of contributors from around the world and supports the creation of both 2D and 3D games. Godot uses its own scripting language, GDScript, which is similar to Python, but it also supports C# and visual scripting. It is equipped with a unique scene system and comes with a multitude of tools that can expedite the development process. Godot's design philosophy centers around flexibility, extensibility, and ease of use, providing a handy tool for both beginners and pros in game development.\n\nVisit the following resources to learn more:",
|
||||
"description": "Godot is an open-source, multi-platform game engine that is known for being feature-rich and user-friendly. It is developed by hundreds of contributors from around the world and supports the creation of both 2D and 3D games. Godot uses its own scripting language, GDScript, which is similar to Python, but it also supports C#. It is equipped with a unique scene system and comes with a multitude of tools that can expedite the development process. Godot's design philosophy centers around flexibility, extensibility, and ease of use, providing a handy tool for both beginners and pros in game development.\n\nVisit the following resources to learn more:",
|
||||
"links": [
|
||||
{
|
||||
"title": "godotengine/godot",
|
||||
@@ -763,7 +768,17 @@
|
||||
},
|
||||
{
|
||||
"title": "Godot in 100 Seconds",
|
||||
"url": "https://m.youtube.com/watch?v=QKgTZWbwD1U",
|
||||
"url": "https://www.youtube.com/watch?v=QKgTZWbwD1U",
|
||||
"type": "video"
|
||||
},
|
||||
{
|
||||
"title": "Tutorial - How to make a Video Game in Godot",
|
||||
"url": "https://www.youtube.com/watch?v=LOhfqjmasi0",
|
||||
"type": "video"
|
||||
},
|
||||
{
|
||||
"title": "Tutorial - How to make 3D Games in Godot",
|
||||
"url": "https://www.youtube.com/watch?v=ke5KpqcoiIU",
|
||||
"type": "video"
|
||||
}
|
||||
]
|
||||
@@ -882,8 +897,8 @@
|
||||
"description": "**C** and **C++ (commonly known as CPP)** are two of the most foundational high-level programming languages in computer science. **C** was developed in the 1970s and it is a procedural language, meaning it follows a step-by-step approach. Its fundamental principles include structured programming and lexical variable scope.\n\nOn the other hand, **C++** follows the paradigm of both procedural and object-oriented programming. It was developed as an extension to C to add the concept of \"classes\" - a core feature of object-oriented programming. C++ enhances C by introducing new features like function overloading, exception handling, and templates.\n\nVisit the following resources to learn more:",
|
||||
"links": [
|
||||
{
|
||||
"title": "The C Programming Language",
|
||||
"url": "https://www.iso.org/standard/74528.html",
|
||||
"title": "C Programming Language",
|
||||
"url": "https://en.wikipedia.org/wiki/C_%28programming_language%29",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
@@ -971,11 +986,6 @@
|
||||
"title": "Computer Graphics",
|
||||
"description": "Computer Graphics is a subfield of computer science that studies methods for digitally synthesizing and manipulating visual content. It involves creating and manipulating visual content using specialized computer software and hardware. This field is primarily used in the creation of digital and video games, CGI in films, and also in visual effects for commercials. The field is divided into two major categories: **Raster graphics** and **Vector graphics**. Raster graphics, also known as bitmap, involve the representation of images through a dot matrix data structure, while Vector graphics involve the use of polygons to represent images in computer graphics. Both of these methods have their unique usage scenarios. Other concepts integral to the study of computer graphics include rendering (including both real-time rendering and offline rendering), animation, and 3D modeling. Generally, computer graphics skills are essential for game developers and animation experts.\n\nVisit the following resources to learn more:",
|
||||
"links": [
|
||||
{
|
||||
"title": "What is Computer Graphics?",
|
||||
"url": "https://www.geeksforgeeks.org/introduction-to-computer-graphics/",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Introduction to Computer Graphics",
|
||||
"url": "https://open.umn.edu/opentextbooks/textbooks/420",
|
||||
@@ -1881,11 +1891,6 @@
|
||||
"title": "MCTS Algorithm",
|
||||
"url": "https://en.wikipedia.org/wiki/Monte_Carlo_tree_search/",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Monte Carlo Tree Search",
|
||||
"url": "https://www.geeksforgeeks.org/ml-monte-carlo-tree-search-mcts/",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1983,11 +1988,6 @@
|
||||
"title": "Artificial Neural Network",
|
||||
"description": "Artificial Neural Networks (ANN) are a branch of machine learning that draw inspiration from biological neural networks. ANNs are capable of 'learning' from observational data, thereby enhancing game development in numerous ways. They consist of interconnected layers of nodes, or artificial neurons, that process information through their interconnected network. Each node's connection has numerical weight that gets adjusted during learning, which helps in optimizing problem solving. ANNs are utilized in various aspects of game development, such as improving AI behavior, procedural content generation, and game testing. They can also be used for image recognition tasks, such as identifying objects or actions in a game environment.\n\nVisit the following resources to learn more:",
|
||||
"links": [
|
||||
{
|
||||
"title": "Artificial Neural Networks (ANN)",
|
||||
"url": "https://www.geeksforgeeks.org/artificial-neural-networks-and-its-applications/",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "What is ANN?",
|
||||
"url": "https://www.coursera.org/articles/artificial-neural-network",
|
||||
|
||||
@@ -670,11 +670,6 @@
|
||||
"url": "https://docs.github.com/en/organizations/managing-user-access-to-your-organizations-repositories/managing-outside-collaborators/adding-outside-collaborators-to-repositories-in-your-organization",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "What are github collaborators",
|
||||
"url": "https://www.geeksforgeeks.org/what-are-github-collaborators/",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "How to Add Collaborators to Your GitHub Repository",
|
||||
"url": "https://www.blinkops.com/blog/how-to-add-collaborators-to-your-github-repository",
|
||||
@@ -1440,7 +1435,7 @@
|
||||
},
|
||||
"uxqJzQFRcALqatNRIWR0w": {
|
||||
"title": "Unstaged Changes",
|
||||
"description": "For changes that are not yet staged with `git add`, such as untracked new files or modified existing ones, use `git diff --unified`. This command compares your working directory against the latest committed version of each file. It's a useful tool for reviewing any local modifications before deciding whether to stage them for future commits.",
|
||||
"description": "For changes that are not yet staged with `git add`, such as untracked new files or modified existing ones , use `git diff`. This command compares your working directory (your current changes) against the staging area (changes already staged with `git add`). It’s a useful tool for reviewing local modifications before deciding whether to stage them for future commits.\n\nThe `--unified` option (or -U) controls the number of context lines shown in the diff output. By default, Git shows 3 lines of context around each change. For example, `git diff --unified=5` will display 5 lines of context around each change, making it easier to understand the surrounding code or content.",
|
||||
"links": [
|
||||
{
|
||||
"title": "What are unstaged changes in GitHub?",
|
||||
|
||||
1591
public/roadmap-content/java.json
Normal file
1591
public/roadmap-content/java.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -334,11 +334,6 @@
|
||||
"title": "JavaScript Scope",
|
||||
"url": "https://www.w3schools.com/js/js_scope.asp",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Block Scoping in JavaScript",
|
||||
"url": "https://www.geeksforgeeks.org/javascript-es2015-block-scoping",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -251,11 +251,6 @@
|
||||
"title": "What is the Relational Model?",
|
||||
"url": "https://www.postgresql.org/docs/7.1/relmodel-oper.html",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "The Relational Model",
|
||||
"url": "https://www.geeksforgeeks.org/relational-model-in-dbms/",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -243,11 +243,6 @@
|
||||
"title": "Loops",
|
||||
"description": "Loops are used to execute a block of code repeatedly.\n\nVisit the following resources to learn more:",
|
||||
"links": [
|
||||
{
|
||||
"title": "Loops in Python",
|
||||
"url": "https://www.geeksforgeeks.org/loops-in-python/",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Python \"while\" Loops (Indefinite Iteration)",
|
||||
"url": "https://realpython.com/python-while-loop/",
|
||||
@@ -652,7 +647,7 @@
|
||||
},
|
||||
"P_Di-XPSDITmU3xKQew8G": {
|
||||
"title": "Object Oriented Programming",
|
||||
"description": "In Python, object-oriented Programming (OOPs) is a programming paradigm that uses objects and classes in programming. It aims to implement real-world entities like inheritance, polymorphisms, encapsulation, etc. in the programming. The main concept of OOPs is to bind the data and the functions that work on that together as a single unit so that no other part of the code can access this data.\n\nVisit the following resources to learn more:",
|
||||
"description": "In Python, object-oriented Programming (OOPs) is a programming paradigm that uses objects and classes in programming. It aims to implement real-world entities like inheritance, polymorphism, encapsulation, etc., in programming. The main concept of OOPs is to bind the data and the functions that work on that together as a single unit so that no other part of the code can access this data.\n\nVisit the following resources to learn more:",
|
||||
"links": [
|
||||
{
|
||||
"title": "Object Oriented Programming in Python",
|
||||
@@ -715,7 +710,7 @@
|
||||
},
|
||||
"zAS4YiEJ6VPsyABrkIG8i": {
|
||||
"title": "Methods, Dunder",
|
||||
"description": "A method in python is somewhat similar to a function, except it is associated with object/classes. Methods in python are very similar to functions except for two major differences.\n\n* The method is implicitly used for an object for which it is called.\n* The method is accessible to data that is contained within the class.\n\nDunder or magic methods in Python are the methods having two prefix and suffix underscores in the method name. Dunder here means “Double Under (Underscores)”. These are commonly used for operator overloading. Few examples for magic methods are: **`__init__`**, **`__add__`**, **`__len__`**, **`__repr__`** etc.\n\nVisit the following resources to learn more:",
|
||||
"description": "A method in python is somewhat similar to a function, except it is associated with object/classes. Methods in python are very similar to functions except for two major differences.\n\n* The method is implicitly used for an object for which it is called.\n* The method is accessible to data that is contained within the class.\n\nDunder or magic methods in Python are the methods that have two prefix and suffix underscores in the method name. Dunder here means “Double Under (Underscores)”. These are commonly used for operator overloading. Few examples for magic methods are: **`__init__`**, **`__add__`**, **`__len__`**, **`__repr__`** etc.\n\nVisit the following resources to learn more:",
|
||||
"links": [
|
||||
{
|
||||
"title": "Method vs Function in Python",
|
||||
@@ -1314,7 +1309,7 @@
|
||||
"description": "An extremely fast Python linter and code formatter, written in Rust.",
|
||||
"links": [
|
||||
{
|
||||
"title": "ruff documentation",
|
||||
"title": "Ruff documentation",
|
||||
"url": "https://docs.astral.sh/ruff/",
|
||||
"type": "article"
|
||||
}
|
||||
|
||||
@@ -732,11 +732,6 @@
|
||||
"title": "Redis Lists",
|
||||
"url": "https://redis.io/docs/latest/develop/data-types/lists/",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Complete Guide to Redis Lists",
|
||||
"url": "https://www.geeksforgeeks.org/complete-guide-to-redis-lists/",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -976,11 +971,6 @@
|
||||
"title": "Redis Pipelining",
|
||||
"url": "https://redis.io/docs/latest/develop/use/pipelining/",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Complete Guide to Redis Pipelining",
|
||||
"url": "https://www.geeksforgeeks.org/complete-guide-to-redis-pipelining/",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -257,7 +257,18 @@
|
||||
"E4H3hniIW6hKpH3Qr--N5": {
|
||||
"title": "C/C++",
|
||||
"description": "\"C\" and \"C++\", often written as \"C/CPP\", are two significantly prominent and similar programming languages widely used in server-side game development. \"C\" is a procedural language, which means that it follows a step-by-step procedure to solve a problem, while \"C++\" is both a procedural and object-oriented programming (OOP) language. This dual nature of \"C++\" allows it to handle more complex interrelated data and functions efficiently, which is a beneficial feature in game development. Moreover, \"C++\" is an extension of \"C\", meaning that any legal \"C\" program is also a valid \"C++\" program. Both languages offer a high degree of control over system resources and memory, making them an excellent choice for building fast and efficient server-side applications, such as multiplayer game servers.",
|
||||
"links": []
|
||||
"links": [
|
||||
{
|
||||
"title": "C Programming Language",
|
||||
"url": "https://en.wikipedia.org/wiki/C_%28programming_language%29",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "C++ Programming Language",
|
||||
"url": "https://en.wikipedia.org/wiki/C%2B%2B",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
},
|
||||
"DuyUc9a-47Uz03yr4aeyg": {
|
||||
"title": "C#",
|
||||
|
||||
@@ -38,14 +38,8 @@
|
||||
},
|
||||
"2sR4KULvAUUoOtopvsEBs": {
|
||||
"title": "Levels of Architecture",
|
||||
"description": "Architecture can be done on several “levels” of abstractions. The level influences the importance of necessary skills. As there are many categorizations possible my favorite segmentation includes these 3 levels:\n\n* **Application Level:** The lowest level of architecture. Focus on one single application. Very detailed, low level design. Communication is usually within one development team.\n* **Solution Level:** The mid-level of architecture. Focus on one or more applications which fulfill a business need (business solution). Some high, but mainly low-level design. Communication is between multiple development teams.\n* **Enterprise Level:** The highest level of architecture. Focus on multiple solutions. High level, abstract design, which needs to be detailed out by solution or application architects. Communication is across the organization.\n\nVisit the following resources to learn more:",
|
||||
"links": [
|
||||
{
|
||||
"title": "Software Engineering Architecture",
|
||||
"url": "https://www.geeksforgeeks.org/software-engineering-architectural-design/",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
"description": "Architecture can be done on several “levels” of abstractions. The level influences the importance of necessary skills. As there are many categorizations possible my favorite segmentation includes these 3 levels:\n\n* **Application Level:** The lowest level of architecture. Focus on one single application. Very detailed, low level design. Communication is usually within one development team.\n* **Solution Level:** The mid-level of architecture. Focus on one or more applications which fulfill a business need (business solution). Some high, but mainly low-level design. Communication is between multiple development teams.\n* **Enterprise Level:** The highest level of architecture. Focus on multiple solutions. High level, abstract design, which needs to be detailed out by solution or application architects. Communication is across the organization.",
|
||||
"links": []
|
||||
},
|
||||
"Lqe47l4j-C4OwkbkwPYry": {
|
||||
"title": "Application Architecture",
|
||||
@@ -103,14 +97,8 @@
|
||||
},
|
||||
"lBtlDFPEQvQ_xtLtehU0S": {
|
||||
"title": "Important Skills to Learn",
|
||||
"description": "To support the laid-out activities specific skills are required. From my experience, read books and discussions we can boil this down to these ten skills every software architect should have:\n\n* Design\n* Decide\n* Simplify\n* Code\n* Document\n* Communicate\n* Estimate\n* Balance\n* Consult\n* Market\n\nVisit the following resources to learn more:",
|
||||
"links": [
|
||||
{
|
||||
"title": "Software Architect Skills",
|
||||
"url": "https://www.geeksforgeeks.org/software-architects-skills/",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
"description": "To support the laid-out activities specific skills are required. From my experience, read books and discussions we can boil this down to these ten skills every software architect should have:\n\n* Design\n* Decide\n* Simplify\n* Code\n* Document\n* Communicate\n* Estimate\n* Balance\n* Consult\n* Market",
|
||||
"links": []
|
||||
},
|
||||
"fBd2m8tMJmhuNSaakrpg4": {
|
||||
"title": "Design & Architecture",
|
||||
@@ -854,14 +842,8 @@
|
||||
},
|
||||
"SuMhTyaBS9vwASxAt39DH": {
|
||||
"title": "Tools",
|
||||
"description": "Architect tools are software tools that help architects to design, document, and manage software architectures. These tools can be used to create architecture diagrams, generate code, and automate the software development process.\n\nVisit the following resources to learn more:",
|
||||
"links": [
|
||||
{
|
||||
"title": "Top 10 Software Architecture Tools in 2024",
|
||||
"url": "https://www.geeksforgeeks.org/software-architecture-tools/",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
"description": "Architect tools are software tools that help architects to design, document, and manage software architectures. These tools can be used to create architecture diagrams, generate code, and automate the software development process.",
|
||||
"links": []
|
||||
},
|
||||
"OaLmlfkZid7hKqJ9G8oNV": {
|
||||
"title": "Architecture",
|
||||
@@ -1856,11 +1838,6 @@
|
||||
"url": "https://www.fortinet.com/resources/cyberglossary/tcp-ip#:~:text=The%20TCP%2FIP%20model%20defines,exchanged%20and%20organized%20over%20networks.",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "TCP/IP Model",
|
||||
"url": "https://www.geeksforgeeks.org/tcp-ip-model/",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "What is TCP/IP and How Does it Work?",
|
||||
"url": "https://www.techtarget.com/searchnetworking/definition/TCP-IP",
|
||||
|
||||
@@ -1326,16 +1326,10 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"LncTxPg-wx8loy55r5NmV": {
|
||||
"queu-based-load-leveling@LncTxPg-wx8loy55r5NmV.md": {
|
||||
"title": "Queu-based Load Leveling",
|
||||
"description": "Use a queue that acts as a buffer between a task and a service it invokes in order to smooth intermittent heavy loads that can cause the service to fail or the task to time out. This can help to minimize the impact of peaks in demand on availability and responsiveness for both the task and the service.\n\nLearn more from the following links:",
|
||||
"links": [
|
||||
{
|
||||
"title": "Queue-Based Load Leveling pattern",
|
||||
"url": "https://learn.microsoft.com/en-us/azure/architecture/patterns/queue-based-load-leveling",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
"description": "",
|
||||
"links": []
|
||||
},
|
||||
"2ryzJhRDTo98gGgn9mAxR": {
|
||||
"title": "Publisher/Subscriber",
|
||||
|
||||
@@ -652,14 +652,8 @@
|
||||
},
|
||||
"lvtTSHH9yBTCiLng8btnI": {
|
||||
"title": "Hybrid Types",
|
||||
"description": "In TypeScript, a hybrid type is a type that combines multiple types into a single type. The resulting type is considered a union of those types. This allows you to specify that a value can have multiple types, rather than just one.\n\nFor example, you can create a hybrid type that can accept either a string or a number:\n\n type StringOrNumber = string | number;\n \n\nYou can also use hybrid types to create more complex types that can represent a combination of several different types of values. For example:\n\n type Education = {\n degree: string;\n school: string;\n year: number;\n };\n \n type User = {\n name: string;\n age: number;\n email: string;\n education: Education;\n };\n \n\nLearn more from the following links:",
|
||||
"links": [
|
||||
{
|
||||
"title": "Geeksforgeeks.org - Hybrid Types",
|
||||
"url": "https://www.geeksforgeeks.org/what-are-hybrid-types-in-typescript/#:~:text=Hybrid%20types%20are%20a%20combination,properties%20like%20a%20regular%20object.",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
"description": "In TypeScript, a hybrid type is a type that combines multiple types into a single type. The resulting type is considered a union of those types. This allows you to specify that a value can have multiple types, rather than just one.\n\nFor example, you can create a hybrid type that can accept either a string or a number:\n\n type StringOrNumber = string | number;\n \n\nYou can also use hybrid types to create more complex types that can represent a combination of several different types of values. For example:\n\n type Education = {\n degree: string;\n school: string;\n year: number;\n };\n \n type User = {\n name: string;\n age: number;\n email: string;\n education: Education;\n };",
|
||||
"links": []
|
||||
},
|
||||
"ib0jfZzukYOZ42AdJqt_W": {
|
||||
"title": "Classes",
|
||||
@@ -800,11 +794,6 @@
|
||||
"title": "TypeScript Utility Types Guide",
|
||||
"url": "https://camchenry.com/blog/typescript-utility-types",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "TypeScript Utility Types: Key Concepts And Best Practices",
|
||||
"url": "https://marketsplash.com/tutorials/typescript/typescript-utility-types/",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -573,11 +573,6 @@
|
||||
"title": "Binding Events",
|
||||
"url": "https://vuejs.org/guide/essentials/event-handling",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Vue.js Event Handling",
|
||||
"url": "https://www.geeksforgeeks.org/vue-js-event-handling/",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
BIN
public/roadmaps/cloudflare.png
Normal file
BIN
public/roadmaps/cloudflare.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 506 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 512 KiB |
@@ -43,6 +43,7 @@ Here is the list of available roadmaps with more being actively worked upon.
|
||||
- [AI and Data Scientist Roadmap](https://roadmap.sh/ai-data-scientist)
|
||||
- [AI Engineer Roadmap](https://roadmap.sh/ai-engineer)
|
||||
- [AWS Roadmap](https://roadmap.sh/aws)
|
||||
- [Cloudflare Roadmap](https://roadmap.sh/cloudflare)
|
||||
- [Linux Roadmap](https://roadmap.sh/linux)
|
||||
- [Terraform Roadmap](https://roadmap.sh/terraform)
|
||||
- [Data Analyst Roadmap](https://roadmap.sh/data-analyst)
|
||||
|
||||
181
scripts/gemini-roadmap-content.ts
Normal file
181
scripts/gemini-roadmap-content.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { Edge, Node } from 'reactflow';
|
||||
import matter from 'gray-matter';
|
||||
import type { RoadmapFrontmatter } from '../src/lib/roadmap';
|
||||
import { slugify } from '../src/lib/slugger';
|
||||
import { runPromisesInBatchSequentially } from '../src/lib/promise';
|
||||
|
||||
import { createGoogleGenerativeAI } from '@ai-sdk/google';
|
||||
import { generateText } from 'ai';
|
||||
|
||||
// ERROR: `__dirname` is not defined in ES module scope
|
||||
// https://iamwebwiz.medium.com/how-to-fix-dirname-is-not-defined-in-es-module-scope-34d94a86694d
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Usage: tsx ./scripts/editor-roadmap-content.ts <roadmapId>
|
||||
const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
|
||||
console.log('GEMINI_API_KEY:', GEMINI_API_KEY);
|
||||
const ROADMAP_CONTENT_DIR = path.join(__dirname, '../src/data/roadmaps');
|
||||
const roadmapId = process.argv[2];
|
||||
|
||||
const google = createGoogleGenerativeAI({
|
||||
apiKey: process.env.GEMINI_API_KEY,
|
||||
});
|
||||
|
||||
const allowedRoadmapIds = await fs.readdir(ROADMAP_CONTENT_DIR);
|
||||
if (!roadmapId) {
|
||||
console.error('Roadmap Id is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!allowedRoadmapIds.includes(roadmapId)) {
|
||||
console.error(`Invalid roadmap key ${roadmapId}`);
|
||||
console.error(`Allowed keys are ${allowedRoadmapIds.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const roadmapFrontmatterDir = path.join(
|
||||
ROADMAP_CONTENT_DIR,
|
||||
roadmapId,
|
||||
`${roadmapId}.md`,
|
||||
);
|
||||
const roadmapFrontmatterRaw = await fs.readFile(roadmapFrontmatterDir, 'utf-8');
|
||||
const { data } = matter(roadmapFrontmatterRaw);
|
||||
|
||||
const roadmapFrontmatter = data as RoadmapFrontmatter;
|
||||
if (!roadmapFrontmatter) {
|
||||
console.error('Invalid roadmap frontmatter');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (roadmapFrontmatter.renderer !== 'editor') {
|
||||
console.error('Only Editor Rendered Roadmaps are allowed');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const roadmapDir = path.join(
|
||||
ROADMAP_CONTENT_DIR,
|
||||
roadmapId,
|
||||
`${roadmapId}.json`,
|
||||
);
|
||||
const roadmapContent = await fs.readFile(roadmapDir, 'utf-8');
|
||||
let { nodes, edges } = JSON.parse(roadmapContent) as {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
};
|
||||
const enrichedNodes = nodes
|
||||
.filter(
|
||||
(node) =>
|
||||
node?.type &&
|
||||
['topic', 'subtopic'].includes(node.type) &&
|
||||
node.data?.label,
|
||||
)
|
||||
.map((node) => {
|
||||
// Because we only need the parent id and title for subtopics
|
||||
if (node.type !== 'subtopic') {
|
||||
return node;
|
||||
}
|
||||
|
||||
const parentNodeId =
|
||||
edges.find((edge) => edge.target === node.id)?.source || '';
|
||||
const parentNode = nodes.find((n) => n.id === parentNodeId);
|
||||
|
||||
return {
|
||||
...node,
|
||||
parentId: parentNodeId,
|
||||
parentTitle: parentNode?.data?.label || '',
|
||||
};
|
||||
}) as (Node & { parentId?: string; parentTitle?: string })[];
|
||||
|
||||
const roadmapContentDir = path.join(ROADMAP_CONTENT_DIR, roadmapId, 'content');
|
||||
const stats = await fs.stat(roadmapContentDir).catch(() => null);
|
||||
if (!stats || !stats.isDirectory()) {
|
||||
await fs.mkdir(roadmapContentDir, { recursive: true });
|
||||
}
|
||||
|
||||
function writeTopicContent(
|
||||
roadmapTitle: string,
|
||||
childTopic: string,
|
||||
parentTopic?: string,
|
||||
) {
|
||||
const updatedTitle = roadmapTitle.replace('Roadmap', '').trim().replace('Developer', '');
|
||||
let prompt = `I will give you a topic and you need to write a brief introduction for that in "${roadmapTitle}". Your format should be as follows and be in strictly markdown format:
|
||||
|
||||
# (Put a heading for the topic without adding parent "Subtopic in Topic" or "Topic in Roadmap" or "Subtopic under XYZ" etc.)
|
||||
|
||||
(Briefly explain the topic in one paragraph using simple english. Don't start with explaining how important the topic is with regard to "${roadmapTitle}". Don't say something along the lines of "XYZ plays a crucial role in ${roadmapTitle}". Don't include anything saying "In the context of ${roadmapTitle}". Instead, start with a simple explanation of the topic itself. For example, if the topic is "React", you can start with "React is a JavaScript library for building user interfaces."".)
|
||||
`;
|
||||
|
||||
if (!parentTopic) {
|
||||
prompt += `First topic is: ${childTopic}`;
|
||||
} else {
|
||||
prompt += `First topic is: "${parentTopic} > ${childTopic}"`;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
generateText({
|
||||
model: google('gemini-2.0-flash'),
|
||||
prompt: prompt,
|
||||
providerOptions: {
|
||||
}
|
||||
})
|
||||
.then((response) => {
|
||||
const article = response.text;
|
||||
|
||||
resolve(article);
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function writeNodeContent(node: Node & { parentTitle?: string }) {
|
||||
const nodeDirPattern = `${slugify(node.data.label)}@${node.id}.md`;
|
||||
if (!roadmapContentFiles.includes(nodeDirPattern)) {
|
||||
console.log(`Missing file for: ${nodeDirPattern}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeDir = path.join(roadmapContentDir, nodeDirPattern);
|
||||
const nodeContent = await fs.readFile(nodeDir, 'utf-8');
|
||||
const isFileEmpty = !nodeContent.replace(`# ${node.data.label}`, '').trim();
|
||||
if (!isFileEmpty) {
|
||||
console.log(`❌ Ignoring ${nodeDirPattern}. Not empty.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const topic = node.data.label;
|
||||
const parentTopic = node.parentTitle;
|
||||
|
||||
console.log(`⏳ Generating content for ${topic}...`);
|
||||
let newContentFile = '';
|
||||
if (GEMINI_API_KEY) {
|
||||
newContentFile = (await writeTopicContent(
|
||||
roadmapFrontmatter.title,
|
||||
topic,
|
||||
parentTopic,
|
||||
)) as string;
|
||||
} else {
|
||||
newContentFile = `# ${topic}`;
|
||||
}
|
||||
|
||||
await fs.writeFile(nodeDir, newContentFile, 'utf-8');
|
||||
console.log(`✅ Content generated for ${topic}`);
|
||||
}
|
||||
|
||||
let roadmapContentFiles = await fs.readdir(roadmapContentDir, {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
if (!GEMINI_API_KEY) {
|
||||
console.log('----------------------------------------');
|
||||
console.log('GEMINI_API_KEY not found. Skipping gemini api calls...');
|
||||
console.log('----------------------------------------');
|
||||
}
|
||||
const promises = enrichedNodes.map((node) => () => writeNodeContent(node));
|
||||
await runPromisesInBatchSequentially(promises, 20);
|
||||
console.log('✅ All content generated');
|
||||
@@ -18,3 +18,16 @@ export function aiRoadmapApi(context: APIContext) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface AICourseDocument {
|
||||
_id: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
slug?: string;
|
||||
keyword: string;
|
||||
difficulty: string;
|
||||
data: string;
|
||||
viewCount: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -27,7 +27,7 @@ const sidebarLinks = [
|
||||
href: '/account/update-profile',
|
||||
title: 'Profile',
|
||||
id: 'profile',
|
||||
isNew: true,
|
||||
isNew: false,
|
||||
icon: {
|
||||
glyph: 'user',
|
||||
classes: 'h-4 w-4',
|
||||
@@ -56,7 +56,7 @@ const sidebarLinks = [
|
||||
},
|
||||
{
|
||||
href: '/account/road-card',
|
||||
title: 'Card',
|
||||
title: 'Road Card',
|
||||
id: 'road-card',
|
||||
isNew: false,
|
||||
icon: {
|
||||
@@ -64,6 +64,16 @@ const sidebarLinks = [
|
||||
classes: 'h-4 w-4',
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/account/billing',
|
||||
title: 'Billing',
|
||||
id: 'billing',
|
||||
isNew: true,
|
||||
icon: {
|
||||
glyph: 'credit-card',
|
||||
classes: 'h-4 w-4',
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/account/settings',
|
||||
title: 'Settings',
|
||||
@@ -97,7 +107,7 @@ const sidebarLinks = [
|
||||
}`}
|
||||
>
|
||||
<AstroIcon icon={'users'} class={`h-4 w-4 mr-2`} />
|
||||
Teams
|
||||
Teams
|
||||
</a>
|
||||
</li>
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getUser } from '../../lib/jwt';
|
||||
import { getPercentage } from '../../helper/number';
|
||||
import { ProjectProgressActions } from './ProjectProgressActions';
|
||||
import { cn } from '../../lib/classname';
|
||||
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getUser } from '../../lib/jwt';
|
||||
import { getPercentage } from '../../helper/number';
|
||||
import { ResourceProgressActions } from './ResourceProgressActions';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { getPercentage } from '../../lib/number';
|
||||
|
||||
type ResourceProgressType = {
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
|
||||
14
src/components/Analytics/Clarity.astro
Normal file
14
src/components/Analytics/Clarity.astro
Normal file
@@ -0,0 +1,14 @@
|
||||
<script type='text/javascript'>
|
||||
(function (c, l, a, r, i, t, y) {
|
||||
c[a] =
|
||||
c[a] ||
|
||||
function () {
|
||||
(c[a].q = c[a].q || []).push(arguments);
|
||||
};
|
||||
t = l.createElement(r);
|
||||
t.async = 1;
|
||||
t.src = 'https://www.clarity.ms/tag/' + i;
|
||||
y = l.getElementsByTagName(r)[0];
|
||||
y.parentNode.insertBefore(t, y);
|
||||
})(window, document, 'clarity', 'script', 'qcw723i36o');
|
||||
</script>
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
COURSE_PURCHASE_PARAM,
|
||||
setAuthToken,
|
||||
} from '../../lib/jwt';
|
||||
import { cn } from '../../../editor/utils/classname.ts';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { Spinner } from '../ReactIcons/Spinner.tsx';
|
||||
import { CHECKOUT_AFTER_LOGIN_KEY } from './CourseLoginPopup.tsx';
|
||||
|
||||
@@ -7,7 +7,7 @@ import { AuthenticationForm } from './AuthenticationForm';
|
||||
<Popup id='login-popup' title='' subtitle=''>
|
||||
<div class='mb-7 text-center'>
|
||||
<p class='mb-3 text-2xl font-semibold leading-5 text-slate-900'>
|
||||
Login to your account
|
||||
Login or Signup
|
||||
</p>
|
||||
<p class='mt-2 text-sm leading-4 text-slate-600'>
|
||||
You must be logged in to perform this action.
|
||||
|
||||
226
src/components/Billing/BillingPage.tsx
Normal file
226
src/components/Billing/BillingPage.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
billingDetailsOptions,
|
||||
USER_SUBSCRIPTION_PLAN_PRICES,
|
||||
} from '../../queries/billing';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { httpPost } from '../../lib/query-http';
|
||||
import { UpgradeAccountModal } from './UpgradeAccountModal';
|
||||
import { getUrlParams } from '../../lib/browser';
|
||||
import { VerifyUpgrade } from './VerifyUpgrade';
|
||||
import { EmptyBillingScreen } from './EmptyBillingScreen';
|
||||
import {
|
||||
Calendar,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
CreditCard,
|
||||
ArrowRightLeft,
|
||||
CircleX,
|
||||
} from 'lucide-react';
|
||||
import { BillingWarning } from './BillingWarning';
|
||||
|
||||
export type CreateCustomerPortalBody = {};
|
||||
|
||||
export type CreateCustomerPortalResponse = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
export function BillingPage() {
|
||||
const toast = useToast();
|
||||
|
||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
||||
const [showVerifyUpgradeModal, setShowVerifyUpgradeModal] = useState(false);
|
||||
|
||||
const { data: billingDetails, isPending: isLoadingBillingDetails } = useQuery(
|
||||
billingDetailsOptions(),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const isCanceled =
|
||||
billingDetails?.status === 'canceled' || billingDetails?.cancelAtPeriodEnd;
|
||||
const isPastDue = billingDetails?.status === 'past_due';
|
||||
|
||||
const {
|
||||
mutate: createCustomerPortal,
|
||||
isSuccess: isCreatingCustomerPortalSuccess,
|
||||
isPending: isCreatingCustomerPortal,
|
||||
} = useMutation(
|
||||
{
|
||||
mutationFn: (body: CreateCustomerPortalBody) => {
|
||||
return httpPost<CreateCustomerPortalResponse>(
|
||||
'/v1-create-customer-portal',
|
||||
body,
|
||||
);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
window.location.href = data.url;
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
toast.error(error?.message || 'Failed to Create Customer Portal');
|
||||
},
|
||||
},
|
||||
queryClient,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadingBillingDetails) {
|
||||
return;
|
||||
}
|
||||
|
||||
pageProgressMessage.set('');
|
||||
const shouldVerifyUpgrade = getUrlParams()?.s === '1';
|
||||
if (shouldVerifyUpgrade) {
|
||||
setShowVerifyUpgradeModal(true);
|
||||
}
|
||||
}, [isLoadingBillingDetails]);
|
||||
|
||||
if (isLoadingBillingDetails || !billingDetails) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedPlanDetails = USER_SUBSCRIPTION_PLAN_PRICES.find(
|
||||
(plan) => plan.priceId === billingDetails?.priceId,
|
||||
);
|
||||
const priceDetails = selectedPlanDetails;
|
||||
|
||||
const formattedNextBillDate = new Date(
|
||||
billingDetails?.currentPeriodEnd || '',
|
||||
).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{showUpgradeModal && (
|
||||
<UpgradeAccountModal
|
||||
onClose={() => {
|
||||
setShowUpgradeModal(false);
|
||||
}}
|
||||
success="/account/billing?s=1"
|
||||
cancel="/account/billing"
|
||||
/>
|
||||
)}
|
||||
|
||||
{showVerifyUpgradeModal && <VerifyUpgrade />}
|
||||
|
||||
{billingDetails?.status === 'none' && !isLoadingBillingDetails && (
|
||||
<EmptyBillingScreen onUpgrade={() => setShowUpgradeModal(true)} />
|
||||
)}
|
||||
|
||||
{billingDetails?.status !== 'none' &&
|
||||
!isLoadingBillingDetails &&
|
||||
priceDetails && (
|
||||
<div className="mt-1">
|
||||
{isCanceled && (
|
||||
<BillingWarning
|
||||
icon={CircleX}
|
||||
message="Your subscription has been canceled."
|
||||
buttonText="Reactivate?"
|
||||
onButtonClick={() => {
|
||||
createCustomerPortal({});
|
||||
}}
|
||||
isLoading={
|
||||
isCreatingCustomerPortal || isCreatingCustomerPortalSuccess
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isPastDue && (
|
||||
<BillingWarning
|
||||
message="We were not able to charge your card."
|
||||
buttonText="Update payment information."
|
||||
onButtonClick={() => {
|
||||
createCustomerPortal({});
|
||||
}}
|
||||
isLoading={
|
||||
isCreatingCustomerPortal || isCreatingCustomerPortalSuccess
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<h2 className="mb-2 text-xl font-semibold text-black">
|
||||
Current Subscription
|
||||
</h2>
|
||||
|
||||
<p className="text-sm text-gray-500">
|
||||
Thank you for being a pro member. Your plan details are below.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-col gap-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-gray-100">
|
||||
<RefreshCw className="size-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs uppercase tracking-wider text-gray-400">
|
||||
Payment
|
||||
</span>
|
||||
<h3 className="flex items-baseline text-lg font-semibold text-black">
|
||||
${priceDetails.amount}
|
||||
<span className="ml-1 text-sm font-normal text-gray-500">
|
||||
/ {priceDetails.interval}
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t border-gray-100 pt-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-gray-100">
|
||||
<Calendar className="size-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs uppercase tracking-wider text-gray-400">
|
||||
{billingDetails?.cancelAtPeriodEnd
|
||||
? 'Expires On'
|
||||
: 'Renews On'}
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-black">
|
||||
{formattedNextBillDate}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex gap-3 max-sm:flex-col">
|
||||
{!isCanceled && (
|
||||
<button
|
||||
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 max-sm:flex-grow"
|
||||
onClick={() => {
|
||||
setShowUpgradeModal(true);
|
||||
}}
|
||||
>
|
||||
<ArrowRightLeft className="mr-2 h-4 w-4" />
|
||||
Switch Plan
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => {
|
||||
createCustomerPortal({});
|
||||
}}
|
||||
disabled={
|
||||
isCreatingCustomerPortal || isCreatingCustomerPortalSuccess
|
||||
}
|
||||
>
|
||||
{isCreatingCustomerPortal ||
|
||||
isCreatingCustomerPortalSuccess ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Manage Subscription
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
39
src/components/Billing/BillingWarning.tsx
Normal file
39
src/components/Billing/BillingWarning.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { AlertTriangle, type LucideIcon } from 'lucide-react';
|
||||
|
||||
export type BillingWarningProps = {
|
||||
icon?: LucideIcon;
|
||||
message: string;
|
||||
onButtonClick?: () => void;
|
||||
buttonText?: string;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export function BillingWarning(props: BillingWarningProps) {
|
||||
const {
|
||||
message,
|
||||
onButtonClick,
|
||||
buttonText,
|
||||
isLoading,
|
||||
icon: Icon = AlertTriangle,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="mb-6 flex items-center gap-2 rounded-lg border border-red-300 bg-red-50 p-4 text-sm text-red-600">
|
||||
<Icon className="h-5 w-5" />
|
||||
<span>
|
||||
{message}
|
||||
{buttonText && (
|
||||
<button
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
onButtonClick?.();
|
||||
}}
|
||||
className="font-semibold underline underline-offset-4 disabled:cursor-not-allowed disabled:opacity-50 ml-0.5"
|
||||
>
|
||||
{buttonText}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
src/components/Billing/CheckSubscriptionVerification.tsx
Normal file
22
src/components/Billing/CheckSubscriptionVerification.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getUrlParams } from '../../lib/browser';
|
||||
import { VerifyUpgrade } from "./VerifyUpgrade";
|
||||
|
||||
export function CheckSubscriptionVerification() {
|
||||
const [shouldVerifyUpgrade, setShouldVerifyUpgrade] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const params = getUrlParams();
|
||||
if (params.s !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
setShouldVerifyUpgrade(true);
|
||||
}, []);
|
||||
|
||||
if (!shouldVerifyUpgrade) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <VerifyUpgrade />;
|
||||
}
|
||||
68
src/components/Billing/EmptyBillingScreen.tsx
Normal file
68
src/components/Billing/EmptyBillingScreen.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
CreditCard,
|
||||
Ellipsis,
|
||||
HeartHandshake,
|
||||
MessageCircleIcon,
|
||||
SparklesIcon,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
type EmptyBillingScreenProps = {
|
||||
onUpgrade: () => void;
|
||||
};
|
||||
|
||||
const perks = [
|
||||
{
|
||||
icon: Zap,
|
||||
text: 'Unlimited AI course generations',
|
||||
},
|
||||
{
|
||||
icon: MessageCircleIcon,
|
||||
text: 'Unlimited AI Chat feature usage',
|
||||
},
|
||||
{
|
||||
icon: SparklesIcon,
|
||||
text: 'Early access to new features',
|
||||
},
|
||||
{
|
||||
icon: HeartHandshake,
|
||||
text: 'Support the development of platform',
|
||||
},
|
||||
{
|
||||
icon: Ellipsis,
|
||||
text: 'more perks coming soon!',
|
||||
},
|
||||
];
|
||||
|
||||
export function EmptyBillingScreen(props: EmptyBillingScreenProps) {
|
||||
const { onUpgrade } = props;
|
||||
|
||||
return (
|
||||
<div className="mt-12 flex h-full w-full flex-col items-center">
|
||||
<CreditCard className="mb-3 h-12 w-12 text-gray-300" />
|
||||
<h3 className="mb-3 text-xl font-semibold text-black">
|
||||
No Active Subscription
|
||||
</h3>
|
||||
|
||||
<p className="text-balance text-gray-700">
|
||||
Unlock pro benefits by upgrading to a subscription
|
||||
</p>
|
||||
|
||||
<div className="my-8 flex flex-col gap-2">
|
||||
{perks.map((perk) => (
|
||||
<p className="textsm flex items-center text-gray-600" key={perk.text}>
|
||||
<perk.icon className="mr-2 h-4 w-4 text-gray-500" />
|
||||
{perk.text}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onUpgrade}
|
||||
className="inline-flex items-center justify-center rounded-lg bg-black px-6 py-2.5 text-sm font-medium text-white transition-colors hover:opacity-80 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2"
|
||||
>
|
||||
Upgrade Account
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
src/components/Billing/UpdatePlanConfirmation.tsx
Normal file
96
src/components/Billing/UpdatePlanConfirmation.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import type { USER_SUBSCRIPTION_PLAN_PRICES } from '../../queries/billing';
|
||||
import { Modal } from '../Modal';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { VerifyUpgrade } from './VerifyUpgrade';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import { httpPost } from '../../lib/query-http';
|
||||
|
||||
type UpdatePlanBody = {
|
||||
priceId: string;
|
||||
};
|
||||
|
||||
type UpdatePlanResponse = {
|
||||
status: 'ok';
|
||||
};
|
||||
|
||||
type UpdatePlanConfirmationProps = {
|
||||
planDetails: (typeof USER_SUBSCRIPTION_PLAN_PRICES)[number];
|
||||
onClose: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function UpdatePlanConfirmation(props: UpdatePlanConfirmationProps) {
|
||||
const { planDetails, onClose, onCancel } = props;
|
||||
|
||||
const toast = useToast();
|
||||
const {
|
||||
mutate: updatePlan,
|
||||
isPending,
|
||||
status,
|
||||
} = useMutation(
|
||||
{
|
||||
mutationFn: (body: UpdatePlanBody) => {
|
||||
return httpPost<UpdatePlanResponse>(
|
||||
'/v1-update-subscription-plan',
|
||||
body,
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
toast.error(error?.message || 'Failed to Create Customer Portal');
|
||||
},
|
||||
},
|
||||
queryClient,
|
||||
);
|
||||
|
||||
if (!planDetails) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedPrice = planDetails;
|
||||
if (status === 'success') {
|
||||
return <VerifyUpgrade newPriceId={selectedPrice.priceId} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={isPending ? () => {} : onClose}
|
||||
bodyClassName="rounded-xl bg-white p-6"
|
||||
>
|
||||
<h3 className="text-xl font-bold text-black">Subscription Update</h3>
|
||||
<p className="mt-2 text-balance text-gray-600">
|
||||
Your plan will be updated to the{' '}
|
||||
<b className="text-black">{planDetails.interval}</b> plan, and will
|
||||
be charged{' '}
|
||||
<b className="text-black">
|
||||
${selectedPrice.amount}/{selectedPrice.interval}
|
||||
</b>
|
||||
.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
className="rounded-md border border-gray-200 py-2 text-sm font-semibold hover:bg-gray-50 transition-colors disabled:opacity-50"
|
||||
onClick={onCancel}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center justify-center rounded-md bg-purple-600 py-2 text-sm font-semibold text-white hover:bg-purple-500 transition-colors disabled:opacity-50"
|
||||
disabled={isPending}
|
||||
onClick={() => {
|
||||
updatePlan({ priceId: selectedPrice.priceId });
|
||||
}}
|
||||
>
|
||||
{isPending && (
|
||||
<Loader2Icon className="size-4 animate-spin stroke-[2.5] mr-2" />
|
||||
)}
|
||||
{!isPending && 'Confirm'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
306
src/components/Billing/UpgradeAccountModal.tsx
Normal file
306
src/components/Billing/UpgradeAccountModal.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import {
|
||||
Loader2,
|
||||
Zap,
|
||||
Infinity,
|
||||
MessageSquare,
|
||||
Sparkles,
|
||||
Heart,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getUser } from '../../lib/jwt';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { Modal } from '../Modal';
|
||||
import {
|
||||
billingDetailsOptions,
|
||||
USER_SUBSCRIPTION_PLAN_PRICES,
|
||||
type AllowedSubscriptionInterval,
|
||||
} from '../../queries/billing';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { httpPost } from '../../lib/query-http';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { UpdatePlanConfirmation } from './UpdatePlanConfirmation';
|
||||
|
||||
// Define the perk type
|
||||
type Perk = {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
// Define the perks array
|
||||
const PREMIUM_PERKS: Perk[] = [
|
||||
{
|
||||
icon: Zap,
|
||||
title: 'Unlimited AI Course Generations',
|
||||
description: 'Generate as many custom courses as you need',
|
||||
},
|
||||
{
|
||||
icon: Infinity,
|
||||
title: 'No Daily Limits on course features',
|
||||
description: 'Use all features without restrictions',
|
||||
},
|
||||
{
|
||||
icon: MessageSquare,
|
||||
title: 'Unlimited Course Follow-ups',
|
||||
description: 'Ask as many questions as you need',
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
title: 'Early Access to Features',
|
||||
description: 'Be the first to try new tools and features',
|
||||
},
|
||||
{
|
||||
icon: Heart,
|
||||
title: 'Support Development',
|
||||
description: 'Help us continue building roadmap.sh',
|
||||
},
|
||||
];
|
||||
|
||||
type CreateSubscriptionCheckoutSessionBody = {
|
||||
priceId: string;
|
||||
success?: string;
|
||||
cancel?: string;
|
||||
};
|
||||
|
||||
type CreateSubscriptionCheckoutSessionResponse = {
|
||||
checkoutUrl: string;
|
||||
};
|
||||
|
||||
type UpgradeAccountModalProps = {
|
||||
onClose: () => void;
|
||||
|
||||
success?: string;
|
||||
cancel?: string;
|
||||
};
|
||||
|
||||
export function UpgradeAccountModal(props: UpgradeAccountModalProps) {
|
||||
const { onClose, success, cancel } = props;
|
||||
|
||||
const [selectedPlan, setSelectedPlan] =
|
||||
useState<AllowedSubscriptionInterval>('month');
|
||||
const [isUpdatingPlan, setIsUpdatingPlan] = useState(false);
|
||||
|
||||
const user = getUser();
|
||||
|
||||
const {
|
||||
data: userBillingDetails,
|
||||
isLoading,
|
||||
error: billingError,
|
||||
} = useQuery(billingDetailsOptions(), queryClient);
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const {
|
||||
mutate: createCheckoutSession,
|
||||
isPending: isCreatingCheckoutSession,
|
||||
} = useMutation(
|
||||
{
|
||||
mutationFn: (body: CreateSubscriptionCheckoutSessionBody) => {
|
||||
return httpPost<CreateSubscriptionCheckoutSessionResponse>(
|
||||
'/v1-create-subscription-checkout-session',
|
||||
body,
|
||||
);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
window.location.href = data.checkoutUrl;
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
toast.error(error?.message || 'Failed to create checkout session');
|
||||
},
|
||||
},
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const selectedPlanDetails = USER_SUBSCRIPTION_PLAN_PRICES.find(
|
||||
(plan) => plan.interval === selectedPlan,
|
||||
);
|
||||
const currentPlanPriceId = userBillingDetails?.priceId;
|
||||
const currentPlan = USER_SUBSCRIPTION_PLAN_PRICES.find(
|
||||
(plan) => plan.priceId === currentPlanPriceId,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentPlan) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedPlan(currentPlan.interval);
|
||||
}, [currentPlan]);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const loader = isLoading ? (
|
||||
<div className="absolute inset-0 flex h-[540px] w-full items-center justify-center bg-white">
|
||||
<Loader2 className="h-6 w-6 animate-spin stroke-[3px] text-green-600" />
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const error = billingError;
|
||||
const errorContent = error ? (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<p className="text-center text-red-400">
|
||||
{error?.message ||
|
||||
'An error occurred while loading the billing details.'}
|
||||
</p>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const calculateYearlyPrice = (monthlyPrice: number) => {
|
||||
return (monthlyPrice * 12).toFixed(2);
|
||||
};
|
||||
|
||||
if (isUpdatingPlan && selectedPlanDetails) {
|
||||
return (
|
||||
<UpdatePlanConfirmation
|
||||
planDetails={selectedPlanDetails}
|
||||
onClose={() => setIsUpdatingPlan(false)}
|
||||
onCancel={() => setIsUpdatingPlan(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={onClose}
|
||||
bodyClassName="p-4 sm:p-6 bg-white"
|
||||
wrapperClassName="h-auto rounded-xl max-w-3xl w-full min-h-[540px] mx-2 sm:mx-4"
|
||||
overlayClassName="items-start md:items-center"
|
||||
>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
{errorContent}
|
||||
|
||||
{loader}
|
||||
{!isLoading && !error && (
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-6 text-left sm:mb-8">
|
||||
<h2 className="text-xl font-bold text-black sm:text-2xl">
|
||||
Unlock Premium Features
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-gray-600 sm:mt-2 sm:text-base">
|
||||
Supercharge your learning experience with premium benefits
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 grid grid-cols-1 gap-4 sm:mb-8 sm:gap-6 md:grid-cols-2">
|
||||
{USER_SUBSCRIPTION_PLAN_PRICES.map((plan) => {
|
||||
const isCurrentPlanSelected =
|
||||
currentPlan?.priceId === plan.priceId;
|
||||
const isYearly = plan.interval === 'year';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={plan.interval}
|
||||
className={cn(
|
||||
'flex flex-col space-y-3 rounded-lg bg-white p-4 sm:space-y-4 sm:p-6',
|
||||
isYearly
|
||||
? 'border-2 border-yellow-400'
|
||||
: 'border border-gray-200',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-black sm:text-base">
|
||||
{isYearly ? 'Yearly Payment' : 'Monthly Payment'}
|
||||
</h4>
|
||||
{isYearly && (
|
||||
<span className="text-xs font-medium text-green-500 sm:text-sm">
|
||||
(2 months free)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isYearly && (
|
||||
<span className="rounded-full bg-yellow-400 px-1.5 py-0.5 text-xs font-semibold text-black sm:px-2 sm:py-1">
|
||||
Most Popular
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline">
|
||||
{isYearly && (
|
||||
<p className="mr-2 text-xs text-gray-400 line-through sm:text-sm">
|
||||
$
|
||||
{calculateYearlyPrice(
|
||||
USER_SUBSCRIPTION_PLAN_PRICES[0].amount,
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-2xl font-bold text-black sm:text-3xl">
|
||||
${plan.amount}{' '}
|
||||
<span className="text-xs font-normal text-gray-500 sm:text-sm">
|
||||
/ {isYearly ? 'year' : 'month'}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-grow"></div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
className={cn(
|
||||
'flex min-h-9 w-full items-center justify-center rounded-md py-2 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-yellow-400 disabled:cursor-not-allowed disabled:opacity-60 sm:min-h-11 sm:py-2.5 sm:text-base',
|
||||
'bg-yellow-400 text-black hover:bg-yellow-500',
|
||||
)}
|
||||
disabled={
|
||||
isCurrentPlanSelected || isCreatingCheckoutSession
|
||||
}
|
||||
onClick={() => {
|
||||
setSelectedPlan(plan.interval);
|
||||
if (!currentPlanPriceId) {
|
||||
const currentUrlPath = window.location.pathname;
|
||||
createCheckoutSession({
|
||||
priceId: plan.priceId,
|
||||
success: success || `${currentUrlPath}?s=1`,
|
||||
cancel: cancel || `${currentUrlPath}?s=0`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsUpdatingPlan(true);
|
||||
}}
|
||||
data-1p-ignore=""
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
>
|
||||
{isCreatingCheckoutSession &&
|
||||
selectedPlan === plan.interval ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin sm:h-4 sm:w-4" />
|
||||
) : isCurrentPlanSelected ? (
|
||||
'Current Plan'
|
||||
) : (
|
||||
'Select Plan'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Benefits Section */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-2">
|
||||
{PREMIUM_PERKS.map((perk, index) => {
|
||||
const Icon = perk.icon;
|
||||
return (
|
||||
<div key={index} className="flex items-start space-x-2 sm:space-x-3">
|
||||
<Icon className="mt-0.5 h-4 w-4 text-yellow-500 sm:h-5 sm:w-5" />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-black sm:text-base">
|
||||
{perk.title}
|
||||
</h4>
|
||||
<p className="text-xs text-gray-600 sm:text-sm">
|
||||
{perk.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
76
src/components/Billing/VerifyUpgrade.tsx
Normal file
76
src/components/Billing/VerifyUpgrade.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Loader2, CheckCircle } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { billingDetailsOptions } from '../../queries/billing';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { Modal } from '../Modal';
|
||||
import { deleteUrlParam } from '../../lib/browser';
|
||||
|
||||
type VerifyUpgradeProps = {
|
||||
newPriceId?: string;
|
||||
};
|
||||
|
||||
export function VerifyUpgrade(props: VerifyUpgradeProps) {
|
||||
const { newPriceId } = props;
|
||||
|
||||
const { data: userBillingDetails } = useQuery(
|
||||
{
|
||||
...billingDetailsOptions(),
|
||||
refetchInterval: 1000,
|
||||
},
|
||||
queryClient,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userBillingDetails) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
userBillingDetails.status === 'active' &&
|
||||
(newPriceId ? userBillingDetails.priceId === newPriceId : true)
|
||||
) {
|
||||
deleteUrlParam('s');
|
||||
window.location.reload();
|
||||
}
|
||||
}, [userBillingDetails]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
// it's an unique modal, so we don't need to close it
|
||||
// user can close it by refreshing the page
|
||||
onClose={() => {}}
|
||||
bodyClassName="rounded-xl bg-white p-6"
|
||||
>
|
||||
<div className="mb-4 flex flex-col items-center text-center">
|
||||
<CheckCircle className="mb-3 h-12 w-12 text-green-600" />
|
||||
<h3 className="text-xl font-bold text-black">Subscription Activated</h3>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-balance text-center text-gray-600">
|
||||
Your subscription has been activated successfully.
|
||||
</p>
|
||||
|
||||
<p className="mt-4 text-balance text-center text-gray-600">
|
||||
It might take a minute for the changes to reflect. We will{' '}
|
||||
<b className="text-black">reload</b> the page for you.
|
||||
</p>
|
||||
|
||||
<div className="my-6 flex animate-pulse items-center justify-center gap-2">
|
||||
<Loader2 className="size-4 animate-spin stroke-[2.5px] text-green-600" />
|
||||
<span className="text-gray-600">Please wait...</span>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
If it takes longer than expected, please email us at{' '}
|
||||
<a
|
||||
href="mailto:info@roadmap.sh"
|
||||
className="text-blue-600 underline underline-offset-2 hover:text-blue-700"
|
||||
>
|
||||
info@roadmap.sh
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getPercentage } from '../../helper/number';
|
||||
import { getRelativeTimeString } from '../../lib/date';
|
||||
import { getPercentage } from '../../lib/number';
|
||||
import type { UserProgress } from '../TeamProgress/TeamProgressPage';
|
||||
|
||||
type DashboardCustomProgressCardProps = {
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { cn } from '../../../editor/utils/classname';
|
||||
import { useParams } from '../../hooks/use-params';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { getUser } from '../../lib/jwt';
|
||||
import { $teamList } from '../../stores/team';
|
||||
import type { TeamListResponse } from '../TeamDropdown/TeamDropdown';
|
||||
import { DashboardTab } from './DashboardTab';
|
||||
import { DashboardTabButton } from './DashboardTabButton';
|
||||
import { PersonalDashboard, type BuiltInRoadmap } from './PersonalDashboard';
|
||||
import { TeamDashboard } from './TeamDashboard';
|
||||
import { getUser } from '../../lib/jwt';
|
||||
import { useParams } from '../../hooks/use-params';
|
||||
import type { QuestionGroupType } from '../../lib/question-group';
|
||||
import type { GuideFileType } from '../../lib/guide';
|
||||
import type { VideoFileType } from '../../lib/video';
|
||||
|
||||
type DashboardPageProps = {
|
||||
builtInRoleRoadmaps?: BuiltInRoadmap[];
|
||||
builtInSkillRoadmaps?: BuiltInRoadmap[];
|
||||
builtInBestPractices?: BuiltInRoadmap[];
|
||||
isTeamPage?: boolean;
|
||||
questionGroups?: QuestionGroupType[];
|
||||
guides?: GuideFileType[];
|
||||
videos?: VideoFileType[];
|
||||
};
|
||||
|
||||
export function DashboardPage(props: DashboardPageProps) {
|
||||
@@ -23,6 +30,9 @@ export function DashboardPage(props: DashboardPageProps) {
|
||||
builtInBestPractices,
|
||||
builtInSkillRoadmaps,
|
||||
isTeamPage = false,
|
||||
questionGroups,
|
||||
guides,
|
||||
videos,
|
||||
} = props;
|
||||
|
||||
const currentUser = getUser();
|
||||
@@ -66,78 +76,79 @@ export function DashboardPage(props: DashboardPageProps) {
|
||||
: '/images/default-avatar.png';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pb-20 pt-8">
|
||||
<div className="container">
|
||||
<div className="mb-6 flex flex-wrap items-center gap-1.5 sm:mb-8">
|
||||
<DashboardTab
|
||||
label="Personal"
|
||||
isActive={!selectedTeamId && !isTeamPage}
|
||||
href="/dashboard"
|
||||
avatar={userAvatar}
|
||||
/>
|
||||
|
||||
{isLoading && (
|
||||
<>
|
||||
<DashboardTabSkeleton />
|
||||
<DashboardTabSkeleton />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isLoading && (
|
||||
<>
|
||||
{teamList.map((team) => {
|
||||
const { avatar } = team;
|
||||
const avatarUrl = avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}`
|
||||
: '/images/default-avatar.png';
|
||||
return (
|
||||
<DashboardTab
|
||||
key={team._id}
|
||||
label={team.name}
|
||||
isActive={team._id === selectedTeamId}
|
||||
{...(team.status === 'invited'
|
||||
? {
|
||||
href: `/respond-invite?i=${team.memberId}`,
|
||||
}
|
||||
: {
|
||||
href: `/team?t=${team._id}`,
|
||||
})}
|
||||
avatar={avatarUrl}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<DashboardTab
|
||||
label="+ Create Team"
|
||||
isActive={false}
|
||||
href="/team/new"
|
||||
className="border border-dashed border-gray-300 bg-transparent px-3 text-[13px] text-sm text-gray-500 hover:border-gray-600 hover:text-black"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<>
|
||||
<div
|
||||
className={cn('bg-slate-900', {
|
||||
'striped-loader-slate': isLoading,
|
||||
})}
|
||||
>
|
||||
<div className="bg-slate-800/30 py-5 min-h-[70px]">
|
||||
<div className="container flex flex-wrap items-center gap-1.5">
|
||||
{!isLoading && (
|
||||
<>
|
||||
<DashboardTabButton
|
||||
label="Personal"
|
||||
isActive={!selectedTeamId && !isTeamPage}
|
||||
href="/dashboard"
|
||||
avatar={userAvatar}
|
||||
/>
|
||||
{teamList.map((team) => {
|
||||
const { avatar } = team;
|
||||
const avatarUrl = avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}`
|
||||
: '/images/default-avatar.png';
|
||||
return (
|
||||
<DashboardTabButton
|
||||
key={team._id}
|
||||
label={team.name}
|
||||
isActive={team._id === selectedTeamId}
|
||||
{...(team.status === 'invited'
|
||||
? {
|
||||
href: `/respond-invite?i=${team.memberId}`,
|
||||
}
|
||||
: {
|
||||
href: `/team?t=${team._id}`,
|
||||
})}
|
||||
avatar={avatarUrl}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<DashboardTabButton
|
||||
label="+ Create Team"
|
||||
isActive={false}
|
||||
href="/team/new"
|
||||
className="border border-dashed border-slate-700 bg-transparent px-3 text-[13px] text-sm text-gray-500 hover:border-solid hover:border-slate-700 hover:text-gray-400"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="">
|
||||
{!selectedTeamId && !isTeamPage && (
|
||||
<PersonalDashboard
|
||||
builtInRoleRoadmaps={builtInRoleRoadmaps}
|
||||
builtInSkillRoadmaps={builtInSkillRoadmaps}
|
||||
builtInBestPractices={builtInBestPractices}
|
||||
/>
|
||||
<div className="bg-slate-900">
|
||||
<PersonalDashboard
|
||||
builtInRoleRoadmaps={builtInRoleRoadmaps}
|
||||
builtInSkillRoadmaps={builtInSkillRoadmaps}
|
||||
builtInBestPractices={builtInBestPractices}
|
||||
questionGroups={questionGroups}
|
||||
guides={guides}
|
||||
videos={videos}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(selectedTeamId || isTeamPage) && (
|
||||
<TeamDashboard
|
||||
builtInRoleRoadmaps={builtInRoleRoadmaps!}
|
||||
builtInSkillRoadmaps={builtInSkillRoadmaps!}
|
||||
teamId={selectedTeamId!}
|
||||
/>
|
||||
<div className="container">
|
||||
<TeamDashboard
|
||||
builtInRoleRoadmaps={builtInRoleRoadmaps!}
|
||||
builtInSkillRoadmaps={builtInSkillRoadmaps!}
|
||||
teamId={selectedTeamId!}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardTabSkeleton() {
|
||||
return (
|
||||
<div className="h-[30px] w-[114px] animate-pulse rounded-md border bg-white"></div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getPercentage } from '../../helper/number';
|
||||
import { getPercentage } from '../../lib/number';
|
||||
import type { UserProgress } from '../TeamProgress/TeamProgressPage';
|
||||
import { ArrowUpRight, ExternalLink } from 'lucide-react';
|
||||
|
||||
type DashboardProgressCardProps = {
|
||||
progress: UserProgress;
|
||||
|
||||
@@ -11,7 +11,7 @@ type DashboardTabProps = {
|
||||
icon?: ReactNode;
|
||||
};
|
||||
|
||||
export function DashboardTab(props: DashboardTabProps) {
|
||||
export function DashboardTabButton(props: DashboardTabProps) {
|
||||
const { isActive, onClick, label, className, href, avatar, icon } = props;
|
||||
|
||||
const Slot = href ? 'a' : 'button';
|
||||
@@ -20,8 +20,10 @@ export function DashboardTab(props: DashboardTabProps) {
|
||||
<Slot
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex h-[30px] shrink-0 items-center gap-1 rounded-md border bg-white p-1.5 px-2 text-sm leading-none text-gray-600',
|
||||
isActive ? 'border-gray-500 bg-gray-200 text-gray-900' : '',
|
||||
'flex h-[30px] shrink-0 items-center gap-1 rounded-md border border-slate-700 bg-slate-800 p-1.5 pl-2 pr-3 text-sm leading-none text-gray-400 transition-colors hover:bg-slate-700',
|
||||
isActive
|
||||
? 'border-slate-200 bg-slate-200 text-gray-900 hover:bg-slate-200'
|
||||
: '',
|
||||
className,
|
||||
)}
|
||||
{...(href ? { href } : {})}
|
||||
@@ -30,7 +32,7 @@ export function DashboardTab(props: DashboardTabProps) {
|
||||
<img
|
||||
src={avatar}
|
||||
alt="avatar"
|
||||
className="h-4 w-4 mr-0.5 rounded-full object-cover"
|
||||
className="mr-0.5 h-4 w-4 rounded-full object-cover"
|
||||
/>
|
||||
)}
|
||||
{icon}
|
||||
@@ -1,23 +1,47 @@
|
||||
import { type JSXElementConstructor, useEffect, useState } from 'react';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import type { UserProgress } from '../TeamProgress/TeamProgressPage';
|
||||
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions';
|
||||
import type { PageType } from '../CommandMenu/CommandMenu';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { getCurrentPeriod } from '../../lib/date';
|
||||
import { ListDashboardCustomProgress } from './ListDashboardCustomProgress';
|
||||
import { RecommendedRoadmaps } from './RecommendedRoadmaps';
|
||||
import { ProgressStack } from './ProgressStack';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { $accountStreak, type StreakResponse } from '../../stores/streak';
|
||||
import { CheckEmoji } from '../ReactIcons/CheckEmoji.tsx';
|
||||
import { ConstructionEmoji } from '../ReactIcons/ConstructionEmoji.tsx';
|
||||
import { BookEmoji } from '../ReactIcons/BookEmoji.tsx';
|
||||
import { DashboardAiRoadmaps } from './DashboardAiRoadmaps.tsx';
|
||||
import {
|
||||
ChartColumn,
|
||||
CheckSquare,
|
||||
FolderGit2,
|
||||
SquarePen,
|
||||
Zap,
|
||||
type LucideIcon
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { AllowedProfileVisibility } from '../../api/user.ts';
|
||||
import { PencilIcon, type LucideIcon } from 'lucide-react';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import type { GuideFileType } from '../../lib/guide';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import type { QuestionGroupType } from '../../lib/question-group';
|
||||
import type { AllowedRoadmapRenderer } from '../../lib/roadmap.ts';
|
||||
import type { VideoFileType } from '../../lib/video';
|
||||
import { $accountStreak, type StreakResponse } from '../../stores/streak';
|
||||
import type { PageType } from '../CommandMenu/CommandMenu';
|
||||
import { FeaturedGuideList } from '../FeaturedGuides/FeaturedGuideList';
|
||||
import { FeaturedVideoList } from '../FeaturedVideos/FeaturedVideoList';
|
||||
import {
|
||||
FavoriteRoadmaps,
|
||||
type AIRoadmapType,
|
||||
} from '../HeroSection/FavoriteRoadmaps.tsx';
|
||||
import { HeroRoadmap } from '../HeroSection/HeroRoadmap.tsx';
|
||||
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions';
|
||||
import type { UserProgress } from '../TeamProgress/TeamProgressPage';
|
||||
|
||||
const projectGroups = [
|
||||
{
|
||||
title: 'Frontend',
|
||||
id: 'frontend',
|
||||
},
|
||||
{
|
||||
title: 'Backend',
|
||||
id: 'backend',
|
||||
},
|
||||
{
|
||||
title: 'DevOps',
|
||||
id: 'devops',
|
||||
},
|
||||
];
|
||||
|
||||
type UserDashboardResponse = {
|
||||
name: string;
|
||||
@@ -28,11 +52,7 @@ type UserDashboardResponse = {
|
||||
profileVisibility: AllowedProfileVisibility;
|
||||
progresses: UserProgress[];
|
||||
projects: ProjectStatusDocument[];
|
||||
aiRoadmaps: {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
}[];
|
||||
aiRoadmaps: AIRoadmapType[];
|
||||
topicDoneToday: number;
|
||||
};
|
||||
|
||||
@@ -42,6 +62,7 @@ export type BuiltInRoadmap = {
|
||||
title: string;
|
||||
description: string;
|
||||
isFavorite?: boolean;
|
||||
isNew?: boolean;
|
||||
relatedRoadmapIds?: string[];
|
||||
renderer?: AllowedRoadmapRenderer;
|
||||
metadata?: Record<string, any>;
|
||||
@@ -51,16 +72,162 @@ type PersonalDashboardProps = {
|
||||
builtInRoleRoadmaps?: BuiltInRoadmap[];
|
||||
builtInSkillRoadmaps?: BuiltInRoadmap[];
|
||||
builtInBestPractices?: BuiltInRoadmap[];
|
||||
questionGroups?: QuestionGroupType[];
|
||||
guides?: GuideFileType[];
|
||||
videos?: VideoFileType[];
|
||||
};
|
||||
|
||||
type DashboardStatItemProps = {
|
||||
icon: LucideIcon;
|
||||
iconClassName: string;
|
||||
value: number;
|
||||
label: string;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
function DashboardStatItem(props: DashboardStatItemProps) {
|
||||
const { icon: Icon, iconClassName, value, label, isLoading } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-lg bg-slate-800/50 py-2 pl-3 pr-3',
|
||||
{
|
||||
'striped-loader-slate striped-loader-slate-fast text-transparent':
|
||||
isLoading,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
size={16}
|
||||
className={cn(iconClassName, { 'text-transparent': isLoading })}
|
||||
/>
|
||||
<span>
|
||||
<span className="tabular-nums">{value}</span> {label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ProfileButtonProps = {
|
||||
isLoading: boolean;
|
||||
name?: string;
|
||||
username?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
function PersonalProfileButton(props: ProfileButtonProps) {
|
||||
const { isLoading, name, username, avatar } = props;
|
||||
|
||||
if (isLoading || !username) {
|
||||
return (
|
||||
<a
|
||||
href="/account/update-profile"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg bg-slate-800/50 py-2 pl-3 pr-3 font-medium outline-slate-700 hover:bg-slate-800 hover:outline-slate-400',
|
||||
{
|
||||
'striped-loader-slate striped-loader-slate-fast text-transparent':
|
||||
isLoading,
|
||||
'bg-blue-500/10 text-blue-500 hover:bg-blue-500/20': !isLoading,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<CheckSquare className="h-4 w-4" strokeWidth={2.5} />
|
||||
Set up your profile
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-1.5">
|
||||
<a
|
||||
href={`/u/${username}`}
|
||||
className="flex items-center gap-2 rounded-lg bg-slate-800/50 py-2 pl-3 pr-3 text-slate-300 transition-colors hover:bg-slate-800/70"
|
||||
>
|
||||
<img
|
||||
src={avatar}
|
||||
alt={name || 'Profile'}
|
||||
className="h-5 w-5 rounded-full ring-1 ring-slate-700"
|
||||
/>
|
||||
<span className="font-medium">Visit Profile</span>
|
||||
</a>
|
||||
<a
|
||||
href="/account/update-profile"
|
||||
className="flex items-center gap-2 rounded-lg bg-slate-800/50 py-2 pl-3 pr-3 text-slate-400 transition-colors hover:bg-slate-800/70 hover:text-slate-300"
|
||||
title="Edit Profile"
|
||||
>
|
||||
<SquarePen className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type DashboardStatsProps = {
|
||||
profile: ProfileButtonProps;
|
||||
accountStreak?: StreakResponse;
|
||||
topicsDoneToday?: number;
|
||||
finishedProjectsCount?: number;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
function DashboardStats(props: DashboardStatsProps) {
|
||||
const {
|
||||
accountStreak,
|
||||
topicsDoneToday = 0,
|
||||
finishedProjectsCount = 0,
|
||||
isLoading,
|
||||
profile,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="container mb-3 flex flex-col gap-4 pb-2 pt-6 text-sm text-slate-400 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex w-full flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<PersonalProfileButton
|
||||
isLoading={isLoading}
|
||||
name={profile.name}
|
||||
username={profile.username}
|
||||
avatar={profile.avatar}
|
||||
/>
|
||||
<div className="hidden flex-wrap items-center gap-2 md:flex">
|
||||
<DashboardStatItem
|
||||
icon={Zap}
|
||||
iconClassName="text-yellow-500"
|
||||
value={accountStreak?.count || 0}
|
||||
label="day streak"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<DashboardStatItem
|
||||
icon={ChartColumn}
|
||||
iconClassName="text-green-500"
|
||||
value={topicsDoneToday}
|
||||
label="learnt today"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<DashboardStatItem
|
||||
icon={FolderGit2}
|
||||
iconClassName="text-blue-500"
|
||||
value={finishedProjectsCount}
|
||||
label="projects finished"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PersonalDashboard(props: PersonalDashboardProps) {
|
||||
const {
|
||||
builtInRoleRoadmaps = [],
|
||||
builtInBestPractices = [],
|
||||
builtInSkillRoadmaps = [],
|
||||
questionGroups = [],
|
||||
guides = [],
|
||||
videos = [],
|
||||
} = props;
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [personalDashboardDetails, setPersonalDashboardDetails] =
|
||||
useState<UserDashboardResponse>();
|
||||
@@ -138,7 +305,9 @@ export function PersonalDashboard(props: PersonalDashboardProps) {
|
||||
return () => window.removeEventListener('refresh-favorites', loadProgress);
|
||||
}, []);
|
||||
|
||||
const learningRoadmapsToShow = (personalDashboardDetails?.progresses || [])
|
||||
const learningRoadmapsToShow: UserProgress[] = (
|
||||
personalDashboardDetails?.progresses || []
|
||||
)
|
||||
.filter((progress) => !progress.isCustomResource)
|
||||
.sort((a, b) => {
|
||||
const updatedAtA = new Date(a.updatedAt);
|
||||
@@ -156,7 +325,10 @@ export function PersonalDashboard(props: PersonalDashboardProps) {
|
||||
});
|
||||
|
||||
const aiGeneratedRoadmaps = personalDashboardDetails?.aiRoadmaps || [];
|
||||
const customRoadmaps = (personalDashboardDetails?.progresses || [])
|
||||
|
||||
const customRoadmaps: UserProgress[] = (
|
||||
personalDashboardDetails?.progresses || []
|
||||
)
|
||||
.filter((progress) => progress.isCustomResource)
|
||||
.sort((a, b) => {
|
||||
const updatedAtA = new Date(a.updatedAt);
|
||||
@@ -169,43 +341,6 @@ export function PersonalDashboard(props: PersonalDashboardProps) {
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}`
|
||||
: '/images/default-avatar.png';
|
||||
|
||||
const allRoadmapsAndBestPractices = [
|
||||
...builtInRoleRoadmaps,
|
||||
...builtInSkillRoadmaps,
|
||||
...builtInBestPractices,
|
||||
];
|
||||
|
||||
const relatedRoadmapIds = allRoadmapsAndBestPractices
|
||||
// take the ones that user is learning
|
||||
.filter((roadmap) =>
|
||||
learningRoadmapsToShow?.some(
|
||||
(learningRoadmap) => learningRoadmap.resourceId === roadmap.id,
|
||||
),
|
||||
)
|
||||
.flatMap((roadmap) => roadmap.relatedRoadmapIds)
|
||||
// remove the ones that user is already learning or has bookmarked
|
||||
.filter(
|
||||
(roadmapId) =>
|
||||
!learningRoadmapsToShow.some((lr) => lr.resourceId === roadmapId),
|
||||
);
|
||||
|
||||
const recommendedRoadmapIds = new Set(
|
||||
relatedRoadmapIds.length === 0
|
||||
? [
|
||||
'frontend',
|
||||
'backend',
|
||||
'devops',
|
||||
'ai-data-scientist',
|
||||
'full-stack',
|
||||
'api-design',
|
||||
]
|
||||
: relatedRoadmapIds,
|
||||
);
|
||||
|
||||
const recommendedRoadmaps = allRoadmapsAndBestPractices.filter((roadmap) =>
|
||||
recommendedRoadmapIds.has(roadmap.id),
|
||||
);
|
||||
|
||||
const enrichedProjects = personalDashboardDetails?.projects
|
||||
.map((project) => {
|
||||
const projectDetail = projectDetails.find(
|
||||
@@ -232,165 +367,200 @@ export function PersonalDashboard(props: PersonalDashboardProps) {
|
||||
const { username } = personalDashboardDetails || {};
|
||||
|
||||
return (
|
||||
<section>
|
||||
{isLoading ? (
|
||||
<div className="h-7 w-1/4 animate-pulse rounded-lg bg-gray-200"></div>
|
||||
) : (
|
||||
<div className="flex flex-col items-start justify-between gap-1 sm:flex-row sm:items-center">
|
||||
<h2 className="text-lg font-medium">
|
||||
Hi {name}, good {getCurrentPeriod()}!
|
||||
</h2>
|
||||
<a
|
||||
href="/home"
|
||||
className="rounded-full bg-gray-200 px-2.5 py-1 text-xs font-medium text-gray-700 hover:bg-gray-300 hover:text-black"
|
||||
>
|
||||
Visit Homepage
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-4">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<DashboardCardSkeleton />
|
||||
<DashboardCardSkeleton />
|
||||
<DashboardCardSkeleton />
|
||||
<DashboardCardSkeleton />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DashboardCard
|
||||
imgUrl={avatarLink}
|
||||
title={name!}
|
||||
description={
|
||||
username ? 'View your profile' : 'Setup your profile'
|
||||
}
|
||||
href={username ? `/u/${username}` : '/account/update-profile'}
|
||||
{...(username && {
|
||||
externalLinkIcon: PencilIcon,
|
||||
externalLinkHref: '/account/update-profile',
|
||||
externalLinkText: 'Edit',
|
||||
})}
|
||||
className={
|
||||
!username
|
||||
? 'border-dashed border-gray-500 bg-gray-100 hover:border-gray-500 hover:bg-gray-200'
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
|
||||
<DashboardCard
|
||||
icon={BookEmoji}
|
||||
title="Visit Roadmaps"
|
||||
description="Learn new skills"
|
||||
href="/roadmaps"
|
||||
/>
|
||||
|
||||
<DashboardCard
|
||||
icon={ConstructionEmoji}
|
||||
title="Build Projects"
|
||||
description="Practice what you learn"
|
||||
href="/projects"
|
||||
/>
|
||||
<DashboardCard
|
||||
icon={CheckEmoji}
|
||||
title="Best Practices"
|
||||
description="Do things the right way"
|
||||
href="/best-practices"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ProgressStack
|
||||
progresses={learningRoadmapsToShow}
|
||||
projects={enrichedProjects || []}
|
||||
<div>
|
||||
<DashboardStats
|
||||
profile={{
|
||||
name,
|
||||
username,
|
||||
avatar: avatarLink,
|
||||
isLoading,
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
accountStreak={accountStreak}
|
||||
topicDoneToday={personalDashboardDetails?.topicDoneToday || 0}
|
||||
topicsDoneToday={personalDashboardDetails?.topicDoneToday}
|
||||
finishedProjectsCount={
|
||||
enrichedProjects?.filter((p) => p.submittedAt && p.repositoryUrl)
|
||||
.length
|
||||
}
|
||||
/>
|
||||
|
||||
<ListDashboardCustomProgress
|
||||
progresses={customRoadmaps}
|
||||
<FavoriteRoadmaps
|
||||
progress={learningRoadmapsToShow}
|
||||
customRoadmaps={customRoadmaps}
|
||||
aiRoadmaps={aiGeneratedRoadmaps}
|
||||
projects={enrichedProjects || []}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
<DashboardAiRoadmaps
|
||||
roadmaps={aiGeneratedRoadmaps}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<div className="bg-gradient-to-b from-slate-900 to-black pb-12">
|
||||
<div className="relative mt-6 border-t border-t-[#1e293c] pt-12">
|
||||
<div className="container">
|
||||
<h2
|
||||
id="role-based-roadmaps"
|
||||
className="text-md font-regular absolute -top-[17px] left-4 flex rounded-lg border border-[#1e293c] bg-slate-900 px-3 py-1 text-slate-400 sm:left-1/2 sm:-translate-x-1/2"
|
||||
>
|
||||
Role Based Roadmaps
|
||||
</h2>
|
||||
|
||||
<RecommendedRoadmaps
|
||||
roadmaps={recommendedRoadmaps}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
<div className="grid grid-cols-1 gap-3 px-2 sm:grid-cols-2 sm:px-0 lg:grid-cols-3">
|
||||
{builtInRoleRoadmaps.map((roadmap) => {
|
||||
const roadmapProgress = learningRoadmapsToShow.find(
|
||||
(lr) => lr.resourceId === roadmap.id,
|
||||
);
|
||||
|
||||
type DashboardCardProps = {
|
||||
icon?: JSXElementConstructor<any>;
|
||||
imgUrl?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
externalLinkIcon?: LucideIcon;
|
||||
externalLinkText?: string;
|
||||
externalLinkHref?: string;
|
||||
className?: string;
|
||||
};
|
||||
const percentageDone =
|
||||
(((roadmapProgress?.skipped || 0) +
|
||||
(roadmapProgress?.done || 0)) /
|
||||
(roadmapProgress?.total || 1)) *
|
||||
100;
|
||||
|
||||
function DashboardCard(props: DashboardCardProps) {
|
||||
const {
|
||||
icon: Icon,
|
||||
imgUrl,
|
||||
title,
|
||||
description,
|
||||
href,
|
||||
externalLinkHref,
|
||||
externalLinkIcon: ExternalLinkIcon,
|
||||
externalLinkText,
|
||||
className,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className={cn('relative overflow-hidden', className)}>
|
||||
<a
|
||||
href={href}
|
||||
className="flex flex-col rounded-lg border border-gray-300 bg-white hover:border-gray-400 hover:bg-gray-50"
|
||||
>
|
||||
{Icon && (
|
||||
<div className="px-4 pb-3 pt-4">
|
||||
<Icon className="size-6" />
|
||||
return (
|
||||
<HeroRoadmap
|
||||
key={roadmap.id}
|
||||
resourceId={roadmap.id}
|
||||
resourceType="roadmap"
|
||||
resourceTitle={roadmap.title}
|
||||
isFavorite={roadmap.isFavorite}
|
||||
percentageDone={percentageDone}
|
||||
isNew={roadmap.isNew}
|
||||
url={`/${roadmap.id}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imgUrl && (
|
||||
<div className="px-4 pb-1.5 pt-3.5">
|
||||
<img src={imgUrl} alt={title} className="size-8 rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex grow flex-col justify-center gap-0.5 p-4">
|
||||
<h3 className="truncate font-medium text-black">{title}</h3>
|
||||
<p className="text-xs text-black">{description}</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{externalLinkHref && (
|
||||
<a
|
||||
href={externalLinkHref}
|
||||
className="absolute right-1 top-1 flex items-center gap-1.5 rounded-md bg-gray-200 p-1 px-2 text-xs text-gray-600 hover:bg-gray-300 hover:text-black"
|
||||
>
|
||||
{ExternalLinkIcon && <ExternalLinkIcon className="size-3" />}
|
||||
{externalLinkText}
|
||||
</a>
|
||||
)}
|
||||
<div className="relative mt-12 border-t border-t-[#1e293c] pt-12">
|
||||
<div className="container">
|
||||
<h2 className="text-md font-regular absolute -top-[17px] left-4 flex rounded-lg border border-[#1e293c] bg-slate-900 px-3 py-1 text-slate-400 sm:left-1/2 sm:-translate-x-1/2">
|
||||
Skill Based Roadmaps
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 px-2 sm:grid-cols-2 sm:px-0 lg:grid-cols-3">
|
||||
{builtInSkillRoadmaps.map((roadmap) => {
|
||||
const roadmapProgress = learningRoadmapsToShow.find(
|
||||
(lr) => lr.resourceId === roadmap.id,
|
||||
);
|
||||
|
||||
const percentageDone =
|
||||
(((roadmapProgress?.skipped || 0) +
|
||||
(roadmapProgress?.done || 0)) /
|
||||
(roadmapProgress?.total || 1)) *
|
||||
100;
|
||||
|
||||
return (
|
||||
<HeroRoadmap
|
||||
key={roadmap.id}
|
||||
resourceId={roadmap.id}
|
||||
resourceType="roadmap"
|
||||
resourceTitle={roadmap.title}
|
||||
isFavorite={roadmap.isFavorite}
|
||||
percentageDone={percentageDone}
|
||||
isNew={roadmap.isNew}
|
||||
url={`/${roadmap.id}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-12 border-t border-t-[#1e293c] pt-12">
|
||||
<div className="container">
|
||||
<h2 className="text-md font-regular absolute -top-[17px] left-4 flex rounded-lg border border-[#1e293c] bg-slate-900 px-3 py-1 text-slate-400 sm:left-1/2 sm:-translate-x-1/2">
|
||||
Project Ideas
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 px-2 sm:grid-cols-2 sm:px-0 lg:grid-cols-3">
|
||||
{projectGroups.map((projectGroup) => {
|
||||
return (
|
||||
<HeroRoadmap
|
||||
percentageDone={0}
|
||||
key={projectGroup.id}
|
||||
resourceId={projectGroup.id}
|
||||
resourceType="roadmap"
|
||||
resourceTitle={projectGroup.title}
|
||||
url={`/${projectGroup.id}/projects`}
|
||||
allowFavorite={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-12 border-t border-t-[#1e293c] pt-12">
|
||||
<div className="container">
|
||||
<h2 className="text-md font-regular absolute -top-[17px] left-4 flex rounded-lg border border-[#1e293c] bg-slate-900 px-3 py-1 text-slate-400 sm:left-1/2 sm:-translate-x-1/2">
|
||||
Best Practices
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 px-2 sm:grid-cols-2 sm:px-0 lg:grid-cols-3">
|
||||
{builtInBestPractices.map((roadmap) => {
|
||||
const roadmapProgress = learningRoadmapsToShow.find(
|
||||
(lr) => lr.resourceId === roadmap.id,
|
||||
);
|
||||
|
||||
const percentageDone =
|
||||
(((roadmapProgress?.skipped || 0) +
|
||||
(roadmapProgress?.done || 0)) /
|
||||
(roadmapProgress?.total || 1)) *
|
||||
100;
|
||||
|
||||
return (
|
||||
<HeroRoadmap
|
||||
key={roadmap.id}
|
||||
resourceId={roadmap.id}
|
||||
resourceType="best-practice"
|
||||
resourceTitle={roadmap.title}
|
||||
isFavorite={roadmap.isFavorite}
|
||||
percentageDone={percentageDone}
|
||||
isNew={roadmap.isNew}
|
||||
url={`/best-practices/${roadmap.id}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-12 border-t border-t-[#1e293c] pt-12">
|
||||
<div className="container">
|
||||
<h2 className="text-md font-regular absolute -top-[17px] left-4 flex rounded-lg border border-[#1e293c] bg-slate-900 px-3 py-1 text-slate-400 sm:left-1/2 sm:-translate-x-1/2">
|
||||
Questions
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 px-2 sm:grid-cols-2 sm:px-0 lg:grid-cols-3">
|
||||
{questionGroups.map((questionGroup) => {
|
||||
return (
|
||||
<HeroRoadmap
|
||||
percentageDone={0}
|
||||
key={questionGroup.id}
|
||||
resourceId={questionGroup.id}
|
||||
resourceType="roadmap"
|
||||
resourceTitle={questionGroup.frontmatter.briefTitle}
|
||||
url={`/questions/${questionGroup.id}`}
|
||||
allowFavorite={false}
|
||||
isNew={questionGroup.frontmatter.isNew}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 bg-gray-50 px-4 py-5 sm:gap-16 sm:px-0 sm:py-16">
|
||||
<FeaturedGuideList
|
||||
heading="Guides"
|
||||
guides={guides}
|
||||
questions={questionGroups
|
||||
.filter((questionGroup) => questionGroup.frontmatter.authorId)
|
||||
.slice(0, 7)}
|
||||
/>
|
||||
<FeaturedVideoList heading="Videos" videos={videos} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardCardSkeleton() {
|
||||
return (
|
||||
<div className="h-[128px] animate-pulse rounded-lg border border-gray-300 bg-white"></div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
---
|
||||
import type { GuideFileType } from '../lib/guide';
|
||||
import GuideListItem from './GuideListItem.astro';
|
||||
import type { QuestionGroupType } from '../lib/question-group';
|
||||
|
||||
export interface Props {
|
||||
heading: string;
|
||||
guides: GuideFileType[];
|
||||
questions: QuestionGroupType[];
|
||||
}
|
||||
|
||||
const { heading, guides, questions = [] } = Astro.props;
|
||||
|
||||
const sortedGuides: (QuestionGroupType | GuideFileType)[] = [
|
||||
...guides,
|
||||
...questions,
|
||||
].sort((a, b) => {
|
||||
const aDate = new Date(a.frontmatter.date as string);
|
||||
const bDate = new Date(b.frontmatter.date as string);
|
||||
|
||||
return bDate.getTime() - aDate.getTime();
|
||||
});
|
||||
---
|
||||
|
||||
<div class='container'>
|
||||
<h2 class='block text-2xl font-bold sm:text-3xl'>{heading}</h2>
|
||||
|
||||
<div class='mt-3 sm:my-5'>
|
||||
{sortedGuides.map((guide) => <GuideListItem guide={guide} />)}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href='/guides'
|
||||
class='hidden rounded-full bg-gradient-to-r from-slate-600 to-black px-3 py-2 text-xs font-medium text-white transition-colors hover:from-blue-600 hover:to-blue-800 sm:inline'
|
||||
>
|
||||
View All Guides →
|
||||
</a>
|
||||
|
||||
<div class='mt-3 block sm:hidden'>
|
||||
<a
|
||||
href='/guides'
|
||||
class='font-regular block rounded-md border border-black p-2 text-center text-sm text-black hover:bg-black hover:text-gray-50'
|
||||
>
|
||||
View All Guides →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
51
src/components/FeaturedGuides/FeaturedGuideList.tsx
Normal file
51
src/components/FeaturedGuides/FeaturedGuideList.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { GuideFileType } from '../../lib/guide';
|
||||
import type { QuestionGroupType } from '../../lib/question-group';
|
||||
import { GuideListItem } from './GuideListItem';
|
||||
|
||||
export interface FeaturedGuidesProps {
|
||||
heading: string;
|
||||
guides: GuideFileType[];
|
||||
questions: QuestionGroupType[];
|
||||
}
|
||||
|
||||
export function FeaturedGuideList(props: FeaturedGuidesProps) {
|
||||
const { heading, guides, questions = [] } = props;
|
||||
|
||||
const sortedGuides: (QuestionGroupType | GuideFileType)[] = [
|
||||
...guides,
|
||||
...questions,
|
||||
].sort((a, b) => {
|
||||
const aDate = new Date(a.frontmatter.date as string);
|
||||
const bDate = new Date(b.frontmatter.date as string);
|
||||
|
||||
return bDate.getTime() - aDate.getTime();
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<h2 className="block text-2xl font-bold sm:text-3xl">{heading}</h2>
|
||||
|
||||
<div className="mt-3 sm:my-5">
|
||||
{sortedGuides.map((guide) => (
|
||||
<GuideListItem key={guide.id} guide={guide} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="/guides"
|
||||
className="hidden rounded-full bg-gradient-to-r from-slate-600 to-black px-3 py-2 text-xs font-medium text-white transition-colors hover:from-blue-600 hover:to-blue-800 sm:inline"
|
||||
>
|
||||
View All Guides →
|
||||
</a>
|
||||
|
||||
<div className="mt-3 block sm:hidden">
|
||||
<a
|
||||
href="/guides"
|
||||
className="font-regular block rounded-md border border-black p-2 text-center text-sm text-black hover:bg-black hover:text-gray-50"
|
||||
>
|
||||
View All Guides →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
src/components/FeaturedGuides/GuideListItem.tsx
Normal file
57
src/components/FeaturedGuides/GuideListItem.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { GuideFileType, GuideFrontmatter } from '../../lib/guide';
|
||||
import { type QuestionGroupType } from '../../lib/question-group';
|
||||
|
||||
export interface GuideListItemProps {
|
||||
guide: GuideFileType | QuestionGroupType;
|
||||
}
|
||||
|
||||
function isQuestionGroupType(
|
||||
guide: GuideFileType | QuestionGroupType,
|
||||
): guide is QuestionGroupType {
|
||||
return (guide as QuestionGroupType).questions !== undefined;
|
||||
}
|
||||
|
||||
export function GuideListItem(props: GuideListItemProps) {
|
||||
const { guide } = props;
|
||||
const { frontmatter, id } = guide;
|
||||
|
||||
let pageUrl = '';
|
||||
let guideType = '';
|
||||
|
||||
if (isQuestionGroupType(guide)) {
|
||||
pageUrl = `/questions/${id}`;
|
||||
guideType = 'Questions';
|
||||
} else {
|
||||
const excludedBySlug = (frontmatter as GuideFrontmatter).excludedBySlug;
|
||||
pageUrl = excludedBySlug ? excludedBySlug : `/guides/${id}`;
|
||||
guideType = (frontmatter as GuideFrontmatter).type;
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
className="text-md group block flex items-center justify-between border-b py-2 text-gray-600 no-underline hover:text-blue-600"
|
||||
href={pageUrl}
|
||||
>
|
||||
<span className="text-sm transition-transform group-hover:translate-x-2 md:text-base">
|
||||
{frontmatter.title}
|
||||
|
||||
{frontmatter.isNew && (
|
||||
<span className="ml-2.5 rounded-sm bg-green-300 px-1.5 py-0.5 text-xs font-medium uppercase text-green-900">
|
||||
New
|
||||
<span className="hidden sm:inline">
|
||||
·
|
||||
{new Date(frontmatter.date || '').toLocaleString('default', {
|
||||
month: 'long',
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="hidden text-xs capitalize text-gray-500 sm:block">
|
||||
{guideType}
|
||||
</span>
|
||||
|
||||
<span className="block text-xs text-gray-400 sm:hidden"> »</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
import type { VideoFileType } from '../lib/video';
|
||||
import VideoListItem from './VideoListItem.astro';
|
||||
|
||||
export interface Props {
|
||||
heading: string;
|
||||
videos: VideoFileType[];
|
||||
}
|
||||
|
||||
const { heading, videos } = Astro.props;
|
||||
---
|
||||
|
||||
<div class='container'>
|
||||
<h2 class='text-2xl sm:text-3xl font-bold block'>{heading}</h2>
|
||||
|
||||
<div class='mt-3 sm:my-5'>
|
||||
{videos.map((video) => <VideoListItem video={video} />)}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href='/videos'
|
||||
class='hidden sm:inline transition-colors py-2 px-3 text-xs font-medium rounded-full bg-gradient-to-r from-slate-600 to-black hover:from-blue-600 hover:to-blue-800 text-white'
|
||||
>
|
||||
View All Videos →
|
||||
</a>
|
||||
|
||||
<div class='block sm:hidden mt-3'>
|
||||
<a
|
||||
href='/videos'
|
||||
class='text-sm font-regular block p-2 border border-black text-black rounded-md text-center hover:bg-black hover:text-gray-50'
|
||||
>
|
||||
View All Videos →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
39
src/components/FeaturedVideos/FeaturedVideoList.tsx
Normal file
39
src/components/FeaturedVideos/FeaturedVideoList.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { VideoFileType } from '../../lib/video';
|
||||
import { VideoListItem } from './VideoListItem';
|
||||
|
||||
export interface FeaturedVideoListProps {
|
||||
heading: string;
|
||||
videos: VideoFileType[];
|
||||
}
|
||||
|
||||
export function FeaturedVideoList(props: FeaturedVideoListProps) {
|
||||
const { heading, videos } = props;
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<h2 className="block text-2xl font-bold sm:text-3xl">{heading}</h2>
|
||||
|
||||
<div className="mt-3 sm:my-5">
|
||||
{videos.map((video) => (
|
||||
<VideoListItem key={video.id} video={video} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="/videos"
|
||||
className="hidden rounded-full bg-gradient-to-r from-slate-600 to-black px-3 py-2 text-xs font-medium text-white transition-colors hover:from-blue-600 hover:to-blue-800 sm:inline"
|
||||
>
|
||||
View All Videos →
|
||||
</a>
|
||||
|
||||
<div className="mt-3 block sm:hidden">
|
||||
<a
|
||||
href="/videos"
|
||||
className="font-regular block rounded-md border border-black p-2 text-center text-sm text-black hover:bg-black hover:text-gray-50"
|
||||
>
|
||||
View All Videos →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/components/FeaturedVideos/VideoListItem.tsx
Normal file
38
src/components/FeaturedVideos/VideoListItem.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { VideoFileType } from '../../lib/video';
|
||||
|
||||
export interface VideoListItemProps {
|
||||
video: VideoFileType;
|
||||
}
|
||||
|
||||
export function VideoListItem(props: VideoListItemProps) {
|
||||
const { video } = props;
|
||||
const { frontmatter, id } = video;
|
||||
|
||||
return (
|
||||
<a
|
||||
className="block no-underline py-2 group text-md items-center text-gray-600 hover:text-blue-600 flex justify-between border-b"
|
||||
href={`/videos/${id}`}
|
||||
>
|
||||
<span className="group-hover:translate-x-2 transition-transform">
|
||||
{frontmatter.title}
|
||||
|
||||
{frontmatter.isNew && (
|
||||
<span className="bg-green-300 text-green-900 text-xs font-medium px-1.5 py-0.5 rounded-sm uppercase ml-1.5">
|
||||
New
|
||||
<span className="hidden sm:inline">
|
||||
·
|
||||
{new Date(frontmatter.date).toLocaleString('default', {
|
||||
month: 'long',
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="capitalize text-gray-500 text-xs hidden sm:block">
|
||||
{frontmatter.duration}
|
||||
</span>
|
||||
|
||||
<span className="text-gray-400 text-xs block sm:hidden"> »</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
172
src/components/GenerateCourse/AICourse.tsx
Normal file
172
src/components/GenerateCourse/AICourse.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { SearchIcon, WandIcon } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { UserCoursesList } from './UserCoursesList';
|
||||
import { FineTuneCourse } from './FineTuneCourse';
|
||||
import {
|
||||
clearFineTuneData,
|
||||
getCourseFineTuneData,
|
||||
getLastSessionId,
|
||||
storeFineTuneData,
|
||||
} from '../../lib/ai';
|
||||
|
||||
export const difficultyLevels = [
|
||||
'beginner',
|
||||
'intermediate',
|
||||
'advanced',
|
||||
] as const;
|
||||
export type DifficultyLevel = (typeof difficultyLevels)[number];
|
||||
|
||||
type AICourseProps = {};
|
||||
|
||||
export function AICourse(props: AICourseProps) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [difficulty, setDifficulty] = useState<DifficultyLevel>('beginner');
|
||||
|
||||
const [hasFineTuneData, setHasFineTuneData] = useState(false);
|
||||
const [about, setAbout] = useState('');
|
||||
const [goal, setGoal] = useState('');
|
||||
const [customInstructions, setCustomInstructions] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const lastSessionId = getLastSessionId();
|
||||
if (!lastSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fineTuneData = getCourseFineTuneData(lastSessionId);
|
||||
if (!fineTuneData) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAbout(fineTuneData.about);
|
||||
setGoal(fineTuneData.goal);
|
||||
setCustomInstructions(fineTuneData.customInstructions);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && keyword.trim()) {
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
function onSubmit() {
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
let sessionId = '';
|
||||
if (hasFineTuneData) {
|
||||
clearFineTuneData();
|
||||
sessionId = storeFineTuneData({
|
||||
about,
|
||||
goal,
|
||||
customInstructions,
|
||||
});
|
||||
}
|
||||
|
||||
window.location.href = `/ai-tutor/search?term=${encodeURIComponent(keyword)}&difficulty=${difficulty}&id=${sessionId}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex flex-grow flex-col bg-gray-100">
|
||||
<div className="container mx-auto flex max-w-3xl flex-col py-24 max-sm:py-4">
|
||||
<h1 className="mb-2.5 text-center text-4xl font-bold max-sm:mb-2 max-sm:text-left max-sm:text-xl">
|
||||
Learn anything with AI
|
||||
</h1>
|
||||
<p className="mb-6 text-center text-lg text-gray-600 max-sm:hidden max-sm:text-left max-sm:text-sm">
|
||||
Enter a topic below to generate a personalized course for it
|
||||
</p>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6 max-sm:p-4">
|
||||
<form
|
||||
className="flex flex-col gap-5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<label
|
||||
htmlFor="keyword"
|
||||
className="mb-2.5 text-sm font-medium text-gray-700"
|
||||
>
|
||||
Course Topic
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
<SearchIcon size={18} />
|
||||
</div>
|
||||
<input
|
||||
id="keyword"
|
||||
type="text"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="e.g., Algebra, JavaScript, Photography"
|
||||
className="w-full rounded-md border border-gray-300 bg-white p-3 pl-10 text-gray-900 focus:outline-none focus:ring-1 focus:ring-gray-500 max-sm:placeholder:text-base"
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label className="mb-2.5 text-sm font-medium text-gray-700">
|
||||
Difficulty Level
|
||||
</label>
|
||||
<div className="flex gap-2 max-sm:flex-col max-sm:gap-1">
|
||||
{difficultyLevels.map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
type="button"
|
||||
onClick={() => setDifficulty(level)}
|
||||
className={cn(
|
||||
'rounded-md border px-4 py-2 capitalize max-sm:text-sm',
|
||||
difficulty === level
|
||||
? 'border-gray-800 bg-gray-800 text-white'
|
||||
: 'border-gray-200 bg-gray-100 text-gray-700 hover:bg-gray-200',
|
||||
)}
|
||||
>
|
||||
{level}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FineTuneCourse
|
||||
hasFineTuneData={hasFineTuneData}
|
||||
setHasFineTuneData={setHasFineTuneData}
|
||||
about={about}
|
||||
goal={goal}
|
||||
customInstructions={customInstructions}
|
||||
setAbout={setAbout}
|
||||
setGoal={setGoal}
|
||||
setCustomInstructions={setCustomInstructions}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!keyword.trim()}
|
||||
className={cn(
|
||||
'mt-2 flex items-center justify-center rounded-md px-4 py-2 font-medium text-white transition-colors max-sm:text-sm',
|
||||
!keyword.trim()
|
||||
? 'cursor-not-allowed bg-gray-400'
|
||||
: 'bg-black hover:bg-gray-800',
|
||||
)}
|
||||
>
|
||||
<WandIcon size={18} className="mr-2" />
|
||||
Generate Course
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 min-h-[200px]">
|
||||
<UserCoursesList />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
116
src/components/GenerateCourse/AICourseActions.tsx
Normal file
116
src/components/GenerateCourse/AICourseActions.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { MoreVertical, Play, Trash2 } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { httpDelete } from '../../lib/query-http';
|
||||
|
||||
type AICourseActionsType = {
|
||||
courseSlug: string;
|
||||
onDeleted?: () => void;
|
||||
};
|
||||
|
||||
export function AICourseActions(props: AICourseActionsType) {
|
||||
const { courseSlug, onDeleted } = props;
|
||||
|
||||
const toast = useToast();
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
|
||||
const { mutate: deleteCourse, isPending: isDeleting } = useMutation(
|
||||
{
|
||||
mutationFn: async () => {
|
||||
return httpDelete(`/v1-delete-ai-course/${courseSlug}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Course deleted');
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => query.queryKey?.[0] === 'user-ai-courses',
|
||||
});
|
||||
onDeleted?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error?.message || 'Failed to delete course');
|
||||
},
|
||||
},
|
||||
queryClient,
|
||||
);
|
||||
|
||||
useOutsideClick(dropdownRef, () => {
|
||||
setIsOpen(false);
|
||||
});
|
||||
|
||||
useKeydown('Escape', () => {
|
||||
setIsOpen(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative h-full" ref={dropdownRef}>
|
||||
<button
|
||||
className="h-full text-gray-400 hover:text-gray-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 top-8 z-10 w-48 overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg">
|
||||
<a
|
||||
href={`/ai-tutor/${courseSlug}`}
|
||||
className="flex w-full items-center gap-1.5 p-2 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-black disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
Start Course
|
||||
</a>
|
||||
{!isConfirming && (
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 p-2 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-black disabled:cursor-not-allowed disabled:opacity-70"
|
||||
onClick={() => setIsConfirming(true)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{!isDeleting ? (
|
||||
<>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete Course
|
||||
</>
|
||||
) : (
|
||||
'Deleting...'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isConfirming && (
|
||||
<span className="flex w-full items-center justify-between gap-1.5 p-2 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-black disabled:cursor-not-allowed disabled:opacity-70">
|
||||
Are you sure?
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsConfirming(false);
|
||||
deleteCourse();
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
className="text-red-500 underline hover:text-red-800"
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsConfirming(false)}
|
||||
className="text-red-500 underline hover:text-red-800"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
src/components/GenerateCourse/AICourseCard.tsx
Normal file
82
src/components/GenerateCourse/AICourseCard.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { AICourseWithLessonCount } from '../../queries/ai-course';
|
||||
import type { DifficultyLevel } from './AICourse';
|
||||
import { BookOpen } from 'lucide-react';
|
||||
import { AICourseActions } from './AICourseActions';
|
||||
|
||||
type AICourseCardProps = {
|
||||
course: AICourseWithLessonCount;
|
||||
};
|
||||
|
||||
export function AICourseCard(props: AICourseCardProps) {
|
||||
const { course } = props;
|
||||
|
||||
// Format date if available
|
||||
const formattedDate = course.createdAt
|
||||
? new Date(course.createdAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
: null;
|
||||
|
||||
// Map difficulty to color
|
||||
const difficultyColor =
|
||||
{
|
||||
beginner: 'text-green-700',
|
||||
intermediate: 'text-blue-700',
|
||||
advanced: 'text-purple-700',
|
||||
}[course.difficulty as DifficultyLevel] || 'text-gray-700';
|
||||
|
||||
// Calculate progress percentage
|
||||
const totalTopics = course.lessonCount || 0;
|
||||
const completedTopics = course.done?.length || 0;
|
||||
const progressPercentage =
|
||||
totalTopics > 0 ? Math.round((completedTopics / totalTopics) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<a
|
||||
href={`/ai-tutor/${course.slug}`}
|
||||
className="hover:border-gray-3 00 group relative flex w-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white p-4 text-left transition-all hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={`rounded-full text-xs font-medium capitalize opacity-80 ${difficultyColor}`}
|
||||
>
|
||||
{course.difficulty}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="my-2 text-base font-semibold text-gray-900">
|
||||
{course.title}
|
||||
</h3>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between pt-2">
|
||||
<div className="flex items-center text-xs text-gray-600">
|
||||
<BookOpen className="mr-1 h-3.5 w-3.5" />
|
||||
<span>{totalTopics} lessons</span>
|
||||
</div>
|
||||
|
||||
{totalTopics > 0 && (
|
||||
<div className="flex items-center">
|
||||
<div className="mr-2 h-1.5 w-16 overflow-hidden rounded-full bg-gray-200">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-600"
|
||||
style={{ width: `${progressPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-gray-700">
|
||||
{progressPercentage}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{course.slug && (
|
||||
<div className="absolute right-2 top-2">
|
||||
<AICourseActions courseSlug={course.slug} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
533
src/components/GenerateCourse/AICourseContent.tsx
Normal file
533
src/components/GenerateCourse/AICourseContent.tsx
Normal file
@@ -0,0 +1,533 @@
|
||||
import {
|
||||
BookOpenCheck,
|
||||
ChevronLeft,
|
||||
CircleAlert,
|
||||
CircleOff,
|
||||
Loader2,
|
||||
Menu,
|
||||
Play,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { type AiCourse } from '../../lib/ai';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { slugify } from '../../lib/slugger';
|
||||
import { useIsPaidUser } from '../../queries/billing';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
import { CheckIcon } from '../ReactIcons/CheckIcon';
|
||||
import { ErrorIcon } from '../ReactIcons/ErrorIcon';
|
||||
import { AICourseLesson } from './AICourseLesson';
|
||||
import { AICourseLimit } from './AICourseLimit';
|
||||
import { AICourseSidebarModuleList } from './AICourseSidebarModuleList';
|
||||
import { AILimitsPopup } from './AILimitsPopup';
|
||||
import { RegenerateOutline } from './RegenerateOutline';
|
||||
|
||||
type AICourseContentProps = {
|
||||
courseSlug?: string;
|
||||
course: AiCourse;
|
||||
isLoading: boolean;
|
||||
error?: string;
|
||||
onRegenerateOutline: (prompt?: string) => void;
|
||||
};
|
||||
|
||||
export function AICourseContent(props: AICourseContentProps) {
|
||||
const { course, courseSlug, isLoading, error, onRegenerateOutline } = props;
|
||||
|
||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
||||
const [showAILimitsPopup, setShowAILimitsPopup] = useState(false);
|
||||
|
||||
const [activeModuleIndex, setActiveModuleIndex] = useState(0);
|
||||
const [activeLessonIndex, setActiveLessonIndex] = useState(0);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<'module' | 'outline'>('outline');
|
||||
|
||||
const { isPaidUser } = useIsPaidUser();
|
||||
|
||||
const aiCourseProgress = course.done || [];
|
||||
|
||||
const [expandedModules, setExpandedModules] = useState<
|
||||
Record<number, boolean>
|
||||
>({});
|
||||
|
||||
const goToNextModule = () => {
|
||||
if (activeModuleIndex >= course.modules.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextModuleIndex = activeModuleIndex + 1;
|
||||
setActiveModuleIndex(nextModuleIndex);
|
||||
setActiveLessonIndex(0);
|
||||
|
||||
setExpandedModules((prev) => {
|
||||
const newState: Record<number, boolean> = {};
|
||||
course.modules.forEach((_, idx) => {
|
||||
newState[idx] = false;
|
||||
});
|
||||
|
||||
newState[nextModuleIndex] = true;
|
||||
return newState;
|
||||
});
|
||||
};
|
||||
|
||||
const goToNextLesson = () => {
|
||||
const currentModule = course.modules[activeModuleIndex];
|
||||
if (currentModule && activeLessonIndex < currentModule.lessons.length - 1) {
|
||||
setActiveLessonIndex(activeLessonIndex + 1);
|
||||
} else {
|
||||
goToNextModule();
|
||||
}
|
||||
};
|
||||
|
||||
const goToPrevLesson = () => {
|
||||
if (activeLessonIndex > 0) {
|
||||
setActiveLessonIndex(activeLessonIndex - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const prevModule = course.modules[activeModuleIndex - 1];
|
||||
if (!prevModule) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prevModuleIndex = activeModuleIndex - 1;
|
||||
setActiveModuleIndex(prevModuleIndex);
|
||||
setActiveLessonIndex(prevModule.lessons.length - 1);
|
||||
|
||||
// Expand the previous module in the sidebar
|
||||
setExpandedModules((prev) => {
|
||||
const newState: Record<number, boolean> = {};
|
||||
// Set all modules to collapsed
|
||||
course.modules.forEach((_, idx) => {
|
||||
newState[idx] = false;
|
||||
});
|
||||
// Expand only the previous module
|
||||
newState[prevModuleIndex] = true;
|
||||
return newState;
|
||||
});
|
||||
};
|
||||
|
||||
const currentModule = course.modules[activeModuleIndex];
|
||||
const currentLesson = currentModule?.lessons[activeLessonIndex];
|
||||
const totalModules = course.modules.length;
|
||||
const totalLessons = currentModule?.lessons.length || 0;
|
||||
|
||||
const totalCourseLessons = course.modules.reduce(
|
||||
(total, module) => total + module.lessons.length,
|
||||
0,
|
||||
);
|
||||
|
||||
const totalDoneLessons = (course?.done || []).length;
|
||||
const finishedPercentage = Math.round(
|
||||
(totalDoneLessons / totalCourseLessons) * 100,
|
||||
);
|
||||
|
||||
const modals = (
|
||||
<>
|
||||
{showUpgradeModal && (
|
||||
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
|
||||
)}
|
||||
|
||||
{showAILimitsPopup && (
|
||||
<AILimitsPopup
|
||||
onClose={() => setShowAILimitsPopup(false)}
|
||||
onUpgrade={() => {
|
||||
setShowAILimitsPopup(false);
|
||||
setShowUpgradeModal(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (error && !isLoading) {
|
||||
const isLimitReached = error.includes('limit');
|
||||
const isNotFound = error.includes('not exist');
|
||||
|
||||
let icon = <ErrorIcon additionalClasses="mb-4 size-16" />;
|
||||
let title = 'Error occurred';
|
||||
let message = error;
|
||||
|
||||
if (isLimitReached) {
|
||||
icon = <CircleAlert className="mb-4 size-16 text-yellow-500" />;
|
||||
title = 'Limit Reached';
|
||||
message =
|
||||
'You have reached the daily AI usage limit. Please upgrade your account to continue.';
|
||||
} else if (isNotFound) {
|
||||
icon = <CircleOff className="mb-4 size-16 text-gray-300" />;
|
||||
title = 'Course Not Found';
|
||||
message =
|
||||
'The course you are looking for does not exist. Why not create your own course?';
|
||||
}
|
||||
|
||||
const showUpgradeButton = isLimitReached && !isPaidUser;
|
||||
|
||||
return (
|
||||
<>
|
||||
{modals}
|
||||
<div className="flex h-screen flex-col items-center justify-center px-4 text-center">
|
||||
{icon}
|
||||
<h1 className="mb-2 text-2xl font-bold">{title}</h1>
|
||||
<p className="max-w-sm text-balance text-gray-500">{message}</p>
|
||||
|
||||
{showUpgradeButton && (
|
||||
<div className="my-5">
|
||||
<button
|
||||
onClick={() => setShowUpgradeModal(true)}
|
||||
className="rounded-md bg-yellow-400 px-6 py-2 text-sm font-medium text-black hover:bg-yellow-500"
|
||||
>
|
||||
Upgrade to remove Limits
|
||||
</button>
|
||||
|
||||
<p className="mt-5 text-sm text-black">
|
||||
<a
|
||||
href="/ai-tutor"
|
||||
className="font-medium underline underline-offset-2"
|
||||
>
|
||||
Back to AI Tutor
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isNotFound || !showUpgradeButton) && (
|
||||
<div className="my-5">
|
||||
<a
|
||||
href="/ai-tutor"
|
||||
className="rounded-md bg-black px-6 py-2 text-sm font-medium text-white hover:bg-opacity-80"
|
||||
>
|
||||
Create a course with AI
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const isViewingLesson = viewMode === 'module';
|
||||
|
||||
return (
|
||||
<section className="flex h-screen flex-grow flex-col overflow-hidden bg-gray-50">
|
||||
{modals}
|
||||
|
||||
<div className="border-b border-gray-200 bg-gray-100">
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<a
|
||||
href="/ai-tutor"
|
||||
onClick={(e) => {
|
||||
if (isViewingLesson) {
|
||||
e.preventDefault();
|
||||
setViewMode('outline');
|
||||
}
|
||||
}}
|
||||
className="flex flex-row items-center gap-1.5 text-sm font-medium text-gray-700 hover:text-gray-900"
|
||||
aria-label="Back to generator"
|
||||
>
|
||||
<ChevronLeft className="size-4" strokeWidth={2.5} />
|
||||
Back {isViewingLesson ? 'to Outline' : 'to AI Tutor'}
|
||||
</a>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-row lg:hidden">
|
||||
<AICourseLimit
|
||||
onUpgrade={() => setShowUpgradeModal(true)}
|
||||
onShowLimits={() => setShowAILimitsPopup(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="flex items-center justify-center text-gray-400 shadow-sm transition-colors hover:bg-gray-50 hover:text-gray-900 lg:hidden"
|
||||
>
|
||||
{sidebarOpen ? (
|
||||
<X size={17} strokeWidth={3} />
|
||||
) : (
|
||||
<Menu size={17} strokeWidth={3} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<header className="flex items-center justify-between border-b border-gray-200 bg-white px-6 max-lg:py-4 lg:h-[80px]">
|
||||
<div className="flex items-center">
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-balance text-xl font-bold !leading-tight text-gray-900 max-lg:mb-0.5 max-lg:text-lg">
|
||||
{course.title || 'Loading Course...'}
|
||||
</h1>
|
||||
<div className="mt-1 flex flex-row items-center gap-2 text-sm text-gray-600 max-lg:text-xs">
|
||||
<span className="font-medium">{totalModules} modules</span>
|
||||
<span className="text-gray-400">•</span>
|
||||
<span className="font-medium">{totalCourseLessons} lessons</span>
|
||||
{viewMode === 'module' && (
|
||||
<span className="flex flex-row items-center gap-1 lg:hidden">
|
||||
<span className="text-gray-400">•</span>
|
||||
<button
|
||||
className="underline underline-offset-2"
|
||||
onClick={() => {
|
||||
setExpandedModules({});
|
||||
setViewMode('outline');
|
||||
}}
|
||||
>
|
||||
View outline
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{finishedPercentage > 0 && (
|
||||
<>
|
||||
<span className="text-gray-400">•</span>
|
||||
<span className="font-medium text-green-600">
|
||||
{finishedPercentage}% complete
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="hidden gap-2 lg:flex">
|
||||
<AICourseLimit
|
||||
onUpgrade={() => setShowUpgradeModal(true)}
|
||||
onShowLimits={() => setShowAILimitsPopup(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{viewMode === 'module' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setExpandedModules({});
|
||||
setViewMode('outline');
|
||||
}}
|
||||
className="flex flex-shrink-0 items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 hover:text-gray-900 max-lg:hidden"
|
||||
>
|
||||
<BookOpenCheck size={18} className="mr-2" />
|
||||
View Course Outline
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed inset-y-0 left-0 z-20 w-80 transform overflow-y-auto border-r border-gray-200 bg-white transition-transform duration-200 ease-in-out lg:relative lg:mt-0 lg:translate-x-0',
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex min-h-[40px] items-center justify-between border-b border-gray-200 px-3',
|
||||
isLoading && 'striped-loader bg-gray-50',
|
||||
)}
|
||||
>
|
||||
{!isLoading && (
|
||||
<div className="flex w-full items-center justify-between text-xs text-black">
|
||||
<span>
|
||||
<span className="relative z-10 rounded-full bg-yellow-400 px-1.5 py-0.5">
|
||||
{finishedPercentage}%
|
||||
</span>{' '}
|
||||
<span className="relative z-10">Completed</span>
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
width: `${finishedPercentage}%`,
|
||||
}}
|
||||
className={cn(
|
||||
'absolute bottom-0 left-0 top-0',
|
||||
'bg-gray-200/50',
|
||||
)}
|
||||
></span>
|
||||
|
||||
{viewMode !== 'outline' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setExpandedModules({});
|
||||
setViewMode('outline');
|
||||
}}
|
||||
className="flex items-center gap-1 rounded-md bg-gray-200 px-2.5 py-1.5 text-xs transition-colors hover:bg-gray-300"
|
||||
>
|
||||
<BookOpenCheck size={14} />
|
||||
View Outline
|
||||
</button>
|
||||
)}
|
||||
|
||||
{viewMode === 'outline' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setExpandedModules({
|
||||
...expandedModules,
|
||||
0: true,
|
||||
});
|
||||
setActiveModuleIndex(0);
|
||||
setActiveLessonIndex(0);
|
||||
setViewMode('module');
|
||||
}}
|
||||
className="flex items-center gap-1 rounded-md bg-gray-200 px-2.5 py-1.5 text-xs transition-colors hover:bg-gray-300"
|
||||
>
|
||||
<Play size={14} />
|
||||
Start Course
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="rounded-md p-1 hover:bg-gray-100 lg:hidden"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AICourseSidebarModuleList
|
||||
course={course}
|
||||
courseSlug={courseSlug}
|
||||
activeModuleIndex={
|
||||
viewMode === 'module' ? activeModuleIndex : undefined
|
||||
}
|
||||
setActiveModuleIndex={setActiveModuleIndex}
|
||||
activeLessonIndex={
|
||||
viewMode === 'module' ? activeLessonIndex : undefined
|
||||
}
|
||||
setActiveLessonIndex={setActiveLessonIndex}
|
||||
setSidebarOpen={setSidebarOpen}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
expandedModules={expandedModules}
|
||||
setExpandedModules={setExpandedModules}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<main
|
||||
className={cn(
|
||||
'flex-1 overflow-y-auto p-6 transition-all duration-200 ease-in-out max-lg:p-3',
|
||||
sidebarOpen ? 'lg:ml-0' : '',
|
||||
)}
|
||||
>
|
||||
{viewMode === 'module' && (
|
||||
<AICourseLesson
|
||||
courseSlug={courseSlug!}
|
||||
progress={aiCourseProgress}
|
||||
activeModuleIndex={activeModuleIndex}
|
||||
totalModules={totalModules}
|
||||
currentModuleTitle={currentModule?.title || ''}
|
||||
activeLessonIndex={activeLessonIndex}
|
||||
totalLessons={totalLessons}
|
||||
currentLessonTitle={currentLesson || ''}
|
||||
onGoToPrevLesson={goToPrevLesson}
|
||||
onGoToNextLesson={goToNextLesson}
|
||||
key={`${courseSlug}-${activeModuleIndex}-${activeLessonIndex}`}
|
||||
onUpgrade={() => setShowUpgradeModal(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'outline' && (
|
||||
<div className="mx-auto rounded-xl border border-gray-200 bg-white shadow-sm lg:max-w-3xl">
|
||||
<div
|
||||
className={cn(
|
||||
'relative mb-1 flex items-start justify-between border-b border-gray-100 p-6 max-lg:hidden',
|
||||
isLoading && 'striped-loader',
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<h2 className="mb-1 text-balance text-2xl font-bold max-lg:text-lg max-lg:leading-tight">
|
||||
{course.title || 'Loading course ..'}
|
||||
</h2>
|
||||
<p className="text-sm capitalize text-gray-500">
|
||||
{course.title ? course.difficulty : 'Please wait ..'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isLoading && (
|
||||
<RegenerateOutline
|
||||
onRegenerateOutline={onRegenerateOutline}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{course.title ? (
|
||||
<div className="flex flex-col p-6 max-lg:mt-0.5 max-lg:p-4">
|
||||
{course.modules.map((courseModule, moduleIdx) => {
|
||||
return (
|
||||
<div
|
||||
key={moduleIdx}
|
||||
className="mb-5 pb-4 last:border-0 last:pb-0 max-lg:mb-2"
|
||||
>
|
||||
<h2 className="mb-4 text-xl font-bold text-gray-800 max-lg:mb-2 max-lg:text-lg max-lg:leading-tight">
|
||||
{courseModule.title}
|
||||
</h2>
|
||||
<div className="divide-y divide-gray-100">
|
||||
{courseModule.lessons.map((lesson, lessonIdx) => {
|
||||
const key = `${slugify(String(moduleIdx))}-${slugify(String(lessonIdx))}`;
|
||||
const isCompleted = aiCourseProgress.includes(key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex cursor-pointer items-center gap-2 px-2 py-2.5 transition-colors hover:bg-gray-100 max-lg:px-0 max-lg:py-1.5"
|
||||
onClick={() => {
|
||||
setActiveModuleIndex(moduleIdx);
|
||||
setActiveLessonIndex(lessonIdx);
|
||||
setExpandedModules((prev) => {
|
||||
const newState: Record<number, boolean> =
|
||||
{};
|
||||
course.modules.forEach((_, idx) => {
|
||||
newState[idx] = false;
|
||||
});
|
||||
newState[moduleIdx] = true;
|
||||
return newState;
|
||||
});
|
||||
|
||||
setSidebarOpen(false);
|
||||
setViewMode('module');
|
||||
}}
|
||||
>
|
||||
{!isCompleted && (
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-6 flex-shrink-0 items-center justify-center rounded-full bg-gray-200 text-sm font-medium text-gray-800 max-lg:size-5 max-lg:text-xs',
|
||||
)}
|
||||
>
|
||||
{lessonIdx + 1}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isCompleted && (
|
||||
<CheckIcon additionalClasses="size-6 flex-shrink-0 text-green-500" />
|
||||
)}
|
||||
|
||||
<p className="flex-1 truncate text-base text-gray-800 max-lg:text-sm">
|
||||
{lesson.replace(/^Lesson\s*?\d+[\.:]\s*/, '')}
|
||||
</p>
|
||||
<span className="text-sm font-medium text-gray-700 max-lg:hidden">
|
||||
{isCompleted ? 'View' : 'Start'} →
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 size={36} className="animate-spin text-gray-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-10 mt-5 mx-auto text-center text-sm text-gray-400">
|
||||
AI can make mistakes, check imporant info.
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-10 bg-gray-900 bg-opacity-50 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
></div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
131
src/components/GenerateCourse/AICourseFollowUp.css
Normal file
131
src/components/GenerateCourse/AICourseFollowUp.css
Normal file
@@ -0,0 +1,131 @@
|
||||
.prose ul li > code,
|
||||
.prose ol li > code,
|
||||
p code,
|
||||
a > code,
|
||||
strong > code,
|
||||
em > code,
|
||||
h1 > code,
|
||||
h2 > code,
|
||||
h3 > code {
|
||||
background: #ebebeb !important;
|
||||
color: currentColor !important;
|
||||
font-size: 14px;
|
||||
font-weight: normal !important;
|
||||
}
|
||||
|
||||
.course-ai-content.course-content.prose ul li > code,
|
||||
.course-ai-content.course-content.prose ol li > code,
|
||||
.course-ai-content.course-content.prose p code,
|
||||
.course-ai-content.course-content.prose a > code,
|
||||
.course-ai-content.course-content.prose strong > code,
|
||||
.course-ai-content.course-content.prose em > code,
|
||||
.course-ai-content.course-content.prose h1 > code,
|
||||
.course-ai-content.course-content.prose h2 > code,
|
||||
.course-ai-content.course-content.prose h3 > code,
|
||||
.course-notes-content.prose ul li > code,
|
||||
.course-notes-content.prose ol li > code,
|
||||
.course-notes-content.prose p code,
|
||||
.course-notes-content.prose a > code,
|
||||
.course-notes-content.prose strong > code,
|
||||
.course-notes-content.prose em > code,
|
||||
.course-notes-content.prose h1 > code,
|
||||
.course-notes-content.prose h2 > code,
|
||||
.course-notes-content.prose h3 > code {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.course-ai-content pre {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.course-ai-content pre::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.course-ai-content pre,
|
||||
.course-notes-content pre {
|
||||
overflow: scroll;
|
||||
font-size: 15px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.prose ul li > code:before,
|
||||
p > code:before,
|
||||
.prose ul li > code:after,
|
||||
.prose ol li > code:before,
|
||||
p > code:before,
|
||||
.prose ol li > code:after,
|
||||
.course-content h1 > code:after,
|
||||
.course-content h1 > code:before,
|
||||
.course-content h2 > code:after,
|
||||
.course-content h2 > code:before,
|
||||
.course-content h3 > code:after,
|
||||
.course-content h3 > code:before,
|
||||
.course-content h4 > code:after,
|
||||
.course-content h4 > code:before,
|
||||
p > code:after,
|
||||
a > code:after,
|
||||
a > code:before {
|
||||
content: '' !important;
|
||||
}
|
||||
|
||||
.course-content.prose ul li > code,
|
||||
.course-content.prose ol li > code,
|
||||
.course-content p code,
|
||||
.course-content a > code,
|
||||
.course-content strong > code,
|
||||
.course-content em > code,
|
||||
.course-content h1 > code,
|
||||
.course-content h2 > code,
|
||||
.course-content h3 > code,
|
||||
.course-content table code {
|
||||
background: #f4f4f5 !important;
|
||||
border: 1px solid #282a36 !important;
|
||||
color: #282a36 !important;
|
||||
padding: 2px 4px;
|
||||
border-radius: 5px;
|
||||
font-size: 16px !important;
|
||||
white-space: pre;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.course-content blockquote {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.course-content.prose blockquote h1,
|
||||
.course-content.prose blockquote h2,
|
||||
.course-content.prose blockquote h3,
|
||||
.course-content.prose blockquote h4 {
|
||||
font-style: normal;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.course-content.prose ul li > code:before,
|
||||
.course-content p > code:before,
|
||||
.course-content.prose ul li > code:after,
|
||||
.course-content p > code:after,
|
||||
.course-content h2 > code:after,
|
||||
.course-content h2 > code:before,
|
||||
.course-content table code:before,
|
||||
.course-content table code:after,
|
||||
.course-content a > code:after,
|
||||
.course-content a > code:before,
|
||||
.course-content h2 code:after,
|
||||
.course-content h2 code:before,
|
||||
.course-content h2 code:after,
|
||||
.course-content h2 code:before {
|
||||
content: '' !important;
|
||||
}
|
||||
|
||||
.course-content table {
|
||||
border-collapse: collapse;
|
||||
border: 1px solid black;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.course-content table td,
|
||||
.course-content table th {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
74
src/components/GenerateCourse/AICourseFollowUp.tsx
Normal file
74
src/components/GenerateCourse/AICourseFollowUp.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { ArrowRightIcon, BotIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
AICourseFollowUpPopover,
|
||||
type AIChatHistoryType,
|
||||
} from './AICourseFollowUpPopover';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
|
||||
type AICourseFollowUpProps = {
|
||||
courseSlug: string;
|
||||
moduleTitle: string;
|
||||
lessonTitle: string;
|
||||
};
|
||||
|
||||
export function AICourseFollowUp(props: AICourseFollowUpProps) {
|
||||
const { courseSlug, moduleTitle, lessonTitle } = props;
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
||||
|
||||
const [courseAIChatHistory, setCourseAIChatHistory] = useState<
|
||||
AIChatHistoryType[]
|
||||
>([
|
||||
{
|
||||
role: 'assistant',
|
||||
content:
|
||||
'Hey, I am your AI instructor. Here are some examples of what you can ask me about 🤖',
|
||||
isDefault: true,
|
||||
},
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
className="mt-4 flex w-full items-center gap-2 rounded-lg border border-yellow-300 bg-yellow-100 p-4 hover:bg-yellow-200 max-lg:mt-3 max-lg:text-sm"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<BotIcon className="h-4 w-4" />
|
||||
<span>Ask AI some follow up questions</span>
|
||||
|
||||
<ArrowRightIcon className="ml-auto h-4 w-4 max-sm:hidden" />
|
||||
</button>
|
||||
|
||||
{showUpgradeModal && (
|
||||
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
|
||||
)}
|
||||
|
||||
{isOpen && (
|
||||
<AICourseFollowUpPopover
|
||||
courseSlug={courseSlug}
|
||||
moduleTitle={moduleTitle}
|
||||
lessonTitle={lessonTitle}
|
||||
courseAIChatHistory={courseAIChatHistory}
|
||||
setCourseAIChatHistory={setCourseAIChatHistory}
|
||||
onUpgradeClick={() => {
|
||||
setIsOpen(false);
|
||||
setShowUpgradeModal(true);
|
||||
}}
|
||||
onOutsideClick={() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isOpen && (
|
||||
<div className="pointer-events-none fixed inset-0 z-50 bg-black/50" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
390
src/components/GenerateCourse/AICourseFollowUpPopover.tsx
Normal file
390
src/components/GenerateCourse/AICourseFollowUpPopover.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
BookOpen,
|
||||
Bot,
|
||||
Code,
|
||||
Globe, Hammer,
|
||||
HelpCircle,
|
||||
LockIcon,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { readStream } from '../../lib/ai';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { isLoggedIn, removeAuthToken } from '../../lib/jwt';
|
||||
import {
|
||||
markdownToHtml,
|
||||
markdownToHtmlWithHighlighting,
|
||||
} from '../../lib/markdown';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
|
||||
export type AllowedAIChatRole = 'user' | 'assistant';
|
||||
export type AIChatHistoryType = {
|
||||
role: AllowedAIChatRole;
|
||||
content: string;
|
||||
isDefault?: boolean;
|
||||
html?: string;
|
||||
};
|
||||
|
||||
type AICourseFollowUpPopoverProps = {
|
||||
courseSlug: string;
|
||||
moduleTitle: string;
|
||||
lessonTitle: string;
|
||||
|
||||
courseAIChatHistory: AIChatHistoryType[];
|
||||
setCourseAIChatHistory: (value: AIChatHistoryType[]) => void;
|
||||
|
||||
onOutsideClick?: () => void;
|
||||
onUpgradeClick: () => void;
|
||||
};
|
||||
|
||||
export function AICourseFollowUpPopover(props: AICourseFollowUpPopoverProps) {
|
||||
const {
|
||||
courseSlug,
|
||||
moduleTitle,
|
||||
lessonTitle,
|
||||
onOutsideClick,
|
||||
onUpgradeClick,
|
||||
|
||||
courseAIChatHistory,
|
||||
setCourseAIChatHistory,
|
||||
} = props;
|
||||
|
||||
const toast = useToast();
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollareaRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [isStreamingMessage, setIsStreamingMessage] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [streamedMessage, setStreamedMessage] = useState('');
|
||||
|
||||
useOutsideClick(containerRef, onOutsideClick);
|
||||
|
||||
const { data: tokenUsage, isLoading } = useQuery(
|
||||
getAiCourseLimitOptions(),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
|
||||
|
||||
const handleChatSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const trimmedMessage = message.trim();
|
||||
if (
|
||||
!trimmedMessage ||
|
||||
isStreamingMessage ||
|
||||
!isLoggedIn() ||
|
||||
isLimitExceeded ||
|
||||
isLoading
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newMessages: AIChatHistoryType[] = [
|
||||
...courseAIChatHistory,
|
||||
{
|
||||
role: 'user',
|
||||
content: trimmedMessage,
|
||||
},
|
||||
];
|
||||
|
||||
flushSync(() => {
|
||||
setCourseAIChatHistory(newMessages);
|
||||
setMessage('');
|
||||
});
|
||||
|
||||
scrollToBottom();
|
||||
completeCourseAIChat(newMessages);
|
||||
};
|
||||
|
||||
const scrollToBottom = () => {
|
||||
scrollareaRef.current?.scrollTo({
|
||||
top: scrollareaRef.current.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
const completeCourseAIChat = async (messages: AIChatHistoryType[]) => {
|
||||
setIsStreamingMessage(true);
|
||||
|
||||
const response = await fetch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-follow-up-ai-course/${courseSlug}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
moduleTitle,
|
||||
lessonTitle,
|
||||
messages: messages.slice(-10),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
toast.error(data?.message || 'Something went wrong');
|
||||
setCourseAIChatHistory([...messages].slice(0, messages.length - 1));
|
||||
setIsStreamingMessage(false);
|
||||
|
||||
if (data.status === 401) {
|
||||
removeAuthToken();
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
|
||||
if (!reader) {
|
||||
setIsStreamingMessage(false);
|
||||
toast.error('Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
await readStream(reader, {
|
||||
onStream: async (content) => {
|
||||
flushSync(() => {
|
||||
setStreamedMessage(content);
|
||||
});
|
||||
|
||||
scrollToBottom();
|
||||
},
|
||||
onStreamEnd: async (content) => {
|
||||
const newMessages: AIChatHistoryType[] = [
|
||||
...messages,
|
||||
{
|
||||
role: 'assistant',
|
||||
content,
|
||||
html: await markdownToHtmlWithHighlighting(content),
|
||||
},
|
||||
];
|
||||
|
||||
flushSync(() => {
|
||||
setStreamedMessage('');
|
||||
setIsStreamingMessage(false);
|
||||
setCourseAIChatHistory(newMessages);
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries(getAiCourseLimitOptions());
|
||||
scrollToBottom();
|
||||
},
|
||||
});
|
||||
|
||||
setIsStreamingMessage(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute bottom-0 left-0 z-[99] flex h-[500px] w-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow"
|
||||
ref={containerRef}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 border-b border-gray-200 px-4 py-2 text-sm">
|
||||
<h4 className="text-base font-medium">Course AI</h4>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="scrollbar-thumb-gray-300 scrollbar-track-transparent scrollbar-thin relative grow overflow-y-auto"
|
||||
ref={scrollareaRef}
|
||||
>
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
<div className="flex grow flex-col justify-end">
|
||||
<div className="flex flex-col justify-end gap-2 px-3 py-2">
|
||||
{courseAIChatHistory.map((chat, index) => {
|
||||
return (
|
||||
<>
|
||||
<AIChatCard
|
||||
key={`chat-${index}`}
|
||||
role={chat.role}
|
||||
content={chat.content}
|
||||
html={chat.html}
|
||||
/>
|
||||
|
||||
{chat.isDefault && (
|
||||
<div className="mb-1 mt-0.5">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{capabilities.map((capability, index) => (
|
||||
<CapabilityCard
|
||||
key={`capability-${index}`}
|
||||
{...capability}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
|
||||
{isStreamingMessage && !streamedMessage && (
|
||||
<AIChatCard role="assistant" content="Thinking..." />
|
||||
)}
|
||||
|
||||
{streamedMessage && (
|
||||
<AIChatCard role="assistant" content={streamedMessage} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="relative flex items-start border-t border-gray-200 text-sm"
|
||||
onSubmit={handleChatSubmit}
|
||||
>
|
||||
{isLimitExceeded && (
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black text-white">
|
||||
<LockIcon className="size-4 cursor-not-allowed" strokeWidth={2.5} />
|
||||
<p className="cursor-not-allowed">Limit reached for today</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
onUpgradeClick();
|
||||
}}
|
||||
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
|
||||
>
|
||||
Upgrade for more
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<TextareaAutosize
|
||||
className="h-full min-h-[41px] grow resize-none bg-transparent px-4 py-2 focus:outline-none"
|
||||
placeholder="Ask AI anything about the lesson..."
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
autoFocus={true}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
handleChatSubmit(e as unknown as FormEvent<HTMLFormElement>);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isStreamingMessage || isLimitExceeded}
|
||||
className="flex aspect-square size-[41px] items-center justify-center text-zinc-500 hover:text-black"
|
||||
>
|
||||
<Send className="size-4 stroke-[2.5]" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type AIChatCardProps = {
|
||||
role: AllowedAIChatRole;
|
||||
content: string;
|
||||
html?: string;
|
||||
};
|
||||
|
||||
function AIChatCard(props: AIChatCardProps) {
|
||||
const { role, content, html: defaultHtml } = props;
|
||||
|
||||
const html = useMemo(() => {
|
||||
if (defaultHtml) {
|
||||
return defaultHtml;
|
||||
}
|
||||
|
||||
return markdownToHtml(content, false);
|
||||
}, [content, defaultHtml]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col rounded-lg',
|
||||
role === 'user' ? 'bg-gray-300/30' : 'bg-yellow-500/30',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2.5 p-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-6 shrink-0 items-center justify-center rounded-full',
|
||||
role === 'user'
|
||||
? 'bg-gray-200 text-black'
|
||||
: 'bg-yellow-400 text-black',
|
||||
)}
|
||||
>
|
||||
<Bot className="size-4 stroke-[2.5]" />
|
||||
</div>
|
||||
<div
|
||||
className="course-content course-ai-content prose prose-sm mt-0.5 max-w-full overflow-hidden text-sm"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CapabilityCardProps = {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function CapabilityCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
}: CapabilityCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-2 rounded-lg bg-yellow-500/10 p-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<span className="text-[13px] font-medium leading-none text-black">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[12px] leading-normal text-gray-600">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const capabilities = [
|
||||
{
|
||||
icon: (
|
||||
<HelpCircle
|
||||
className="size-4 shrink-0 text-yellow-600"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
),
|
||||
title: 'Clarify Concepts',
|
||||
description: "If you don't understand a concept, ask me to clarify it",
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<BookOpen className="size-4 shrink-0 text-yellow-600" strokeWidth={2.5} />
|
||||
),
|
||||
title: 'More Details',
|
||||
description: 'Get deeper insights about topics covered in the lesson',
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<Hammer className="size-4 shrink-0 text-yellow-600" strokeWidth={2.5} />
|
||||
),
|
||||
title: 'Real-world Examples',
|
||||
description: 'Ask for real-world examples to understand better',
|
||||
},
|
||||
{
|
||||
icon: <Bot className="size-4 shrink-0 text-yellow-600" strokeWidth={2.5} />,
|
||||
title: 'Best Practices',
|
||||
description: 'Learn about best practices and common pitfalls',
|
||||
},
|
||||
] as const;
|
||||
387
src/components/GenerateCourse/AICourseLesson.tsx
Normal file
387
src/components/GenerateCourse/AICourseLesson.tsx
Normal file
@@ -0,0 +1,387 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Loader2Icon,
|
||||
LockIcon,
|
||||
XIcon,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { AICourseDocument } from '../../api/ai-roadmap';
|
||||
import { readStream } from '../../lib/ai';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { isLoggedIn, removeAuthToken } from '../../lib/jwt';
|
||||
import {
|
||||
markdownToHtml,
|
||||
markdownToHtmlWithHighlighting,
|
||||
} from '../../lib/markdown';
|
||||
import { httpPatch } from '../../lib/query-http';
|
||||
import { slugify } from '../../lib/slugger';
|
||||
import {
|
||||
getAiCourseLimitOptions,
|
||||
getAiCourseOptions,
|
||||
} from '../../queries/ai-course';
|
||||
import { useIsPaidUser } from '../../queries/billing';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { AICourseFollowUp } from './AICourseFollowUp';
|
||||
import './AICourseFollowUp.css';
|
||||
import { RegenerateLesson } from './RegenerateLesson';
|
||||
|
||||
type AICourseLessonProps = {
|
||||
courseSlug: string;
|
||||
progress: string[];
|
||||
|
||||
activeModuleIndex: number;
|
||||
totalModules: number;
|
||||
currentModuleTitle: string;
|
||||
activeLessonIndex: number;
|
||||
totalLessons: number;
|
||||
currentLessonTitle: string;
|
||||
|
||||
onGoToPrevLesson: () => void;
|
||||
onGoToNextLesson: () => void;
|
||||
|
||||
onUpgrade: () => void;
|
||||
};
|
||||
|
||||
export function AICourseLesson(props: AICourseLessonProps) {
|
||||
const {
|
||||
courseSlug,
|
||||
progress = [],
|
||||
|
||||
activeModuleIndex,
|
||||
totalModules,
|
||||
currentModuleTitle,
|
||||
activeLessonIndex,
|
||||
totalLessons,
|
||||
currentLessonTitle,
|
||||
|
||||
onGoToPrevLesson,
|
||||
onGoToNextLesson,
|
||||
|
||||
onUpgrade,
|
||||
} = props;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [lessonHtml, setLessonHtml] = useState('');
|
||||
|
||||
const lessonId = `${slugify(String(activeModuleIndex))}-${slugify(String(activeLessonIndex))}`;
|
||||
const isLessonDone = progress?.includes(lessonId);
|
||||
|
||||
const { isPaidUser } = useIsPaidUser();
|
||||
|
||||
const abortController = useMemo(
|
||||
() => new AbortController(),
|
||||
[activeModuleIndex, activeLessonIndex],
|
||||
);
|
||||
|
||||
const generateAiCourseContent = async (
|
||||
isForce?: boolean,
|
||||
customPrompt?: string,
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
setLessonHtml('');
|
||||
|
||||
if (!isLoggedIn()) {
|
||||
setIsLoading(false);
|
||||
setError('Please login to generate course content');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentModuleTitle || !currentLessonTitle) {
|
||||
setIsLoading(false);
|
||||
setError('Invalid module title or lesson title');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-course-lesson/${courseSlug}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal: abortController.signal,
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
moduleIndex: activeModuleIndex,
|
||||
lessonIndex: activeLessonIndex,
|
||||
isForce,
|
||||
customPrompt,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
setError(data?.message || 'Something went wrong');
|
||||
setIsLoading(false);
|
||||
|
||||
// Logout user if token is invalid
|
||||
if (data.status === 401) {
|
||||
removeAuthToken();
|
||||
window.location.reload();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
setIsLoading(false);
|
||||
setError('No response body received');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const reader = response.body.getReader();
|
||||
setIsLoading(false);
|
||||
setIsGenerating(true);
|
||||
await readStream(reader, {
|
||||
onStream: async (result) => {
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLessonHtml(markdownToHtml(result, false));
|
||||
},
|
||||
onStreamEnd: async (result) => {
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLessonHtml(await markdownToHtmlWithHighlighting(result));
|
||||
queryClient.invalidateQueries(getAiCourseLimitOptions());
|
||||
setIsGenerating(false);
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Something went wrong');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const { mutate: toggleDone, isPending: isTogglingDone } = useMutation(
|
||||
{
|
||||
mutationFn: () => {
|
||||
return httpPatch<AICourseDocument>(
|
||||
`/v1-toggle-done-ai-lesson/${courseSlug}`,
|
||||
{
|
||||
moduleIndex: activeModuleIndex,
|
||||
lessonIndex: activeLessonIndex,
|
||||
},
|
||||
);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(
|
||||
getAiCourseOptions({ aiCourseSlug: courseSlug }).queryKey,
|
||||
data,
|
||||
);
|
||||
},
|
||||
},
|
||||
queryClient,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
generateAiCourseContent();
|
||||
}, [currentModuleTitle, currentLessonTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
}, [abortController]);
|
||||
|
||||
const cantGoForward =
|
||||
(activeModuleIndex === totalModules - 1 &&
|
||||
activeLessonIndex === totalLessons - 1) ||
|
||||
isGenerating ||
|
||||
isLoading;
|
||||
|
||||
const cantGoBack =
|
||||
(activeModuleIndex === 0 && activeLessonIndex === 0) || isGenerating || isLoading;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="relative rounded-lg border border-gray-200 bg-white p-6 shadow-sm max-lg:px-4 max-lg:pb-4 max-lg:pt-3">
|
||||
{(isGenerating || isLoading) && (
|
||||
<div className="absolute right-3 top-3 flex items-center justify-center">
|
||||
<Loader2Icon
|
||||
size={18}
|
||||
strokeWidth={3}
|
||||
className="animate-spin text-gray-400/70"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500">
|
||||
Lesson {activeLessonIndex + 1} of {totalLessons}
|
||||
</div>
|
||||
|
||||
{!isGenerating && !isLoading && (
|
||||
<div className="absolute right-3 top-3 flex items-center justify-between gap-2">
|
||||
<RegenerateLesson
|
||||
onRegenerateLesson={(prompt) => {
|
||||
generateAiCourseContent(true, prompt);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
disabled={isLoading || isTogglingDone}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-full bg-black py-1 pl-2 pr-3 text-sm text-white hover:bg-gray-800 disabled:opacity-50 max-lg:text-xs',
|
||||
isLessonDone
|
||||
? 'bg-red-500 hover:bg-red-600'
|
||||
: 'bg-green-500 hover:bg-green-600',
|
||||
)}
|
||||
onClick={() => toggleDone()}
|
||||
>
|
||||
{isTogglingDone ? (
|
||||
<>
|
||||
<Loader2Icon
|
||||
size={16}
|
||||
strokeWidth={3}
|
||||
className="animate-spin text-white"
|
||||
/>
|
||||
Please wait ...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isLessonDone ? (
|
||||
<>
|
||||
<XIcon size={16} />
|
||||
Mark as Undone
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckIcon size={16} />
|
||||
Mark as Done
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="mb-6 text-balance text-3xl font-semibold max-lg:mb-3 max-lg:text-xl">
|
||||
{currentLessonTitle?.replace(/^Lesson\s*?\d+[\.:]\s*/, '')}
|
||||
</h1>
|
||||
|
||||
{!error && isLoggedIn() && (
|
||||
<div
|
||||
className="course-content prose prose-lg mt-8 max-w-full text-black prose-headings:mb-3 prose-headings:mt-8 prose-blockquote:font-normal prose-pre:rounded-2xl prose-pre:text-lg prose-li:my-1 prose-thead:border-zinc-800 prose-tr:border-zinc-800 max-lg:mt-4 max-lg:text-base max-lg:prose-h2:mt-3 max-lg:prose-h2:text-lg max-lg:prose-h3:text-base max-lg:prose-pre:px-3 max-lg:prose-pre:text-sm"
|
||||
dangerouslySetInnerHTML={{ __html: lessonHtml }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && isLoggedIn() && (
|
||||
<div className="mt-8 flex min-h-[300px] items-center justify-center rounded-xl bg-red-50/80">
|
||||
{error.includes('reached the limit') ? (
|
||||
<div className="flex max-w-sm flex-col items-center text-center">
|
||||
<h2 className="text-xl font-semibold text-red-600">
|
||||
Limit reached
|
||||
</h2>
|
||||
<p className="my-3 text-red-600">
|
||||
You have reached the AI usage limit for today.
|
||||
{!isPaidUser && <>Please upgrade your account to continue.</>}
|
||||
{isPaidUser && <>Please wait until tomorrow to continue.</>}
|
||||
</p>
|
||||
|
||||
{!isPaidUser && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onUpgrade();
|
||||
}}
|
||||
className="rounded-full bg-red-600 px-4 py-1 text-white hover:bg-red-700"
|
||||
>
|
||||
Upgrade Account
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-red-600">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoggedIn() && (
|
||||
<div className="mt-8 flex min-h-[152px] flex-col items-center justify-center gap-3 rounded-lg border border-gray-200 p-8">
|
||||
<LockIcon className="size-7 stroke-[2] text-gray-400/90" />
|
||||
<p className="text-sm text-gray-500">
|
||||
Please login to generate course content
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<button
|
||||
onClick={onGoToPrevLesson}
|
||||
disabled={cantGoBack}
|
||||
className={cn(
|
||||
'flex items-center rounded-full px-4 py-2 disabled:opacity-50 max-lg:px-3 max-lg:py-1.5 max-lg:text-sm',
|
||||
cantGoBack
|
||||
? 'cursor-not-allowed text-gray-400'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200',
|
||||
)}
|
||||
>
|
||||
<ChevronLeft size={16} className="mr-2" />
|
||||
Previous <span className="hidden lg:inline"> Lesson</span>
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!isLessonDone) {
|
||||
toggleDone(undefined, {
|
||||
onSuccess: () => {
|
||||
onGoToNextLesson();
|
||||
},
|
||||
});
|
||||
} else {
|
||||
onGoToNextLesson();
|
||||
}
|
||||
}}
|
||||
disabled={cantGoForward || isTogglingDone}
|
||||
className={cn(
|
||||
'flex items-center rounded-full px-4 py-2 disabled:opacity-50 max-lg:px-3 max-lg:py-1.5 max-lg:text-sm',
|
||||
cantGoForward
|
||||
? 'cursor-not-allowed text-gray-400'
|
||||
: 'bg-gray-800 text-white hover:bg-gray-700',
|
||||
)}
|
||||
>
|
||||
{isTogglingDone ? (
|
||||
<>
|
||||
<Loader2Icon
|
||||
size={16}
|
||||
strokeWidth={3}
|
||||
className="animate-spin text-white"
|
||||
/>
|
||||
Please wait ...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Next <span className="hidden lg:inline"> Lesson</span>
|
||||
<ChevronRight size={16} className="ml-2" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isGenerating && !isLoading && (
|
||||
<AICourseFollowUp
|
||||
courseSlug={courseSlug}
|
||||
moduleTitle={currentModuleTitle}
|
||||
lessonTitle={currentLessonTitle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
src/components/GenerateCourse/AICourseLimit.tsx
Normal file
81
src/components/GenerateCourse/AICourseLimit.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Gift, Info } from 'lucide-react';
|
||||
import { getPercentage } from '../../lib/number';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
import { billingDetailsOptions } from '../../queries/billing';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
|
||||
type AICourseLimitProps = {
|
||||
onUpgrade: () => void;
|
||||
onShowLimits: () => void;
|
||||
};
|
||||
|
||||
export function AICourseLimit(props: AICourseLimitProps) {
|
||||
const { onUpgrade, onShowLimits } = props;
|
||||
|
||||
const { data: limits, isLoading } = useQuery(
|
||||
getAiCourseLimitOptions(),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
|
||||
useQuery(billingDetailsOptions(), queryClient);
|
||||
|
||||
if (isLoading || !limits || isBillingDetailsLoading || !userBillingDetails) {
|
||||
return (
|
||||
<div className="hidden h-[38px] w-[208.09px] animate-pulse rounded-lg border border-gray-200 bg-gray-200 lg:block"></div>
|
||||
);
|
||||
}
|
||||
|
||||
const { used, limit } = limits;
|
||||
|
||||
const totalPercentage = getPercentage(used, limit);
|
||||
|
||||
// has consumed 85% of the limit
|
||||
const isNearLimit = used >= limit * 0.85;
|
||||
const isPaidUser = userBillingDetails.status === 'active';
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isPaidUser ||
|
||||
(isNearLimit && (
|
||||
<button
|
||||
className="mr-1 flex items-center gap-1 text-sm font-medium underline underline-offset-2 lg:hidden"
|
||||
onClick={() => onShowLimits()}
|
||||
>
|
||||
<Info className="size-4" />
|
||||
{totalPercentage}% limit used
|
||||
</button>
|
||||
))}
|
||||
|
||||
{(!isPaidUser || isNearLimit) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onShowLimits();
|
||||
}}
|
||||
className="relative hidden h-full min-h-[38px] cursor-pointer items-center overflow-hidden rounded-lg border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50 lg:flex"
|
||||
>
|
||||
<span className="relative z-10">
|
||||
{totalPercentage}% of the daily limit used
|
||||
</span>
|
||||
<div
|
||||
className="absolute inset-0 h-full bg-gray-200/80"
|
||||
style={{
|
||||
width: `${totalPercentage}%`,
|
||||
}}
|
||||
></div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isPaidUser && (
|
||||
<button
|
||||
className="hidden items-center justify-center gap-1 rounded-md bg-yellow-400 px-4 py-1 text-sm font-medium underline-offset-2 hover:bg-yellow-500 lg:flex"
|
||||
onClick={() => onUpgrade()}
|
||||
>
|
||||
<Gift className="size-4" />
|
||||
Upgrade
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
46
src/components/GenerateCourse/AICourseSearch.tsx
Normal file
46
src/components/GenerateCourse/AICourseSearch.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { SearchIcon } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDebounceValue } from '../../hooks/use-debounce';
|
||||
|
||||
type AICourseSearchProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export function AICourseSearch(props: AICourseSearchProps) {
|
||||
const { value: defaultValue, onChange } = props;
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(defaultValue);
|
||||
const debouncedSearchTerm = useDebounceValue(searchTerm, 500);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchTerm(defaultValue);
|
||||
}, [defaultValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedSearchTerm && debouncedSearchTerm.length < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (debouncedSearchTerm === defaultValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(debouncedSearchTerm);
|
||||
}, [debouncedSearchTerm]);
|
||||
|
||||
return (
|
||||
<div className="relative w-64 max-sm:hidden">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<SearchIcon className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="block w-full rounded-md border border-gray-200 bg-white py-1.5 pl-10 pr-3 leading-5 placeholder-gray-500 focus:border-gray-300 focus:outline-none focus:ring-blue-500 disabled:opacity-70 sm:text-sm"
|
||||
placeholder="Search your courses..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
204
src/components/GenerateCourse/AICourseSidebarModuleList.tsx
Normal file
204
src/components/GenerateCourse/AICourseSidebarModuleList.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { type Dispatch, type SetStateAction } from 'react';
|
||||
import type { AiCourse } from '../../lib/ai';
|
||||
import { Check, ChevronDownIcon, ChevronRightIcon } from 'lucide-react';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { slugify } from '../../lib/slugger';
|
||||
import { CheckIcon } from '../ReactIcons/CheckIcon';
|
||||
import { CircularProgress } from './CircularProgress';
|
||||
|
||||
type AICourseModuleListProps = {
|
||||
course: AiCourse;
|
||||
courseSlug?: string;
|
||||
activeModuleIndex: number | undefined;
|
||||
setActiveModuleIndex: (index: number) => void;
|
||||
activeLessonIndex: number | undefined;
|
||||
setActiveLessonIndex: (index: number) => void;
|
||||
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
|
||||
viewMode: 'module' | 'outline';
|
||||
setViewMode: (mode: 'module' | 'outline') => void;
|
||||
|
||||
expandedModules: Record<number, boolean>;
|
||||
setExpandedModules: Dispatch<SetStateAction<Record<number, boolean>>>;
|
||||
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
export function AICourseSidebarModuleList(props: AICourseModuleListProps) {
|
||||
const {
|
||||
course,
|
||||
courseSlug,
|
||||
activeModuleIndex,
|
||||
setActiveModuleIndex,
|
||||
activeLessonIndex,
|
||||
setActiveLessonIndex,
|
||||
setSidebarOpen,
|
||||
setViewMode,
|
||||
expandedModules,
|
||||
setExpandedModules,
|
||||
|
||||
isLoading,
|
||||
} = props;
|
||||
|
||||
const aiCourseProgress = course.done || [];
|
||||
|
||||
const toggleModule = (index: number) => {
|
||||
setExpandedModules((prev) => {
|
||||
// If this module is already expanded, collapse it
|
||||
if (prev[index]) {
|
||||
return {
|
||||
...prev,
|
||||
[index]: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Otherwise, collapse all modules and expand only this one
|
||||
const newState: Record<number, boolean> = {};
|
||||
// Set all modules to collapsed
|
||||
course.modules.forEach((_, idx) => {
|
||||
newState[idx] = false;
|
||||
});
|
||||
// Expand only the clicked module
|
||||
newState[index] = true;
|
||||
return newState;
|
||||
});
|
||||
};
|
||||
|
||||
const done = aiCourseProgress || [];
|
||||
|
||||
return (
|
||||
<nav className="bg-gray-100">
|
||||
{course.modules.map((courseModule, moduleIdx) => {
|
||||
const totalLessons = courseModule.lessons.length;
|
||||
const completedLessons = courseModule.lessons.filter(
|
||||
(lesson, lessonIdx) => {
|
||||
const key = `${slugify(String(moduleIdx))}-${slugify(String(lessonIdx))}`;
|
||||
return done.includes(key);
|
||||
},
|
||||
).length;
|
||||
|
||||
const percentage = Math.round((completedLessons / totalLessons) * 100);
|
||||
const isActive = expandedModules[moduleIdx];
|
||||
const isModuleCompleted = completedLessons === totalLessons;
|
||||
|
||||
return (
|
||||
<div key={moduleIdx} className="rounded-md">
|
||||
<button
|
||||
onClick={() => toggleModule(moduleIdx)}
|
||||
className={cn(
|
||||
'relative z-10 flex w-full cursor-pointer flex-row items-center gap-2 border-b border-b-gray-200 bg-white px-2 py-3 text-base text-gray-600 hover:bg-gray-100',
|
||||
activeModuleIndex === moduleIdx
|
||||
? 'text-gray-900'
|
||||
: 'text-gray-700',
|
||||
moduleIdx === 0 && 'pt-4',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<div className="flex-shrink-0">
|
||||
<CircularProgress
|
||||
percentage={percentage}
|
||||
isVisible={!isModuleCompleted}
|
||||
isActive={isActive}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-[21px] flex-shrink-0 items-center justify-center rounded-full bg-gray-400/70 text-xs font-semibold text-white',
|
||||
{
|
||||
'bg-black': isActive,
|
||||
'bg-green-600': isModuleCompleted,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{!isModuleCompleted && moduleIdx + 1}
|
||||
{isModuleCompleted && (
|
||||
<Check className="size-3 stroke-[3] text-white" />
|
||||
)}
|
||||
</span>
|
||||
</CircularProgress>
|
||||
</div>
|
||||
<span className="flex flex-1 items-center break-words text-left text-sm leading-relaxed">
|
||||
{courseModule.title?.replace(/^Module\s*?\d+[\.:]\s*/, '')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-auto self-center">
|
||||
{expandedModules[moduleIdx] ? (
|
||||
<ChevronDownIcon size={16} className="flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRightIcon size={16} className="flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Lessons */}
|
||||
{expandedModules[moduleIdx] && (
|
||||
<div className="flex flex-col border-b border-b-gray-200 bg-gray-100">
|
||||
{courseModule.lessons.map((lesson, lessonIdx) => {
|
||||
const key = `${slugify(String(moduleIdx))}-${slugify(String(lessonIdx))}`;
|
||||
const isCompleted = done.includes(key);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => {
|
||||
setActiveModuleIndex(moduleIdx);
|
||||
setActiveLessonIndex(lessonIdx);
|
||||
setExpandedModules((prev) => {
|
||||
const newState: Record<number, boolean> = {};
|
||||
course.modules.forEach((_, idx) => {
|
||||
newState[idx] = false;
|
||||
});
|
||||
newState[moduleIdx] = true;
|
||||
return newState;
|
||||
});
|
||||
setSidebarOpen(false);
|
||||
setViewMode('module');
|
||||
}}
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer items-center gap-2.5 py-3 pl-3.5 pr-2 text-left text-sm leading-normal',
|
||||
activeModuleIndex === moduleIdx &&
|
||||
activeLessonIndex === lessonIdx
|
||||
? 'bg-gray-200 text-black'
|
||||
: 'text-gray-600 hover:bg-gray-200/70',
|
||||
)}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<CheckIcon
|
||||
additionalClasses={cn(
|
||||
'size-[18px] relative bg-white rounded-full top-[2px] flex-shrink-0 text-green-600',
|
||||
{
|
||||
'text-black':
|
||||
activeModuleIndex === moduleIdx &&
|
||||
activeLessonIndex === lessonIdx,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-[18px] flex-shrink-0 items-center justify-center rounded-full bg-gray-400/70 text-xs font-semibold text-white',
|
||||
{
|
||||
'bg-black':
|
||||
activeModuleIndex === moduleIdx &&
|
||||
activeLessonIndex === lessonIdx,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{lessonIdx + 1}
|
||||
</span>
|
||||
)}
|
||||
<span className="break-words">
|
||||
{lesson?.replace(/^Lesson\s*?\d+[\.:]\s*/, '')}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
103
src/components/GenerateCourse/AILimitsPopup.tsx
Normal file
103
src/components/GenerateCourse/AILimitsPopup.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Gift } from 'lucide-react';
|
||||
import { Modal } from '../Modal';
|
||||
import { formatCommaNumber } from '../../lib/number';
|
||||
import { billingDetailsOptions } from '../../queries/billing';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||
|
||||
type AILimitsPopupProps = {
|
||||
onClose: () => void;
|
||||
onUpgrade: () => void;
|
||||
};
|
||||
|
||||
export function AILimitsPopup(props: AILimitsPopupProps) {
|
||||
const { onClose, onUpgrade } = props;
|
||||
|
||||
const { data: limits, isLoading } = useQuery(
|
||||
getAiCourseLimitOptions(),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const { used, limit } = limits ?? { used: 0, limit: 0 };
|
||||
|
||||
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
|
||||
useQuery(billingDetailsOptions(), queryClient);
|
||||
|
||||
const isPaidUser = userBillingDetails?.status === 'active';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={onClose}
|
||||
wrapperClassName="rounded-xl max-w-xl w-full h-auto"
|
||||
bodyClassName="p-6"
|
||||
overlayClassName="items-start md:items-center"
|
||||
>
|
||||
<h2 className="mb-8 text-center text-xl font-semibold">
|
||||
Daily AI Limits
|
||||
</h2>
|
||||
|
||||
{/* Usage Progress Bar */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 flex justify-between">
|
||||
<span className="text-sm font-medium">
|
||||
Usage: {formatCommaNumber(used)} /
|
||||
{formatCommaNumber(limit)} tokens
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{Math.round((used / limit) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2.5 w-full rounded-full bg-gray-200">
|
||||
<div
|
||||
className="h-2.5 rounded-full bg-yellow-500"
|
||||
style={{ width: `${Math.min(100, (used / limit) * 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage Stats */}
|
||||
<div className="rounded-lg bg-gray-50 p-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Used Today</p>
|
||||
<p className="text-2xl font-bold">{formatCommaNumber(used)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Daily Limit</p>
|
||||
<p className="text-2xl font-bold">{formatCommaNumber(limit)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Explanation */}
|
||||
<div className="mt-2">
|
||||
<div className="space-y-3 text-gray-600">
|
||||
<p className="text-sm">
|
||||
Limit resets every 24 hours.{' '}
|
||||
{!isPaidUser && 'Consider upgrading for more tokens.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
<div className="mt-auto flex flex-col gap-2 pt-4">
|
||||
{!isPaidUser && (
|
||||
<button
|
||||
onClick={onUpgrade}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-yellow-400 px-4 py-2.5 text-sm font-medium text-black transition-colors hover:bg-yellow-500"
|
||||
>
|
||||
<Gift className="size-4" />
|
||||
Upgrade to Unlimited
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full rounded-lg bg-gray-200 px-4 py-2.5 text-sm text-gray-600 transition-colors hover:bg-gray-300"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
57
src/components/GenerateCourse/CircularProgress.tsx
Normal file
57
src/components/GenerateCourse/CircularProgress.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
export function ChapterNumberSkeleton() {
|
||||
return (
|
||||
<div className="h-[28px] w-[28px] animate-pulse rounded-full bg-gray-200" />
|
||||
);
|
||||
}
|
||||
|
||||
type CircularProgressProps = {
|
||||
percentage: number;
|
||||
children: React.ReactNode;
|
||||
isVisible?: boolean;
|
||||
isActive?: boolean;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export function CircularProgress(props: CircularProgressProps) {
|
||||
const {
|
||||
percentage,
|
||||
children,
|
||||
isVisible = true,
|
||||
isActive = false,
|
||||
isLoading = false,
|
||||
} = props;
|
||||
|
||||
const circumference = 2 * Math.PI * 13;
|
||||
const strokeDasharray = `${circumference}`;
|
||||
const strokeDashoffset = circumference - (percentage / 100) * circumference;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-[28px] w-[28px] flex-shrink-0 items-center justify-center">
|
||||
{isVisible && !isLoading && (
|
||||
<svg className="absolute h-full w-full -rotate-90">
|
||||
<circle
|
||||
cx="14"
|
||||
cy="14"
|
||||
r="13"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
fill="none"
|
||||
className={cn('text-gray-400/70', {
|
||||
'text-black': isActive,
|
||||
})}
|
||||
style={{
|
||||
strokeDasharray,
|
||||
strokeDashoffset,
|
||||
transition: 'stroke-dashoffset 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{!isLoading && children}
|
||||
{isLoading && <ChapterNumberSkeleton />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
src/components/GenerateCourse/FineTuneCourse.tsx
Normal file
103
src/components/GenerateCourse/FineTuneCourse.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useState } from 'react';
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
type QuestionProps = {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
autoFocus?: boolean;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
function Question(props: QuestionProps) {
|
||||
const { label, placeholder, value, onChange, autoFocus = false } = props;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<label className="border-y bg-gray-100 px-4 py-2.5 text-sm font-medium text-gray-700">
|
||||
{label}
|
||||
</label>
|
||||
<textarea
|
||||
placeholder={placeholder}
|
||||
className="min-h-[80px] w-full resize-none px-4 py-3 text-gray-700 placeholder:text-gray-400 focus:outline-none"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type FineTuneCourseProps = {
|
||||
hasFineTuneData: boolean;
|
||||
about: string;
|
||||
goal: string;
|
||||
customInstructions: string;
|
||||
|
||||
setHasFineTuneData: (hasMetadata: boolean) => void;
|
||||
setAbout: (about: string) => void;
|
||||
setGoal: (goal: string) => void;
|
||||
setCustomInstructions: (customInstructions: string) => void;
|
||||
};
|
||||
|
||||
export function FineTuneCourse(props: FineTuneCourseProps) {
|
||||
const {
|
||||
about,
|
||||
goal,
|
||||
customInstructions,
|
||||
hasFineTuneData,
|
||||
setAbout,
|
||||
setGoal,
|
||||
setCustomInstructions,
|
||||
setHasFineTuneData,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden rounded-lg border border-gray-200 transition-all">
|
||||
<label
|
||||
className={cn(
|
||||
'group flex cursor-pointer select-none flex-row items-center gap-2.5 px-4 py-3 text-left text-gray-500 transition-colors hover:bg-gray-100 focus:outline-none',
|
||||
hasFineTuneData && 'bg-gray-100',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
id="fine-tune-checkbox"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 group-hover:fill-current"
|
||||
checked={hasFineTuneData}
|
||||
onChange={() => {
|
||||
setHasFineTuneData(!hasFineTuneData);
|
||||
}}
|
||||
/>
|
||||
Tell us more to tailor the course (optional){' '}
|
||||
<span className="ml-auto rounded-md bg-gray-400 px-2 py-0.5 text-xs text-white">
|
||||
recommended
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{hasFineTuneData && (
|
||||
<div className="mt-0 flex flex-col">
|
||||
<Question
|
||||
label="Tell us about your self"
|
||||
placeholder="e.g. I am a frontend developer and have good knowledge of HTML, CSS, and JavaScript."
|
||||
value={about}
|
||||
onChange={setAbout}
|
||||
autoFocus={true}
|
||||
/>
|
||||
<Question
|
||||
label="What is your goal with this course?"
|
||||
placeholder="e.g. I want to be able to build Node.js APIs with Express.js and MongoDB."
|
||||
value={goal}
|
||||
onChange={setGoal}
|
||||
/>
|
||||
<Question
|
||||
label="Custom Instructions (Optional)"
|
||||
placeholder="Give additional instructions to the AI as if you were giving them to a friend."
|
||||
value={customInstructions}
|
||||
onChange={setCustomInstructions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
166
src/components/GenerateCourse/GenerateAICourse.tsx
Normal file
166
src/components/GenerateCourse/GenerateAICourse.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getUrlParams } from '../../lib/browser';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { getCourseFineTuneData, type AiCourse } from '../../lib/ai';
|
||||
import { AICourseContent } from './AICourseContent';
|
||||
import { generateCourse } from '../../helper/generate-ai-course';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getAiCourseOptions } from '../../queries/ai-course';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
|
||||
type GenerateAICourseProps = {};
|
||||
|
||||
export function GenerateAICourse(props: GenerateAICourseProps) {
|
||||
const [term, setTerm] = useState('');
|
||||
const [difficulty, setDifficulty] = useState('');
|
||||
const [sessionId, setSessionId] = useState('');
|
||||
const [goal, setGoal] = useState('');
|
||||
const [about, setAbout] = useState('');
|
||||
const [customInstructions, setCustomInstructions] = useState('');
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [courseId, setCourseId] = useState('');
|
||||
const [courseSlug, setCourseSlug] = useState('');
|
||||
const [course, setCourse] = useState<AiCourse>({
|
||||
title: '',
|
||||
modules: [],
|
||||
difficulty: '',
|
||||
done: [],
|
||||
});
|
||||
|
||||
// Once the course is generated, we fetch the course from the database
|
||||
// so that we get the up-to-date course data and also so that we
|
||||
// can reload the changes (e.g. progress) etc using queryClient.setQueryData
|
||||
const { data: aiCourse } = useQuery(
|
||||
getAiCourseOptions({ aiCourseSlug: courseSlug }),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (aiCourse) {
|
||||
setCourse(aiCourse);
|
||||
}
|
||||
}, [aiCourse]);
|
||||
|
||||
useEffect(() => {
|
||||
if (term || difficulty) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = getUrlParams();
|
||||
const paramsTerm = params?.term;
|
||||
const paramsDifficulty = params?.difficulty;
|
||||
if (!paramsTerm || !paramsDifficulty) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTerm(paramsTerm);
|
||||
setDifficulty(paramsDifficulty);
|
||||
|
||||
const sessionId = params?.id;
|
||||
setSessionId(sessionId);
|
||||
|
||||
let paramsGoal = '';
|
||||
let paramsAbout = '';
|
||||
let paramsCustomInstructions = '';
|
||||
|
||||
if (sessionId) {
|
||||
const fineTuneData = getCourseFineTuneData(sessionId);
|
||||
if (fineTuneData) {
|
||||
paramsGoal = fineTuneData.goal;
|
||||
paramsAbout = fineTuneData.about;
|
||||
paramsCustomInstructions = fineTuneData.customInstructions;
|
||||
|
||||
setGoal(paramsGoal);
|
||||
setAbout(paramsAbout);
|
||||
setCustomInstructions(paramsCustomInstructions);
|
||||
}
|
||||
}
|
||||
|
||||
handleGenerateCourse({
|
||||
term: paramsTerm,
|
||||
difficulty: paramsDifficulty,
|
||||
instructions: paramsCustomInstructions,
|
||||
goal: paramsGoal,
|
||||
about: paramsAbout,
|
||||
});
|
||||
}, [term, difficulty]);
|
||||
|
||||
const handleGenerateCourse = async (options: {
|
||||
term: string;
|
||||
difficulty: string;
|
||||
instructions?: string;
|
||||
goal?: string;
|
||||
about?: string;
|
||||
isForce?: boolean;
|
||||
prompt?: string;
|
||||
}) => {
|
||||
const { term, difficulty, isForce, prompt, instructions, goal, about } =
|
||||
options;
|
||||
|
||||
if (!isLoggedIn()) {
|
||||
window.location.href = '/ai-tutor';
|
||||
return;
|
||||
}
|
||||
|
||||
await generateCourse({
|
||||
term,
|
||||
difficulty,
|
||||
slug: courseSlug,
|
||||
onCourseIdChange: setCourseId,
|
||||
onCourseSlugChange: setCourseSlug,
|
||||
onCourseChange: setCourse,
|
||||
onLoadingChange: setIsLoading,
|
||||
onError: setError,
|
||||
instructions,
|
||||
goal,
|
||||
about,
|
||||
isForce,
|
||||
prompt,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handlePopState = (e: PopStateEvent) => {
|
||||
const { courseId, courseSlug, term, difficulty } = e.state || {};
|
||||
if (!courseId || !courseSlug) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
setCourseId(courseId);
|
||||
setCourseSlug(courseSlug);
|
||||
setTerm(term);
|
||||
setDifficulty(difficulty);
|
||||
|
||||
setIsLoading(true);
|
||||
handleGenerateCourse({ term, difficulty }).finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => {
|
||||
window.removeEventListener('popstate', handlePopState);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AICourseContent
|
||||
courseSlug={courseSlug}
|
||||
course={course}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
onRegenerateOutline={(prompt) => {
|
||||
handleGenerateCourse({
|
||||
term,
|
||||
difficulty,
|
||||
isForce: true,
|
||||
prompt,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
107
src/components/GenerateCourse/GetAICourse.tsx
Normal file
107
src/components/GenerateCourse/GetAICourse.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getAiCourseOptions } from '../../queries/ai-course';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AICourseContent } from './AICourseContent';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { generateCourse } from '../../helper/generate-ai-course';
|
||||
|
||||
type GetAICourseProps = {
|
||||
courseSlug: string;
|
||||
};
|
||||
|
||||
export function GetAICourse(props: GetAICourseProps) {
|
||||
const { courseSlug } = props;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const { data: aiCourse, error: queryError } = useQuery(
|
||||
{
|
||||
...getAiCourseOptions({ aiCourseSlug: courseSlug }),
|
||||
enabled: !!courseSlug && !!isLoggedIn(),
|
||||
},
|
||||
queryClient,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) {
|
||||
window.location.href = '/ai-tutor';
|
||||
}
|
||||
}, [isLoggedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!aiCourse) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}, [aiCourse]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!queryError) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
setError(queryError.message);
|
||||
}, [queryError]);
|
||||
|
||||
const handleRegenerateCourse = async (prompt?: string) => {
|
||||
if (!aiCourse) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.setQueryData(
|
||||
getAiCourseOptions({ aiCourseSlug: courseSlug }).queryKey,
|
||||
{
|
||||
...aiCourse,
|
||||
title: '',
|
||||
difficulty: '',
|
||||
modules: [],
|
||||
},
|
||||
);
|
||||
|
||||
await generateCourse({
|
||||
term: aiCourse.keyword,
|
||||
difficulty: aiCourse.difficulty,
|
||||
slug: courseSlug,
|
||||
prompt,
|
||||
onCourseChange: (course, rawData) => {
|
||||
queryClient.setQueryData(
|
||||
getAiCourseOptions({ aiCourseSlug: courseSlug }).queryKey,
|
||||
{
|
||||
...aiCourse,
|
||||
title: course.title,
|
||||
difficulty: course.difficulty,
|
||||
modules: course.modules,
|
||||
},
|
||||
);
|
||||
},
|
||||
onLoadingChange: (isNewLoading) => {
|
||||
setIsRegenerating(isNewLoading);
|
||||
if (!isNewLoading) {
|
||||
// TODO: Update progress
|
||||
}
|
||||
},
|
||||
onError: setError,
|
||||
isForce: true,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AICourseContent
|
||||
course={{
|
||||
title: aiCourse?.title || '',
|
||||
modules: aiCourse?.modules || [],
|
||||
difficulty: aiCourse?.difficulty || 'Easy',
|
||||
done: aiCourse?.done || [],
|
||||
}}
|
||||
isLoading={isLoading || isRegenerating}
|
||||
courseSlug={courseSlug}
|
||||
error={error}
|
||||
onRegenerateOutline={handleRegenerateCourse}
|
||||
/>
|
||||
);
|
||||
}
|
||||
69
src/components/GenerateCourse/ModifyCoursePrompt.tsx
Normal file
69
src/components/GenerateCourse/ModifyCoursePrompt.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useState } from 'react';
|
||||
import { Modal } from '../Modal';
|
||||
|
||||
export type ModifyCoursePromptProps = {
|
||||
onClose: () => void;
|
||||
onSubmit: (prompt: string) => void;
|
||||
};
|
||||
|
||||
export function ModifyCoursePrompt(props: ModifyCoursePromptProps) {
|
||||
const { onClose, onSubmit } = props;
|
||||
|
||||
const [prompt, setPrompt] = useState('');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
onSubmit(prompt);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={onClose}
|
||||
wrapperClassName="rounded-xl max-w-xl w-full h-auto"
|
||||
bodyClassName="p-6"
|
||||
overlayClassName="items-start md:items-center"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 className="mb-2 text-left text-xl font-semibold">
|
||||
Give AI more context
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Pass additional information to the AI to generate a course outline.
|
||||
</p>
|
||||
</div>
|
||||
<form className="flex flex-col gap-2" onSubmit={handleSubmit}>
|
||||
<textarea
|
||||
id="prompt"
|
||||
autoFocus
|
||||
rows={3}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-200 p-2 placeholder:text-sm focus:outline-black"
|
||||
placeholder="e.g. make sure to add a section on React hooks"
|
||||
/>
|
||||
|
||||
<p className="text-sm text-gray-500">
|
||||
Complete the sentence: "I want AI to..."
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
className="rounded-md bg-gray-200 px-4 py-2.5 text-sm text-black hover:opacity-80"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!prompt.trim()}
|
||||
className="rounded-md bg-black px-4 py-2.5 text-sm text-white hover:opacity-80 disabled:opacity-50"
|
||||
>
|
||||
Modify Prompt
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
88
src/components/GenerateCourse/RegenerateLesson.tsx
Normal file
88
src/components/GenerateCourse/RegenerateLesson.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { PenSquare, RefreshCcw } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
import { ModifyCoursePrompt } from './ModifyCoursePrompt';
|
||||
|
||||
type RegenerateLessonProps = {
|
||||
onRegenerateLesson: (prompt?: string) => void;
|
||||
};
|
||||
|
||||
export function RegenerateLesson(props: RegenerateLessonProps) {
|
||||
const { onRegenerateLesson } = props;
|
||||
|
||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
|
||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
||||
const [showPromptModal, setShowPromptModal] = useState(false);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useOutsideClick(ref, () => setIsDropdownVisible(false));
|
||||
|
||||
return (
|
||||
<>
|
||||
{showUpgradeModal && (
|
||||
<UpgradeAccountModal
|
||||
onClose={() => {
|
||||
setShowUpgradeModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showPromptModal && (
|
||||
<ModifyCoursePrompt
|
||||
title="Give AI more context"
|
||||
description="Pass additional information to the AI to generate a lesson."
|
||||
onClose={() => setShowPromptModal(false)}
|
||||
onSubmit={(prompt) => {
|
||||
setShowPromptModal(false);
|
||||
onRegenerateLesson(prompt);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative mr-2 flex items-center" ref={ref}>
|
||||
<button
|
||||
className={cn('rounded-full p-1 text-gray-400 hover:text-black', {
|
||||
'text-black': isDropdownVisible,
|
||||
})}
|
||||
onClick={() => setIsDropdownVisible(!isDropdownVisible)}
|
||||
>
|
||||
<PenSquare className="text-current" size={16} strokeWidth={2.5} />
|
||||
</button>
|
||||
{isDropdownVisible && (
|
||||
<div className="absolute right-0 top-full min-w-[170px] overflow-hidden rounded-md border border-gray-200 bg-white">
|
||||
<button
|
||||
onClick={() => {
|
||||
onRegenerateLesson();
|
||||
}}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
<RefreshCcw
|
||||
size={16}
|
||||
className="text-gray-400"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
Regenerate
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDropdownVisible(false);
|
||||
setShowPromptModal(true);
|
||||
}}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
<PenSquare
|
||||
size={16}
|
||||
className="text-gray-400"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
Modify Prompt
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
86
src/components/GenerateCourse/RegenerateOutline.tsx
Normal file
86
src/components/GenerateCourse/RegenerateOutline.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { PenSquare, RefreshCcw } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
import { ModifyCoursePrompt } from './ModifyCoursePrompt';
|
||||
|
||||
type RegenerateOutlineProps = {
|
||||
onRegenerateOutline: (prompt?: string) => void;
|
||||
};
|
||||
|
||||
export function RegenerateOutline(props: RegenerateOutlineProps) {
|
||||
const { onRegenerateOutline } = props;
|
||||
|
||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
|
||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
||||
const [showPromptModal, setShowPromptModal] = useState(false);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useOutsideClick(ref, () => setIsDropdownVisible(false));
|
||||
|
||||
return (
|
||||
<>
|
||||
{showUpgradeModal && (
|
||||
<UpgradeAccountModal
|
||||
onClose={() => {
|
||||
setShowUpgradeModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showPromptModal && (
|
||||
<ModifyCoursePrompt
|
||||
onClose={() => setShowPromptModal(false)}
|
||||
onSubmit={(prompt) => {
|
||||
setShowPromptModal(false);
|
||||
onRegenerateOutline(prompt);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="absolute right-3 top-3" ref={ref}>
|
||||
<button
|
||||
className={cn('text-gray-400 hover:text-black', {
|
||||
'text-black': isDropdownVisible,
|
||||
})}
|
||||
onClick={() => setIsDropdownVisible(!isDropdownVisible)}
|
||||
>
|
||||
<PenSquare className="text-current" size={16} strokeWidth={2.5} />
|
||||
</button>
|
||||
{isDropdownVisible && (
|
||||
<div className="absolute right-0 top-full min-w-[170px] overflow-hidden rounded-md border border-gray-200 bg-white">
|
||||
<button
|
||||
onClick={() => {
|
||||
onRegenerateOutline();
|
||||
}}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
<RefreshCcw
|
||||
size={16}
|
||||
className="text-gray-400"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
Regenerate
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDropdownVisible(false);
|
||||
setShowPromptModal(true);
|
||||
}}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
<PenSquare
|
||||
size={16}
|
||||
className="text-gray-400"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
Modify Prompt
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
195
src/components/GenerateCourse/UserCoursesList.tsx
Normal file
195
src/components/GenerateCourse/UserCoursesList.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
getAiCourseLimitOptions,
|
||||
listUserAiCoursesOptions,
|
||||
type ListUserAiCoursesQuery,
|
||||
} from '../../queries/ai-course';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { AICourseCard } from './AICourseCard';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Gift, Loader2, User2 } from 'lucide-react';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { useIsPaidUser } from '../../queries/billing';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
import { getUrlParams, setUrlParams, deleteUrlParam } from '../../lib/browser';
|
||||
import { AICourseSearch } from './AICourseSearch';
|
||||
import { Pagination } from '../Pagination/Pagination';
|
||||
|
||||
type UserCoursesListProps = {};
|
||||
|
||||
export function UserCoursesList(props: UserCoursesListProps) {
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const [showUpgradePopup, setShowUpgradePopup] = useState(false);
|
||||
|
||||
const [pageState, setPageState] = useState<ListUserAiCoursesQuery>({
|
||||
perPage: '10',
|
||||
currPage: '1',
|
||||
query: '',
|
||||
});
|
||||
|
||||
const { data: limits, isLoading: isLimitsLoading } = useQuery(
|
||||
getAiCourseLimitOptions(),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const { used, limit } = limits ?? { used: 0, limit: 0 };
|
||||
const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser();
|
||||
|
||||
const { data: userAiCourses, isFetching: isUserAiCoursesLoading } = useQuery(
|
||||
listUserAiCoursesOptions(pageState),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsInitialLoading(false);
|
||||
}, [userAiCourses]);
|
||||
|
||||
const courses = userAiCourses?.data ?? [];
|
||||
const isAuthenticated = isLoggedIn();
|
||||
const limitUsedPercentage = Math.round((used / limit) * 100);
|
||||
|
||||
useEffect(() => {
|
||||
const queryParams = getUrlParams();
|
||||
|
||||
setPageState({
|
||||
...pageState,
|
||||
currPage: queryParams?.p || '1',
|
||||
query: queryParams?.q || '',
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (pageState?.currPage !== '1' || pageState?.query !== '') {
|
||||
setUrlParams({
|
||||
p: pageState?.currPage || '1',
|
||||
q: pageState?.query || '',
|
||||
});
|
||||
} else {
|
||||
deleteUrlParam('p');
|
||||
deleteUrlParam('q');
|
||||
}
|
||||
}, [pageState]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showUpgradePopup && (
|
||||
<UpgradeAccountModal onClose={() => setShowUpgradePopup(false)} />
|
||||
)}
|
||||
<div className="mb-3 flex min-h-[35px] items-center justify-between max-sm:mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">
|
||||
<span className="max-md:hidden">Your </span>Courses
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{used > 0 && limit > 0 && !isPaidUserLoading && (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none flex items-center gap-2 opacity-0 transition-opacity',
|
||||
{
|
||||
'pointer-events-auto opacity-100': !isPaidUser,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<p className="flex items-center text-sm text-yellow-600">
|
||||
<span className="max-md:hidden">
|
||||
{limitUsedPercentage}% of daily limit used{' '}
|
||||
</span>
|
||||
<span className="inline md:hidden">
|
||||
{limitUsedPercentage}% used
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUpgradePopup(true);
|
||||
}}
|
||||
className="ml-1.5 flex items-center gap-1 rounded-full bg-yellow-600 py-0.5 pl-1.5 pr-2 text-xs text-white"
|
||||
>
|
||||
<Gift className="size-4" />
|
||||
Upgrade
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AICourseSearch
|
||||
value={pageState?.query || ''}
|
||||
onChange={(value) => {
|
||||
setPageState({
|
||||
...pageState,
|
||||
query: value,
|
||||
currPage: '1',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isInitialLoading && !isUserAiCoursesLoading && !isAuthenticated && (
|
||||
<div className="flex min-h-[152px] flex-col items-center justify-center rounded-lg border border-gray-200 bg-white px-6 py-4">
|
||||
<User2 className="mb-2 size-8 text-gray-300" />
|
||||
<p className="max-w-sm text-balance text-center text-gray-500">
|
||||
<button
|
||||
onClick={() => {
|
||||
showLoginPopup();
|
||||
}}
|
||||
className="font-medium text-black underline underline-offset-2 hover:opacity-80"
|
||||
>
|
||||
Sign up (free and takes 2s) or login
|
||||
</button>{' '}
|
||||
to generate and save courses.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isUserAiCoursesLoading && !isInitialLoading && courses.length === 0 && isAuthenticated && (
|
||||
<div className="flex min-h-[152px] items-center justify-center rounded-lg border border-gray-200 bg-white py-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
You haven't generated any courses yet.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isUserAiCoursesLoading || isInitialLoading) && (
|
||||
<div className="flex min-h-[152px] items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white py-4">
|
||||
<Loader2
|
||||
className="size-4 animate-spin text-gray-400"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
<p className="text-sm font-medium text-gray-600">Loading...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isUserAiCoursesLoading && courses && courses.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{courses.map((course) => (
|
||||
<AICourseCard key={course._id} course={course} />
|
||||
))}
|
||||
|
||||
<Pagination
|
||||
totalCount={userAiCourses?.totalCount || 0}
|
||||
totalPages={userAiCourses?.totalPages || 0}
|
||||
currPage={Number(userAiCourses?.currPage || 1)}
|
||||
perPage={Number(userAiCourses?.perPage || 10)}
|
||||
onPageChange={(page) => {
|
||||
setPageState({ ...pageState, currPage: String(page) });
|
||||
}}
|
||||
className="rounded-lg border border-gray-200 bg-white p-4"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isUserAiCoursesLoading &&
|
||||
(userAiCourses?.data?.length || 0 > 0) &&
|
||||
courses.length === 0 && (
|
||||
<div className="flex min-h-[114px] items-center justify-center rounded-lg border border-gray-200 bg-white py-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
No courses match your search.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -128,32 +128,32 @@ export function AITermSuggestionInput(props: AITermSuggestionInputProps) {
|
||||
return allRoadmaps;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedSearchValue.length === 0 || isFirstRender.current) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
// useEffect(() => {
|
||||
// if (debouncedSearchValue.length === 0 || isFirstRender.current) {
|
||||
// setSearchResults([]);
|
||||
// return;
|
||||
// }
|
||||
|
||||
setIsActive(true);
|
||||
setIsLoading(true);
|
||||
loadTopAIRoadmapTerm()
|
||||
.then((results) => {
|
||||
const normalizedSearchText = debouncedSearchValue.trim().toLowerCase();
|
||||
const matchingOfficialRoadmaps = officialRoadmaps.filter((roadmap) => {
|
||||
return (
|
||||
roadmap.title.toLowerCase().indexOf(normalizedSearchText) !== -1
|
||||
);
|
||||
});
|
||||
// setIsActive(true);
|
||||
// setIsLoading(true);
|
||||
// loadTopAIRoadmapTerm()
|
||||
// .then((results) => {
|
||||
// const normalizedSearchText = debouncedSearchValue.trim().toLowerCase();
|
||||
// const matchingOfficialRoadmaps = officialRoadmaps.filter((roadmap) => {
|
||||
// return (
|
||||
// roadmap.title.toLowerCase().indexOf(normalizedSearchText) !== -1
|
||||
// );
|
||||
// });
|
||||
|
||||
setSearchResults(
|
||||
[...matchingOfficialRoadmaps, ...results]?.slice(0, 5) || [],
|
||||
);
|
||||
setActiveCounter(0);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [debouncedSearchValue]);
|
||||
// setSearchResults(
|
||||
// [...matchingOfficialRoadmaps, ...results]?.slice(0, 5) || [],
|
||||
// );
|
||||
// setActiveCounter(0);
|
||||
// })
|
||||
// .finally(() => {
|
||||
// setIsLoading(false);
|
||||
// });
|
||||
// }, [debouncedSearchValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
|
||||
@@ -11,7 +11,6 @@ import { useToast } from '../../hooks/use-toast';
|
||||
import { generateAIRoadmapFromText } from '../../../editor/utils/roadmap-generator';
|
||||
import { renderFlowJSON } from '../../../editor/renderer/renderer';
|
||||
import { replaceChildren } from '../../lib/dom';
|
||||
import { readAIRoadmapStream } from '../../helper/read-stream';
|
||||
import {
|
||||
getOpenAIKey,
|
||||
isLoggedIn,
|
||||
@@ -31,7 +30,7 @@ import { showLoginPopup } from '../../lib/popup.ts';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import { RoadmapTopicDetail } from './RoadmapTopicDetail.tsx';
|
||||
import { AIRoadmapAlert } from './AIRoadmapAlert.tsx';
|
||||
import { IS_KEY_ONLY_ROADMAP_GENERATION } from '../../lib/ai.ts';
|
||||
import { IS_KEY_ONLY_ROADMAP_GENERATION, readAIRoadmapStream } from '../../lib/ai.ts';
|
||||
import { AITermSuggestionInput } from './AITermSuggestionInput.tsx';
|
||||
import { IncreaseRoadmapLimit } from './IncreaseRoadmapLimit.tsx';
|
||||
import { AuthenticationForm } from '../AuthenticationFlow/AuthenticationForm.tsx';
|
||||
|
||||
@@ -3,13 +3,13 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { markdownToHtml } from '../../lib/markdown';
|
||||
import { Ban, Cog, Contact, FileText, User, UserRound, X } from 'lucide-react';
|
||||
import { Ban, Cog, Contact, FileText, X } from 'lucide-react';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import type { RoadmapNodeDetails } from './GenerateRoadmap';
|
||||
import { getOpenAIKey, isLoggedIn, removeAuthToken } from '../../lib/jwt';
|
||||
import { readAIRoadmapContentStream } from '../../helper/read-stream';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { readAIRoadmapContentStream } from '../../lib/ai';
|
||||
|
||||
type RoadmapTopicDetailProps = RoadmapNodeDetails & {
|
||||
onClose?: () => void;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
type RelatedGuidesProps = {
|
||||
relatedTitle?: string;
|
||||
@@ -27,7 +27,7 @@ export function RelatedGuides(props: RelatedGuidesProps) {
|
||||
<div className={cn('relative min-w-[250px] pt-0 lg:px-5 lg:pt-10')}>
|
||||
<h4 className="text-lg font-medium max-lg:hidden">{relatedTitle}</h4>
|
||||
<button
|
||||
className="flex border-b w-full items-center justify-between gap-2 bg-gray-300 px-3 py-2 text-sm font-medium lg:hidden"
|
||||
className="flex w-full items-center justify-between gap-2 border-b bg-gray-300 px-3 py-2 text-sm font-medium lg:hidden"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
{relatedTitle}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
---
|
||||
import type { GuideFileType, GuideFrontmatter } from '../lib/guide';
|
||||
import { type QuestionGroupType } from '../lib/question-group';
|
||||
|
||||
export interface Props {
|
||||
guide: GuideFileType | QuestionGroupType;
|
||||
}
|
||||
|
||||
function isQuestionGroupType(
|
||||
guide: GuideFileType | QuestionGroupType,
|
||||
): guide is QuestionGroupType {
|
||||
return (guide as QuestionGroupType).questions !== undefined;
|
||||
}
|
||||
|
||||
const { guide } = Astro.props;
|
||||
const { frontmatter, id } = guide;
|
||||
|
||||
let pageUrl = '';
|
||||
let guideType = '';
|
||||
|
||||
if (isQuestionGroupType(guide)) {
|
||||
pageUrl = `/questions/${id}`;
|
||||
guideType = 'Questions';
|
||||
} else {
|
||||
const excludedBySlug = (frontmatter as GuideFrontmatter).excludedBySlug;
|
||||
pageUrl = excludedBySlug ? excludedBySlug : `/guides/${id}`;
|
||||
guideType = (frontmatter as GuideFrontmatter).type;
|
||||
}
|
||||
---
|
||||
|
||||
<a
|
||||
class:list={[
|
||||
'text-md group block flex items-center justify-between border-b py-2 text-gray-600 no-underline hover:text-blue-600',
|
||||
]}
|
||||
href={pageUrl}
|
||||
>
|
||||
<span
|
||||
class='text-sm transition-transform group-hover:translate-x-2 md:text-base'
|
||||
>
|
||||
{frontmatter.title}
|
||||
|
||||
{
|
||||
frontmatter.isNew && (
|
||||
<span class='ml-1.5 rounded-sm bg-green-300 px-1.5 py-0.5 text-xs font-medium uppercase text-green-900'>
|
||||
New
|
||||
<span class='hidden sm:inline'>
|
||||
·
|
||||
{new Date(frontmatter.date || '').toLocaleString('default', {
|
||||
month: 'long',
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</span>
|
||||
<span class='hidden text-xs capitalize text-gray-500 sm:block'>
|
||||
{guideType}
|
||||
</span>
|
||||
|
||||
<span class='block text-xs text-gray-400 sm:hidden'> »</span>
|
||||
</a>
|
||||
@@ -1,164 +1,229 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { EmptyProgress } from './EmptyProgress';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { HeroRoadmaps, type HeroTeamRoadmaps } from './HeroRoadmaps';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import type { AllowedMemberRoles } from '../ShareOptions/ShareTeamMemberList.tsx';
|
||||
import {
|
||||
FolderKanban,
|
||||
MapIcon,
|
||||
Plus,
|
||||
Sparkle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Square,
|
||||
SquareCheckBig,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions.tsx';
|
||||
import { CheckIcon } from '../ReactIcons/CheckIcon.tsx';
|
||||
import type { UserProgress } from '../TeamProgress/TeamProgressPage.tsx';
|
||||
import { HeroProject } from './HeroProject';
|
||||
import { HeroRoadmap } from './HeroRoadmap';
|
||||
import { CreateRoadmapButton } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapButton.tsx';
|
||||
import { HeroItemsGroup } from './HeroItemsGroup';
|
||||
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
|
||||
|
||||
export type UserProgressResponse = {
|
||||
resourceId: string;
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
resourceTitle: string;
|
||||
isFavorite: boolean;
|
||||
done: number;
|
||||
learning: number;
|
||||
skipped: number;
|
||||
total: number;
|
||||
updatedAt: Date;
|
||||
isCustomResource: boolean;
|
||||
roadmapSlug?: string;
|
||||
team?: {
|
||||
name: string;
|
||||
id: string;
|
||||
role: AllowedMemberRoles;
|
||||
};
|
||||
}[];
|
||||
export type AIRoadmapType = {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
function renderProgress(progressList: UserProgressResponse) {
|
||||
progressList.forEach((progress) => {
|
||||
const href =
|
||||
progress.resourceType === 'best-practice'
|
||||
? `/best-practices/${progress.resourceId}`
|
||||
: `/${progress.resourceId}`;
|
||||
const element = document.querySelector(`a[href="${href}"]`);
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
type FavoriteRoadmapsProps = {
|
||||
progress: UserProgress[];
|
||||
projects: (ProjectStatusDocument & {
|
||||
title: string;
|
||||
})[];
|
||||
customRoadmaps: UserProgress[];
|
||||
aiRoadmaps: AIRoadmapType[];
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('mark-favorite', {
|
||||
detail: {
|
||||
resourceId: progress.resourceId,
|
||||
resourceType: progress.resourceType,
|
||||
isFavorite: progress.isFavorite,
|
||||
},
|
||||
}),
|
||||
);
|
||||
export function FavoriteRoadmaps(props: FavoriteRoadmapsProps) {
|
||||
const { progress, isLoading, customRoadmaps, aiRoadmaps, projects } = props;
|
||||
const [showCompleted, setShowCompleted] = useState(false);
|
||||
const [isCreatingCustomRoadmap, setIsCreatingCustomRoadmap] = useState(false);
|
||||
|
||||
const totalDone = progress.done + progress.skipped;
|
||||
const percentageDone = (totalDone / progress.total) * 100;
|
||||
|
||||
const progressBar: HTMLElement | null =
|
||||
element.querySelector('[data-progress]');
|
||||
if (progressBar) {
|
||||
progressBar.style.width = `${percentageDone}%`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type ProgressResponse = UserProgressResponse;
|
||||
|
||||
export function FavoriteRoadmaps() {
|
||||
const isAuthenticated = isLoggedIn();
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [isPreparing, setIsPreparing] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [progress, setProgress] = useState<ProgressResponse>([]);
|
||||
const [containerOpacity, setContainerOpacity] = useState(0);
|
||||
|
||||
function showProgressContainer() {
|
||||
const heroEl = document.getElementById('hero-text')!;
|
||||
if (!heroEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
heroEl.classList.add('opacity-0');
|
||||
setTimeout(() => {
|
||||
heroEl.parentElement?.removeChild(heroEl);
|
||||
setIsPreparing(false);
|
||||
|
||||
setTimeout(() => {
|
||||
setContainerOpacity(100);
|
||||
}, 50);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async function loadProgress() {
|
||||
setIsLoading(true);
|
||||
|
||||
const { response: progressList, error } = await httpGet<ProgressResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-hero-roadmaps`,
|
||||
);
|
||||
|
||||
if (error || !progressList) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProgress(progressList);
|
||||
setIsLoading(false);
|
||||
showProgressContainer();
|
||||
|
||||
// render progress on featured items
|
||||
renderProgress(progressList);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadProgress().finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('refresh-favorites', loadProgress);
|
||||
return () => window.removeEventListener('refresh-favorites', loadProgress);
|
||||
}, []);
|
||||
|
||||
if (isPreparing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasProgress = progress?.length > 0;
|
||||
const customRoadmaps = progress?.filter(
|
||||
(p) => p.isCustomResource && !p.team?.name,
|
||||
const completedProjects = projects.filter(
|
||||
(project) => project.submittedAt && project.repositoryUrl,
|
||||
);
|
||||
const inProgressProjects = projects.filter(
|
||||
(project) => !project.submittedAt || !project.repositoryUrl,
|
||||
);
|
||||
const defaultRoadmaps = progress?.filter((p) => !p.isCustomResource);
|
||||
const teamRoadmaps: HeroTeamRoadmaps = progress
|
||||
?.filter((p) => p.isCustomResource && p.team?.name)
|
||||
.reduce((acc: HeroTeamRoadmaps, curr) => {
|
||||
const currTeam = curr.team!;
|
||||
if (!acc[currTeam.name]) {
|
||||
acc[currTeam.name] = [];
|
||||
}
|
||||
|
||||
acc[currTeam.name].push(curr);
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
const projectsToShow = [
|
||||
...inProgressProjects,
|
||||
...(showCompleted ? completedProjects : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`transition-opacity duration-500 opacity-${containerOpacity}`}
|
||||
>
|
||||
<div
|
||||
className={`flex min-h-[192px] bg-gradient-to-b sm:min-h-[280px] ${
|
||||
hasProgress && `border-t border-t-[#1e293c]`
|
||||
}`}
|
||||
<div className="flex flex-col">
|
||||
{isCreatingCustomRoadmap && (
|
||||
<CreateRoadmapModal
|
||||
onClose={() => {
|
||||
setIsCreatingCustomRoadmap(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<HeroItemsGroup
|
||||
icon={<CheckIcon additionalClasses="mr-1.5 h-[14px] w-[14px]" />}
|
||||
isLoading={isLoading}
|
||||
title="Your progress and bookmarks"
|
||||
isEmpty={!isLoading && progress.length === 0}
|
||||
emptyTitle={
|
||||
<>
|
||||
No bookmarks found
|
||||
<a
|
||||
href="#role-based-roadmaps"
|
||||
className="ml-1.5 inline-flex items-center gap-1 font-medium text-blue-500 underline-offset-2 hover:underline"
|
||||
>
|
||||
<SquareCheckBig className="size-3.5" strokeWidth={2.5} />
|
||||
Bookmark a roadmap
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="container min-h-full">
|
||||
{!isLoading && progress?.length == 0 && <EmptyProgress />}
|
||||
{hasProgress && (
|
||||
<HeroRoadmaps
|
||||
teamRoadmaps={teamRoadmaps}
|
||||
customRoadmaps={customRoadmaps}
|
||||
progress={defaultRoadmaps}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{progress.map((resource) => (
|
||||
<HeroRoadmap
|
||||
key={`${resource.resourceType}-${resource.resourceId}`}
|
||||
resourceId={resource.resourceId}
|
||||
resourceType={resource.resourceType}
|
||||
resourceTitle={resource.resourceTitle}
|
||||
isFavorite={resource.isFavorite}
|
||||
percentageDone={
|
||||
((resource.skipped + resource.done) / resource.total) * 100
|
||||
}
|
||||
url={
|
||||
resource.resourceType === 'roadmap'
|
||||
? `/${resource.resourceId}`
|
||||
: `/best-practices/${resource.resourceId}`
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</HeroItemsGroup>
|
||||
|
||||
<HeroItemsGroup
|
||||
icon={<MapIcon className="mr-1.5 h-[14px] w-[14px]" />}
|
||||
isLoading={isLoading}
|
||||
title="Your custom roadmaps"
|
||||
isEmpty={!isLoading && customRoadmaps.length === 0}
|
||||
emptyTitle={
|
||||
<>
|
||||
No custom roadmaps found
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsCreatingCustomRoadmap(true);
|
||||
}}
|
||||
className="ml-1.5 inline-flex items-center gap-1 font-medium text-blue-500 underline-offset-2 hover:underline"
|
||||
>
|
||||
<SquareCheckBig className="size-3.5" strokeWidth={2.5} />
|
||||
Create custom roadmap
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{customRoadmaps.map((customRoadmap) => (
|
||||
<HeroRoadmap
|
||||
key={customRoadmap.resourceId}
|
||||
resourceId={customRoadmap.resourceId}
|
||||
resourceType={'roadmap'}
|
||||
resourceTitle={customRoadmap.resourceTitle}
|
||||
percentageDone={
|
||||
((customRoadmap.skipped + customRoadmap.done) /
|
||||
customRoadmap.total) *
|
||||
100
|
||||
}
|
||||
url={`/r/${customRoadmap?.roadmapSlug}`}
|
||||
allowFavorite={false}
|
||||
/>
|
||||
))}
|
||||
<CreateRoadmapButton />
|
||||
</HeroItemsGroup>
|
||||
|
||||
<HeroItemsGroup
|
||||
icon={<Sparkle className="mr-1.5 h-[14px] w-[14px]" />}
|
||||
isLoading={isLoading}
|
||||
title="Your AI roadmaps"
|
||||
isEmpty={!isLoading && aiRoadmaps.length === 0}
|
||||
emptyTitle={
|
||||
<>
|
||||
No AI roadmaps found
|
||||
<a
|
||||
href="/ai"
|
||||
className="ml-1.5 inline-flex items-center gap-1 font-medium text-blue-500 underline-offset-2 hover:underline"
|
||||
>
|
||||
<SquareCheckBig className="size-3.5" strokeWidth={2.5} />
|
||||
Generate AI roadmap
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{aiRoadmaps.map((aiRoadmap) => (
|
||||
<HeroRoadmap
|
||||
key={aiRoadmap.id}
|
||||
resourceId={aiRoadmap.id}
|
||||
resourceType={'roadmap'}
|
||||
resourceTitle={aiRoadmap.title}
|
||||
url={`/ai/${aiRoadmap.slug}`}
|
||||
percentageDone={0}
|
||||
allowFavorite={false}
|
||||
isTrackable={false}
|
||||
/>
|
||||
))}
|
||||
|
||||
<a
|
||||
href="/ai"
|
||||
className={
|
||||
'flex h-full w-full items-center justify-center gap-1 overflow-hidden rounded-md border border-dashed border-gray-800 p-3 text-sm text-gray-400 hover:border-gray-600 hover:bg-gray-900 hover:text-gray-300'
|
||||
}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Generate New
|
||||
</a>
|
||||
</HeroItemsGroup>
|
||||
|
||||
<HeroItemsGroup
|
||||
icon={<FolderKanban className="mr-1.5 h-[14px] w-[14px]" />}
|
||||
isLoading={isLoading}
|
||||
title="Your active projects"
|
||||
isEmpty={!isLoading && projectsToShow.length === 0}
|
||||
emptyTitle={
|
||||
<>
|
||||
No active projects found
|
||||
<a
|
||||
href="/projects"
|
||||
className="ml-1.5 inline-flex items-center gap-1 font-medium text-blue-500 underline-offset-2 hover:underline"
|
||||
>
|
||||
<SquareCheckBig className="size-3.5" strokeWidth={2.5} />
|
||||
Start a new project
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
rightContent={
|
||||
completedProjects.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowCompleted(!showCompleted)}
|
||||
className="hidden items-center gap-2 rounded-md text-xs text-slate-400 hover:text-slate-300 sm:flex"
|
||||
>
|
||||
{showCompleted ? (
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{completedProjects.length} Completed
|
||||
</button>
|
||||
)
|
||||
}
|
||||
className="border-b-0"
|
||||
>
|
||||
{projectsToShow.map((project) => (
|
||||
<HeroProject key={project._id} project={project} />
|
||||
))}
|
||||
|
||||
<a
|
||||
href="/projects"
|
||||
className="flex min-h-[80px] items-center justify-center gap-2 rounded-md border border-dashed border-slate-800 p-4 text-sm text-slate-400 hover:border-slate-600 hover:bg-slate-900/50 hover:text-slate-300"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Start a new project
|
||||
</a>
|
||||
</HeroItemsGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
78
src/components/HeroSection/HeroItemsGroup.tsx
Normal file
78
src/components/HeroSection/HeroItemsGroup.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { HeroTitle } from './HeroTitle';
|
||||
|
||||
type HeroItemsGroupProps = {
|
||||
icon: any;
|
||||
isLoading?: boolean;
|
||||
isEmpty?: boolean;
|
||||
emptyTitle?: ReactNode;
|
||||
title: string | ReactNode;
|
||||
rightContent?: ReactNode;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function HeroItemsGroup(props: HeroItemsGroupProps) {
|
||||
const {
|
||||
icon,
|
||||
isLoading = false,
|
||||
isEmpty = false,
|
||||
emptyTitle,
|
||||
title,
|
||||
rightContent,
|
||||
children,
|
||||
className,
|
||||
} = props;
|
||||
|
||||
const storageKey = `hero-group-${title}-collapsed`;
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
|
||||
function isCollapsedByStorage() {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
|
||||
return stored === 'true';
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setIsCollapsed(isCollapsedByStorage());
|
||||
}, [isLoading]);
|
||||
|
||||
const isLoadingOrCollapsedOrEmpty = isLoading || isCollapsed || isEmpty;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-b border-gray-800/50',
|
||||
{
|
||||
'py-4': !isLoadingOrCollapsedOrEmpty,
|
||||
'py-4 ': isLoadingOrCollapsedOrEmpty,
|
||||
'opacity-50 transition-opacity hover:opacity-100':
|
||||
isCollapsed && !isLoading,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="container">
|
||||
<HeroTitle
|
||||
icon={icon}
|
||||
isLoading={isLoading}
|
||||
isEmpty={isEmpty}
|
||||
emptyTitle={emptyTitle}
|
||||
title={title}
|
||||
rightContent={rightContent}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleCollapse={() => {
|
||||
setIsCollapsed(!isCollapsed);
|
||||
localStorage.setItem(storageKey, (!isCollapsed).toString());
|
||||
}}
|
||||
/>
|
||||
{!isLoadingOrCollapsedOrEmpty && (
|
||||
<div className="mt-4 grid grid-cols-1 gap-2.5 sm:grid-cols-2 md:grid-cols-3">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
src/components/HeroSection/HeroProject.tsx
Normal file
52
src/components/HeroSection/HeroProject.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { ThumbsUp } from 'lucide-react';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import { getRelativeTimeString } from '../../lib/date';
|
||||
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions.tsx';
|
||||
|
||||
type HeroProjectProps = {
|
||||
project: ProjectStatusDocument & {
|
||||
title: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function HeroProject({ project }: HeroProjectProps) {
|
||||
return (
|
||||
<a
|
||||
href={`/projects/${project.projectId}`}
|
||||
className="group relative flex flex-col justify-between gap-2 rounded-md border border-slate-800 bg-slate-900 p-3.5 hover:border-slate-600"
|
||||
>
|
||||
<div className="relative z-10 flex items-start justify-between gap-2">
|
||||
<h3 className="truncate font-medium text-slate-300 group-hover:text-slate-100">
|
||||
{project.title}
|
||||
</h3>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -right-2 -top-2 flex flex-shrink-0 items-center gap-1 rounded-full text-xs uppercase tracking-wide',
|
||||
{
|
||||
'text-green-600/50': project.submittedAt && project.repositoryUrl,
|
||||
'text-yellow-600': !project.submittedAt || !project.repositoryUrl,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{project.submittedAt && project.repositoryUrl ? 'Done' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative z-10 flex items-center gap-2 text-xs text-slate-400">
|
||||
{project.submittedAt && project.repositoryUrl && (
|
||||
<span className="flex items-center gap-1">
|
||||
<ThumbsUp className="h-3 w-3" />
|
||||
{project.upvotes}
|
||||
</span>
|
||||
)}
|
||||
{project.startedAt && (
|
||||
<span>Started {getRelativeTimeString(project.startedAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 rounded-md bg-gradient-to-br from-slate-800/50 via-transparent to-transparent" />
|
||||
{project.submittedAt && project.repositoryUrl && (
|
||||
<div className="absolute inset-0 rounded-md bg-gradient-to-br from-green-950/20 via-transparent to-transparent" />
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
74
src/components/HeroSection/HeroRoadmap.tsx
Normal file
74
src/components/HeroSection/HeroRoadmap.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import type { ResourceType } from '../../lib/resource-progress.ts';
|
||||
import { MarkFavorite } from '../FeaturedItems/MarkFavorite.tsx';
|
||||
|
||||
type ProgressRoadmapProps = {
|
||||
url: string;
|
||||
percentageDone: number;
|
||||
allowFavorite?: boolean;
|
||||
|
||||
resourceId: string;
|
||||
resourceType: ResourceType;
|
||||
resourceTitle: string;
|
||||
isFavorite?: boolean;
|
||||
|
||||
isTrackable?: boolean;
|
||||
isNew?: boolean;
|
||||
};
|
||||
|
||||
export function HeroRoadmap(props: ProgressRoadmapProps) {
|
||||
const {
|
||||
url,
|
||||
percentageDone,
|
||||
resourceType,
|
||||
resourceId,
|
||||
resourceTitle,
|
||||
isFavorite,
|
||||
allowFavorite = true,
|
||||
isTrackable = true,
|
||||
isNew = false,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
className={cn(
|
||||
'relative flex flex-col overflow-hidden rounded-md border p-3 text-sm text-slate-400 hover:text-slate-300',
|
||||
{
|
||||
'border-slate-800 bg-slate-900 hover:border-slate-600': isTrackable,
|
||||
'border-slate-700/50 bg-slate-800/50 hover:border-slate-600/70':
|
||||
!isTrackable,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<span title={resourceTitle} className="relative z-20 truncate">
|
||||
{resourceTitle}
|
||||
</span>
|
||||
|
||||
{isTrackable && (
|
||||
<span
|
||||
className="absolute bottom-0 left-0 top-0 z-10 bg-[#172a3a]"
|
||||
style={{ width: `${percentageDone}%` }}
|
||||
></span>
|
||||
)}
|
||||
|
||||
{allowFavorite && (
|
||||
<MarkFavorite
|
||||
resourceId={resourceId}
|
||||
resourceType={resourceType}
|
||||
favorite={isFavorite}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isNew && (
|
||||
<span className="absolute bottom-1.5 right-2 flex items-center rounded-br rounded-tl text-xs font-medium text-purple-300">
|
||||
<span className="mr-1.5 flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-2 w-2 animate-ping rounded-full bg-purple-400 opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-purple-500" />
|
||||
</span>
|
||||
New
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
import type { UserProgressResponse } from './FavoriteRoadmaps';
|
||||
import { CheckIcon } from '../ReactIcons/CheckIcon';
|
||||
import { MarkFavorite } from '../FeaturedItems/MarkFavorite';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import type { ResourceType } from '../../lib/resource-progress';
|
||||
import { MapIcon, Users2 } from 'lucide-react';
|
||||
import { CreateRoadmapButton } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapButton';
|
||||
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { FeatureAnnouncement } from '../FeatureAnnouncement.tsx';
|
||||
|
||||
type ProgressRoadmapProps = {
|
||||
url: string;
|
||||
percentageDone: number;
|
||||
allowFavorite?: boolean;
|
||||
|
||||
resourceId: string;
|
||||
resourceType: ResourceType;
|
||||
resourceTitle: string;
|
||||
isFavorite?: boolean;
|
||||
};
|
||||
function HeroRoadmap(props: ProgressRoadmapProps) {
|
||||
const {
|
||||
url,
|
||||
percentageDone,
|
||||
resourceType,
|
||||
resourceId,
|
||||
resourceTitle,
|
||||
isFavorite,
|
||||
allowFavorite = true,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
className="relative flex flex-col overflow-hidden rounded-md border border-slate-800 bg-slate-900 p-3 text-sm text-slate-400 hover:border-slate-600 hover:text-slate-300"
|
||||
>
|
||||
<span className="relative z-20">{resourceTitle}</span>
|
||||
|
||||
<span
|
||||
className="absolute bottom-0 left-0 top-0 z-10 bg-[#172a3a]"
|
||||
style={{ width: `${percentageDone}%` }}
|
||||
></span>
|
||||
|
||||
{allowFavorite && (
|
||||
<MarkFavorite
|
||||
resourceId={resourceId}
|
||||
resourceType={resourceType}
|
||||
favorite={isFavorite}
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
type ProgressTitleProps = {
|
||||
icon: any;
|
||||
isLoading?: boolean;
|
||||
title: string | ReactNode;
|
||||
};
|
||||
|
||||
export function HeroTitle(props: ProgressTitleProps) {
|
||||
const { isLoading = false, title, icon } = props;
|
||||
|
||||
return (
|
||||
<p className="mb-4 flex items-center text-sm text-gray-400">
|
||||
{!isLoading && icon}
|
||||
{isLoading && (
|
||||
<span className="mr-1.5">
|
||||
<Spinner />
|
||||
</span>
|
||||
)}
|
||||
{title}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
export type HeroTeamRoadmaps = Record<string, UserProgressResponse>;
|
||||
|
||||
type ProgressListProps = {
|
||||
progress: UserProgressResponse;
|
||||
customRoadmaps: UserProgressResponse;
|
||||
teamRoadmaps?: HeroTeamRoadmaps;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export function HeroRoadmaps(props: ProgressListProps) {
|
||||
const {
|
||||
teamRoadmaps = {},
|
||||
progress,
|
||||
isLoading = false,
|
||||
customRoadmaps,
|
||||
} = props;
|
||||
|
||||
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
|
||||
const [creatingRoadmapTeamId, setCreatingRoadmapTeamId] = useState<string>();
|
||||
|
||||
return (
|
||||
<div className="relative pb-12 pt-4 sm:pt-7">
|
||||
<p className="mb-7 mt-2 text-sm">
|
||||
<FeatureAnnouncement />
|
||||
</p>
|
||||
{isCreatingRoadmap && (
|
||||
<CreateRoadmapModal
|
||||
teamId={creatingRoadmapTeamId}
|
||||
onClose={() => {
|
||||
setIsCreatingRoadmap(false);
|
||||
setCreatingRoadmapTeamId(undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{
|
||||
<HeroTitle
|
||||
icon={
|
||||
(<CheckIcon additionalClasses="mr-1.5 h-[14px] w-[14px]" />) as any
|
||||
}
|
||||
isLoading={isLoading}
|
||||
title="Your progress and favorite roadmaps."
|
||||
/>
|
||||
}
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
|
||||
{progress.map((resource) => (
|
||||
<HeroRoadmap
|
||||
key={`${resource.resourceType}-${resource.resourceId}`}
|
||||
resourceId={resource.resourceId}
|
||||
resourceType={resource.resourceType}
|
||||
resourceTitle={resource.resourceTitle}
|
||||
isFavorite={resource.isFavorite}
|
||||
percentageDone={
|
||||
((resource.skipped + resource.done) / resource.total) * 100
|
||||
}
|
||||
url={
|
||||
resource.resourceType === 'roadmap'
|
||||
? `/${resource.resourceId}`
|
||||
: `/best-practices/${resource.resourceId}`
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
{
|
||||
<HeroTitle
|
||||
icon={<MapIcon className="mr-1.5 h-[14px] w-[14px]" />}
|
||||
title="Your custom roadmaps"
|
||||
/>
|
||||
}
|
||||
|
||||
{customRoadmaps.length === 0 && (
|
||||
<p className="rounded-md border border-dashed border-gray-800 p-2 text-sm text-gray-600">
|
||||
You haven't created any custom roadmaps yet.{' '}
|
||||
<button
|
||||
className="text-gray-500 underline underline-offset-2 hover:text-gray-400"
|
||||
onClick={() => setIsCreatingRoadmap(true)}
|
||||
>
|
||||
Create one!
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{customRoadmaps.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
|
||||
{customRoadmaps.map((customRoadmap) => {
|
||||
return (
|
||||
<HeroRoadmap
|
||||
key={customRoadmap.resourceId}
|
||||
resourceId={customRoadmap.resourceId}
|
||||
resourceType={'roadmap'}
|
||||
resourceTitle={customRoadmap.resourceTitle}
|
||||
percentageDone={
|
||||
((customRoadmap.skipped + customRoadmap.done) /
|
||||
customRoadmap.total) *
|
||||
100
|
||||
}
|
||||
url={`/r/${customRoadmap?.roadmapSlug}`}
|
||||
allowFavorite={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<CreateRoadmapButton />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{Object.keys(teamRoadmaps).map((teamName) => {
|
||||
const currentTeam: UserProgressResponse[0]['team'] =
|
||||
teamRoadmaps?.[teamName]?.[0]?.team;
|
||||
const roadmapsList = teamRoadmaps[teamName].filter(
|
||||
(roadmap) => !!roadmap.resourceTitle,
|
||||
);
|
||||
const canManageTeam = ['admin', 'manager'].includes(currentTeam?.role!);
|
||||
|
||||
return (
|
||||
<div className="mt-5" key={teamName}>
|
||||
{
|
||||
<HeroTitle
|
||||
icon={<Users2 className="mr-1.5 h-[14px] w-[14px]" />}
|
||||
title={
|
||||
<>
|
||||
Team{' '}
|
||||
<a
|
||||
className="mx-1 font-medium underline underline-offset-2 transition-colors hover:text-gray-300"
|
||||
href={`/team/activity?t=${currentTeam?.id}`}
|
||||
>
|
||||
{teamName}
|
||||
</a>
|
||||
Roadmaps
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
|
||||
{roadmapsList.length === 0 && (
|
||||
<p className="rounded-md border border-dashed border-gray-800 p-2 text-sm text-gray-600">
|
||||
Team does not have any roadmaps yet.{' '}
|
||||
{canManageTeam && (
|
||||
<button
|
||||
className="text-gray-500 underline underline-offset-2 hover:text-gray-400"
|
||||
onClick={() => {
|
||||
setCreatingRoadmapTeamId(currentTeam?.id);
|
||||
setIsCreatingRoadmap(true);
|
||||
}}
|
||||
>
|
||||
Create one!
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{roadmapsList.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
|
||||
{roadmapsList.map((customRoadmap) => {
|
||||
return (
|
||||
<HeroRoadmap
|
||||
key={customRoadmap.resourceId}
|
||||
resourceId={customRoadmap.resourceId}
|
||||
resourceType={'roadmap'}
|
||||
resourceTitle={customRoadmap.resourceTitle}
|
||||
percentageDone={
|
||||
((customRoadmap.skipped + customRoadmap.done) /
|
||||
customRoadmap.total) *
|
||||
100
|
||||
}
|
||||
url={`/r/${customRoadmap?.roadmapSlug}`}
|
||||
allowFavorite={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{canManageTeam && (
|
||||
<CreateRoadmapButton
|
||||
teamId={currentTeam?.id}
|
||||
text="Create Team Roadmap"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
src/components/HeroSection/HeroTitle.tsx
Normal file
71
src/components/HeroSection/HeroTitle.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Spinner } from '../ReactIcons/Spinner.tsx';
|
||||
import { ChevronDown, ChevronsDownUp, ChevronsUpDown } from 'lucide-react';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
|
||||
type HeroTitleProps = {
|
||||
icon: any;
|
||||
isLoading?: boolean;
|
||||
title: string | ReactNode;
|
||||
rightContent?: ReactNode;
|
||||
isCollapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
isEmpty?: boolean;
|
||||
emptyTitle?: ReactNode;
|
||||
};
|
||||
|
||||
export function HeroTitle(props: HeroTitleProps) {
|
||||
const {
|
||||
isLoading = false,
|
||||
title,
|
||||
icon,
|
||||
rightContent,
|
||||
isCollapsed = false,
|
||||
onToggleCollapse,
|
||||
isEmpty = false,
|
||||
emptyTitle,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="flex items-center gap-0.5 text-sm text-gray-400">
|
||||
{!isLoading && icon}
|
||||
{isLoading && (
|
||||
<span className="mr-1.5">
|
||||
<Spinner />
|
||||
</span>
|
||||
)}
|
||||
{!isEmpty ? title : emptyTitle || title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isCollapsed && rightContent}
|
||||
|
||||
{!isLoading && !isEmpty && (
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className={cn(
|
||||
'ml-2 inline-flex items-center gap-1 rounded-md bg-slate-800 py-0.5 pl-1 pr-1.5 text-xs uppercase tracking-wider text-slate-400 hover:bg-slate-700',
|
||||
{
|
||||
'bg-slate-800 text-slate-500 hover:bg-slate-800 hover:text-slate-400':
|
||||
!isCollapsed,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{isCollapsed && (
|
||||
<>
|
||||
<ChevronsUpDown className="h-3.5 w-3.5" /> Expand
|
||||
</>
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<ChevronsDownUp className="h-3.5 w-3.5" /> Collapse
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,20 +21,10 @@ import { CourseAnnouncement } from '../SQLCourse/CourseAnnouncement';
|
||||
</a>
|
||||
|
||||
<a
|
||||
href='/teams'
|
||||
class='group relative inline text-blue-300 hover:text-white sm:hidden'
|
||||
href='/ai-tutor'
|
||||
class='group relative inline text-gray-400 hover:text-white sm:hidden'
|
||||
>
|
||||
Teams
|
||||
|
||||
<span class='absolute -right-[11px] top-0'>
|
||||
<span class='relative flex h-2 w-2'>
|
||||
<span
|
||||
class='absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75'
|
||||
></span>
|
||||
<span class='relative inline-flex h-2 w-2 rounded-full bg-sky-500'
|
||||
></span>
|
||||
</span>
|
||||
</span>
|
||||
AI Tutor
|
||||
</a>
|
||||
|
||||
<!-- Desktop navigation items -->
|
||||
@@ -44,15 +34,11 @@ import { CourseAnnouncement } from '../SQLCourse/CourseAnnouncement';
|
||||
Start Here
|
||||
</a>
|
||||
<RoadmapDropdownMenu client:load />
|
||||
<a href='/teams' class='group relative text-gray-400 hover:text-white'>
|
||||
Teams
|
||||
</a>
|
||||
<a
|
||||
href='/changelog'
|
||||
class='group relative ml-0.5 hidden text-blue-300 hover:text-white md:block'
|
||||
href='/ai-tutor'
|
||||
class='group relative mr-3 text-blue-300 hover:text-white'
|
||||
>
|
||||
Changelog
|
||||
|
||||
AI Tutor
|
||||
<span class='absolute -right-[11px] top-0'>
|
||||
<span class='relative flex h-2 w-2'>
|
||||
<span
|
||||
@@ -63,6 +49,12 @@ import { CourseAnnouncement } from '../SQLCourse/CourseAnnouncement';
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href='/teams'
|
||||
class='group relative hidden text-gray-400 hover:text-white xl:block'
|
||||
>
|
||||
Teams
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ type PaginationProps = {
|
||||
totalCount: number;
|
||||
isDisabled?: boolean;
|
||||
onPageChange: (page: number) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Pagination(props: PaginationProps) {
|
||||
@@ -22,6 +23,7 @@ export function Pagination(props: PaginationProps) {
|
||||
currPage,
|
||||
perPage,
|
||||
isDisabled = false,
|
||||
className,
|
||||
} = props;
|
||||
|
||||
if (!totalPages || totalPages === 1) {
|
||||
@@ -32,10 +34,14 @@ export function Pagination(props: PaginationProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center', {
|
||||
'justify-between': variant === 'default',
|
||||
'justify-start': variant === 'minimal',
|
||||
})}
|
||||
className={cn(
|
||||
'flex items-center',
|
||||
{
|
||||
'justify-between': variant === 'default',
|
||||
'justify-start': variant === 'minimal',
|
||||
},
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1 text-xs font-medium">
|
||||
<button
|
||||
|
||||
@@ -60,7 +60,7 @@ const relatedQuestionDetails = await getQuestionGroupsByIds(relatedQuestions);
|
||||
class:list={[
|
||||
'border-t bg-gray-100',
|
||||
{
|
||||
'mt-8': !relatedQuestionDetails.length,
|
||||
'mt-0': !relatedQuestionDetails.length,
|
||||
},
|
||||
]}
|
||||
>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import type { UserProgressResponse } from '../HeroSection/FavoriteRoadmaps';
|
||||
import { SelectionButton } from './SelectionButton';
|
||||
import type { UserProgressResponse } from '../Roadmaps/RoadmapsPage';
|
||||
|
||||
type RoadmapSelectProps = {
|
||||
selectedRoadmaps: string[];
|
||||
|
||||
@@ -33,7 +33,7 @@ export function RoadmapTitleQuestion(props: RoadmapTitleQuestionProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative hidden rounded-b-[5px] border-t bg-white text-sm font-medium hover:bg-gray-50 sm:block',
|
||||
'relative block rounded-b-[5px] border-t bg-white text-sm font-medium hover:bg-gray-50 sm:block',
|
||||
{
|
||||
'rounded-0 -mx-4 sm:mx-0': isAnswerVisible,
|
||||
// @FIXME:
|
||||
@@ -41,7 +41,6 @@ export function RoadmapTitleQuestion(props: RoadmapTitleQuestionProps) {
|
||||
// the frontend roadmap. This is because we did not use to have the question
|
||||
// on mobile devices before and we don't want to cause any SEO issues. It will
|
||||
// be enabled on other roadmaps in the future.
|
||||
block: roadmapId === 'frontend',
|
||||
},
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -10,8 +10,27 @@ import {
|
||||
} from '../../lib/browser.ts';
|
||||
import { RoadmapCard } from './RoadmapCard.tsx';
|
||||
import { httpGet } from '../../lib/http.ts';
|
||||
import type { UserProgressResponse } from '../HeroSection/FavoriteRoadmaps.tsx';
|
||||
import { isLoggedIn } from '../../lib/jwt.ts';
|
||||
import type { AllowedMemberRoles } from '../ShareOptions/ShareTeamMemberList.tsx';
|
||||
|
||||
export type UserProgressResponse = {
|
||||
resourceId: string;
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
resourceTitle: string;
|
||||
isFavorite: boolean;
|
||||
done: number;
|
||||
learning: number;
|
||||
skipped: number;
|
||||
total: number;
|
||||
updatedAt: Date;
|
||||
isCustomResource: boolean;
|
||||
roadmapSlug?: string;
|
||||
team?: {
|
||||
name: string;
|
||||
id: string;
|
||||
role: AllowedMemberRoles;
|
||||
};
|
||||
}[];
|
||||
|
||||
const groupNames = [
|
||||
'Absolute Beginners',
|
||||
@@ -238,6 +257,12 @@ const groups: GroupType[] = [
|
||||
type: 'skill',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
{
|
||||
title: 'Cloudflare',
|
||||
link: '/cloudflare',
|
||||
type: 'skill',
|
||||
otherGroups: ['Web Development'],
|
||||
},
|
||||
{
|
||||
title: 'Linux',
|
||||
link: '/linux',
|
||||
|
||||
41
src/components/SQLCourse/AuthorCredentials.tsx
Normal file
41
src/components/SQLCourse/AuthorCredentials.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Award } from 'lucide-react';
|
||||
|
||||
export function AuthorCredentials() {
|
||||
return (
|
||||
<div className="mx-auto mt-8 flex flex-col items-start gap-4 text-sm text-zinc-400 sm:flex-row sm:flex-wrap sm:items-center md:mt-12 md:justify-center md:gap-x-3 md:gap-y-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<img
|
||||
src="https://assets.roadmap.sh/guest/kamran-lqjta.jpeg"
|
||||
className="size-8 rounded-full object-cover mr-1.5"
|
||||
alt="Kamran Ahmed"
|
||||
/>
|
||||
<span>Course by</span>
|
||||
<a
|
||||
href="https://twitter.com/kamrify"
|
||||
target="_blank"
|
||||
className="font-medium text-yellow-500 hover:text-yellow-400"
|
||||
>
|
||||
Kamran Ahmed
|
||||
</a>
|
||||
</div>
|
||||
<div className="hidden flex-wrap items-center gap-x-3 gap-y-2 sm:flex sm:justify-center">
|
||||
<a
|
||||
href="https://github.com/kamranahmedse"
|
||||
target="_blank"
|
||||
className="hidden items-center gap-1 sm:inline-flex text-yellow-500 hover:text-yellow-400"
|
||||
>
|
||||
<svg className="size-4 fill-zinc-400" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.603-3.369-1.342-3.369-1.342-.454-1.155-1.11-1.462-1.11-1.462-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.022A9.607 9.607 0 0 1 12 6.82c.85.004 1.705.114 2.504.336 1.909-1.291 2.747-1.022 2.747-1.022.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z" />
|
||||
</svg>
|
||||
#2 most-starred on GitHub
|
||||
</a>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<svg className="size-3 fill-current" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" />
|
||||
</svg>
|
||||
founder roadmap.sh
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,59 @@
|
||||
import { QuoteIcon } from 'lucide-react';
|
||||
import { Award, QuoteIcon, Trophy } from 'lucide-react';
|
||||
|
||||
export function AuthorQuoteMessage() {
|
||||
return (
|
||||
<div className="mx-auto mt-14 max-w-2xl sm:mt-20">
|
||||
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-yellow-500/10 via-yellow-400/5 to-yellow-300/10 p-6 sm:p-10">
|
||||
<div className="relative">
|
||||
<p className="mb-6 text-base sm:text-xl leading-relaxed text-zinc-200">
|
||||
"As someone who has worked extensively with databases throughout my
|
||||
career, I know firsthand how crucial SQL skills are. I've created
|
||||
this course to share the practical knowledge that has helped me
|
||||
build and scale data systems at various companies."
|
||||
</p>
|
||||
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-yellow-500/10 via-yellow-400/5 to-yellow-300/10 p-8 sm:p-12">
|
||||
<div className="absolute right-0 top-0 -translate-y-1/2 translate-x-1/2">
|
||||
<div className="size-[500px] rounded-full bg-yellow-500/5 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 border-t border-yellow-500/20 pt-6">
|
||||
<div className="relative flex flex-col items-center text-center">
|
||||
<h2 className="mb-4 hidden text-2xl font-semibold text-yellow-500 md:block">
|
||||
From your Instructor
|
||||
</h2>
|
||||
|
||||
<div className="mt-4 hidden flex-wrap items-center justify-center gap-x-4 gap-y-2 text-sm text-zinc-400 md:flex">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-yellow-500/10 px-3 py-1">
|
||||
<Trophy className="size-4 text-yellow-500/80" />
|
||||
Multiple GitHub Star Awards
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-yellow-500/10 px-3 py-1">
|
||||
<svg className="size-4 fill-yellow-500/80" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.603-3.369-1.342-3.369-1.342-.454-1.155-1.11-1.462-1.11-1.462-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.022A9.607 9.607 0 0 1 12 6.82c.85.004 1.705.114 2.504.336 1.909-1.291 2.747-1.022 2.747-1.022.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z" />
|
||||
</svg>
|
||||
#2 Most Starred Developer
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-yellow-500/10 px-3 py-1">
|
||||
<Award className="size-4 text-yellow-500/80" />
|
||||
Founder roadmap.sh
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-yellow-500/10 px-3 py-1">
|
||||
<Award className="size-4 text-yellow-500/80" />
|
||||
Google Developer Expert
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-0 md:mt-8">
|
||||
<p className="text-base leading-relaxed text-zinc-200 sm:text-xl">
|
||||
"As someone who has worked extensively with databases throughout
|
||||
my career, I know firsthand how crucial SQL skills are. I've
|
||||
created this course to share the practical knowledge that has
|
||||
helped me build and scale data systems at various companies."
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center gap-4">
|
||||
<img
|
||||
src="https://assets.roadmap.sh/guest/kamran-lqjta.jpeg"
|
||||
alt="Kamran Ahmed"
|
||||
className="size-14 rounded-full ring-2 ring-yellow-500/20"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-left">
|
||||
<h3 className="font-medium text-yellow-500">Kamran Ahmed</h3>
|
||||
<p className="text-sm text-zinc-400">
|
||||
Founder roadmap.sh <span className="mx-1 sm:inline hidden">·</span>
|
||||
Founder roadmap.sh{' '}
|
||||
<span className="mx-1 hidden sm:inline">·</span>
|
||||
<a
|
||||
href="https://twitter.com/kamrify"
|
||||
target="_blank"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { ArrowRightIcon } from 'lucide-react';
|
||||
import { ArrowRightIcon, Play } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { cn } from '../../lib/classname';
|
||||
import {
|
||||
@@ -14,6 +14,7 @@ import { CourseLoginPopup } from '../AuthenticationFlow/CourseLoginPopup';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { httpPost } from '../../lib/query-http';
|
||||
import { deleteUrlParam, getUrlParams } from '../../lib/browser';
|
||||
import { VideoModal } from '../VideoModal';
|
||||
|
||||
export const SQL_COURSE_SLUG = 'sql';
|
||||
|
||||
@@ -35,6 +36,7 @@ export function BuyButton(props: BuyButtonProps) {
|
||||
const { variant = 'main' } = props;
|
||||
|
||||
const [isLoginPopupOpen, setIsLoginPopupOpen] = useState(false);
|
||||
const [isVideoModalOpen, setIsVideoModalOpen] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
const { data: coursePricing, isLoading: isLoadingCourse } = useQuery(
|
||||
@@ -164,6 +166,12 @@ export function BuyButton(props: BuyButtonProps) {
|
||||
return (
|
||||
<div className="relative flex w-full flex-col items-center gap-2 md:w-auto">
|
||||
{courseLoginPopup}
|
||||
{isVideoModalOpen && (
|
||||
<VideoModal
|
||||
videoId="6S1CcF-ngeQ"
|
||||
onClose={() => setIsVideoModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={onBuyClick}
|
||||
disabled={isLoadingPricing}
|
||||
@@ -200,8 +208,14 @@ export function BuyButton(props: BuyButtonProps) {
|
||||
</button>
|
||||
|
||||
{!isLoadingPricing && (
|
||||
<span className="absolute top-full translate-y-2.5 text-sm text-yellow-400">
|
||||
Lifetime access <span className="mx-1">·</span> Free updates
|
||||
<span className="absolute top-full z-50 flex w-[300px] translate-y-3 flex-row items-center justify-center text-sm text-yellow-400">
|
||||
Lifetime access <span className="mx-2">·</span>{' '}
|
||||
<button
|
||||
onClick={() => setIsVideoModalOpen(true)}
|
||||
className="flex cursor-pointer flex-row items-center gap-1.5 underline underline-offset-4 hover:text-yellow-500"
|
||||
>
|
||||
<Play className="size-3 fill-current" /> Watch Video (3 min)
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,11 +10,8 @@ export function CourseAnnouncement() {
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-[91]">
|
||||
<a
|
||||
href="/courses/sql"
|
||||
className="flex items-center bg-yellow-400 py-1.5"
|
||||
>
|
||||
<span className="container mx-auto flex items-center justify-start sm:justify-center gap-2 text-center sm:gap-4">
|
||||
<a href="/courses/sql" className="flex items-center bg-yellow-400 py-1.5">
|
||||
<span className="container mx-auto flex items-center justify-start gap-2 text-center sm:justify-center sm:gap-4">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-black md:text-base">
|
||||
<Database className="hidden h-4 w-4 flex-shrink-0 text-black sm:block" />
|
||||
<span className="hidden sm:block">
|
||||
@@ -22,7 +19,7 @@ export function CourseAnnouncement() {
|
||||
</span>
|
||||
<span className="block sm:hidden">Announcing our SQL course</span>
|
||||
</span>
|
||||
<span className="items-center gap-1.5 rounded-full bg-black px-2 py-0.5 text-sm text-xs font-medium uppercase tracking-wide text-white hover:bg-zinc-800 sm:px-3 sm:py-1">
|
||||
<span className="items-center gap-1.5 rounded-full bg-black px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-white hover:bg-zinc-800 sm:px-3 sm:py-1">
|
||||
<span className="mr-1.5 hidden sm:inline">Start Learning</span>
|
||||
<span className="mr-1.5 inline sm:hidden">Visit</span>
|
||||
<span className="">→</span>
|
||||
|
||||
@@ -1,40 +1,34 @@
|
||||
import { SectionHeader } from './SectionHeader';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Play } from 'lucide-react';
|
||||
import { VideoModal } from '../VideoModal';
|
||||
|
||||
export function PlatformDemo() {
|
||||
const [isZoomed, setIsZoomed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function onScroll() {
|
||||
if (isZoomed) {
|
||||
setIsZoomed(false);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', onScroll);
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, [isZoomed]);
|
||||
const [isVideoModalOpen, setIsVideoModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isZoomed && (
|
||||
<div
|
||||
onClick={() => setIsZoomed(false)}
|
||||
className="fixed inset-0 z-[999] flex cursor-zoom-out items-center justify-center bg-black bg-opacity-75"
|
||||
>
|
||||
<img
|
||||
src="https://assets.roadmap.sh/guest/course-environment-87jg8.png"
|
||||
alt="Course Environment"
|
||||
className="max-h-[90vh] max-w-[90vw] rounded-xl object-contain"
|
||||
/>
|
||||
</div>
|
||||
{isVideoModalOpen && (
|
||||
<VideoModal
|
||||
videoId="6S1CcF-ngeQ"
|
||||
onClose={() => setIsVideoModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<img
|
||||
src="https://assets.roadmap.sh/guest/course-environment-87jg8.png"
|
||||
alt="Course Environment"
|
||||
onClick={() => setIsZoomed(true)}
|
||||
className="mt-12 sm:mt-20 w-full max-w-5xl rounded-xl cursor-zoom-in"
|
||||
/>
|
||||
<div className="relative mt-12 w-full max-w-5xl sm:mt-24">
|
||||
<img
|
||||
src="https://assets.roadmap.sh/guest/course-environment-87jg8.png"
|
||||
alt="Course Environment"
|
||||
className="w-full rounded-xl"
|
||||
/>
|
||||
<div
|
||||
onClick={() => setIsVideoModalOpen(true)}
|
||||
className="group absolute inset-0 flex cursor-pointer items-center justify-center rounded-xl bg-black/40 transition-all hover:bg-black/50"
|
||||
>
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-white/90 transition-transform group-hover:scale-105 lg:size-16">
|
||||
<Play className="ml-1 fill-current text-black lg:size-8" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
254
src/components/SQLCourse/ReviewsSection.tsx
Normal file
254
src/components/SQLCourse/ReviewsSection.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import { ChevronDownIcon, StarIcon, User2Icon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { cn } from '../../../editor/utils/classname';
|
||||
import { markdownToHtml } from '../../lib/markdown';
|
||||
|
||||
type Review = {
|
||||
name: string;
|
||||
role: string;
|
||||
rating: number;
|
||||
text: string | string[];
|
||||
avatarUrl?: string;
|
||||
isProminent?: boolean;
|
||||
isSecondaryProminent?: boolean;
|
||||
};
|
||||
|
||||
export function ReviewsSection() {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const reviews: Review[] = [
|
||||
{
|
||||
name: 'Robin Wieruch',
|
||||
role: 'Author - Multiple best-selling books',
|
||||
rating: 5,
|
||||
text: [
|
||||
'Kamran has been in the **educative space for a long time**, and it shows in the way he teaches SQL: clear, structured, and straight to the point.',
|
||||
"He breaks down SQL fundamentals in a way that's both **intuitive and practical**, helping you not just write queries, but truly understand how databases work.",
|
||||
"Even if you've used SQL before, this **course will fill in gaps you didn't even realize you had**. Get ready to level up your database skills!",
|
||||
],
|
||||
avatarUrl: 'https://assets.roadmap.sh/guest/robin.jpeg',
|
||||
isProminent: true,
|
||||
},
|
||||
{
|
||||
name: 'William Imoh',
|
||||
role: 'Founder and Data Enthusiast',
|
||||
rating: 5,
|
||||
text: [
|
||||
'I have been working with SQL and databases for a long time, I bought this course for the advanced chapters but ended up completing the entire course. I learned a lot of new things and it was **well worth the investment**.',
|
||||
'No matter your SQL experience, this course is **a must-have** if you want to level up your SQL and data analysis skills. Highly recommended!',
|
||||
],
|
||||
avatarUrl: 'https://assets.roadmap.sh/guest/william-imoh-sd2dk.jpg',
|
||||
isProminent: true,
|
||||
},
|
||||
{
|
||||
name: 'Tomáš Janků',
|
||||
role: 'Software Engineer',
|
||||
rating: 5,
|
||||
text: "The course and it's interactivity is excellent and I'd honestly say it's **one of the best** on the SQL theme I've seen out there.",
|
||||
avatarUrl: 'https://assets.roadmap.sh/guest/tomas-janku-6bg89.jpeg',
|
||||
},
|
||||
{
|
||||
name: 'Gourav Khunger',
|
||||
role: 'Software Engineer',
|
||||
rating: 5,
|
||||
text: 'This course was **absolutely brilliant!** The integrated database environment to practice what I learned was the best part.',
|
||||
avatarUrl: 'https://assets.roadmap.sh/guest/gourav-h2f3a.png',
|
||||
},
|
||||
{
|
||||
name: 'Meabed',
|
||||
role: 'CTO',
|
||||
rating: 5,
|
||||
text: 'Kamran has **clearly put a lot of thought** into this course. The content, structure and exercises were all great.',
|
||||
avatarUrl: 'https://assets.roadmap.sh/guest/meabed-fu83q.jpeg',
|
||||
},
|
||||
{
|
||||
name: 'Mohsin Aheer',
|
||||
role: 'Sr. Software Engineer',
|
||||
rating: 5,
|
||||
text: 'I already knew SQL but this course **taught me a bunch of new things.** Practical examples and challenges were great. Highly recommended!',
|
||||
avatarUrl: 'https://assets.roadmap.sh/guest/mohsinaheer-szchu.jpeg',
|
||||
},
|
||||
{
|
||||
name: 'Reeve Tee',
|
||||
role: 'Software Engineer',
|
||||
rating: 5,
|
||||
text: 'I found the course **highly comprehensive and incredibly valuable**. I would love to see more courses like this!',
|
||||
avatarUrl: '',
|
||||
},
|
||||
{
|
||||
name: 'Zeeshan',
|
||||
role: 'Sr. Software Engineer',
|
||||
rating: 5,
|
||||
text: 'Loved the teaching style and the way the course was structured. The **AI tutor was a great help** when I got stuck.',
|
||||
avatarUrl: 'https://assets.roadmap.sh/guest/ziishaned-qjepj.png',
|
||||
},
|
||||
{
|
||||
name: 'Adnan Ahmed',
|
||||
role: 'Engineering Manager',
|
||||
rating: 5,
|
||||
text: 'Having the integrated IDE made a huge difference. Being able to **immediately practice** what I learned was **invaluable**.',
|
||||
avatarUrl: 'https://assets.roadmap.sh/guest/idnan-fzps5.jpeg',
|
||||
},
|
||||
{
|
||||
name: 'Kalvin Chakma',
|
||||
role: 'Jr. Software Engineer',
|
||||
rating: 5,
|
||||
text: "Best SQL course I've taken. The progression from basic to advanced concepts is **well thought out**, and the challenges are **excellent**.",
|
||||
avatarUrl: 'https://assets.roadmap.sh/guest/kalvin-d65ol.jpeg',
|
||||
},
|
||||
{
|
||||
name: 'Faisal Ahsan',
|
||||
role: 'Software Engineer',
|
||||
rating: 5,
|
||||
text: 'The course and the learning experience was great. What I really liked was the **no-fluff explanations** and **practical examples**.',
|
||||
avatarUrl: 'https://assets.roadmap.sh/guest/faisal-q78p2.jpeg',
|
||||
},
|
||||
];
|
||||
|
||||
const prominentReviews = reviews.filter((r) => r.isProminent);
|
||||
const regularReviews = reviews.filter((r) => !r.isProminent);
|
||||
|
||||
return (
|
||||
<div className="relative max-w-5xl">
|
||||
<div
|
||||
className={cn('rounded-2xl pb-0 pt-24', {
|
||||
'pb-8': isExpanded,
|
||||
})}
|
||||
>
|
||||
{/* Prominent Reviews */}
|
||||
<div className="mb-4 md:mb-6">
|
||||
<div className="grid grid-cols-1 gap-4 md:gap-6 md:grid-cols-2">
|
||||
{prominentReviews.map((review, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="review-testimonial relative overflow-hidden rounded-2xl bg-gradient-to-br from-yellow-500/10 via-yellow-500/5 to-transparent p-8 backdrop-blur [&_strong]:font-normal [&_strong]:text-yellow-300/70"
|
||||
>
|
||||
<div className="absolute -right-8 -top-8 h-32 w-32 rounded-full bg-yellow-500/5" />
|
||||
<div className="flex items-center gap-4">
|
||||
{review.avatarUrl && (
|
||||
<img
|
||||
src={review.avatarUrl}
|
||||
alt={review.name}
|
||||
className="h-16 w-16 rounded-full border-2 border-yellow-500/20 object-cover"
|
||||
/>
|
||||
)}
|
||||
{!review.avatarUrl && (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-zinc-800">
|
||||
<User2Icon className="h-8 w-8 text-zinc-400" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-zinc-100">
|
||||
{review.name}
|
||||
</h3>
|
||||
<p className="text-sm text-yellow-500/70">{review.role}</p>
|
||||
<div className="mt-1 flex">
|
||||
{Array.from({ length: review.rating }).map((_, i) => (
|
||||
<StarIcon
|
||||
key={i}
|
||||
className="h-4 w-4 fill-yellow-500 text-yellow-500"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
{(typeof review.text === 'string'
|
||||
? [review.text]
|
||||
: review.text
|
||||
).map((text, index) => (
|
||||
<p
|
||||
key={index}
|
||||
className="text-zinc-300"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: markdownToHtml(text),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'relative grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3',
|
||||
isExpanded ? '' : 'max-h-[400px] overflow-hidden',
|
||||
)}
|
||||
>
|
||||
{regularReviews.map((review, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'review-testimonial flex-shrink-0 break-inside-avoid-column rounded-xl p-6 backdrop-blur [&_strong]:font-normal [&_strong]:text-yellow-300/70',
|
||||
{
|
||||
'bg-gradient-to-br from-yellow-500/10 via-yellow-500/5 to-transparent':
|
||||
review.isSecondaryProminent,
|
||||
'bg-zinc-800/30': !review.isSecondaryProminent,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{review.avatarUrl && (
|
||||
<img
|
||||
src={review.avatarUrl}
|
||||
alt={review.name}
|
||||
className="h-12 w-12 rounded-full object-cover"
|
||||
/>
|
||||
)}
|
||||
{!review.avatarUrl && (
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-zinc-800">
|
||||
<User2Icon className="h-6 w-6 text-zinc-400" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="font-semibold text-zinc-100">{review.name}</h3>
|
||||
<p className="text-sm text-zinc-400">{review.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex">
|
||||
{Array.from({ length: review.rating }).map((_, i) => (
|
||||
<StarIcon
|
||||
key={i}
|
||||
className="h-4 w-4 fill-yellow-500 text-yellow-500"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p
|
||||
className="mt-4 text-zinc-300"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: markdownToHtml(review.text),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'absolute bottom-0 left-0 right-0 h-40 bg-gradient-to-t from-[#121212] via-[#121212]/80 to-transparent',
|
||||
isExpanded ? 'opacity-0' : 'opacity-100',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn('absolute left-1/2 top-full -translate-x-1/2', {
|
||||
'-translate-y-1/2': !isExpanded,
|
||||
})}
|
||||
>
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center gap-2 rounded-full bg-zinc-800 px-6 py-2 text-sm font-medium text-zinc-300 transition-all hover:bg-zinc-700 hover:text-zinc-100"
|
||||
>
|
||||
{isExpanded ? 'Show Less' : 'Show More Reviews'}
|
||||
<ChevronDownIcon
|
||||
className={`h-4 w-4 transition-transform ${
|
||||
isExpanded ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user