Compare commits

..

3 Commits

Author SHA1 Message Date
Arik Chakma
0264b88069 Merge branch 'master' into fix/activity 2024-05-08 04:16:18 +06:00
Arik Chakma
755e494224 fix: comma and gap 2024-05-03 23:32:44 +06:00
Arik Chakma
f6d4da48f9 fix: change topicIds to topicTitles 2024-05-03 04:57:59 +06:00
7038 changed files with 324723 additions and 269487 deletions

View File

@@ -3,6 +3,6 @@
"enabled": false
},
"_variables": {
"lastUpdateCheck": 1721257136269
"lastUpdateCheck": 1714413381505
}
}

View File

@@ -1,6 +1,6 @@
name: "✍️ Missing or Deprecated Roadmap Topics"
name: "✍️ Suggest Changes"
description: Help us improve the roadmaps by suggesting changes
labels: [topic-change]
labels: [suggestion]
assignees: []
body:
- type: markdown

View File

@@ -1,50 +0,0 @@
name: Close PRs with Feedback
on:
workflow_dispatch:
schedule:
- cron: '0 0 * * *'
jobs:
close-pr:
runs-on: ubuntu-latest
steps:
- name: Close PR if it has label "feedback left" and no changes in 7 days
uses: actions/github-script@v3
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { data: pullRequests } = await github.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
base: 'master',
});
for (const pullRequest of pullRequests) {
const { data: labels } = await github.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
});
const feedbackLabel = labels.find((label) => label.name === 'feedback left');
if (feedbackLabel) {
const lastUpdated = new Date(pullRequest.updated_at);
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
if (lastUpdated < sevenDaysAgo) {
await github.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
body: 'Closing this PR because there has been no activity for the past 7 days. Feel free to reopen if you have any feedback.',
});
await github.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pullRequest.number,
state: 'closed',
});
}
}
}

View File

@@ -1,6 +1,10 @@
name: Clears Cloudfront Cache
on:
# Allow manual Run
workflow_dispatch:
# Run at midnight utc
schedule:
- cron: '0 0 * * *'
jobs:
aws_costs:
runs-on: ubuntu-latest

View File

@@ -1,6 +1,9 @@
name: Deploy to EC2
on:
workflow_dispatch: # allow manual run
push:
branches:
- master
jobs:
deploy:
runs-on: ubuntu-latest
@@ -56,17 +59,4 @@ jobs:
key: ${{ secrets.EC2_PRIVATE_KEY }}
script: |
cd /var/www/roadmap.sh
sudo pm2 restart web-roadmap
# --------------------
# Clear cloudfront cache
# --------------------
- name: Clear Cloudfront Caching
run: |
curl -L \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.GH_PAT }}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/roadmapsh/infra-ansible/actions/workflows/playbook.yml/dispatches \
-d '{ "ref":"master", "inputs": { "playbook": "roadmap_web.yml", "tags": "cloudfront", "is_verbose": false } }'
sudo pm2 restart web-roadmap

View File

@@ -1,38 +0,0 @@
name: Label Issue
on:
issues:
types: [ opened, edited ]
jobs:
label-topic-change-issue:
runs-on: ubuntu-latest
steps:
- name: Add roadmap slug to issue as label
uses: actions/github-script@v3
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const issue = context.payload.issue;
const roadmapUrl = issue.body.match(/https?:\/\/roadmap.sh\/[^ ]+/);
// if the issue is labeled as a topic-change, add the roadmap slug as a label
if (issue.labels.some(label => label.name === 'topic-change')) {
if (roadmapUrl) {
const roadmapSlug = new URL(roadmapUrl[0]).pathname.replace(/\//, '');
github.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: [roadmapSlug]
});
}
// Close the issue if it has no roadmap URL
if (!roadmapUrl) {
github.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: 'closed'
});
}
}

2
.gitignore vendored
View File

