Compare commits

..

1 Commits

Author SHA1 Message Date
Arik Chakma
6fa93c9648 wip 2024-05-03 22:34:55 +06:00
4174 changed files with 152377 additions and 151257 deletions

View File

@@ -3,6 +3,6 @@
"enabled": false
},
"_variables": {
"lastUpdateCheck": 1720119515249
"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,99 +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.
(See the following guide on how to write good [commit messages](https://www.freecodecamp.org/news/how-to-write-better-git-commit-messages/)).
</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");
}

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.2.5",
"@astrojs/react": "^3.4.0",
"@astrojs/sitemap": "^3.1.5",
"@astrojs/react": "^3.3.1",
"@astrojs/sitemap": "^3.1.4",
"@astrojs/tailwind": "^5.1.0",
"@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.2",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"astro": "^4.9.1",
"astro": "^4.7.0",
"clsx": "^2.1.1",
"dayjs": "^1.11.11",
"dom-to-image": "^2.6.0",
@@ -46,27 +40,26 @@
"gray-matter": "^4.0.3",
"htm": "^3.1.1",
"image-size": "^1.1.1",
"jose": "^5.3.0",
"jose": "^5.2.4",
"js-cookie": "^3.0.5",
"lucide-react": "^0.378.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.44.0",
"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.26.4",
"reactflow": "^11.11.3",
"reactflow": "^11.11.2",
"rehype-external-links": "^3.0.0",
"remark-parse": "^11.0.0",
"roadmap-renderer": "^1.0.6",
"satori": "^0.10.13",
"satori-html": "^0.3.2",
"sharp": "^0.33.4",
"sharp": "^0.33.3",
"slugify": "^1.6.6",
"tailwind-merge": "^2.3.0",
"tailwindcss": "^3.4.3",
@@ -74,20 +67,20 @@
"zustand": "^4.5.2"
},
"devDependencies": {
"@playwright/test": "^1.44.0",
"@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.47.1",
"openai": "^4.38.5",
"prettier": "^3.2.5",
"prettier-plugin-astro": "^0.13.0",
"prettier-plugin-tailwindcss": "^0.5.14",
"tsx": "^4.10.5"
"tsx": "^4.7.3"
}
}

9763
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

@@ -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

