Compare commits

..

10 Commits

Author SHA1 Message Date
Kamran Ahmed
91ad9dd5a3 Add search icon in navigation 2023-05-20 23:19:19 +01:00
Kamran Ahmed
bb23f57204 Add page filtering 2023-05-20 22:59:19 +01:00
Kamran Ahmed
80dac2f7f7 Add icons to menu 2023-05-20 22:55:59 +01:00
Kamran Ahmed
c4f3c3529d Activate on CMD+K and focus 2023-05-20 22:21:54 +01:00
Kamran Ahmed
f57b61a920 Fix responsiveness of command menu 2023-05-20 22:12:12 +01:00
Kamran Ahmed
af05d795e3 Group separation, no result handling and filtering 2023-05-20 22:07:10 +01:00
Kamran Ahmed
44e5138b32 Refactor pages and add retrieval 2023-05-20 21:55:16 +01:00
Arik Chakma
4d7cada25c chore: backend fix 2023-05-20 18:44:07 +06:00
Kamran Ahmed
b707aa02d8 On Enter open the page 2023-05-20 13:06:20 +01:00
Kamran Ahmed
c9c4516aa7 Add command k input 2023-05-20 13:01:54 +01:00
1037 changed files with 10724 additions and 85151 deletions

View File

@@ -1,25 +0,0 @@
name: "✍️ Suggest Changes"
description: Help us improve the roadmaps by suggesting changes
labels: [suggestion]
assignees: []
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to help us improve the roadmaps with your suggestions.
- type: input
id: url
attributes:
label: Roadmap URL
description: Please provide the URL of the roadmap you are suggesting changes to.
placeholder: https://roadmap.sh
validations:
required: true
- type: textarea
id: roadmap-suggestions
attributes:
label: Suggestions
description: What changes would you like to suggest?
placeholder: Enter your suggestions here.
validations:
required: true

View File

@@ -1,42 +0,0 @@
name: "🐛 Bug Report"
description: Report an issue or possible bug
labels: [bug]
assignees: []
body:
- type: input
id: url
attributes:
label: What is the URL where the issue is happening
placeholder: https://roadmap.sh
validations:
required: true
- type: dropdown
id: browsers
attributes:
label: What browsers are you seeing the problem on?
multiple: true
options:
- Firefox
- Chrome
- Safari
- Microsoft Edge
- Other
- type: textarea
id: bug-description
attributes:
label: Describe the Bug
description: A clear and concise description of what the bug is.
validations:
required: true
- type: textarea
id: logs
attributes:
label: Output from browser console (if any)
description: Please copy and paste any relevant log output.
- type: checkboxes
id: will-pr
attributes:
label: Participation
options:
- label: I am willing to submit a pull request for this issue.
required: false

View File

@@ -1,12 +0,0 @@
name: "✨ Feature Suggestion"
description: Is there a feature you'd like to see on Roadmap.sh? Let us know!
labels: [feature request]
assignees: []
body:
- type: textarea
id: feature-description
attributes:
label: Feature Description
description: Please provide a detailed description of the feature you are suggesting and how it would help you/others.
validations:
required: true

View File