@@ -31,5 +31,3 @@ tests-examples
/editor/*
!/editor/readonly-editor.tsx
!/editor/renderer/renderer.ts
!/editor/renderer/index.tsx

View File

@@ -11,9 +11,6 @@ import react from '@astrojs/react';
// https://astro.build/config
export default defineConfig({
site: 'https://roadmap.sh/',
experimental: {
rewriting: true,
},
markdown: {
shikiConfig: {
theme: 'dracula',

View File

@@ -2,97 +2,40 @@
First of all thank you for considering to contribute. Please look at the details below:
- [New Roadmaps](#new-roadmaps)
- [Existing Roadmaps](#existing-roadmaps)
- [Adding Content](#adding-content)
- [Guidelines](#guidelines)
- [Contribution](#contribution)
- [New Roadmaps](#new-roadmaps)
- [Existing Roadmaps](#existing-roadmaps)
- [Adding Content](#adding-content)
- [Guidelines](#guidelines)
## New Roadmaps
For new roadmaps, you can either:
- Submit a roadmap by providing [a textual roadmap similar to this roadmap](https://gist.github.com/kamranahmedse/98758d2c73799b3a6ce17385e4c548a5) in an [issue](https://github.com/kamranahmedse/developer-roadmap/issues).
- Create an interactive roadmap yourself using [our roadmap editor](https://draw.roadmap.sh/) & submit the link to that roadmap in an [issue](https://github.com/kamranahmedse/developer-roadmap/issues).
For new roadmaps, submit a roadmap by providing [a textual roadmap similar to this roadmap](https://gist.github.com/kamranahmedse/98758d2c73799b3a6ce17385e4c548a5) in an issue.
## Existing Roadmaps
For the existing roadmaps, please follow the details listed for the nature of contribution:
- **Fixing Typos** — Make your changes in the [roadmap JSON file](https://github.com/kamranahmedse/developer-roadmap/tree/master/src/data/roadmaps) and submit a [PR](https://github.com/kamranahmedse/developer-roadmap/pulls).
- **Adding or Removing Nodes** — Please open an [issue](https://github.com/kamranahmedse/developer-roadmap/issues) with your suggestion.
- **Fixing Typos** — Make your changes in the [roadmap JSON file](https://github.com/kamranahmedse/developer-roadmap/tree/master/src/data/roadmaps)
- **Adding or Removing Nodes** — Please open an issue with your suggestion.
**Note:** Please note that our goal is <strong>not to have the biggest list of items</strong>. Our goal is to list items or skills most relevant today.
**Note:** Please note that our goal is not to have the biggest list of items. Our goal is to list items or skills most relevant today.
## Adding Content
Find [the content directory inside the relevant roadmap](https://github.com/kamranahmedse/developer-roadmap/tree/master/src/data/roadmaps). Please keep the following guidelines in mind when submitting content:
- Content must be in English.
- Maximum of 8 links per topic.
- Follow the below style guide for content.
### How To Structure Content
Please adhere to the following style when adding content to a topic:
```
# Topic Title
(Content)
Visit the following resources to learn more:
- [@type@Description of link](Link)
```
`@type@` must be one of the following and describes the type of content you are adding:
- `@official@`
- `@opensource@`
- `@article@`
- `@course@`
- `@podcast@`
- `@video@`
It's important to add a valid type, this will help us categorize the content and display it properly on the roadmap.
- Put a brief description about the topic on top of the file and the a list of links below with each link having title of the URL.
## Guidelines
- <p><strong>Please don't use the project for self-promotion!</strong><br />
We believe this project is a valuable asset to the developer community and it includes numerous helpful resources. We kindly ask you to avoid submitting pull requests for the sole purpose of self-promotion. We appreciate contributions that genuinely add value, such as guides from maintainers of well-known frameworks, and will consider accepting these even if they're self authored. Thank you for your understanding and cooperation!
- <p><strong>Adding everything available out there is not the goal!</strong><br />
The roadmaps represent the skillset most valuable today, i.e., if you were to enter any of the listed fields today, what would you learn? There might be things that are of-course being used today but prioritize the things that are most in demand today, e.g., agreed that lots of people are using angular.js today but you wouldn't want to learn that instead of React, Angular, or Vue. Use your critical thinking to filter out non-essential stuff. Give honest arguments for why the resource should be included.</p>
The roadmaps represent the skillset most valuable today, i.e., if you were to enter any of the listed fields today, what would you learn?! There might be things that are of-course being used today but prioritize the things that are most in demand today, e.g., agreed that lots of people are using angular.js today but you wouldn't want to learn that instead of React, Angular, or Vue. Use your critical thinking to filter out non-essential stuff. Give honest arguments for why the resource should be included.</p>
- <p><strong>Do not add things you have not evaluated personally!</strong><br />
Use your critical thinking to filter out non-essential stuff. Give honest arguments for why the resource should be included. Have you read this book? Can you give a short article?</p>
- <p><strong>Create a Single PR for Content Additions</strong></p>
If you are planning to contribute by adding content to the roadmaps, I recommend you to clone the repository, add content to the [content directory of the roadmap](./src/data/roadmaps/) and create a single PR to make it easier for me to review and merge the PR.
- <p><strong>Write meaningful commit messages</strong><br >
Meaningful commit messages help speed up the review process as well as help other contributors in gaining a good overview of the repositories commit history without having to dive into every commit.
</p>
- <p><strong>Look at the existing issues/pull requests before opening new ones</strong></p>
### Good vs Not So Good Contributions
<strong>Good</strong>
- New Roadmaps.
- Engaging, fresh content links.
- Typos and grammatical fixes.
- Content copy in topics that do not have any (or minimal copy exists).
<strong>Not So Good</strong>
- Adding whitespace that doesn't add to the readability of the content.
- Rewriting content in a way that doesn't add any value.
- Non-English content.
- PR's that don't follow our style guide, have no description and a default title.
- Links to your own blog articles.
- Write meaningful commit messages
- Look at the existing issues/pull requests before opening new ones

View File

@@ -1,14 +0,0 @@
export function Renderer(props: any) {
return (
<div className="fixed bottom-0 left-0 right-0 top-0 z-[9999] border bg-white p-5 text-black">
<h2 className="mb-2 text-xl font-semibold">Private Component</h2>
<p className="mb-4">
Renderer is a private component. If you are a collaborator and have
access to it. Run the following command:
</p>
<code className="mt-5 rounded-md bg-gray-800 p-2 text-white">
npm run generate-renderer
</code>
</div>
);
}

View File

@@ -1,5 +0,0 @@
export function renderFlowJSON(data: any, options?: any) {
console.warn("renderFlowJSON is not implemented");
console.warn("run the following command to generate the renderer:");
console.warn("> npm run generate-renderer");
}

12019
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,36 +9,30 @@
"build": "astro build",
"preview": "astro preview",
"format": "prettier --write .",
"gh-labels": "./scripts/create-roadmap-labels.sh",
"astro": "astro",
"deploy": "NODE_DEBUG=gh-pages gh-pages -d dist -t",
"upgrade": "ncu -u",
"roadmap-links": "node scripts/roadmap-links.cjs",
"roadmap-dirs": "node scripts/roadmap-dirs.cjs",
"roadmap-assets": "tsx scripts/editor-roadmap-assets.ts",
"editor-roadmap-dirs": "tsx scripts/editor-roadmap-dirs.ts",
"editor-roadmap-content": "tsx scripts/editor-roadmap-content.ts",
"roadmap-content": "node scripts/roadmap-content.cjs",
"generate-renderer": "sh scripts/generate-renderer.sh",
"best-practice-dirs": "node scripts/best-practice-dirs.cjs",
"best-practice-content": "node scripts/best-practice-content.cjs",
"generate:og": "node ./scripts/generate-og-images.mjs",
"warm:urls": "sh ./scripts/warm-urls.sh https://roadmap.sh/sitemap-0.xml",
"compress:images": "tsx ./scripts/compress-images.ts",
"test:e2e": "playwright test"
},
"dependencies": {
"@astrojs/node": "^8.3.2",
"@astrojs/react": "^3.6.0",
"@astrojs/sitemap": "^3.1.6",
"@astrojs/node": "^8.2.5",
"@astrojs/react": "^3.3.1",
"@astrojs/sitemap": "^3.1.4",
"@astrojs/tailwind": "^5.1.0",
"@fingerprintjs/fingerprintjs": "^4.4.3",
"@fingerprintjs/fingerprintjs": "^4.3.0",
"@nanostores/react": "^0.7.2",
"@napi-rs/image": "^1.9.2",
"@resvg/resvg-js": "^2.6.2",
"@types/react": "^18.3.3",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"astro": "^4.11.5",
"astro": "^4.7.0",
"clsx": "^2.1.1",
"dayjs": "^1.11.11",
"dom-to-image": "^2.6.0",
@@ -46,48 +40,47 @@
"gray-matter": "^4.0.3",
"htm": "^3.1.1",
"image-size": "^1.1.1",
"jose": "^5.6.3",
"jose": "^5.2.4",
"js-cookie": "^3.0.5",
"lucide-react": "^0.399.0",
"lucide-react": "^0.376.0",
"nanoid": "^5.0.7",
"nanostores": "^0.10.3",
"node-html-parser": "^6.1.13",
"npm-check-updates": "^16.14.20",
"playwright": "^1.45.2",
"prismjs": "^1.29.0",
"react": "^18.3.1",
"react-calendar-heatmap": "^1.9.0",
"react-confetti": "^6.1.0",
"react-dom": "^18.3.1",
"react-tooltip": "^5.27.1",
"reactflow": "^11.11.4",
"react-tooltip": "^5.26.4",
"reactflow": "^11.11.2",
"rehype-external-links": "^3.0.0",
"remark-parse": "^11.0.0",
"roadmap-renderer": "^1.0.6",
"satori": "^0.10.14",
"satori": "^0.10.13",
"satori-html": "^0.3.2",
"sharp": "^0.33.4",
"sharp": "^0.33.3",
"slugify": "^1.6.6",
"tailwind-merge": "^2.4.0",
"tailwindcss": "^3.4.6",
"unified": "^11.0.5",
"zustand": "^4.5.4"
"tailwind-merge": "^2.3.0",
"tailwindcss": "^3.4.3",
"unified": "^11.0.4",
"zustand": "^4.5.2"
},
"devDependencies": {
"@playwright/test": "^1.45.2",
"@playwright/test": "^1.43.1",
"@tailwindcss/typography": "^0.5.13",
"@types/dom-to-image": "^2.6.7",
"@types/js-cookie": "^3.0.6",
"@types/prismjs": "^1.26.4",
"@types/prismjs": "^1.26.3",
"@types/react-calendar-heatmap": "^1.6.7",
"csv-parser": "^3.0.0",
"gh-pages": "^6.1.1",
"js-yaml": "^4.1.0",
"markdown-it": "^14.1.0",
"openai": "^4.52.7",
"prettier": "^3.3.3",
"prettier-plugin-astro": "^0.14.1",
"prettier-plugin-tailwindcss": "^0.6.5",
"tsx": "^4.16.2"
"openai": "^4.38.5",
"prettier": "^3.2.5",
"prettier-plugin-astro": "^0.13.0",
"prettier-plugin-tailwindcss": "^0.5.14",
"tsx": "^4.7.3"
}
}

10183
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 723 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 672 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 675 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 KiB

View File

@@ -36,16 +36,13 @@ Here is the list of available roadmaps with more being actively worked upon.
- [Backend Roadmap](https://roadmap.sh/backend) / [Backend Beginner Roadmap](https://roadmap.sh/backend?r=backend-beginner)
- [DevOps Roadmap](https://roadmap.sh/devops) / [DevOps Beginner Roadmap](https://roadmap.sh/devops?r=devops-beginner)
- [Full Stack Roadmap](https://roadmap.sh/full-stack)
- [API Design Roadmap](https://roadmap.sh/api-design)
- [Computer Science Roadmap](https://roadmap.sh/computer-science)
- [Data Structures and Algorithms Roadmap](https://roadmap.sh/datastructures-and-algorithms)
- [AI and Data Scientist Roadmap](https://roadmap.sh/ai-data-scientist)
- [AWS Roadmap](https://roadmap.sh/aws)
- [Linux Roadmap](https://roadmap.sh/linux)
- [Terraform Roadmap](https://roadmap.sh/terraform)
- [Data Analyst Roadmap](https://roadmap.sh/data-analyst)
- [MLOps Roadmap](https://roadmap.sh/mlops)
- [Product Manager Roadmap](https://roadmap.sh/product-manager)
- [QA Roadmap](https://roadmap.sh/qa)
- [Python Roadmap](https://roadmap.sh/python)
- [Software Architect Roadmap](https://roadmap.sh/software-architect)
@@ -61,7 +58,6 @@ Here is the list of available roadmaps with more being actively worked upon.
- [Node.js Roadmap](https://roadmap.sh/nodejs)
- [GraphQL Roadmap](https://roadmap.sh/graphql)
- [Android Roadmap](https://roadmap.sh/android)
- [iOS Roadmap](https://roadmap.sh/ios)
- [Flutter Roadmap](https://roadmap.sh/flutter)
- [Go Roadmap](https://roadmap.sh/golang)
- [Rust Roadmap](https://roadmap.sh/rust)
@@ -80,7 +76,6 @@ Here is the list of available roadmaps with more being actively worked upon.
- [Docker Roadmap](https://roadmap.sh/docker)
- [Prompt Engineering Roadmap](https://roadmap.sh/prompt-engineering)
- [Technical Writer Roadmap](https://roadmap.sh/technical-writer)
- [DevRel Engineer Roadmap](https://roadmap.sh/devrel)
There are also interactive best practices:
@@ -95,8 +90,6 @@ There are also interactive best practices:
- [JavaScript Questions](https://roadmap.sh/questions/javascript)
- [Node.js Questions](https://roadmap.sh/questions/nodejs)
- [React Questions](https://roadmap.sh/questions/react)
- [Backend Questions](https://roadmap.sh/questions/backend)
- [Frontend Questions](https://roadmap.sh/questions/frontend)
![](https://i.imgur.com/waxVImv.png)
@@ -116,7 +109,6 @@ Clone the repository, install the dependencies and start the application
```bash
git clone git@github.com:kamranahmedse/developer-roadmap.git
cd developer-roadmap
npm install
npm run dev
```

View File

@@ -1,189 +0,0 @@
const fs = require('node:fs');
const path = require('node:path');
const allRoadmapDirs = fs.readdirSync(
path.join(__dirname, '../src/data/roadmaps'),
);
allRoadmapDirs.forEach((roadmapId) => {
const roadmapDir = path.join(
__dirname,
`../src/data/roadmaps/${roadmapId}/content`,
);
function getHostNameWithoutTld(hostname) {
const parts = hostname.split('.');
return parts.slice(0, parts.length - 1).join('.');
}
function isOfficialWebsite(hostname, fileName, roadmapId) {
fileName = fileName.replace('/index.md', '').replace('.md', '');
const parts = fileName.split('/');
const lastPart = parts[parts.length - 1];
const normalizedFilename = lastPart.replace(/\d+/g, '').replace(/-/g, '');
const normalizedHostname = getHostNameWithoutTld(hostname);
if (normalizedFilename === normalizedHostname) {
return true;
}
if (normalizedFilename.includes(normalizedHostname)) {
return true;
}
return !!roadmapId.includes(normalizedHostname);
}
// websites are educational websites that are of following types:
// - @official@
// - @article@
// - @course@
// - @opensource@
// - @podcast@
// - @video@
// - @website@
// content is only educational websites
function getTypeFromHostname(hostname, fileName, roadmapId) {
hostname = hostname.replace('www.', '');
const videoHostnames = ['youtube.com', 'vimeo.com', 'youtu.be'];
const courseHostnames = ['coursera.org', 'udemy.com', 'edx.org'];
const podcastHostnames = ['spotify.com', 'apple.com'];
const opensourceHostnames = ['github.com', 'gitlab.com'];
const articleHostnames = [
'neilpatel.com',
'learningseo.io',
'htmlreference.io',
'docs.gitlab.com',
'docs.github.com',
'skills.github.com',
'cloudflare.com',
'w3schools.com',
'medium.com',
'dev.to',
'web.dev',
'css-tricks.com',
'developer.mozilla.org',
'smashingmagazine.com',
'freecodecamp.org',
'cs.fyi',
'thenewstack.io',
'html5rocks.com',
'html.com',
'javascript.info',
'css-tricks.com',
'developer.apple.com',
];
if (articleHostnames.includes(hostname)) {
return 'article';
}
if (videoHostnames.includes(hostname)) {
return 'video';
}
if (courseHostnames.includes(hostname)) {
return 'course';
}
if (podcastHostnames.includes(hostname)) {
return 'podcast';
}
if (opensourceHostnames.includes(hostname)) {
return 'opensource';
}
if (hostname === 'roadmap.sh') {
return 'roadmap.sh';
}
if (isOfficialWebsite(hostname, fileName, roadmapId)) {
return 'official';
}
return 'article';
}
function readNestedMarkdownFiles(dir, files = []) {
const dirEnts = fs.readdirSync(dir, { withFileTypes: true });
for (const dirent of dirEnts) {
const fullPath = path.join(dir, dirent.name);
if (dirent.isDirectory()) {
readNestedMarkdownFiles(fullPath, files);
} else {
if (path.extname(fullPath) === '.md') {
files.push(fullPath);
}
}
}
return files;
}
const files = readNestedMarkdownFiles(roadmapDir);
// for each of the files, assign the type of link to the beginning of each markdown link
// i.e. - [@article@abc](xyz) where @article@ is the type of link. Possible types:
// - @official@
// - @opensource@
// - @article@
// - @course@
// - @opensource@
// - @podcast@
// - @video@
files.forEach((file) => {
const content = fs.readFileSync(file, 'utf-8');
const lines = content.split('\n');
const newContent = lines
.map((line) => {
if (line.startsWith('- [') && !line.startsWith('- [@')) {
const type = line.match(/@(\w+)@/);
if (type) {
return line;
}
let urlMatches = line.match(/\((https?:\/\/[^)]+)\)/);
let fullUrl = urlMatches?.[1];
if (!fullUrl) {
// is it slashed URL i.e. - [abc](/xyz)
fullUrl = line.match(/\((\/[^)]+)\)/)?.[1];
if (fullUrl) {
fullUrl = `https://roadmap.sh${fullUrl}`;
}
if (!fullUrl) {
console.error('Invalid URL found in:', file);
return line;
}
}
const url = new URL(fullUrl);
const hostname = url.hostname;
let urlType = getTypeFromHostname(hostname, file, roadmapId);
const linkText = line.match(/\[([^\]]+)\]/)[1];
if (
linkText.toLowerCase().startsWith('visit dedicated') &&
linkText.toLowerCase().endsWith('roadmap')
) {
urlType = 'roadmap';
}
return line.replace('- [', `- [@${urlType}@`).replace('](', '](');
}
return line;
})
.join('\n');
fs.writeFileSync(file, newContent);
});
});

View File

@@ -1,31 +0,0 @@
#!/usr/bin/env bash
# Fetch issues JSON data and parse it properly
issues=$(gh issue list --repo kamranahmedse/developer-roadmap --search "sort:created-asc" --state open --limit 500 --json number,title,createdAt,updatedAt,state,url,comments,reactionGroups,body | jq -c '.[]')
# Loop through the issues and delete the ones created in 2022 and not updated in the past year
while IFS= read -r issue; do
created_at=$(echo "$issue" | jq -r '.createdAt')
updated_at=$(echo "$issue" | jq -r '.updatedAt')
issue_number=$(echo "$issue" | jq -r '.number')
issue_title=$(echo "$issue" | jq -r '.title')
reaction_groups=$(echo "$issue" | jq -r '.reactionGroups')
has_reactions=$(echo "$issue" | jq -r '.reactionGroups | length')
comment_count=$(echo "$issue" | jq -r '.comments | length')
body_characters=$(echo "$issue" | jq -r '.body | length')
# if has empty body
if [[ "$created_at" == 2024-01* ]]; then
comment="Hey there!
Looks like this issue has been hanging around for a bit without much action. Our roadmaps have evolved quite a bit since then, and a bunch of older issues aren't really applicable anymore. So, we're tidying things up by closing out the older ones to keep our issue tracker nice and organized for future feedback.
If you still think this problem needs addressing, don't hesitate to reopen the issue. We're here to help!
Thanks a bunch!"
gh issue comment "$issue_number" --body "$comment"
gh issue close "$issue_number"
fi
done <<< "$issues"

View File

@@ -1,11 +0,0 @@
#!/usr/bin/env bash
# get all the folder names inside src/data/roadmaps
roadmap_ids=$(ls src/data/roadmaps)
# create a label for each roadmap name on github issues using gh cli
for roadmap_id in $roadmap_ids
do
random_color=$(openssl rand -hex 3)
gh label create "$roadmap_id" --color $random_color --description "Roadmap: $roadmap_id"
done

View File

@@ -1,76 +0,0 @@
import playwright from 'playwright';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import matter from 'gray-matter';
import type { RoadmapFrontmatter } from '../src/lib/roadmap';
// ERROR: `__dirname` is not defined in ES module scope
// https://iamwebwiz.medium.com/how-to-fix-dirname-is-not-defined-in-es-module-scope-34d94a86694d
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Usage: tsx ./scripts/editor-roadmap-dirs.ts <roadmapId>
// Directory containing the roadmaps
const ROADMAP_CONTENT_DIR = path.join(__dirname, '../src/data/roadmaps');
const roadmapId = process.argv[2];
const allowedRoadmapIds = await fs.readdir(ROADMAP_CONTENT_DIR);
if (!roadmapId) {
console.error('Roadmap Id is required');
process.exit(1);
}
if (!allowedRoadmapIds.includes(roadmapId)) {
console.error(`Invalid roadmap key ${roadmapId}`);
console.error(`Allowed keys are ${allowedRoadmapIds.join(', ')}`);
process.exit(1);
}
const roadmapFrontmatterDir = path.join(
ROADMAP_CONTENT_DIR,
roadmapId,
`${roadmapId}.md`,
);
const roadmapFrontmatterRaw = await fs.readFile(roadmapFrontmatterDir, 'utf-8');
const { data } = matter(roadmapFrontmatterRaw);
const roadmapFrontmatter = data as RoadmapFrontmatter;
if (!roadmapFrontmatter) {
console.error('Invalid roadmap frontmatter');
process.exit(1);
}
if (roadmapFrontmatter.renderer !== 'editor') {
console.error('Only Editor Rendered Roadmaps are allowed');
process.exit(1);
}
console.log(`Launching chromium`);
const browser = await playwright.chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
const pageUrl = `http://localhost:3000/${roadmapId}/svg`;
console.log(`Opening page ${pageUrl}`);
await page.goto(pageUrl);
await page.waitForSelector('#resource-svg-wrap');
await page.waitForTimeout(5000);
console.log(`Generating PDF ${pageUrl}`);
await page.pdf({
path: `./public/pdfs/roadmaps/${roadmapId}.pdf`,
margin: { top: 0, right: 0, bottom: 0, left: 0 },
height: roadmapFrontmatter?.dimensions?.height || 2000,
width: roadmapFrontmatter?.dimensions?.width || 968,
});
// @todo generate png from the pdf
console.log(`Generating png ${pageUrl}`);
await page.locator('#resource-svg-wrap>svg').screenshot({
path: `./public/roadmaps/${roadmapId}.png`,
type: 'png',
scale: 'device',
});
await browser.close();

View File

@@ -1,185 +0,0 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import type { Edge, Node } from 'reactflow';
import matter from 'gray-matter';
import type { RoadmapFrontmatter } from '../src/lib/roadmap';
import { slugify } from '../src/lib/slugger';
import OpenAI from 'openai';
import { runPromisesInBatchSequentially } from '../src/lib/promise';
// ERROR: `__dirname` is not defined in ES module scope
// https://iamwebwiz.medium.com/how-to-fix-dirname-is-not-defined-in-es-module-scope-34d94a86694d
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Usage: tsx ./scripts/editor-roadmap-content.ts <roadmapId>
const OPEN_AI_API_KEY = process.env.OPEN_AI_API_KEY;
console.log('OPEN_AI_API_KEY:', OPEN_AI_API_KEY);
const ROADMAP_CONTENT_DIR = path.join(__dirname, '../src/data/roadmaps');
const roadmapId = process.argv[2];
const allowedRoadmapIds = await fs.readdir(ROADMAP_CONTENT_DIR);
if (!roadmapId) {
console.error('Roadmap Id is required');
process.exit(1);
}
if (!allowedRoadmapIds.includes(roadmapId)) {
console.error(`Invalid roadmap key ${roadmapId}`);
console.error(`Allowed keys are ${allowedRoadmapIds.join(', ')}`);
process.exit(1);
}
const roadmapFrontmatterDir = path.join(
ROADMAP_CONTENT_DIR,
roadmapId,
`${roadmapId}.md`,
);
const roadmapFrontmatterRaw = await fs.readFile(roadmapFrontmatterDir, 'utf-8');
const { data } = matter(roadmapFrontmatterRaw);
const roadmapFrontmatter = data as RoadmapFrontmatter;
if (!roadmapFrontmatter) {
console.error('Invalid roadmap frontmatter');
process.exit(1);
}
if (roadmapFrontmatter.renderer !== 'editor') {
console.error('Only Editor Rendered Roadmaps are allowed');
process.exit(1);
}
const roadmapDir = path.join(
ROADMAP_CONTENT_DIR,
roadmapId,
`${roadmapId}.json`,
);
const roadmapContent = await fs.readFile(roadmapDir, 'utf-8');
let { nodes, edges } = JSON.parse(roadmapContent) as {
nodes: Node[];
edges: Edge[];
};
const enrichedNodes = nodes
.filter(
(node) =>
node?.type &&
['topic', 'subtopic'].includes(node.type) &&
node.data?.label,
)
.map((node) => {
// Because we only need the parent id and title for subtopics
if (node.type !== 'subtopic') {
return node;
}
const parentNodeId =
edges.find((edge) => edge.target === node.id)?.source || '';
const parentNode = nodes.find((n) => n.id === parentNodeId);
return {
...node,
parentId: parentNodeId,
parentTitle: parentNode?.data?.label || '',
};
}) as (Node & { parentId?: string; parentTitle?: string })[];
const roadmapContentDir = path.join(ROADMAP_CONTENT_DIR, roadmapId, 'content');
const stats = await fs.stat(roadmapContentDir).catch(() => null);
if (!stats || !stats.isDirectory()) {
await fs.mkdir(roadmapContentDir, { recursive: true });
}
let openai: OpenAI | undefined;
if (OPEN_AI_API_KEY) {
openai = new OpenAI({
apiKey: OPEN_AI_API_KEY,
});
}
function writeTopicContent(
roadmapTitle: string,
childTopic: string,
parentTopic?: string,
) {
let prompt = `I will give you a topic and you need to write a brief introduction for that with regards to "${roadmapTitle}". Your format should be as follows and be in strictly markdown format:
# (Put a heading for the topic without adding parent "Subtopic in Topic" or "Topic in Roadmap" or "Subtopic under XYZ" etc.)
(Briefly explain the topic in one paragraph using simple english with regards to "${roadmapTitle}". Don't start with explaining how important the topic is with regard to "${roadmapTitle}". Don't say something along the lines of "XYZ plays a crucial role in ${roadmapTitle}". Don't include anything saying "In the context of ${roadmapTitle}". Instead, start with a simple explanation of the topic itself. For example, if the topic is "React", you can start with "React is a JavaScript library for building user interfaces." and then you can explain how it is used in "${roadmapTitle}".)
`;
if (!parentTopic) {
prompt += `First topic is: ${childTopic}`;
} else {
prompt += `First topic is: ${childTopic} under ${parentTopic}`;
}
return new Promise((resolve, reject) => {
openai?.chat.completions
.create({
model: 'gpt-4',
messages: [
{
role: 'user',
content: prompt,
},
],
})
.then((response) => {
const article = response.choices[0].message.content;
resolve(article);
})
.catch((err) => {
reject(err);
});
});
}
async function writeNodeContent(node: Node & { parentTitle?: string }) {
const nodeDirPattern = `${slugify(node.data.label)}@${node.id}.md`;
if (!roadmapContentFiles.includes(nodeDirPattern)) {
console.log(`Missing file for: ${nodeDirPattern}`);
return;
}
const nodeDir = path.join(roadmapContentDir, nodeDirPattern);
const nodeContent = await fs.readFile(nodeDir, 'utf-8');
const isFileEmpty = !nodeContent.replace(`# ${node.data.label}`, '').trim();
if (!isFileEmpty) {
console.log(`❌ Ignoring ${nodeDirPattern}. Not empty.`);
return;
}
const topic = node.data.label;
const parentTopic = node.parentTitle;
console.log(`⏳ Generating content for ${topic}...`);
let newContentFile = '';
if (OPEN_AI_API_KEY) {
newContentFile = (await writeTopicContent(
roadmapFrontmatter.title,
topic,
parentTopic,
)) as string;
} else {
newContentFile = `# ${topic}`;
}
await fs.writeFile(nodeDir, newContentFile, 'utf-8');
console.log(`✅ Content generated for ${topic}`);
}
let roadmapContentFiles = await fs.readdir(roadmapContentDir, {
recursive: true,
});
if (!OPEN_AI_API_KEY) {
console.log('----------------------------------------');
console.log('OPEN_AI_API_KEY not found. Skipping openai api calls...');
console.log('----------------------------------------');
}
const promises = enrichedNodes.map((node) => () => writeNodeContent(node));
await runPromisesInBatchSequentially(promises, 20);
console.log('✅ All content generated');

View File

@@ -1,86 +0,0 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import type { Node } from 'reactflow';
import matter from 'gray-matter';
import type { RoadmapFrontmatter } from '../src/lib/roadmap';
import { slugify } from '../src/lib/slugger';
// ERROR: `__dirname` is not defined in ES module scope
// https://iamwebwiz.medium.com/how-to-fix-dirname-is-not-defined-in-es-module-scope-34d94a86694d
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Usage: tsx ./scripts/editor-roadmap-dirs.ts <roadmapId>
// Directory containing the roadmaps
const ROADMAP_CONTENT_DIR = path.join(__dirname, '../src/data/roadmaps');
const roadmapId = process.argv[2];
const allowedRoadmapIds = await fs.readdir(ROADMAP_CONTENT_DIR);
if (!roadmapId) {
console.error('Roadmap Id is required');
process.exit(1);
}
if (!allowedRoadmapIds.includes(roadmapId)) {
console.error(`Invalid roadmap key ${roadmapId}`);
console.error(`Allowed keys are ${allowedRoadmapIds.join(', ')}`);
process.exit(1);
}
const roadmapFrontmatterDir = path.join(
ROADMAP_CONTENT_DIR,
roadmapId,
`${roadmapId}.md`,
);
const roadmapFrontmatterRaw = await fs.readFile(roadmapFrontmatterDir, 'utf-8');
const { data } = matter(roadmapFrontmatterRaw);
const roadmapFrontmatter = data as RoadmapFrontmatter;
if (!roadmapFrontmatter) {
console.error('Invalid roadmap frontmatter');
process.exit(1);
}
if (roadmapFrontmatter.renderer !== 'editor') {
console.error('Only Editor Rendered Roadmaps are allowed');
process.exit(1);
}
const roadmapDir = path.join(
ROADMAP_CONTENT_DIR,
roadmapId,
`${roadmapId}.json`,
);
const roadmapContent = await fs.readFile(roadmapDir, 'utf-8');
let { nodes } = JSON.parse(roadmapContent) as {
nodes: Node[];
};
nodes = nodes.filter(
(node) =>
node?.type && ['topic', 'subtopic'].includes(node.type) && node.data?.label,
);
const roadmapContentDir = path.join(ROADMAP_CONTENT_DIR, roadmapId, 'content');
const stats = await fs.stat(roadmapContentDir).catch(() => null);
if (!stats || !stats.isDirectory()) {
await fs.mkdir(roadmapContentDir, { recursive: true });
}
const roadmapContentFiles = await fs.readdir(roadmapContentDir, {
recursive: true,
});
nodes.forEach(async (node, index) => {
const nodeDirPattern = `${slugify(node.data.label)}@${node.id}.md`;
if (roadmapContentFiles.includes(nodeDirPattern)) {
console.log(`Skipping ${nodeDirPattern}`);
return;
}
await fs.writeFile(
path.join(roadmapContentDir, nodeDirPattern),
`# ${node.data.label}`,
);
});

View File

@@ -475,6 +475,8 @@ function getRoadmapDefaultTemplate({ title, description }) {
function getRoadmapImageTemplate({ title, description, image, height, width }) {
return html`<div tw="bg-white relative flex flex-col h-full w-full">
<div tw="absolute flex top-0 left-0 w-full h-[18px] bg-black"></div>
<div tw="flex flex-col px-[90px] pt-[90px]">
<div tw="flex flex-col pb-0">
<div tw="text-[70px] leading-[70px] tracking-tight">

View File

@@ -29,6 +29,4 @@ done
# ignore the worktree changes for the editor directory
git update-index --assume-unchanged editor/readonly-editor.tsx || true
git update-index --assume-unchanged editor/renderer/index.tsx || true
git update-index --assume-unchanged editor/renderer/renderer.ts || true
git update-index --assume-unchanged editor/readonly-editor.tsx || true

View File

@@ -1,41 +0,0 @@
#!/usr/bin/env bash
# Fetch issues JSON data and parse it properly
issues=$(gh issue list --repo kamranahmedse/developer-roadmap --search "sort:created-asc" --state open --limit 500 --json number,title,createdAt,updatedAt,state,url,comments,reactionGroups,body | jq -c '.[]')
# checks the body of issue, identifies the slug from the roadmap URLs
# and labels the issue with the corresponding slug
while IFS= read -r issue; do
created_at=$(echo "$issue" | jq -r '.createdAt')
updated_at=$(echo "$issue" | jq -r '.updatedAt')
issue_number=$(echo "$issue" | jq -r '.number')
issue_title=$(echo "$issue" | jq -r '.title')
reaction_groups=$(echo "$issue" | jq -r '.reactionGroups')
has_reactions=$(echo "$issue" | jq -r '.reactionGroups | length')
comment_count=$(echo "$issue" | jq -r '.comments | length')
body_characters=$(echo "$issue" | jq -r '.body | length')
# If the issue has no body, then skip it
if [ "$body_characters" -eq 0 ]; then
continue
fi
# Extract the roadmap URLs from the issue body
roadmap_urls=$(echo "$issue" | jq -r '.body' | grep -o 'https://roadmap\.sh/[^ ]*')
# If no roadmap URLs found, then skip it
if [ -z "$roadmap_urls" ]; then
continue
fi
# URL is like https://roadmap.sh/frontend
# Extract the slug from the URL
slug_of_first_url=$(echo "$roadmap_urls" | head -n 1 | sed 's/https:\/\/roadmap\.sh\///')
if [ -z "$slug_of_first_url" ]; then
continue
fi
# Label the issue with the slug
gh issue edit "$issue_number" --add-label "$slug_of_first_url"
done <<< "$issues"

View File

@@ -1,45 +0,0 @@
#!/usr/bin/env bash
# Usage: warm-urls.sh <sitemap-url>
# Example: warm-urls.sh https://www.example.com/sitemap.xml
# Check if sitemap url is provided
if [ -z "$1" ]; then
echo "Please provide sitemap URL" >&2
exit 1
fi
# Get all URLs from sitemap
urls=$(curl -s "$1" | grep -o "<loc>[^<]*</loc>" | sed 's#<loc>\(.*\)</loc>#\1#')
failed_urls=()
# Warm up URLs
for url in $urls; do
# Fetch the og:image URL from the meta tags
og_image_url=$(curl -s "$url" | grep -o "<meta property=\"og:image\" content=\"[^\"]*\"" | sed 's#<meta property="og:image" content="\([^"]*\)"#\1#')
# warm the URL
echo "Warming up URL: $url"
if ! curl -s -I "$url" > /dev/null; then
failed_urls+=("$url")
fi
# Warm up the og:image URL
if [ -n "$og_image_url" ]; then
echo "Warming up OG: $og_image_url"
if ! curl -s -I "$og_image_url" > /dev/null; then
failed_urls+=("$og_image_url")
fi
else
echo "No og:image found for $url"
fi
done
# Print failed URLs
if [ ${#failed_urls[@]} -gt 0 ]; then
echo "Failed to warm up the following URLs:" >&2
for failed_url in "${failed_urls[@]}"; do
echo "$failed_url" >&2
done
fi

View File

@@ -1,39 +0,0 @@
import { type APIContext } from 'astro';
import { api } from './api.ts';
import type { RoadmapDocument } from '../components/CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
export type ListShowcaseRoadmapResponse = {
data: Pick<
RoadmapDocument,
| '_id'
| 'title'
| 'description'
| 'slug'
| 'creatorId'
| 'visibility'
| 'createdAt'
| 'topicCount'
| 'ratings'
>[];
totalCount: number;
totalPages: number;
currPage: number;
perPage: number;
};
export function roadmapApi(context: APIContext) {
return {
listShowcaseRoadmap: async function () {
const searchParams = new URLSearchParams(context.url.searchParams);
return api(context).get<ListShowcaseRoadmapResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-list-showcase-roadmap`,
searchParams,
);
},
isShowcaseRoadmap: async function (slug: string) {
return api(context).get<{
isShowcase: boolean;
}>(`${import.meta.env.PUBLIC_API_URL}/v1-is-showcase-roadmap/${slug}`);
},
};
}

View File

@@ -18,9 +18,6 @@ export const allowedProfileVisibility = ['public', 'private'] as const;
export type AllowedProfileVisibility =
(typeof allowedProfileVisibility)[number];
export const allowedOnboardingStatus = ['done', 'pending', 'ignored'] as const;
export type AllowedOnboardingStatus = (typeof allowedOnboardingStatus)[number];
export interface UserDocument {
_id?: string;
name: string;
@@ -44,7 +41,6 @@ export interface UserDocument {
github?: string;
linkedin?: string;
twitter?: string;
dailydev?: string;
website?: string;
};
username?: string;
@@ -60,18 +56,6 @@ export interface UserDocument {
};
resetPasswordCodeAt: string;
verifiedAt: string;
// Onboarding fields
onboardingStatus?: AllowedOnboardingStatus;
onboarding?: {
updateProgress: AllowedOnboardingStatus;
publishProfile: AllowedOnboardingStatus;
customRoadmap: AllowedOnboardingStatus;
addFriends: AllowedOnboardingStatus;
roadCard: AllowedOnboardingStatus;
inviteTeam: AllowedOnboardingStatus;
};
createdAt: string;
updatedAt: string;
}

View File

@@ -0,0 +1,16 @@
type AIAnnouncementProps = {};
export function AIAnnouncement(props: AIAnnouncementProps) {
return (
<a
className="rounded-md border border-dashed border-purple-600 px-3 py-1.5 text-purple-400 transition-colors hover:border-purple-400 hover:text-purple-200"
href="/ai"
>
<span className="relative -top-[1px] mr-1 text-xs font-semibold uppercase text-white">
New
</span>{' '}
<span className={'hidden sm:inline'}>Generate visual roadmaps with AI</span>
<span className={'inline text-sm sm:hidden'}>AI Roadmap Generator!</span>
</a>
);
}

View File

@@ -1,11 +1,10 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { getRelativeTimeString } from '../../lib/date';
import type { ResourceType } from '../../lib/resource-progress';
import { EmptyStream } from './EmptyStream';
import { ActivityTopicsModal } from './ActivityTopicsModal.tsx';
import { ChevronsDown, ChevronsUp } from 'lucide-react';
import { ActivityTopicTitles } from './ActivityTopicTitles.tsx';
import { cn } from '../../lib/classname.ts';
export const allowedActivityActionType = [
'in_progress',
@@ -30,16 +29,10 @@ export type UserStreamActivity = {
type ActivityStreamProps = {
activities: UserStreamActivity[];
className?: string;
onResourceClick?: (
resourceId: string,
resourceType: ResourceType,
isCustomResource: boolean,
) => void;
};
export function ActivityStream(props: ActivityStreamProps) {
const { activities, className, onResourceClick } = props;
const { activities } = props;
const [showAll, setShowAll] = useState(false);
const [selectedActivity, setSelectedActivity] =
@@ -55,7 +48,7 @@ export function ActivityStream(props: ActivityStreamProps) {
.slice(0, showAll ? activities.length : 10);
return (
<div className={cn('mx-0 px-0 py-5 md:-mx-10 md:px-8 md:py-8', className)}>
<div className="mx-0 px-0 py-5 md:-mx-10 md:px-8 md:py-8">
<h2 className="mb-3 text-xs uppercase text-gray-400">
Learning Activity
</h2>
@@ -85,7 +78,6 @@ export function ActivityStream(props: ActivityStreamProps) {
updatedAt,
topicTitles,
isCustomResource,
resourceSlug,
} = activity;
const resourceUrl =
@@ -94,28 +86,18 @@ export function ActivityStream(props: ActivityStreamProps) {
: resourceType === 'best-practice'
? `/best-practices/${resourceId}`
: isCustomResource && resourceType === 'roadmap'
? `/r/${resourceSlug}`
? `/r/${resourceId}`
: `/${resourceId}`;
const resourceLinkComponent =
onResourceClick && resourceType !== 'question' ? (
<button
className="font-medium underline transition-colors hover:cursor-pointer hover:text-black"
onClick={() =>
onResourceClick(resourceId, resourceType, isCustomResource!)
}
>
{resourceTitle}
</button>
) : (
<a
className="font-medium underline transition-colors hover:cursor-pointer hover:text-black"
target="_blank"
href={resourceUrl}
>
{resourceTitle}
</a>
);
const resourceLinkComponent = (
<a
className="font-medium underline transition-colors hover:cursor-pointer hover:text-black"
target="_blank"
href={resourceUrl}
>
{resourceTitle}
</a>
);
const topicCount = topicTitles?.length || 0;
@@ -129,35 +111,28 @@ export function ActivityStream(props: ActivityStreamProps) {
<li key={_id} className="py-2 text-sm text-gray-600">
{actionType === 'in_progress' && (
<>
<p className="mb-1">
Started&nbsp;{topicCount}&nbsp;topic
{topicCount > 1 ? 's' : ''}&nbsp;in&nbsp;
{resourceLinkComponent}&nbsp;
{timeAgo}
</p>
<ActivityTopicTitles topicTitles={topicTitles || []} />
Started{' '}
<ActivityTopicTitles
topicTitles={topicTitles || []}
onSelectActivity={() => setSelectedActivity(activity)}
/>{' '}
in {resourceLinkComponent} {timeAgo}
</>
)}
{actionType === 'done' && (
<>
<p className="mb-1">
Completed&nbsp;{topicCount}&nbsp;topic
{topicCount > 1 ? 's' : ''}&nbsp;in&nbsp;
{resourceLinkComponent}&nbsp;
{timeAgo}
</p>
<ActivityTopicTitles topicTitles={topicTitles || []} />
Completed{' '}
<ActivityTopicTitles
topicTitles={topicTitles || []}
onSelectActivity={() => setSelectedActivity(activity)}
/>{' '}
in {resourceLinkComponent} {timeAgo}
</>
)}
{actionType === 'answered' && (
<>
<p className="mb-1">
Answered&nbsp;{topicCount}&nbsp;question
{topicCount > 1 ? 's' : ''}&nbsp;in&nbsp;
{resourceLinkComponent}&nbsp;
{timeAgo}
</p>
<ActivityTopicTitles topicTitles={topicTitles || []} />
Answered {topicCount} question{topicCount > 1 ? 's' : ''} in{' '}
{resourceLinkComponent} {timeAgo}
</>
)}
</li>

View File

@@ -1,43 +1,41 @@
import { useState } from 'react';
import { cn } from '../../lib/classname';
type ActivityTopicTitlesProps = {
topicTitles: string[];
className?: string;
onSelectActivity?: () => void;
};
export function ActivityTopicTitles(props: ActivityTopicTitlesProps) {
const { topicTitles, onSelectActivity, className } = props;
const [showAll, setShowAll] = useState(false);
const filteredTopicTitles = topicTitles.slice(
0,
showAll ? topicTitles.length : 3,
);
const shouldShowButton = topicTitles.length > 3;
const { topicTitles, onSelectActivity } = props;
const firstThreeTopics = topicTitles?.slice(0, 3);
const remainingTopics = topicTitles?.slice(3);
return (
<div
className={cn(
'flex flex-wrap gap-1 text-sm font-normal text-gray-600',
className,
<>
{firstThreeTopics.map((topicTitle, index) => {
return (
<span className="font-medium">
<>
{index > 0 && ', '}
{index === firstThreeTopics.length - 1 &&
firstThreeTopics.length > 1 &&
'and '}
{topicTitle}
</>
</span>
);
})}
{remainingTopics?.length > 0 && (
<>
&nbsp;and&nbsp;
<button
className="font-medium underline underline-offset-2 hover:text-black"
onClick={onSelectActivity}
>
{remainingTopics.length} more topic
{remainingTopics.length > 1 ? 's' : ''}
</button>
</>
)}
>
{filteredTopicTitles.map((topicTitle, index) => (
<span key={index} className="rounded-md bg-gray-200 px-1.5">
{topicTitle}
</span>
))}
{shouldShowButton && !showAll && (
<button
onClick={() => setShowAll(!showAll)}
className="bg-white border border-black text-black rounded-md px-1.5 hover:bg-black text-xs h-[20px] hover:text-white"
>
{showAll ? '- Show less' : `+${topicTitles.length - 3}`}
</button>
)}
</div>
</>
);
}

View File

@@ -4,7 +4,7 @@ export function EmptyStream() {
return (
<div className="rounded-md">
<div className="flex flex-col items-center p-7 text-center">
<List className="mb-4 h-[60px] w-[60px] opacity-10 sm:h-[60px] sm:w-[60px]" />
<List className="mb-2 h-[60px] w-[60px] opacity-10 sm:h-[120px] sm:w-[120px]" />
<h2 className="text-lg font-bold sm:text-xl">No Activities</h2>
<p className="my-1 max-w-[400px] text-balance text-sm text-gray-500 sm:my-2 sm:text-base">

View File

@@ -1,7 +1,6 @@
import { getUser } from '../../lib/jwt';
import { getPercentage } from '../../helper/number';
import { ResourceProgressActions } from './ResourceProgressActions';
import { cn } from '../../lib/classname';
type ResourceProgressType = {
resourceType: 'roadmap' | 'best-practice';
@@ -16,17 +15,10 @@ type ResourceProgressType = {
showClearButton?: boolean;
isCustomResource: boolean;
roadmapSlug?: string;
showActions?: boolean;
onResourceClick?: () => void;
};
export function ResourceProgress(props: ResourceProgressType) {
const {
showClearButton = true,
isCustomResource,
showActions = true,
onResourceClick,
} = props;
const { showClearButton = true, isCustomResource } = props;
const userId = getUser()?.id;
@@ -55,23 +47,12 @@ export function ResourceProgress(props: ResourceProgressType) {
const totalMarked = doneCount + skippedCount;
const progressPercentage = getPercentage(totalMarked, totalCount);
const Slot = onResourceClick ? 'button' : 'a';
return (
<div className="relative">
<Slot
{...(onResourceClick
? {
onClick: onResourceClick,
}
: {
href: url,
target: '_blank',
})}
className={cn(
'group relative flex w-full 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',
showActions ? 'pr-7' : '',
)}
<a
target="_blank"
href={url}
className="group relative flex items-center justify-between overflow-hidden rounded-md border border-gray-300 bg-white px-3 py-2 pr-7 text-left text-sm transition-all hover:border-gray-400"
>
<span className="flex-grow truncate">{title}</span>
<span className="text-xs text-gray-400">
@@ -84,20 +65,18 @@ export function ResourceProgress(props: ResourceProgressType) {
width: `${progressPercentage}%`,
}}
></span>
</Slot>
</a>
{showActions && (
<div className="absolute right-2 top-0 flex h-full items-center">
<ResourceProgressActions
userId={userId!}
resourceType={resourceType}
resourceId={resourceId}
isCustomResource={isCustomResource}
onCleared={onCleared}
showClearButton={showClearButton}
/>
</div>
)}
<div className="absolute right-2 top-0 flex h-full items-center">
<ResourceProgressActions
userId={userId!}
resourceType={resourceType}
resourceId={resourceId}
isCustomResource={isCustomResource}
onCleared={onCleared}
showClearButton={showClearButton}
/>
</div>
</div>
);
}

View File

@@ -73,7 +73,7 @@ export function TriggerVerifyEmail() {
Verifying your new Email
</h2>
<div className="text-sm sm:text-base">
{isLoading && <p>Please wait while we verify your new Email.</p>}
{isLoading && <p>Please wait while we verify your new Email..</p>}
{error && <p className="text-red-700">{error}</p>}
</div>
</div>

View File

@@ -46,7 +46,6 @@ function handleGuest() {
'/team/roadmaps',
'/team/new',
'/team/members',
'/team/member',
'/team/settings',
];

View File

@@ -87,7 +87,7 @@ const isBestPracticeReady = !isUpcoming;
{
isBestPracticeReady && (
<a
href={`https://github.com/kamranahmedse/developer-roadmap/issues/new/choose`}
href={`https://github.com/kamranahmedse/developer-roadmap/issues/new?title=[Suggestion] ${title}`}
target="_blank"
class="inline-flex items-center justify-center rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm"
aria-label="Suggest Changes"

View File

@@ -17,8 +17,6 @@ import { ClipboardIcon } from '../ReactIcons/ClipboardIcon.tsx';
import { GuideIcon } from '../ReactIcons/GuideIcon.tsx';
import { HomeIcon } from '../ReactIcons/HomeIcon.tsx';
import { VideoIcon } from '../ReactIcons/VideoIcon.tsx';
import { cn } from '../../lib/classname.ts';
import type { AllowedRoadmapRenderer } from '../../lib/roadmap.ts';
export type PageType = {
id: string;
@@ -28,7 +26,6 @@ export type PageType = {
icon?: ReactElement;
isProtected?: boolean;
metadata?: Record<string, any>;
renderer?: AllowedRoadmapRenderer;
};
const defaultPages: PageType[] = [
@@ -193,7 +190,7 @@ export function CommandMenu() {
return (
<div className="fixed left-0 right-0 top-0 z-50 flex h-full justify-center overflow-y-auto overflow-x-hidden bg-black/50">
<div className="relative top-0 h-full w-full max-w-lg p-2 sm:mt-20 md:h-auto">
<div className="relative top-0 h-full w-full max-w-lg p-2 sm:top-20 md:h-auto">
<div className="relative rounded-lg bg-white shadow" ref={modalRef}>
<input
ref={inputRef}
@@ -243,15 +240,14 @@ export function CommandMenu() {
const groupChanged = prevPage && prevPage.group !== page.group;
return (
<Fragment key={page.group+'/'+page.id}>
<Fragment key={page.id}>
{groupChanged && (
<div className="border-b border-gray-100"></div>
)}
<a
className={cn(
'flex w-full items-center rounded p-2 text-sm',
counter === activeCounter ? 'bg-gray-100' : '',
)}
className={`flex w-full items-center rounded p-2 text-sm ${
counter === activeCounter ? 'bg-gray-100' : ''
}`}
onMouseOver={() => setActiveCounter(counter)}
href={page.url}
>

View File

@@ -24,7 +24,6 @@ export type TeamResourceConfig = {
topics?: number;
sharedTeamMemberIds: string[];
sharedFriendIds: string[];
defaultRoadmapId?: string;
}[];
type RoadmapSelectorProps = {
@@ -107,7 +106,6 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
}
pageProgressMessage.set(`Adding roadmap to team`);
const renderer = allRoadmaps.find((r) => r.id === roadmapId)?.renderer;
const { error, response } = await httpPut<TeamResourceConfig>(
`${
import.meta.env.PUBLIC_API_URL
@@ -117,7 +115,6 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
resourceId: roadmapId,
resourceType: 'roadmap',
removed: [],
renderer: renderer || 'balsamiq',
},
);
@@ -127,9 +124,6 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
}
setTeamResources(response);
if (renderer === 'editor') {
setShowSelectRoadmapModal(false);
}
}
useEffect(() => {

View File

@@ -68,7 +68,7 @@ export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
);
return (
<div className="fixed left-0 right-0 top-0 z-[100] h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
<div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
<div className="relative mx-auto h-full w-full max-w-2xl p-4 md:h-auto">
<div
ref={popupBodyEl}

View File

@@ -148,7 +148,7 @@ export function UpdateTeamResourceModal(props: ProgressMapProps) {
}, []);
return (
<div className="fixed left-0 right-0 top-0 z-[100] h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
<div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
<div className="relative mx-auto h-full w-full max-w-4xl p-4 md:h-auto">
<div
id={'customized-roadmap'}

View File

@@ -23,44 +23,24 @@ export const allowedCustomRoadmapType = ['role', 'skill'] as const;
export type AllowedCustomRoadmapType =
(typeof allowedCustomRoadmapType)[number];
export const allowedShowcaseStatus = ['visible', 'hidden'] as const;
export type AllowedShowcaseStatus = (typeof allowedShowcaseStatus)[number];
export interface RoadmapDocument {
_id?: string;
title: string;
description?: string;
slug?: string;
creatorId: string;
aiRoadmapId?: string;
teamId?: string;
topicCount: number;
isDiscoverable: boolean;
type: AllowedCustomRoadmapType;
visibility: AllowedRoadmapVisibility;
sharedFriendIds?: string[];
sharedTeamMemberIds?: string[];
feedbacks?: {
userId: string;
email: string;
feedback: string;
}[];
metadata?: {
originalRoadmapId?: string;
defaultRoadmapId?: string;
};
nodes: any[];
edges: any[];
isDiscoverable?: boolean;
showcaseStatus?: AllowedShowcaseStatus;
ratings: {
average: number;
breakdown: {
[key: number]: number;
};
};
createdAt: Date;
updatedAt: Date;
canManage: boolean;
isCustomResource: boolean;
}
interface CreateRoadmapModalProps {

View File

@@ -15,10 +15,6 @@ export const allowedLinkTypes = [
'course',
'website',
'podcast',
'roadmap.sh',
'official',
'roadmap',
'feed',
] as const;
export type AllowedLinkTypes = (typeof allowedLinkTypes)[number];
@@ -47,7 +43,6 @@ export type GetRoadmapResponse = RoadmapDocument & {
canManage: boolean;
creator?: CreatorType;
team?: CreatorType;
unseenRatingCount: number;
};
export function hideRoadmapLoader() {

View File

@@ -1,11 +1,4 @@
import {
BadgeCheck,
Heart,
HeartHandshake,
MessageCircleHeart,
PencilRuler,
Search,
} from 'lucide-react';
import { BadgeCheck, MessageCircleHeart, PencilRuler } from 'lucide-react';
import { showLoginPopup } from '../../lib/popup.ts';
import { isLoggedIn } from '../../lib/jwt.ts';
import { useState } from 'react';
@@ -24,11 +17,14 @@ export function CustomRoadmapAlert() {
/>
)}
<div className="relative mb-5 mt-0 rounded-md border border-yellow-500 bg-yellow-100 p-2 sm:-mt-6 sm:mb-7 sm:p-2.5">
<p className="mb-2.5 mt-2 text-sm text-yellow-800 sm:mb-1.5 sm:mt-1 sm:text-base">
This is a custom roadmap made by a community member and is not
verified by <span className="font-semibold">roadmap.sh</span>
<h2 className="text-base font-semibold text-yellow-800 sm:text-lg">
Community Roadmap
</h2>
<p className="mt-2 mb-2.5 sm:mb-1.5 sm:mt-1 text-sm text-yellow-800 sm:text-base">
This is a custom roadmap made by a community member and is not verified by{' '}
<span className="font-semibold">roadmap.sh</span>
</p>
<div className="flex flex-col items-start gap-2 sm:flex-row sm:items-center">
<div className="flex items-start sm:items-center flex-col sm:flex-row gap-2">
<a
href="/roadmaps"
className="inline-flex items-center gap-1.5 text-sm font-semibold text-yellow-700 underline-offset-2 hover:underline"
@@ -36,16 +32,20 @@ export function CustomRoadmapAlert() {
<BadgeCheck className="h-4 w-4 stroke-[2.5]" />
Visit Official Roadmaps
</a>
<span className="hidden font-black text-yellow-700 sm:block">
&middot;
</span>
<a
href="/community"
<span className="font-black text-yellow-700 hidden sm:block">&middot;</span>
<button
className="inline-flex items-center gap-1.5 text-sm font-semibold text-yellow-700 underline-offset-2 hover:underline"
onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
} else {
setIsCreatingRoadmap(true);
}
}}
>
<HeartHandshake className="h-4 w-4 stroke-[2.5]" />
More Community Roadmaps
</a>
<PencilRuler className="h-4 w-4 stroke-[2.5]" />
Create Your Own Roadmap
</button>
</div>
<MessageCircleHeart className="absolute bottom-2 right-2 hidden h-12 w-12 text-yellow-500 opacity-50 sm:block" />

View File

@@ -1,90 +0,0 @@
import { useState } from 'react';
import { Rating } from '../Rating/Rating';
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
import { CustomRoadmapRatingsModal } from './CustomRoadmapRatingsModal';
import { Star } from 'lucide-react';
type CustomRoadmapRatingsProps = {
roadmapSlug: string;
ratings: RoadmapDocument['ratings'];
canManage?: boolean;
unseenRatingCount: number;
};
export function CustomRoadmapRatings(props: CustomRoadmapRatingsProps) {
const { ratings, roadmapSlug, canManage, unseenRatingCount } = props;
const average = ratings?.average || 0;
const totalPeopleWhoRated = Object.keys(ratings?.breakdown || {}).reduce(
(acc, key) => acc + ratings?.breakdown[key as any],
0,
);
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
return (
<>
{isDetailsOpen && (
<CustomRoadmapRatingsModal
roadmapSlug={roadmapSlug}
onClose={() => {
setIsDetailsOpen(false);
}}
ratings={ratings}
canManage={canManage}
/>
)}
{average === 0 && (
<>
{!canManage && (
<button
className="flex h-[34px] items-center gap-2 rounded-md border border-gray-300 bg-white py-1 pl-2 pr-3 text-sm font-medium hover:border-black"
onClick={() => {
setIsDetailsOpen(true);
}}
>
<Star className="size-4 fill-yellow-400 text-yellow-400" />
<span className="hidden md:block">Rate this roadmap</span>
<span className="block md:hidden">Rate</span>
</button>
)}
{canManage && (
<span className="flex h-[34px] cursor-default items-center gap-2 rounded-md border border-gray-300 bg-white py-1 pl-2 pr-3 text-sm font-medium opacity-50">
<Star className="size-4 fill-yellow-400 text-yellow-400" />
<span className="hidden md:block">No ratings yet</span>
<span className="block md:hidden">Rate</span>
</span>
)}
</>
)}
{average > 0 && (
<button
className="relative flex h-[34px] items-center gap-2 rounded-md border border-gray-300 bg-white py-1 pl-2 pr-3 text-sm font-medium hover:border-black"
onClick={() => {
setIsDetailsOpen(true);
}}
>
{average.toFixed(1)}
<span className="hidden lg:block">
<Rating
starSize={16}
rating={average}
className={'pointer-events-none gap-px'}
readOnly
/>
</span>
<span className="lg:hidden">
<Star className="size-5 fill-yellow-400 text-yellow-400" />
</span>
({totalPeopleWhoRated})
{canManage && unseenRatingCount > 0 && (
<span className="absolute right-0 top-0 flex size-4 -translate-y-1/2 translate-x-1/2 items-center justify-center rounded-full bg-red-500 text-[10px] font-medium leading-none text-white">
{unseenRatingCount}
</span>
)}
</button>
)}
</>
);
}

View File

@@ -1,58 +0,0 @@
import { useState } from 'react';
import { Modal } from '../Modal';
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
import { RateRoadmapForm } from './RateRoadmapForm';
import { ListRoadmapRatings } from './ListRoadmapRatings';
type ActiveTab = 'ratings' | 'feedback';
type CustomRoadmapRatingsModalProps = {
onClose: () => void;
roadmapSlug: string;
ratings: RoadmapDocument['ratings'];
canManage?: boolean;
};
export function CustomRoadmapRatingsModal(
props: CustomRoadmapRatingsModalProps,
) {
const { onClose, ratings, roadmapSlug, canManage = false } = props;
const [activeTab, setActiveTab] = useState<ActiveTab>(
canManage ? 'feedback' : 'ratings',
);
const tabs: {
id: ActiveTab;
label: string;
}[] = [
{
id: 'ratings',
label: 'Ratings',
},
{
id: 'feedback',
label: 'Feedback',
},
];
return (
<Modal
onClose={onClose}
bodyClassName="bg-transparent shadow-none"
wrapperClassName="h-auto"
overlayClassName="items-start md:items-center"
>
{activeTab === 'ratings' && (
<RateRoadmapForm
ratings={ratings}
roadmapSlug={roadmapSlug}
canManage={canManage}
/>
)}
{activeTab === 'feedback' && (
<ListRoadmapRatings ratings={ratings} roadmapSlug={roadmapSlug} />
)}
</Modal>
);
}

View File

@@ -62,10 +62,7 @@ export function FlowRoadmapRenderer(props: FlowRoadmapRendererProps) {
}
const handleTopicRightClick = useCallback((e: MouseEvent, node: Node) => {
const target =
node?.type === 'todo'
? document.querySelector(`[data-id="${node.id}"]`)
: (e?.currentTarget as HTMLDivElement);
const target = e?.currentTarget as HTMLDivElement;
if (!target) {
return;
}

View File

@@ -1,181 +0,0 @@
import { useEffect, useState } from 'react';
import { httpGet } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
import { isLoggedIn } from '../../lib/jwt';
import { Loader2, MessageCircle, ServerCrash } from 'lucide-react';
import { Rating } from '../Rating/Rating';
import { Spinner } from '../ReactIcons/Spinner.tsx';
import { getRelativeTimeString } from '../../lib/date.ts';
import { cn } from '../../lib/classname.ts';
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal.tsx';
import { Pagination } from '../Pagination/Pagination.tsx';
export interface RoadmapRatingDocument {
_id?: string;
roadmapId: string;
userId: string;
rating: number;
feedback?: string;
createdAt: Date;
updatedAt: Date;
}
type ListRoadmapRatingsResponse = {
data: (RoadmapRatingDocument & {
name: string;
avatar?: string;
})[];
totalCount: number;
totalPages: number;
currPage: number;
perPage: number;
};
type ListRoadmapRatingsProps = {
roadmapSlug: string;
ratings: RoadmapDocument['ratings'];
};
export function ListRoadmapRatings(props: ListRoadmapRatingsProps) {
const { roadmapSlug, ratings: ratingSummary } = props;
const totalWhoRated = Object.keys(ratingSummary.breakdown || {}).reduce(
(acc, key) => acc + ratingSummary.breakdown[key as any],
0,
);
const averageRating = ratingSummary.average;
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const [ratingsResponse, setRatingsResponse] =
useState<ListRoadmapRatingsResponse | null>(null);
const listRoadmapRatings = async (currPage: number = 1) => {
setIsLoading(true);
const { response, error } = await httpGet<ListRoadmapRatingsResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-list-roadmap-ratings/${roadmapSlug}`,
{
currPage,
},
);
if (!response || error) {
setError(error?.message || 'Something went wrong');
setIsLoading(false);
return;
}
setRatingsResponse(response);
setError('');
setIsLoading(false);
};
useEffect(() => {
if (!isLoggedIn()) {
return;
}
listRoadmapRatings().then();
}, []);
if (error) {
return (
<div className="flex flex-col items-center justify-center bg-white py-10">
<ServerCrash className="size-12 text-red-500" />
<p className="mt-3 text-lg text-red-500">{error}</p>
</div>
);
}
const ratings = ratingsResponse?.data || [];
return (
<div className="relative min-h-[100px] overflow-auto rounded-lg bg-white p-2 md:max-h-[550px]">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<Spinner isDualRing={false} />
</div>
)}
{!isLoading && ratings.length > 0 && (
<div className="relative">
<div className="sticky top-1.5 mb-2 flex items-center justify-center gap-1 rounded-lg bg-yellow-50 px-2 py-1.5 text-sm text-yellow-900">
<span>
Rated{' '}
<span className="font-medium">{averageRating.toFixed(1)}</span>
</span>
<Rating starSize={15} rating={averageRating} readOnly />
by{' '}
<span className="font-medium">
{totalWhoRated} user{totalWhoRated > 1 && 's'}
</span>
</div>
<div className="mb-3 flex flex-col">
{ratings.map((rating) => {
const userAvatar = rating?.avatar
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${rating.avatar}`
: '/images/default-avatar.png';
const isLastRating =
ratings[ratings.length - 1]._id === rating._id;
return (
<div
key={rating._id}
className={cn('px-2 py-2.5', {
'border-b': !isLastRating,
})}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<img
src={userAvatar}
alt={rating.name}
className="h-4 w-4 rounded-full"
/>
<span className="text-sm font-medium">{rating.name}</span>
</div>
<span className="text-xs text-gray-400">
{getRelativeTimeString(rating.createdAt)}
</span>
</div>
<div className="mt-2.5">
<Rating rating={rating.rating} readOnly />
{rating.feedback && (
<p className="mt-2 text-sm text-gray-500">
{rating.feedback}
</p>
)}
</div>
</div>
);
})}
</div>
<Pagination
variant="minimal"
totalCount={ratingsResponse?.totalCount || 1}
currPage={ratingsResponse?.currPage || 1}
totalPages={ratingsResponse?.totalPages || 1}
perPage={ratingsResponse?.perPage || 1}
onPageChange={(page) => {
listRoadmapRatings(page).then();
}}
/>
</div>
)}
{!isLoading && ratings.length === 0 && (
<div className="flex flex-col items-center justify-center py-10">
<MessageCircle className="size-12 text-gray-200" />
<p className="mt-3 text-base text-gray-600">No Feedbacks</p>
</div>
)}
</div>
);
}

View File

@@ -1,273 +0,0 @@
import { useEffect, useState } from 'react';
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
import { formatCommaNumber } from '../../lib/number';
import { Rating } from '../Rating/Rating';
import { httpGet, httpPost } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
import { isLoggedIn } from '../../lib/jwt';
import { Loader2, Star } from 'lucide-react';
import { cn } from '../../lib/classname';
import { showLoginPopup } from '../../lib/popup';
import { Spinner } from '../ReactIcons/Spinner.tsx';
type GetMyRoadmapRatingResponse = {
id?: string;
rating: number;
feedback?: string;
};
type RateRoadmapFormProps = {
ratings: RoadmapDocument['ratings'];
roadmapSlug: string;
canManage?: boolean;
};
export function RateRoadmapForm(props: RateRoadmapFormProps) {
const { ratings, canManage = false, roadmapSlug } = props;
const { breakdown = {}, average: _average } = ratings || {};
const average = _average || 0;
const ratingsKeys = [5, 4, 3, 2, 1];
const totalRatings = ratingsKeys.reduce(
(total, rating) => total + breakdown?.[rating] || 0,
0,
);
// if no rating then only show the ratings breakdown if the user can manage the roadmap
const showRatingsBreakdown = average > 0 || canManage;
const toast = useToast();
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isRatingRoadmap, setIsRatingRoadmap] = useState(!showRatingsBreakdown);
const [userRatingId, setUserRatingId] = useState<string | undefined>();
const [userRating, setUserRating] = useState(0);
const [userFeedback, setUserFeedback] = useState('');
const loadMyRoadmapRating = async () => {
// user can't have the rating for their own roadmap
if (canManage) {
setIsLoading(false);
return;
}
const { response, error } = await httpGet<GetMyRoadmapRatingResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-my-roadmap-rating/${roadmapSlug}`,
);
if (!response || error) {
toast.error(error?.message || 'Something went wrong');
setIsLoading(false);
return;
}
setUserRatingId(response?.id);
setUserRating(response?.rating);
setUserFeedback(response?.feedback || '');
setIsLoading(false);
};
const submitMyRoadmapRating = async () => {
if (userRating <= 0) {
toast.error('At least give it a star');
return;
}
setIsSubmitting(true);
const path = userRatingId
? 'v1-update-custom-roadmap-rating'
: 'v1-rate-custom-roadmap';
const { response, error } = await httpPost<{
id: string;
}>(`${import.meta.env.PUBLIC_API_URL}/${path}/${roadmapSlug}`, {
rating: userRating,
feedback: userFeedback,
});
if (!response || error) {
toast.error(error?.message || 'Something went wrong');
setIsSubmitting(false);
return;
}
window.location.reload();
};
useEffect(() => {
if (!isLoggedIn() || !roadmapSlug) {
setIsLoading(false);
return;
}
loadMyRoadmapRating().then();
}, [roadmapSlug]);
return (
<div className="flex flex-col gap-3">
{showRatingsBreakdown && !isRatingRoadmap && (
<>
<ul className="flex flex-col gap-1 rounded-lg bg-white p-5">
{ratingsKeys.map((rating) => {
const percentage =
totalRatings <= 0
? 0
: ((breakdown?.[rating] || 0) / totalRatings) * 100;
return (
<li
key={`rating-${rating}`}
className="flex items-center gap-2 text-sm"
>
<span className="shrink-0">{rating} star</span>
<div className="relative h-8 w-full overflow-hidden rounded-md border">
<div
className="h-full bg-yellow-300"
style={{ width: `${percentage}%` }}
></div>
{percentage > 0 && (
<span className="absolute right-3 top-1/2 flex -translate-y-1/2 items-center justify-center text-xs text-black">
{formatCommaNumber(breakdown?.[rating] || 0)}
</span>
)}
</div>
<span className="w-[35px] shrink-0 text-xs text-gray-500">
{parseInt(`${percentage}`, 10)}%
</span>
</li>
);
})}
</ul>
</>
)}
{!canManage && !isRatingRoadmap && (
<div className="relative min-h-[100px] rounded-lg bg-white p-4">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<Spinner isDualRing={false} className="h-5 w-5" />
</div>
)}
{!isLoading && !isRatingRoadmap && !userRatingId && (
<>
<p className="mb-2 text-center text-sm font-medium">
Rate and share your thoughts with the roadmap creator.
</p>
<button
className="flex h-10 w-full items-center justify-center rounded-full bg-black p-2.5 text-sm font-medium text-white disabled:opacity-60"
onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
setIsRatingRoadmap(true);
}}
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
'Rate Roadmap'
)}
</button>
</>
)}
{!isLoading && !isRatingRoadmap && userRatingId && (
<div>
<h3 className="mb-2.5 flex items-center justify-between text-base font-semibold">
Your Feedback
<button
className="ml-2 text-sm font-medium text-blue-500 underline underline-offset-2"
onClick={() => {
setIsRatingRoadmap(true);
}}
>
Edit Rating
</button>
</h3>
<div className="flex items-center gap-2">
<Rating rating={userRating} starSize={19} readOnly /> (
{userRating})
</div>
{userFeedback && <p className="mt-2 text-sm">{userFeedback}</p>}
</div>
)}
</div>
)}
{!canManage && isRatingRoadmap && (
<div className="rounded-lg bg-white p-5">
<h3 className="font-semibold">Rate this roadmap</h3>
<p className="mt-1 text-sm">
Share your thoughts with the roadmap creator.
</p>
<form
className="mt-4"
onSubmit={(e) => {
e.preventDefault();
submitMyRoadmapRating().then();
}}
>
<Rating
rating={userRating}
onRatingChange={(rating) => {
setUserRating(rating);
}}
starSize={32}
/>
<div className="mt-3 flex flex-col gap-1">
<label
htmlFor="rating-feedback"
className="block text-sm font-medium"
>
Feedback to Creator{' '}
<span className="font-normal text-gray-400">(Optional)</span>
</label>
<textarea
id="rating-feedback"
className="min-h-24 rounded-md border p-2 text-sm outline-none focus:border-gray-500"
placeholder="Share your thoughts with the roadmap creator"
value={userFeedback}
onChange={(e) => {
setUserFeedback(e.target.value);
}}
/>
</div>
<div className={cn('mt-4 grid grid-cols-2 gap-1')}>
<button
className="h-10 w-full rounded-full border p-2.5 text-sm font-medium disabled:opacity-60"
onClick={() => {
setIsRatingRoadmap(false);
}}
type="button"
disabled={isSubmitting}
>
Cancel
</button>
<button
className="flex h-10 w-full items-center justify-center rounded-full bg-black p-2.5 text-sm font-medium text-white disabled:opacity-60"
type="submit"
disabled={isSubmitting}
>
{isSubmitting ? (
<Loader2 className="size-4 animate-spin" />
) : userRatingId ? (
'Update Rating'
) : (
'Submit Rating'
)}
</button>
</div>
</form>
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useRef, useState } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { Lock, MoreVertical, PenSquare, Shapes, Trash2 } from 'lucide-react';
import { Lock, MoreVertical, Shapes, Trash2 } from 'lucide-react';
type RoadmapActionButtonProps = {
onDelete?: () => void;
@@ -32,23 +32,9 @@ export function RoadmapActionButton(props: RoadmapActionButtonProps) {
{isOpen && (
<div
ref={menuRef}
className="align-right absolute right-0 top-full z-[9999] mt-1 w-[140px] rounded-md bg-slate-800 px-2 py-2 text-white shadow-md"
className="align-right absolute right-0 top-full mt-1 w-[140px] rounded-md bg-slate-800 px-2 py-2 text-white shadow-md z-[9999]"
>
<ul>
{onCustomize && (
<li>
<button
onClick={() => {
setIsOpen(false);
onCustomize();
}}
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<PenSquare size={14} className="mr-2" />
Edit
</button>
</li>
)}
{onUpdateSharing && (
<li>
<button
@@ -63,6 +49,20 @@ export function RoadmapActionButton(props: RoadmapActionButtonProps) {
</button>
</li>
)}
{onCustomize && (
<li>
<button
onClick={() => {
setIsOpen(false);
onCustomize();
}}
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<Shapes size={14} className="mr-2" />
Customize
</button>
</li>
)}
{onDelete && (
<li>
<button

View File

@@ -8,9 +8,11 @@ import { httpDelete, httpPut } from '../../lib/http';
import { type TeamResourceConfig } from '../CreateTeam/RoadmapSelector';
import { useToast } from '../../hooks/use-toast';
import { RoadmapActionButton } from './RoadmapActionButton';
import { Lock, Shapes } from 'lucide-react';
import { Modal } from '../Modal';
import { ShareSuccess } from '../ShareOptions/ShareSuccess';
import { ShareRoadmapButton } from '../ShareRoadmapButton.tsx';
import { CustomRoadmapAlert } from './CustomRoadmapAlert.tsx';
import { CustomRoadmapRatings } from './CustomRoadmapRatings.tsx';
type RoadmapHeaderProps = {};
@@ -26,12 +28,10 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
creator,
team,
visibility,
ratings,
unseenRatingCount,
showcaseStatus,
} = useStore(currentRoadmap) || {};
const [isSharing, setIsSharing] = useState(false);
const [isSharingWithOthers, setIsSharingWithOthers] = useState(false);
const toast = useToast();
async function deleteResource() {
@@ -72,6 +72,23 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${creator?.avatar}`
: '/images/default-avatar.png';
const sharingWithOthersModal = isSharingWithOthers && (
<Modal
onClose={() => setIsSharingWithOthers(false)}
wrapperClassName="max-w-lg"
bodyClassName="p-4 flex flex-col"
>
<ShareSuccess
visibility="public"
roadmapSlug={roadmapSlug}
roadmapId={roadmapId!}
description={description}
onClose={() => setIsSharingWithOthers(false)}
isSharingWithOthers={true}
/>
</Modal>
);
return (
<div className="border-b">
<div className="container relative py-5 sm:py-12">
@@ -110,12 +127,11 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
<div className="flex justify-between gap-2 sm:gap-0">
<div className="flex justify-stretch gap-1 sm:gap-2">
<a
href="/community"
href="/roadmaps"
className="rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm"
aria-label="Back to All Roadmaps"
>
&larr;
<span className="hidden sm:inline">&nbsp;Discover more</span>
&larr;<span className="hidden sm:inline">&nbsp;All Roadmaps</span>
</a>
<ShareRoadmapButton
@@ -150,13 +166,26 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
/>
)}
<a
href={`${
import.meta.env.PUBLIC_EDITOR_APP_URL
}/${$currentRoadmap?._id}`}
target="_blank"
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white py-1.5 pl-2 pr-2 text-xs font-medium text-black hover:border-gray-300 hover:bg-gray-300 sm:px-3 sm:text-sm"
>
<Shapes className="mr-1.5 h-4 w-4 stroke-[2.5]" />
<span className="hidden sm:inline-block">Edit Roadmap</span>
<span className="sm:hidden">Edit</span>
</a>
<button
onClick={() => setIsSharing(true)}
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white py-1.5 pl-2 pr-2 text-xs font-medium text-black hover:border-gray-300 hover:bg-gray-300 sm:px-3 sm:text-sm"
>
<Lock className="mr-1.5 h-4 w-4 stroke-[2.5]" />
Sharing
</button>
<RoadmapActionButton
onUpdateSharing={() => setIsSharing(true)}
onCustomize={() => {
window.location.href = `${
import.meta.env.PUBLIC_EDITOR_APP_URL
}/${$currentRoadmap?._id}`;
}}
onDelete={() => {
const confirmation = window.confirm(
'Are you sure you want to delete this roadmap?',
@@ -172,13 +201,17 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
</>
)}
{((ratings?.average || 0) > 0 || showcaseStatus === 'visible') && (
<CustomRoadmapRatings
roadmapSlug={roadmapSlug!}
ratings={ratings!}
canManage={$canManageCurrentRoadmap}
unseenRatingCount={unseenRatingCount || 0}
/>
{!$canManageCurrentRoadmap && visibility === 'public' && (
<>
{sharingWithOthersModal}
<button
onClick={() => setIsSharingWithOthers(true)}
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white py-1.5 pl-2 pr-2 text-xs font-medium text-black hover:border-gray-300 hover:bg-gray-300 sm:px-3 sm:text-sm"
>
<Lock className="mr-1.5 h-4 w-4 stroke-[2.5]" />
Share with Others
</button>
</>
)}
</div>
</div>

View File

@@ -17,8 +17,9 @@ export function SkeletonRoadmapHeader() {
<div className="h-7 w-[35.04px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[85px]" />
</div>
<div className="flex items-center gap-2">
<div className="h-7 w-[60.52px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[92px]" />
<div className="h-7 w-[71.48px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[139px]" />
<div className="h-7 w-[60.52px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[139.71px]" />
<div className="h-7 w-[71.48px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[100.34px]" />
<div className="h-7 w-[32px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[89.73px]" />
</div>
</div>

View File

@@ -1,15 +0,0 @@
import type { SVGProps } from 'react';
export function DailyDevIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 32 18" xmlns="http://www.w3.org/2000/svg" {...props}>
<g fill="currentColor" fillRule="nonzero">
<path
d="M26.633 8.69l-3.424-3.431 1.711-3.43 5.563 5.575c.709.71.709 1.861 0 2.572l-6.847 6.86c-.709.711-1.858.711-2.567 0a1.821 1.821 0 010-2.571l5.564-5.575z"
fillOpacity="0.64"
></path>
<path d="M21.07.536a1.813 1.813 0 012.568 0l1.283 1.286L9.945 16.83c-.709.71-1.858.71-2.567 0l-1.284-1.287L21.071.536zm-6.418 4.717l-2.567 2.572-3.424-3.43-4.28 4.288 3.424 3.43-1.71 3.43L.531 9.97a1.821 1.821 0 010-2.572L7.378.537A1.813 1.813 0 019.945.535l4.707 4.717z"></path>
</g>
</svg>
);
}

View File

@@ -1,21 +0,0 @@
import { ErrorIcon } from '../ReactIcons/ErrorIcon';
type DiscoverErrorProps = {
message: string;
};
export function DiscoverError(props: DiscoverErrorProps) {
const { message } = props;
return (
<div className="flex min-h-[250px] flex-col items-center justify-center rounded-xl border px-5 py-3 sm:px-0 sm:py-20">
<ErrorIcon additionalClasses="mb-4 h-8 w-8 sm:h-14 sm:w-14" />
<h2 className="mb-1 text-lg font-semibold sm:text-xl">
Oops! Something went wrong
</h2>
<p className="mb-3 text-balance text-center text-xs text-gray-800 sm:text-sm">
{message}
</p>
</div>
);
}

View File

@@ -1,77 +0,0 @@
import { ArrowDownWideNarrow, Check, ChevronDown } from 'lucide-react';
import { useRef, useState } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import type { SortByValues } from './DiscoverRoadmaps';
const sortingLabels: { label: string; value: SortByValues }[] = [
{
label: 'Newest',
value: 'createdAt',
},
{
label: 'Oldest',
value: '-createdAt',
},
{
label: 'Highest Rated',
value: 'rating',
},
{
label: 'Lowest Rated',
value: '-rating',
},
];
type DiscoverRoadmapSortingProps = {
sortBy: SortByValues;
onSortChange: (sortBy: SortByValues) => void;
};
export function DiscoverRoadmapSorting(props: DiscoverRoadmapSortingProps) {
const { sortBy, onSortChange } = props;
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef(null);
const selectedValue = sortingLabels.find((item) => item.value === sortBy);
useOutsideClick(dropdownRef, () => {
setIsOpen(false);
});
return (
<div
className="min-auto relative flex flex-shrink-0 sm:min-w-[140px]"
ref={dropdownRef}
>
<button
className="py-15 flex w-full items-center justify-between gap-2 rounded-md border px-2 text-sm bg-white"
onClick={() => setIsOpen(!isOpen)}
>
<span>{selectedValue?.label}</span>
<span>
<ChevronDown className="ml-4 h-3.5 w-3.5" />
</span>
</button>
{isOpen && (
<div className="absolute right-0 top-10 z-10 min-w-40 overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg">
{sortingLabels.map((item) => (
<button
key={item.value}
className="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100"
onClick={() => {
onSortChange(item.value);
setIsOpen(false);
}}
>
<span>{item.label}</span>
{item.value === sortBy && <Check className="ml-auto h-4 w-4" />}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -1,271 +0,0 @@
import { Shapes } from 'lucide-react';
import type { ListShowcaseRoadmapResponse } from '../../api/roadmap';
import { Pagination } from '../Pagination/Pagination';
import { SearchRoadmap } from './SearchRoadmap';
import { EmptyDiscoverRoadmaps } from './EmptyDiscoverRoadmaps';
import { Rating } from '../Rating/Rating';
import { useEffect, useState } from 'react';
import { deleteUrlParam, getUrlParams, setUrlParams } from '../../lib/browser';
import { LoadingRoadmaps } from '../ExploreAIRoadmap/LoadingRoadmaps';
import { httpGet } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
import { DiscoverRoadmapSorting } from './DiscoverRoadmapSorting';
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
import { Tooltip } from '../Tooltip.tsx';
type DiscoverRoadmapsProps = {};
export type SortByValues = 'rating' | '-rating' | 'createdAt' | '-createdAt';
type QueryParams = {
q?: string;
s?: SortByValues;
p?: string;
};
type PageState = {
searchTerm: string;
sortBy: SortByValues;
currentPage: number;
};
export function DiscoverRoadmaps(props: DiscoverRoadmapsProps) {
const toast = useToast();
const [pageState, setPageState] = useState<PageState>({
searchTerm: '',
sortBy: 'createdAt',
currentPage: 0,
});
const [isLoading, setIsLoading] = useState(true);
const [roadmapsResponse, setRoadmapsResponse] =
useState<ListShowcaseRoadmapResponse | null>(null);
useEffect(() => {
const queryParams = getUrlParams() as QueryParams;
setPageState({
searchTerm: queryParams.q || '',
sortBy: queryParams.s || 'createdAt',
currentPage: +(queryParams.p || '1'),
});
}, []);
useEffect(() => {
setIsLoading(true);
if (!pageState.currentPage) {
return;
}
// only set the URL params if the user modified anything
if (
pageState.currentPage !== 1 ||
pageState.searchTerm !== '' ||
pageState.sortBy !== 'createdAt'
) {
setUrlParams({
q: pageState.searchTerm,
s: pageState.sortBy,
p: String(pageState.currentPage),
});
} else {
deleteUrlParam('q');
deleteUrlParam('s');
deleteUrlParam('p');
}
loadAIRoadmaps(
pageState.currentPage,
pageState.searchTerm,
pageState.sortBy,
).finally(() => {
setIsLoading(false);
});
}, [pageState]);
const loadAIRoadmaps = async (
currPage: number = 1,
searchTerm: string = '',
sortBy: SortByValues = 'createdAt',
) => {
const { response, error } = await httpGet<ListShowcaseRoadmapResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-list-showcase-roadmap`,
{
currPage,
...(searchTerm && { searchTerm }),
...(sortBy && { sortBy }),
},
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
return;
}
setRoadmapsResponse(response);
};
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
const roadmaps = roadmapsResponse?.data || [];
const loadingIndicator = isLoading && <LoadingRoadmaps />;
return (
<>
{isCreatingRoadmap && (
<CreateRoadmapModal
onClose={() => {
setIsCreatingRoadmap(false);
}}
/>
)}
<div className="border-b bg-white pt-10 pb-7">
<div className="container text-left">
<div className="flex flex-col items-start bg-white">
<h1 className="mb-1 text-2xl font-bold sm:text-4xl">
Community Roadmaps
</h1>
<p className="mb-3 text-base text-gray-500">
An unvetted, selected list of community-curated roadmaps
</p>
<div className="relative">
<div className="flex flex-col sm:flex-row items-center gap-1.5">
<span className="group relative normal-case">
<Tooltip
position={'bottom-left'}
additionalClass={
'translate-y-0.5 bg-yellow-300 font-normal !text-black'
}
>
Ask us to feature it once you're done!
</Tooltip>
<button
className="rounded-md bg-black px-3.5 py-1.5 text-sm font-medium text-white transition-colors hover:bg-black"
onClick={() => {
setIsCreatingRoadmap(true);
}}
>
Create your own roadmap
</button>
</span>
<span className="group relative normal-case">
<Tooltip
position={'bottom-left'}
additionalClass={
'translate-y-0.5 bg-yellow-300 font-normal !text-black'
}
>
Up-to-date and maintained by the official team
</Tooltip>
<a
href="/roadmaps"
className="inline-block rounded-md bg-gray-300 px-3.5 py-1.5 text-sm text-black sm:py-1.5 sm:text-sm"
>
Visit our official roadmaps
</a>
</span>
</div>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 py-3">
<section className="container mx-auto py-3">
<div className="mb-3.5 flex items-stretch justify-between gap-2.5">
<SearchRoadmap
total={roadmapsResponse?.totalCount || 0}
value={pageState.searchTerm}
isLoading={isLoading}
onValueChange={(value) => {
setPageState({
...pageState,
searchTerm: value,
currentPage: 1,
});
}}
/>
<DiscoverRoadmapSorting
sortBy={pageState.sortBy}
onSortChange={(sortBy) => {
setPageState({
...pageState,
sortBy,
});
}}
/>
</div>
{loadingIndicator}
{roadmaps.length === 0 && !isLoading && <EmptyDiscoverRoadmaps />}
{roadmaps.length > 0 && !isLoading && (
<>
<ul className="mb-4 grid grid-cols-1 items-stretch gap-3 sm:grid-cols-2 lg:grid-cols-3">
{roadmaps.map((roadmap) => {
const roadmapLink = `/r/${roadmap.slug}`;
const totalRatings = Object.keys(
roadmap.ratings?.breakdown || [],
).reduce(
(acc: number, key: string) =>
acc + roadmap.ratings.breakdown[key as any],
0,
);
return (
<li key={roadmap._id} className="h-full min-h-[175px]">
<a
key={roadmap._id}
href={roadmapLink}
className="flex h-full flex-col rounded-lg border bg-white p-3.5 transition-colors hover:border-gray-300 hover:bg-gray-50"
target={'_blank'}
>
<div className="grow">
<h2 className="text-balance text-base font-bold leading-tight">
{roadmap.title}
</h2>
<p className="mt-2 text-sm text-gray-500">
{roadmap.description}
</p>
</div>
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1 text-xs text-gray-400">
<Shapes size={15} className="inline-block" />
{Intl.NumberFormat('en-US', {
notation: 'compact',
}).format(roadmap.topicCount)}{' '}
</span>
<Rating
rating={roadmap?.ratings?.average || 0}
readOnly={true}
starSize={16}
total={totalRatings}
/>
</div>
</a>
</li>
);
})}
</ul>
<Pagination
currPage={roadmapsResponse?.currPage || 1}
totalPages={roadmapsResponse?.totalPages || 1}
perPage={roadmapsResponse?.perPage || 0}
totalCount={roadmapsResponse?.totalCount || 0}
onPageChange={(page) => {
setPageState({
...pageState,
currentPage: page,
});
}}
/>
</>
)}
</section>
</div>
</>
);
}

View File

@@ -1,53 +0,0 @@
import { Map, Wand2 } from 'lucide-react';
import { useState } from 'react';
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
export function EmptyDiscoverRoadmaps() {
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
const creatingRoadmapModal = isCreatingRoadmap && (
<CreateRoadmapModal
onClose={() => setIsCreatingRoadmap(false)}
onCreated={(roadmap) => {
window.location.href = `${
import.meta.env.PUBLIC_EDITOR_APP_URL
}/${roadmap?._id}`;
}}
/>
);
return (
<>
{creatingRoadmapModal}
<div className="flex min-h-[250px] flex-col items-center justify-center rounded-xl border px-5 py-3 sm:px-0 sm:py-20 bg-white">
<Map className="mb-4 h-8 w-8 opacity-10 sm:h-14 sm:w-14" />
<h2 className="mb-1 text-lg font-semibold sm:text-xl">
No Roadmaps Found
</h2>
<p className="mb-3 text-balance text-center text-xs text-gray-800 sm:text-sm">
Try searching for something else or create a new roadmap.
</p>
<div className="flex flex-col items-center gap-1 sm:flex-row sm:gap-1.5">
<button
className="flex w-full items-center gap-1.5 rounded-md bg-gray-900 px-3 py-1.5 text-xs text-white sm:w-auto sm:text-sm"
type="button"
onClick={() => {
setIsCreatingRoadmap(true);
}}
>
<Wand2 className="h-4 w-4" />
Create your Roadmap
</button>
<a
href="/roadmaps"
className="flex w-full items-center gap-1.5 rounded-md bg-gray-300 px-3 py-1.5 text-xs text-black hover:bg-gray-400 sm:w-auto sm:text-sm"
>
<Map className="h-4 w-4" />
Visit Official Roadmaps
</a>
</div>
</div>
</>
);
}

View File

@@ -1,76 +0,0 @@
import { Search } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useDebounceValue } from '../../hooks/use-debounce';
import { Spinner } from '../ReactIcons/Spinner';
type SearchRoadmapProps = {
value: string;
total: number;
isLoading: boolean;
onValueChange: (value: string) => void;
};
export function SearchRoadmap(props: SearchRoadmapProps) {
const { total, value: defaultValue, onValueChange, isLoading } = props;
const [term, setTerm] = useState(defaultValue);
const debouncedTerm = useDebounceValue(term, 500);
useEffect(() => {
setTerm(defaultValue);
}, [defaultValue]);
useEffect(() => {
if (debouncedTerm && debouncedTerm.length < 3) {
return;
}
if (debouncedTerm === defaultValue) {
return;
}
onValueChange(debouncedTerm);
}, [debouncedTerm]);
return (
<div className="relative flex w-full items-center gap-3">
<form
className="relative flex w-full max-w-[310px] items-center"
onSubmit={(e) => {
e.preventDefault();
onValueChange(term);
}}
>
<label
className="absolute left-3 flex h-full items-center text-gray-500"
htmlFor="search"
>
<Search className="h-4 w-4" />
</label>
<input
id="q"
name="q"
type="text"
minLength={3}
placeholder="Type 3 or more characters to search..."
className="w-full rounded-md border border-gray-200 px-3 py-2 pl-9 text-sm transition-colors focus:border-black focus:outline-none"
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
{isLoading && (
<span className="absolute right-3 top-0 flex h-full items-center text-gray-500">
<Spinner isDualRing={false} className={`h-3 w-3`} />
</span>
)}
</form>
{total > 0 && (
<p className="hidden flex-shrink-0 text-sm text-gray-500 sm:block">
{Intl.NumberFormat('en-US', {
notation: 'compact',
}).format(total)}{' '}
result{total > 1 ? 's' : ''} found
</p>
)}
</div>
);
}

View File

@@ -1,106 +0,0 @@
import { useEffect, useState, type CSSProperties } from 'react';
import {
EditorRoadmapRenderer,
type RoadmapRendererProps,
} from './EditorRoadmapRenderer';
import { Spinner } from '../ReactIcons/Spinner';
import {
clearMigratedRoadmapProgress,
type ResourceType,
} from '../../lib/resource-progress';
import { httpGet } from '../../lib/http';
import { ProgressNudge } from '../FrameRenderer/ProgressNudge';
import { getUrlParams } from '../../lib/browser.ts';
import { cn } from '../../lib/classname.ts';
import { getUser } from '../../lib/jwt.ts';
type EditorRoadmapProps = {
resourceId: string;
resourceType?: ResourceType;
dimensions: {
width: number;
height: number;
};
};
export function EditorRoadmap(props: EditorRoadmapProps) {
const { resourceId, resourceType = 'roadmap', dimensions } = props;
const [hasSwitchedRoadmap, setHasSwitchedRoadmap] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [roadmapData, setRoadmapData] = useState<
Omit<RoadmapRendererProps, 'resourceId'> | undefined
>(undefined);
const loadRoadmapData = async () => {
setIsLoading(true);
const { r: switchRoadmapId } = getUrlParams();
const { response, error } = await httpGet<
Omit<RoadmapRendererProps, 'resourceId'>
>(`/${switchRoadmapId || resourceId}.json`);
if (error) {
console.error(error);
return;
}
setRoadmapData(response);
setIsLoading(false);
setHasSwitchedRoadmap(!!switchRoadmapId);
};
useEffect(() => {
clearMigratedRoadmapProgress(resourceType, resourceId);
loadRoadmapData().finally();
}, [resourceId]);
const aspectRatio = dimensions.width / dimensions.height;
if (!roadmapData || isLoading) {
return (
<div
style={
!hasSwitchedRoadmap
? ({
'--aspect-ratio': aspectRatio,
} as CSSProperties)
: undefined
}
className={
'flex aspect-[var(--aspect-ratio)] w-full flex-col justify-center'
}
>
<div className="flex w-full justify-center">
<Spinner
innerFill="#2563eb"
outerFill="#E5E7EB"
className="h-6 w-6 animate-spin sm:h-12 sm:w-12"
/>
</div>
</div>
);
}
return (
<div
style={
!hasSwitchedRoadmap
? ({
'--aspect-ratio': aspectRatio,
} as CSSProperties)
: undefined
}
className={
'flex aspect-[var(--aspect-ratio)] w-full flex-col justify-center'
}
>
<EditorRoadmapRenderer
{...roadmapData}
dimensions={dimensions}
resourceId={resourceId}
/>
<ProgressNudge resourceId={resourceId} resourceType={resourceType} />
</div>
);
}

View File

@@ -1,63 +0,0 @@
svg text tspan {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeSpeed;
}
svg > g[data-type='topic'],
svg > g[data-type='subtopic'],
svg g[data-type='link-item'],
svg > g[data-type='button'],
svg > g[data-type='resourceButton'],
svg > g[data-type='todo-checkbox'],
svg > g[data-type='todo'] {
cursor: pointer;
}
svg > g[data-type='topic']:hover > rect {
fill: var(--hover-color);
}
svg > g[data-type='subtopic']:hover > rect {
fill: var(--hover-color);
}
svg g[data-type='button']:hover,
svg g[data-type='link-item']:hover,
svg g[data-type='resourceButton']:hover,
svg g[data-type='todo-checkbox']:hover {
opacity: 0.8;
}
svg .done rect {
fill: #cbcbcb !important;
}
svg .done text,
svg .skipped text {
text-decoration: line-through;
}
svg > g[data-type='topic'].learning > rect + text,
svg > g[data-type='topic'].done > rect + text {
fill: black;
}
svg .done text[fill='#ffffff'] {
fill: black;
}
svg > g[data-type='subtipic'].done > rect + text,
svg > g[data-type='subtipic'].learning > rect + text {
fill: #cbcbcb;
}
svg .learning rect {
fill: #dad1fd !important;
}
svg .learning text {
text-decoration: underline;
}
svg .skipped rect {
fill: #496b69 !important;
}

View File

@@ -1,218 +0,0 @@
import { useCallback, useEffect, useRef } from 'react';
import './EditorRoadmapRenderer.css';
import {
renderResourceProgress,
updateResourceProgress,
type ResourceProgressType,
renderTopicProgress,
refreshProgressCounters,
} from '../../lib/resource-progress';
import { pageProgressMessage } from '../../stores/page';
import { useToast } from '../../hooks/use-toast';
import type { Edge, Node } from 'reactflow';
import { Renderer } from '../../../editor/renderer';
import { slugify } from '../../lib/slugger';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
export type RoadmapRendererProps = {
resourceId: string;
nodes: Node[];
edges: Edge[];
dimensions: {
width: number;
height: number;
};
};
type RoadmapNodeDetails = {
nodeId: string;
nodeType: string;
targetGroup: SVGElement;
title?: string;
};
function getNodeDetails(svgElement: SVGElement): RoadmapNodeDetails | null {
const targetGroup = (svgElement?.closest('g') as SVGElement) || {};
const nodeId = targetGroup?.dataset?.nodeId;
const nodeType = targetGroup?.dataset?.type;
const title = targetGroup?.dataset?.title;
if (!nodeId || !nodeType) {
return null;
}
return { nodeId, nodeType, targetGroup, title };
}
const allowedNodeTypes = [
'topic',
'subtopic',
'button',
'link-item',
'resourceButton',
'todo',
'todo-checkbox',
];
export function EditorRoadmapRenderer(props: RoadmapRendererProps) {
const { resourceId, nodes = [], edges = [] } = props;
const roadmapRef = useRef<HTMLDivElement>(null);
const toast = useToast();
async function updateTopicStatus(
topicId: string,
newStatus: ResourceProgressType,
) {
pageProgressMessage.set('Updating progress');
updateResourceProgress(
{
resourceId,
resourceType: 'roadmap',
topicId,
},
newStatus,
)
.then(() => {
renderTopicProgress(topicId, newStatus);
})
.catch((err) => {
toast.error('Something went wrong, please try again.');
console.error(err);
})
.finally(() => {
pageProgressMessage.set('');
refreshProgressCounters();
});
return;
}
const handleSvgClick = useCallback((e: MouseEvent) => {
const target = e.target as SVGElement;
const { nodeId, nodeType, targetGroup, title } =
getNodeDetails(target) || {};
if (!nodeId || !nodeType || !allowedNodeTypes.includes(nodeType)) {
return;
}
if (
nodeType === 'button' ||
nodeType === 'link-item' ||
nodeType === 'resourceButton'
) {
const link = targetGroup?.dataset?.link || '';
const isExternalLink = link.startsWith('http');
if (isExternalLink) {
window.open(link, '_blank');
} else {
window.location.href = link;
}
return;
}
const isCurrentStatusLearning = targetGroup?.classList.contains('learning');
const isCurrentStatusSkipped = targetGroup?.classList.contains('skipped');
if (nodeType === 'todo-checkbox') {
e.preventDefault();
if (!isLoggedIn()) {
showLoginPopup();
return;
}
const newStatus = targetGroup?.classList.contains('done')
? 'pending'
: 'done';
updateTopicStatus(nodeId, newStatus);
return;
}
if (e.shiftKey) {
e.preventDefault();
if (!isLoggedIn()) {
showLoginPopup();
return;
}
updateTopicStatus(
nodeId,
isCurrentStatusLearning ? 'pending' : 'learning',
);
return;
} else if (e.altKey) {
e.preventDefault();
if (!isLoggedIn()) {
showLoginPopup();
return;
}
updateTopicStatus(nodeId, isCurrentStatusSkipped ? 'pending' : 'skipped');
return;
}
if (!title) {
return;
}
const detailsPattern = `${slugify(title)}@${nodeId}`;
window.dispatchEvent(
new CustomEvent('roadmap.node.click', {
detail: {
topicId: detailsPattern,
resourceId,
resourceType: 'roadmap',
},
}),
);
}, []);
const handleSvgRightClick = useCallback((e: MouseEvent) => {
e.preventDefault();
const target = e.target as SVGElement;
const { nodeId, nodeType, targetGroup } = getNodeDetails(target) || {};
if (!nodeId || !nodeType || !allowedNodeTypes.includes(nodeType)) {
return;
}
if (nodeType === 'button') {
return;
}
if (!isLoggedIn()) {
showLoginPopup();
return;
}
const isCurrentStatusDone = targetGroup?.classList.contains('done');
updateTopicStatus(nodeId, isCurrentStatusDone ? 'pending' : 'done');
}, []);
useEffect(() => {
if (!roadmapRef?.current) {
return;
}
roadmapRef?.current?.addEventListener('click', handleSvgClick);
roadmapRef?.current?.addEventListener('contextmenu', handleSvgRightClick);
return () => {
roadmapRef?.current?.removeEventListener('click', handleSvgClick);
roadmapRef?.current?.removeEventListener(
'contextmenu',
handleSvgRightClick,
);
};
}, []);
return (
<Renderer
ref={roadmapRef}
roadmap={{ nodes, edges }}
onRendered={() => {
roadmapRef.current?.setAttribute('data-renderer', 'editor');
renderResourceProgress('roadmap', resourceId).finally();
}}
/>
);
}

View File

@@ -4,7 +4,7 @@ export function LoadingRoadmaps() {
{new Array(21).fill(0).map((_, index) => (
<li
key={index}
className="h-[175px] animate-pulse rounded-md border bg-gray-200"
className="h-[95px] animate-pulse rounded-md border bg-gray-100"
/>
))}
</ul>

View File

@@ -1,20 +0,0 @@
type AIAnnouncementProps = {};
export function FeatureAnnouncement(props: AIAnnouncementProps) {
return (
<a
className="rounded-md border border-dashed border-purple-600 px-3 py-1.5 text-purple-400 transition-colors hover:border-purple-400 hover:text-purple-200"
href="/community"
>
<span className="relative -top-[1px] mr-1 text-xs font-semibold uppercase text-white">
New
</span>{' '}
<span className={'hidden sm:inline'}>
Explore community made roadmaps
</span>
<span className={'inline text-sm sm:hidden'}>
Community roadmaps explorer!
</span>
</a>
);
}

View File

@@ -1,47 +1,35 @@
---
import type { GuideFileType } from '../lib/guide';
import GuideListItem from './GuideListItem.astro';
import { QuestionGroupType } from '../lib/question-group';
export interface Props {
heading: string;
guides: GuideFileType[];
questions: QuestionGroupType[];
}
const { heading, guides, questions = [] } = Astro.props;
const sortedGuides: (QuestionGroupType | GuideFileType)[] = [
...guides,
...questions,
].sort((a, b) => {
const aDate = new Date(a.frontmatter.date);
const bDate = new Date(b.frontmatter.date);
return bDate.getTime() - aDate.getTime();
});
const { heading, guides } = Astro.props;
---
<div class='container'>
<h2 class='block text-2xl font-bold sm:text-3xl'>{heading}</h2>
<h2 class='text-2xl sm:text-3xl font-bold block'>{heading}</h2>
<div class='mt-3 sm:my-5'>
{sortedGuides.map((guide) => <GuideListItem guide={guide} />)}
{guides.map((guide) => <GuideListItem guide={guide} />)}
</div>
<a
href='/guides'
class='hidden rounded-full bg-gradient-to-r from-slate-600 to-black px-3 py-2 text-xs font-medium text-white transition-colors hover:from-blue-600 hover:to-blue-800 sm:inline'
class='hidden sm:inline transition-colors py-2 px-3 text-xs font-medium rounded-full bg-gradient-to-r from-slate-600 to-black hover:from-blue-600 hover:to-blue-800 text-white'
>
View All Guides &rarr;
</a>
<div class='mt-3 block sm:hidden'>
<div class='block sm:hidden mt-3'>
<a
href='/guides'
class='font-regular block rounded-md border border-black p-2 text-center text-sm text-black hover:bg-black hover:text-gray-50'
class='text-sm font-regular block p-2 border border-black text-black rounded-md text-center hover:bg-black hover:text-gray-50'
>
View All Guides &nbsp;&rarr;
</a>
</div>
</div>
</div>

View File

@@ -52,7 +52,7 @@ svg .done rect {
fill: #cbcbcb !important;
}
svg .done rect[stroke='rgb(255,229,153)'] {
svg .done rect[stroke="rgb(255,229,153)"] {
stroke: #cbcbcb !important;
}
@@ -133,12 +133,10 @@ svg .removed path {
}
}
#customized-roadmap #resource-svg-wrap:not([data-renderer]) g:not([class]),
#customized-roadmap #resource-svg-wrap:not([data-renderer]) circle,
#customized-roadmap #resource-svg-wrap:not([data-renderer]) path[stroke='#fff'],
#customized-roadmap
#resource-svg-wrap:not([data-renderer])
g[data-group-id$='-note'] {
#customized-roadmap #resource-svg-wrap g:not([class]),
#customized-roadmap #resource-svg-wrap circle,
#customized-roadmap #resource-svg-wrap path[stroke='#fff'],
#customized-roadmap #resource-svg-wrap g[data-group-id$='-note'] {
display: none;
}

View File

@@ -51,7 +51,7 @@ export function ProgressNudge(props: ProgressNudgeProps) {
<span className="relative -top-[0.45px] mr-2 text-xs font-medium uppercase text-yellow-400">
Progress
</span>
<span>{done > $totalRoadmapNodes ? $totalRoadmapNodes : done}</span> of <span>{$totalRoadmapNodes}</span> Done
<span>{done}</span> of <span>{$totalRoadmapNodes}</span> Done
</span>
<span

View File

@@ -152,10 +152,6 @@ export class Renderer {
return;
}
if (/^check:/.test(topicId)) {
topicId = topicId.replace('check:', '');
}
pageProgressMessage.set('Updating progress');
updateResourceProgress(
{

View File

@@ -7,14 +7,12 @@ import { useToast } from '../../hooks/use-toast';
import { TrashIcon } from '../ReactIcons/TrashIcon';
import { AddedUserIcon } from '../ReactIcons/AddedUserIcon';
import { AddUserIcon } from '../ReactIcons/AddUserIcon';
import type { AllowedRoadmapRenderer } from '../../lib/roadmap';
type FriendProgressItemProps = {
friend: ListFriendsResponse[0];
onShowResourceProgress: (
resourceId: string,
isCustomResource?: boolean,
renderer?: AllowedRoadmapRenderer,
isCustomResource?: boolean
) => void;
onReload: () => void;
};
@@ -29,7 +27,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
pageProgressMessage.set('Please wait...');
const { response, error } = await httpDelete(
`${import.meta.env.PUBLIC_API_URL}/v1-delete-friend/${userId}`,
{},
{}
);
if (error || !response) {
@@ -45,7 +43,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
pageProgressMessage.set('Please wait...');
const { response, error } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-add-friend/${userId}`,
{},
{}
);
if (error || !response) {
@@ -94,8 +92,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
onClick={() =>
onShowResourceProgress(
progress.resourceId,
progress.isCustomResource,
progress?.renderer,
progress.isCustomResource
)
}
className="group relative overflow-hidden rounded-md border p-2 hover:border-gray-300 hover:text-black focus:outline-none"
@@ -163,7 +160,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
deleteFriend(friend.userId, 'Friend removed').finally(
() => {
pageProgressMessage.set('');
},
}
);
}}
>
@@ -201,7 +198,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
addFriend(friend.userId, 'Friend request accepted').finally(
() => {
pageProgressMessage.set('');
},
}
);
}}
>
@@ -228,7 +225,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
deleteFriend(friend.userId, 'Friend request removed').finally(
() => {
pageProgressMessage.set('');
},
}
);
}}
>
@@ -270,7 +267,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
onClick={() => {
deleteFriend(
friend.userId,
'Friend request withdrawn',
'Friend request withdrawn'
).finally(() => {
pageProgressMessage.set('');
});
@@ -307,7 +304,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
addFriend(friend.userId, 'Friend request accepted').finally(
() => {
pageProgressMessage.set('');
},
}
);
}}
className="mb-1 block w-full max-w-[150px] rounded-md bg-black py-1.5 text-sm text-white"
@@ -319,7 +316,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
onClick={() => {
deleteFriend(
friend.userId,
'Friend request rejected',
'Friend request rejected'
).finally(() => {
pageProgressMessage.set('');
});

View File

@@ -11,7 +11,6 @@ import { UserProgressModal } from '../UserProgress/UserProgressModal';
import { InviteFriendPopup } from './InviteFriendPopup';
import { UserCustomProgressModal } from '../UserProgress/UserCustomProgressModal';
import { UserIcon } from 'lucide-react';
import type { AllowedRoadmapRenderer } from '../../lib/roadmap';
type FriendResourceProgress = {
updatedAt: string;
@@ -23,7 +22,6 @@ type FriendResourceProgress = {
skipped: number;
done: number;
total: number;
renderer?: AllowedRoadmapRenderer;
};
export type ListFriendsResponse = {
@@ -57,7 +55,6 @@ export function FriendsPage() {
resourceId: string;
friend: ListFriendsResponse[0];
isCustomResource?: boolean;
renderer?: AllowedRoadmapRenderer;
}>();
const [isLoading, setIsLoading] = useState(true);
@@ -95,8 +92,8 @@ export function FriendsPage() {
(grouping) => grouping.value === selectedGrouping,
);
const filteredFriends = friends.filter((friend) =>
selectedGroupingType?.statuses.includes(friend.status),
const filteredFriends = friends.filter(
(friend) => selectedGroupingType?.statuses.includes(friend.status),
);
const receivedRequests = friends.filter(
@@ -127,7 +124,6 @@ export function FriendsPage() {
resourceType={'roadmap'}
onClose={() => setShowFriendProgress(undefined)}
isCustomResource={showFriendProgress?.isCustomResource}
renderer={showFriendProgress?.renderer}
/>
);
@@ -186,16 +182,11 @@ export function FriendsPage() {
{filteredFriends.map((friend) => (
<FriendProgressItem
friend={friend}
onShowResourceProgress={(
resourceId,
isCustomResource,
renderer,
) => {
onShowResourceProgress={(resourceId, isCustomResource) => {
setShowFriendProgress({
resourceId,
friend,
isCustomResource,
renderer,
});
}}
key={friend.userId}

View File

@@ -25,14 +25,20 @@ import { Ban, Cog, Download, PenSquare, Save, Wand } from 'lucide-react';
import { ShareRoadmapButton } from '../ShareRoadmapButton.tsx';
import { httpGet, httpPost } from '../../lib/http.ts';
import { pageProgressMessage } from '../../stores/page.ts';
import { deleteUrlParam, getUrlParams } from '../../lib/browser.ts';
import {
deleteUrlParam,
getUrlParams,
setUrlParams,
} from '../../lib/browser.ts';
import { downloadGeneratedRoadmapImage } from '../../helper/download-image.ts';
import { showLoginPopup } from '../../lib/popup.ts';
import { cn } from '../../lib/classname.ts';
import { RoadmapTopicDetail } from './RoadmapTopicDetail.tsx';
import { AIRoadmapAlert } from './AIRoadmapAlert.tsx';
import { OpenAISettings } from './OpenAISettings.tsx';
import { IS_KEY_ONLY_ROADMAP_GENERATION } from '../../lib/ai.ts';
import { AITermSuggestionInput } from './AITermSuggestionInput.tsx';
import { useParams } from '../../hooks/use-params.ts';
import { IncreaseRoadmapLimit } from './IncreaseRoadmapLimit.tsx';
import { AuthenticationForm } from '../AuthenticationFlow/AuthenticationForm.tsx';
@@ -288,10 +294,7 @@ export function GenerateRoadmap(props: GenerateRoadmapProps) {
setIsLoading(false);
pageProgressMessage.set('');
return {
roadmapId: response.roadmapId,
roadmapSlug: response.roadmapSlug,
};
return response.roadmapSlug;
};
const downloadGeneratedRoadmapContent = async () => {
@@ -683,9 +686,9 @@ export function GenerateRoadmap(props: GenerateRoadmapProps) {
<button
className="inline-flex items-center justify-center gap-2 rounded-md bg-gray-200 py-1.5 pl-2.5 pr-3 text-xs font-medium text-black transition-colors duration-300 hover:bg-gray-300 sm:text-sm"
onClick={async () => {
const response = await saveAIRoadmap();
if (response?.roadmapSlug) {
window.location.href = `/r/${response.roadmapSlug}`;
const roadmapSlug = await saveAIRoadmap();
if (roadmapSlug) {
window.location.href = `/r/${roadmapSlug}`;
}
}}
disabled={isLoading}
@@ -700,10 +703,10 @@ export function GenerateRoadmap(props: GenerateRoadmapProps) {
<button
className="hidden items-center justify-center gap-2 rounded-md bg-gray-200 py-1.5 pl-2.5 pr-3 text-xs font-medium text-black transition-colors duration-300 hover:bg-gray-300 sm:inline-flex sm:text-sm"
onClick={async () => {
const response = await saveAIRoadmap();
if (response?.roadmapId) {
const roadmapId = await saveAIRoadmap();
if (roadmapId) {
window.open(
`${import.meta.env.PUBLIC_EDITOR_APP_URL}/${response?.roadmapId}`,
`${import.meta.env.PUBLIC_EDITOR_APP_URL}/${roadmapId}`,
'_blank',
);
}

View File

@@ -42,7 +42,7 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
setIsAuthenticatedUser(isLoggedIn());
}, []);
const randomTerms = ['OAuth', 'UI / UX', 'SRE', 'DevRel'];
const randomTerms = ['OAuth', 'APIs', 'UX Design', 'gRPC'];
return (
<div className="flex flex-grow flex-col items-center px-4 py-6 sm:px-6 md:my-24 lg:my-32">

View File

@@ -124,7 +124,7 @@ export function RoadmapTopicDetail(props: RoadmapTopicDetailProps) {
const openAIKey = getOpenAIKey();
return (
<div className={'relative z-[90]'}>
<div className={'relative z-50'}>
<div
ref={topicRef}
tabIndex={0}

View File

@@ -1,39 +1,22 @@
---
import type { GuideFileType, GuideFrontmatter } from '../lib/guide';
import type { GuideFileType } from '../lib/guide';
import { replaceVariables } from '../lib/markdown';
import { QuestionGroupType } from '../lib/question-group';
export interface Props {
guide: GuideFileType | QuestionGroupType;
}
function isQuestionGroupType(
guide: GuideFileType | QuestionGroupType,
): guide is QuestionGroupType {
return (guide as QuestionGroupType).questions !== undefined;
guide: GuideFileType;
}
const { guide } = Astro.props;
const { frontmatter, id } = guide;
let pageUrl = '';
let guideType = '';
if (isQuestionGroupType(guide)) {
pageUrl = `/questions/${id}`;
guideType = 'Questions';
} else {
const excludedBySlug = (frontmatter as GuideFrontmatter).excludedBySlug;
pageUrl = excludedBySlug ? excludedBySlug : `/guides/${id}`;
guideType = (frontmatter as GuideFrontmatter).type;
}
---
<a
class:list={[
'text-md group block flex items-center justify-between border-b py-2 text-gray-600 no-underline hover:text-blue-600',
]}
href={pageUrl}
href={frontmatter.excludedBySlug
? frontmatter.excludedBySlug
: `/guides/${id}`}
>
<span
class='text-sm transition-transform group-hover:translate-x-2 md:text-base'
@@ -55,7 +38,7 @@ if (isQuestionGroupType(guide)) {
}
</span>
<span class='hidden text-xs capitalize text-gray-500 sm:block'>
{guideType}
{frontmatter.type}
</span>
<span class='block text-xs text-gray-400 sm:hidden'> &raquo;</span>

View File

@@ -1,5 +1,5 @@
import { CheckIcon } from '../ReactIcons/CheckIcon';
import { FeatureAnnouncement } from '../FeatureAnnouncement.tsx';
import { AIAnnouncement } from '../AIAnnouncement.tsx';
type EmptyProgressProps = {
title?: string;
@@ -23,7 +23,7 @@ export function EmptyProgress(props: EmptyProgressProps) {
<p className={'text-sm text-gray-400 sm:text-base'}>{message}</p>
<p className="mt-5">
<FeatureAnnouncement />
<AIAnnouncement />
</p>
</div>
);

View File

@@ -7,7 +7,7 @@ import { MapIcon, Users2 } from 'lucide-react';
import { CreateRoadmapButton } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapButton';
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
import { type ReactNode, useState } from 'react';
import { FeatureAnnouncement } from '../FeatureAnnouncement.tsx';
import { AIAnnouncement } from '../AIAnnouncement.tsx';
type ProgressRoadmapProps = {
url: string;
@@ -97,7 +97,7 @@ export function HeroRoadmaps(props: ProgressListProps) {
return (
<div className="relative pb-12 pt-4 sm:pt-7">
<p className="mb-7 mt-2 text-sm">
<FeatureAnnouncement />
<AIAnnouncement />
</p>
{isCreatingRoadmap && (
<CreateRoadmapModal

View File

@@ -1,6 +1,6 @@
---
import { FavoriteRoadmaps } from './FavoriteRoadmaps';
import { FeatureAnnouncement } from "../FeatureAnnouncement";
import { AIAnnouncement } from "../AIAnnouncement";
---
<div
@@ -10,12 +10,12 @@ import { FeatureAnnouncement } from "../FeatureAnnouncement";
class='container px-5 py-6 pb-14 text-left transition-opacity duration-300 sm:px-0 sm:py-20 sm:text-center'
id='hero-text'
>
<p class='-mt-4 mb-7 sm:-mt-10 sm:mb-4'>
<FeatureAnnouncement />
<p class='-mt-4 mb-7 sm:-mt-10'>
<AIAnnouncement />
</p>
<h1
class='mb-2 bg-gradient-to-b from-amber-50 to-purple-500 bg-clip-text text-2xl font-bold text-transparent sm:mb-4 sm:text-5xl sm:leading-tight'
class='mb-2 bg-gradient-to-b from-amber-50 to-purple-500 bg-clip-text text-2xl font-bold text-transparent sm:mb-4 sm:text-5xl'
>
Developer Roadmaps
</h1>

View File

@@ -1,173 +1,68 @@
import { useEffect, useRef, useState } from 'react';
import { ChevronDown, User } from 'lucide-react';
import { getUser, isLoggedIn } from '../../lib/jwt';
import { useRef, useState } from 'react';
import { ChevronDown } from 'lucide-react';
import { isLoggedIn } from '../../lib/jwt';
import { AccountDropdownList } from './AccountDropdownList';
import { DropdownTeamList } from './DropdownTeamList';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
import { OnboardingModal } from './OnboardingModal.tsx';
import { httpGet } from '../../lib/http.ts';
import { useToast } from '../../hooks/use-toast.ts';
import type { UserDocument } from '../../api/user.ts';
import { NotificationIndicator } from './NotificationIndicator.tsx';
import { OnboardingNudge } from '../OnboardingNudge.tsx';
export type OnboardingConfig = Pick<
UserDocument,
'onboarding' | 'onboardingStatus'
>;
export function AccountDropdown() {
const toast = useToast();
const dropdownRef = useRef(null);
const [showDropdown, setShowDropdown] = useState(false);
const [isTeamsOpen, setIsTeamsOpen] = useState(false);
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
const [isConfigLoading, setIsConfigLoading] = useState(false);
const [isOnboardingModalOpen, setIsOnboardingModalOpen] = useState(false);
const [onboardingConfig, setOnboardingConfig] = useState<
OnboardingConfig | undefined
>(undefined);
const currentUser = getUser();
const shouldShowOnboardingStatus =
currentUser?.onboardingStatus === 'pending' ||
onboardingConfig?.onboardingStatus === 'pending';
const loadOnboardingConfig = async () => {
if (!isLoggedIn() || !shouldShowOnboardingStatus) {
return;
}
setIsConfigLoading(true);
const { response, error } = await httpGet<OnboardingConfig>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-onboarding-config`,
);
if (error || !response) {
toast.error(error?.message || 'Failed to load onboarding config');
}
setOnboardingConfig(response);
};
useOutsideClick(dropdownRef, () => {
setShowDropdown(false);
setIsTeamsOpen(false);
setIsConfigLoading(true);
});
useEffect(() => {
if (!isLoggedIn() || !showDropdown) {
return;
}
loadOnboardingConfig().finally(() => {
setIsConfigLoading(false);
});
}, [showDropdown]);
useEffect(() => {
const loadConfig = () => {
loadOnboardingConfig().finally(() => {
setIsConfigLoading(false);
});
};
window.addEventListener('visibilitychange', loadConfig);
return () => {
window.removeEventListener('visibilitychange', loadConfig);
};
}, []);
if (!isLoggedIn()) {
return null;
}
const onboardingDoneCount = Object.values(
onboardingConfig?.onboarding || {},
).filter((status) => status !== 'pending').length;
const onboardingCount = Object.keys(
onboardingConfig?.onboarding || {},
).length;
return (
<>
{shouldShowOnboardingStatus && !isOnboardingModalOpen && (
<OnboardingNudge
onStartOnboarding={() => {
loadOnboardingConfig().then(() => {
setIsOnboardingModalOpen(true);
});
<div className="relative z-50 animate-fade-in">
{isCreatingRoadmap && (
<CreateRoadmapModal
onClose={() => {
setIsCreatingRoadmap(false);
}}
/>
)}
<div className="relative z-[90] animate-fade-in">
{isOnboardingModalOpen && onboardingConfig && (
<OnboardingModal
onboardingConfig={onboardingConfig}
onClose={() => {
setIsOnboardingModalOpen(false);
}}
onIgnoreTask={(taskId, status) => {
loadOnboardingConfig().finally(() => {});
}}
/>
)}
{isCreatingRoadmap && (
<CreateRoadmapModal
onClose={() => {
setIsCreatingRoadmap(false);
}}
/>
)}
<button
className="flex h-8 w-40 items-center justify-center gap-1.5 rounded-full bg-gradient-to-r from-purple-500 to-purple-700 px-4 py-2 text-sm font-medium text-white hover:from-purple-500 hover:to-purple-600"
onClick={() => {
setIsTeamsOpen(false);
setShowDropdown(!showDropdown);
}}
>
<span className="inline-flex items-center">
Account&nbsp;<span className="text-gray-300">/</span>&nbsp;Teams
</span>
<ChevronDown className="h-4 w-4 shrink-0 stroke-[2.5px]" />
</button>
<button
className="relative flex h-8 w-40 items-center justify-center gap-1.5 rounded-full bg-gradient-to-r from-purple-500 to-purple-700 px-4 py-2 text-sm font-medium text-white hover:from-purple-500 hover:to-purple-600"
onClick={() => {
setIsTeamsOpen(false);
setShowDropdown(!showDropdown);
}}
{showDropdown && (
<div
ref={dropdownRef}
className="absolute right-0 z-50 mt-2 min-h-[152px] w-48 rounded-md bg-slate-800 py-1 shadow-xl"
>
<span className="inline-flex items-center">
Account&nbsp;<span className="text-gray-300">/</span>&nbsp;Teams
</span>
<ChevronDown className="h-4 w-4 shrink-0 stroke-[2.5px]" />
{shouldShowOnboardingStatus && !showDropdown && (
<NotificationIndicator />
{isTeamsOpen ? (
<DropdownTeamList setIsTeamsOpen={setIsTeamsOpen} />
) : (
<AccountDropdownList
onCreateRoadmap={() => {
setIsCreatingRoadmap(true);
setShowDropdown(false);
}}
setIsTeamsOpen={setIsTeamsOpen}
/>
)}
</button>
{showDropdown && (
<div
ref={dropdownRef}
className="absolute right-0 z-50 mt-2 min-h-[152px] w-48 rounded-md bg-slate-800 py-1 shadow-xl"
>
{isTeamsOpen ? (
<DropdownTeamList setIsTeamsOpen={setIsTeamsOpen} />
) : (
<AccountDropdownList
onCreateRoadmap={() => {
setIsCreatingRoadmap(true);
setShowDropdown(false);
}}
setIsTeamsOpen={setIsTeamsOpen}
onOnboardingClick={() => {
setIsOnboardingModalOpen(true);
setShowDropdown(false);
}}
shouldShowOnboardingStatus={shouldShowOnboardingStatus}
isConfigLoading={isConfigLoading}
onboardingConfigCount={onboardingCount}
doneConfigCount={onboardingDoneCount}
/>
)}
</div>
)}
</div>
</>
</div>
)}
</div>
);
}

View File

@@ -6,67 +6,21 @@ import {
SquareUserRound,
User2,
Users2,
Handshake,
} from 'lucide-react';
import { logout } from './navigation';
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
import { useState } from 'react';
import { cn } from '../../lib/classname.ts';
import { NotificationIndicator } from './NotificationIndicator.tsx';
import { Spinner } from '../ReactIcons/Spinner.tsx';
import { CheckIcon } from '../ReactIcons/CheckIcon.tsx';
type AccountDropdownListProps = {
onCreateRoadmap: () => void;
setIsTeamsOpen: (isOpen: boolean) => void;
onOnboardingClick: () => void;
isConfigLoading: boolean;
shouldShowOnboardingStatus?: boolean;
onboardingConfigCount: number;
doneConfigCount: number;
};
export function AccountDropdownList(props: AccountDropdownListProps) {
const {
setIsTeamsOpen,
onCreateRoadmap,
onOnboardingClick,
isConfigLoading = true,
shouldShowOnboardingStatus = false,
onboardingConfigCount,
doneConfigCount,
} = props;
const { setIsTeamsOpen, onCreateRoadmap } = props;
return (
<ul>
{shouldShowOnboardingStatus && (
<li className="mb-1 px-1">
<button
className={cn(
'flex h-9 w-full items-center rounded py-1 pl-3 pr-2 text-sm font-medium text-slate-100 hover:opacity-80',
isConfigLoading
? 'striped-loader-darker flex border-slate-800 opacity-70'
: 'border-slate-600 bg-slate-700',
)}
onClick={onOnboardingClick}
disabled={isConfigLoading}
>
<NotificationIndicator className="-left-0.5 -top-0.5" />
{isConfigLoading ? (
<></>
) : (
<>
<Handshake className="mr-2 h-4 w-4 text-slate-400 group-hover:text-white" />
<span>Onboarding</span>
<span className="ml-auto flex items-center gap-1.5 text-xs text-slate-400">
{doneConfigCount} of {onboardingConfigCount}
</span>
</>
)}
</button>
</li>
)}
<li className="px-1">
<a
href="/account"

View File

@@ -3,7 +3,6 @@ import { Menu } from 'lucide-react';
import Icon from '../AstroIcon.astro';
import { NavigationDropdown } from '../NavigationDropdown';
import { AccountDropdown } from './AccountDropdown';
import NewIndicator from './NewIndicator.astro';
---
<div class='bg-slate-900 py-5 text-white sm:py-8'>
@@ -18,20 +17,20 @@ import NewIndicator from './NewIndicator.astro';
</a>
<a
href='/teams'
class='group relative !mr-2 inline text-blue-300 hover:text-white sm:hidden'
href='/ai'
class='group inline sm:hidden relative !mr-2 text-blue-300 hover:text-white'
>
Teams
AI Roadmaps&nbsp;
<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 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>
</span>
</a>
<!-- Desktop navigation items -->
@@ -40,27 +39,30 @@ import NewIndicator from './NewIndicator.astro';
<a href='/get-started' class='text-gray-400 hover:text-white'>
Start Here
</a>
<a href='/teams' class='text-gray-400 hover:text-white'> Teams</a>
<a
href='/teams'
class='group relative text-gray-400 hover:text-white'
>
Teams
</a>
<a href='/ai' class='text-gray-400 hover:text-white'> AI</a>
<a
href='/community'
href='/ai'
class='group relative !mr-2 text-blue-300 hover:text-white'
>
Community
<NewIndicator />
AI Roadmaps
<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>
<!--<button-->
<!-- data-command-menu-->
<!-- class='hidden items-center rounded-md border border-gray-800 px-2.5 py-1.5 text-sm text-gray-400 hover:cursor-pointer hover:bg-gray-800 md:flex'-->
<!--&gt;-->
<!-- <Icon icon='search' class='h-3 w-3' />-->
<!-- <span class='ml-2'>Search</span>-->
<!--</button>-->
<button
data-command-menu
class='hidden items-center rounded-md border border-gray-800 px-2.5 py-1.5 text-sm text-gray-400 hover:cursor-pointer hover:bg-gray-800 md:flex'
>
<Icon icon='search' class='h-3 w-3' />
<span class='ml-2'>Search</span>
</button>
</div>
</div>

View File

@@ -1,8 +0,0 @@
<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>

View File

@@ -1,20 +0,0 @@
import { cn } from '../../lib/classname.ts';
type NotificationIndicatorProps = {
className?: string;
};
export function NotificationIndicator(props: NotificationIndicatorProps) {
const { className = '' } = props;
return (
<span
className={cn(
'absolute -top-1 right-0 h-3 w-3 text-xs uppercase tracking-wider',
className,
)}
>
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex h-3 w-3 rounded-full bg-green-500"></span>
</span>
);
}

View File

@@ -1,253 +0,0 @@
import { ArrowUpRight, Check } from 'lucide-react';
import { Modal } from '../Modal';
import { cn } from '../../lib/classname';
import { useEffect, useMemo, useState } from 'react';
import type { AllowedOnboardingStatus } from '../../api/user';
import { pageProgressMessage } from '../../stores/page';
import { httpPatch } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
import type { OnboardingConfig } from './AccountDropdown';
import { setAuthToken } from '../../lib/jwt';
import { NUDGE_ONBOARDING_KEY } from '../OnboardingNudge.tsx';
type Task = {
id: string;
title: string;
description: string;
status: AllowedOnboardingStatus;
url: string;
urlText: string;
onClick?: () => void;
};
type OnboardingModalProps = {
onClose: () => void;
onboardingConfig: OnboardingConfig;
onIgnoreTask?: (taskId: string, status: AllowedOnboardingStatus) => void;
};
export function OnboardingModal(props: OnboardingModalProps) {
const { onboardingConfig, onClose, onIgnoreTask } = props;
const toast = useToast();
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const tasks = useMemo(() => {
return [
{
id: 'updateProgress',
title: 'Update your Progress',
description: 'Mark your progress on roadmaps',
status: onboardingConfig?.onboarding?.updateProgress || 'pending',
url: '/roadmaps',
urlText: 'Roadmaps List',
},
{
id: 'publishProfile',
title: 'Claim a Username',
description: 'Optionally create a public profile to share your skills',
status: onboardingConfig?.onboarding?.publishProfile || 'pending',
url: '/account/update-profile',
urlText: 'Update Profile',
},
{
id: 'customRoadmap',
title: 'Custom Roadmaps',
description: 'Create your own roadmap from scratch',
status: onboardingConfig?.onboarding?.customRoadmap || 'pending',
url: import.meta.env.DEV
? 'http://localhost:4321'
: 'https://draw.roadmap.sh',
urlText: 'Create Roadmap',
},
{
id: 'addFriends',
title: 'Invite your Friends',
description: 'Invite friends to join you on roadmaps',
status: onboardingConfig?.onboarding?.addFriends || 'pending',
url: '/account/friends',
urlText: 'Add Friends',
onClick: () => {
ignoreOnboardingTask(
'addFriends',
'done',
'Updating status..',
).finally(() => pageProgressMessage.set(''));
},
},
{
id: 'roadCard',
title: 'Create your Roadmap Card',
description: 'Embed your skill card on your github or website',
status: onboardingConfig?.onboarding?.roadCard || 'pending',
url: '/account/road-card',
urlText: 'Create Road Card',
onClick: () => {
ignoreOnboardingTask('roadCard', 'done', 'Updating status..').finally(
() => pageProgressMessage.set(''),
);
},
},
{
id: 'inviteTeam',
title: 'Invite your Team',
description: 'Invite your team to collaborate on roadmaps',
status: onboardingConfig?.onboarding?.inviteTeam || 'pending',
url: '/team/new',
urlText: 'Create Team',
},
];
}, [onboardingConfig]);
const ignoreOnboardingTask = async (
taskId: string,
status: AllowedOnboardingStatus,
message: string = 'Ignoring Task',
) => {
pageProgressMessage.set(message);
const { response, error } = await httpPatch(
`${import.meta.env.PUBLIC_API_URL}/v1-update-onboarding-config`,
{
id: taskId,
status,
},
);
if (error || !response) {
toast.error(error?.message || 'Failed to ignore task');
return;
}
onIgnoreTask?.(taskId, status);
setSelectedTask(null);
};
const ignoreForever = async () => {
const { response, error } = await httpPatch<{ token: string }>(
`${import.meta.env.PUBLIC_API_URL}/v1-ignore-onboarding-forever`,
{},
);
if (error || !response) {
toast.error(error?.message || 'Failed to ignore onboarding');
return;
}
setAuthToken(response.token);
window.location.reload();
};
const isAllTasksDone = tasks.every(
(task) => task.status === 'done' || task.status === 'ignored',
);
useEffect(() => {
if (!isAllTasksDone) {
return;
}
pageProgressMessage.set('Finishing Onboarding');
ignoreForever().finally(() => {});
}, [isAllTasksDone]);
return (
<Modal onClose={onClose} bodyClassName="text-black h-auto">
<div className="px-4 pb-2 pl-11 pt-4">
<h2 className="mb-0.5 text-xl font-semibold">Welcome to roadmap.sh</h2>
<p className="text-balance text-sm text-gray-500">
Complete the tasks below to get started!
</p>
</div>
<ul
className={cn('flex flex-col divide-y', {
'border-b': tasks[tasks.length - 1]?.status === 'done',
})}
>
{/*sort to put completed tasks at the end */}
{tasks.map((task, taskCounter) => {
const isDone = task.status === 'done';
const isActive = selectedTask?.id === task.id;
return (
<li
key={task.id}
data-active={isActive}
data-status={task.status}
className={cn('group/task px-4 py-2.5', {
'bg-gray-100': isDone,
'border-t': taskCounter === 0 && isDone,
})}
>
<div
className={cn('flex items-start gap-2', {
'opacity-50': task.status === 'done',
})}
>
<span className="relative top-px flex h-5 w-5 items-center justify-center">
{isDone ? (
<Check className="h-4 w-4 stroke-[3px] text-green-500" />
) : (
<div
className={cn(
'h-4 w-4 rounded-md border border-gray-300',
task.status === 'ignored'
? 'bg-gray-200'
: 'bg-transparent',
)}
/>
)}
</span>
<div className="group-data-[status=ignored]/task:text-gray-400">
<h3 className="flex items-center text-sm font-semibold group-data-[status=done]/task:line-through">
{task.title}
<a
href={task.url}
target="_blank"
className={cn(
'ml-1 inline-block rounded-xl border border-black bg-white pl-1.5 pr-1 text-xs font-normal text-black hover:bg-black hover:text-white',
)}
aria-label="Open task in new tab"
onClick={() => {
if (!task?.onClick) {
return;
}
task.onClick();
}}
>
{task.urlText}
<ArrowUpRight className="relative -top-[0.5px] ml-0.5 inline-block h-3.5 w-3.5 stroke-[2px]" />
</a>
</h3>
<p className="text-xs text-gray-500 group-data-[status=ignored]/task:text-gray-400">
{task.description}
</p>
</div>
</div>
</li>
);
})}
</ul>
<div className="mt-2 px-11 pb-5">
<button
className="w-full rounded-md bg-gradient-to-r from-purple-500 to-purple-700 px-4 py-2 text-sm font-medium text-white hover:from-purple-500 hover:to-purple-600"
onClick={onClose}
>
Do it later
</button>
<button
className="mt-3 text-sm text-gray-500 underline underline-offset-2 hover:text-black"
onClick={() => {
pageProgressMessage.set('Ignoring Onboarding');
ignoreForever().finally();
}}
>
Ignore forever
</button>
</div>
</Modal>
);
}

