mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2026-03-15 03:18:46 +08:00
Compare commits
1 Commits
fix/ai-cou
...
fix/sql
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75945c7c65 |
@@ -3,6 +3,6 @@
|
||||
"enabled": false
|
||||
},
|
||||
"_variables": {
|
||||
"lastUpdateCheck": 1741697790683
|
||||
"lastUpdateCheck": 1739229597159
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,4 @@
|
||||
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_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
|
||||
PUBLIC_COURSE_APP_URL=http://localhost:5173
|
||||
@@ -53,7 +53,6 @@
|
||||
"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",
|
||||
@@ -64,7 +63,6 @@
|
||||
"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",
|
||||
@@ -74,7 +72,6 @@
|
||||
"satori": "^0.11.2",
|
||||
"satori-html": "^0.3.2",
|
||||
"sharp": "^0.33.5",
|
||||
"shiki": "^3.1.0",
|
||||
"slugify": "^1.6.6",
|
||||
"tailwind-merge": "^2.5.3",
|
||||
"tailwindcss": "^3.4.13",
|
||||
|
||||
204
pnpm-lock.yaml
generated
204
pnpm-lock.yaml
generated
@@ -80,9 +80,6 @@ importers:
|
||||
luxon:
|
||||
specifier: ^3.5.0
|
||||
version: 3.5.0
|
||||
markdown-it-async:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
nanoid:
|
||||
specifier: ^5.0.7
|
||||
version: 5.0.9
|
||||
@@ -113,9 +110,6 @@ importers:
|
||||
react-dom:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1(react@18.3.1)
|
||||
react-textarea-autosize:
|
||||
specifier: ^8.5.7
|
||||
version: 8.5.7(@types/react@18.3.18)(react@18.3.1)
|
||||
react-tooltip:
|
||||
specifier: ^5.28.0
|
||||
version: 5.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -143,9 +137,6 @@ importers:
|
||||
sharp:
|
||||
specifier: ^0.33.5
|
||||
version: 0.33.5
|
||||
shiki:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0
|
||||
slugify:
|
||||
specifier: ^1.6.6
|
||||
version: 1.6.6
|
||||
@@ -405,10 +396,6 @@ packages:
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
|
||||
'@babel/runtime@7.26.9':
|
||||
resolution: {integrity: sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/template@7.25.9':
|
||||
resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -1192,45 +1179,24 @@ packages:
|
||||
'@shikijs/core@1.29.1':
|
||||
resolution: {integrity: sha512-Mo1gGGkuOYjDu5H8YwzmOuly9vNr8KDVkqj9xiKhhhFS8jisAtDSEWB9hzqRHLVQgFdA310e8XRJcW4tYhRB2A==}
|
||||
|
||||
'@shikijs/core@3.1.0':
|
||||
resolution: {integrity: sha512-1ppAOyg3F18N8Ge9DmJjGqRVswihN33rOgPovR6gUHW17Hw1L4RlRhnmVQcsacSHh0A8IO1FIgNbtTxUFwodmg==}
|
||||
|
||||
'@shikijs/engine-javascript@1.29.1':
|
||||
resolution: {integrity: sha512-Hpi8k9x77rCQ7F/7zxIOUruNkNidMyBnP5qAGbLFqg4kRrg1HZhkB8btib5EXbQWTtLb5gBHOdBwshk20njD7Q==}
|
||||
|
||||
'@shikijs/engine-javascript@3.1.0':
|
||||
resolution: {integrity: sha512-/LwkhW17jYi7uPcdaaSQQDNW+xgrHXarkrxYPoC6WPzH2xW5mFMw12doHXJBqxmYvtcTbaatcv2MkH9+3PU1FA==}
|
||||
|
||||
'@shikijs/engine-oniguruma@1.29.1':
|
||||
resolution: {integrity: sha512-gSt2WhLNgEeLstcweQOSp+C+MhOpTsgdNXRqr3zP6M+BUBZ8Md9OU2BYwUYsALBxHza7hwaIWtFHjQ/aOOychw==}
|
||||
|
||||
'@shikijs/engine-oniguruma@3.1.0':
|
||||
resolution: {integrity: sha512-reRgy8VzDPdiDocuGDD60Rk/jLxgcgy+6H4n6jYLeN2Yw5ikasRjQQx8ERXtDM35yg2v/d6KolDBcK8hYYhcmw==}
|
||||
|
||||
'@shikijs/langs@1.29.1':
|
||||
resolution: {integrity: sha512-iERn4HlyuT044/FgrvLOaZgKVKf3PozjKjyV/RZ5GnlyYEAZFcgwHGkYboeBv2IybQG1KVS/e7VGgiAU4JY2Gw==}
|
||||
|
||||
'@shikijs/langs@3.1.0':
|
||||
resolution: {integrity: sha512-hAM//sExPXAXG3ZDWjrmV6Vlw4zlWFOcT1ZXNhFRBwPP27scZu/ZIdZ+TdTgy06zSvyF4KIjnF8j6+ScKGu6ww==}
|
||||
|
||||
'@shikijs/themes@1.29.1':
|
||||
resolution: {integrity: sha512-lb11zf72Vc9uxkl+aec2oW1HVTHJ2LtgZgumb4Rr6By3y/96VmlU44bkxEb8WBWH3RUtbqAJEN0jljD9cF7H7g==}
|
||||
|
||||
'@shikijs/themes@3.1.0':
|
||||
resolution: {integrity: sha512-A4MJmy9+ydLNbNCtkmdTp8a+ON+MMXoUe1KTkELkyu0+pHGOcbouhNuobhZoK59cL4cOST6CCz1x+kUdkp9UZA==}
|
||||
|
||||
'@shikijs/types@1.29.1':
|
||||
resolution: {integrity: sha512-aBqAuhYRp5vSir3Pc9+QPu9WESBOjUo03ao0IHLC4TyTioSsp/SkbAZSrIH4ghYYC1T1KTEpRSBa83bas4RnPA==}
|
||||
|
||||
'@shikijs/types@3.1.0':
|
||||
resolution: {integrity: sha512-F8e7Fy4ihtcNpJG572BZZC1ErYrBrzJ5Cbc9Zi3REgWry43gIvjJ9lFAoUnuy7Bvy4IFz7grUSxL5edfrrjFEA==}
|
||||
|
||||
'@shikijs/vscode-textmate@10.0.1':
|
||||
resolution: {integrity: sha512-fTIQwLF+Qhuws31iw7Ncl1R3HUDtGwIipiJ9iU+UsDUwMhegFcQKQHd51nZjb7CArq0MvON8rbgCGQYWHUKAdg==}
|
||||
|
||||
'@shikijs/vscode-textmate@10.0.2':
|
||||
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
|
||||
|
||||
'@shuding/opentype.js@1.4.0-beta.0':
|
||||
resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==}
|
||||
engines: {node: '>= 8.0.0'}
|
||||
@@ -2124,9 +2090,6 @@ packages:
|
||||
hast-util-to-html@9.0.4:
|
||||
resolution: {integrity: sha512-wxQzXtdbhiwGAUKrnQJXlOPmHnEehzphwkK7aluUPQ+lEc1xefC8pblMgpp2w5ldBTEfveRIrADcrhGIWrlTDA==}
|
||||
|
||||
hast-util-to-html@9.0.5:
|
||||
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
|
||||
|
||||
hast-util-to-parse5@8.0.0:
|
||||
resolution: {integrity: sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==}
|
||||
|
||||
@@ -2380,9 +2343,6 @@ packages:
|
||||
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
markdown-it-async@2.0.0:
|
||||
resolution: {integrity: sha512-jBthmQR5MwXR9Y8Y0teRoZAenaKQMdjuTfpbNARqMBSRPvyzyXCVduHZHakyyhL3ugIacCobXJrO07t277sIjw==}
|
||||
|
||||
markdown-it-task-lists@2.1.1:
|
||||
resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==}
|
||||
|
||||
@@ -2643,9 +2603,6 @@ packages:
|
||||
oniguruma-to-es@2.3.0:
|
||||
resolution: {integrity: sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g==}
|
||||
|
||||
oniguruma-to-es@3.1.1:
|
||||
resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==}
|
||||
|
||||
openai@4.80.1:
|
||||
resolution: {integrity: sha512-+6+bbXFwbIE88foZsBEt36bPkgZPdyFN82clAXG61gnHb2gXdZApDyRrcAHqEtpYICywpqaNo57kOm9dtnb7Cw==}
|
||||
hasBin: true
|
||||
@@ -2890,9 +2847,6 @@ packages:
|
||||
property-information@6.5.0:
|
||||
resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==}
|
||||
|
||||
property-information@7.0.0:
|
||||
resolution: {integrity: sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg==}
|
||||
|
||||
prosemirror-changeset@2.2.1:
|
||||
resolution: {integrity: sha512-J7msc6wbxB4ekDFj+n9gTW/jav/p53kdlivvuppHsrZXCaQdVgRghoZbSS3kwrRyAstRVQ4/+u5k7YfLgkkQvQ==}
|
||||
|
||||
@@ -2988,12 +2942,6 @@ packages:
|
||||
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
react-textarea-autosize@8.5.7:
|
||||
resolution: {integrity: sha512-2MqJ3p0Jh69yt9ktFIaZmORHXw4c4bxSIhCeWiFwmJ9EYKgLmuNII3e9c9b2UO+ijl4StnpZdqpxNIhTdHvqtQ==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
react-tooltip@5.28.0:
|
||||
resolution: {integrity: sha512-R5cO3JPPXk6FRbBHMO0rI9nkUG/JKfalBSQfZedZYzmqaZQgq7GLzF8vcCWx6IhUCKg0yPqJhXIzmIO5ff15xg==}
|
||||
peerDependencies:
|
||||
@@ -3017,24 +2965,15 @@ packages:
|
||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||
engines: {node: '>=8.10.0'}
|
||||
|
||||
regenerator-runtime@0.14.1:
|
||||
resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==}
|
||||
|
||||
regex-recursion@5.1.1:
|
||||
resolution: {integrity: sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w==}
|
||||
|
||||
regex-recursion@6.0.2:
|
||||
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
|
||||
|
||||
regex-utilities@2.3.0:
|
||||
resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
|
||||
|
||||
regex@5.1.1:
|
||||
resolution: {integrity: sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw==}
|
||||
|
||||
regex@6.0.1:
|
||||
resolution: {integrity: sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==}
|
||||
|
||||
rehype-external-links@3.0.0:
|
||||
resolution: {integrity: sha512-yp+e5N9V3C6bwBeAC4n796kc86M4gJCdlVhiMTxIrJG5UHDMh+PJANf9heqORJbt1nrCbDwIlAZKjANIaVBbvw==}
|
||||
|
||||
@@ -3171,9 +3110,6 @@ packages:
|
||||
shiki@1.29.1:
|
||||
resolution: {integrity: sha512-TghWKV9pJTd/N+IgAIVJtr0qZkB7FfFCUrrEJc0aRmZupo3D1OCVRknQWVRVA7AX/M0Ld7QfoAruPzr3CnUJuw==}
|
||||
|
||||
shiki@3.1.0:
|
||||
resolution: {integrity: sha512-LdTNyWQlC5zdCaHdcp1zPA1OVA2ivb+KjGOOnGcy02tGaF5ja+dGibWFH7Ar8YlngUgK/scDqworK18Ys9cbYA==}
|
||||
|
||||
signal-exit@4.1.0:
|
||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -3412,33 +3348,6 @@ packages:
|
||||
peerDependencies:
|
||||
browserslist: '>= 4.21.0'
|
||||
|
||||
use-composed-ref@1.4.0:
|
||||
resolution: {integrity: sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
use-isomorphic-layout-effect@1.2.0:
|
||||
resolution: {integrity: sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
use-latest@1.3.0:
|
||||
resolution: {integrity: sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
use-sync-external-store@1.4.0:
|
||||
resolution: {integrity: sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==}
|
||||
peerDependencies:
|
||||
@@ -3834,10 +3743,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/runtime@7.26.9':
|
||||
dependencies:
|
||||
regenerator-runtime: 0.14.1
|
||||
|
||||
'@babel/template@7.25.9':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.26.2
|
||||
@@ -4431,65 +4336,32 @@ snapshots:
|
||||
'@types/hast': 3.0.4
|
||||
hast-util-to-html: 9.0.4
|
||||
|
||||
'@shikijs/core@3.1.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.1.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
hast-util-to-html: 9.0.5
|
||||
|
||||
'@shikijs/engine-javascript@1.29.1':
|
||||
dependencies:
|
||||
'@shikijs/types': 1.29.1
|
||||
'@shikijs/vscode-textmate': 10.0.1
|
||||
oniguruma-to-es: 2.3.0
|
||||
|
||||
'@shikijs/engine-javascript@3.1.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.1.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
oniguruma-to-es: 3.1.1
|
||||
|
||||
'@shikijs/engine-oniguruma@1.29.1':
|
||||
dependencies:
|
||||
'@shikijs/types': 1.29.1
|
||||
'@shikijs/vscode-textmate': 10.0.1
|
||||
|
||||
'@shikijs/engine-oniguruma@3.1.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.1.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
|
||||
'@shikijs/langs@1.29.1':
|
||||
dependencies:
|
||||
'@shikijs/types': 1.29.1
|
||||
|
||||
'@shikijs/langs@3.1.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.1.0
|
||||
|
||||
'@shikijs/themes@1.29.1':
|
||||
dependencies:
|
||||
'@shikijs/types': 1.29.1
|
||||
|
||||
'@shikijs/themes@3.1.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.1.0
|
||||
|
||||
'@shikijs/types@1.29.1':
|
||||
dependencies:
|
||||
'@shikijs/vscode-textmate': 10.0.1
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
'@shikijs/types@3.1.0':
|
||||
dependencies:
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
'@shikijs/vscode-textmate@10.0.1': {}
|
||||
|
||||
'@shikijs/vscode-textmate@10.0.2': {}
|
||||
|
||||
'@shuding/opentype.js@1.4.0-beta.0':
|
||||
dependencies:
|
||||
fflate: 0.7.4
|
||||
@@ -5509,20 +5381,6 @@ snapshots:
|
||||
stringify-entities: 4.0.4
|
||||
zwitch: 2.0.4
|
||||
|
||||
hast-util-to-html@9.0.5:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/unist': 3.0.3
|
||||
ccount: 2.0.1
|
||||
comma-separated-tokens: 2.0.3
|
||||
hast-util-whitespace: 3.0.0
|
||||
html-void-elements: 3.0.0
|
||||
mdast-util-to-hast: 13.2.0
|
||||
property-information: 7.0.0
|
||||
space-separated-tokens: 2.0.2
|
||||
stringify-entities: 4.0.4
|
||||
zwitch: 2.0.4
|
||||
|
||||
hast-util-to-parse5@8.0.0:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@@ -5753,11 +5611,6 @@ snapshots:
|
||||
dependencies:
|
||||
semver: 6.3.1
|
||||
|
||||
markdown-it-async@2.0.0:
|
||||
dependencies:
|
||||
'@types/markdown-it': 14.1.2
|
||||
markdown-it: 14.1.0
|
||||
|
||||
markdown-it-task-lists@2.1.1: {}
|
||||
|
||||
markdown-it@14.1.0:
|
||||
@@ -6174,12 +6027,6 @@ snapshots:
|
||||
regex: 5.1.1
|
||||
regex-recursion: 5.1.1
|
||||
|
||||
oniguruma-to-es@3.1.1:
|
||||
dependencies:
|
||||
emoji-regex-xs: 1.0.0
|
||||
regex: 6.0.1
|
||||
regex-recursion: 6.0.2
|
||||
|
||||
openai@4.80.1(zod@3.24.1):
|
||||
dependencies:
|
||||
'@types/node': 18.19.74
|
||||
@@ -6367,8 +6214,6 @@ snapshots:
|
||||
|
||||
property-information@6.5.0: {}
|
||||
|
||||
property-information@7.0.0: {}
|
||||
|
||||
prosemirror-changeset@2.2.1:
|
||||
dependencies:
|
||||
prosemirror-transform: 1.10.2
|
||||
@@ -6503,15 +6348,6 @@ snapshots:
|
||||
|
||||
react-refresh@0.14.2: {}
|
||||
|
||||
react-textarea-autosize@8.5.7(@types/react@18.3.18)(react@18.3.1):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.26.9
|
||||
react: 18.3.1
|
||||
use-composed-ref: 1.4.0(@types/react@18.3.18)(react@18.3.1)
|
||||
use-latest: 1.3.0(@types/react@18.3.18)(react@18.3.1)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
|
||||
react-tooltip@5.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.6.13
|
||||
@@ -6545,27 +6381,17 @@ snapshots:
|
||||
dependencies:
|
||||
picomatch: 2.3.1
|
||||
|
||||
regenerator-runtime@0.14.1: {}
|
||||
|
||||
regex-recursion@5.1.1:
|
||||
dependencies:
|
||||
regex: 5.1.1
|
||||
regex-utilities: 2.3.0
|
||||
|
||||
regex-recursion@6.0.2:
|
||||
dependencies:
|
||||
regex-utilities: 2.3.0
|
||||
|
||||
regex-utilities@2.3.0: {}
|
||||
|
||||
regex@5.1.1:
|
||||
dependencies:
|
||||
regex-utilities: 2.3.0
|
||||
|
||||
regex@6.0.1:
|
||||
dependencies:
|
||||
regex-utilities: 2.3.0
|
||||
|
||||
rehype-external-links@3.0.0:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@@ -6829,17 +6655,6 @@ snapshots:
|
||||
'@shikijs/vscode-textmate': 10.0.1
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
shiki@3.1.0:
|
||||
dependencies:
|
||||
'@shikijs/core': 3.1.0
|
||||
'@shikijs/engine-javascript': 3.1.0
|
||||
'@shikijs/engine-oniguruma': 3.1.0
|
||||
'@shikijs/langs': 3.1.0
|
||||
'@shikijs/themes': 3.1.0
|
||||
'@shikijs/types': 3.1.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
signal-exit@4.1.0: {}
|
||||
|
||||
simple-swizzle@0.2.2:
|
||||
@@ -7097,25 +6912,6 @@ snapshots:
|
||||
escalade: 3.2.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
use-composed-ref@1.4.0(@types/react@18.3.18)(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.18
|
||||
|
||||
use-isomorphic-layout-effect@1.2.0(@types/react@18.3.18)(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.18
|
||||
|
||||
use-latest@1.3.0(@types/react@18.3.18)(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
use-isomorphic-layout-effect: 1.2.0(@types/react@18.3.18)(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.18
|
||||
|
||||
use-sync-external-store@1.4.0(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
@@ -2311,10 +2311,31 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"azuer-service-bus@gBuaVZfqJ0-g21sKohQtx.md": {
|
||||
"gBuaVZfqJ0-g21sKohQtx": {
|
||||
"title": "Azuer Service Bus",
|
||||
"description": "",
|
||||
"links": []
|
||||
"description": "Azure Service Bus is a scalable and reliable messaging platform that can handle a high volume of messages, it's also easy to use, has a lot of features like subscription, Topics, Dead Letter, and easy to integrate with other Azure services, and it's a managed service which means Microsoft takes care of the infrastructure and scaling. However, it's worth noting that Azure Service Bus is a paid service and the cost will depend on the number of messages and the size of the data that you are sending and receiving.\n\nTo learn more, visit the following links:",
|
||||
"links": [
|
||||
{
|
||||
"title": "Getting Started With Azure Service Bus and ASP.NET Core",
|
||||
"url": "https://www.c-sharpcorner.com/article/get-started-with-azure-service-bus-queues-asp-net-core-part-1/",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "How to Send & receive messages from Azure Service Bus queue (.NET)?",
|
||||
"url": "https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-dotnet-get-started-with-queues?tabs=passwordless",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "What is Azure Service Bus?",
|
||||
"url": "https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-messaging-overview",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "Explore top posts about Azure",
|
||||
"url": "https://app.daily.dev/tags/azure?ref=roadmapsh",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
},
|
||||
"SQKIUa_UsJ4cls-Vs9yHU": {
|
||||
"title": "Mass Transit",
|
||||
|
||||
@@ -3365,6 +3365,11 @@
|
||||
"title": "DCL",
|
||||
"url": "https://en.wikipedia.org/wiki/Data_Control_Language",
|
||||
"type": "article"
|
||||
},
|
||||
{
|
||||
"title": "DCL Commands",
|
||||
"url": "https://www.geeksforgeeks.org/sql-ddl-dql-dml-dcl-tcl-commands/",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -3412,8 +3417,14 @@
|
||||
},
|
||||
"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",
|
||||
"links": []
|
||||
"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:",
|
||||
"links": [
|
||||
{
|
||||
"title": "BASE Model vs. ACID Model",
|
||||
"url": "https://www.geeksforgeeks.org/acid-model-vs-base-model-for-database/",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
},
|
||||
"uqfeiQ9K--QkGNwks4kjk": {
|
||||
"title": "CAP Theorem",
|
||||
@@ -4084,8 +4095,29 @@
|
||||
},
|
||||
"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.",
|
||||
"links": []
|
||||
"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"
|
||||
}
|
||||
]
|
||||
},
|
||||
"cpQvB0qMDL3-NWret7oeA": {
|
||||
"title": "CPU Interrupts",
|
||||
|
||||
@@ -521,6 +521,11 @@
|
||||
"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",
|
||||
@@ -703,6 +708,11 @@
|
||||
"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"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -843,6 +853,11 @@
|
||||
"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/",
|
||||
@@ -1241,6 +1256,11 @@
|
||||
"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",
|
||||
@@ -1678,6 +1698,11 @@
|
||||
"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",
|
||||
@@ -1708,8 +1733,14 @@
|
||||
},
|
||||
"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.",
|
||||
"links": []
|
||||
"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"
|
||||
}
|
||||
]
|
||||
},
|
||||
"y8GaUNpaCT1Ai88wPOk6d": {
|
||||
"title": "tcpdump",
|
||||
@@ -2277,6 +2308,11 @@
|
||||
"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",
|
||||
@@ -3074,8 +3110,14 @@
|
||||
},
|
||||
"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.",
|
||||
"links": []
|
||||
"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"
|
||||
}
|
||||
]
|
||||
},
|
||||
"FxuMJmDoDkIsPFp2iocFg": {
|
||||
"title": "Group Policy",
|
||||
|
||||
@@ -19,6 +19,11 @@
|
||||
"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/",
|
||||
|
||||
@@ -986,6 +986,11 @@
|
||||
"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",
|
||||
@@ -1891,6 +1896,11 @@
|
||||
"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"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1988,6 +1998,11 @@
|
||||
"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,6 +670,11 @@
|
||||
"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",
|
||||
|
||||
@@ -334,6 +334,11 @@
|
||||
"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,6 +251,11 @@
|
||||
"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,6 +243,11 @@
|
||||
"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/",
|
||||
|
||||
@@ -732,6 +732,11 @@
|
||||
"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"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -971,6 +976,11 @@
|
||||
"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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -38,8 +38,14 @@
|
||||
},
|
||||
"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.",
|
||||
"links": []
|
||||
"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"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Lqe47l4j-C4OwkbkwPYry": {
|
||||
"title": "Application Architecture",
|
||||
@@ -97,8 +103,14 @@
|
||||
},
|
||||
"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",
|
||||
"links": []
|
||||
"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"
|
||||
}
|
||||
]
|
||||
},
|
||||
"fBd2m8tMJmhuNSaakrpg4": {
|
||||
"title": "Design & Architecture",
|
||||
@@ -842,8 +854,14 @@
|
||||
},
|
||||
"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.",
|
||||
"links": []
|
||||
"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"
|
||||
}
|
||||
]
|
||||
},
|
||||
"OaLmlfkZid7hKqJ9G8oNV": {
|
||||
"title": "Architecture",
|
||||
@@ -1838,6 +1856,11 @@
|
||||
"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",
|
||||
|
||||
@@ -652,8 +652,14 @@
|
||||
},
|
||||
"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 };",
|
||||
"links": []
|
||||
"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"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ib0jfZzukYOZ42AdJqt_W": {
|
||||
"title": "Classes",
|
||||
|
||||
@@ -573,6 +573,11 @@
|
||||
"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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -18,16 +18,3 @@ 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: false,
|
||||
isNew: true,
|
||||
icon: {
|
||||
glyph: 'user',
|
||||
classes: 'h-4 w-4',
|
||||
@@ -56,7 +56,7 @@ const sidebarLinks = [
|
||||
},
|
||||
{
|
||||
href: '/account/road-card',
|
||||
title: 'Road Card',
|
||||
title: 'Card',
|
||||
id: 'road-card',
|
||||
isNew: false,
|
||||
icon: {
|
||||
@@ -64,16 +64,6 @@ 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',
|
||||
@@ -107,7 +97,7 @@ const sidebarLinks = [
|
||||
}`}
|
||||
>
|
||||
<AstroIcon icon={'users'} class={`h-4 w-4 mr-2`} />
|
||||
Teams
|
||||
Teams
|
||||
</a>
|
||||
</li>
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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 />;
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
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,5 +1,6 @@
|
||||
import { getPercentage } from '../../lib/number';
|
||||
import { getPercentage } from '../../helper/number';
|
||||
import type { UserProgress } from '../TeamProgress/TeamProgressPage';
|
||||
import { ArrowUpRight, ExternalLink } from 'lucide-react';
|
||||
|
||||
type DashboardProgressCardProps = {
|
||||
progress: UserProgress;
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import { SearchIcon, WandIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { UserCoursesList } from './UserCoursesList';
|
||||
|
||||
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 handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && keyword.trim()) {
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
function onSubmit() {
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/ai-tutor/search?term=${encodeURIComponent(keyword)}&difficulty=${difficulty}`;
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { AICourseWithLessonCount } from '../../queries/ai-course';
|
||||
import type { DifficultyLevel } from './AICourse';
|
||||
import { BookOpen } from 'lucide-react';
|
||||
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,512 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
BookOpenCheck,
|
||||
ChevronLeft,
|
||||
Loader2,
|
||||
Menu,
|
||||
X,
|
||||
CircleAlert,
|
||||
Play,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { type AiCourse } from '../../lib/ai';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { slugify } from '../../lib/slugger';
|
||||
import { getAiCourseProgressOptions } from '../../queries/ai-course';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { CheckIcon } from '../ReactIcons/CheckIcon';
|
||||
import { ErrorIcon } from '../ReactIcons/ErrorIcon';
|
||||
import { AICourseLimit } from './AICourseLimit';
|
||||
import { AICourseSidebarModuleList } from './AICourseSidebarModuleList';
|
||||
import { AICourseLesson } from './AICourseLesson';
|
||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||
import { AILimitsPopup } from './AILimitsPopup';
|
||||
import { RegenerateOutline } from './RegenerateOutline';
|
||||
import { useIsPaidUser } from '../../queries/billing';
|
||||
|
||||
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 { data: aiCourseProgress } = useQuery(
|
||||
getAiCourseProgressOptions({ aiCourseSlug: courseSlug || '' }),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
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 = aiCourseProgress?.done?.length || 0;
|
||||
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 icon = isLimitReached ? (
|
||||
<CircleAlert className="mb-4 size-16 text-yellow-500" />
|
||||
) : (
|
||||
<ErrorIcon additionalClasses="mb-4 size-16" />
|
||||
);
|
||||
const title = isLimitReached ? 'Limit Reached' : 'Error Generating Course';
|
||||
const message = isLimitReached
|
||||
? 'You have reached the daily AI usage limit. Please upgrade your account to continue.'
|
||||
: error;
|
||||
return (
|
||||
<>
|
||||
{modals}
|
||||
<div className="flex h-screen flex-col items-center justify-center px-4 text-center">
|
||||
{icon}
|
||||
<h1 className="text-2xl font-bold">{title}</h1>
|
||||
<p className="my-3 max-w-sm text-balance text-gray-500">{message}</p>
|
||||
|
||||
{isLimitReached && (
|
||||
<div className="mt-4">
|
||||
{!isPaidUser && (
|
||||
<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-4 text-sm text-black">
|
||||
<a href="/ai-tutor" className="underline underline-offset-2">
|
||||
Back to AI Tutor
|
||||
</a>
|
||||
</p>
|
||||
</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!}
|
||||
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(courseModule.title)}__${slugify(lesson)}`;
|
||||
const isCompleted =
|
||||
aiCourseProgress?.done.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>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-10 bg-gray-900 bg-opacity-50 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
></div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { BookOpen, Bot, Code, 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: (
|
||||
<Code className="size-4 shrink-0 text-yellow-600" strokeWidth={2.5} />
|
||||
),
|
||||
title: 'Code Help',
|
||||
description: 'Share your code and ask me to help you debug it',
|
||||
},
|
||||
{
|
||||
icon: <Bot className="size-4 shrink-0 text-yellow-600" strokeWidth={2.5} />,
|
||||
title: 'Best Practices',
|
||||
description: 'Share your code and ask me the best way to do something',
|
||||
},
|
||||
] as const;
|
||||
@@ -1,355 +0,0 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Loader2Icon,
|
||||
LockIcon,
|
||||
XIcon,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
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,
|
||||
getAiCourseProgressOptions,
|
||||
type AICourseProgressDocument,
|
||||
} from '../../queries/ai-course';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { AICourseFollowUp } from './AICourseFollowUp';
|
||||
import './AICourseFollowUp.css';
|
||||
import { useIsPaidUser } from '../../queries/billing';
|
||||
|
||||
type AICourseLessonProps = {
|
||||
courseSlug: 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,
|
||||
|
||||
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 { data: aiCourseProgress } = useQuery(
|
||||
getAiCourseProgressOptions({ aiCourseSlug: courseSlug || '' }),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
const lessonId = `${slugify(currentModuleTitle)}__${slugify(currentLessonTitle)}`;
|
||||
const isLessonDone = aiCourseProgress?.done.includes(lessonId);
|
||||
|
||||
const { isPaidUser } = useIsPaidUser();
|
||||
|
||||
const abortController = useMemo(
|
||||
() => new AbortController(),
|
||||
[activeModuleIndex, activeLessonIndex],
|
||||
);
|
||||
|
||||
const generateAiCourseContent = async () => {
|
||||
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({
|
||||
moduleTitle: currentModuleTitle,
|
||||
lessonTitle: currentLessonTitle,
|
||||
modulePosition: activeModuleIndex,
|
||||
lessonPosition: activeLessonIndex,
|
||||
totalLessonsInModule: totalLessons,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
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<AICourseProgressDocument>(
|
||||
`/v1-toggle-done-ai-lesson/${courseSlug}`,
|
||||
{
|
||||
lessonId,
|
||||
},
|
||||
);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(
|
||||
['ai-course-progress', { aiCourseSlug: courseSlug }],
|
||||
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;
|
||||
|
||||
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">
|
||||
<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>
|
||||
|
||||
<button
|
||||
onClick={onGoToNextLesson}
|
||||
disabled={cantGoForward}
|
||||
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',
|
||||
)}
|
||||
>
|
||||
Next <span className="hidden lg:inline"> Lesson</span>
|
||||
<ChevronRight size={16} className="ml-2" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isGenerating && !isLoading && (
|
||||
<AICourseFollowUp
|
||||
courseSlug={courseSlug}
|
||||
moduleTitle={currentModuleTitle}
|
||||
lessonTitle={currentLessonTitle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
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 { getAiCourseProgressOptions } from '../../queries/ai-course';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
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 { data: aiCourseProgress } = useQuery(
|
||||
getAiCourseProgressOptions({ aiCourseSlug: courseSlug || '' }),
|
||||
queryClient,
|
||||
);
|
||||
|
||||
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) => {
|
||||
const key = `${slugify(courseModule.title)}__${slugify(lesson)}`;
|
||||
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(courseModule.title)}__${slugify(lesson)}`;
|
||||
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 gap-2.5 w-full cursor-pointer items-center 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>
|
||||
);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getUrlParams } from '../../lib/browser';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { type AiCourse } from '../../lib/ai';
|
||||
import { AICourseContent } from './AICourseContent';
|
||||
import { generateCourse } from '../../helper/generate-ai-course';
|
||||
|
||||
type GenerateAICourseProps = {};
|
||||
|
||||
export function GenerateAICourse(props: GenerateAICourseProps) {
|
||||
const [term, setTerm] = useState('');
|
||||
const [difficulty, setDifficulty] = 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: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (term || difficulty) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = getUrlParams();
|
||||
const paramsTerm = params?.term;
|
||||
const paramsDifficulty = params?.difficulty;
|
||||
if (!paramsTerm || !paramsDifficulty) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTerm(paramsTerm);
|
||||
setDifficulty(paramsDifficulty);
|
||||
handleGenerateCourse({ term: paramsTerm, difficulty: paramsDifficulty });
|
||||
}, [term, difficulty]);
|
||||
|
||||
const handleGenerateCourse = async (options: {
|
||||
term: string;
|
||||
difficulty: string;
|
||||
isForce?: boolean;
|
||||
prompt?: string;
|
||||
}) => {
|
||||
const { term, difficulty, isForce, prompt } = 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,
|
||||
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,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
getAiCourseOptions,
|
||||
getAiCourseProgressOptions,
|
||||
} from '../../queries/ai-course';
|
||||
import { queryClient } from '../../stores/query-client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AICourseContent } from './AICourseContent';
|
||||
import { generateAiCourseStructure } from '../../lib/ai';
|
||||
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 }),
|
||||
select: (data) => {
|
||||
return {
|
||||
...data,
|
||||
course: generateAiCourseStructure(data.data),
|
||||
};
|
||||
},
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
data: rawData,
|
||||
},
|
||||
);
|
||||
},
|
||||
onLoadingChange: (isNewLoading) => {
|
||||
setIsRegenerating(isNewLoading);
|
||||
if (!isNewLoading) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getAiCourseProgressOptions({
|
||||
aiCourseSlug: courseSlug,
|
||||
}).queryKey,
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: setError,
|
||||
isForce: true,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AICourseContent
|
||||
course={{
|
||||
title: aiCourse?.title || '',
|
||||
modules: aiCourse?.course.modules || [],
|
||||
difficulty: aiCourse?.difficulty || 'Easy',
|
||||
}}
|
||||
isLoading={isLoading || isRegenerating}
|
||||
courseSlug={courseSlug}
|
||||
error={error}
|
||||
onRegenerateOutline={handleRegenerateCourse}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { PenSquare, RefreshCcw } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { useIsPaidUser } from '../../queries/billing';
|
||||
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);
|
||||
|
||||
const { isPaidUser } = useIsPaidUser();
|
||||
|
||||
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={() => {
|
||||
if (!isPaidUser) {
|
||||
setIsDropdownVisible(false);
|
||||
setShowUpgradeModal(true);
|
||||
} else {
|
||||
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);
|
||||
if (!isPaidUser) {
|
||||
setShowUpgradeModal(true);
|
||||
} else {
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
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 && (
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ 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,
|
||||
@@ -30,7 +31,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, readAIRoadmapStream } from '../../lib/ai.ts';
|
||||
import { IS_KEY_ONLY_ROADMAP_GENERATION } 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, X } from 'lucide-react';
|
||||
import { Ban, Cog, Contact, FileText, User, UserRound, 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 w-full items-center justify-between gap-2 border-b bg-gray-300 px-3 py-2 text-sm font-medium lg:hidden"
|
||||
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"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
{relatedTitle}
|
||||
|
||||
@@ -20,12 +20,12 @@ import { CourseAnnouncement } from '../SQLCourse/CourseAnnouncement';
|
||||
<Icon icon='logo' />
|
||||
</a>
|
||||
|
||||
<!-- <a
|
||||
href='/ai-tutor'
|
||||
<a
|
||||
href='/roadmaps'
|
||||
class='group relative inline text-gray-400 hover:text-white sm:hidden'
|
||||
>
|
||||
AI Tutor
|
||||
</a> -->
|
||||
Roadmaps
|
||||
</a>
|
||||
|
||||
<!-- Desktop navigation items -->
|
||||
<div class='hidden gap-5 sm:flex sm:items-center'>
|
||||
@@ -34,27 +34,15 @@ import { CourseAnnouncement } from '../SQLCourse/CourseAnnouncement';
|
||||
Start Here
|
||||
</a>
|
||||
<RoadmapDropdownMenu client:load />
|
||||
<!-- <a
|
||||
href='/ai-tutor'
|
||||
class='group relative mr-3 text-blue-300 hover:text-white'
|
||||
>
|
||||
AI Tutor
|
||||
<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>
|
||||
</a> -->
|
||||
<a
|
||||
href='/teams'
|
||||
class='group relative hidden text-gray-400 hover:text-white xl:block'
|
||||
>
|
||||
<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-gray-400 hover:text-white md:block'
|
||||
>
|
||||
Changelog
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ type PaginationProps = {
|
||||
totalCount: number;
|
||||
isDisabled?: boolean;
|
||||
onPageChange: (page: number) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Pagination(props: PaginationProps) {
|
||||
@@ -23,7 +22,6 @@ export function Pagination(props: PaginationProps) {
|
||||
currPage,
|
||||
perPage,
|
||||
isDisabled = false,
|
||||
className,
|
||||
} = props;
|
||||
|
||||
if (!totalPages || totalPages === 1) {
|
||||
@@ -34,14 +32,10 @@ export function Pagination(props: PaginationProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center',
|
||||
{
|
||||
'justify-between': variant === 'default',
|
||||
'justify-start': variant === 'minimal',
|
||||
},
|
||||
className,
|
||||
)}
|
||||
className={cn('flex items-center', {
|
||||
'justify-between': variant === 'default',
|
||||
'justify-start': variant === 'minimal',
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-1 text-xs font-medium">
|
||||
<button
|
||||
|
||||
@@ -10,8 +10,11 @@ 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 gap-2 text-center sm:justify-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 sm:justify-center gap-2 text-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">
|
||||
@@ -19,7 +22,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-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-sm 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>
|
||||
|
||||
@@ -311,7 +311,7 @@ export function SQLCoursePage() {
|
||||
|
||||
<SectionHeader
|
||||
title="Not your average SQL course"
|
||||
description="Built around a text-based interactive approach and packed with practical challenges, this comprehensive SQL bootcamp stands out with features that make it truly unique."
|
||||
description="This SQL programming class is designed to help you go from beginner to expert through hands-on practice with real-world scenarios, mastering everything from basic to complex queries."
|
||||
className="mt-16 md:mt-20"
|
||||
/>
|
||||
|
||||
@@ -370,7 +370,9 @@ export function SQLCoursePage() {
|
||||
|
||||
<SectionHeader
|
||||
title="Course Overview"
|
||||
description="This SQL programming class is designed to help you go from beginner to expert through hands-on practice with real-world scenarios, mastering everything from basic to complex queries."
|
||||
description="The course is designed to help you go from SQL beginner to expert
|
||||
through hands-on practice with real-world scenarios, mastering
|
||||
everything from basic to complex queries."
|
||||
className="mt-8 md:mt-24"
|
||||
/>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type {
|
||||
GetUserProfileRoadmapResponse,
|
||||
GetPublicProfileResponse,
|
||||
} from '../../api/user';
|
||||
import { getPercentage } from '../../lib/number';
|
||||
import { getPercentage } from '../../helper/number';
|
||||
import { PrivateProfileBanner } from './PrivateProfileBanner';
|
||||
import { UserProfileRoadmapRenderer } from './UserProfileRoadmapRenderer';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getPercentage } from '../../helper/number';
|
||||
import { getRelativeTimeString } from '../../lib/date';
|
||||
import { getPercentage } from '../../lib/number';
|
||||
|
||||
type UserPublicProgressStats = {
|
||||
resourceType: 'roadmap';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { GetPublicProfileResponse } from '../../api/user';
|
||||
import { getPercentage } from '../../lib/number';
|
||||
import { UserPublicProgressStats } from './UserPublicProgressStats';
|
||||
import { getPercentage } from '../../helper/number.ts';
|
||||
|
||||
type UserPublicProgressesProps = {
|
||||
userId: string;
|
||||
@@ -72,15 +73,15 @@ export function UserPublicProgresses(props: UserPublicProgressesProps) {
|
||||
target="_blank"
|
||||
key={roadmap.id + counter}
|
||||
href={`/${roadmap.id}?s=${userId}`}
|
||||
className="group relative flex items-center justify-between overflow-hidden rounded-md border border-gray-300 bg-white px-3 py-2 text-left text-sm transition-all hover:border-gray-400"
|
||||
className="relative group border-gray-300 flex items-center justify-between rounded-md border bg-white px-3 py-2 text-left text-sm transition-all hover:border-gray-400 overflow-hidden"
|
||||
>
|
||||
<span className="flex-grow truncate">{roadmap.title}</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{percentageDone}%
|
||||
{parseInt(percentageDone, 10)}%
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="absolute left-0 top-0 block h-full cursor-pointer rounded-tl-md bg-black/5 transition-colors group-hover:bg-black/10"
|
||||
className="absolute transition-colors left-0 top-0 block h-full cursor-pointer rounded-tl-md bg-black/5 group-hover:bg-black/10"
|
||||
style={{
|
||||
width: `${percentageDone}%`,
|
||||
}}
|
||||
|
||||
@@ -68,7 +68,7 @@ Usually saved in a .js file extension, this code will show **"Sign up on roadmap
|
||||
|
||||
### Java vs. JavaScript: Execution and runtime
|
||||
|
||||
Java is often used for server-side programming. It is a compiled language that requires a Java Virtual Machine **(JVM)** to run Java code. Before running your code, it's compiled into bytecode, a platform-independent format. The JVM then executes the bytecode to ensure that Java programs run on any system with JVM installed. It makes Java a good choice for large-scale applications that run on any operating system. These operating systems include Windows, macOS, Linux, and mobile devices.
|
||||
Java is often used for server-side programming. It is a compiled language that requires a [Java Virtual Machine](https://www.geeksforgeeks.org/jvm-works-jvm-architecture/) **(JVM)** to run Java code. Before running your code, it's compiled into bytecode, a platform-independent format. The JVM then executes the bytecode to ensure that Java programs run on any system with JVM installed. It makes Java a good choice for large-scale applications that run on any operating system. These operating systems include Windows, macOS, Linux, and mobile devices.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@ jsonUrl: '/jsons/roadmaps/ai-data-scientist.json'
|
||||
pdfUrl: '/pdfs/roadmaps/ai-data-scientist.pdf'
|
||||
order: 5
|
||||
renderer: 'editor'
|
||||
briefTitle: 'AI and Data Scientist Roadmap'
|
||||
briefTitle: 'AI and Data Scientist'
|
||||
briefDescription: 'Step by step guide to becoming an AI and Data Scientist in 2025'
|
||||
title: 'AI and Data Scientist Roadmap'
|
||||
description: 'Step by step roadmap guide to becoming an AI and Data Scientist in 2025'
|
||||
title: 'AI and Data Scientist'
|
||||
description: 'Step by step guide to becoming an AI and Data Scientist in 2025'
|
||||
hasTopics: true
|
||||
isNew: false
|
||||
dimensions:
|
||||
|
||||
@@ -5,3 +5,7 @@ The rise in popularity of NoSQL databases provided a flexible and fluidity with
|
||||
- **B**asically **A**vailable
|
||||
- **S**oft state
|
||||
- **E**ventual consistency
|
||||
|
||||
Visit the following resources to learn more:
|
||||
|
||||
- [@article@BASE Model vs. ACID Model](https://www.geeksforgeeks.org/acid-model-vs-base-model-for-database/)
|
||||
@@ -4,4 +4,5 @@ DCL includes commands such as GRANT and REVOKE which mainly deal with the rights
|
||||
|
||||
Visit the following resources to learn more:
|
||||
|
||||
- [@article@DCL](https://en.wikipedia.org/wiki/Data_Control_Language)
|
||||
- [@article@DCL](https://en.wikipedia.org/wiki/Data_Control_Language)
|
||||
- [@article@DCL Commands](https://www.geeksforgeeks.org/sql-ddl-dql-dml-dcl-tcl-commands/)
|
||||
@@ -13,3 +13,10 @@ Here is the list of some of the most commonly used scheduling algorithms:
|
||||
- **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.
|
||||
- **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.
|
||||
- **Lottery Scheduling:** The process is allocated the CPU based on a lottery system. It is a preemptive algorithm.
|
||||
|
||||
Visit the following resources to learn more
|
||||
|
||||
- [@video@Introduction to CPU Scheduling](https://youtu.be/EWkQl0n0w5M?si=Lb-PxN_t-rDfn4JL)
|
||||
- [@article@CPU Scheduling in Operating Systems - geeksforgeeks](https://www.geeksforgeeks.org/cpu-scheduling-in-operating-systems/)
|
||||
- [@article@Lottery Scheduling for Operating Systems - geeksforgeeks](https://www.geeksforgeeks.org/lottery-process-scheduling-in-operating-system/)
|
||||
- [@article@Program for Round Robin Scheduling for the same Arrival time - geeksforgeeks](https://www.geeksforgeeks.org/program-for-round-robin-scheduling-for-the-same-arrival-time/)
|
||||
|
||||
@@ -7,4 +7,5 @@ Visit the following resources to learn more:
|
||||
- [@article@What is DNS cache poisoning? | DNS spoofing](https://www.cloudflare.com/learning/dns/dns-cache-poisoning/)
|
||||
|
||||
- [@article@What Is DNS Poisoning?](https://www.fortinet.com/resources/cyberglossary/dns-poisoning)
|
||||
- [@article@DNS Spoofing or DNS Cache poisoning](https://www.geeksforgeeks.org/dns-spoofing-or-dns-cache-poisoning/)
|
||||
- [@article@DNS Poisoning (DNS Spoofing): Definition, Technique & Defense](https://www.okta.com/identity-101/dns-poisoning/)
|
||||
|
||||
@@ -4,4 +4,5 @@
|
||||
|
||||
Learn more from the following resources:
|
||||
|
||||
- [@article@What is a loopback address?](https://www.geeksforgeeks.org/what-is-a-loopback-address/)
|
||||
- [@article@Understanding the loopback address and loopback interfaces](https://study-ccna.com/loopback-interface-loopback-address/)
|
||||
@@ -14,3 +14,4 @@ This knowledge is essential for designing, implementing, and maintaining effecti
|
||||
Learn more from the following resources:
|
||||
|
||||
- [@article@What are Network Protocols?](https://www.solarwinds.com/resources/it-glossary/network-protocols)
|
||||
- [@article@Types of Network Topology](https://www.geeksforgeeks.org/types-of-network-topology/)
|
||||
|
||||
@@ -4,4 +4,5 @@
|
||||
|
||||
Learn more from the following resources:
|
||||
|
||||
- [@article@What is an operating system?](https://www.geeksforgeeks.org/what-is-an-operating-system/)
|
||||
- [@video@What is an operating system as fast as possible](https://www.youtube.com/watch?v=pVzRTmdd9j0)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# Port Blocking
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Learn more from the following resources:
|
||||
|
||||
- [@article@What is port blocking with LAN?](https://www.geeksforgeeks.org/what-is-port-blocking-within-lan/)
|
||||
|
||||
@@ -4,4 +4,5 @@
|
||||
|
||||
Learn more from the following resources:
|
||||
|
||||
- [@article@What is a protocol analyzer?](https://www.geeksforgeeks.org/what-is-protocol-analyzer/)
|
||||
- [@video@Protocol Analyzers](https://www.youtube.com/watch?v=hTMhlB-o0Ow)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# route
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Learn more from the following resources:
|
||||
|
||||
- [@article@How to check the routing table in Linux](https://www.geeksforgeeks.org/route-command-in-linux-with-examples/)
|
||||
|
||||
@@ -4,4 +4,5 @@ A star network topology is a configuration where all devices (nodes) are connect
|
||||
|
||||
Learn more from the following resources:
|
||||
|
||||
- [@article@Advantages and Disadvantages of Star Topology](https://www.geeksforgeeks.org/advantages-and-disadvantages-of-star-topology/)
|
||||
- [@video@Star Topology](https://www.youtube.com/watch?v=EQ3rW22-Py0)
|
||||
@@ -4,4 +4,5 @@ In networking and cybersecurity, a handshake is a process of establishing a secu
|
||||
|
||||
Learn more from the following resources:
|
||||
|
||||
- [@article@TCP 3-Way Handshake Process](https://www.geeksforgeeks.org/tcp-3-way-handshake-process/)
|
||||
- [@video@TLS Handshake Explained](https://www.youtube.com/watch?v=86cQJ0MMses)
|
||||
@@ -11,6 +11,7 @@ Data Analysts, as ambassadors of this domain, employ these types, to answer vari
|
||||
|
||||
Visit the following resources to learn more:
|
||||
|
||||
- [@article@Data Analytics and its type](https://www.geeksforgeeks.org/data-analytics-and-its-type/)
|
||||
- [@article@The 4 Types of Data Analysis: Ultimate Guide](https://careerfoundry.com/en/blog/data-analytics/different-types-of-data-analysis/)
|
||||
- [@video@Descriptive vs Diagnostic vs Predictive vs Prescriptive Analytics: What's the Difference?](https://www.youtube.com/watch?v=QoEpC7jUb9k)
|
||||
- [@video@Types of Data Analytics](https://www.youtube.com/watch?v=lsZnSgxMwBA)
|
||||
|
||||
@@ -4,4 +4,5 @@ Data structures are crucial in the field of computer science and coding because
|
||||
|
||||
Learn more from the following links:
|
||||
|
||||
- [@article@Why Data Structures and Algorithms Are Important to Learn?](https://www.geeksforgeeks.org/why-data-structures-and-algorithms-are-important-to-learn/)
|
||||
- [@video@What are Data Structures? Why is it Important?](https://www.youtube.com/watch?v=18V8Avz2OH8)
|
||||
|
||||
@@ -4,5 +4,6 @@ Heap Sort is an efficient, comparison-based sorting algorithm. It utilizes a dat
|
||||
|
||||
Learn more from the following resources:
|
||||
|
||||
- [@article@Heap Sort - W3Schools](https://www.geeksforgeeks.org/heap-sort/)
|
||||
- [@article@Heap Sort Visualize](https://www.hackerearth.com/practice/algorithms/sorting/heap-sort/tutorial/)
|
||||
- [@video@Heap sort in 4 minutes](https://www.youtube.com/watch?v=2DmK_H7IdTo)
|
||||
|
||||
@@ -5,4 +5,5 @@ Linear search is one of the simplest search algorithms. In this method, every el
|
||||
Learn more from the following resources:
|
||||
|
||||
- [@article@DSA Linear Search - W3Schools](https://www.w3schools.com/dsa/dsa_algo_linearsearch.php)
|
||||
- [@article@Linear Search - GeeksForGeeks](https://www.geeksforgeeks.org/linear-search/)
|
||||
- [@video@Learn Linear Search in 3 minutes](https://www.youtube.com/watch?v=246V51AWwZM)
|
||||
|
||||
@@ -4,4 +4,5 @@ An **AVL tree** is a type of binary search tree that is self-balancing, which me
|
||||
|
||||
Learn more from the following links:
|
||||
|
||||
- [@article@AVL Tree Data Structure](https://www.geeksforgeeks.org/introduction-to-avl-tree/)
|
||||
- [@video@AVL trees in 5 minutes — Intro & Search](https://www.youtube.com/watch?v=DB1HFCEdLxA)
|
||||
|
||||
@@ -4,4 +4,5 @@ B-Tree is a self-balanced search tree data structure that maintains sorted data
|
||||
|
||||
Learn more from the following links:
|
||||
|
||||
- [@article@Introduction of B-Tree](https://www.geeksforgeeks.org/introduction-of-b-tree-2/)
|
||||
- [@video@B-trees in 4 minutes — Intro](https://www.youtube.com/watch?v=FgWbADOG44s)
|
||||
|
||||
@@ -4,4 +4,6 @@
|
||||
|
||||
Learn more from the following resources:
|
||||
|
||||
- [@article@Introduction of B Tree](https://www.geeksforgeeks.org/introduction-of-b-tree-2/)
|
||||
- [@article@Introduction of B+ Tree](https://www.geeksforgeeks.org/introduction-of-b-tree/)
|
||||
- [@video@B Trees and B+ Trees. How they are useful in Databases](https://www.youtube.com/watch?v=aZjYr87r1b8)
|
||||
|
||||
@@ -4,4 +4,5 @@ A **Skip List** is a probabilistic data structure that allows efficient search,
|
||||
|
||||
Learn more from the following resources:
|
||||
|
||||
- [@article@Skip List – Efficient Search, Insert and Delete in Linked List](https://www.geeksforgeeks.org/skip-list/)
|
||||
- [@video@Skip Lists](https://www.youtube.com/watch?v=NDGpsfwAaqo)
|
||||
|
||||
@@ -4,4 +4,5 @@ ISAM, which stands for Indexed Sequential Access Method, is a type of disk stora
|
||||
|
||||
Learn more from the following resources:
|
||||
|
||||
- [@article@ISAM in Database](https://www.geeksforgeeks.org/isam-in-database/)
|
||||
- [@video@DBMS - Index Sequential Access Method (ISAM)](https://www.youtube.com/watch?v=EiW1VVPor10)
|
||||
|
||||
@@ -4,4 +4,5 @@ Backtracking is a powerful algorithmic technique that aims to solve a problem in
|
||||
|
||||
Learn more from the following links:
|
||||
|
||||
- [@article@Backtracking Algorithm](https://www.geeksforgeeks.org/backtracking-algorithms/)
|
||||
- [@video@What is backtracking?](https://www.youtube.com/watch?v=Peo7k2osVVs)
|
||||
|
||||
@@ -4,4 +4,5 @@ Greedy algorithms follow the problem-solving heuristic of making the locally opt
|
||||
|
||||
Learn more from the following links:
|
||||
|
||||
- [@article@Greedy Algorithm Tutorial – Examples, Application and Practice Problem](https://www.geeksforgeeks.org/introduction-to-greedy-algorithm-data-structures-and-algorithm-tutorials/)
|
||||
- [@video@Greedy Algorithms Tutorial ](https://www.youtube.com/watch?v=bC7o8P_Ste4)
|
||||
|
||||
@@ -4,4 +4,5 @@ Randomised algorithms are a type of algorithm that employs a degree of randomnes
|
||||
|
||||
Learn more from the following links:
|
||||
|
||||
- [@article@Randomized Algorithms](https://www.geeksforgeeks.org/randomized-algorithms/)
|
||||
- [@video@Algorithm Classification Randomized Algorithm](https://www.youtube.com/watch?v=J_EVG6yCOz0)
|
||||
|
||||
@@ -4,4 +4,5 @@ Divide and conquer is a powerful algorithm design technique that solves a proble
|
||||
|
||||
Learn more from the following links:
|
||||
|
||||
- [@article@Introduction to Divide and Conquer Algorithm](https://www.geeksforgeeks.org/introduction-to-divide-and-conquer-algorithm/)
|
||||
- [@video@Divide & Conquer Algorithm In 3 Minutes](https://www.youtube.com/watch?v=YOh6hBtX5l0)
|
||||
|
||||
@@ -4,4 +4,5 @@ Recursion is a method where the solution to a problem depends on solutions to sh
|
||||
|
||||
Learn more from the following links:
|
||||
|
||||
- [@article@Introduction to Recursion](https://www.geeksforgeeks.org/introduction-to-recursion-2/)
|
||||
- [@video@Recursion in 100 Seconds](https://www.youtube.com/watch?v=rf60MejMz3E)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
Learn more from the following links:
|
||||
|
||||
- [@article@Dynamic Programming (DP) Tutorial with Problems](https://www.geeksforgeeks.org/introduction-to-dynamic-programming-data-structures-and-algorithm-tutorials/)
|
||||
- [@article@Getting Started with Dynamic Programming in Data Structures and Algorithms](https://medium.com/@PythonicPioneer/getting-started-with-dynamic-programming-in-data-structures-and-algorithms-126c7a16775c)
|
||||
- [@video@What Is Dynamic Programming and How To Use It](https://www.youtube.com/watch?v=vYquumk4nWw&t=4s)
|
||||
- [@video@5 Simple Steps for Solving Dynamic Programming Problems](https://www.youtube.com/watch?v=aPQY__2H3tE)
|
||||
|
||||
@@ -4,6 +4,7 @@ The **two-pointer technique** is a strategy that can be used to solve certain ty
|
||||
|
||||
Learn more from the following links:
|
||||
|
||||
- [@article@Two Pointers Technique](https://www.geeksforgeeks.org/two-pointers-technique/)
|
||||
- [@article@Two Pointers Technique](https://medium.com/@johnnyJK/data-structures-and-algorithms-907a63d691c1)
|
||||
- [@article@Mastering the Two Pointers Technique: An In-Depth Guide](https://lordkonadu.medium.com/mastering-the-two-pointers-technique-an-in-depth-guide-3c2167584ccc)
|
||||
- [@video@Visual introduction Two Pointer Algorithm](https://www.youtube.com/watch?v=On03HWe2tZM)
|
||||
|
||||
@@ -4,5 +4,6 @@ The **Sliding Window Technique** is an algorithmic paradigm that manages a subse
|
||||
|
||||
Learn more from the following links:
|
||||
|
||||
- [@article@Sliding Window Technique](https://www.geeksforgeeks.org/window-sliding-technique/)
|
||||
- [@article@Mastering Sliding Window Techniques](https://medium.com/@rishu__2701/mastering-sliding-window-techniques-48f819194fd7)
|
||||
- [@video@Sliding window technique](https://www.youtube.com/watch?v=p-ss2JNynmw)
|
||||
|
||||
@@ -5,3 +5,4 @@ Bind mounts have limited functionality compared to volumes. When you use a bind
|
||||
Visit the following resources to learn more:
|
||||
|
||||
- [@official@Docker Bind Mounts](https://docs.docker.com/storage/bind-mounts/)
|
||||
- [@article@How to Use Bind Mount in Docker?](https://www.geeksforgeeks.org/how-to-use-bind-mount-in-docker/)
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
# Variables
|
||||
|
||||
In Flutter, variables are used to store values. There are three types of variables in Flutter namely local, global and instance variables.Variables in Flutter can store values of different data types, such as numbers, strings, booleans, and more.
|
||||
In Flutter, variables are used to store values. There are two types of variables in Flutter:
|
||||
|
||||
- local variables: These are declared within a function and are only accessible within that function
|
||||
- Instance variables: They are declared within a class and are accessible throughout the entire class.
|
||||
- Global Variables: While not always recommended, Dart does allow variables to be declared globally (outside any class or function). These variables are accessible throughout the file in which they are declared and can also be accessed across libraries if properly imported.
|
||||
|
||||
Variables in Flutter can store values of different data types, such as numbers, strings, booleans, and more.
|
||||
|
||||
Visit the following resources to learn more:
|
||||
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
# Built-in Types
|
||||
|
||||
There are several built-in data types, including int, double, String, bool, List, Sets and Map. Additionally, there are other complex data types like dynamic, var, and Object in Dart programming language which is used in Flutter.
|
||||
There are several built-in data types, including:
|
||||
|
||||
- int: used to store integers
|
||||
- double: used to store floating-point numbers
|
||||
- String: used to store text
|
||||
- bool: used to store true or false values
|
||||
- List: used to store ordered collections of objects
|
||||
- Sets: used to store unordered collection of unique items
|
||||
- Map: used to store unordered collections of key-value pairs
|
||||
|
||||
Additionally, there are other complex data types like dynamic, var, and Object in Dart programming language which is used in Flutter.
|
||||
|
||||
Visit the following resources to learn more:
|
||||
|
||||
- [@official@Built-in types](https://dart.dev/guides/language/language-tour#built-in-types)
|
||||
- [@official@Overview of Built-in Types](https://dart.dev/guides/language/coming-from/js-to-dart#built-in-types)
|
||||
- [@article@Built-in types](https://dart.dev/guides/language/language-tour#built-in-types)
|
||||
- [@article@Overview of Built-in Types](https://dart.dev/guides/language/coming-from/js-to-dart#built-in-types)
|
||||
- [@article@Collections | Dart](https://dart.dev/language/collections)
|
||||
|
||||
@@ -4,4 +4,5 @@ Dart is a true object-oriented language, so even functions are objects and have
|
||||
|
||||
Visit the following resources to learn more:
|
||||
|
||||
- [@official@Functions](https://dart.dev/guides/language/language-tour#functions)
|
||||
- [@article@Functions](https://dart.dev/guides/language/language-tour#functions)
|
||||
- [@article@Dart Function](https://www.javatpoint.com/dart-function)
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
# Operators
|
||||
|
||||
Flutter, and Dart, utilize various operators to manipulate data: arithmetic operators for math, relational operators for comparisons, logical operators for boolean logic, assignment operators for value assignment, and the ternary operator for concise conditional expressions, enabling diverse operations on values and variables.
|
||||
Operators are symbols or keywords used to perform operations on values. There are several types of operators available in Flutter:
|
||||
|
||||
- Arithmetic operators: used to perform mathematical operations like addition (+), subtraction (-), multiplication (\*), division (/), and more.
|
||||
- Relational operators: used to compare values and return a boolean result (==, !=, >, <, >=, <=).
|
||||
- Logical operators: used to perform logical operations like AND (&&), OR (||), and NOT (!).
|
||||
- Assignment operators: used to assign values to variables (=, +=, -=, \*=, /=, %=).
|
||||
- Ternary operator: a shorthand way of writing simple if-else statements (condition ? if_true : if_false).
|
||||
|
||||
These operators can be used to perform operations on values, variables, and expressions in Flutter.
|
||||
|
||||
Visit the following resources to learn more:
|
||||
|
||||
- [@official@Operators](https://dart.dev/guides/language/language-tour#operators)
|
||||
- [@article@Operators](https://dart.dev/guides/language/language-tour#operators)
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
# Control Flow Statements
|
||||
|
||||
Dart's control flow statements manage program execution: `if-else` for conditional logic, `for`, `while`, and `do-while` loops for repetition, `switch-case` for multi-way selection, and `break` and `continue` to alter loop behavior, enabling complex program logic.
|
||||
In Dart, control flow statements are used to control the flow of execution of a program. The following are the main types of control flow statements in Dart:
|
||||
|
||||
- if-else: used to conditionally execute code based on a boolean expression.
|
||||
- for loop: used to repeat a block of code a specific number of times.
|
||||
- while loop: used to repeat a block of code as long as a given condition is true.
|
||||
- do-while loop: similar to the while loop, but the block of code is executed at least once before the condition is evaluated.
|
||||
- switch-case: used to select one of several code blocks to execute based on a value.
|
||||
- break: used to exit a loop early.
|
||||
- continue: used to skip the current iteration of a loop and continue with the next one.
|
||||
|
||||
These control flow statements can be used to create complex logic and control the flow of execution in Dart programs.
|
||||
|
||||
Visit the following resources to learn more:
|
||||
|
||||
- [@official@Branches](https://dart.dev/language/branches)
|
||||
- [@official@Loops](https://dart.dev/language/loops)
|
||||
- [@article@Branches in Dart](https://dart.dev/language/branches)
|
||||
- [@article@Loops in Dart](https://dart.dev/language/loops)
|
||||
|
||||
@@ -8,9 +8,10 @@ Flutter CLI (Command Line Interface) is a command-line tool that is used to deve
|
||||
- Updating the Flutter framework and packages
|
||||
- Analyzing the performance of Flutter apps
|
||||
|
||||
By using the Flutter CLI, developers can streamline the development process and automate repetitive tasks. The Flutter CLI is included in the Flutter SDK and is available for Windows, macOS, and Linux.
|
||||
Visit the following resources to learn more:
|
||||
|
||||
- [@official@The Flutter CLI](https://docs.flutter.dev/reference/flutter-cli)
|
||||
- [@official@CLI Packages in Flutter](https://dart.dev/server/libraries#command-line-packages)
|
||||
- [@official@Get Started with Flutter CLI](https://dart.dev/tutorials/server/get-started)
|
||||
- [@article@The Flutter command-line tool](https://docs.flutter.dev/reference/flutter-cli)
|
||||
- [@article@CLI Packages in Flutter](https://dart.dev/server/libraries#command-line-packages)
|
||||
- [@article@Get started with Flutter CLI](https://dart.dev/tutorials/server/get-started)
|
||||
- [@feed@Explore top posts about CLI](https://app.daily.dev/tags/cli?ref=roadmapsh)
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
# VS Code
|
||||
|
||||
To use VS Code for Flutter development, you must install the Flutter and Dart plugins for VS Code. These plugins support Flutter-specific features such as syntax highlighting, debugging, and hot reloading.
|
||||
|
||||
Here are the steps to set up VS Code for Flutter development:
|
||||
|
||||
- Install VS Code from the official website: https://code.visualstudio.com/
|
||||
- Open VS Code and click the Extensions icon on the left-hand side of the window.
|
||||
- In the search box, type "Flutter" and press Enter. This will display a list of Flutter-related plugins.
|
||||
- Install the "Flutter" and "Dart" plugins by clicking the Install button next to each one.
|
||||
- Once the plugins are installed, you will need to restart VS Code for the changes to take effect.
|
||||
- To create a new Flutter project, click the File menu, then select New > New Project. This will open the New Project dialog box.
|
||||
- Select the Flutter application template, enter the project's name and location and click Create. This will create a new Flutter project in the specified location.
|
||||
- To run the project, open the command palette (Ctrl + Shift + P on Windows or Cmd + Shift + P on Mac) and type "flutter run". This will run the project on the default emulator or device.
|
||||
|
||||
That's it! You should now be able to use VS Code for Flutter development.
|
||||
|
||||
@@ -12,6 +12,6 @@ By providing a rich set of tools and features for Flutter development, Android S
|
||||
|
||||
Learn more from the following links:
|
||||
|
||||
- [@official@Android Studio for Flutter](https://docs.flutter.dev/development/tools/android-studio)
|
||||
- [@official@Get started with Android Studio](https://dart.dev/tools/jetbrains-plugin)
|
||||
- [@article@Android Studio for Flutter](https://docs.flutter.dev/development/tools/android-studio)
|
||||
- [@article@Get started with Android Studio](https://dart.dev/tools/jetbrains-plugin)
|
||||
- [@feed@Explore top posts about Android](https://app.daily.dev/tags/android?ref=roadmapsh)
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
# IntelliJ IDEA
|
||||
|
||||
IntelliJ IDEA is a powerful Integrated Development Environment (IDE) created by JetBrains. Essentially, it's a software application that provides comprehensive facilities to computer programmers for software development.
|
||||
|
||||
Learn more from the following:
|
||||
|
||||
- [@official@IntelliJ IDEA](https://www.jetbrains.com/idea/)
|
||||
- [@article@IntelliJ IDEA for Flutter](https://docs.flutter.dev/development/tools/android-studio)
|
||||
- [@article@Get started with IntelliJ](https://dart.dev/tools/jetbrains-plugin)
|
||||
- [@article@IntelliJ IDEA](https://www.jetbrains.com/idea/)
|
||||
- [@feed@Explore top posts about DevTools](https://app.daily.dev/tags/devtools?ref=roadmapsh)
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# IDEs
|
||||
|
||||
An IDE (Integrated Development Environment) is a software application that provides a comprehensive environment for coding, debugging, testing, and deploying software.
|
||||
An IDE (Integrated Development Environment) is a software application that provides a comprehensive environment for coding, debugging, testing, and deploying software. There are several IDEs that support Flutter development, including:
|
||||
|
||||
- Android Studio: Google's official IDE for Android development, which also supports Flutter development.
|
||||
- Visual Studio Code: a popular, free, and open-source code editor that can be extended with plugins, including the Flutter extension.
|
||||
- IntelliJ IDEA: a commercial Java IDE that also supports Flutter development.
|
||||
- Xcode: Apple's official IDE for iOS development, which also supports Flutter development for macOS and iOS.
|
||||
|
||||
These IDEs provide a variety of features and tools to assist in the development of Flutter apps, including code completion, debugging, testing, and more. Developers can choose the IDE that works best for their needs and preferences.
|
||||
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
Flutter version manager is a tool used to manage different versions of Flutter SDK on a developer's machine. Flutter is a popular open-source mobile application development framework, and its SDK is updated frequently with new features, bug fixes, and improvements. However, sometimes developers need to work with older versions of Flutter due to various reasons like compatibility issues or project requirements.
|
||||
|
||||
Visit the following resources to learn more:
|
||||
Flutter version manager allows developers to easily switch between different versions of the Flutter SDK on their machine without having to uninstall or manually install each version. It provides a command-line interface (CLI) that enables developers to install, list, and switch between different Flutter SDK versions.
|
||||
|
||||
- [@official@Flutter Version Manager](https://fvm.app/)
|
||||
- [@official@Flutter Version Manager - Documentation](https://fvm.app/documentation/getting-started)
|
||||
Flutter version manager also allows developers to easily manage their Flutter channel, which determines the frequency of SDK updates they receive. For example, developers can switch between the stable, beta, or dev channel based on their preferences.
|
||||
|
||||
Using Flutter version manager can help developers ensure that their project works with the desired version of Flutter SDK and minimize the time and effort required to manage multiple Flutter SDK versions.
|
||||
|
||||
Here are some of the links
|
||||
|
||||
- [@official@Flutter Version Manager - Official Website](https://fvm.app/)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user