@@ -1,11 +1,9 @@
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';
import {Book, BookOpen, ChevronsDown, ChevronsDownUp, ChevronsUp, ChevronsUpDown} from 'lucide-react';
export const allowedActivityActionType = [
'in_progress',
@@ -23,39 +21,31 @@ export type UserStreamActivity = {
resourceSlug?: string;
isCustomResource?: boolean;
actionType: AllowedActivityActionType;
topicTitles?: string[];
topicIds?: string[];
createdAt: Date;
updatedAt: Date;
};
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] =
useState<UserStreamActivity | null>(null);
const sortedActivities = activities
.filter(
(activity) => activity?.topicTitles && activity.topicTitles.length > 0,
)
.filter((activity) => activity?.topicIds && activity.topicIds.length > 0)
.sort((a, b) => {
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
})
.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>
@@ -67,8 +57,8 @@ export function ActivityStream(props: ActivityStreamProps) {
resourceId={selectedActivity.resourceId}
resourceType={selectedActivity.resourceType}
isCustomResource={selectedActivity.isCustomResource}
topicTitles={selectedActivity.topicTitles || []}
topicCount={selectedActivity.topicTitles?.length || 0}
topicIds={selectedActivity.topicIds || []}
topicCount={selectedActivity.topicIds?.length || 0}
actionType={selectedActivity.actionType}
/>
)}
@@ -83,9 +73,8 @@ export function ActivityStream(props: ActivityStreamProps) {
resourceTitle,
actionType,
updatedAt,
topicTitles,
topicIds,
isCustomResource,
resourceSlug,
} = activity;
const resourceUrl =
@@ -94,30 +83,20 @@ 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;
const topicCount = topicIds?.length || 0;
const timeAgo = (
<span className="ml-1 text-xs text-gray-400">
@@ -129,35 +108,32 @@ 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{' '}
<button
className="font-medium underline underline-offset-2 hover:text-black"
onClick={() => setSelectedActivity(activity)}
>
{topicCount} topic{topicCount > 1 ? 's' : ''}
</button>{' '}
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{' '}
<button
className="font-medium underline underline-offset-2 hover:text-black"
onClick={() => setSelectedActivity(activity)}
>
{topicCount} topic{topicCount > 1 ? 's' : ''}
</button>{' '}
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>
@@ -170,20 +146,16 @@ export function ActivityStream(props: ActivityStreamProps) {
{activities.length > 10 && (
<button
className="mt-3 flex items-center gap-2 rounded-md border border-black py-1 pl-1.5 pr-2 text-xs uppercase tracking-wide text-black transition-colors hover:border-black hover:bg-black hover:text-white"
className="mt-3 gap-2 flex items-center rounded-md border border-black pl-1.5 pr-2 py-1 text-xs uppercase tracking-wide text-black transition-colors hover:border-black hover:bg-black hover:text-white"
onClick={() => setShowAll(!showAll)}
>
{showAll ? (
<>
<ChevronsUp size={14} />
Show less
</>
) : (
<>
<ChevronsDown size={14} />
Show more
</>
)}
{showAll ? <>
<ChevronsUp size={14} />
Show less
</> : <>
<ChevronsDown size={14} />
Show more
</>}
</button>
)}
</div>

View File

@@ -1,43 +0,0 @@
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;
return (
<div
className={cn(
'flex flex-wrap gap-1 text-sm font-normal text-gray-600',
className,
)}
>
{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

@@ -11,7 +11,7 @@ type ActivityTopicDetailsProps = {
resourceId: string;
resourceType: ResourceType | 'question';
isCustomResource?: boolean;
topicTitles: string[];
topicIds: string[];
topicCount: number;
actionType: AllowedActivityActionType;
onClose: () => void;
@@ -22,12 +22,56 @@ export function ActivityTopicsModal(props: ActivityTopicDetailsProps) {
resourceId,
resourceType,
isCustomResource,
topicTitles = [],
topicIds = [],
topicCount,
actionType,
onClose,
} = props;
const [isLoading, setIsLoading] = useState(true);
const [topicTitles, setTopicTitles] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
const loadTopicTitles = async () => {
setIsLoading(true);
setError(null);
const { response, error } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-get-topic-titles`,
{
resourceId,
resourceType,
isCustomResource,
topicIds,
},
);
if (error || !response) {
setError(error?.message || 'Failed to load topic titles');
setIsLoading(false);
return;
}
setTopicTitles(response);
setIsLoading(false);
};
useEffect(() => {
loadTopicTitles().finally(() => {
setIsLoading(false);
});
}, []);
if (isLoading || error) {
return (
<ModalLoader
error={error!}
text={'Loading topics..'}
isLoading={isLoading}
/>
);
}
let pageUrl = '';
if (resourceType === 'roadmap') {
pageUrl = isCustomResource ? `/r/${resourceId}` : `/${resourceId}`;
@@ -41,6 +85,8 @@ export function ActivityTopicsModal(props: ActivityTopicDetailsProps) {
<Modal
onClose={() => {
onClose();
setError(null);
setIsLoading(false);
}}
>
<div className={`popup-body relative rounded-lg bg-white p-4 shadow`}>
@@ -62,7 +108,9 @@ export function ActivityTopicsModal(props: ActivityTopicDetailsProps) {
</a>
</span>
<ul className="flex max-h-[50vh] flex-col gap-1 overflow-y-auto max-md:max-h-full">
{topicTitles.map((topicTitle) => {
{topicIds.map((topicId) => {
const topicTitle = topicTitles[topicId] || 'Unknown Topic';
const ActivityIcon =
actionType === 'done'
? Check
@@ -71,7 +119,7 @@ export function ActivityTopicsModal(props: ActivityTopicDetailsProps) {
: Check;
return (
<li key={topicTitle} className="flex items-start gap-2">
<li key={topicId} className="flex items-start gap-2">
<ActivityIcon
strokeWidth={3}
className="relative top-[4px] text-green-500"

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

@@ -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}
@@ -248,10 +245,9 @@ export function CommandMenu() {
<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

@@ -9,7 +9,7 @@ import { pageProgressMessage } from '../../stores/page';
import type { TeamResourceConfig } from './RoadmapSelector';
import { Step3 } from './Step3';
import { Step4 } from './Step4';
import { useToast } from '../../hooks/use-toast';
import {useToast} from "../../hooks/use-toast";
export interface TeamDocument {
_id?: string;
@@ -22,7 +22,6 @@ export interface TeamDocument {
linkedIn?: string;
};
type: ValidTeamType;
personalProgressOnly?: boolean;
canMemberSendInvite: boolean;
teamSize?: ValidTeamSize;
createdAt: Date;
@@ -41,10 +40,10 @@ export function CreateTeamForm() {
async function loadTeam(
teamIdToFetch: string,
requiredStepIndex: number | string,
requiredStepIndex: number | string
) {
const { response, error } = await httpGet<TeamDocument>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-team/${teamIdToFetch}`,
`${import.meta.env.PUBLIC_API_URL}/v1-get-team/${teamIdToFetch}`
);
if (error || !response) {
@@ -71,7 +70,7 @@ export function CreateTeamForm() {
async function loadTeamResourceConfig(teamId: string) {
const { error, response } = await httpGet<TeamResourceConfig>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-resource-config/${teamId}`,
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-resource-config/${teamId}`
);
if (error || !Array.isArray(response)) {
console.error(error);
@@ -97,7 +96,7 @@ export function CreateTeamForm() {
}, [teamId, queryStepIndex]);
const [selectedTeamType, setSelectedTeamType] = useState<ValidTeamType>(
team?.type || 'company',
team?.type || 'company'
);
const [completedSteps, setCompletedSteps] = useState([0]);
@@ -192,17 +191,13 @@ export function CreateTeamForm() {
return (
<div className={'mx-auto max-w-[700px] py-1 md:py-6'}>
<div
className={
'mb-3 flex flex-col items-start border-b pb-3 md:mb-8 md:items-center md:border-b-0 md:pb-0'
}
>
<h1 className={'text-xl font-bold md:text-4xl'}>Create Team</h1>
<p className={'mt-1 text-sm text-gray-500 md:mt-2 md:text-base'}>
<div className={'mb-3 md:mb-8 pb-3 md:pb-0 border-b md:border-b-0 flex flex-col items-start md:items-center'}>
<h1 className={'text-xl md:text-4xl font-bold'}>Create Team</h1>
<p className={'mt-1 md:mt-2 text-sm md:text-base text-gray-500'}>
Complete the steps below to create your team
</p>
</div>
<div className="mb-8 mt-8 hidden w-full sm:flex">
<div className="mb-8 mt-8 hidden sm:flex w-full">
<Stepper
activeIndex={stepIndex}
completeSteps={completedSteps}

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

@@ -46,7 +46,7 @@ export function Step1(props: Step1Props) {
const [linkedInUrl, setLinkedInUrl] = useState(team?.links?.linkedIn || '');
const [gitHubUrl, setGitHubUrl] = useState(team?.links?.github || '');
const [teamSize, setTeamSize] = useState<ValidTeamSize>(
team?.teamSize || ('' as any),
team?.teamSize || ('' as any)
);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
@@ -74,7 +74,7 @@ export function Step1(props: Step1Props) {
}),
roadmapIds: [],
bestPracticeIds: [],
},
}
));
if (error || !response?._id) {
@@ -96,7 +96,7 @@ export function Step1(props: Step1Props) {
teamSize,
linkedInUrl: linkedInUrl || undefined,
}),
},
}
));
if (error || (response as any)?.status !== 'ok') {
@@ -168,10 +168,7 @@ export function Step1(props: Step1Props) {
{selectedTeamType === 'company' && (
<div className="mt-4 flex w-full flex-col">
<label
htmlFor="website"
className="text-sm leading-none text-slate-500"
>
<label htmlFor="website" className="text-sm leading-none text-slate-500">
Company LinkedIn URL
</label>
<input
@@ -190,10 +187,7 @@ export function Step1(props: Step1Props) {
)}
<div className="mt-4 flex w-full flex-col">
<label
htmlFor="website"
className="text-sm leading-none text-slate-500"
>
<label htmlFor="website" className="text-sm leading-none text-slate-500">
GitHub Organization URL
</label>
<input
@@ -227,11 +221,11 @@ export function Step1(props: Step1Props) {
setTeamSize((e.target as HTMLSelectElement).value as any)
}
>
<option value="">Select team size</option>
<option value="">
Select team size
</option>
{validTeamSizes.map((size) => (
<option key={size} value={size}>
{size} people
</option>
<option key={size} value={size}>{size} people</option>
))}
</select>
</div>

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

@@ -15,10 +15,6 @@ export const allowedLinkTypes = [
'course',
'website',
'podcast',
'roadmap.sh',
'official',
'roadmap',
'feed'
] as const;
export type AllowedLinkTypes = (typeof allowedLinkTypes)[number];

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,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,59 +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 > 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

@@ -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

@@ -1,3 +1,4 @@
import { Modal } from '../Modal.tsx';
import { useEffect, useState } from 'react';
import { deleteOpenAIKey, getOpenAIKey, saveOpenAIKey } from '../../lib/jwt.ts';
import { cn } from '../../lib/classname.ts';
@@ -16,7 +17,7 @@ export function OpenAISettings(props: OpenAISettingsProps) {
const [defaultOpenAIKey, setDefaultOpenAIKey] = useState('');
const [error, setError] = useState('');
const [hasError, setHasError] = useState(false);
const [openaiApiKey, setOpenaiApiKey] = useState('');
const [isLoading, setIsLoading] = useState(false);
@@ -56,7 +57,7 @@ export function OpenAISettings(props: OpenAISettingsProps) {
className="mt-4"
onSubmit={async (e) => {
e.preventDefault();
setError('');
setHasError(false);
const normalizedKey = openaiApiKey.trim();
if (!normalizedKey) {
@@ -67,7 +68,7 @@ export function OpenAISettings(props: OpenAISettingsProps) {
}
if (!normalizedKey.startsWith('sk-')) {
setError("Invalid OpenAI API key. It should start with 'sk-'");
setHasError(true);
return;
}
@@ -80,7 +81,7 @@ export function OpenAISettings(props: OpenAISettingsProps) {
);
if (error) {
setError(error.message);
setHasError(true);
setIsLoading(false);
return;
}
@@ -99,13 +100,13 @@ export function OpenAISettings(props: OpenAISettingsProps) {
className={cn(
'block w-full rounded-md border border-gray-300 px-3 py-2 text-gray-800 transition-colors focus:border-black focus:outline-none',
{
'border-red-500 bg-red-100 focus:border-red-500': error,
'border-red-500 bg-red-100 focus:border-red-500': hasError,
},
)}
placeholder="Enter your OpenAI API key"
value={openaiApiKey}
onChange={(e) => {
setError('');
setHasError(false);
setOpenaiApiKey((e.target as HTMLInputElement).value);
}}
/>
@@ -126,9 +127,9 @@ export function OpenAISettings(props: OpenAISettingsProps) {
We do not store your API key on our servers.
</p>
{error && (
{hasError && (
<p className="mt-2 text-sm text-red-500">
{error}
Please enter a valid OpenAI API key
</p>
)}
<button

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

@@ -10,12 +10,12 @@ import { AIAnnouncement } from "../AIAnnouncement";
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'>
<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

@@ -17,10 +17,10 @@ import { AccountDropdown } from './AccountDropdown';
</a>
<a
href='/teams'
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'>
@@ -39,11 +39,13 @@ import { AccountDropdown } from './AccountDropdown';
<a href='/get-started' class='text-gray-400 hover:text-white'>
Start Here
</a>
<a
href='/teams'
<a href='/teams' class='text-gray-400 hover:text-white'> Teams</a>
<a
href='/ai'
class='group relative !mr-2 text-blue-300 hover:text-white'
>
Teams
AI Roadmaps
<span class='absolute -right-[11px] top-0'>
<span class='relative flex h-2 w-2'>
<span
@@ -54,8 +56,6 @@ import { AccountDropdown } from './AccountDropdown';
</span>
</span>
</a>
<a
href='/ai' class='text-gray-400 hover:text-white'> AI Roadmaps</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'

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);
}}
/>
)}

View File

@@ -1,11 +1,4 @@
import {
CheckCircle,
ChevronLeft,
ChevronRight,
RotateCcw,
SkipForward,
Sparkles,
} from 'lucide-react';
import { CheckCircle, RotateCcw, SkipForward, Sparkles } from 'lucide-react';
import { showLoginPopup } from '../../lib/popup';
type QuestionsProgressProps = {
@@ -16,8 +9,6 @@ type QuestionsProgressProps = {
totalCount?: number;
skippedCount?: number;
onResetClick?: () => void;
onPrevClick?: () => void;
onNextClick?: () => void;
};
export function QuestionsProgress(props: QuestionsProgressProps) {
@@ -29,8 +20,6 @@ export function QuestionsProgress(props: QuestionsProgressProps) {
totalCount = 0,
skippedCount = 0,
onResetClick = () => null,
onPrevClick = () => null,
onNextClick = () => null,
} = props;
const totalSolved = knowCount + didNotKnowCount + skippedCount;
@@ -47,22 +36,8 @@ export function QuestionsProgress(props: QuestionsProgressProps) {
}}
/>
</div>
<span className="ml-3 flex items-center text-sm">
<button
onClick={onPrevClick}
className="text-zinc-400 hover:text-black"
>
<ChevronLeft className="h-4" strokeWidth={3} />
</button>
<span className="block min-w-[41px] text-center">
<span className="tabular-nums">{totalSolved}</span> / {totalCount}
</span>
<button
onClick={onNextClick}
className="text-zinc-400 hover:text-black"
>
<ChevronRight className="h-4" strokeWidth={3} />
</button>
<span className="ml-3 text-sm">
{totalSolved} / {totalCount}
</span>
</div>
@@ -71,7 +46,9 @@ export function QuestionsProgress(props: QuestionsProgressProps) {
<CheckCircle className="mr-1 h-4" />
<span>Knew</span>
<span className="ml-2 rounded-md bg-gray-200/80 px-1.5 font-medium text-black">
<span className="tabular-nums">{knowCount}</span> Items
<span className="tabular-nums">{knowCount}</span>{' '}
<span className="hidden lg:inline">Questions</span>
<span className="inline sm:hidden">Questions</span>
</span>
</span>
@@ -79,7 +56,9 @@ export function QuestionsProgress(props: QuestionsProgressProps) {
<Sparkles className="mr-1 h-4" />
<span>Learnt</span>
<span className="ml-2 rounded-md bg-gray-200/80 px-1.5 font-medium text-black">
<span className="tabular-nums">{didNotKnowCount}</span> Items
<span className="tabular-nums">{didNotKnowCount}</span>{' '}
<span className="hidden lg:inline">Questions</span>
<span className="inline sm:hidden">Questions</span>
</span>
</span>
@@ -87,7 +66,9 @@ export function QuestionsProgress(props: QuestionsProgressProps) {
<SkipForward className="mr-1 h-4" />
<span>Skipped</span>
<span className="ml-2 rounded-md bg-gray-200/80 px-1.5 font-medium text-black">
<span className="tabular-nums">{skippedCount}</span> Items
<span className="tabular-nums">{skippedCount}</span>{' '}
<span className="hidden lg:inline">Questions</span>
<span className="inline sm:hidden">Questions</span>
</span>
</span>

View File

@@ -1,6 +1,6 @@
---
import { getQuestionGroupsByIds } from '../lib/question-group';
import { getRoadmapsByIds, type RoadmapFrontmatter } from '../lib/roadmap';
import { getRoadmapsByIds, RoadmapFrontmatter } from '../lib/roadmap';
import { Map, Clipboard } from 'lucide-react';
export interface Props {
@@ -24,6 +24,9 @@ const relatedQuestionDetails = await getQuestionGroupsByIds(relatedQuestions);
<span class='text-md flex items-center rounded-md border bg-white px-3 py-1 font-medium'>
<Clipboard className='mr-1.5 text-black' size='17px' />
Test your Knowledge
<span class='ml-2 rounded-md border border-yellow-300 bg-yellow-100 px-1 py-0.5 text-xs uppercase'>
New
</span>
</span>
<a
href='/questions'
@@ -56,19 +59,14 @@ const relatedQuestionDetails = await getQuestionGroupsByIds(relatedQuestions);
{
relatedRoadmaps.length && (
<div
class:list={[
'border-t bg-gray-100',
{
'mt-8': !relatedQuestionDetails.length,
},
]}
>
<div class:list={['border-t bg-gray-100', {
'mt-8': !relatedQuestionDetails.length
}]}>
<div class='container'>
<div class='relative -top-5 flex justify-between'>
<span class='text-md flex items-center rounded-md border bg-white px-3 py-1 font-medium'>
<Map className='text-black mr-1.5' size='17px' />
Related <span class='hidden sm:inline'>Roadmaps</span>
Related Roadmaps
</span>
<a
href='/roadmaps'

View File

@@ -4,11 +4,10 @@ import { CopyIcon } from 'lucide-react';
type EditorProps = {
title: string;
text: string;
onCopy?: () => void;
};
export function Editor(props: EditorProps) {
const { text, title, onCopy } = props;
const { text, title } = props;
const { isCopied, copyText } = useCopyText();
@@ -18,13 +17,7 @@ export function Editor(props: EditorProps) {
<span className="text-xs uppercase leading-none text-gray-400">
{title}
</span>
<button
className="flex items-center"
onClick={() => {
copyText(text);
onCopy?.();
}}
>
<button className="flex items-center" onClick={() => copyText(text)}>
{isCopied && (
<span className="mr-1 text-xs leading-none text-gray-700">
Copied!&nbsp;
@@ -40,7 +33,6 @@ export function Editor(props: EditorProps) {
onClick={(e: any) => {
e.target.select();
copyText(e.target.value);
onCopy?.();
}}
value={text}
/>

View File

@@ -9,8 +9,6 @@ import { SelectionButton } from './SelectionButton';
import { StepCounter } from './StepCounter';
import { Editor } from './Editor';
import { CopyIcon } from 'lucide-react';
import { httpPatch } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
type StepLabelProps = {
label: string;
@@ -26,34 +24,17 @@ function StepLabel(props: StepLabelProps) {
}
export function RoadCardPage() {
const user = useAuth();
const toast = useToast();
const { isCopied, copyText } = useCopyText();
const [roadmaps, setRoadmaps] = useState<string[]>([]);
const [version, setVersion] = useState<'tall' | 'wide'>('tall');
const [variant, setVariant] = useState<'dark' | 'light'>('dark');
const markRoadCardDone = async () => {
const { error } = await httpPatch(
`${import.meta.env.PUBLIC_API_URL}/v1-update-onboarding-config`,
{
id: 'roadCard',
status: 'done',
},
);
if (error) {
toast.error(error?.message || 'Something went wrong');
}
};
const user = useAuth();
if (!user) {
return null;
}
const badgeUrl = new URL(
`${import.meta.env.PUBLIC_APP_URL}/card/${version}/${user?.id}`,
`${import.meta.env.PUBLIC_API_URL}/v1-badge/${version}/${user?.id}`,
);
badgeUrl.searchParams.set('variant', variant);
@@ -150,26 +131,22 @@ export function RoadCardPage() {
<div className="mt-3 grid grid-cols-2 gap-2">
<button
className="flex items-center justify-center rounded border border-gray-300 p-1.5 px-2 text-sm font-medium"
onClick={() => {
onClick={() =>
downloadImage({
url: badgeUrl.toString(),
name: 'road-card',
scale: 4,
});
markRoadCardDone();
}}
})
}
>
Download
</button>
<button
disabled={isCopied}
className="flex cursor-pointer items-center justify-center rounded border border-gray-300 p-1.5 px-2 text-sm font-medium disabled:bg-blue-50"
onClick={() => {
copyText(badgeUrl.toString());
markRoadCardDone();
}}
onClick={() => copyText(badgeUrl.toString())}
>
<CopyIcon size={16} className="mr-1 inline-block h-4 w-4" />
<CopyIcon size={16} className="inline-block h-4 w-4 mr-1" />
{isCopied ? 'Copied!' : 'Copy Link'}
</button>
@@ -179,13 +156,11 @@ export function RoadCardPage() {
<Editor
title={'HTML'}
text={`<a href="https://roadmap.sh"><img src="${badgeUrl}" alt="roadmap.sh"/></a>`.trim()}
onCopy={() => markRoadCardDone()}
/>
<Editor
title={'Markdown'}
text={`[![roadmap.sh](${badgeUrl})](https://roadmap.sh)`.trim()}
onCopy={() => markRoadCardDone()}
/>
</div>

View File

@@ -157,7 +157,7 @@ const hasTnsBanner = !!tnsBannerLink;
{
isRoadmapReady && (
<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

@@ -23,7 +23,7 @@ const hasTnsBanner = !!tnsBannerLink;
<div
class:list={[
'mb-0 mt-4 rounded-md border-0 bg-white sm:mt-7 sm:border',
'mt-4 sm:mt-7 border-0 sm:border rounded-md mb-0 bg-white',
...(hasTnsBanner
? [
{
@@ -42,7 +42,7 @@ const hasTnsBanner = !!tnsBannerLink;
<ResourceProgressStats
resourceId={roadmapId}
resourceType='roadmap'
hasSecondaryBanner={Boolean(hasTitleQuestion)}
hasSecondaryBanner={hasTitleQuestion}
/>
{

View File

@@ -21,7 +21,7 @@ export function RoadmapTitleQuestion(props: RoadmapTitleQuestionProps) {
return (
<div className="relative hidden border-t text-sm font-medium sm:block">
{isAnswerVisible && (
<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>
<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>
)}
<h2
className="z-50 flex cursor-pointer items-center px-2 py-2.5 text-base font-medium"
@@ -41,7 +41,7 @@ export function RoadmapTitleQuestion(props: RoadmapTitleQuestionProps) {
</h2>
<div
className={`absolute left-0 right-0 top-0 z-[100] mt-0 rounded-md border bg-white ${
className={`absolute left-0 right-0 top-0 z-50 mt-0 rounded-md border bg-white ${
isAnswerVisible ? 'block' : 'hidden'
}`}
ref={ref}

View File

@@ -77,12 +77,6 @@ const groups: GroupType[] = [
type: 'role',
otherGroups: ['Web Development', 'Absolute Beginners'],
},
{
title: 'API Design',
link: '/api-design',
type: 'role',
otherGroups: ['Web Development'],
},
{
title: 'QA',
link: '/qa',
@@ -222,12 +216,6 @@ const groups: GroupType[] = [
type: 'skill',
otherGroups: ['Web Development'],
},
{
title: 'Terraform',
link: '/terraform',
type: 'skill',
otherGroups: ['Web Development'],
},
],
},
{
@@ -238,20 +226,15 @@ const groups: GroupType[] = [
link: '/android',
type: 'role',
},
{
title: 'iOS',
link: '/ios',
type: 'role',
},
{
title: 'React Native',
link: '/react-native',
type: 'skill',
type: 'role',
},
{
title: 'Flutter',
link: '/flutter',
type: 'skill',
type: 'role',
},
],
},
@@ -316,16 +299,6 @@ const groups: GroupType[] = [
link: '/technical-writer',
type: 'role',
},
{
title: 'Product Manager',
link: '/product-manager',
type: 'role',
},
{
title: 'DevRel Engineer',
link: '/devrel',
type: 'role',
},
],
},
{

View File

@@ -53,7 +53,6 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
const toast = useToast();
const [isLoading, setIsLoading] = useState(false);
const [isTransferringToTeam, setIsTransferringToTeam] = useState(false);
const [isSettingsUpdated, setIsSettingsUpdated] = useState(false);
const [friends, setFriends] = useState<ListFriendsResponse>([]);
const [teams, setTeams] = useState<UserTeamItem[]>([]);
@@ -72,12 +71,13 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
);
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
const canTransferRoadmap = visibility === 'team' && !teamId;
let isUpdateDisabled = false;
// Disable update button if there are no friends to share with
if (visibility === 'friends' && sharedFriendIds.length === 0) {
isUpdateDisabled = true;
// Disable update button if there are no team to transfer
} else if (isTransferringToTeam && !selectedTeamId) {
} else if (canTransferRoadmap && !selectedTeamId) {
isUpdateDisabled = true;
// Disable update button if there are no members to share with
} else if (
@@ -198,8 +198,6 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
</div>
<ShareOptionTabs
isTransferringToTeam={isTransferringToTeam}
setIsTransferringToTeam={setIsTransferringToTeam}
visibility={visibility}
setVisibility={setVisibility}
teamId={teamId}
@@ -228,52 +226,48 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
/>
<div className="mt-4 flex grow flex-col">
{!isTransferringToTeam && (
<>
{visibility === 'public' && (
<div className="flex h-full flex-grow flex-col items-center justify-center rounded-md border bg-gray-50 text-center">
<Globe2 className="mb-3 h-10 w-10 text-gray-300" />
<p className="font-medium text-gray-500">
Anyone with the link can access.
</p>
</div>
)}
{visibility === 'me' && (
<div className="flex h-full flex-grow flex-col items-center justify-center rounded-md border bg-gray-50 text-center">
<Lock className="mb-3 h-10 w-10 text-gray-300" />
<p className="font-medium text-gray-500">
Only you will be able to access.
</p>
</div>
)}
{/* For Personal Roadmap */}
{visibility === 'friends' && (
<ShareFriendList
friends={friends}
setFriends={setFriends}
sharedFriendIds={sharedFriendIds}
setSharedFriendIds={setSharedFriendIds}
/>
)}
{/* For Team Roadmap */}
{visibility === 'team' && teamId && (
<ShareTeamMemberList
teamId={teamId}
sharedTeamMemberIds={sharedTeamMemberIds}
setSharedTeamMemberIds={setSharedTeamMemberIds}
membersCache={membersCache}
isTeamMembersLoading={isTeamMembersLoading}
setIsTeamMembersLoading={setIsTeamMembersLoading}
/>
)}
</>
{visibility === 'public' && (
<div className="flex h-full flex-grow flex-col items-center justify-center rounded-md border bg-gray-50 text-center">
<Globe2 className="mb-3 h-10 w-10 text-gray-300" />
<p className="font-medium text-gray-500">
Anyone with the link can access.
</p>
</div>
)}
{visibility === 'me' && (
<div className="flex h-full flex-grow flex-col items-center justify-center rounded-md border bg-gray-50 text-center">
<Lock className="mb-3 h-10 w-10 text-gray-300" />
<p className="font-medium text-gray-500">
Only you will be able to access.
</p>
</div>
)}
{isTransferringToTeam && (
{/* For Personal Roadmap */}
{visibility === 'friends' && (
<ShareFriendList
friends={friends}
setFriends={setFriends}
sharedFriendIds={sharedFriendIds}
setSharedFriendIds={setSharedFriendIds}
/>
)}
{/* For Team Roadmap */}
{visibility === 'team' && teamId && (
<ShareTeamMemberList
teamId={teamId}
sharedTeamMemberIds={sharedTeamMemberIds}
setSharedTeamMemberIds={setSharedTeamMemberIds}
membersCache={membersCache}
isTeamMembersLoading={isTeamMembersLoading}
setIsTeamMembersLoading={setIsTeamMembersLoading}
/>
)}
{canTransferRoadmap && (
<>
<TransferToTeamList
currentTeamId={teamId}
teams={teams}
setTeams={setTeams}
selectedTeamId={selectedTeamId}
@@ -325,7 +319,7 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
Close
</button>
{isTransferringToTeam && (
{canTransferRoadmap && (
<UpdateAction
disabled={
isUpdateDisabled || isLoading || sharedTeamMemberIds.length === 0
@@ -341,7 +335,7 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
</UpdateAction>
)}
{!isTransferringToTeam && (
{!canTransferRoadmap && (
<UpdateAction
disabled={isUpdateDisabled || isLoading}
onClick={() => {

View File

@@ -8,8 +8,6 @@ import {
} from 'lucide-react';
import type { AllowedRoadmapVisibility } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
import { cn } from '../../lib/classname';
import { $teamList } from '../../stores/team.ts';
import { useStore } from '@nanostores/react';
export const allowedVisibilityLabels: {
id: AllowedRoadmapVisibility;
@@ -46,29 +44,15 @@ export const allowedVisibilityLabels: {
type ShareOptionTabsProps = {
visibility: AllowedRoadmapVisibility;
setVisibility: (visibility: AllowedRoadmapVisibility) => void;
isTransferringToTeam: boolean;
setIsTransferringToTeam: (isTransferringToTeam: boolean) => void;
teamId?: string;
onChange: (visibility: AllowedRoadmapVisibility) => void;
};
export function ShareOptionTabs(props: ShareOptionTabsProps) {
const {
isTransferringToTeam,
setIsTransferringToTeam,
visibility,
setVisibility,
teamId,
onChange,
} = props;
const { visibility, setVisibility, teamId, onChange } = props;
const teamList = useStore($teamList);
const handleTabClick = (visibility: AllowedRoadmapVisibility) => {
setIsTransferringToTeam(false);
const handleClick = (visibility: AllowedRoadmapVisibility) => {
setVisibility(visibility);
onChange(visibility);
};
@@ -79,9 +63,11 @@ export function ShareOptionTabs(props: ShareOptionTabsProps) {
{allowedVisibilityLabels.map((v) => {
if (v.id === 'friends' && teamId) {
return null;
} else if (v.id === 'team' && !teamId) {
return null;
}
const isActive = !isTransferringToTeam && v.id === visibility;
const isActive = v.id === visibility;
return (
<li key={v.id}>
<OptionTab
@@ -89,21 +75,21 @@ export function ShareOptionTabs(props: ShareOptionTabsProps) {
isActive={isActive}
icon={v.icon}
onClick={() => {
handleTabClick(v.id);
handleClick(v.id);
}}
/>
</li>
);
})}
</ul>
{(!teamId || teamList.length > 1) && (
{!teamId && (
<div className="grow">
<OptionTab
label="Transfer to team"
icon={ArrowLeftRight}
isActive={isTransferringToTeam}
isActive={visibility === 'team'}
onClick={() => {
setIsTransferringToTeam(true);
handleClick('team');
}}
className='border-red-300 text-red-600 hover:border-red-200 hover:bg-red-50 data-[active="true"]:border-red-600 data-[active="true"]:bg-red-600 data-[active="true"]:text-white'
/>
@@ -129,7 +115,7 @@ function OptionTab(props: OptionTabProps) {
className={cn(
'flex items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm text-black hover:border-gray-300 hover:bg-gray-100',
'data-[active="true"]:border-gray-500 data-[active="true"]:bg-gray-200 data-[active="true"]:text-black',
className,
className
)}
data-active={isActive}
disabled={isActive}

View File

@@ -82,24 +82,25 @@ export function ShareSuccess(props: ShareSuccessProps) {
</p>
)}
<div className="mt-2 border-t pt-2">
<p className="text-sm text-gray-400">
You can also embed this roadmap on your website.
</p>
<div className="mt-2">
<input
onClick={(e) => {
e.currentTarget.select();
copyText(embedHtml);
}}
readOnly={true}
className="w-full resize-none rounded-md border bg-gray-50 p-2 text-sm"
value={embedHtml}
/>
</div>
</div>
{visibility === 'public' && (
<>
<div className="mt-2 border-t pt-2">
<p className="text-sm text-gray-400">
You can also embed this roadmap on your website.
</p>
<div className="mt-2">
<input
onClick={(e) => {
e.currentTarget.select();
copyText(embedHtml);
}}
readOnly={true}
className="w-full resize-none rounded-md border bg-gray-50 p-2 text-sm"
value={embedHtml}
/>
</div>
</div>
<div className="-mx-4 mt-4 flex items-center gap-1.5">
<span className="h-px grow bg-gray-300" />
<span className="px-2 text-xs uppercase text-gray-400">Or</span>

View File

@@ -9,7 +9,6 @@ type TransferToTeamListProps = {
teams: UserTeamItem[];
setTeams: (teams: UserTeamItem[]) => void;
currentTeamId?: string;
selectedTeamId: string | null;
setSelectedTeamId: (teamId: string | null) => void;
@@ -25,7 +24,6 @@ export function TransferToTeamList(props: TransferToTeamListProps) {
selectedTeamId,
setSelectedTeamId,
isTeamMembersLoading,
currentTeamId,
setIsTeamMembersLoading,
onTeamChange,
} = props;
@@ -40,7 +38,7 @@ export function TransferToTeamList(props: TransferToTeamListProps) {
}
const { response, error } = await httpGet<UserTeamItem[]>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-teams`,
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-teams`
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
@@ -48,7 +46,7 @@ export function TransferToTeamList(props: TransferToTeamListProps) {
}
setTeams(
response.filter((team) => ['admin', 'manager'].includes(team.role)),
response.filter((team) => ['admin', 'manager'].includes(team.role))
);
}
@@ -82,16 +80,13 @@ export function TransferToTeamList(props: TransferToTeamListProps) {
<ul className="mt-2 grid grid-cols-3 gap-1.5">
{teams.map((team) => {
const isSelected = team._id === selectedTeamId;
if (team._id === currentTeamId) {
return null;
}
return (
<li key={team._id}>
<button
className={cn(
'relative flex w-full items-center gap-2.5 rounded-lg border p-2.5 disabled:cursor-not-allowed disabled:opacity-70',
isSelected && 'border-gray-500 bg-gray-100 text-black',
isSelected && 'border-gray-500 bg-gray-100 text-black'
)}
disabled={isTeamMembersLoading}
onClick={() => {

View File

@@ -2,10 +2,6 @@ import { useState } from 'react';
import { getRelativeTimeString } from '../../lib/date';
import type { TeamStreamActivity } from './TeamActivityPage';
import { ChevronsDown, ChevronsUp } from 'lucide-react';
import { ActivityTopicTitles } from '../Activity/ActivityTopicTitles';
import { cn } from '../../lib/classname';
import { useStore } from '@nanostores/react';
import { $currentTeam } from '../../stores/team';
type TeamActivityItemProps = {
onTopicClick?: (activity: TeamStreamActivity) => void;
@@ -16,7 +12,6 @@ type TeamActivityItemProps = {
name: string;
avatar?: string | undefined;
username?: string | undefined;
memberId?: string;
};
};
@@ -24,7 +19,6 @@ export function TeamActivityItem(props: TeamActivityItemProps) {
const { user, onTopicClick, teamId } = props;
const { activities } = user;
const currentTeam = useStore($currentTeam);
const [showAll, setShowAll] = useState(false);
const resourceLink = (activity: TeamStreamActivity) => {
@@ -65,39 +59,21 @@ export function TeamActivityItem(props: TeamActivityItemProps) {
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${user.avatar}`
: '/images/default-avatar.png';
const isPersonalProgressOnly =
currentTeam?.personalProgressOnly &&
currentTeam.role === 'member' &&
user.memberId !== currentTeam.memberId;
const username = (
<a
href={`/team/member?t=${teamId}&m=${user?.memberId}`}
className={cn(
'inline-flex items-center gap-1.5 underline underline-offset-2 hover:underline',
isPersonalProgressOnly
? 'pointer-events-none cursor-default no-underline'
: '',
)}
onClick={(e) => {
if (isPersonalProgressOnly) {
e.preventDefault();
}
}}
aria-disabled={isPersonalProgressOnly}
>
<>
<img
className="inline-block h-5 w-5 rounded-full"
className="mr-1 inline-block h-5 w-5 rounded-full"
src={userAvatar}
alt={user.name}
/>
<span className="font-medium">{user?.name || 'Unknown'}</span>
</a>
<span className="font-medium">{user?.name || 'Unknown'}</span>{' '}
</>
);
if (activities.length === 1) {
const activity = activities[0];
const { actionType, topicTitles } = activity;
const topicCount = topicTitles?.length || 0;
const { actionType, topicIds } = activity;
const topicCount = topicIds?.length || 0;
return (
<li
@@ -106,45 +82,34 @@ export function TeamActivityItem(props: TeamActivityItemProps) {
>
{actionType === 'in_progress' && (
<>
<p className="mb-1 flex w-full flex-wrap items-center">
{username}&nbsp;started&nbsp;
{topicCount}&nbsp;topic{topicCount > 1 ? 's' : ''}&nbsp;in&nbsp;
{resourceLink(activity)}&nbsp;
{timeAgo(activity.updatedAt)}
</p>
<ActivityTopicTitles
className="pl-5"
topicTitles={topicTitles || []}
/>
{username} started{' '}
<button
className="font-medium underline underline-offset-2 hover:text-black"
onClick={() => onTopicClick?.(activity)}
>
{topicCount} topic{topicCount > 1 ? 's' : ''}
</button>{' '}
in {resourceLink(activity)} {timeAgo(activity.updatedAt)}
</>
)}
{actionType === 'done' && (
<>
<p className="mb-1 flex w-full flex-wrap items-center">
{username}&nbsp;completed&nbsp;
{topicCount}&nbsp;topic{topicCount > 1 ? 's' : ''}&nbsp;in&nbsp;
{resourceLink(activity)}&nbsp;
{timeAgo(activity.updatedAt)}
</p>
<ActivityTopicTitles
className="pl-5"
topicTitles={topicTitles || []}
/>
{username} completed{' '}
<button
className="font-medium underline underline-offset-2 hover:text-black"
onClick={() => onTopicClick?.(activity)}
>
{topicCount} topic{topicCount > 1 ? 's' : ''}
</button>{' '}
in {resourceLink(activity)} {timeAgo(activity.updatedAt)}
</>
)}
{actionType === 'answered' && (
<>
<p className="mb-1 flex w-full flex-wrap items-center">
{username}&nbsp;answered&nbsp;
{topicCount}&nbsp;question{topicCount > 1 ? 's' : ''}
&nbsp;in&nbsp;
{resourceLink(activity)}&nbsp;
{timeAgo(activity.updatedAt)}
</p>
<ActivityTopicTitles
className="pl-5"
topicTitles={topicTitles || []}
/>
{username} answered {topicCount} question
{topicCount > 1 ? 's' : ''} in {resourceLink(activity)}{' '}
{timeAgo(activity.updatedAt)}
</>
)}
</li>
@@ -160,55 +125,46 @@ export function TeamActivityItem(props: TeamActivityItemProps) {
return (
<li key={user._id} className="overflow-hidden rounded-md border">
<h3 className="flex flex-wrap items-center gap-1 bg-gray-100 px-2 py-2.5 text-sm">
{username} has {activities.length} updates in {uniqueResourcesCount}
&nbsp;resource(s)
{username} has {activities.length} updates in {uniqueResourcesCount}{' '}
resource(s)
</h3>
<div className="py-3">
<ul className="ml-2 flex flex-col divide-y pr-2 sm:ml-[36px]">
{activities.slice(0, activityLimit).map((activity, counter) => {
const { actionType, topicTitles } = activity;
const topicCount = topicTitles?.length || 0;
<ul className="ml-2 flex flex-col gap-2 sm:ml-[36px]">
{activities.slice(0, activityLimit).map((activity) => {
const { actionType, topicIds } = activity;
const topicCount = topicIds?.length || 0;
return (
<li
key={activity._id}
className={cn(
'text-sm text-gray-600',
counter === 0 ? 'pb-2.5' : 'py-2.5',
counter === activities.length - 1 ? 'pb-0' : '',
)}
>
<li key={activity._id} className="text-sm text-gray-600">
{actionType === 'in_progress' && (
<>
<p className="mb-1">
Started&nbsp;{topicCount}&nbsp;topic
{topicCount > 1 ? 's' : ''}&nbsp;in&nbsp;
{resourceLink(activity)}&nbsp;
{timeAgo(activity.updatedAt)}
</p>
<ActivityTopicTitles topicTitles={topicTitles || []} />
Started{' '}
<button
className="font-medium underline underline-offset-2 hover:text-black"
onClick={() => onTopicClick?.(activity)}
>
{topicCount} topic{topicCount > 1 ? 's' : ''}
</button>{' '}
in {resourceLink(activity)} {timeAgo(activity.updatedAt)}
</>
)}
{actionType === 'done' && (
<>
<p className="mb-1">
Completed&nbsp;{topicCount}&nbsp;topic
{topicCount > 1 ? 's' : ''}&nbsp;in&nbsp;
{resourceLink(activity)}&nbsp;
{timeAgo(activity.updatedAt)}
</p>
<ActivityTopicTitles topicTitles={topicTitles || []} />
Completed{' '}
<button
className="font-medium underline underline-offset-2 hover:text-black"
onClick={() => onTopicClick?.(activity)}
>
{topicCount} topic{topicCount > 1 ? 's' : ''}
</button>{' '}
in {resourceLink(activity)} {timeAgo(activity.updatedAt)}
</>
)}
{actionType === 'answered' && (
<>
<p className="mb-1">
Answered&nbsp;{topicCount}&nbsp;question
{topicCount > 1 ? 's' : ''}&nbsp;in&nbsp;
{resourceLink(activity)}&nbsp;
{timeAgo(activity.updatedAt)}
</p>
<ActivityTopicTitles topicTitles={topicTitles || []} />
Answered {topicCount} question
{topicCount > 1 ? 's' : ''} in {resourceLink(activity)}{' '}
{timeAgo(activity.updatedAt)}
</>
)}
</li>

View File

@@ -18,7 +18,7 @@ export type TeamStreamActivity = {
resourceSlug?: string;
isCustomResource?: boolean;
actionType: AllowedActivityActionType;
topicTitles?: string[];
topicIds?: string[];
createdAt: Date;
updatedAt: Date;
};
@@ -39,7 +39,6 @@ type GetTeamActivityResponse = {
name: string;
avatar?: string;
username?: string;
memberId?: string;
}[];
activities: TeamActivityStreamDocument[];
};
@@ -99,70 +98,38 @@ export function TeamActivityPage() {
}, [teamId]);
const { users, activities } = teamActivities?.data;
const validActivities = useMemo(() => {
return activities?.filter((activity) => {
const usersWithActivities = useMemo(() => {
const validActivities = activities.filter((activity) => {
return (
activity.activity.length > 0 &&
activity.activity.some((t) => (t?.topicTitles?.length || 0) > 0)
activity.activity.some((t) => (t?.topicIds?.length || 0) > 0)
);
});
}, [activities]);
const sortedUniqueCreatedAt = useMemo(() => {
return new Set(
validActivities
?.map((activity) => new Date(activity.createdAt).setHours(0, 0, 0, 0))
.sort((a, b) => {
return new Date(b).getTime() - new Date(a).getTime();
}),
);
}, [validActivities]);
return users
.map((user) => {
const userActivities = validActivities
.filter((activity) => activity.userId === user._id)
.flatMap((activity) => activity.activity)
.filter((activity) => (activity?.topicIds?.length || 0) > 0)
.sort((a, b) => {
return (
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
});
const usersWithActivities = useMemo(() => {
const enrichedUsers: {
_id: string;
name: string;
avatar?: string;
username?: string;
activities: TeamStreamActivity[];
}[] = [];
for (const uniqueCreatedAt of sortedUniqueCreatedAt) {
const uniqueActivities = validActivities.filter(
(activity) =>
new Date(activity.createdAt).setHours(0, 0, 0, 0) === uniqueCreatedAt,
);
const usersWithUniqueActivities = users
.map((user) => {
const userActivities = uniqueActivities
.filter((activity) => activity.userId === user._id)
.flatMap((activity) => activity.activity)
.filter((activity) => (activity?.topicTitles?.length || 0) > 0)
.sort((a, b) => {
return (
new Date(b.updatedAt).getTime() -
new Date(a.updatedAt).getTime()
);
});
return {
...user,
activities: userActivities,
};
})
.filter((user) => user.activities.length > 0)
.sort((a, b) => {
return (
new Date(b.activities[0].updatedAt).getTime() -
new Date(a.activities[0].updatedAt).getTime()
);
});
enrichedUsers.push(...usersWithUniqueActivities);
}
return enrichedUsers;
return {
...user,
activities: userActivities,
};
})
.filter((user) => user.activities.length > 0)
.sort((a, b) => {
return (
new Date(b.activities[0].updatedAt).getTime() -
new Date(a.activities[0].updatedAt).getTime()
);
});
}, [users, activities]);
if (!teamId) {
@@ -189,10 +156,10 @@ export function TeamActivityPage() {
Team Activity
</h3>
<ul className="mb-4 mt-2 flex flex-col gap-3">
{usersWithActivities.map((user, index) => {
{usersWithActivities.map((user) => {
return (
<TeamActivityItem
key={`${user._id}-${index}`}
key={user._id}
user={user}
teamId={teamId}
onTopicClick={setSelectedActivity}

View File

@@ -16,10 +16,54 @@ export function TeamActivityTopicsModal(props: TeamActivityTopicsModalProps) {
resourceId,
resourceType,
isCustomResource,
topicTitles = [],
topicIds = [],
actionType,
} = activity;
const [isLoading, setIsLoading] = useState(true);
const [topicTitles, setTopicTitles] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
const loadTopicTitles = async () => {
setIsLoading(true);
setError(null);
const { response, error } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-get-topic-titles`,
{
resourceId,
resourceType,
isCustomResource,
topicIds,
},
);
if (error || !response) {
setError(error?.message || 'Failed to load topic titles');
setIsLoading(false);
return;
}
setTopicTitles(response);
setIsLoading(false);
};
useEffect(() => {
loadTopicTitles().finally(() => {
setIsLoading(false);
});
}, []);
if (isLoading || error) {
return (
<ModalLoader
error={error!}
text={'Loading topics..'}
isLoading={isLoading}
/>
);
}
let pageUrl = '';
if (resourceType === 'roadmap') {
pageUrl = isCustomResource ? `/r/${resourceId}` : `/${resourceId}`;
@@ -33,6 +77,8 @@ export function TeamActivityTopicsModal(props: TeamActivityTopicsModalProps) {
<Modal
onClose={() => {
onClose();
setError(null);
setIsLoading(false);
}}
>
<div className={`popup-body relative rounded-lg bg-white p-4 shadow`}>
@@ -54,7 +100,9 @@ export function TeamActivityTopicsModal(props: TeamActivityTopicsModalProps) {
</a>
</span>
<ul className="flex max-h-[50vh] flex-col gap-1 overflow-y-auto max-md:max-h-full">
{topicTitles.map((topicTitle) => {
{topicIds.map((topicId) => {
const topicTitle = topicTitles[topicId] || 'Unknown Topic';
const ActivityIcon =
actionType === 'done'
? Check
@@ -63,7 +111,7 @@ export function TeamActivityTopicsModal(props: TeamActivityTopicsModalProps) {
: Check;
return (
<li key={topicTitle} className="flex items-start gap-2">
<li key={topicId} className="flex items-start gap-2">
<ActivityIcon
strokeWidth={3}
className="relative top-[4px] text-green-500"

View File

@@ -1,4 +1,4 @@
import { ListTodo } from 'lucide-react';
import { Activity, List, ListTodo } from 'lucide-react';
type TeamActivityItemProps = {
teamId: string;

View File

@@ -23,7 +23,6 @@ export type UserTeamItem = {
role: AllowedRoles;
status: AllowedMemberStatus;
memberId: string;
personalProgressOnly?: boolean;
};
export type TeamListResponse = UserTeamItem[];

View File

@@ -1,215 +0,0 @@
import { useEffect, useState } from 'react';
import { httpGet } from '../../lib/http';
import { pageProgressMessage } from '../../stores/page';
import { getUrlParams } from '../../lib/browser';
import { useToast } from '../../hooks/use-toast';
import type { TeamMemberDocument } from '../TeamMembers/TeamMembersPage';
import type { UserProgress } from '../TeamProgress/TeamProgressPage';
import type { TeamActivityStreamDocument } from '../TeamActivity/TeamActivityPage';
import { ResourceProgress } from '../Activity/ResourceProgress';
import { ActivityStream } from '../Activity/ActivityStream';
import { MemberRoleBadge } from '../TeamMembers/RoleBadge';
import { TeamMemberEmptyPage } from './TeamMemberEmptyPage';
import { Pagination } from '../Pagination/Pagination';
import type { ResourceType } from '../../lib/resource-progress';
import { MemberProgressModal } from '../TeamProgress/MemberProgressModal';
import { useStore } from '@nanostores/react';
import { $currentTeam } from '../../stores/team';
import { MemberCustomProgressModal } from '../TeamProgress/MemberCustomProgressModal';
type GetTeamMemberProgressesResponse = TeamMemberDocument & {
name: string;
avatar: string;
email: string;
progresses: UserProgress[];
};
type GetTeamMemberActivityResponse = {
data: TeamActivityStreamDocument[];
totalCount: number;
totalPages: number;
currPage: number;
perPage: number;
};
export function TeamMemberDetailsPage() {
const { t: teamId, m: memberId } = getUrlParams() as { t: string; m: string };
const toast = useToast();
const currentTeam = useStore($currentTeam);
const [memberProgress, setMemberProgress] =
useState<GetTeamMemberProgressesResponse | null>(null);
const [memberActivity, setMemberActivity] =
useState<GetTeamMemberActivityResponse | null>(null);
const [currPage, setCurrPage] = useState(1);
const [selectedResource, setSelectedResource] = useState<{
resourceId: string;
resourceType: ResourceType;
isCustomResource?: boolean;
} | null>(null);
const loadMemberProgress = async () => {
const { response, error } = await httpGet<GetTeamMemberProgressesResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-member-progresses/${teamId}/${memberId}`,
);
if (error || !response) {
pageProgressMessage.set('');
toast.error(error?.message || 'Failed to load team member');
return;
}
setMemberProgress(response);
};
const loadMemberActivity = async (currPage: number = 1) => {
const { response, error } = await httpGet<GetTeamMemberActivityResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-member-activity/${teamId}/${memberId}`,
{
currPage,
},
);
if (error || !response) {
pageProgressMessage.set('');
toast.error(error?.message || 'Failed to load team member activity');
return;
}
setMemberActivity(response);
setCurrPage(response?.currPage || 1);
};
useEffect(() => {
if (!teamId) {
return;
}
Promise.allSettled([loadMemberProgress(), loadMemberActivity()]).finally(
() => {
pageProgressMessage.set('');
},
);
}, [teamId]);
if (!teamId || !memberId || !memberProgress || !memberActivity) {
return null;
}
const avatarUrl = memberProgress?.avatar
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${memberProgress?.avatar}`
: '/images/default-avatar.png';
const ProgressModal =
selectedResource && !selectedResource.isCustomResource
? MemberProgressModal
: MemberCustomProgressModal;
return (
<>
{selectedResource && (
<ProgressModal
teamId={teamId}
member={{
...memberProgress,
_id: memberId,
updatedAt: new Date(memberProgress.updatedAt).toISOString(),
progress: memberProgress.progresses,
}}
resourceId={selectedResource.resourceId}
resourceType={selectedResource.resourceType}
isCustomResource={selectedResource.isCustomResource}
onClose={() => setSelectedResource(null)}
onShowMyProgress={() => {
window.location.href = `/team/member?t=${teamId}&m=${currentTeam?.memberId}`;
}}
/>
)}
<div className="mb-8 flex items-center gap-3">
<img
src={avatarUrl}
alt={memberProgress?.name}
className="h-14 w-14 rounded-full"
/>
<div>
<h1 className="mt-1 text-2xl font-medium">{memberProgress?.name}</h1>
<p className="text-sm text-gray-500">{memberProgress?.email}</p>
</div>
</div>
{memberProgress?.progresses && memberProgress?.progresses?.length > 0 ? (
<>
<h2 className="mb-3 text-xs uppercase text-gray-400">
Progress Overview
</h2>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{memberProgress?.progresses?.map((progress) => {
const learningCount = progress.learning || 0;
const doneCount = progress.done || 0;
const totalCount = progress.total || 0;
const skippedCount = progress.skipped || 0;
return (
<ResourceProgress
key={progress.resourceId}
isCustomResource={progress.isCustomResource!}
doneCount={doneCount > totalCount ? totalCount : doneCount}
learningCount={
learningCount > totalCount ? totalCount : learningCount
}
totalCount={totalCount}
skippedCount={skippedCount}
resourceId={progress.resourceId}
resourceType={'roadmap'}
updatedAt={progress.updatedAt}
title={progress.resourceTitle}
roadmapSlug={progress.roadmapSlug}
showActions={false}
onResourceClick={() => {
setSelectedResource({
resourceId: progress.resourceId,
resourceType: progress.resourceType,
isCustomResource: progress.isCustomResource,
});
}}
/>
);
})}
</div>
</>
) : (
<TeamMemberEmptyPage teamId={teamId} />
)}
{memberActivity?.data && memberActivity?.data?.length > 0 ? (
<>
<ActivityStream
className="mt-8 p-0 md:m-0 md:mb-4 md:mt-8 md:p-0"
activities={
memberActivity?.data?.flatMap((act) => act.activity) || []
}
onResourceClick={(resourceId, resourceType, isCustomResource) => {
setSelectedResource({
resourceId,
resourceType,
isCustomResource,
});
}}
/>
<Pagination
currPage={currPage}
totalPages={memberActivity?.totalPages || 1}
totalCount={memberActivity?.totalCount || 0}
perPage={memberActivity?.perPage || 10}
onPageChange={(page) => {
pageProgressMessage.set('Loading Activity');
loadMemberActivity(page).finally(() => {
pageProgressMessage.set('');
});
}}
/>
</>
) : null}
</>
);
}

View File

@@ -1,29 +0,0 @@
import { RoadmapIcon } from '../ReactIcons/RoadmapIcon';
type TeamMemberEmptyPageProps = {
teamId: string;
};
export function TeamMemberEmptyPage(props: TeamMemberEmptyPageProps) {
const { teamId } = props;
return (
<div className="rounded-md">
<div className="flex flex-col items-center p-7 text-center">
<RoadmapIcon 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 Progress</h2>
<p className="my-1 max-w-[400px] text-balance text-sm text-gray-500 sm:my-2 sm:text-base">
Progress will appear here as they start tracking their{' '}
<a
href={`/team/roadmaps?t=${teamId}`}
className="mt-4 text-blue-500 hover:underline"
>
Roadmaps
</a>{' '}
progress.
</p>
</div>
</div>
);
}

View File

@@ -1,23 +1,12 @@
import { cn } from '../../lib/classname';
import type { AllowedRoles } from '../CreateTeam/RoleDropdown';
type RoleBadgeProps = {
role: AllowedRoles;
className?: string;
};
export function MemberRoleBadge(props: RoleBadgeProps) {
const { role, className } = props;
export function MemberRoleBadge({ role }: { role: AllowedRoles }) {
return (
<span
className={cn(
`items-center rounded-full px-2 py-0.5 text-xs capitalize sm:flex ${
['admin'].includes(role)
? 'bg-blue-100 text-blue-700 '
: 'bg-gray-100 text-gray-700 '
} ${['manager'].includes(role) ? 'bg-green-100 text-green-700' : ''}`,
className,
)}
className={`rounded-full px-2 py-0.5 text-xs sm:flex items-center capitalize ${['admin'].includes(role)
? 'bg-blue-100 text-blue-700 '
: 'bg-gray-100 text-gray-700 '
} ${['manager'].includes(role) ? 'bg-green-100 text-green-700' : ''}`}
>
{role}
</span>

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