View File

@@ -1,69 +0,0 @@
import { cn } from '../lib/classname.ts';
import { memo, useEffect, useState } from 'react';
import { useScrollPosition } from '../hooks/use-scroll-position.ts';
import { X } from 'lucide-react';
type OnboardingNudgeProps = {
onStartOnboarding: () => void;
};
export const NUDGE_ONBOARDING_KEY = 'should_nudge_onboarding';
export function OnboardingNudge(props: OnboardingNudgeProps) {
const { onStartOnboarding } = props;
const [isLoading, setIsLoading] = useState(false);
const { y: scrollY } = useScrollPosition();
useEffect(() => {
if (localStorage.getItem(NUDGE_ONBOARDING_KEY) === null) {
localStorage.setItem(NUDGE_ONBOARDING_KEY, 'true');
}
}, []);
if (localStorage.getItem(NUDGE_ONBOARDING_KEY) !== 'true') {
return null;
}
if (scrollY < 100) {
return null;
}
return (
<div
className={cn(
'fixed left-0 right-0 top-0 z-[91] flex w-full items-center justify-center bg-yellow-300 border-b border-b-yellow-500/30 pt-1.5 pb-2',
{
'striped-loader': isLoading,
},
)}
>
<p className="text-base font-semibold text-yellow-950">
Welcome! Please take a moment to{' '}
<button
type="button"
onClick={() => {
setIsLoading(true);
localStorage.setItem(NUDGE_ONBOARDING_KEY, 'false');
onStartOnboarding();
}}
className="underline"
>
complete onboarding
</button>
<button
type="button"
className="relative top-[3px] ml-1 px-1 py-1 text-yellow-600 hover:text-yellow-950"
onClick={(e) => {
e.stopPropagation();
localStorage.setItem(NUDGE_ONBOARDING_KEY, 'false');
setIsLoading(true);
}}
>
<X className="h-4 w-4" strokeWidth={3} />
</button>
</p>
</div>
);
}