@@ -1,37 +0,0 @@
name: "🙏 Submit a Roadmap"
description: Help us launch a new roadmap with your expertise.
labels: [roadmap contribution]
assignees: []
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to submit a roadmap! Please fill out the information below and we'll get back to you as soon as we can.
- type: input
id: roadmap-title
attributes:
label: What is the title of the roadmap you are submitting?
placeholder: e.g. Roadmap to learn Data Science
validations:
required: true
- type: dropdown
id: browsers
attributes:
label: Is this roadmap prepared by you or someone else?
options:
- I prepared this roadmap
- I found this roadmap online (please provide a link below)
- type: textarea
id: roadmap-description
attributes:
label: Roadmap Items
description: Please submit a nested list of items which we can convert into the visual. Here is an [example of roadmap items list.](https://gist.github.com/kamranahmedse/98758d2c73799b3a6ce17385e4c548a5).
placeholder: |
- Item 1
- Subitem 1
- Subitem 2
- Item 2
- Subitem 1
- Subitem 2
validations:
required: true

View File

@@ -1,12 +0,0 @@
name: "🤷‍♂️ Something else"
description: If none of the above templates fit your needs, please use this template to submit your issue.
labels: []
assignees: []
body:
- type: textarea
id: issue-description
attributes:
label: Detailed Description
description: Please provide a detailed description of the issue.
validations:
required: true

View File

@@ -1,14 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Roadmap Request
url: https://discord.gg/cJpEt5Qbwa
about: Please do not open issues with roadmap requests, hop onto the discord server for that.
- name: 📝 Typo or Grammatical Mistake
url: https://github.com/kamranahmedse/developer-roadmap/tree/master/src/data
about: Please submit a pull request instead of reporting it as an issue.
- name: 💬 Chat on Discord
url: https://discord.gg/cJpEt5Qbwa
about: Join the community on our Discord server.
- name: 🤝 Guidance
url: https://discord.gg/cJpEt5Qbwa
about: Join the community in our Discord server.

2
.gitignore vendored
View File

@@ -1,5 +1,3 @@
.idea
# build output
dist/
.output/

View File

@@ -5,7 +5,6 @@ import tailwind from '@astrojs/tailwind';
import compress from 'astro-compress';
import { defineConfig } from 'astro/config';
import rehypeExternalLinks from 'rehype-external-links';
import { fileURLToPath } from 'node:url';
import { serializeSitemap, shouldIndexPage } from './sitemap.mjs';
// https://astro.build/config
@@ -46,22 +45,6 @@ export default defineConfig({
format: 'file',
},
integrations: [
{
name: 'client-authenticated',
hooks: {
'astro:config:setup'(options) {
options.addClientDirective({
name: 'authenticated',
entrypoint: fileURLToPath(
new URL(
'./src/directives/client-authenticated.mjs',
import.meta.url
)
),
});
},
},
},
tailwind({
config: {
applyBaseStyles: false,

View File

@@ -16,7 +16,7 @@ For new roadmaps, submit a roadmap by providing [a textual roadmap similar to th
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)
- **Fixing Typos** — Make your changes in the [roadmap JSON file](https://github.com/kamranahmedse/developer-roadmap/tree/master/public/jsons)
- **Adding or Removing Nodes** — Please open an issue with your suggestion.
**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.

View File

@@ -21,35 +21,34 @@
"test:e2e": "playwright test"
},
"dependencies": {
"@astrojs/preact": "^2.2.1",
"@astrojs/sitemap": "^1.3.3",
"@astrojs/preact": "^2.2.0",
"@astrojs/sitemap": "^1.3.1",
"@astrojs/tailwind": "^3.1.3",
"@fingerprintjs/fingerprintjs": "^3.4.1",
"@nanostores/preact": "^0.5.0",
"astro": "^2.6.6",
"astro-compress": "^1.1.47",
"@nanostores/preact": "^0.4.1",
"astro": "^2.5.0",
"astro-compress": "^1.1.43",
"jose": "^4.14.4",
"js-cookie": "^3.0.5",
"nanostores": "^0.9.2",
"nanostores": "^0.8.1",
"node-html-parser": "^6.1.5",
"npm-check-updates": "^16.10.12",
"preact": "^10.15.1",
"preact": "^10.14.1",
"rehype-external-links": "^2.1.0",
"roadmap-renderer": "^1.0.6",
"slugify": "^1.6.6",
"tailwindcss": "^3.3.2"
},
"devDependencies": {
"@playwright/test": "^1.35.1",
"@playwright/test": "^1.33.0",
"@tailwindcss/typography": "^0.5.9",
"@types/js-cookie": "^3.0.3",
"csv-parser": "^3.0.0",
"gh-pages": "^5.0.0",
"js-yaml": "^4.1.0",
"markdown-it": "^13.0.1",
"openai": "^3.3.0",
"openai": "^3.2.1",
"prettier": "^2.8.8",
"prettier-plugin-astro": "^0.10.0",
"prettier-plugin-astro": "^0.9.0",
"prettier-plugin-tailwindcss": "^0.3.0"
}
}

3401
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 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: 773 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 550 KiB

View File

@@ -30,9 +30,9 @@ Roadmaps are now interactive, you can click the nodes to read more about the top
Here is the list of available roadmaps with more being actively worked upon.
- [Frontend Roadmap](https://roadmap.sh/frontend) / [Frontend Beginner Roadmap](https://roadmap.sh/frontend?r=frontend-beginner)
- [Frontend Roadmap](https://roadmap.sh/frontend)
- [Backend Roadmap](https://roadmap.sh/backend)
- [DevOps Roadmap](https://roadmap.sh/devops) / [DevOps Beginner Roadmap](https://roadmap.sh/devops?r=devops-beginner)
- [DevOps Roadmap](https://roadmap.sh/devops)
- [Full Stack Roadmap](https://roadmap.sh/full-stack)
- [Computer Science Roadmap](https://roadmap.sh/computer-science)
- [QA Roadmap](https://roadmap.sh/qa)
@@ -40,9 +40,7 @@ Here is the list of available roadmaps with more being actively worked upon.
- [Software Design and Architecture Roadmap](https://roadmap.sh/software-design-architecture)
- [JavaScript Roadmap](https://roadmap.sh/javascript)
- [TypeScript Roadmap](https://roadmap.sh/typescript)
- [C++ Roadmap](https://roadmap.sh/cpp)
- [React Roadmap](https://roadmap.sh/react)
- [React Native Roadmap](https://roadmap.sh/react-native)
- [Vue Roadmap](https://roadmap.sh/vue)
- [Angular Roadmap](https://roadmap.sh/angular)
- [Node.js Roadmap](https://roadmap.sh/nodejs)
@@ -54,8 +52,7 @@ Here is the list of available roadmaps with more being actively worked upon.
- [Java Roadmap](https://roadmap.sh/java)
- [Spring Boot Roadmap](https://roadmap.sh/spring-boot)
- [Design System Roadmap](https://roadmap.sh/design-system)
- [PostgreSQL Roadmap](https://roadmap.sh/postgresql-dba)
- [SQL Roadmap](https://roadmap.sh/sql)
- [DBA Roadmap](https://roadmap.sh/postgresql-dba)
- [Blockchain Roadmap](https://roadmap.sh/blockchain)
- [ASP.NET Core Roadmap](https://roadmap.sh/aspnet-core)
- [System Design Roadmap](https://roadmap.sh/system-design)
@@ -64,7 +61,6 @@ Here is the list of available roadmaps with more being actively worked upon.
- [MongoDB Roadmap](https://roadmap.sh/mongodb)
- [UX Design Roadmap](https://roadmap.sh/ux-design)
- [Docker Roadmap](https://roadmap.sh/docker)
- [Prompt Engineering Roadmap](https://roadmap.sh/prompt-engineering)
We have also added a new form of visual content covering best practices:
@@ -95,12 +91,6 @@ npm install
npm run dev
```
Note: use the `depth` parameter to reduce the clone size and speed up the clone.
```sh
git clone --depth=1 https://github.com/kamranahmedse/developer-roadmap.git
```
## Contribution
> Have a look at [contribution docs](./contributing.md) for how to update any of the roadmaps

View File

@@ -0,0 +1,19 @@
const fs = require('node:fs');
const path = require('node:path');
const jsonsDir = path.join(process.cwd(), 'public/jsons');
const childJsonDirs = fs.readdirSync(jsonsDir);
childJsonDirs.forEach((childJsonDir) => {
const fullChildJsonDirPath = path.join(jsonsDir, childJsonDir);
const jsonFiles = fs.readdirSync(fullChildJsonDirPath);
jsonFiles.forEach((jsonFileName) => {
console.log(`Compressing ${jsonFileName}...`);
const jsonFilePath = path.join(fullChildJsonDirPath, jsonFileName);
const json = require(jsonFilePath);
fs.writeFileSync(jsonFilePath, JSON.stringify(json));
});
});

View File

@@ -3,6 +3,7 @@ const path = require('path');
const OPEN_AI_API_KEY = process.env.OPEN_AI_API_KEY;
const ALL_ROADMAPS_DIR = path.join(__dirname, '../src/data/roadmaps');
const ROADMAP_JSON_DIR = path.join(__dirname, '../public/jsons/roadmaps');
const roadmapId = process.argv[2];
@@ -60,9 +61,9 @@ function writeTopicContent(currTopicUrl) {
const roadmapTitle = roadmapId.replace(/-/g, ' ');
let prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${childTopic}". Write me with a brief summary of that. Content should be in markdown. I already know the benefits of each so do not add benefits in the output. Also include the code examples if applicable to this topic.`;
let prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${childTopic}". Write me a brief summary for that topic. Content should be in markdown. Behave as if you are the author of the guide.`;
if (!childTopic) {
prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${parentTopic}". Write me with a brief summary of that. Content should be in markdown. I already know the benefits of each so do not add benefits in the output. Also include the code examples if applicable to this topic.`;
prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${parentTopic}". Write me a brief summary for that topic. Content should be in markdown. Behave as if you are the author of the guide.`;
}
console.log(`Generating '${childTopic || parentTopic}'...`);
@@ -138,11 +139,7 @@ async function writeFileForGroup(group, topicUrlToPathMapping) {
async function run() {
const topicUrlToPathMapping = getFilesInFolder(ROADMAP_CONTENT_DIR);
const roadmapJson = require(path.join(
ALL_ROADMAPS_DIR,
`${roadmapId}/${roadmapId}`
));
const roadmapJson = require(path.join(ROADMAP_JSON_DIR, `${roadmapId}.json`));
const groups = roadmapJson?.mockup?.controls?.control?.filter(
(control) =>
control.typeID === '__group__' &&

View File

@@ -84,9 +84,8 @@ function prepareDirTree(control, dirTree, dirSortOrders) {
const roadmap = require(path.join(
__dirname,
`../src/data/roadmaps/${roadmapId}/${roadmapId}`
`../public/jsons/roadmaps/${roadmapId}`
));
const controls = roadmap.mockup.controls.control;
// Prepare the dir tree that we will be creating and also calculate the sort orders

View File

@@ -1,170 +0,0 @@
---
import AstroIcon from './AstroIcon.astro';
import { TeamDropdown } from './TeamDropdown/TeamDropdown';
export interface Props {
activePageId: string;
activePageTitle: string;
hasDesktopSidebar?: boolean;
}
const { hasDesktopSidebar = true, activePageId, activePageTitle } = Astro.props;
const sidebarLinks = [
{
href: '/account',
title: 'Activity',
id: 'activity',
isNew: false,
icon: {
glyph: 'analytics',
classes: 'h-3 w-4',
},
},
{
href: '/account/road-card',
title: 'Card',
id: 'road-card',
isNew: true,
icon: {
glyph: 'badge',
classes: 'h-4 w-4',
},
},
{
href: '/account/update-profile',
title: 'Profile',
id: 'profile',
isNew: false,
icon: {
glyph: 'user',
classes: 'h-4 w-4',
},
},
{
href: '/account/settings',
title: 'Settings',
id: 'settings',
isNew: false,
icon: {
glyph: 'cog',
classes: 'h-4 w-4',
},
},
];
---
<div class='relative mb-5 block border-b p-4 shadow-inner md:hidden'>
<button
class='flex h-10 w-full items-center justify-between rounded-md border bg-white px-2 text-center text-sm font-medium text-gray-900'
id='settings-menu'
>
{activePageTitle}
<AstroIcon icon='dropdown' />
</button>
<ul
id='settings-menu-dropdown'
class='absolute left-0 right-0 z-10 mt-1 hidden space-y-1.5 bg-white p-2 shadow-lg'
>
<!--<li>-->
<!-- <a-->
<!-- href='/team'-->
<!-- class={`flex w-full items-center rounded px-3 py-1.5 text-sm text-slate-900 hover:bg-slate-200 ${-->
<!-- activePageId === 'team' ? 'bg-slate-100' : ''-->
<!-- }`}-->
<!-- >-->
<!-- <AstroIcon icon={'users'} class={`h-4 w-4 mr-2`} />-->
<!-- Teams-->
<!-- </a>-->
<!--</li>-->
{
sidebarLinks.map((sidebarLink) => {
const isActive = activePageId === sidebarLink.id;
return (
<li>
<a
href={sidebarLink.href}
class={`flex w-full items-center rounded px-3 py-1.5 text-sm text-slate-900 hover:bg-slate-200 ${
isActive ? 'bg-slate-100' : ''
}`}
>
<AstroIcon
icon={sidebarLink.icon.glyph}
class={`${sidebarLink.icon.classes} mr-2`}
/>
{sidebarLink.title}
</a>
</li>
);
})
}
</ul>
</div>
<div class='container flex min-h-screen items-stretch'>
<!-- Start Desktop Sidebar -->
{
hasDesktopSidebar && (
<aside class='hidden w-[195px] shrink-0 border-r border-slate-200 py-10 md:block'>
<TeamDropdown client:load />
<nav>
<ul class='space-y-1'>
{sidebarLinks.map((sidebarLink) => {
const isActive = activePageId === sidebarLink.id;
return (
<li>
<a
href={sidebarLink.href}
class={`font-regular flex w-full items-center border-r-2 px-2 py-1.5 text-sm ${
isActive
? 'border-r-black bg-gray-100 text-black'
: 'border-r-transparent text-gray-500 hover:border-r-gray-300'
}`}
>
<span class='flex flex-grow items-center'>
<AstroIcon
icon={sidebarLink.icon.glyph}
class={`${sidebarLink.icon.classes} mr-2`}
/>
{sidebarLink.title}
</span>
{sidebarLink.isNew && !isActive && (
<span class='relative mr-1 flex items-center'>
<span class='relative rounded-full bg-gray-200 p-1 text-xs' />
<span class='absolute bottom-0 left-0 right-0 top-0 animate-ping rounded-full bg-gray-400 p-1 text-xs' />
</span>
)}
</a>
</li>
);
})}
</ul>
</nav>
</aside>
)
}
<!-- /End Desktop Sidebar -->
<div class:list={['grow px-0 py-0 md:py-10', { 'md:px-10': hasDesktopSidebar, 'md:px-5': !hasDesktopSidebar }]}>
<slot />
</div>
</div>
<script>
const menuButton = document.getElementById('settings-menu');
const menuDropdown = document.getElementById('settings-menu-dropdown');
menuButton?.addEventListener('click', () => {
menuDropdown?.classList.toggle('hidden');
});
document.addEventListener('click', (e) => {
if (!menuButton?.contains(e.target as Node)) {
menuDropdown?.classList.add('hidden');
}
});
</script>

View File

@@ -1,56 +0,0 @@
type ActivityCountersType = {
done: {
today: number;
total: number;
};
learning: {
today: number;
total: number;
};
streak: {
count: number;
};
};
type ActivityCounterType = {
text: string;
count: string;
};
function ActivityCounter(props: ActivityCounterType) {
const { text, count } = props;
return (
<div class="relative flex flex-1 flex-row-reverse sm:flex-col px-0 sm:px-4 py-2 sm:py-4 text-center sm:pt-10 items-center gap-2 sm:gap-0 justify-end">
<h2 class="text-base sm:text-5xl font-bold">
{count}
</h2>
<p class="mt-0 sm:mt-2 text-sm text-gray-400">{text}</p>
</div>
);
}
export function ActivityCounters(props: ActivityCountersType) {
const { done, learning, streak } = props;
return (
<div class="mx-0 -mt-5 sm:-mx-10 md:-mt-10">
<div class="flex flex-col sm:flex-row gap-0 sm:gap-2 divide-y sm:divide-y-0 divide-x-0 sm:divide-x border-b">
<ActivityCounter
text={'Topics Completed'}
count={`${done?.total || 0}`}
/>
<ActivityCounter
text={'Currently Learning'}
count={`${learning?.total || 0}`}
/>
<ActivityCounter
text={'Visit Streak'}
count={`${streak?.count || 0}d`}
/>
</div>
</div>
);
}

View File

@@ -1,161 +0,0 @@
import { useEffect, useState } from 'preact/hooks';
import { httpGet } from '../../lib/http';
import { ActivityCounters } from './ActivityCounters';
import { ResourceProgress } from './ResourceProgress';
import { pageProgressMessage } from '../../stores/page';
import { EmptyActivity } from './EmptyActivity';
export type ActivityResponse = {
done: {
today: number;
total: number;
};
learning: {
today: number;
total: number;
roadmaps: {
title: string;
id: string;
learning: number;
done: number;
total: number;
skipped: number;
updatedAt: string;
}[];
bestPractices: {
title: string;
id: string;
learning: number;
done: number;
skipped: number;
total: number;
updatedAt: string;
}[];
};
streak: {
count: number;
firstVisitAt: Date | null;
lastVisitAt: Date | null;
};
activity: {
type: 'done' | 'learning' | 'pending' | 'skipped';
createdAt: Date;
metadata: {
resourceId?: string;
resourceType?: 'roadmap' | 'best-practice';
topicId?: string;
topicLabel?: string;
resourceTitle?: string;
};
}[];
};
export function ActivityPage() {
const [activity, setActivity] = useState<ActivityResponse>();
const [isLoading, setIsLoading] = useState(true);
async function loadActivity() {
const { error, response } = await httpGet<ActivityResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-stats`
);
if (!response || error) {
console.error('Error loading activity');
console.error(error);
return;
}
setActivity(response);
}
useEffect(() => {
loadActivity().finally(() => {
pageProgressMessage.set('');
setIsLoading(false);
});
}, []);
const learningRoadmaps = activity?.learning.roadmaps || [];
const learningBestPractices = activity?.learning.bestPractices || [];
if (isLoading) {
return null;
}
return (
<>
<ActivityCounters
done={activity?.done || { today: 0, total: 0 }}
learning={activity?.learning || { today: 0, total: 0 }}
streak={activity?.streak || { count: 0 }}
/>
<div class="mx-0 px-0 py-5 md:-mx-10 md:px-8 md:py-8">
{learningRoadmaps.length === 0 &&
learningBestPractices.length === 0 && <EmptyActivity />}
{(learningRoadmaps.length > 0 || learningBestPractices.length > 0) && (
<>
<h2 class="mb-3 text-xs uppercase text-gray-400">
Continue Following
</h2>
<div class="flex flex-col gap-3">
{learningRoadmaps
.sort((a, b) => {
const updatedAtA = new Date(a.updatedAt);
const updatedAtB = new Date(b.updatedAt);
return updatedAtB.getTime() - updatedAtA.getTime();
})
.map((roadmap) => (
<ResourceProgress
doneCount={roadmap.done || 0}
learningCount={roadmap.learning || 0}
totalCount={roadmap.total || 0}
skippedCount={roadmap.skipped || 0}
resourceId={roadmap.id}
resourceType={'roadmap'}
updatedAt={roadmap.updatedAt}
title={roadmap.title}
onCleared={() => {
pageProgressMessage.set('Updating activity');
loadActivity().finally(() => {
pageProgressMessage.set('');
});
}}
/>
))}
{learningBestPractices
.sort((a, b) => {
const updatedAtA = new Date(a.updatedAt);
const updatedAtB = new Date(b.updatedAt);
return updatedAtB.getTime() - updatedAtA.getTime();
})
.map((bestPractice) => (
<ResourceProgress
doneCount={bestPractice.done || 0}
totalCount={bestPractice.total || 0}
learningCount={bestPractice.learning || 0}
resourceId={bestPractice.id}
skippedCount={bestPractice.skipped || 0}
resourceType={'best-practice'}
title={bestPractice.title}
updatedAt={bestPractice.updatedAt}
onCleared={() => {
pageProgressMessage.set('Updating activity');
loadActivity().finally(() => {
pageProgressMessage.set('');
});
}}
/>
))}
</div>
</>
)}
</div>
</>
);
}

View File

@@ -1,27 +0,0 @@
import RoadmapIcon from '../../icons/roadmap.svg';
export function EmptyActivity() {
return (
<div class="rounded-md">
<div class="flex flex-col items-center p-7 text-center">
<img
alt="no roadmaps"
src={RoadmapIcon}
class="mb-2 w-[60px] h-[60px] sm:h-[120px] sm:w-[120px] opacity-10"
/>
<h2 class="text-lg sm:text-xl font-bold">No Progress</h2>
<p className="my-1 sm:my-2 max-w-[400px] text-gray-500 text-sm sm:text-base">
Progress will appear here as you start tracking your{' '}
<a href="/roadmaps" class="mt-4 text-blue-500 hover:underline">
Roadmaps
</a>{' '}
or{' '}
<a href="/best-practices" class="mt-4 text-blue-500 hover:underline">
Best Practices
</a>{' '}
progress.
</p>
</div>
</div>
);
}

View File

@@ -1,150 +0,0 @@
import { useState } from 'preact/hooks';
import { httpPost } from '../../lib/http';
import { getRelativeTimeString } from '../../lib/date';
import { useToast } from '../../hooks/use-toast';
type ResourceProgressType = {
resourceType: 'roadmap' | 'best-practice';
resourceId: string;
title: string;
updatedAt: string;
totalCount: number;
doneCount: number;
learningCount: number;
skippedCount: number;
onCleared?: () => void;
showClearButton?: boolean;
};
export function ResourceProgress(props: ResourceProgressType) {
const { showClearButton = true } = props;
const toast = useToast();
const [isClearing, setIsClearing] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
const {
updatedAt,
resourceType,
resourceId,
title,
totalCount,
learningCount,
doneCount,
skippedCount,
onCleared,
} = props;
async function clearProgress() {
setIsClearing(true);
const { error, response } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-clear-resource-progress`,
{
resourceId,
resourceType,
}
);
if (error || !response) {
toast.error('Error clearing progress. Please try again.');
console.error(error);
setIsClearing(false);
return;
}
localStorage.removeItem(`${resourceType}-${resourceId}-favorite`);
localStorage.removeItem(`${resourceType}-${resourceId}-progress`);
setIsClearing(false);
setIsConfirming(false);
if (onCleared) {
onCleared();
}
}
const url =
resourceType === 'roadmap'
? `/${resourceId}`
: `/best-practices/${resourceId}`;
const totalMarked = doneCount + skippedCount;
const progressPercentage = Math.round((totalMarked / totalCount) * 100);
return (
<div>
<a
href={url}
className="group relative flex cursor-pointer items-center rounded-t-md border p-3 text-gray-600 hover:border-gray-300 hover:text-black"
>
<span
className={`absolute left-0 top-0 block h-full cursor-pointer rounded-tl-md bg-black/5 group-hover:bg-black/10`}
style={{
width: `${progressPercentage}%`,
}}
></span>
<span className="relative flex-1 cursor-pointer truncate">
{title}
</span>
<span className="ml-1 cursor-pointer text-sm text-gray-400">
{getRelativeTimeString(updatedAt)}
</span>
</a>
<p className="sm:space-between flex flex-row items-start rounded-b-md border border-t-0 px-2 py-2 text-xs text-gray-500">
<span className="hidden flex-1 gap-1 sm:flex">
{doneCount > 0 && (
<>
<span>{doneCount} done</span> &bull;
</>
)}
{learningCount > 0 && (
<>
<span>{learningCount} in progress</span> &bull;
</>
)}
{skippedCount > 0 && (
<>
<span>{skippedCount} skipped</span> &bull;
</>
)}
<span>{totalCount} total</span>
</span>
{showClearButton && (
<>
{!isConfirming && (
<button
className="text-red-500 hover:text-red-800"
onClick={() => setIsConfirming(true)}
disabled={isClearing}
>
{!isClearing && (
<>
Clear Progress <span>&times;</span>
</>
)}
{isClearing && 'Processing...'}
</button>
)}
{isConfirming && (
<span>
Are you sure?{' '}
<button
onClick={clearProgress}
className="ml-1 mr-1 text-red-500 underline hover:text-red-800"
>
Yes
</button>{' '}
<button
onClick={() => setIsConfirming(false)}
className="text-red-500 underline hover:text-red-800"
>
No
</button>
</span>
)}
</>
)}
</p>
</div>
);
}

View File

@@ -1,174 +0,0 @@
import { useRef, useState } from 'preact/hooks';
import { useOutsideClick } from '../hooks/use-outside-click';
import { OptionType, SearchSelector } from './SearchSelector';
import type { PageType } from './CommandMenu/CommandMenu';
import { CheckIcon } from './ReactIcons/CheckIcon';
import { httpPut } from '../lib/http';
import type { TeamResourceConfig } from './CreateTeam/RoadmapSelector';
import { Spinner } from './ReactIcons/Spinner';
type AddTeamRoadmapProps = {
teamId: string;
allRoadmaps: PageType[];
availableRoadmaps: PageType[];
onClose: () => void;
onMakeChanges: (roadmapId: string) => void;
setResourceConfigs: (config: TeamResourceConfig) => void;
};
export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
const {
teamId,
onMakeChanges,
onClose,
allRoadmaps,
availableRoadmaps,
setResourceConfigs,
} = props;
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [selectedRoadmap, setSelectedRoadmap] = useState<string>('');
const popupBodyEl = useRef<HTMLDivElement>(null);
async function addTeamResource(roadmapId: string) {
if (!teamId) {
return;
}
setIsLoading(true);
const { error, response } = await httpPut<TeamResourceConfig>(
`${
import.meta.env.PUBLIC_API_URL
}/v1-update-team-resource-config/${teamId}`,
{
teamId: teamId,
resourceId: roadmapId,
resourceType: 'roadmap',
removed: [],
}
);
if (error || !response) {
setError(error?.message || 'Error adding roadmap');
return;
}
setResourceConfigs(response);
}
useOutsideClick(popupBodyEl, () => {
onClose();
});
const selectedRoadmapTitle = allRoadmaps.find(
(roadmap) => roadmap.id === selectedRoadmap
)?.title;
return (
<div class="popup fixed left-0 right-0 top-0 z-50 flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
<div class="relative h-full w-full max-w-md p-4 md:h-auto">
<div
ref={popupBodyEl}
class="popup-body relative rounded-lg bg-white p-4 shadow"
>
{isLoading && (
<>
<div class="flex items-center justify-center gap-2 py-8">
<Spinner isDualRing={false} className="h-4 w-4" />
<h2 className="font-medium">Loading...</h2>
</div>
</>
)}
{!isLoading && !error && selectedRoadmap && (
<div className={'text-center'}>
<CheckIcon additionalClasses="h-10 w-10 mx-auto opacity-20 mb-3 mt-4" />
<h3 class="mb-1.5 text-2xl font-medium">
{selectedRoadmapTitle} Added
</h3>
<p className="mb-4 text-sm leading-none text-gray-400">
<button
onClick={() => onMakeChanges(selectedRoadmap)}
className="underline underline-offset-2 hover:text-gray-900"
>
Click here
</button>{' '}
to make changes to the roadmap.
</p>
<div class="flex items-center gap-2">
<button
onClick={onClose}
type="button"
class="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
>
Done
</button>
<button
onClick={() => {
setSelectedRoadmap('');
setError('');
setIsLoading(false);
}}
type="button"
class="flex-grow cursor-pointer rounded-lg bg-black py-2 text-center text-white"
>
+ Add More
</button>
</div>
</div>
)}
{!isLoading && error && (
<>
<h3 class="mb-1.5 text-2xl font-medium">Error</h3>
<p className="mb-3 text-sm leading-none text-red-400">{error}</p>
<div class="flex items-center gap-2">
<button
onClick={onClose}
type="button"
class="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
>
Cancel
</button>
</div>
</>
)}
{!isLoading && !error && !selectedRoadmap && (
<>
<h3 class="mb-1.5 text-2xl font-medium">Add Roadmap</h3>
<p className="mb-3 text-sm leading-none text-gray-400">
Search and add a roadmap
</p>
<SearchSelector
options={availableRoadmaps.map((roadmap) => ({
value: roadmap.id,
label: roadmap.title,
}))}
onSelect={(option: OptionType) => {
const roadmapId = option.value;
addTeamResource(roadmapId).finally(() => {
setIsLoading(false);
setSelectedRoadmap(roadmapId);
});
}}
inputClassName="mt-2 mb-2 block w-full rounded-md border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:border-gray-400"
placeholder={'Search for roadmap'}
/>
<div class="flex items-center gap-2">
<button
onClick={onClose}
type="button"
class="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
>
Cancel
</button>
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -26,10 +26,7 @@ const EmailLoginForm: FunctionComponent<{}> = () => {
// Log the user in and reload the page
if (response?.token) {
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
path: '/',
expires: 30,
});
Cookies.set(TOKEN_COOKIE_NAME, response.token);
window.location.reload();
return;

View File

@@ -59,10 +59,7 @@ export function GitHubButton(props: GitHubButtonProps) {
localStorage.removeItem(GITHUB_REDIRECT_AT);
localStorage.removeItem(GITHUB_LAST_PAGE);
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
path: '/',
expires: 30,
});
Cookies.set(TOKEN_COOKIE_NAME, response.token);
window.location.href = redirectUrl;
})
.catch((err) => {
@@ -90,13 +87,8 @@ export function GitHubButton(props: GitHubButtonProps) {
// For non authentication pages, we want to redirect back to the page
// the user was on before they clicked the social login button
if (!['/login', '/signup'].includes(window.location.pathname)) {
const pagePath =
window.location.pathname === '/respond-invite'
? window.location.pathname + window.location.search
: window.location.pathname;
localStorage.setItem(GITHUB_REDIRECT_AT, Date.now().toString());
localStorage.setItem(GITHUB_LAST_PAGE, pagePath);
localStorage.setItem(GITHUB_LAST_PAGE, window.location.pathname);
}
window.location.href = response.loginUrl;

View File

@@ -57,10 +57,7 @@ export function GoogleButton(props: GoogleButtonProps) {
localStorage.removeItem(GOOGLE_REDIRECT_AT);
localStorage.removeItem(GOOGLE_LAST_PAGE);
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
path: '/',
expires: 30,
});
Cookies.set(TOKEN_COOKIE_NAME, response.token);
window.location.href = redirectUrl;
})
.catch((err) => {
@@ -85,13 +82,8 @@ export function GoogleButton(props: GoogleButtonProps) {
// For non authentication pages, we want to redirect back to the page
// the user was on before they clicked the social login button
if (!['/login', '/signup'].includes(window.location.pathname)) {
const pagePath =
window.location.pathname === '/respond-invite'
? window.location.pathname + window.location.search
: window.location.pathname;
localStorage.setItem(GOOGLE_REDIRECT_AT, Date.now().toString());
localStorage.setItem(GOOGLE_LAST_PAGE, pagePath);
localStorage.setItem(GOOGLE_LAST_PAGE, window.location.pathname);
}
window.location.href = response.loginUrl;

View File

@@ -1,124 +0,0 @@
import { useEffect, useState } from 'preact/hooks';
import Cookies from 'js-cookie';
import LinkedIn from '../../icons/linkedin.svg';
import SpinnerIcon from '../../icons/spinner.svg';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
import { httpGet } from '../../lib/http';
type LinkedInButtonProps = {};
const LINKEDIN_REDIRECT_AT = 'linkedInRedirectAt';
const LINKEDIN_LAST_PAGE = 'linkedInLastPage';
export function LinkedInButton(props: LinkedInButtonProps) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const icon = isLoading ? SpinnerIcon : LinkedIn;
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
const provider = urlParams.get('provider');
if (!code || !state || provider !== 'linkedin') {
return;
}
setIsLoading(true);
httpGet<{ token: string }>(
`${import.meta.env.PUBLIC_API_URL}/v1-linkedin-callback${
window.location.search
}`
)
.then(({ response, error }) => {
if (!response?.token) {
setError(error?.message || 'Something went wrong.');
setIsLoading(false);
return;
}
let redirectUrl = '/';
const linkedInRedirectAt = localStorage.getItem(LINKEDIN_REDIRECT_AT);
const lastPageBeforeLinkedIn = localStorage.getItem(LINKEDIN_LAST_PAGE);
// If the social redirect is there and less than 30 seconds old
// redirect to the page that user was on before they clicked the github login button
if (linkedInRedirectAt && lastPageBeforeLinkedIn) {
const socialRedirectAtTime = parseInt(linkedInRedirectAt, 10);
const now = Date.now();
const timeSinceRedirect = now - socialRedirectAtTime;
if (timeSinceRedirect < 30 * 1000) {
redirectUrl = lastPageBeforeLinkedIn;
}
}
localStorage.removeItem(LINKEDIN_REDIRECT_AT);
localStorage.removeItem(LINKEDIN_LAST_PAGE);
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
path: '/',
expires: 30,
});
window.location.href = redirectUrl;
})
.catch((err) => {
setError('Something went wrong. Please try again later.');
setIsLoading(false);
});
}, []);
const handleClick = () => {
setIsLoading(true);
httpGet<{ loginUrl: string }>(
`${import.meta.env.PUBLIC_API_URL}/v1-linkedin-login`
)
.then(({ response, error }) => {
if (!response?.loginUrl) {
setError(error?.message || 'Something went wrong.');
setIsLoading(false);
return;
}
// For non authentication pages, we want to redirect back to the page
// the user was on before they clicked the social login button
if (!['/login', '/signup'].includes(window.location.pathname)) {
const pagePath =
window.location.pathname === '/respond-invite'
? window.location.pathname + window.location.search
: window.location.pathname;
localStorage.setItem(LINKEDIN_REDIRECT_AT, Date.now().toString());
localStorage.setItem(LINKEDIN_LAST_PAGE, pagePath);
}
window.location.href = response.loginUrl;
})
.catch((err) => {
setError('Something went wrong. Please try again later.');
setIsLoading(false);
});
};
return (
<>
<button
class="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
disabled={isLoading}
onClick={handleClick}
>
<img
src={icon}
alt="Google"
class={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
/>
Continue with LinkedIn
</button>
{error && (
<p className="mb-2 mt-1 text-sm font-medium text-red-600">{error}</p>
)}
</>
);
}

View File

@@ -4,7 +4,6 @@ import EmailLoginForm from './EmailLoginForm';
import Divider from './Divider.astro';
import { GitHubButton } from './GitHubButton';
import { GoogleButton } from './GoogleButton';
import { LinkedInButton } from './LinkedInButton';
---
<Popup id='login-popup' title='' subtitle=''>
@@ -20,7 +19,6 @@ import { LinkedInButton } from './LinkedInButton';
<div class='mt-7 flex flex-col gap-2'>
<GitHubButton client:load />
<GoogleButton client:load />
<LinkedInButton client:load />
</div>
<Divider />

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from 'preact/hooks';
import { httpPost } from '../../lib/http';
import Cookies from 'js-cookie';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
import {TOKEN_COOKIE_NAME} from "../../lib/jwt";
export default function ResetPasswordForm() {
const [code, setCode] = useState('');
@@ -53,10 +53,7 @@ export default function ResetPasswordForm() {
}
const token = response.token;
Cookies.set(TOKEN_COOKIE_NAME, token, {
path: '/',
expires: 30,
});
Cookies.set(TOKEN_COOKIE_NAME, token);
window.location.href = '/';
};

View File

@@ -27,10 +27,7 @@ export function TriggerVerifyAccount() {
return;
}
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
path: '/',
expires: 30,
});
Cookies.set(TOKEN_COOKIE_NAME, response.token);
window.location.href = '/';
})
.catch((err) => {

View File

@@ -32,18 +32,8 @@ function showHideGuestElements(hideOrShow: 'hide' | 'show' = 'hide') {
// Prepares the UI for the user who is logged in
function handleGuest() {
const authenticatedRoutes = [
'/account/update-profile',
'/account/notification',
'/account/update-password',
'/account/settings',
'/account/road-card',
'/account',
'/team',
'/team/progress',
'/team/roadmaps',
'/team/new',
'/team/members',
'/team/settings'
'/settings/update-profile',
'/settings/update-password',
];
showHideAuthElements('hide');

View File

@@ -2,8 +2,6 @@
import Icon from './AstroIcon.astro';
import LoginPopup from './AuthenticationFlow/LoginPopup.astro';
import BestPracticeHint from './BestPracticeHint.astro';
import { MarkFavorite } from './FeaturedItems/MarkFavorite';
import ProgressHelpPopup from './ProgressHelpPopup.astro';
export interface Props {
title: string;
@@ -17,43 +15,36 @@ const isBestPracticeReady = !isUpcoming;
---
<LoginPopup />
<ProgressHelpPopup />
<div class="border-b">
<div class="container relative py-5 sm:py-12">
<div class="mb-3 mt-0 sm:mb-6">
<h1 class="mb-0.5 text-2xl font-bold sm:mb-2 sm:text-4xl">
<div class='border-b'>
<div class='container relative py-5 sm:py-12'>
<div class='mb-3 mt-0 sm:mb-6'>
<h1 class='mb-0.5 text-2xl font-bold sm:mb-2 sm:text-4xl'>
{title}
<MarkFavorite
resourceId={bestPracticeId}
resourceType="best-practice"
className="text-gray-500 !opacity-100 hover:text-gray-600 [&>svg]:stroke-[0.4] [&>svg]:stroke-gray-400 hover:[&>svg]:stroke-gray-600 [&>svg]:h-4 [&>svg]:w-4 sm:[&>svg]:h-5 sm:[&>svg]:w-5 ml-1.5 relative focus:outline-0"
client:load
/>
</h1>
<p class="text-sm text-gray-500 sm:text-lg">{description}</p>
<p class='text-sm text-gray-500 sm:text-lg'>{description}</p>
</div>
<div class="flex justify-between">
<div class="flex gap-1 sm:gap-2">
<div class='flex justify-between'>
<div class='flex gap-1 sm:gap-2'>
<a
href="/best-practices"
class="rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm"
aria-label="Back to All Best Practices"
href='/best-practices'
class='rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm'
aria-label='Back to All Best Practices'
>
&larr;<span class="hidden sm:inline">&nbsp;All Best Practices</span>
&larr;<span class='hidden sm:inline'>&nbsp;All Best Practices</span>
</a>
{
isBestPracticeReady && (
<button
data-guest-required
data-popup="login-popup"
class="inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm"
aria-label="Download Roadmap"
data-popup='login-popup'
class='hidden inline-flex items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm'
aria-label='Download Roadmap'
>
<Icon icon="download" />
<span class="ml-2 hidden sm:inline">Download</span>
<Icon icon='download' />
<span class='ml-2 hidden sm:inline'>Download</span>
</button>
)
}
@@ -62,25 +53,25 @@ const isBestPracticeReady = !isUpcoming;
isBestPracticeReady && (
<a
data-auth-required
class="inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm"
aria-label="Download Roadmap"
class='inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm'
aria-label='Download Roadmap'
target="_blank"
href={`/pdfs/best-practices/${bestPracticeId}.pdf`}
>
<Icon icon="download" />
<span class="ml-2 hidden sm:inline">Download</span>
<Icon icon='download' />
<span class='ml-2 hidden sm:inline'>Download</span>
</a>
)
}
<button
data-guest-required
data-popup="login-popup"
class="inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm"
aria-label="Subscribe for Updates"
data-popup='login-popup'
class='inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm'
aria-label='Subscribe for Updates'
>
<Icon icon="email" />
<span class="ml-2">Subscribe</span>
<Icon icon='email' />
<span class='ml-2'>Subscribe</span>
</button>
</div>
@@ -88,13 +79,13 @@ const isBestPracticeReady = !isUpcoming;
isBestPracticeReady && (
<a
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"
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'
>
<Icon icon="comment" class="h-3 w-3" />
<span class="ml-2 hidden sm:inline">Suggest Changes</span>
<span class="ml-2 inline sm:hidden">Suggest</span>
<Icon icon='comment' class='h-3 w-3' />
<span class='ml-2 hidden sm:inline'>Suggest Changes</span>
<span class='ml-2 inline sm:hidden'>Suggest</span>
</a>
)
}

View File

@@ -1,10 +1,20 @@
---
import ResourceProgressStats from './ResourceProgressStats.astro';
export interface Props {
bestPracticeId: string;
}
---
<div class='mt-4 sm:mt-7 border-0 sm:border rounded-md mb-0 sm:-mb-[65px]'>
<ResourceProgressStats />
<!-- Desktop: Roadmap Resources - Alert -->
<div class='hidden sm:flex justify-between px-2 bg-white items-center rounded-md p-1.5'>
<p class='text-sm'>
<span class='text-yellow-900 bg-yellow-200 py-0.5 px-1 text-xs rounded-sm font-medium uppercase mr-0.5'>Tip</span>
Click the best practices for details and resources
</p>
</div>
<!-- Mobile - Roadmap resources alert -->
<p class='block sm:hidden text-sm border border-yellow-500 text-yellow-700 rounded-md py-1.5 px-2 bg-white relative'>
Click the best practices for details and resources
</p>
</div>

View File

@@ -0,0 +1,5 @@
---
---
<div class='recaptcha-field mb-2'></div>
<input type='hidden' name='g-recaptcha-response' class='recaptcha-response' />

View File

@@ -0,0 +1,36 @@
---
---
<script src='./captcha.js'></script>
<script is:inline>
window.onCaptchaLoad = function () {
if (!window.grecaptcha) {
console.warn('window.grecaptcha is not defined');
return;
}
const recaptchaFields = document.querySelectorAll('.recaptcha-field');
// render recaptcha on fields
recaptchaFields.forEach((field) => {
// If captcha already rendered for this field
if (field.hasAttribute('data-recaptcha-id')) {
return;
}
const renderedId = window.grecaptcha.render(field, {
sitekey: '6Ldn2YsjAAAAABlUxNxukAuDAUIuZIhO0hRVxzJW',
});
field.setAttribute('data-recaptcha-id', renderedId);
});
};
</script>
<script
src='https://www.google.com/recaptcha/api.js?onload=onCaptchaLoad&render=explicit'
async
defer
></script>

View File

@@ -0,0 +1,49 @@
class Captcha {
constructor() {
this.onDOMLoaded = this.onDOMLoaded.bind(this);
this.bindValidation = this.bindValidation.bind(this);
this.validateCaptchaBeforeSubmit =
this.validateCaptchaBeforeSubmit.bind(this);
}
validateCaptchaBeforeSubmit(e) {
const target = e.target;
const captchaField = target.querySelector('.recaptcha-field');
if (captchaField) {
const captchaId = captchaField.dataset.recaptchaId;
const captchaResponse = window.grecaptcha.getResponse(captchaId);
// If valid captcha is not present, prevent form submission
if (!captchaResponse) {
e.preventDefault();
alert('Please verify that you are human first');
return false;
}
target.querySelector('.recaptcha-response').value = captchaResponse;
}
target.closest('.popup').classList.add('hidden');
return true;
}
bindValidation() {
const forms = document.querySelectorAll('[captcha-form]');
forms.forEach((form) => {
form.addEventListener('submit', this.validateCaptchaBeforeSubmit);
});
}
onDOMLoaded() {
this.bindValidation();
}
init() {
window.addEventListener('DOMContentLoaded', this.onDOMLoaded);
}
}
const captcha = new Captcha();
captcha.init();

View File

@@ -1,18 +1,16 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { useKeydown } from '../../hooks/use-keydown';
import { useOutsideClick } from '../../hooks/use-outside-click';
import BestPracticesIcon from '../../icons/best-practices.svg';
import GuideIcon from '../../icons/guide.svg';
import HomeIcon from '../../icons/home.svg';
import RoadmapIcon from '../../icons/roadmap.svg';
import UserIcon from '../../icons/user.svg';
import GroupIcon from '../../icons/group.svg';
import RoadmapIcon from '../../icons/roadmap.svg';
import GuideIcon from '../../icons/guide.svg';
import VideoIcon from '../../icons/video.svg';
import { httpGet } from '../../lib/http';
import { useKeydown } from '../../hooks/use-keydown';
import { isLoggedIn } from '../../lib/jwt';
import { useOutsideClick } from '../../hooks/use-outside-click';
export type PageType = {
id: string;
type PageType = {
url: string;
title: string;
group: string;
@@ -21,51 +19,23 @@ export type PageType = {
};
const defaultPages: PageType[] = [
{ id: 'home', url: '/', title: 'Home', group: 'Pages', icon: HomeIcon },
{ url: '/', title: 'Home', group: 'Pages', icon: HomeIcon },
{
id: 'account',
url: '/account',
url: '/settings/update-profile',
title: 'Account',
group: 'Pages',
icon: UserIcon,
isProtected: true,
},
{ url: '/roadmaps', title: 'Roadmaps', group: 'Pages', icon: RoadmapIcon },
{
id: 'team',
url: '/team',
title: 'Teams',
group: 'Pages',
icon: GroupIcon,
isProtected: true,
},
{
id: 'roadmaps',
url: '/roadmaps',
title: 'Roadmaps',
group: 'Pages',
icon: RoadmapIcon,
},
{
id: 'best-practices',
url: '/best-practices',
title: 'Best Practices',
group: 'Pages',
icon: BestPracticesIcon,
},
{
id: 'guides',
url: '/guides',
title: 'Guides',
group: 'Pages',
icon: GuideIcon,
},
{
id: 'videos',
url: '/videos',
title: 'Videos',
group: 'Pages',
icon: VideoIcon,
},
{ url: '/guides', title: 'Guides', group: 'Pages', icon: GuideIcon },
{ url: '/videos', title: 'Videos', group: 'Pages', icon: VideoIcon },
];
function shouldShowPage(page: PageType) {
@@ -88,7 +58,6 @@ export function CommandMenu() {
});
useOutsideClick(modalRef, () => {
setSearchedText('');
setIsActive(false);
});
@@ -97,7 +66,6 @@ export function CommandMenu() {
setIsActive(true);
}
getAllPages();
window.addEventListener(`command.k`, handleToggleTopic);
return () => {
window.removeEventListener(`command.k`, handleToggleTopic);
@@ -160,7 +128,7 @@ export function CommandMenu() {
autofocus={true}
type="text"
value={searchedText}
className="w-full rounded-t-md border-b p-4 text-sm focus:bg-gray-50 focus:outline-none"
className="w-full rounded-t-md border-b p-4 text-sm focus:bg-gray-50 focus:outline-0"
placeholder="Search roadmaps, guides or pages .."
autocomplete="off"
onInput={(e) => {
@@ -179,7 +147,6 @@ export function CommandMenu() {
} else if (e.key === 'Tab') {
e.preventDefault();
} else if (e.key === 'Escape') {
setSearchedText('');
setIsActive(false);
} else if (e.key === 'Enter') {
const activePage = searchResults[activeCounter];
@@ -218,7 +185,7 @@ export function CommandMenu() {
<span class="mr-2 text-gray-400">{page.group}</span>
)}
{page.icon && (
<img alt={page.title} src={page.icon} class="mr-2 h-4 w-4" />
<img src={page.icon} class="mr-2 h-4 w-4" />
)}
{page.title}
</a>

View File

@@ -1,216 +0,0 @@
import { useEffect, useState } from 'preact/hooks';
import { Stepper } from '../Stepper';
import { Step0, ValidTeamType } from './Step0';
import { Step1, ValidTeamSize } from './Step1';
import { Step2 } from './Step2';
import { httpGet } from '../../lib/http';
import { getUrlParams, setUrlParams } from '../../lib/browser';
import { pageProgressMessage } from '../../stores/page';
import type { TeamResourceConfig } from './RoadmapSelector';
import { Step3 } from './Step3';
import { Step4 } from './Step4';
import {useToast} from "../../hooks/use-toast";
export interface TeamDocument {
_id?: string;
name: string;
avatar?: string;
creatorId: string;
links: {
website?: string;
github?: string;
linkedIn?: string;
};
type: ValidTeamType;
canMemberSendInvite: boolean;
teamSize?: ValidTeamSize;
createdAt: Date;
updatedAt: Date;
}
export function CreateTeamForm() {
// Can't use hook `useParams` because it runs asynchronously
const { s: queryStepIndex, t: teamId } = getUrlParams();
const toast = useToast();
const [team, setTeam] = useState<TeamDocument>();
const [loadingTeam, setLoadingTeam] = useState(!!teamId && !team?._id);
const [stepIndex, setStepIndex] = useState(0);
async function loadTeam(
teamIdToFetch: string,
requiredStepIndex: number | string
) {
const { response, error } = await httpGet<TeamDocument>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-team/${teamIdToFetch}`
);
if (error || !response) {
toast.error(error?.message || 'Error loading team');
window.location.href = '/account';
return;
}
const requiredStepIndexNumber = parseInt(requiredStepIndex as string, 10);
const completedSteps = Array(requiredStepIndexNumber)
.fill(1)
.map((_, counter) => counter);
setTeam(response);
setSelectedTeamType(response.type);
setCompletedSteps(completedSteps);
setStepIndex(requiredStepIndexNumber);
await loadTeamResourceConfig(teamIdToFetch);
}
const [teamResourceConfig, setTeamResourceConfig] =
useState<TeamResourceConfig>([]);
async function loadTeamResourceConfig(teamId: string) {
const { error, response } = await httpGet<TeamResourceConfig>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-resource-config/${teamId}`
);
if (error || !Array.isArray(response)) {
console.error(error);
return;
}
setTeamResourceConfig(response);
}
useEffect(() => {
if (!teamId || !queryStepIndex || team) {
return;
}
pageProgressMessage.set('Fetching team');
setLoadingTeam(true);
loadTeam(teamId, queryStepIndex).finally(() => {
setLoadingTeam(false);
pageProgressMessage.set('');
});
// fetch team and move to step
}, [teamId, queryStepIndex]);
const [selectedTeamType, setSelectedTeamType] = useState<ValidTeamType>(
team?.type || 'company'
);
const [completedSteps, setCompletedSteps] = useState([0]);
if (loadingTeam) {
return null;
}
let stepForm = null;
if (stepIndex === 0) {
stepForm = (
<Step0
team={team}
selectedTeamType={selectedTeamType}
setSelectedTeamType={setSelectedTeamType}
onStepComplete={() => {
if (team?._id) {
setUrlParams({ t: team._id, s: '1' });
}
setCompletedSteps([0]);
setStepIndex(1);
}}
/>
);
} else if (stepIndex === 1) {
stepForm = (
<Step1
team={team}
onBack={() => {
if (team?._id) {
setUrlParams({ t: team._id, s: '0' });
}
setStepIndex(0);
}}
onStepComplete={(team: TeamDocument) => {
const createdTeamId = team._id!;
setUrlParams({ t: createdTeamId, s: '2' });
setCompletedSteps([0, 1]);
setStepIndex(2);
setTeam(team);
}}
selectedTeamType={selectedTeamType}
/>
);
} else if (stepIndex === 2) {
stepForm = (
<Step2
team={team!}
teamResourceConfig={teamResourceConfig}
setTeamResourceConfig={setTeamResourceConfig}
onBack={() => {
if (team) {
setUrlParams({ t: team._id!, s: '1' });
}
setStepIndex(1);
}}
onNext={() => {
setUrlParams({ t: teamId!, s: '3' });
setCompletedSteps([0, 1, 2]);
setStepIndex(3);
}}
/>
);
} else if (stepIndex === 3) {
stepForm = (
<Step3
team={team}
onBack={() => {
if (team) {
setUrlParams({ t: team._id!, s: '2' });
}
setStepIndex(2);
}}
onNext={() => {
if (team) {
setUrlParams({ t: team._id!, s: '4' });
}
setCompletedSteps([0, 1, 2, 3]);
setStepIndex(4);
}}
/>
);
} else if (stepIndex === 4) {
stepForm = <Step4 team={team!} />;
}
return (
<div className={'mx-auto max-w-[700px] py-6'}>
<div className={'mb-8 flex flex-col items-center'}>
<h1 className={'text-4xl font-bold'}>Create Team</h1>
<p className={'mt-2 text-gray-500'}>
Complete the steps below to create your team
</p>
</div>
<div className="mb-8 mt-8 flex w-full">
<Stepper
activeIndex={stepIndex}
completeSteps={completedSteps}
steps={[
{ label: 'Type' },
{ label: 'Details' },
{ label: 'Skills' },
{ label: 'Members' },
]}
/>
</div>
{stepForm}
</div>
);
}

View File

@@ -1,44 +0,0 @@
import { Spinner } from '../ReactIcons/Spinner';
type NextButtonProps = {
isLoading?: boolean;
loadingMessage?: string;
text: string;
hasNextArrow?: boolean;
onClick?: () => void;
type?: string;
};
export function NextButton(props: NextButtonProps) {
const {
isLoading = false,
text = 'Next Step',
type = 'button',
loadingMessage = 'Please wait ..',
onClick = () => null,
hasNextArrow = true,
} = props;
return (
<button
type={type}
onClick={onClick}
disabled={isLoading}
className={
'rounded-md border border-black bg-black px-4 py-2 text-white disabled:opacity-50'
}
>
{isLoading ? (
<span className={'flex items-center justify-center'}>
<Spinner />
<span className="ml-2">{loadingMessage}</span>
</span>
) : (
<>
{text}
{hasNextArrow && <span className="ml-1">&rarr;</span>}
</>
)}
</button>
);
}

View File

@@ -1,221 +0,0 @@
import { useEffect, useState } from 'preact/hooks';
import { SearchSelector } from '../SearchSelector';
import { httpGet, httpPut } from '../../lib/http';
import type { PageType } from '../CommandMenu/CommandMenu';
import SearchIcon from '../../icons/search.svg';
import { pageProgressMessage } from '../../stores/page';
import type { TeamDocument } from './CreateTeamForm';
import { UpdateTeamResourceModal } from './UpdateTeamResourceModal';
export type TeamResourceConfig = {
resourceId: string;
resourceType: string;
removed: string[];
}[];
type RoadmapSelectorProps = {
team: TeamDocument;
teamResourceConfig: TeamResourceConfig;
setTeamResourceConfig: (config: TeamResourceConfig) => void;
};
export function RoadmapSelector(props: RoadmapSelectorProps) {
const { team, teamResourceConfig = [], setTeamResourceConfig } = props;
const [allRoadmaps, setAllRoadmaps] = useState<PageType[]>([]);
const [changingRoadmapId, setChangingRoadmapId] = useState<string>('');
const [error, setError] = useState<string>('');
async function loadAllRoadmaps() {
const { error, response } = await httpGet<PageType[]>(`/pages.json`);
if (error) {
setError(error.message || 'Something went wrong. Please try again!');
return;
}
if (!response) {
return [];
}
const allRoadmaps = response
.filter((page) => page.group === 'Roadmaps')
.sort((a, b) => {
if (a.title === 'Android') return 1;
return a.title.localeCompare(b.title);
});
setAllRoadmaps(allRoadmaps);
return response;
}
async function deleteResource(roadmapId: string) {
if (!team?._id) {
return;
}
pageProgressMessage.set(`Deleting resource`);
const { error, response } = await httpPut<TeamResourceConfig>(
`${import.meta.env.PUBLIC_API_URL}/v1-delete-team-resource-config/${
team._id
}`,
{
resourceId: roadmapId,
resourceType: 'roadmap',
}
);
if (error || !response) {
setError(error?.message || 'Error deleting roadmap');
return;
}
setTeamResourceConfig(response);
}
async function onRemove(resourceId: string) {
pageProgressMessage.set('Removing roadmap');
deleteResource(resourceId).finally(() => {
pageProgressMessage.set('');
});
}
async function addTeamResource(roadmapId: string) {
if (!team?._id) {
return;
}
pageProgressMessage.set(`Adding roadmap to team`);
const { error, response } = await httpPut<TeamResourceConfig>(
`${import.meta.env.PUBLIC_API_URL}/v1-update-team-resource-config/${
team._id
}`,
{
teamId: team._id,
resourceId: roadmapId,
resourceType: 'roadmap',
removed: [],
}
);
if (error || !response) {
setError(error?.message || 'Error adding roadmap');
return;
}
setTeamResourceConfig(response);
}
useEffect(() => {
loadAllRoadmaps().finally();
}, []);
return (
<div>
{changingRoadmapId && (
<UpdateTeamResourceModal
onClose={() => setChangingRoadmapId('')}
resourceId={changingRoadmapId}
resourceType={'roadmap'}
teamId={team?._id!}
setTeamResourceConfig={setTeamResourceConfig}
defaultRemovedItems={
teamResourceConfig.find((c) => c.resourceId === changingRoadmapId)
?.removed || []
}
/>
)}
<SearchSelector
placeholder={`Search Roadmaps ..`}
onSelect={(option) => {
const roadmapId = option.value;
addTeamResource(roadmapId).finally(() => {
pageProgressMessage.set('');
});
}}
options={allRoadmaps
.filter((roadmap) => {
return !teamResourceConfig
.map((c) => c.resourceId)
.includes(roadmap.id);
})
.map((roadmap) => ({
value: roadmap.id,
label: roadmap.title,
}))}
searchInputId={'roadmap-input'}
inputClassName="mt-2 block w-full rounded-md border px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
/>
{!teamResourceConfig.length && (
<div className="mt-4 rounded-md border px-4 py-12 text-center text-sm text-gray-700">
<img
alt={'search'}
src={SearchIcon}
className={'mx-auto mb-5 h-[42px] w-[42px] opacity-10'}
/>
<span className="block text-lg font-semibold text-black">
No roadmaps selected.
</span>
<p className={'text-sm text-gray-400'}>
Please search and add roadmaps from above
</p>
</div>
)}
{teamResourceConfig.length > 0 && (
<div className="mt-4 grid grid-cols-3 flex-wrap gap-2.5">
{teamResourceConfig.map(({ resourceId, removed: removedTopics }) => {
const roadmapTitle =
allRoadmaps.find((roadmap) => roadmap.id === resourceId)?.title ||
'...';
return (
<div className="flex flex-col items-start rounded-md border border-gray-300">
<div className={'w-full px-3 pb-2 pt-4'}>
<span className="mb-0.5 block text-base font-medium leading-none text-black">
{roadmapTitle}
</span>
{removedTopics.length > 0 ? (
<span className={'text-xs leading-none text-gray-900'}>
{removedTopics.length} topic
{removedTopics.length > 1 ? 's' : ''} removed
</span>
) : (
<span className="text-xs italic leading-none text-gray-400/60">
No changes made ..
</span>
)}
</div>
<div className={'flex w-full justify-between p-3'}>
<button
type="button"
className={
'text-xs text-gray-500 underline hover:text-black focus:outline-none'
}
onClick={() => setChangingRoadmapId(resourceId)}
>
Customize
</button>
<button
type="button"
className={
'text-xs text-red-500 underline hover:text-black'
}
onClick={() => onRemove(resourceId)}
>
Remove
</button>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -1,135 +0,0 @@
import { ChevronDownIcon } from '../ReactIcons/ChevronDownIcon';
import { useRef, useState } from 'preact/hooks';
import { useOutsideClick } from '../../hooks/use-outside-click';
const allowedRoles = [
{
name: 'Admin',
value: 'admin',
description: 'Can do everything',
},
{
name: 'Manager',
value: 'manager',
description: 'Can manage team and skills',
},
{
name: 'Member',
value: 'member',
description: 'Can view team and skills',
},
] as const;
export type AllowedRoles = (typeof allowedRoles)[number]['value'];
type RoleDropdownProps = {
className?: string;
selectedRole: string;
setSelectedRole: (role: AllowedRoles) => void;
};
export function RoleDropdown(props: RoleDropdownProps) {
const { selectedRole, setSelectedRole, className = 'w-[120px]' } = props;
const dropdownRef = useRef(null);
const [activeRoleIndex, setActiveRoleIndex] = useState(0);
const [isMenuOpen, setIsMenuOpen] = useState(false);
useOutsideClick(dropdownRef, () => {
setIsMenuOpen(false);
});
return (
<div className={`relative ${className}`}>
<button
type={'button'}
onKeyDown={(e) => {
const isUpOrDown = e.key === 'ArrowUp' || e.key === 'ArrowDown';
if (isUpOrDown && !isMenuOpen) {
e.preventDefault();
setIsMenuOpen(true);
return;
}
const isEnter = e.key === 'Enter';
if (isEnter && isMenuOpen) {
e.preventDefault();
setSelectedRole(allowedRoles[activeRoleIndex].value);
setIsMenuOpen(false);
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveRoleIndex((prev) => {
const nextIndex = prev + 1;
if (nextIndex >= allowedRoles.length) {
return 0;
}
return nextIndex;
});
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveRoleIndex((prev) => {
const nextIndex = prev - 1;
if (nextIndex < 0) {
return allowedRoles.length - 1;
}
return nextIndex;
});
}
}}
onClick={() => setIsMenuOpen(!isMenuOpen)}
className={`flex h-full w-full cursor-default items-center justify-between rounded-md border px-4 ${
isMenuOpen ? 'border-gray-300 bg-gray-100' : ''
}`}
>
<span
className={`capitalize ${
selectedRole === 'admin' ? 'text-blue-600' : ''
} ${selectedRole === 'manager' ? 'text-cyan-600' : ''}`}
>
{selectedRole || 'Select Role'}
</span>
<ChevronDownIcon
className={'relative top-0.5 ml-2 h-4 w-4 text-gray-400'}
/>
</button>
{isMenuOpen && (
<div
className="absolute z-10 mt-1 w-[200px] rounded-md border bg-white shadow-md"
ref={dropdownRef}
>
<div
className="py-1"
role="menu"
aria-orientation="vertical"
aria-labelledby="options-menu"
>
{allowedRoles.map((allowedRole, roleCounter) => (
<button
key={allowedRole.value}
type={'button'}
className={`w-full cursor-default px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 ${
roleCounter === activeRoleIndex ? 'bg-gray-100' : 'bg-white'
}`}
role="menuitem"
onClick={() => {
setIsMenuOpen(false);
setSelectedRole(allowedRole.value);
}}
>
<span className="block font-medium">{allowedRole.name}</span>
<span className="block text-xs text-gray-400">
{allowedRole.description}
</span>
</button>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,122 +0,0 @@
import BuildingIcon from '../../icons/building.svg';
import UsersIcon from '../../icons/users.svg';
import type { TeamDocument } from './CreateTeamForm';
import { httpPut } from '../../lib/http';
import { useState } from 'preact/hooks';
import { NextButton } from './NextButton';
export const validTeamTypes = [
{
value: 'company',
label: 'Company',
icon: BuildingIcon,
description: 'Use roadmap.sh for your company',
},
{
value: 'study_group',
label: 'Study Group',
icon: UsersIcon,
description: 'Invite your friends and learn together',
},
] as const;
export type ValidTeamType = (typeof validTeamTypes)[number]['value'];
type Step0Props = {
team?: TeamDocument;
selectedTeamType: ValidTeamType;
setSelectedTeamType: (teamType: ValidTeamType) => void;
onStepComplete: () => void;
};
export function Step0(props: Step0Props) {
const { team, selectedTeamType, onStepComplete, setSelectedTeamType } = props;
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string>();
async function onNextClick() {
if (!team) {
onStepComplete();
return;
}
setIsLoading(true);
setError('');
const { response, error } = await httpPut(
`${import.meta.env.PUBLIC_API_URL}/v1-update-team/${team._id}`,
{
name: team.name,
website: team?.links?.website || undefined,
type: selectedTeamType,
gitHubUrl: team?.links?.github || undefined,
...(selectedTeamType === 'company' && {
teamSize: team.teamSize,
linkedInUrl: team?.links?.linkedIn || undefined,
}),
}
);
if (error || !response) {
setIsLoading(false);
setError(error?.message || 'Something went wrong');
return;
}
setIsLoading(false);
setError('');
onStepComplete();
}
return (
<>
<div className={'flex flex-row gap-3'}>
{validTeamTypes.map((validTeamType) => (
<button
className={`flex flex-grow flex-col items-center rounded-lg border px-5 py-12 ${
validTeamType.value == selectedTeamType
? 'border-gray-400 bg-gray-100'
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-50'
}`}
onClick={() => setSelectedTeamType(validTeamType.value)}
>
<img
alt={validTeamType.label}
src={validTeamType.icon}
className={`mb-3 h-12 w-12 opacity-10 ${
validTeamType.value === selectedTeamType ? 'opacity-100' : ''
}`}
/>
<span className="mb-1 block text-2xl font-bold">
{validTeamType.label}
</span>
<span className="text-sm text-gray-500">
{validTeamType.description}
</span>
</button>
))}
</div>
{/*Error message*/}
{error && <div className="mt-4 text-sm text-red-500">{error}</div>}
<div className="mt-4 flex flex-row items-center justify-between gap-2">
<a
href="/account"
className={
'rounded-md border border-red-400 bg-white px-8 py-2 text-red-500'
}
>
Cancel
</a>
<NextButton
type={'button'}
onClick={onNextClick}
isLoading={isLoading}
text={'Next Step'}
loadingMessage={'Updating team ..'}
/>
</div>
</>
);
}

View File

@@ -1,252 +0,0 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { AppError, httpPost, httpPut } from '../../lib/http';
import type { ValidTeamType } from './Step0';
import type { TeamDocument } from './CreateTeamForm';
import { NextButton } from './NextButton';
export const validTeamSizes = [
'0-1',
'2-10',
'11-50',
'51-200',
'201-500',
'501-1000',
'1000+',
] as const;
export type ValidTeamSize = (typeof validTeamSizes)[number];
type Step1Props = {
team?: TeamDocument;
selectedTeamType: ValidTeamType;
onStepComplete: (team: TeamDocument) => void;
onBack: () => void;
};
export function Step1(props: Step1Props) {
const { team, selectedTeamType, onBack, onStepComplete } = props;
const [error, setError] = useState('');
const nameRef = useRef<HTMLElement>(null);
useEffect(() => {
if (!nameRef.current) {
return;
}
nameRef.current.focus();
}, [nameRef]);
const [isLoading, setIsLoading] = useState(false);
const [name, setName] = useState(team?.name || '');
const [website, setWebsite] = useState(team?.links?.website || '');
const [linkedInUrl, setLinkedInUrl] = useState(team?.links?.linkedIn || '');
const [gitHubUrl, setGitHubUrl] = useState(team?.links?.github || '');
const [teamSize, setTeamSize] = useState<ValidTeamSize>(
team?.teamSize || ('' as any)
);
const handleSubmit = async (e: Event) => {
e.preventDefault();
setIsLoading(true);
if (!name || !selectedTeamType) {
setIsLoading(false);
return;
}
let response: TeamDocument | undefined;
let error: AppError | undefined;
if (!team?._id) {
({ response, error } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-create-team`,
{
name,
website: website || undefined,
type: selectedTeamType,
gitHubUrl: gitHubUrl || undefined,
...(selectedTeamType === 'company' && {
teamSize,
linkedInUrl: linkedInUrl || undefined,
}),
roadmapIds: [],
bestPracticeIds: [],
}
));
if (error || !response?._id) {
setError(error?.message || 'Something went wrong. Please try again.');
setIsLoading(false);
return;
}
onStepComplete(response as TeamDocument);
} else {
({ response, error } = await httpPut(
`${import.meta.env.PUBLIC_API_URL}/v1-update-team/${team._id}`,
{
name,
website: website || undefined,
type: selectedTeamType,
gitHubUrl: gitHubUrl || undefined,
...(selectedTeamType === 'company' && {
teamSize,
linkedInUrl: linkedInUrl || undefined,
}),
}
));
if (error || (response as any)?.status !== 'ok') {
setError(error?.message || 'Something went wrong. Please try again.');
setIsLoading(false);
return;
}
onStepComplete({
...team,
name,
_id: team._id,
links: {
website: website || team?.links?.website,
linkedIn: linkedInUrl || team?.links?.linkedIn,
github: gitHubUrl || team?.links?.github,
},
type: selectedTeamType,
teamSize: teamSize!,
});
}
};
return (
<form onSubmit={handleSubmit}>
<div className="flex w-full flex-col">
<label
for="name"
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
>
{selectedTeamType === 'company' ? 'Company Name' : 'Group Name'}
</label>
<input
type="text"
name="name"
ref={nameRef as any}
autofocus={true}
id="name"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="roadmap.sh"
disabled={isLoading}
required
value={name}
onInput={(e) => setName((e.target as HTMLInputElement).value)}
/>
</div>
{selectedTeamType === 'company' && (
<div className="mt-4 flex w-full flex-col">
<label
for="website"
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
>
Website
</label>
<input
type="url"
name="website"
required
id="website"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="https://roadmap.sh"
disabled={isLoading}
value={website}
onInput={(e) => setWebsite((e.target as HTMLInputElement).value)}
/>
</div>
)}
{selectedTeamType === 'company' && (
<div className="mt-4 flex w-full flex-col">
<label for="website" className="text-sm leading-none text-slate-500">
LinkedIn URL
</label>
<input
type="url"
name="website"
id="website"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="https://www.linkedin.com/company/roadmapsh"
disabled={isLoading}
value={linkedInUrl}
onInput={(e) =>
setLinkedInUrl((e.target as HTMLInputElement).value)
}
/>
</div>
)}
<div className="mt-4 flex w-full flex-col">
<label for="website" className="text-sm leading-none text-slate-500">
GitHub Organization URL
</label>
<input
type="url"
name="website"
id="website"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="https://github.com/roadmapsh"
disabled={isLoading}
value={gitHubUrl}
onInput={(e) => setGitHubUrl((e.target as HTMLInputElement).value)}
/>
</div>
{selectedTeamType === 'company' && (
<div className="mt-4 flex w-full flex-col">
<label
for="team-size"
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
>
Company Size
</label>
<select
name="team-size"
id="team-size"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
required={selectedTeamType === 'company'}
disabled={isLoading}
value={teamSize}
onChange={(e) =>
setTeamSize((e.target as HTMLSelectElement).value as any)
}
>
<option value="" selected>
Select team size
</option>
{validTeamSizes.map((size) => (
<option value={size}>{size} people</option>
))}
</select>
</div>
)}
<div className="mt-4 flex flex-row items-center justify-between gap-2">
<button
type="button"
onClick={onBack}
className={
'rounded-md border border-red-400 bg-white px-4 py-2 text-red-500'
}
>
<span className="mr-1">&larr;</span>
Previous Step
</button>
<NextButton
isLoading={isLoading}
text={'Next Step'}
type={'submit'}
loadingMessage={'Creating team ..'}
/>
</div>
</form>
);
}

View File

@@ -1,59 +0,0 @@
import { RoadmapSelector, TeamResourceConfig } from './RoadmapSelector';
import type { TeamDocument } from './CreateTeamForm';
type Step2Props = {
team: TeamDocument;
teamResourceConfig: TeamResourceConfig;
setTeamResourceConfig: (config: TeamResourceConfig) => void;
onBack: () => void;
onNext: () => void;
};
export function Step2(props: Step2Props) {
const { team, onBack, onNext, teamResourceConfig, setTeamResourceConfig } =
props;
return (
<>
<div className="mt-4 flex w-full flex-col">
<div className="mb-1 mt-2">
<h2 className="mb-2 text-2xl font-bold">Select Roadmaps</h2>
<p className="text-sm text-gray-700">
Picks the roadmaps to be made available to your team for tracking.
You can always add more later.
</p>
</div>
<RoadmapSelector
team={team}
teamResourceConfig={teamResourceConfig}
setTeamResourceConfig={setTeamResourceConfig}
/>
</div>
<div className="mt-4 flex flex-row items-center justify-between gap-2">
<button
type="button"
onClick={onBack}
className={
'rounded-md border border-red-400 bg-white px-4 py-2 text-red-500'
}
>
<span className="mr-1">&larr;</span>
Previous Step
</button>
<button
type="submit"
disabled={teamResourceConfig.length === 0}
onClick={onNext}
className={
'rounded-md border bg-black px-4 py-2 text-white disabled:opacity-50'
}
>
Next Step
<span className="ml-1">&rarr;</span>
</button>
</div>
</>
);
}

View File

@@ -1,198 +0,0 @@
import type { TeamDocument } from './CreateTeamForm';
import { NextButton } from './NextButton';
import { TrashIcon } from '../ReactIcons/TrashIcon';
import { AllowedRoles, RoleDropdown } from './RoleDropdown';
import { useEffect, useRef, useState } from 'preact/hooks';
import { httpPost } from '../../lib/http';
type Step3Props = {
team?: TeamDocument;
onNext: () => void;
onBack: () => void;
};
type InviteType = {
id: string;
email: string;
role: AllowedRoles;
};
function generateId() {
return `${new Date().getTime()}`;
}
export function Step3(props: Step3Props) {
const { onNext, onBack, team } = props;
const [error, setError] = useState('');
const [invitingTeam, setInvitingTeam] = useState(false);
const emailInputRef = useRef(null);
const [users, setUsers] = useState<InviteType[]>([
{
id: generateId(),
email: '',
role: 'member',
},
]);
async function inviteTeam() {
setInvitingTeam(true);
const { error, response } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-invite-team/${team?._id}`,
{
members: users,
}
);
if (error || !response) {
setError(error?.message || 'Something went wrong');
setInvitingTeam(false);
return;
}
onNext();
}
function focusLastEmailInput() {
if (!emailInputRef.current) {
return;
}
(emailInputRef.current as HTMLInputElement).focus();
}
function onSubmit(e: any) {
e.preventDefault();
inviteTeam().finally(() => null);
}
useEffect(() => {
focusLastEmailInput();
}, [users.length]);
return (
<form className="mt-4 flex w-full flex-col" onSubmit={onSubmit}>
<div class="mb-1 mt-2">
<h2 class="mb-2 text-2xl font-bold">Invite your Team</h2>
<p class="text-sm text-gray-700">
Use the form below to invite your team members to your team. You can
also invite them later.
</p>
</div>
<div className="mt-4 flex flex-col gap-1">
{users.map((user, userCounter) => {
return (
<div className="flex flex-row gap-2" key={user.id}>
<input
ref={userCounter === users.length - 1 ? emailInputRef : null}
autofocus={true}
type="email"
name="email"
required
id="email"
placeholder="Email"
value={user.email}
onChange={(e) => {
const newUsers = users.map((u) => {
if (u.id === user.id) {
return {
...u,
email: (e.target as HTMLInputElement)?.value,
};
}
return u;
});
setUsers(newUsers);
}}
className="flex-grow rounded-md border border-gray-200 bg-white px-4 py-2 text-gray-900"
/>
<RoleDropdown
selectedRole={user.role}
setSelectedRole={(role: AllowedRoles) => {
const newUsers = users.map((u) => {
if (u.id === user.id) {
return {
...u,
role,
};
}
return u;
});
setUsers(newUsers);
}}
/>
<button
disabled={users.length <= 1}
type="button"
className="rounded-md border border-red-200 bg-white px-4 py-2 text-red-500 hover:bg-red-100 disabled:opacity-30"
onClick={() => {
setUsers(users.filter((u) => u.id !== user.id));
}}
>
<TrashIcon className="h-5 w-5" />
</button>
</div>
);
})}
</div>
{users.length <= 30 && (
<button
onClick={() => {
setUsers([
...users,
{ id: generateId(), email: '', role: 'member' },
]);
}}
type="button"
className="mt-2 rounded-md border border-dashed border-gray-400 py-2 text-sm text-gray-500 hover:border-gray-500 hover:text-gray-800"
>
+ Add another
</button>
)}
{error && (
<div className="mt-2 text-sm font-medium text-red-500" role="alert">
{error}
</div>
)}
<div className="mt-4 flex flex-row items-center justify-between gap-2">
<button
type="button"
onClick={onBack}
className={
'rounded-md border border-red-400 bg-white px-4 py-2 text-red-500'
}
>
<span className="mr-1">&larr;</span>
Previous Step
</button>
<div className={'flex gap-2'}>
<button
type="button"
onClick={onNext}
className={
'rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-500 hover:border-gray-400 hover:text-black'
}
>
Skip for Now
</button>
<NextButton
type={'submit'}
isLoading={invitingTeam}
text={'Send Invites'}
loadingMessage={'Updating team ..'}
hasNextArrow={false}
/>
</div>
</div>
</form>
);
}

View File

@@ -1,26 +0,0 @@
import { CheckIcon } from '../ReactIcons/CheckIcon';
import type { TeamDocument } from './CreateTeamForm';
type Step4Props = {
team: TeamDocument;
};
export function Step4({ team }: Step4Props) {
return (
<div className="mt-4 flex flex-col rounded-xl border py-12 text-center">
<div class="mb-1 flex flex-col items-center">
<CheckIcon additionalClasses={'h-14 w-14 mb-4 opacity-100'} />
<h2 class="mb-2 text-2xl font-bold">Team Created</h2>
<p class="text-sm text-gray-700">
Your team has been created. Happy learning!
</p>
<a
href={`/team/progress?t=${team._id}`}
class="mt-4 rounded-md bg-black px-5 py-1.5 text-sm text-white"
>
View Team
</a>
</div>
</div>
);
}

View File

@@ -1,206 +0,0 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { wireframeJSONToSVG } from 'roadmap-renderer';
import { Spinner } from '../ReactIcons/Spinner';
import { httpGet, httpPut } from '../../lib/http';
import { renderTopicProgress } from '../../lib/resource-progress';
import '../FrameRenderer/FrameRenderer.css';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { useKeydown } from '../../hooks/use-keydown';
import type { TeamResourceConfig } from './RoadmapSelector';
import { useToast } from '../../hooks/use-toast';
export type ProgressMapProps = {
teamId: string;
resourceId: string;
resourceType: 'roadmap' | 'best-practice';
defaultRemovedItems?: string[];
setTeamResourceConfig: (config: TeamResourceConfig) => void;
onClose: () => void;
};
export function UpdateTeamResourceModal(props: ProgressMapProps) {
const {
defaultRemovedItems = [],
resourceId,
resourceType,
teamId,
setTeamResourceConfig,
onClose,
} = props;
const containerEl = useRef<HTMLDivElement>(null);
const popupBodyEl = useRef<HTMLDivElement>(null);
const toast = useToast();
const [isLoading, setIsLoading] = useState(true);
const [isUpdating, setIsUpdating] = useState(false);
const [removedItems, setRemovedItems] =
useState<string[]>(defaultRemovedItems);
useEffect(() => {
function onTopicClick(e: any) {
const groupEl = e.target.closest('.clickable-group');
const groupId = groupEl?.dataset?.groupId;
if (!groupId) {
return;
}
const normalizedGroupId = groupId.replace(/^\d+-/, '');
if (removedItems.includes(normalizedGroupId)) {
setRemovedItems((prev) =>
prev.filter((id) => id !== normalizedGroupId)
);
renderTopicProgress(normalizedGroupId, 'reset' as any);
} else {
setRemovedItems((prev) => [...prev, normalizedGroupId]);
renderTopicProgress(normalizedGroupId, 'removed');
}
}
document.addEventListener('click', onTopicClick);
return () => {
document.removeEventListener('click', onTopicClick);
};
}, [removedItems]);
let resourceJsonUrl = 'https://roadmap.sh';
if (resourceType === 'roadmap') {
resourceJsonUrl += `/${resourceId}.json`;
} else {
resourceJsonUrl += `/best-practices/${resourceId}.json`;
}
async function renderResource(jsonUrl: string) {
const res = await fetch(jsonUrl);
const json = await res.json();
const svg = await wireframeJSONToSVG(json, {
fontURL: '/fonts/balsamiq.woff2',
});
containerEl.current?.replaceChildren(svg);
// Render team configuration
removedItems.forEach((topicId: string) => {
renderTopicProgress(topicId, 'removed');
});
}
useKeydown('Escape', () => {
onClose();
});
useOutsideClick(popupBodyEl, () => {
onClose();
});
async function onSaveChanges() {
if (removedItems.length === 0) {
return;
}
setIsUpdating(true);
const { error, response } = await httpPut<TeamResourceConfig>(
`${
import.meta.env.PUBLIC_API_URL
}/v1-update-team-resource-config/${teamId}`,
{
teamId: teamId,
resourceId: resourceId,
resourceType: resourceType,
removed: removedItems,
}
);
if (error || !response) {
toast.error(error?.message || 'Error adding roadmap');
return;
}
setTeamResourceConfig(response);
onClose();
}
useEffect(() => {
if (
!containerEl.current ||
!resourceJsonUrl ||
!resourceId ||
!resourceType ||
!teamId
) {
return;
}
renderResource(resourceJsonUrl)
.catch((err) => {
console.error(err);
toast.error('Something went wrong. Please try again!');
})
.finally(() => {
setIsLoading(false);
});
}, []);
return (
<div class="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 class="relative mx-auto h-full w-full max-w-4xl p-4 md:h-auto">
<div
ref={popupBodyEl}
class="popup-body relative rounded-lg bg-white shadow"
>
<div
className={
'sticky top-0 mb-3 rounded-2xl border-4 border-white bg-black p-4'
}
>
<p className="mb-2 text-gray-300">
Click and select the items to remove from the roadmap.
</p>
<div className="flex flex-row items-center gap-1.5">
<button
disabled={removedItems.length === 0}
onClick={() =>
onSaveChanges().finally(() => setIsUpdating(false))
}
className={
'rounded-md bg-blue-600 px-2.5 py-1.5 text-sm text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-400'
}
>
{isUpdating ? (
<span className={'flex items-center gap-1.5'}>
<Spinner
className="h-3 w-3"
innerFill="white"
isDualRing={false}
/>{' '}
Saving ..
</span>
) : (
'Save Changes'
)}
</button>
<button
onClick={onClose}
className="rounded-md bg-gray-600 px-2.5 py-1.5 text-sm text-white hover:bg-gray-700"
>
Cancel
</button>
</div>
</div>
<div ref={containerEl} className="px-4"></div>
{isLoading && (
<div class="flex w-full justify-center">
<Spinner
isDualRing={false}
className="mb-4 mt-2 h-4 w-4 animate-spin fill-blue-600 text-gray-200 sm:h-8 sm:w-8"
/>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,16 +0,0 @@
---
import DeleteAccountPopup from "./DeleteAccountPopup.astro";
---
<DeleteAccountPopup />
<h2 class='text-xl font-bold sm:text-2xl'>Delete Account</h2>
<p class='mt-2 text-gray-400'>
Permanently remove your account from the roadmap.sh. This cannot be undone and all your progress and data will be lost.
</p>
<button
data-popup='delete-account-popup'
class="mt-4 w-full rounded-lg bg-red-600 py-2 text-base font-regular text-white outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-1"
>
Delete Account
</button>

View File

@@ -1,89 +0,0 @@
import {useEffect, useState} from 'preact/hooks';
import { httpDelete } from '../../lib/http';
import { logout } from '../Navigation/navigation';
export function DeleteAccountForm() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [confirmationText, setConfirmationText] = useState('');
useEffect(() => {
setError('');
setConfirmationText('');
}, [])
const handleSubmit = async (e: Event) => {
e.preventDefault();
setIsLoading(true);
setError('');
if (confirmationText.toUpperCase() !== 'DELETE') {
setError('Verification text does not match');
setIsLoading(false);
return;
}
const { response, error } = await httpDelete(
`${import.meta.env.PUBLIC_API_URL}/v1-delete-account`
);
if (error || !response) {
setIsLoading(false);
setError(error?.message || 'Something went wrong');
return;
}
logout();
};
const handleClosePopup = () => {
setIsLoading(false);
setError('');
setConfirmationText('');
const deleteAccountPopup = document.getElementById('delete-account-popup');
deleteAccountPopup?.classList.add('hidden');
deleteAccountPopup?.classList.remove('flex');
};
return (
<form onSubmit={handleSubmit}>
<div className="my-4">
<input
type="text"
name="delete-account"
id="delete-account"
className="mt-2 block w-full rounded-md border border-gray-300 py-2 px-3 outline-none placeholder:text-gray-400 focus:border-gray-400"
placeholder={'Type "delete" to confirm'}
required
autoFocus
value={confirmationText}
onInput={(e) =>
setConfirmationText((e.target as HTMLInputElement).value)
}
/>
{error && (
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">{error}</p>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={isLoading}
onClick={handleClosePopup}
className="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading || confirmationText.toUpperCase() !== 'DELETE'}
className="flex-grow cursor-pointer rounded-lg bg-red-500 py-2 text-white disabled:opacity-40"
>
{isLoading ? 'Please wait ..' : 'Confirm'}
</button>
</div>
</form>
);
}

View File

@@ -1,17 +0,0 @@
---
import Popup from '../Popup/Popup.astro';
import { DeleteAccountForm } from './DeleteAccountForm';
---
<Popup id='delete-account-popup' title='Delete Account' subtitle=''>
<div class='-mt-2.5'>
<p>
This will permanently delete your account and all your associated data
including your progress.
</p>
<p class="text-black font-medium -mb-2 mt-3 text-base">Please type "delete" to confirm.</p>
<DeleteAccountForm client:only />
</div>
</Popup>

View File

@@ -1,131 +0,0 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { httpDelete } from '../lib/http';
import type { TeamDocument } from './CreateTeam/CreateTeamForm';
import { useTeamId } from '../hooks/use-team-id';
import { useOutsideClick } from '../hooks/use-outside-click';
import { useKeydown } from '../hooks/use-keydown';
type DeleteTeamPopupProps = {
onClose: () => void;
};
export function DeleteTeamPopup(props: DeleteTeamPopupProps) {
const { onClose } = props;
const popupBodyEl = useRef<HTMLDivElement>(null);
const inputEl = useRef<HTMLInputElement>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [confirmationText, setConfirmationText] = useState('');
const { teamId } = useTeamId();
useOutsideClick(popupBodyEl, () => {
onClose();
});
useKeydown('Escape', () => {
onClose();
});
useEffect(() => {
inputEl.current?.focus();
}, []);
const handleSubmit = async (e: Event) => {
e.preventDefault();
setIsLoading(true);
setError('');
if (confirmationText.toUpperCase() !== 'DELETE') {
setError('Verification text does not match');
setIsLoading(false);
return;
}
const { response, error } = await httpDelete<TeamDocument>(
`${import.meta.env.PUBLIC_API_URL}/v1-delete-team/${teamId}`
);
if (error || !response) {
setIsLoading(false);
setError(error?.message || 'Something went wrong');
return;
}
window.location.href = '/account';
};
const handleClosePopup = () => {
setIsLoading(false);
setError('');
setConfirmationText('');
onClose();
};
return (
<>
<div class="fixed left-0 right-0 top-0 z-50 flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
<div class="relative h-full w-full max-w-md p-4 md:h-auto">
<div
ref={popupBodyEl}
class="popup-body relative rounded-lg bg-white p-4 shadow"
>
<p>
This will permanently delete your account and all your associated
data including your progress.
</p>
<p class="-mb-2 mt-3 text-base font-medium text-black">
Please type "delete" to confirm.
</p>
<form onSubmit={handleSubmit}>
<div className="my-4">
<input
ref={inputEl}
type="text"
name="delete-account"
id="delete-account"
className="mt-2 block w-full rounded-md border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:border-gray-400"
placeholder={'Type "delete" to confirm'}
required
autoFocus
value={confirmationText}
onInput={(e) =>
setConfirmationText((e.target as HTMLInputElement).value)
}
/>
{error && (
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">
{error}
</p>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={isLoading}
onClick={handleClosePopup}
className="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center"
>
Cancel
</button>
<button
type="submit"
disabled={
isLoading || confirmationText.toUpperCase() !== 'DELETE'
}
className="flex-grow cursor-pointer rounded-lg bg-red-500 py-2 text-white disabled:opacity-40"
>
{isLoading ? 'Please wait ..' : 'Confirm'}
</button>
</div>
</form>
</div>
</div>
</div>
</>
);
}

View File

@@ -1,45 +0,0 @@
type FavoriteIconProps = {
isFavorite?: boolean;
};
export function FavoriteIcon(props: FavoriteIconProps) {
const { isFavorite } = props;
if (!isFavorite) {
return (
<svg
width="8"
height="10"
viewBox="0 0 8 10"
fill="none"
className="h-3.5 w-3.5"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M5.93682 0.5H2.06282C1.63546 0.500094 1.22423 0.663195 0.912987 0.956045C0.601741 1.2489 0.413919 1.64944 0.387822 2.076L0.00182198 8.461C-0.012178 8.6905 0.0548218 8.9185 0.191822 9.104L0.242322 9.1665C0.575322 9.5485 1.15132 9.6165 1.56582 9.31L3.99982 7.5115L6.43382 9.31C6.58413 9.42115 6.76305 9.48708 6.94954 9.50006C7.13603 9.51303 7.32235 9.4725 7.4866 9.38323C7.65085 9.29397 7.78621 9.15967 7.87677 8.99613C7.96733 8.83258 8.00932 8.64659 7.99782 8.46L7.61232 2.0765C7.58622 1.64981 7.39835 1.24914 7.08701 0.956192C6.77567 0.663248 6.36431 0.500094 5.93682 0.5ZM5.93682 1.25C6.42732 1.25 6.83382 1.632 6.86382 2.122L7.24932 8.506C7.25216 8.55018 7.24229 8.59425 7.22089 8.63301C7.19949 8.67176 7.16745 8.70359 7.12854 8.72472C7.08964 8.74585 7.0455 8.75542 7.00134 8.75228C6.95718 8.74914 6.91484 8.73343 6.87932 8.707L4.27582 6.783C4.19591 6.72397 4.09917 6.69211 3.99982 6.69211C3.90047 6.69211 3.80373 6.72397 3.72382 6.783L1.11982 8.707C1.0843 8.73343 1.04196 8.74914 0.9978 8.75228C0.953639 8.75542 0.909502 8.74585 0.8706 8.72472C0.831697 8.70359 0.799653 8.67176 0.778252 8.63301C0.756851 8.59425 0.746986 8.55018 0.749822 8.506L1.13632 2.122C1.16632 1.632 1.57232 1.25 2.06282 1.25H5.93682Z"
fill="currentColor"
/>
</svg>
);
}
return (
<svg
width="8"
height="10"
viewBox="0 0 8 10"
className="h-3.5 w-3.5"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M5.93682 0.5H2.06282C1.63546 0.500094 1.22423 0.663195 0.912987 0.956045C0.601741 1.2489 0.413919 1.64944 0.387822 2.076L0.00182198 8.461C-0.012178 8.6905 0.0548218 8.9185 0.191822 9.104L0.242322 9.1665C0.575322 9.5485 1.15132 9.6165 1.56582 9.31L3.99982 7.5115L6.43382 9.31C6.58413 9.42115 6.76305 9.48708 6.94954 9.50006C7.13603 9.51303 7.32235 9.4725 7.4866 9.38323C7.65085 9.29397 7.78621 9.15967 7.87677 8.99613C7.96733 8.83258 8.00932 8.64659 7.99782 8.46L7.61232 2.0765C7.58622 1.64981 7.39835 1.24914 7.08701 0.956192C6.77567 0.663248 6.36431 0.500094 5.93682 0.5Z"
fill="currentColor"
/>
</svg>
);
}

View File

@@ -1,6 +1,4 @@
---
import AstroIcon from '../AstroIcon.astro';
import { MarkFavorite } from './MarkFavorite';
export interface FeaturedItemType {
isUpcoming?: boolean;
isNew?: boolean;
@@ -15,29 +13,23 @@ const { isUpcoming = false, isNew = false, text, url } = Astro.props;
<a
class:list={[
'group border border-slate-800 bg-slate-900 p-2.5 sm:p-3.5 block no-underline rounded-lg relative text-slate-400 font-regular text-md hover:border-slate-600 hover:text-slate-100 overflow-hidden',
'group border border-slate-800 bg-slate-900 p-2.5 sm:p-3.5 block no-underline rounded-lg relative text-slate-400 font-regular text-md hover:border-slate-600 hover:text-slate-100',
{
'opacity-50': isUpcoming,
},
]}
href={url}
>
<span class='relative z-20 text-slate-400'>
<span class='text-slate-400'>
{text}
</span>
<MarkFavorite
resourceId={url.split('/').pop()!}
resourceType={url.includes('best-practices') ? 'best-practice' : 'roadmap'}
client:load
/>
{
isNew && (
<span class='absolute bottom-1.5 right-2 flex items-center rounded-br rounded-tl text-xs font-medium text-purple-300'>
<span class='mr-1.5 flex h-2 w-2'>
<span class='absolute inline-flex h-2 w-2 animate-ping rounded-full bg-purple-400 opacity-75' />
<span class='relative inline-flex h-2 w-2 rounded-full bg-purple-500' />
<span class='absolute bottom-1.5 right-2 text-xs font-medium rounded-br rounded-tl text-purple-300 flex items-center'>
<span class='flex h-2 w-2 mr-1.5'>
<span class='animate-ping absolute inline-flex h-2 w-2 rounded-full bg-purple-400 opacity-75' />
<span class='relative inline-flex rounded-full h-2 w-2 bg-purple-500' />
</span>
New
</span>
@@ -46,17 +38,13 @@ const { isUpcoming = false, isNew = false, text, url } = Astro.props;
{
isUpcoming && (
<span class='absolute bottom-1.5 right-2 flex items-center rounded-br rounded-tl text-xs font-medium text-slate-500'>
<span class='mr-1.5 flex h-2 w-2'>
<span class='absolute inline-flex h-2 w-2 animate-ping rounded-full bg-slate-500 opacity-75' />
<span class='relative inline-flex h-2 w-2 rounded-full bg-slate-600' />
<span class='absolute bottom-1.5 right-2 text-xs font-medium rounded-br rounded-tl text-slate-500 flex items-center'>
<span class='flex h-2 w-2 mr-1.5'>
<span class='animate-ping absolute inline-flex h-2 w-2 rounded-full bg-slate-500 opacity-75' />
<span class='relative inline-flex rounded-full h-2 w-2 bg-slate-600' />
</span>
Upcoming
</span>
)
}
<span
data-progress
class='absolute bottom-0 left-0 top-0 z-10 w-0 bg-[#172a3a] transition-[width] duration-300'
></span>
</a>

View File

@@ -1,104 +0,0 @@
import { useEffect, useState } from 'preact/hooks';
import { httpPatch } from '../../lib/http';
import type { ResourceType } from '../../lib/resource-progress';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
import { FavoriteIcon } from './FavoriteIcon';
import { Spinner } from '../ReactIcons/Spinner';
import { useToast } from '../../hooks/use-toast';
type MarkFavoriteType = {
resourceType: ResourceType;
resourceId: string;
favorite?: boolean;
className?: string;
};
export function MarkFavorite({
resourceId,
resourceType,
favorite,
className,
}: MarkFavoriteType) {
const toast = useToast();
const [isLoading, setIsLoading] = useState(false);
const [isFavorite, setIsFavorite] = useState(
favorite || false
);
async function toggleFavoriteHandler(e: Event) {
e.preventDefault();
if (!isLoggedIn()) {
showLoginPopup();
return;
}
if (isLoading) {
return;
}
setIsLoading(true);
const { error } = await httpPatch<{ status: 'ok' }>(
`${import.meta.env.PUBLIC_API_URL}/v1-mark-favorite`,
{
resourceType,
resourceId,
}
);
if (error) {
setIsLoading(false);
toast.error('Failed to update favorite status');
return;
}
// Dispatching an event instead of setting the state because
// MarkFavorite component is used in the HeroSection as well
// as featured items section. We will let the custom event
// listener set the update `useEffect`
window.dispatchEvent(
new CustomEvent('mark-favorite', {
detail: {
resourceId,
resourceType,
isFavorite: !isFavorite,
},
})
);
window.dispatchEvent(new CustomEvent('refresh-favorites', {}));
setIsLoading(false);
}
useEffect(() => {
const listener = (e: Event) => {
const {
resourceId: id,
resourceType: type,
isFavorite: fav,
} = (e as CustomEvent).detail;
if (id === resourceId && type === resourceType) {
setIsFavorite(fav);
}
};
window.addEventListener('mark-favorite', listener);
return () => {
window.removeEventListener('mark-favorite', listener);
};
}, []);
return (
<button
onClick={toggleFavoriteHandler}
tabIndex={-1}
className={`${isFavorite ? '' : 'opacity-30 hover:opacity-100'} ${
className || 'absolute right-1.5 top-1.5 z-30 focus:outline-0'
}`}
>
{isLoading ? <Spinner /> : <FavoriteIcon isFavorite={isFavorite} />}
</button>
);
}

View File

@@ -1,5 +1,4 @@
---
import AstroIcon from './AstroIcon.astro';
import Icon from './AstroIcon.astro';
---
@@ -25,8 +24,10 @@ import Icon from './AstroIcon.astro';
href='/videos'>Videos</a
>
<a
target='_blank'
rel='noopener noreferrer nofollow'
class='border-b border-b-gray-700 px-2 py-1.5 transition-colors hover:text-white sm:border-b-0 sm:px-0 sm:py-0'
href='/about'>FAQs</a
href='https://cottonbureau.com/people/roadmapsh'>Store</a
>
<a
class='px-2 py-1.5 transition-colors hover:text-white sm:border-b-0 sm:px-0 sm:py-0'
@@ -63,24 +64,11 @@ import Icon from './AstroIcon.astro';
<p>
&copy; roadmap.sh
<span class='mx-1.5'>&middot;</span>
<a href='/about' class='hover:text-white'>FAQs</a>
<span class='mx-1.5'>&middot;</span>
<a href='/terms' class='hover:text-white'>Terms</a>
<span class='mx-1.5'>&middot;</span>
<a href='/privacy' class='hover:text-white'>Privacy</a>
<span class='mx-1.5'>&middot;</span>
<a
href='https://youtube.com/theroadmap?sub_confirmation=1'
target='_blank'
class='hover:text-white'
>
<AstroIcon icon='youtube' class='inline-block h-5 w-5' />
</a>
<a
href='https://twitter.com/roadmapsh'
target='_blank'
class='ml-2 hover:text-white'
>
<AstroIcon icon='twitter-fill' class='inline-block h-5 w-5 fill-current' />
</a>
</p>
</div>
</div>

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