View File

@@ -24,7 +24,7 @@ const discordInfo = await getDiscordInfo();
class='mt-5 grid grid-cols-1 justify-between gap-2 divide-x-0 sm:my-11 sm:grid-cols-3 sm:gap-0 sm:divide-x mb-4 sm:mb-0'
>
<OpenSourceStat text='GitHub Stars' value={starCount} />
<OpenSourceStat text='Registered Users' value={'+1M'} />
<OpenSourceStat text='Registered Users' value={'850k'} />
<OpenSourceStat
text='Discord Members'
value={discordInfo.totalFormatted}

View File

@@ -28,7 +28,7 @@ const isDiscordMembers = text.toLowerCase() === 'discord members';
{
isRegistered && (
<p class='flex items-center text-sm text-blue-500 sm:flex'>
<span class='mr-1.5 rounded-md bg-blue-500 px-1 text-white'>+75k</span>
<span class='mr-1.5 rounded-md bg-blue-500 px-1 text-white'>+55k</span>
every month
</p>
)
@@ -44,7 +44,7 @@ const isDiscordMembers = text.toLowerCase() === 'discord members';
}
<div class="flex flex-row items-center sm:flex-col my-1 sm:my-0">
<p
class='relative my-0 sm:my-4 mr-1 sm:mr-0 text-base font-bold sm:w-auto sm:text-5xl'
class='relative my-0 sm:my-4 mr-1 sm:mr-0 text-base font-bold lowercase sm:w-auto sm:text-5xl'
>
{value}
</p>

View File

@@ -28,7 +28,7 @@ export function PageProgress(props: Props) {
return (
<div>
{/* Tailwind based spinner for full page */}
<div className="fixed left-0 top-0 z-[100] flex h-full w-full items-center justify-center bg-white bg-opacity-75">
<div className="fixed left-0 top-0 z-50 flex h-full w-full items-center justify-center bg-white bg-opacity-75">
<div className="flex items-center justify-center rounded-md border bg-white px-4 py-2 ">
<Spinner
className="h-4 w-4 sm:h-4 sm:w-4"

View File

@@ -4,8 +4,6 @@ import { sponsorHidden } from '../stores/page';
import { useStore } from '@nanostores/react';
import { X } from 'lucide-react';
import { setViewSponsorCookie } from '../lib/jwt';
import { isMobile } from '../lib/is-mobile';
import Cookies from 'js-cookie';
export type PageSponsorType = {
company: string;
@@ -27,22 +25,6 @@ type PageSponsorProps = {
gaPageIdentifier?: string;
};
const CLOSE_SPONSOR_KEY = 'sponsorClosed';
function markSponsorHidden(sponsorId: string) {
Cookies.set(`${CLOSE_SPONSOR_KEY}-${sponsorId}`, '1', {
path: '/',
expires: 1,
sameSite: 'lax',
secure: true,
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
});
}
function isSponsorMarkedHidden(sponsorId: string) {
return Cookies.get(`${CLOSE_SPONSOR_KEY}-${sponsorId}`) === '1';
}
export function PageSponsor(props: PageSponsorProps) {
const { gaPageIdentifier } = props;
const $isSponsorHidden = useStore(sponsorHidden);
@@ -68,7 +50,6 @@ export function PageSponsor(props: PageSponsorProps) {
`${import.meta.env.PUBLIC_API_URL}/v1-get-sponsor`,
{
href: window.location.pathname,
mobile: isMobile() ? 'true' : 'false',
},
);
@@ -77,16 +58,12 @@ export function PageSponsor(props: PageSponsorProps) {
return;
}
if (
!response?.sponsor ||
!response.id ||
isSponsorMarkedHidden(response.id)
) {
if (!response?.sponsor) {
return;
}
setSponsor(response.sponsor);
setSponsorId(response.id);
setSponsorId(response?.id || null);
window.fireEvent({
category: 'SponsorImpression',
@@ -98,15 +75,9 @@ export function PageSponsor(props: PageSponsorProps) {
};
const clickSponsor = async (sponsorId: string) => {
const clickUrl = new URL(
`${import.meta.env.PUBLIC_API_URL}/v1-view-sponsor/${sponsorId}`,
);
const { response, error } = await httpPatch<{ status: 'ok' }>(
clickUrl.toString(),
{
mobile: isMobile(),
},
`${import.meta.env.PUBLIC_API_URL}/v1-view-sponsor/${sponsorId}`,
{},
);
if (error || !response) {
@@ -132,7 +103,7 @@ export function PageSponsor(props: PageSponsorProps) {
href={url}
target="_blank"
rel="noopener sponsored nofollow"
className="fixed bottom-0 left-0 right-0 z-50 flex bg-white shadow-lg outline-0 outline-transparent sm:bottom-[15px] sm:left-auto sm:right-[15px] sm:max-w-[350px]"
className="fixed bottom-[15px] right-[15px] z-50 flex max-w-[350px] bg-white shadow-lg outline-0 outline-transparent"
onClick={async () => {
window.fireEvent({
category: 'SponsorClick',
@@ -143,32 +114,26 @@ export function PageSponsor(props: PageSponsorProps) {
}}
>
<span
className="absolute right-1 top-1 text-gray-400 hover:text-gray-800 sm:right-1.5 sm:top-1.5 sm:text-gray-300"
className="absolute right-1.5 top-1.5 text-gray-300 hover:text-gray-800"
aria-label="Close"
onClick={(e) => {
e.preventDefault();
markSponsorHidden(sponsorId || '');
sponsorHidden.set(true);
}}
>
<X className="h-5 w-5 sm:h-4 sm:w-4" />
<X className="h-4 w-4" />
</span>
<span>
<img
src={imageUrl}
className="block h-[106px] object-cover sm:h-[169px] sm:w-[118.18px]"
alt="Sponsor Banner"
/>
</span>
<span className="flex flex-1 flex-col justify-between text-xs sm:text-sm">
<img
src={imageUrl}
className="block h-[150px] object-cover lg:h-[169px] lg:w-[118.18px]"
alt="Sponsor Banner"
/>
<span className="flex flex-1 flex-col justify-between text-sm">
<span className="p-[10px]">
<span className="mb-0.5 block font-semibold">{title}</span>
<span className="block text-gray-500">{description}</span>
</span>
<span className="sponsor-footer hidden sm:block">Partner Content</span>
<span className="block pb-1 text-center text-[10px] uppercase text-gray-400 sm:hidden">
Partner Content
</span>
<span className="sponsor-footer">Partner Content</span>
</span>
</a>
);

View File

@@ -13,19 +13,28 @@ type ProgressStatButtonProps = {
icon: ReactNode;
label: string;
count: number;
onClick: () => void;
};
function ProgressStatLabel(props: ProgressStatButtonProps) {
const { icon, label, count } = props;
function ProgressStatButton(props: ProgressStatButtonProps) {
const { icon, label, count, onClick, isDisabled = false } = props;
return (
<span className="group relative flex flex-1 items-center overflow-hidden rounded-md border border-gray-300 bg-white px-2 py-2 text-sm text-black transition-colors disabled:opacity-50 sm:rounded-xl sm:px-4 sm:py-3 sm:text-base">
<button
disabled={isDisabled}
onClick={onClick}
className="group relative flex flex-1 items-center overflow-hidden rounded-md border border-gray-300 bg-white px-2 py-2 text-sm text-black transition-colors hover:border-black disabled:pointer-events-none disabled:opacity-50 sm:rounded-xl sm:px-4 sm:py-3 sm:text-base"
>
{icon}
<span className="flex flex-grow justify-between">
<span>{label}</span>
<span>{count}</span>
</span>
</span>
<span className="absolute left-0 right-0 top-full flex h-full items-center justify-center border border-black bg-black text-white transition-all duration-200 group-hover:top-0">
Restart Asking
</span>
</button>
);
}
@@ -34,11 +43,12 @@ type QuestionFinishedProps = {
didNotKnowCount: number;
skippedCount: number;
totalCount: number;
onReset: () => void;
onReset: (type: QuestionProgressType | 'reset') => void;
};
export function QuestionFinished(props: QuestionFinishedProps) {
const { knowCount, didNotKnowCount, skippedCount, onReset } = props;
const { knowCount, didNotKnowCount, skippedCount, totalCount, onReset } =
props;
return (
<div className="relative flex flex-grow flex-col items-center justify-center px-4 sm:px-0">
@@ -53,25 +63,31 @@ export function QuestionFinished(props: QuestionFinishedProps) {
</p>
<div className="mb-5 mt-5 flex w-full flex-col gap-1.5 px-2 sm:flex-row sm:gap-3 sm:px-16">
<ProgressStatLabel
<ProgressStatButton
icon={<ThumbsUp className="mr-1 h-4" />}
label="Knew"
count={knowCount}
isDisabled={knowCount === 0}
onClick={() => onReset('know')}
/>
<ProgressStatLabel
<ProgressStatButton
icon={<Sparkles className="mr-1 h-4" />}
label="Learned"
count={didNotKnowCount}
isDisabled={didNotKnowCount === 0}
onClick={() => onReset('dontKnow')}
/>
<ProgressStatLabel
<ProgressStatButton
icon={<SkipForward className="mr-1 h-4" />}
label="Skipped"
count={skippedCount}
isDisabled={skippedCount === 0}
onClick={() => onReset('skip')}
/>
</div>
<div className="mb-4 mt-2 text-sm sm:mb-0">
<button
onClick={() => onReset()}
onClick={() => onReset('reset')}
className="flex items-center gap-0.5 text-sm text-red-700 hover:text-black sm:text-base"
>
<RefreshCcw className="mr-1 h-4" />

View File

@@ -1,154 +0,0 @@
---
import {
getGuideTableOfContent,
type GuideFileType,
HeadingGroupType,
} from '../../lib/guide';
import MarkdownFile from '../MarkdownFile.astro';
import { TableOfContent } from '../TableOfContent/TableOfContent';
import { markdownToHtml, replaceVariables } from '../../lib/markdown';
import { QuestionGroupType } from '../../lib/question-group';
import { QuestionsList } from './QuestionsList';
interface Props {
questionGroup: QuestionGroupType;
}
const { questionGroup } = Astro.props;
const allHeadings = questionGroup.getHeadings();
const tableOfContent: HeadingGroupType[] = [
...getGuideTableOfContent(allHeadings),
{
depth: 2,
title: 'Test with Flashcards',
children: [],
slug: 'test-with-flashcards',
text: 'Test yourself with Flashcards',
},
{
depth: 2,
title: 'Questions List',
children: [
{
depth: 2,
title: 'Beginner Level',
children: [],
slug: 'beginner-level',
text: 'Beginner Level',
} as HeadingGroupType,
{
depth: 2,
title: 'Intermediate Level',
children: [],
slug: 'intermediate-level',
text: 'Intermediate Level',
} as HeadingGroupType,
{
depth: 2,
title: 'Advanced Level',
children: [],
slug: 'advanced-level',
text: 'Advanced Level',
} as HeadingGroupType,
],
slug: 'questions-list',
text: 'Questions List',
},
];
const showTableOfContent = tableOfContent.length > 0;
const { frontmatter: guideFrontmatter, author } = questionGroup;
---
<article class='lg:grid lg:max-w-full lg:grid-cols-[1fr_minmax(0,700px)_1fr]'>
{
showTableOfContent && (
<div class='bg-gradient-to-r from-gray-50 py-0 lg:col-start-3 lg:col-end-4 lg:row-start-1'>
<TableOfContent toc={tableOfContent} client:load />
</div>
)
}
<div
class:list={[
'col-start-2 col-end-3 row-start-1 mx-auto max-w-[700px] py-5 sm:py-10',
{
'lg:border-r': showTableOfContent,
},
]}
>
<MarkdownFile>
<h1 class='mb-3 text-balance text-4xl font-bold'>
{replaceVariables(guideFrontmatter.title)}
</h1>
{
author && (
<p class='my-0 flex items-center justify-start text-sm text-gray-400'>
<a
href={`/authors/${author?.id}`}
class='inline-flex items-center font-medium underline-offset-2 hover:text-gray-600 hover:underline'
>
<img
alt={author.frontmatter.name}
src={author.frontmatter.imageUrl}
class='mb-0 mr-2 inline h-5 w-5 rounded-full'
/>
{author.frontmatter.name}
</a>
<span class='mx-2 hidden sm:inline'>&middot;</span>
<a
class='hidden underline-offset-2 hover:text-gray-600 sm:inline'
href={`https://github.com/kamranahmedse/developer-roadmap/tree/master/src/data/question-groups/${questionGroup.id}`}
target='_blank'
>
Improve this Guide
</a>
</p>
)
}
<questionGroup.Content />
<h2 id='test-with-flashcards'>Test yourself with Flashcards</h2>
<p>
You can either use these flashcards or jump to the questions list
section below to see them in a list format.
</p>
<div class='mx-0 sm:-mb-32'>
<QuestionsList
groupId={questionGroup.id}
questions={questionGroup.questions}
client:load
/>
</div>
<h2 id='questions-list'>Questions List</h2>
<p>
If you prefer to see the questions in a list format, you can find them
below.
</p>
{
['beginner', 'intermediate', 'advanced'].map((questionLevel) => (
<div class='mb-5'>
<h3 id={`${questionLevel}-level`} class='mb-0 capitalize'>
{questionLevel} Level
</h3>
{questionGroup.questions
.filter((q) => {
return q.topics
.map((t) => t.toLowerCase())
.includes(questionLevel);
})
.map((q) => (
<div class='mb-5'>
<h4>{q.question}</h4>
<div set:html={markdownToHtml(q.answer, false)} />
</div>
))}
</div>
))
}
</MarkdownFile>
</div>
</article>

View File

@@ -24,14 +24,14 @@ type QuestionsListProps = {
};
export function QuestionsList(props: QuestionsListProps) {
const { questions: defaultQuestions, groupId } = props;
const { questions: unshuffledQuestions, groupId } = props;
const toast = useToast();
const [questions, setQuestions] = useState(defaultQuestions);
const [isLoading, setIsLoading] = useState(true);
const [showConfetti, setShowConfetti] = useState(false);
const [currQuestionIndex, setCurrQuestionIndex] = useState(0);
const [questions, setQuestions] = useState<QuestionType[]>();
const [pendingQuestions, setPendingQuestions] = useState<QuestionType[]>([]);
const [userProgress, setUserProgress] = useState<UserQuestionProgress>();
const containerRef = useRef<HTMLDivElement>(null);
@@ -57,7 +57,7 @@ export function QuestionsList(props: QuestionsListProps) {
return response;
}
async function prepareProgress() {
async function loadQuestions() {
const userProgress = await fetchUserProgress();
setUserProgress(userProgress);
@@ -65,7 +65,7 @@ export function QuestionsList(props: QuestionsListProps) {
const didNotKnowQuestions = userProgress?.dontKnow || [];
const skipQuestions = userProgress?.skip || [];
const pendingQuestionIndex = questions.findIndex((question) => {
const pendingQuestions = unshuffledQuestions.filter((question) => {
return (
!knownQuestions.includes(question.id) &&
!didNotKnowQuestions.includes(question.id) &&
@@ -73,21 +73,30 @@ export function QuestionsList(props: QuestionsListProps) {
);
});
setCurrQuestionIndex(pendingQuestionIndex);
// Shuffle and set pending questions
setPendingQuestions(pendingQuestions.sort(() => Math.random() - 0.5));
setQuestions(unshuffledQuestions);
setIsLoading(false);
}
async function resetProgress() {
async function resetProgress(type: QuestionProgressType | 'reset' = 'reset') {
let knownQuestions = userProgress?.know || [];
let didNotKnowQuestions = userProgress?.dontKnow || [];
let skipQuestions = userProgress?.skip || [];
if (!isLoggedIn()) {
setQuestions(defaultQuestions);
knownQuestions = [];
didNotKnowQuestions = [];
skipQuestions = [];
if (type === 'know') {
knownQuestions = [];
} else if (type === 'dontKnow') {
didNotKnowQuestions = [];
} else if (type === 'skip') {
skipQuestions = [];
} else if (type === 'reset') {
knownQuestions = [];
didNotKnowQuestions = [];
skipQuestions = [];
}
} else {
setIsLoading(true);
@@ -96,7 +105,7 @@ export function QuestionsList(props: QuestionsListProps) {
import.meta.env.PUBLIC_API_URL
}/v1-reset-question-progress/${groupId}`,
{
status: 'reset',
status: type,
},
);
@@ -110,13 +119,21 @@ export function QuestionsList(props: QuestionsListProps) {
skipQuestions = response?.skip || [];
}
setCurrQuestionIndex(0);
const pendingQuestions = unshuffledQuestions.filter((question) => {
return (
!knownQuestions.includes(question.id) &&
!didNotKnowQuestions.includes(question.id) &&
!skipQuestions.includes(question.id)
);
});
setUserProgress({
know: knownQuestions,
dontKnow: didNotKnowQuestions,
skip: skipQuestions,
});
setPendingQuestions(pendingQuestions.sort(() => Math.random() - 0.5));
setIsLoading(false);
}
@@ -155,29 +172,30 @@ export function QuestionsList(props: QuestionsListProps) {
newProgress = response;
}
const nextQuestionIndex = currQuestionIndex + 1;
const updatedQuestionList = pendingQuestions.filter(
(q) => q.id !== questionId,
);
setUserProgress(newProgress);
setPendingQuestions(updatedQuestionList);
setIsLoading(false);
if (!nextQuestionIndex || !questions[nextQuestionIndex]) {
if (updatedQuestionList.length === 0) {
setShowConfetti(true);
}
setCurrQuestionIndex(nextQuestionIndex);
}
useEffect(() => {
prepareProgress().then(() => null);
}, [questions]);
loadQuestions().then(() => null);
}, [unshuffledQuestions]);
const knowCount = userProgress?.know.length || 0;
const dontKnowCount = userProgress?.dontKnow.length || 0;
const skipCount = userProgress?.skip.length || 0;
const hasProgress = knowCount > 0 || dontKnowCount > 0 || skipCount > 0;
const currQuestion = questions[currQuestionIndex];
const hasFinished = !isLoading && hasProgress && currQuestionIndex === -1;
const currQuestion = pendingQuestions[0];
const hasFinished = !isLoading && hasProgress && !currQuestion;
return (
<div className="mb-0 gap-3 text-center sm:mb-40">
@@ -185,37 +203,11 @@ export function QuestionsList(props: QuestionsListProps) {
knowCount={knowCount}
didNotKnowCount={dontKnowCount}
skippedCount={skipCount}
totalCount={questions?.length}
totalCount={unshuffledQuestions?.length || questions?.length}
isLoading={isLoading}
showLoginAlert={!isLoggedIn() && hasProgress}
onResetClick={() => {
resetProgress().finally(() => null);
}}
onNextClick={() => {
if (
currQuestionIndex !== -1 &&
currQuestionIndex < questions.length - 1
) {
updateQuestionStatus('skip', currQuestion.id).finally(() => null);
}
}}
onPrevClick={() => {
if (currQuestionIndex > 0) {
const prevQuestion = questions[currQuestionIndex - 1];
// remove last question from the progress of the user
const tempUserProgress = {
know:
userProgress?.know.filter((id) => id !== prevQuestion.id) || [],
dontKnow:
userProgress?.dontKnow.filter((id) => id !== prevQuestion.id) ||
[],
skip:
userProgress?.skip.filter((id) => id !== prevQuestion.id) || [],
};
setUserProgress(tempUserProgress);
setCurrQuestionIndex(currQuestionIndex - 1);
}
resetProgress('reset').finally(() => null);
}}
/>
@@ -235,12 +227,12 @@ export function QuestionsList(props: QuestionsListProps) {
>
{hasFinished && (
<QuestionFinished
totalCount={questions?.length || 0}
totalCount={unshuffledQuestions?.length || questions?.length || 0}
knowCount={knowCount}
didNotKnowCount={dontKnowCount}
skippedCount={skipCount}
onReset={() => {
resetProgress().finally(() => null);
onReset={(type: QuestionProgressType | 'reset') => {
resetProgress(type).finally(() => null);
}}
/>
)}

Some files were not shown because too many files have changed in this diff Show More