mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2026-03-13 18:21:57 +08:00
Compare commits
1 Commits
chore/cach
...
feat/roadm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd6888a61d |
25
.github/ISSUE_TEMPLATE/01-suggest-changes.yml
vendored
25
.github/ISSUE_TEMPLATE/01-suggest-changes.yml
vendored
@@ -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
|
||||
42
.github/ISSUE_TEMPLATE/02-bug-report.yml
vendored
42
.github/ISSUE_TEMPLATE/02-bug-report.yml
vendored
@@ -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
|
||||
12
.github/ISSUE_TEMPLATE/03-feature-suggestion.yml
vendored
12
.github/ISSUE_TEMPLATE/03-feature-suggestion.yml
vendored
@@ -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
|
||||
@@ -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
|
||||
12
.github/ISSUE_TEMPLATE/05-something-else.yml
vendored
12
.github/ISSUE_TEMPLATE/05-something-else.yml
vendored
@@ -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
|
||||
14
.github/ISSUE_TEMPLATE/config.yml
vendored
14
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -26,28 +26,27 @@
|
||||
"@astrojs/tailwind": "^3.1.3",
|
||||
"@fingerprintjs/fingerprintjs": "^3.4.1",
|
||||
"@nanostores/preact": "^0.5.0",
|
||||
"astro": "^2.6.6",
|
||||
"astro": "^2.6.3",
|
||||
"astro-compress": "^1.1.47",
|
||||
"jose": "^4.14.4",
|
||||
"js-cookie": "^3.0.5",
|
||||
"nanostores": "^0.9.2",
|
||||
"nanostores": "^0.9.1",
|
||||
"node-html-parser": "^6.1.5",
|
||||
"npm-check-updates": "^16.10.12",
|
||||
"preact": "^10.15.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.35.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-tailwindcss": "^0.3.0"
|
||||
|
||||
1944
pnpm-lock.yaml
generated
1944
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: 102 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 550 KiB |
10
readme.md
10
readme.md
@@ -42,7 +42,6 @@ Here is the list of available roadmaps with more being actively worked upon.
|
||||
- [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 +53,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)
|
||||
@@ -95,12 +93,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
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
---
|
||||
import AstroIcon from './AstroIcon.astro';
|
||||
import { TeamDropdown } from './TeamDropdown/TeamDropdown';
|
||||
|
||||
const { activePageId, activePageTitle } = Astro.props;
|
||||
|
||||
export interface Props {
|
||||
activePageId: string;
|
||||
activePageTitle: string;
|
||||
hasDesktopSidebar?: boolean;
|
||||
}
|
||||
|
||||
const { hasDesktopSidebar = true, activePageId, activePageTitle } = Astro.props;
|
||||
|
||||
const sidebarLinks = [
|
||||
{
|
||||
href: '/account',
|
||||
@@ -42,12 +40,12 @@ const sidebarLinks = [
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/account/settings',
|
||||
title: 'Settings',
|
||||
id: 'settings',
|
||||
href: '/account/update-password',
|
||||
title: 'Security',
|
||||
id: 'change-password',
|
||||
isNew: false,
|
||||
icon: {
|
||||
glyph: 'cog',
|
||||
glyph: 'security',
|
||||
classes: 'h-4 w-4',
|
||||
},
|
||||
},
|
||||
@@ -66,17 +64,6 @@ const sidebarLinks = [
|
||||
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;
|
||||
@@ -104,52 +91,48 @@ const sidebarLinks = [
|
||||
|
||||
<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 />
|
||||
<aside class='hidden w-44 shrink-0 border-r border-slate-200 py-10 md:block'>
|
||||
<nav>
|
||||
<ul class='space-y-1'>
|
||||
{
|
||||
sidebarLinks.map((sidebarLink) => {
|
||||
const isActive = activePageId === sidebarLink.id;
|
||||
|
||||
<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>
|
||||
|
||||
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}
|
||||
{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>
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</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 }]}>
|
||||
<div class='grow px-0 py-0 md:px-10 md:py-10'>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ResourceProgress } from './ResourceProgress';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { EmptyActivity } from './EmptyActivity';
|
||||
|
||||
export type ActivityResponse = {
|
||||
type ActivityResponse = {
|
||||
done: {
|
||||
today: number;
|
||||
total: number;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
@@ -12,13 +11,10 @@ type ResourceProgressType = {
|
||||
doneCount: number;
|
||||
learningCount: number;
|
||||
skippedCount: number;
|
||||
onCleared?: () => void;
|
||||
showClearButton?: boolean;
|
||||
onCleared: () => void;
|
||||
};
|
||||
|
||||
export function ResourceProgress(props: ResourceProgressType) {
|
||||
const { showClearButton = true } = props;
|
||||
const toast = useToast();
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
|
||||
@@ -45,20 +41,17 @@ export function ResourceProgress(props: ResourceProgressType) {
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error('Error clearing progress. Please try again.');
|
||||
alert('Error clearing progress. Please try again.');
|
||||
console.error(error);
|
||||
setIsClearing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.removeItem(`${resourceType}-${resourceId}-favorite`);
|
||||
localStorage.removeItem(`${resourceType}-${resourceId}-progress`);
|
||||
|
||||
console.log(`${resourceType}-${resourceId}-progress`);
|
||||
setIsClearing(false);
|
||||
setIsConfirming(false);
|
||||
if (onCleared) {
|
||||
onCleared();
|
||||
}
|
||||
onCleared();
|
||||
}
|
||||
|
||||
const url =
|
||||
@@ -107,42 +100,38 @@ export function ResourceProgress(props: ResourceProgressType) {
|
||||
)}
|
||||
<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>×</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isClearing && 'Processing...'}
|
||||
</button>
|
||||
{!isConfirming && (
|
||||
<button
|
||||
className="text-red-500 hover:text-red-800"
|
||||
onClick={() => setIsConfirming(true)}
|
||||
disabled={isClearing}
|
||||
>
|
||||
{!isClearing && (
|
||||
<>
|
||||
Clear Progress <span>×</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
{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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -90,13 +90,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;
|
||||
|
||||
@@ -85,13 +85,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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -33,17 +33,9 @@ function showHideGuestElements(hideOrShow: 'hide' | 'show' = 'hide') {
|
||||
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'
|
||||
];
|
||||
|
||||
showHideAuthElements('hide');
|
||||
|
||||
@@ -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'
|
||||
>
|
||||
←<span class="hidden sm:inline"> All Best Practices</span>
|
||||
←<span class='hidden sm:inline'> 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -6,13 +6,11 @@ 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 VideoIcon from '../../icons/video.svg';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
|
||||
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',
|
||||
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) {
|
||||
@@ -218,7 +188,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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">→</span>}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">←</span>
|
||||
Previous Step
|
||||
</button>
|
||||
<NextButton
|
||||
isLoading={isLoading}
|
||||
text={'Next Step'}
|
||||
type={'submit'}
|
||||
loadingMessage={'Creating team ..'}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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">←</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">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">←</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
---
|
||||
import AstroIcon from '../AstroIcon.astro';
|
||||
import { MarkFavorite } from './MarkFavorite';
|
||||
export interface FeaturedItemType {
|
||||
isUpcoming?: boolean;
|
||||
isNew?: boolean;
|
||||
@@ -22,22 +20,16 @@ const { isUpcoming = false, isNew = false, text, url } = Astro.props;
|
||||
]}
|
||||
href={url}
|
||||
>
|
||||
<span class='relative z-20 text-slate-400'>
|
||||
<span class='text-slate-400 relative z-20'>
|
||||
{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,14 @@ 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>
|
||||
<span data-progress class="z-10 bg-[#172a3a] absolute top-0 left-0 bottom-0 duration-300 transition-[width] w-0"></span>
|
||||
</a>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
© roadmap.sh
|
||||
<span class='mx-1.5'>·</span>
|
||||
<a href='/about' class='hover:text-white'>FAQs</a>
|
||||
<span class='mx-1.5'>·</span>
|
||||
<a href='/terms' class='hover:text-white'>Terms</a>
|
||||
<span class='mx-1.5'>·</span>
|
||||
<a href='/privacy' class='hover:text-white'>Privacy</a>
|
||||
<span class='mx-1.5'>·</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>
|
||||
|
||||
@@ -79,19 +79,6 @@ svg .clickable-group.done[data-group-id^='check:'] rect {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
svg .removed rect {
|
||||
fill: #fdfdfd !important;
|
||||
stroke: #c4c4c4 !important;
|
||||
}
|
||||
|
||||
svg .removed text {
|
||||
fill: #9c9c9c !important;
|
||||
}
|
||||
|
||||
svg .removed g, svg .removed circle, svg .removed path {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/************************************
|
||||
Aspect ratio implementation
|
||||
*************************************/
|
||||
|
||||
@@ -2,19 +2,13 @@ import { wireframeJSONToSVG } from 'roadmap-renderer';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import {
|
||||
refreshProgressCounters,
|
||||
renderResourceProgress,
|
||||
renderTopicProgress,
|
||||
ResourceProgressType,
|
||||
ResourceType,
|
||||
updateResourceProgress,
|
||||
} from '../../lib/resource-progress';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
|
||||
export class Renderer {
|
||||
resourceId: string;
|
||||
resourceType: ResourceType | string;
|
||||
resourceType: string;
|
||||
jsonUrl: string;
|
||||
loaderHTML: string | null;
|
||||
|
||||
@@ -34,10 +28,8 @@ export class Renderer {
|
||||
this.onDOMLoaded = this.onDOMLoaded.bind(this);
|
||||
this.jsonToSvg = this.jsonToSvg.bind(this);
|
||||
this.handleSvgClick = this.handleSvgClick.bind(this);
|
||||
this.handleSvgRightClick = this.handleSvgRightClick.bind(this);
|
||||
this.prepareConfig = this.prepareConfig.bind(this);
|
||||
this.switchRoadmap = this.switchRoadmap.bind(this);
|
||||
this.updateTopicStatus = this.updateTopicStatus.bind(this);
|
||||
}
|
||||
|
||||
get loaderEl() {
|
||||
@@ -169,57 +161,6 @@ export class Renderer {
|
||||
this.jsonToSvg(newJsonUrl)?.then(() => {});
|
||||
}
|
||||
|
||||
updateTopicStatus(topicId: string, newStatus: ResourceProgressType) {
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
pageProgressMessage.set('Updating progress');
|
||||
updateResourceProgress(
|
||||
{
|
||||
resourceId: this.resourceId,
|
||||
resourceType: this.resourceType as ResourceType,
|
||||
topicId,
|
||||
},
|
||||
newStatus
|
||||
)
|
||||
.then(() => {
|
||||
renderTopicProgress(topicId, newStatus);
|
||||
refreshProgressCounters();
|
||||
})
|
||||
.catch((err) => {
|
||||
alert('Something went wrong, please try again.');
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
handleSvgRightClick(e: any) {
|
||||
const targetGroup = e.target?.closest('g') || {};
|
||||
const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : '';
|
||||
if (!groupId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetGroup.classList.contains('removed')) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const isCurrentStatusDone = targetGroup.classList.contains('done');
|
||||
const normalizedGroupId = groupId.replace(/^\d+-/, '');
|
||||
this.updateTopicStatus(
|
||||
normalizedGroupId,
|
||||
!isCurrentStatusDone ? 'done' : 'pending'
|
||||
);
|
||||
}
|
||||
|
||||
handleSvgClick(e: any) {
|
||||
const targetGroup = e.target?.closest('g') || {};
|
||||
const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : '';
|
||||
@@ -228,20 +169,6 @@ export class Renderer {
|
||||
}
|
||||
|
||||
e.stopImmediatePropagation();
|
||||
let status: ResourceProgressType = 'pending';
|
||||
if (targetGroup.classList.contains('done')) {
|
||||
status = 'done';
|
||||
} else if (targetGroup.classList.contains('learning')) {
|
||||
status = 'learning';
|
||||
} else if (targetGroup.classList.contains('skipped')) {
|
||||
status = 'skipped';
|
||||
} else if (targetGroup.classList.contains('removed')) {
|
||||
status = 'removed';
|
||||
}
|
||||
|
||||
if (status === 'removed') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (/^ext_link/.test(groupId)) {
|
||||
const externalLink = groupId.replace('ext_link:', '');
|
||||
@@ -273,7 +200,6 @@ export class Renderer {
|
||||
topicId: groupId.replace('check:', ''),
|
||||
resourceType: this.resourceType,
|
||||
resourceId: this.resourceId,
|
||||
status,
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -283,35 +209,12 @@ export class Renderer {
|
||||
// Remove sorting prefix from groupId
|
||||
const normalizedGroupId = groupId.replace(/^\d+-/, '');
|
||||
|
||||
const isCurrentStatusLearning = targetGroup.classList.contains('learning');
|
||||
const isCurrentStatusSkipped = targetGroup.classList.contains('skipped');
|
||||
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this.updateTopicStatus(
|
||||
normalizedGroupId,
|
||||
!isCurrentStatusLearning ? 'learning' : 'pending'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.altKey) {
|
||||
e.preventDefault();
|
||||
this.updateTopicStatus(
|
||||
normalizedGroupId,
|
||||
!isCurrentStatusSkipped ? 'skipped' : 'pending'
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(`${this.resourceType}.topic.click`, {
|
||||
detail: {
|
||||
topicId: normalizedGroupId,
|
||||
resourceId: this.resourceId,
|
||||
resourceType: this.resourceType,
|
||||
status,
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -320,7 +223,7 @@ export class Renderer {
|
||||
init() {
|
||||
window.addEventListener('DOMContentLoaded', this.onDOMLoaded);
|
||||
window.addEventListener('click', this.handleSvgClick);
|
||||
window.addEventListener('contextmenu', this.handleSvgRightClick);
|
||||
// window.addEventListener('contextmenu', this.handleSvgClick);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ export function CheckIcon(props: CheckIconProps) {
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={`relative ${additionalClasses}`}
|
||||
className={`relative ${additionalClasses}]`}
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
stroke-width="0"
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CheckIcon } from '../ReactIcons/CheckIcon';
|
||||
import { CheckIcon } from './CheckIcon';
|
||||
|
||||
type EmptyProgressProps = {
|
||||
title?: string;
|
||||
|
||||
@@ -7,7 +7,6 @@ export type UserProgressResponse = {
|
||||
resourceId: string;
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
resourceTitle: string;
|
||||
isFavorite: boolean;
|
||||
done: number;
|
||||
learning: number;
|
||||
skipped: number;
|
||||
@@ -26,16 +25,6 @@ function renderProgress(progressList: UserProgressResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('mark-favorite', {
|
||||
detail: {
|
||||
resourceId: progress.resourceId,
|
||||
resourceType: progress.resourceType,
|
||||
isFavorite: progress.isFavorite,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const totalDone = progress.done + progress.skipped;
|
||||
const percentageDone = (totalDone / progress.total) * 100;
|
||||
|
||||
@@ -72,7 +61,6 @@ export function FavoriteRoadmaps() {
|
||||
|
||||
async function loadProgress() {
|
||||
setIsLoading(true);
|
||||
|
||||
const { response: progressList, error } =
|
||||
await httpGet<UserProgressResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-all-progress`
|
||||
@@ -96,11 +84,6 @@ export function FavoriteRoadmaps() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('refresh-favorites', loadProgress);
|
||||
return () => window.removeEventListener('refresh-favorites', loadProgress);
|
||||
}, []);
|
||||
|
||||
if (isPreparing) {
|
||||
return null;
|
||||
}
|
||||
@@ -114,9 +97,10 @@ export function FavoriteRoadmaps() {
|
||||
}`}
|
||||
>
|
||||
<div className="container min-h-full">
|
||||
{isLoading && <EmptyProgress title="Loading progress .." />}
|
||||
{!isLoading && progress.length == 0 && <EmptyProgress />}
|
||||
{progress.length > 0 && (
|
||||
<ProgressList progress={progress} isLoading={isLoading} />
|
||||
{!isLoading && progress.length > 0 && (
|
||||
<ProgressList progress={progress} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,31 +1,22 @@
|
||||
import type { UserProgressResponse } from './FavoriteRoadmaps';
|
||||
import { CheckIcon } from '../ReactIcons/CheckIcon';
|
||||
import { MarkFavorite } from '../FeaturedItems/MarkFavorite';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import { CheckIcon } from './CheckIcon';
|
||||
|
||||
type ProgressListProps = {
|
||||
progress: UserProgressResponse;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export function ProgressList(props: ProgressListProps) {
|
||||
const { progress, isLoading = false } = props;
|
||||
const { progress } = props;
|
||||
|
||||
return (
|
||||
<div className="relative pb-12 pt-4 sm:pt-7">
|
||||
<div className="relative pt-4 sm:pt-7 pb-12">
|
||||
<p className="mb-4 flex items-center text-sm text-gray-400">
|
||||
{!isLoading && (
|
||||
<CheckIcon additionalClasses={'mr-1.5 w-[14px] h-[14px]'} />
|
||||
)}
|
||||
{isLoading && (
|
||||
<span className="mr-1.5">
|
||||
<Spinner />
|
||||
</span>
|
||||
)}
|
||||
Your progress and favorite roadmaps.
|
||||
<CheckIcon additionalClasses={'mr-1.5 w-[14px] h-[14px]'} />
|
||||
<span className='hidden sm:inline'>Your progress and favorite roadmaps.</span>
|
||||
<span className='inline sm:hidden'>Your progress and favorite roadmaps.</span>
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{progress.map((resource) => {
|
||||
const url =
|
||||
resource.resourceType === 'roadmap'
|
||||
@@ -37,21 +28,15 @@ export function ProgressList(props: ProgressListProps) {
|
||||
|
||||
return (
|
||||
<a
|
||||
key={resource.resourceId}
|
||||
href={url}
|
||||
className="relative flex flex-col overflow-hidden rounded-md border border-slate-800 bg-slate-900 p-3 text-sm text-slate-400 hover:border-slate-600 hover:text-slate-300"
|
||||
className="relative flex flex-col rounded-md border border-slate-800 bg-slate-900 p-3 text-sm text-slate-400 hover:border-slate-600 hover:text-slate-300 overflow-hidden"
|
||||
>
|
||||
<span className="relative z-20">{resource.resourceTitle}</span>
|
||||
<span className='relative z-20'>{resource.resourceTitle}</span>
|
||||
|
||||
<span
|
||||
class="absolute bottom-0 left-0 top-0 z-10 bg-[#172a3a]"
|
||||
style={{ width: `${percentageDone}%` }}
|
||||
></span>
|
||||
<MarkFavorite
|
||||
resourceId={resource.resourceId}
|
||||
resourceType={resource.resourceType}
|
||||
favorite={resource.isFavorite}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -18,7 +18,7 @@ import Icon from '../AstroIcon.astro';
|
||||
</button>
|
||||
|
||||
<div
|
||||
class='absolute right-0 z-50 mt-2 hidden w-48 rounded-md bg-slate-800 py-1 shadow-xl'
|
||||
class='absolute right-0 z-10 mt-2 hidden w-48 rounded-md bg-slate-800 py-1 shadow-xl'
|
||||
data-account-dropdown
|
||||
>
|
||||
<ul>
|
||||
|
||||
@@ -17,7 +17,6 @@ function bindEvents() {
|
||||
|
||||
// If the user clicks on the logout button, remove the token cookie
|
||||
if (dataset.logoutButton !== undefined) {
|
||||
e.preventDefault();
|
||||
logout();
|
||||
} else if (dataset.showMobileNav !== undefined) {
|
||||
document.querySelector('[data-mobile-nav]')?.classList.remove('hidden');
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { httpGet, httpPatch, httpPost } from '../../lib/http';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import type { TeamMemberDocument } from '../TeamMembers/TeamMembersPage';
|
||||
import XIcon from '../../icons/close-dark.svg';
|
||||
import AcceptIcon from '../../icons/accept.svg';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
interface NotificationList extends TeamMemberDocument {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function NotificationPage() {
|
||||
const toast = useToast();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [notifications, setNotifications] = useState<NotificationList[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const lostNotifications = async () => {
|
||||
const { error, response } = await httpGet<NotificationList[]>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-invitation-list`
|
||||
);
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
setNotifications(response);
|
||||
};
|
||||
|
||||
async function respondInvitation(status: 'accept' | 'reject', inviteId: string) {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
const { response, error } = await httpPatch<{ teamId: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-respond-invite/${inviteId}`, {
|
||||
status
|
||||
});
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Something went wrong')
|
||||
setIsLoading(false)
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 'accept') {
|
||||
window.location.href = `/team/progress?t=${response.teamId}`;
|
||||
} else {
|
||||
window.dispatchEvent(new CustomEvent('refresh-notification', {
|
||||
detail: {
|
||||
count: notifications.length - 1
|
||||
}
|
||||
}));
|
||||
setNotifications(notifications.filter((notification) => notification._id !== inviteId));
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
lostNotifications().finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div class="mb-8 hidden md:block">
|
||||
<h2 className="text-3xl font-bold sm:text-4xl">Notification</h2>
|
||||
<p className="mt-2 text-gray-400">Manage your notifications</p>
|
||||
</div>
|
||||
{
|
||||
notifications.length === 0 && (
|
||||
<div className="flex items-center justify-center mt-6">
|
||||
<p className="text-gray-400">
|
||||
No notifications, you can <a href="/team/new" className="text-blue-500 underline hover:no-underline">create a team</a> and invite your friends to join.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className="space-y-4">
|
||||
{notifications.map((notification) => (
|
||||
<div className="flex items-center justify-between rounded-md border p-2">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-gray-900">
|
||||
{notification.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button type="button"
|
||||
disabled={isLoading}
|
||||
className="inline-flex border p-1 rounded hover:bg-gray-50 disabled:opacity-75"
|
||||
onClick={() => respondInvitation('accept', notification?._id!)}
|
||||
>
|
||||
<img src={AcceptIcon} className="h-4 w-4" />
|
||||
</button>
|
||||
<button type="button"
|
||||
disabled={isLoading}
|
||||
className="inline-flex border p-1 rounded hover:bg-gray-50 disabled:opacity-75"
|
||||
onClick={() => respondInvitation('reject', notification?._id!)}
|
||||
>
|
||||
<img src={XIcon} className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,8 +36,7 @@ export function PageSponsor(props: PageSponsorProps) {
|
||||
currentPath === '/roadmaps' ||
|
||||
currentPath.startsWith('/guides') ||
|
||||
currentPath.startsWith('/videos') ||
|
||||
currentPath.startsWith('/account') ||
|
||||
currentPath.startsWith('/team')
|
||||
currentPath.startsWith('/account')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ export class Popup {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
popupEl.classList.remove('hidden');
|
||||
popupEl.classList.add('flex');
|
||||
const focusEl = popupEl.querySelector('[autofocus]');
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
---
|
||||
import AstroIcon from './AstroIcon.astro';
|
||||
import Popup from './Popup/Popup.astro';
|
||||
---
|
||||
|
||||
<Popup id='progress-help' title='' subtitle=''>
|
||||
<div class='-mt-2.5'>
|
||||
<h2 class='mb-3 text-2xl font-semibold leading-5 text-gray-900'>
|
||||
Track your Progress
|
||||
</h2>
|
||||
<p class='text-sm leading-4 text-gray-600'>
|
||||
Login and use one of the options listed below.
|
||||
</p>
|
||||
|
||||
<div class='mt-4 flex flex-col gap-1.5'>
|
||||
<div class='rounded-md border px-3 py-3 text-gray-500'>
|
||||
<span class='mb-1.5 block text-xs font-medium uppercase text-green-600'
|
||||
>Option 1</span
|
||||
>
|
||||
<p class='text-sm'>
|
||||
Click the roadmap topics and use <span class='underline'
|
||||
>Update Progress</span
|
||||
> dropdown to update your progress.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class='rounded-md border border-yellow-300 bg-yellow-50 px-3 py-3 text-gray-500'>
|
||||
<span class='mb-1.5 block text-xs font-medium uppercase text-green-600'
|
||||
>Option 2</span
|
||||
>
|
||||
<p class='text-sm'>Use the keyboard shortcuts listed below.</p>
|
||||
|
||||
<ul class="flex flex-col gap-1 mt-3 mb-1.5">
|
||||
<li class='text-sm leading-loose'>
|
||||
<kbd class="px-2 py-1.5 text-xs text-white bg-gray-900 rounded-md">Right Mouse Click</kbd> to mark as Done.
|
||||
</li>
|
||||
<li class='text-sm leading-loose'>
|
||||
<kbd class="px-2 py-1.5 text-xs text-white bg-gray-900 rounded-md">Shift</kbd> + <kbd class="px-2 py-1.5 text-xs text-white bg-gray-900 rounded-md">Click</kbd> to mark as in progress.
|
||||
</li>
|
||||
<li class='text-sm leading-loose'>
|
||||
<kbd class="px-2 py-1.5 text-xs text-white bg-gray-900 rounded-md">Option / Alt</kbd> + <kbd class="px-2 py-1.5 text-xs text-white bg-gray-900 rounded-md">Click</kbd> to mark as skipped.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
@@ -1,26 +0,0 @@
|
||||
type ChevronDownIconProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ChevronDownIcon(props: ChevronDownIconProps) {
|
||||
const { className } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
type ErrorIconProps = {
|
||||
additionalClasses?: string;
|
||||
};
|
||||
|
||||
export function ErrorIcon(props: ErrorIconProps) {
|
||||
const { additionalClasses = 'mr-2 top-[0.5px] w-[20px] h-[20px]' } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={`relative ${additionalClasses}`}
|
||||
>
|
||||
<path
|
||||
d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M15 9L9 15"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9 9L15 15"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
type InfoIconProps = {
|
||||
additionalClasses?: string;
|
||||
};
|
||||
|
||||
export function InfoIcon(props: InfoIconProps) {
|
||||
const { additionalClasses = 'mr-2 top-[0.5px] w-[20px] h-[20px]' } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={`relative ${additionalClasses}`}
|
||||
>
|
||||
<path
|
||||
d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 16V12"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 8H12.01"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
type SpinnerProps = {
|
||||
className?: string;
|
||||
isDualRing?: boolean;
|
||||
outerFill?: string;
|
||||
innerFill?: string;
|
||||
};
|
||||
|
||||
export function Spinner({
|
||||
className = '',
|
||||
isDualRing = true,
|
||||
outerFill = '#404040',
|
||||
innerFill = '#94a3b8',
|
||||
}: SpinnerProps) {
|
||||
|
||||
className += className?.includes('w-') ? '' : ' w-3.5 h-3.5';
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={`animate-spin ${className ?? ''}`}
|
||||
viewBox="0 0 93 93"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{isDualRing && (
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M46.5 93C72.1812 93 93 72.1812 93 46.5C93 20.8188 72.1812 0 46.5 0C20.8188 0 0 20.8188 0 46.5C0 72.1812 20.8188 93 46.5 93ZM46.5 77C63.3447 77 77 63.3447 77 46.5C77 29.6553 63.3447 16 46.5 16C29.6553 16 16 29.6553 16 46.5C16 63.3447 29.6553 77 46.5 77Z"
|
||||
style={`fill: ${outerFill};`}
|
||||
></path>
|
||||
)}
|
||||
<path
|
||||
d="M84.9746 49.5667C89.3257 49.9135 93.2042 46.6479 92.81 42.3008C92.3588 37.3251 91.1071 32.437 89.0872 27.8298C86.0053 20.7998 81.2311 14.6422 75.1905 9.90623C69.15 5.17027 62.031 2.00329 54.4687 0.687889C49.5126 -0.174203 44.467 -0.223422 39.5274 0.525737C35.2118 1.18024 32.966 5.72596 34.3411 9.86865V9.86865C35.7161 14.0113 40.2118 16.1424 44.5681 15.8677C46.9635 15.7166 49.3773 15.8465 51.7599 16.2609C56.7515 17.1291 61.4505 19.2196 65.4377 22.3456C69.4249 25.4717 72.5762 29.5362 74.6105 34.1764C75.5815 36.3912 76.2835 38.7044 76.7084 41.0666C77.4811 45.3626 80.6234 49.2199 84.9746 49.5667V49.5667Z"
|
||||
style={`fill: ${innerFill};`}
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
type TrashIconProps = {
|
||||
className?: string;
|
||||
};
|
||||
export function TrashIcon(props: TrashIconProps) {
|
||||
const { className = '' } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
<line x1="10" x2="10" y1="11" y2="17" />
|
||||
<line x1="14" x2="14" y1="11" y2="17" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
type WarningIconProps = {
|
||||
additionalClasses?: string;
|
||||
};
|
||||
|
||||
export function WarningIcon(props: WarningIconProps) {
|
||||
const { additionalClasses = 'mr-2 top-[0.5px] w-[20px] h-[20px]' } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={`relative ${additionalClasses}`}
|
||||
>
|
||||
<path
|
||||
d="M21.7304 18.0002L13.7304 4.00022C13.556 3.69243 13.303 3.43641 12.9973 3.25829C12.6917 3.08017 12.3442 2.98633 11.9904 2.98633C11.6366 2.98633 11.2892 3.08017 10.9835 3.25829C10.6778 3.43641 10.4249 3.69243 10.2504 4.00022L2.25042 18.0002C2.0741 18.3056 1.98165 18.6521 1.98243 19.0047C1.98321 19.3573 2.0772 19.7035 2.25486 20.008C2.43253 20.3126 2.68757 20.5648 2.99411 20.7391C3.30066 20.9133 3.64783 21.0034 4.00042 21.0002H20.0004C20.3513 20.9999 20.6959 20.9072 20.9997 20.7315C21.3035 20.5558 21.5556 20.3033 21.7309 19.9993C21.9062 19.6954 21.9985 19.3506 21.9984 18.9997C21.9983 18.6488 21.9059 18.3041 21.7304 18.0002Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 9V13"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 17H12.01"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
---
|
||||
import AstroIcon from './AstroIcon.astro';
|
||||
export interface Props {
|
||||
isSecondaryBanner?: boolean;
|
||||
}
|
||||
|
||||
const { isSecondaryBanner = false } = Astro.props;
|
||||
---
|
||||
|
||||
<div
|
||||
data-progress-nums-container
|
||||
class:list={[
|
||||
'hidden sm:flex justify-between px-2 bg-white items-center py-1.5 relative striped-loader bg-white',
|
||||
{
|
||||
'rounded-bl-md rounded-br-md': isSecondaryBanner,
|
||||
'rounded-md': !isSecondaryBanner,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<p
|
||||
class='flex text-sm opacity-0 transition-opacity duration-300'
|
||||
data-progress-nums
|
||||
>
|
||||
<span
|
||||
class='mr-2.5 rounded-sm bg-yellow-200 px-1 py-0.5 text-xs font-medium uppercase text-yellow-900'
|
||||
>
|
||||
<span data-progress-percentage>0</span>% Done
|
||||
</span>
|
||||
|
||||
<span><span data-progress-done>0</span> completed</span><span
|
||||
class='mx-1.5 text-gray-400'>·</span
|
||||
>
|
||||
<span><span data-progress-learning>0</span> in progress</span><span
|
||||
class='mx-1.5 text-gray-400'>·</span
|
||||
>
|
||||
<span><span data-progress-skipped>0</span> skipped</span><span
|
||||
class='mx-1.5 text-gray-400'>·</span
|
||||
>
|
||||
<span><span data-progress-total>0</span> Total</span>
|
||||
</p>
|
||||
|
||||
<button
|
||||
data-popup='progress-help'
|
||||
class='flex items-center gap-1 text-sm font-medium text-gray-500 opacity-0 transition-opacity hover:text-black'
|
||||
data-progress-nums
|
||||
>
|
||||
<AstroIcon icon='question' />
|
||||
Track Progress
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p
|
||||
data-progress-nums-container
|
||||
class='striped-loader relative -mb-2 flex items-center justify-between rounded-md border bg-white bg-white px-2 py-1.5 text-sm text-sm text-gray-700 sm:hidden'
|
||||
>
|
||||
<span data-progress-nums class='opacity-0 transition-opacity duration-300 text-gray-500'>
|
||||
<span data-progress-done>0</span> of <span data-progress-total>0</span> Done
|
||||
</span>
|
||||
|
||||
<button
|
||||
data-popup='progress-help'
|
||||
class='flex items-center gap-1 text-sm font-medium text-gray-500 opacity-0 transition-opacity hover:text-black'
|
||||
data-progress-nums
|
||||
>
|
||||
<AstroIcon icon='question' />
|
||||
Track Progress
|
||||
</button>
|
||||
</p>
|
||||
@@ -1,176 +0,0 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { httpGet, httpPatch } from '../lib/http';
|
||||
import BuildingIcon from '../icons/building.svg';
|
||||
import ErrorIcon from '../icons/error.svg';
|
||||
import { pageProgressMessage } from '../stores/page';
|
||||
import type { TeamDocument } from './CreateTeam/CreateTeamForm';
|
||||
import type { AllowedRoles } from './CreateTeam/RoleDropdown';
|
||||
import type { AllowedMemberStatus } from './TeamDropdown/TeamDropdown';
|
||||
import { isLoggedIn } from '../lib/jwt';
|
||||
import { showLoginPopup } from '../lib/popup';
|
||||
import { getUrlParams } from '../lib/browser';
|
||||
|
||||
type InvitationResponse = {
|
||||
team: TeamDocument;
|
||||
invite: {
|
||||
_id?: string;
|
||||
userId?: string;
|
||||
invitedEmail?: string;
|
||||
teamId: string;
|
||||
role: AllowedRoles;
|
||||
status: AllowedMemberStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
};
|
||||
|
||||
export function RespondInviteForm() {
|
||||
const { i: inviteId } = getUrlParams();
|
||||
|
||||
const [isLoadingInvite, setIsLoadingInvite] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [invite, setInvite] = useState<InvitationResponse>();
|
||||
const isAuthenticated = isLoggedIn();
|
||||
|
||||
async function loadInvitation(inviteId: string) {
|
||||
const { response, error } = await httpGet<InvitationResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-invitation/${inviteId}`
|
||||
);
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
setInvite(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (inviteId) {
|
||||
loadInvitation(inviteId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
setIsLoadingInvite(false);
|
||||
});
|
||||
} else {
|
||||
setIsLoadingInvite(false);
|
||||
setError('Missing invite ID in URL');
|
||||
pageProgressMessage.set('');
|
||||
}
|
||||
}, [inviteId]);
|
||||
|
||||
async function respondInvitation(status: 'accept' | 'reject') {
|
||||
pageProgressMessage.set('Please wait...');
|
||||
setError('');
|
||||
const { response, error } = await httpPatch<{ teamId: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-respond-invite/${inviteId}`,
|
||||
{
|
||||
status,
|
||||
}
|
||||
);
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 'reject') {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
window.location.href = `/team/progress?t=${response.teamId}`;
|
||||
}
|
||||
|
||||
if (isLoadingInvite) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!invite) {
|
||||
return (
|
||||
<div className="container text-center">
|
||||
<img
|
||||
alt={'error'}
|
||||
src={ErrorIcon}
|
||||
className="mx-auto mb-4 mt-24 w-20 opacity-20"
|
||||
/>
|
||||
|
||||
<h2 className={'mb-1 text-2xl font-bold'}>Error</h2>
|
||||
<p class="mb-4 text-base leading-6 text-gray-600">
|
||||
{error || 'There was a problem, please try again.'}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<a
|
||||
href="/"
|
||||
className="flex-grow cursor-pointer rounded-lg bg-gray-200 px-3 py-2 text-center"
|
||||
>
|
||||
Back to home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container text-center">
|
||||
<img
|
||||
alt={'join team'}
|
||||
src={BuildingIcon}
|
||||
className="mx-auto mb-4 mt-24 w-20 opacity-20"
|
||||
/>
|
||||
|
||||
<h2 className={'mb-1 text-2xl font-bold'}>Join Team</h2>
|
||||
<p class="mb-3 text-base leading-6 text-gray-600">
|
||||
You have been invited to join the team{' '}
|
||||
<strong id="team-name">{invite?.team?.name}</strong>.
|
||||
</p>
|
||||
|
||||
{!isAuthenticated && (
|
||||
<div class="mx-auto w-full duration-500 sm:max-w-md">
|
||||
<div class="flex w-full items-center gap-2">
|
||||
<button
|
||||
onClick={() => showLoginPopup()}
|
||||
data-popup="login-popup"
|
||||
type="button"
|
||||
class="flex-grow cursor-pointer rounded-lg bg-gray-200 px-3 py-2 text-center"
|
||||
>
|
||||
Login to respond
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAuthenticated && (
|
||||
<div className={`mx-auto w-full max-w-md`}>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
respondInvitation('accept').finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
})
|
||||
}
|
||||
className="flex-grow cursor-pointer rounded-lg bg-gray-200 px-3 py-2 text-center"
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
respondInvitation('reject').finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
})
|
||||
}
|
||||
className="flex-grow cursor-pointer rounded-lg bg-red-500 px-3 py-2 text-white disabled:opacity-40"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,9 +5,6 @@ import RoadmapHint from './RoadmapHint.astro';
|
||||
import RoadmapNote from './RoadmapNote.astro';
|
||||
import TopicSearch from './TopicSearch/TopicSearch.astro';
|
||||
import YouTubeAlert from './YouTubeAlert.astro';
|
||||
import ProgressHelpPopup from './ProgressHelpPopup.astro';
|
||||
import { MarkFavorite } from './FeaturedItems/MarkFavorite';
|
||||
import { TeamVersions } from './TeamVersions/TeamVersions';
|
||||
|
||||
export interface Props {
|
||||
title: string;
|
||||
@@ -35,24 +32,19 @@ const isRoadmapReady = !isUpcoming;
|
||||
---
|
||||
|
||||
<LoginPopup />
|
||||
<ProgressHelpPopup />
|
||||
|
||||
<div class='border-b'>
|
||||
<div class='container relative py-5 sm:py-12'>
|
||||
<div class='mb-3 mt-0 sm:mb-4'>
|
||||
<YouTubeAlert />
|
||||
|
||||
<div class='mb-3 mt-0 sm:mb-4 sm:mt-4'>
|
||||
<h1 class='mb-0.5 text-2xl font-bold sm:mb-2 sm:text-4xl'>
|
||||
{title}
|
||||
<MarkFavorite
|
||||
resourceId={roadmapId}
|
||||
resourceType='roadmap'
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div class='flex justify-between gap-2 sm:gap-0'>
|
||||
<div class='flex justify-between'>
|
||||
<div class='flex gap-1 sm:gap-2'>
|
||||
{
|
||||
!hasSearch && (
|
||||
@@ -117,28 +109,20 @@ const isRoadmapReady = !isUpcoming;
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class='flex items-center gap-1 sm:gap-2'>
|
||||
<TeamVersions
|
||||
resourceType='roadmap'
|
||||
resourceId={roadmapId}
|
||||
client:only
|
||||
/>
|
||||
|
||||
{
|
||||
isRoadmapReady && (
|
||||
<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'
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
isRoadmapReady && (
|
||||
<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'
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Desktop: Roadmap Resources - Alert -->
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { ClearProgress } from './Activity/ClearProgress';
|
||||
import AstroIcon from './AstroIcon.astro';
|
||||
import Icon from './AstroIcon.astro';
|
||||
import ResourceProgressStats from './ResourceProgressStats.astro';
|
||||
|
||||
export interface Props {
|
||||
roadmapId: string;
|
||||
@@ -44,5 +43,32 @@ const roadmapTitle =
|
||||
)
|
||||
}
|
||||
|
||||
<ResourceProgressStats isSecondaryBanner={hasTNSBanner} />
|
||||
<!-- Desktop: Roadmap Resources - Alert -->
|
||||
<div id="progress-nums-container"
|
||||
class:list={[
|
||||
'hidden sm:flex justify-between px-2 bg-white items-center py-1.5 relative striped-loader bg-white',
|
||||
{
|
||||
'rounded-bl-md rounded-br-md': hasTNSBanner,
|
||||
'rounded-md': !hasTNSBanner,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<p class='text-sm flex opacity-0 transition-opacity duration-300' id="progress-nums">
|
||||
<span class="font-medium py-0.5 rounded-sm text-xs uppercase bg-yellow-200 px-1 text-yellow-900 mr-2.5">
|
||||
<span class="progress-percentage">0</span>% Done
|
||||
</span>
|
||||
|
||||
<span><span class="progress-done">0</span> completed</span><span class="mx-1.5 text-gray-400">·</span>
|
||||
<span><span class="progress-learning">0</span> learning</span><span class="mx-1.5 text-gray-400">·</span>
|
||||
<span><span class="progress-skipped">0</span> skipped</span><span class="mx-1.5 text-gray-400">·</span>
|
||||
<span><span class="progress-total">0</span> Total</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Mobile - Roadmap resources alert -->
|
||||
<p
|
||||
class='relative block rounded-md border border-yellow-500 bg-white px-2 py-1.5 text-sm text-yellow-700 sm:hidden'
|
||||
>
|
||||
Track your progress and learn about the topics by clicking the roadmap items.
|
||||
</p>
|
||||
</div>
|
||||
@@ -1,156 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
|
||||
export type OptionType = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export function SearchSelector({
|
||||
options,
|
||||
onSelect,
|
||||
inputClassName,
|
||||
searchInputId,
|
||||
placeholder,
|
||||
}: {
|
||||
options: OptionType[];
|
||||
onSelect: (data: OptionType) => void;
|
||||
inputClassName?: string;
|
||||
searchInputId?: string;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<OptionType[]>([]);
|
||||
const [searchedText, setSearchedText] = useState('');
|
||||
const [activeCounter, setActiveCounter] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchedText.length === 0) {
|
||||
setSearchResults(options.slice(0, 5));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsActive(true);
|
||||
const normalizedSearchedText = searchedText.trim().toLowerCase();
|
||||
const results = options
|
||||
.filter((data) => {
|
||||
return data.label.toLowerCase().indexOf(normalizedSearchedText) !== -1;
|
||||
})
|
||||
.slice(0, 5);
|
||||
|
||||
setSearchResults(results);
|
||||
setActiveCounter(0);
|
||||
}, [searchedText]);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchResults(options.slice(0, 5));
|
||||
}, [options]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
const handleOutsideClick = (e: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target as Node) &&
|
||||
searchInputRef.current &&
|
||||
!searchInputRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setIsActive(false);
|
||||
setSearchedText('');
|
||||
setSearchResults(options.slice(0, 5));
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleOutsideClick);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleOutsideClick);
|
||||
};
|
||||
}
|
||||
}, [isActive]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
id={searchInputId}
|
||||
value={searchedText}
|
||||
className={`w-full ${inputClassName}`}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
onInput={(e) => {
|
||||
const value = (e.target as HTMLInputElement).value.trim();
|
||||
setSearchedText(value);
|
||||
}}
|
||||
onFocus={() => {
|
||||
setIsActive(true);
|
||||
setSearchResults(options.slice(0, 5));
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
const canGoNext = activeCounter < searchResults.length - 1;
|
||||
setActiveCounter(canGoNext ? activeCounter + 1 : 0);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
const canGoPrev = activeCounter > 0;
|
||||
setActiveCounter(
|
||||
canGoPrev ? activeCounter - 1 : searchResults.length - 1
|
||||
);
|
||||
} else if (e.key === 'Tab') {
|
||||
if (isActive) {
|
||||
e.preventDefault();
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setSearchedText('');
|
||||
setIsActive(false);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const activeData = searchResults[activeCounter];
|
||||
if (activeData) {
|
||||
onSelect(activeData);
|
||||
setSearchedText('');
|
||||
setIsActive(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{isActive && (
|
||||
<div
|
||||
class="absolute top-full z-50 mt-2 w-full rounded-md bg-gray-100 px-2 py-2"
|
||||
ref={dropdownRef}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{searchResults.length === 0 && (
|
||||
<div class="p-5 text-center text-sm text-gray-400">
|
||||
No results found
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchResults.map((result, counter) => {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
class={`flex w-full items-center rounded p-2 text-sm ${
|
||||
counter === activeCounter ? 'bg-gray-200' : ''
|
||||
}`}
|
||||
onMouseOver={() => setActiveCounter(counter)}
|
||||
onClick={() => {
|
||||
onSelect(result);
|
||||
setSearchedText('');
|
||||
setIsActive(false);
|
||||
}}
|
||||
>
|
||||
{result.label}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { CheckIcon } from './ReactIcons/CheckIcon';
|
||||
|
||||
type StepperStep = {
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type StepperProps = {
|
||||
activeIndex: number;
|
||||
completeSteps: number[];
|
||||
steps: StepperStep[];
|
||||
};
|
||||
|
||||
export function Stepper(props: StepperProps) {
|
||||
const { steps, activeIndex = 0, completeSteps = [] } = props;
|
||||
|
||||
return (
|
||||
<ol className="flex w-full items-center text-gray-500">
|
||||
{steps.map((step, stepCounter) => {
|
||||
const isComplete = completeSteps.includes(stepCounter);
|
||||
const isActive = activeIndex === stepCounter;
|
||||
const isLast = stepCounter === (steps.length - 1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<li
|
||||
className={`flex items-center ${
|
||||
isComplete || isActive ? 'text-black' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{isComplete && (
|
||||
<CheckIcon
|
||||
additionalClasses={'mr-2 top-[0.5px] w-[18px] h-[18px]'}
|
||||
/>
|
||||
)}
|
||||
{!isComplete && (
|
||||
<span class="mr-2 font-semibold">{stepCounter + 1}</span>
|
||||
)}
|
||||
<span className="flex flex-grow">{step.label}</span>
|
||||
</li>
|
||||
{!isLast && (
|
||||
<li className={'mx-5 flex flex-grow rounded-md bg-gray-200'}>
|
||||
<span className={'h-1 w-full'} />
|
||||
</li>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import ChevronDown from '../../icons/dropdown.svg';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { useAuth } from '../../hooks/use-auth';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import type { AllowedRoles } from '../CreateTeam/RoleDropdown';
|
||||
import { $currentTeam, $teamList } from '../../stores/team';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { useTeamId } from '../../hooks/use-team-id';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
const allowedStatus = ['invited', 'joined', 'rejected'] as const;
|
||||
export type AllowedMemberStatus = (typeof allowedStatus)[number];
|
||||
|
||||
export type UserTeamItem = {
|
||||
_id: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
roadmaps: string[];
|
||||
role: AllowedRoles;
|
||||
status: AllowedMemberStatus;
|
||||
memberId: string;
|
||||
};
|
||||
|
||||
export type TeamListResponse = UserTeamItem[];
|
||||
|
||||
export function TeamDropdown() {
|
||||
const user = useAuth();
|
||||
const { teamId } = useTeamId();
|
||||
|
||||
const teamList = useStore($teamList);
|
||||
const currentTeam = useStore($currentTeam);
|
||||
|
||||
const toast = useToast();
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
|
||||
const selectedAvatar = currentTeam ? currentTeam.avatar : user?.avatar;
|
||||
const selectedLabel = currentTeam ? currentTeam.name : user?.name;
|
||||
|
||||
useOutsideClick(dropdownRef, () => {
|
||||
setShowDropdown(false);
|
||||
});
|
||||
|
||||
async function getAllTeams() {
|
||||
const { response, error } = await httpGet<TeamListResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-teams`
|
||||
);
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
$teamList.set(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!teamId || !teamList) {
|
||||
return;
|
||||
}
|
||||
|
||||
$currentTeam.set(teamList.find((team) => team._id === teamId));
|
||||
}, [teamList, teamId]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
getAllTeams().finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const pendingTeamIds = teamList
|
||||
.filter((team) => team.status === 'invited')
|
||||
.map((team) => team._id);
|
||||
|
||||
if (
|
||||
!user?.email.endsWith('@insightpartners.com') &&
|
||||
!user?.email.endsWith('@roadmap.sh') &&
|
||||
!['arikchangma@gmail.com', 'kamranahmed.se@gmail.com'].includes(user?.email!)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative mr-2">
|
||||
<button
|
||||
className="flex w-full cursor-pointer items-center justify-between rounded border p-2 text-sm hover:bg-gray-100"
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
>
|
||||
{pendingTeamIds.length > 0 && (
|
||||
<span className="absolute -left-1.5 -top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-xs font-medium text-white">
|
||||
{pendingTeamIds.length}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoading && <Spinner className="h-4 w-4" isDualRing={false} />}
|
||||
{!isLoading && (
|
||||
<img
|
||||
src={
|
||||
selectedAvatar
|
||||
? `${
|
||||
import.meta.env.PUBLIC_AVATAR_BASE_URL
|
||||
}/${selectedAvatar}`
|
||||
: '/images/default-avatar.png'
|
||||
}
|
||||
alt=""
|
||||
className="h-4 w-4 rounded-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">
|
||||
{!isLoading && selectedLabel}
|
||||
{isLoading && 'Loading ..'}
|
||||
</span>
|
||||
</div>
|
||||
<img alt={'show dropdown'} src={ChevronDown} className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{showDropdown && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute top-full z-50 mt-2 w-full rounded-md bg-slate-800 px-2 py-2 text-white shadow-md"
|
||||
>
|
||||
<ul>
|
||||
<li>
|
||||
<a
|
||||
className="flex w-full cursor-pointer items-center gap-2 truncate rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
href="/account"
|
||||
>
|
||||
<span className="truncate">Personal Account</span>
|
||||
</a>
|
||||
</li>
|
||||
{teamList.map((team) => {
|
||||
let pageLink = '';
|
||||
if (team.status === 'invited') {
|
||||
pageLink = `/respond-invite?i=${team.memberId}`;
|
||||
} else if (team.status === 'joined') {
|
||||
pageLink = `/team/progress?t=${team._id}`;
|
||||
}
|
||||
|
||||
if (team.roadmaps.length === 0) {
|
||||
pageLink = `/team/new?t=${team._id}&s=2`;
|
||||
}
|
||||
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
href={`${pageLink}`}
|
||||
>
|
||||
<span className="flex-grow truncate">{team.name}</span>
|
||||
{pendingTeamIds.includes(team._id) && (
|
||||
<span className="flex rounded-md bg-red-500 px-2 text-xs text-white">
|
||||
Invite
|
||||
</span>
|
||||
)}
|
||||
|
||||
{team.roadmaps.length === 0 && (
|
||||
<span className="flex rounded-md bg-gray-500 px-2 text-xs text-white">
|
||||
Draft
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<a
|
||||
className="mt-2 flex w-full cursor-pointer items-center justify-center gap-2 rounded bg-gray-100 p-2 text-sm font-medium text-slate-800 hover:opacity-90"
|
||||
href="/team/new"
|
||||
>
|
||||
<span>+</span>
|
||||
<span>New Team</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<hr class='my-4' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import { useTeamId } from '../../hooks/use-team-id';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { AllowedRoles, RoleDropdown } from '../CreateTeam/RoleDropdown';
|
||||
|
||||
type InviteMemberPopupProps = {
|
||||
onInvited: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function InviteMemberPopup(props: InviteMemberPopupProps) {
|
||||
const { onClose, onInvited } = props;
|
||||
|
||||
const popupBodyRef = useRef<HTMLDivElement>(null);
|
||||
const emailRef = useRef<HTMLInputElement>(null);
|
||||
const [selectedRole, setSelectedRole] = useState<AllowedRoles>('member');
|
||||
const [email, setEmail] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const { teamId } = useTeamId();
|
||||
|
||||
useEffect(() => {
|
||||
emailRef?.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
const { response, error } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-invite-member/${teamId}`,
|
||||
{ email, role: selectedRole }
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setIsLoading(false);
|
||||
setError(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
handleClosePopup();
|
||||
onInvited();
|
||||
};
|
||||
|
||||
const handleClosePopup = () => {
|
||||
setIsLoading(false);
|
||||
setError('');
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
useOutsideClick(popupBodyRef, handleClosePopup);
|
||||
|
||||
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={popupBodyRef}
|
||||
class="popup-body relative rounded-lg bg-white p-4 shadow"
|
||||
>
|
||||
<h3 class="mb-1.5 text-xl sm:text-2xl font-medium">Invite Member</h3>
|
||||
<p className="mb-3 text-sm leading-none text-gray-400 hidden sm:block">
|
||||
Enter the email and role below to invite a member.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mt-0 sm:mt-4 my-4 flex flex-col gap-2">
|
||||
<input
|
||||
ref={emailRef}
|
||||
type="email"
|
||||
name="invite-member"
|
||||
id="invite-member"
|
||||
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="Enter email address"
|
||||
required
|
||||
autoFocus
|
||||
value={email}
|
||||
onInput={(e) => setEmail((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
|
||||
<div className="flex h-[42px] w-full flex-col">
|
||||
<RoleDropdown
|
||||
className="h-full w-full"
|
||||
selectedRole={selectedRole}
|
||||
setSelectedRole={setSelectedRole}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className=" rounded-md border border-red-300 bg-red-50 p-2 text-sm 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 || !email}
|
||||
class="flex-grow cursor-pointer rounded-lg bg-black py-2 text-center text-white disabled:opacity-40"
|
||||
>
|
||||
{isLoading ? 'Please wait ..' : 'Invite'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useState } from "preact/hooks";
|
||||
import { httpDelete } from "../../lib/http";
|
||||
import { Spinner } from "../ReactIcons/Spinner";
|
||||
import { useToast } from "../../hooks/use-toast";
|
||||
|
||||
type LeaveTeamButtonProps = {
|
||||
teamId: string;
|
||||
};
|
||||
|
||||
export function LeaveTeamButton(props: LeaveTeamButtonProps) {
|
||||
const toast = useToast();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { teamId } = props;
|
||||
|
||||
async function leaveTeam() {
|
||||
setIsLoading(true);
|
||||
const { response, error } = await httpDelete(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-leave-team/${teamId}`,
|
||||
{}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setIsLoading(false);
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = '/account';
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
disabled={isLoading}
|
||||
onClick={leaveTeam}
|
||||
className="bg-gray-50 text-red-600 text-sm font-medium px-2 leading-none py-1.5 rounded-md border border-gray-200 h-7 flex items-center justify-center min-w-[95px]">
|
||||
{isLoading ? <Spinner isDualRing={false} /> : 'Leave team'}
|
||||
</button>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import type { TeamMemberDocument } from './TeamMembersPage';
|
||||
import { httpDelete, httpPatch } from '../../lib/http';
|
||||
import MoreIcon from '../../icons/more-vertical.svg';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
export function MemberActionDropdown({
|
||||
member,
|
||||
onUpdateMember,
|
||||
onDeleteMember,
|
||||
isDisabled = false,
|
||||
}: {
|
||||
onDeleteMember: () => void;
|
||||
onUpdateMember: () => void;
|
||||
isDisabled: boolean;
|
||||
member: TeamMemberDocument;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useOutsideClick(menuRef, () => {
|
||||
setIsOpen(false);
|
||||
});
|
||||
|
||||
async function resendInvite() {
|
||||
const { response, error } = await httpPatch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-resend-invite/${member.teamId}/${
|
||||
member._id
|
||||
}`,
|
||||
{}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setIsLoading(false);
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
const actions = [
|
||||
{
|
||||
name: 'Delete',
|
||||
handleClick: () => {
|
||||
onDeleteMember();
|
||||
setIsOpen(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Update Role',
|
||||
handleClick: () => {
|
||||
onUpdateMember();
|
||||
setIsOpen(false);
|
||||
},
|
||||
},
|
||||
...(['invited'].includes(member.status)
|
||||
? [
|
||||
{
|
||||
name: 'Resend Invite',
|
||||
handleClick: resendInvite,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
disabled={isDisabled}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="ml-2 flex items-center opacity-60 transition-opacity hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
<img alt="menu" src={MoreIcon} className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="align-right absolute right-0 top-full z-50 mt-1 w-32 rounded-md bg-slate-800 px-2 py-2 text-white shadow-md"
|
||||
>
|
||||
<ul>
|
||||
{actions.map((action, index) => {
|
||||
return (
|
||||
<li key={index}>
|
||||
<button
|
||||
onClick={action.handleClick}
|
||||
disabled={isLoading}
|
||||
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
{action.name}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { AllowedRoles } from '../CreateTeam/RoleDropdown';
|
||||
|
||||
export function MemberRoleBadge({ role }: { role: AllowedRoles }) {
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs capitalize ${
|
||||
['admin'].includes(role)
|
||||
? 'bg-blue-100 text-blue-700 '
|
||||
: 'bg-gray-100 text-gray-700 '
|
||||
} ${['manager'].includes(role) ? 'bg-green-100 text-green-700' : ''}`}
|
||||
>
|
||||
{role}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { httpDelete, httpGet } from '../../lib/http';
|
||||
import { MemberActionDropdown } from './MemberActionDropdown';
|
||||
import { useAuth } from '../../hooks/use-auth';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import type { TeamDocument } from '../CreateTeam/CreateTeamForm';
|
||||
import { LeaveTeamButton } from './LeaveTeamButton';
|
||||
import type { AllowedRoles } from '../CreateTeam/RoleDropdown';
|
||||
import type { AllowedMemberStatus } from '../TeamDropdown/TeamDropdown';
|
||||
import { InviteMemberPopup } from './InviteMemberPopup';
|
||||
import { getUrlParams } from '../../lib/browser';
|
||||
import { UpdateMemberPopup } from './UpdateMemberPopup';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { $canManageCurrentTeam } from '../../stores/team';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { MemberRoleBadge } from './RoleBadge';
|
||||
|
||||
export interface TeamMemberDocument {
|
||||
_id?: string;
|
||||
userId?: string;
|
||||
invitedEmail?: string;
|
||||
teamId: string;
|
||||
role: AllowedRoles;
|
||||
status: AllowedMemberStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface TeamMemberItem extends TeamMemberDocument {
|
||||
name: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
export function TeamMembersPage() {
|
||||
const { t: teamId } = getUrlParams();
|
||||
|
||||
const toast = useToast();
|
||||
const canManageCurrentTeam = useStore($canManageCurrentTeam);
|
||||
|
||||
const [memberToUpdate, setMemberToUpdate] = useState<TeamMemberItem>();
|
||||
const [isInvitingMember, setIsInvitingMember] = useState(false);
|
||||
const [teamMembers, setTeamMembers] = useState<TeamMemberItem[]>([]);
|
||||
const [team, setTeam] = useState<TeamDocument>();
|
||||
|
||||
const user = useAuth();
|
||||
|
||||
async function loadTeam() {
|
||||
const { response, error } = await httpGet<TeamDocument>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team/${teamId}`
|
||||
);
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
if (response) {
|
||||
setTeam(response);
|
||||
}
|
||||
}
|
||||
|
||||
async function getTeamMemberList() {
|
||||
const { response, error } = await httpGet<TeamMemberItem[]>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-member-list/${teamId}`
|
||||
);
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Failed to load team member list');
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamMembers(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.all([loadTeam(), getTeamMemberList()]).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}, [teamId]);
|
||||
|
||||
async function deleteMember(teamId: string, memberId: string) {
|
||||
pageProgressMessage.set('Deleting member');
|
||||
const { response, error } = await httpDelete(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-delete-member/${teamId}/${memberId}`,
|
||||
{}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Member has been deleted');
|
||||
await getTeamMemberList();
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{memberToUpdate && (
|
||||
<UpdateMemberPopup
|
||||
member={memberToUpdate}
|
||||
onUpdated={() => {
|
||||
pageProgressMessage.set('Refreshing members');
|
||||
getTeamMemberList().finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
setMemberToUpdate(undefined);
|
||||
toast.success('Member has been updated');
|
||||
}}
|
||||
onClose={() => {
|
||||
setMemberToUpdate(undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isInvitingMember && (
|
||||
<InviteMemberPopup
|
||||
onInvited={() => {
|
||||
toast.success('Invite sent');
|
||||
getTeamMemberList().then(() => null);
|
||||
setIsInvitingMember(false);
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsInvitingMember(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div className="rounded-b-sm rounded-t-md border">
|
||||
<div className="flex items-center justify-between gap-2 border-b p-3">
|
||||
<p className="hidden text-sm sm:block">
|
||||
{teamMembers.length} people in the team.
|
||||
</p>
|
||||
<p className="block text-sm sm:hidden">
|
||||
{teamMembers.length} members
|
||||
</p>
|
||||
<LeaveTeamButton teamId={team?._id!} />
|
||||
</div>
|
||||
{teamMembers.map((member, index) => {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-between gap-2 p-3 ${
|
||||
index === 0 ? '' : 'border-t'
|
||||
} ${member.status === 'invited' ? 'bg-gray-50' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={
|
||||
member.avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${
|
||||
member.avatar
|
||||
}`
|
||||
: '/images/default-avatar.png'
|
||||
}
|
||||
alt={member.name || ''}
|
||||
className="hidden h-10 w-10 rounded-full sm:block"
|
||||
/>
|
||||
<div>
|
||||
<span class={'mb-1 block sm:hidden'}>
|
||||
<MemberRoleBadge role={member.role} />
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
<h3 className="flex items-center font-medium">
|
||||
{member.name}
|
||||
{member.userId === user?.id && (
|
||||
<span className="ml-2 hidden text-xs font-normal text-blue-500 sm:inline">
|
||||
You
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<div className="ml-2 flex items-center gap-0.5">
|
||||
{member.status === 'invited' && (
|
||||
<span className="rounded-full bg-yellow-100 px-2 py-0.5 text-xs text-yellow-700">
|
||||
Invited
|
||||
</span>
|
||||
)}
|
||||
{member.status === 'rejected' && (
|
||||
<span className="rounded-full bg-red-100 px-2 py-0.5 text-xs text-red-700">
|
||||
Rejected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
{member.invitedEmail}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-sm">
|
||||
<span class={'hidden sm:block'}>
|
||||
<MemberRoleBadge role={member.role} />
|
||||
</span>
|
||||
{canManageCurrentTeam && (
|
||||
<MemberActionDropdown
|
||||
onDeleteMember={() => {
|
||||
deleteMember(teamId, member._id!).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
isDisabled={member.userId === user?.id}
|
||||
onUpdateMember={() => {
|
||||
setMemberToUpdate(member);
|
||||
}}
|
||||
member={member}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canManageCurrentTeam && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
disabled={teamMembers.length >= 25}
|
||||
onClick={() => setIsInvitingMember(true)}
|
||||
className="block w-full rounded-md border border-dashed border-gray-300 py-2 text-sm transition-colors hover:border-gray-600 hover:bg-gray-50 focus:outline-0"
|
||||
>
|
||||
+ Invite Member
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{teamMembers.length >= 25 && canManageCurrentTeam && (
|
||||
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">
|
||||
You have reached the maximum number of members in a team. Please reach
|
||||
out to us if you need more.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { httpPost, httpPut } from '../../lib/http';
|
||||
import { useTeamId } from '../../hooks/use-team-id';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { AllowedRoles, RoleDropdown } from '../CreateTeam/RoleDropdown';
|
||||
import type { TeamMemberDocument } from './TeamMembersPage';
|
||||
|
||||
type InviteMemberPopupProps = {
|
||||
member: TeamMemberDocument;
|
||||
onUpdated: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function UpdateMemberPopup(props: InviteMemberPopupProps) {
|
||||
const { onClose, onUpdated, member } = props;
|
||||
|
||||
const popupBodyRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedRole, setSelectedRole] = useState<AllowedRoles>(member.role);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const { teamId } = useTeamId();
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
const { response, error } = await httpPut(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-update-member-role/${teamId}/${
|
||||
member._id
|
||||
}`,
|
||||
{ role: selectedRole }
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setIsLoading(false);
|
||||
setError(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
onUpdated();
|
||||
};
|
||||
|
||||
const handleClosePopup = () => {
|
||||
setIsLoading(false);
|
||||
setError('');
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
useOutsideClick(popupBodyRef, handleClosePopup);
|
||||
|
||||
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={popupBodyRef}
|
||||
class="popup-body relative rounded-lg bg-white p-4 shadow"
|
||||
>
|
||||
<h3 class="mb-1.5 text-xl sm:text-2xl font-medium">Update Role</h3>
|
||||
<p className="mb-3 text-sm leading-none text-gray-400 hidden sm:block">
|
||||
Select the role to update for this member
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mt-0 sm:mt-4 my-4 flex flex-col gap-2">
|
||||
<span className="mt-2 block w-full rounded-md bg-gray-100 p-2">
|
||||
{member.invitedEmail}
|
||||
</span>
|
||||
|
||||
<div className="flex h-[42px] w-full flex-col">
|
||||
<RoleDropdown
|
||||
className="h-full w-full"
|
||||
selectedRole={selectedRole}
|
||||
setSelectedRole={setSelectedRole}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className=" rounded-md border border-red-300 bg-red-50 p-2 text-sm 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 || !selectedRole}
|
||||
class="flex-grow cursor-pointer rounded-lg bg-black py-2 text-center text-white disabled:opacity-40"
|
||||
>
|
||||
{isLoading ? 'Please wait ..' : 'Update Role'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import type { GroupByRoadmap, TeamMember } from './TeamProgressPage';
|
||||
import { MemberProgressModal } from './MemberProgressModal';
|
||||
import { getUrlParams } from '../../lib/browser';
|
||||
import ExternalLinkIcon from '../../icons/external-link.svg';
|
||||
|
||||
type GroupRoadmapItemProps = {
|
||||
roadmap: GroupByRoadmap;
|
||||
};
|
||||
|
||||
export function GroupRoadmapItem(props: GroupRoadmapItemProps) {
|
||||
const { members, resourceTitle, resourceId } = props.roadmap;
|
||||
const { t: teamId } = getUrlParams();
|
||||
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [detailResourceId, setDetailResourceId] = useState<string | null>(null);
|
||||
const [selectedMember, setSelectedMember] = useState<TeamMember | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
{detailResourceId && (
|
||||
<MemberProgressModal
|
||||
member={selectedMember!}
|
||||
teamId={teamId}
|
||||
resourceId={detailResourceId}
|
||||
resourceType={'roadmap'}
|
||||
onClose={() => {
|
||||
setDetailResourceId(null);
|
||||
setSelectedMember(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex h-full min-h-[270px] flex-col rounded-md border">
|
||||
<div className="flex items-center gap-3 border-b p-3">
|
||||
<div className="flex min-w-0 flex-grow items-center justify-between">
|
||||
<h3 className="truncate font-medium">{resourceTitle}</h3>
|
||||
<a
|
||||
href={`/${resourceId}?t=${teamId}`}
|
||||
className="group mb-0.5 flex shrink-0 items-center justify-between text-base font-medium leading-none text-black"
|
||||
target={'_blank'}
|
||||
>
|
||||
<img
|
||||
alt={'link'}
|
||||
src={ExternalLinkIcon}
|
||||
className="ml-2 h-4 w-4 opacity-20 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex grow flex-col space-y-2 p-3">
|
||||
{(showAll ? members : members.slice(0, 4)).map((member) => {
|
||||
if (!member.progress) return null;
|
||||
return (
|
||||
<button
|
||||
className="group relative w-full overflow-hidden rounded-md border p-2 hover:border-gray-300 hover:text-black focus:outline-none"
|
||||
key={member?.member._id}
|
||||
onClick={() => {
|
||||
setDetailResourceId(member?.progress?.resourceId!);
|
||||
setSelectedMember(member.member);
|
||||
}}
|
||||
>
|
||||
<span className="relative z-10 flex items-center justify-between gap-1 text-sm">
|
||||
<span className="inline-grid grid-cols-[20px_auto] gap-2">
|
||||
<img
|
||||
src={
|
||||
member.member.avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${
|
||||
member.member.avatar
|
||||
}`
|
||||
: '/images/default-avatar.png'
|
||||
}
|
||||
alt={member.member.name || ''}
|
||||
className="h-5 w-5 shrink-0 rounded-full"
|
||||
/>
|
||||
<span className="truncate">{member?.member?.name}</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-gray-400">
|
||||
{member?.progress?.done} / {member?.progress?.total}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="absolute inset-0 bg-gray-100 group-hover:bg-gray-200"
|
||||
style={{
|
||||
width: `${
|
||||
(member?.progress?.done / member?.progress?.total) * 100
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{members.length > 4 && !showAll && (
|
||||
<button
|
||||
onClick={() => setShowAll(true)}
|
||||
className={'text-sm text-gray-400 underline'}
|
||||
>
|
||||
+ {members.length - 4} more
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showAll && (
|
||||
<button
|
||||
onClick={() => setShowAll(false)}
|
||||
className={'text-sm text-gray-400 underline'}
|
||||
>
|
||||
- Show less
|
||||
</button>
|
||||
)}
|
||||
|
||||
{members.length === 0 && (
|
||||
<div className="text-sm text-gray-500">No progress</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import type { TeamMember } from './TeamProgressPage';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { MemberProgressModal } from './MemberProgressModal';
|
||||
|
||||
type MemberProgressItemProps = {
|
||||
teamId: string;
|
||||
member: TeamMember;
|
||||
};
|
||||
export function MemberProgressItem(props: MemberProgressItemProps) {
|
||||
const { member, teamId } = props;
|
||||
|
||||
const memberProgress = member?.progress?.sort((a, b) => {
|
||||
return b.done - a.done;
|
||||
});
|
||||
|
||||
const [detailResourceId, setDetailResourceId] = useState<string | null>(null);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{detailResourceId && (
|
||||
<MemberProgressModal
|
||||
member={member}
|
||||
teamId={teamId}
|
||||
resourceId={detailResourceId}
|
||||
resourceType={'roadmap'}
|
||||
onClose={() => {
|
||||
setDetailResourceId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex h-full min-h-[270px] flex-col rounded-md border"
|
||||
key={member._id}
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b p-3">
|
||||
<img
|
||||
src={
|
||||
member.avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${member.avatar}`
|
||||
: '/images/default-avatar.png'
|
||||
}
|
||||
alt={member.name || ''}
|
||||
className="h-8 w-8 rounded-full"
|
||||
/>
|
||||
<div className="inline-grid">
|
||||
<h3 className="truncate font-medium">{member.name}</h3>
|
||||
<p className="truncate text-sm text-gray-500">{member.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex grow flex-col space-y-2 p-3">
|
||||
{(showAll ? memberProgress : memberProgress.slice(0, 4)).map(
|
||||
(progress) => {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setDetailResourceId(progress.resourceId)}
|
||||
className="group relative overflow-hidden rounded-md border p-2 hover:border-gray-300 hover:text-black focus:outline-none"
|
||||
key={progress.resourceId}
|
||||
>
|
||||
<span className="relative z-10 flex items-center justify-between text-sm">
|
||||
<span className="inline-grid">
|
||||
<span className={'truncate'}>{progress.resourceTitle}</span>
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 shrink-0 ml-1.5">
|
||||
{progress.done} / {progress.total}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="absolute inset-0 bg-gray-100 group-hover:bg-gray-200"
|
||||
style={{
|
||||
width: `${(progress.done / progress.total) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
)}
|
||||
|
||||
{memberProgress.length > 4 && !showAll && (
|
||||
<button
|
||||
onClick={() => setShowAll(true)}
|
||||
className={'text-sm text-gray-400 underline'}
|
||||
>
|
||||
+ {memberProgress.length - 4} more
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showAll && (
|
||||
<button
|
||||
onClick={() => setShowAll(false)}
|
||||
className={'text-sm text-gray-400 underline'}
|
||||
>
|
||||
- Show less
|
||||
</button>
|
||||
)}
|
||||
|
||||
{memberProgress.length === 0 && (
|
||||
<div className="text-sm text-gray-500">No progress</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { wireframeJSONToSVG } from 'roadmap-renderer';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import '../FrameRenderer/FrameRenderer.css';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import type { TeamMember } from './TeamProgressPage';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { renderTopicProgress } from '../../lib/resource-progress';
|
||||
import CloseIcon from '../../icons/close.svg';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
export type ProgressMapProps = {
|
||||
member: TeamMember;
|
||||
teamId: string;
|
||||
resourceId: string;
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
type MemberProgressResponse = {
|
||||
removed: string[];
|
||||
done: string[];
|
||||
learning: string[];
|
||||
skipped: string[];
|
||||
};
|
||||
|
||||
export function MemberProgressModal(props: ProgressMapProps) {
|
||||
const { resourceId, member, resourceType, teamId, onClose } = props;
|
||||
|
||||
const containerEl = useRef<HTMLDivElement>(null);
|
||||
const popupBodyEl = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [memberProgress, setMemberProgress] =
|
||||
useState<MemberProgressResponse>();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const toast = useToast();
|
||||
|
||||
let resourceJsonUrl = 'https://roadmap.sh';
|
||||
if (resourceType === 'roadmap') {
|
||||
resourceJsonUrl += `/${resourceId}.json`;
|
||||
} else {
|
||||
resourceJsonUrl += `/best-practices/${resourceId}.json`;
|
||||
}
|
||||
|
||||
async function getMemberProgress(
|
||||
teamId: string,
|
||||
memberId: string,
|
||||
resourceType: string,
|
||||
resourceId: string
|
||||
) {
|
||||
const { error, response } = await httpGet<MemberProgressResponse>(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-get-member-resource-progress/${teamId}/${memberId}?resourceType=${resourceType}&resourceId=${resourceId}`
|
||||
);
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Failed to get member progress');
|
||||
return;
|
||||
}
|
||||
|
||||
setMemberProgress(response);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
useKeydown('Escape', () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
useOutsideClick(popupBodyEl, () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!containerEl.current ||
|
||||
!resourceJsonUrl ||
|
||||
!resourceId ||
|
||||
!resourceType ||
|
||||
!teamId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
renderResource(resourceJsonUrl),
|
||||
getMemberProgress(teamId, member._id, resourceType, resourceId),
|
||||
])
|
||||
.then(([_, memberProgress = {}]) => {
|
||||
const {
|
||||
removed = [],
|
||||
done = [],
|
||||
learning = [],
|
||||
skipped = [],
|
||||
} = memberProgress;
|
||||
|
||||
done.forEach((id: string) => renderTopicProgress(id, 'done'));
|
||||
learning.forEach((id: string) => renderTopicProgress(id, 'learning'));
|
||||
skipped.forEach((id: string) => renderTopicProgress(id, 'skipped'));
|
||||
removed.forEach((id: string) => renderTopicProgress(id, 'removed'));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
toast.error(err?.message || 'Something went wrong. Please try again!');
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const currProgress = member.progress.find((p) => p.resourceId === resourceId);
|
||||
const memberDone = currProgress?.done || 0;
|
||||
const memberLearning = currProgress?.learning || 0;
|
||||
const memberSkipped = currProgress?.skipped || 0;
|
||||
const memberTotal = currProgress?.total || 0;
|
||||
|
||||
const progressPercentage = Math.round((memberDone / memberTotal) * 100);
|
||||
|
||||
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="p-4">
|
||||
<div className="mb-5 mt-0 text-left md:mt-4 md:text-center">
|
||||
<h2 className={'mb-1 text-lg font-bold md:text-2xl'}>
|
||||
{member.name}'s Progress
|
||||
</h2>
|
||||
<p
|
||||
className={
|
||||
'hidden text-xs text-gray-500 sm:text-sm md:block md:text-base'
|
||||
}
|
||||
>
|
||||
You are looking at {member.name}'s progress.{' '}
|
||||
<a
|
||||
target={'_blank'}
|
||||
href={`/${resourceId}?t=${teamId}`}
|
||||
className="text-blue-600 underline"
|
||||
>
|
||||
View your progress
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p className={'block text-gray-500 md:hidden'}>
|
||||
View your progress
|
||||
<a
|
||||
target={'_blank'}
|
||||
href={`/${resourceId}?t=${teamId}`}
|
||||
className="text-blue-600 underline"
|
||||
>
|
||||
on the roadmap page.
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<p class="-mx-4 mb-3 flex items-center justify-start border-b border-t py-2 text-sm sm:hidden px-4">
|
||||
<span class="mr-2.5 block rounded-sm bg-yellow-200 px-1 py-0.5 text-xs font-medium uppercase text-yellow-900">
|
||||
<span>{progressPercentage}</span>% Done
|
||||
</span>
|
||||
|
||||
<span>
|
||||
<span>{memberDone}</span> of <span>{memberTotal}</span> done
|
||||
</span>
|
||||
</p>
|
||||
<p class="-mx-4 mb-3 hidden items-center justify-center border-b border-t py-2 text-sm sm:flex">
|
||||
<span class="mr-2.5 block rounded-sm bg-yellow-200 px-1 py-0.5 text-xs font-medium uppercase text-yellow-900">
|
||||
<span>{progressPercentage}</span>% Done
|
||||
</span>
|
||||
|
||||
<span>
|
||||
<span>{memberDone}</span> completed
|
||||
</span>
|
||||
<span class="mx-1.5 text-gray-400">·</span>
|
||||
<span>
|
||||
<span data-progress-learning="">{memberLearning}</span> in
|
||||
progress
|
||||
</span>
|
||||
|
||||
{memberSkipped > 0 && (
|
||||
<>
|
||||
<span class="mx-1.5 text-gray-400">·</span>
|
||||
<span>
|
||||
<span data-progress-skipped="">{memberSkipped}</span>{' '}
|
||||
skipped
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<span class="mx-1.5 text-gray-400">·</span>
|
||||
<span>
|
||||
<span data-progress-total="">{memberTotal}</span> Total
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div ref={containerEl} className="px-4 pb-2"></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>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="popup-close absolute right-2.5 top-3 ml-auto inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900 sm:hidden"
|
||||
onClick={onClose}
|
||||
>
|
||||
<img src={CloseIcon} className="h-4 w-4" />
|
||||
<span class="sr-only">Close modal</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { useTeamId } from '../../hooks/use-team-id';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { MemberProgressItem } from './MemberProgressItem';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { $currentTeam } from '../../stores/team';
|
||||
import { GroupRoadmapItem } from './GroupRoadmapItem';
|
||||
import { setUrlParams } from '../../lib/browser';
|
||||
import { getUrlParams } from '../../lib/browser';
|
||||
import { $toastMessage } from '../../stores/toast';
|
||||
|
||||
export type UserProgress = {
|
||||
resourceTitle: string;
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
resourceId: string;
|
||||
isFavorite: boolean;
|
||||
done: number;
|
||||
learning: number;
|
||||
skipped: number;
|
||||
total: number;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type TeamMember = {
|
||||
_id: string;
|
||||
role: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
progress: UserProgress[];
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type GroupByRoadmap = {
|
||||
resourceId: string;
|
||||
resourceTitle: string;
|
||||
resourceType: string;
|
||||
members: {
|
||||
member: TeamMember;
|
||||
progress: UserProgress | undefined;
|
||||
}[];
|
||||
};
|
||||
|
||||
const groupingTypes = [
|
||||
{ label: 'Members', value: 'member' },
|
||||
{ label: 'Roadmaps', value: 'roadmap' },
|
||||
] as const;
|
||||
|
||||
export function TeamProgressPage() {
|
||||
const { teamId } = useTeamId();
|
||||
const { gb: groupBy } = getUrlParams();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const toast = useToast();
|
||||
const currentTeam = useStore($currentTeam);
|
||||
|
||||
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
|
||||
const [selectedGrouping, setSelectedGrouping] = useState<
|
||||
'roadmap' | 'member'
|
||||
>(groupBy || 'member');
|
||||
|
||||
async function getTeamProgress() {
|
||||
const { response, error } = await httpGet<TeamMember[]>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-progress/${teamId}`
|
||||
);
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Failed to get team progress');
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamMembers(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
getTeamProgress().finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [teamId]);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!teamId) {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedGrouping) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUrlParams({ gb: selectedGrouping });
|
||||
}, [selectedGrouping]);
|
||||
|
||||
const groupByRoadmap: GroupByRoadmap[] = [];
|
||||
for (const roadmap of currentTeam?.roadmaps || []) {
|
||||
const members: GroupByRoadmap['members'] = [];
|
||||
for (const member of teamMembers) {
|
||||
const progress = member.progress.find(
|
||||
(progress) => progress.resourceId === roadmap
|
||||
);
|
||||
if (!progress) {
|
||||
continue;
|
||||
}
|
||||
members.push({
|
||||
member,
|
||||
progress,
|
||||
});
|
||||
}
|
||||
|
||||
if (!members.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
groupByRoadmap.push({
|
||||
resourceId: roadmap,
|
||||
resourceTitle: members?.[0].progress?.resourceTitle || '',
|
||||
resourceType: 'roadmap',
|
||||
members,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
{groupingTypes.map((grouping) => (
|
||||
<button
|
||||
className={`rounded-md border p-1 px-2 text-sm ${
|
||||
selectedGrouping === grouping.value
|
||||
? ' border-gray-400 bg-gray-200 '
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => setSelectedGrouping(grouping.value)}
|
||||
>
|
||||
{grouping.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{selectedGrouping === 'roadmap' && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{groupByRoadmap.map((roadmap) => {
|
||||
return (
|
||||
<GroupRoadmapItem key={roadmap.resourceId} roadmap={roadmap} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{selectedGrouping === 'member' && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{teamMembers.map((member) => (
|
||||
<MemberProgressItem teamId={teamId} member={member} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
import { getUrlParams } from '../lib/browser';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import type { TeamDocument } from './CreateTeam/CreateTeamForm';
|
||||
import type { TeamResourceConfig } from './CreateTeam/RoadmapSelector';
|
||||
import { httpGet, httpPut } from '../lib/http';
|
||||
import { pageProgressMessage } from '../stores/page';
|
||||
import ExternalLinkIcon from '../icons/external-link.svg';
|
||||
import PlusIcon from '../icons/plus.svg';
|
||||
import type { PageType } from './CommandMenu/CommandMenu';
|
||||
import { UpdateTeamResourceModal } from './CreateTeam/UpdateTeamResourceModal';
|
||||
import { AddTeamRoadmap } from './AddTeamRoadmap';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { $canManageCurrentTeam } from '../stores/team';
|
||||
import {useToast} from "../hooks/use-toast";
|
||||
|
||||
export function TeamRoadmaps() {
|
||||
const { t: teamId } = getUrlParams();
|
||||
|
||||
const canManageCurrentTeam = useStore($canManageCurrentTeam);
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const [removingRoadmapId, setRemovingRoadmapId] = useState<string>('');
|
||||
const [isAddingRoadmap, setIsAddingRoadmap] = useState(false);
|
||||
const [changingRoadmapId, setChangingRoadmapId] = useState<string>('');
|
||||
const [team, setTeam] = useState<TeamDocument>();
|
||||
const [resourceConfigs, setResourceConfigs] = useState<TeamResourceConfig>(
|
||||
[]
|
||||
);
|
||||
const [allRoadmaps, setAllRoadmaps] = useState<PageType[]>([]);
|
||||
|
||||
async function loadAllRoadmaps() {
|
||||
const { error, response } = await httpGet<PageType[]>(`/pages.json`);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message || 'Something went wrong');
|
||||
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 loadTeam(teamIdToFetch: string) {
|
||||
const { response, error } = await httpGet<TeamDocument>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team/${teamIdToFetch}`
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error('Error loading team');
|
||||
window.location.href = '/account';
|
||||
return;
|
||||
}
|
||||
|
||||
setTeam(response);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
setResourceConfigs(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
loadTeam(teamId),
|
||||
loadTeamResourceConfig(teamId),
|
||||
loadAllRoadmaps(),
|
||||
]).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}, [teamId]);
|
||||
|
||||
async function deleteResource(roadmapId: string) {
|
||||
if (!team?._id) {
|
||||
return;
|
||||
}
|
||||
|
||||
pageProgressMessage.set(`Deleting roadmap from team`);
|
||||
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) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Roadmap removed');
|
||||
setResourceConfigs(response);
|
||||
}
|
||||
|
||||
async function onRemove(resourceId: string) {
|
||||
pageProgressMessage.set('Removing roadmap');
|
||||
|
||||
deleteResource(resourceId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isAddingRoadmap && (
|
||||
<AddTeamRoadmap
|
||||
onMakeChanges={(roadmapId) => {
|
||||
setChangingRoadmapId(roadmapId);
|
||||
setIsAddingRoadmap(false);
|
||||
}}
|
||||
teamId={team?._id!}
|
||||
setResourceConfigs={setResourceConfigs}
|
||||
allRoadmaps={allRoadmaps}
|
||||
availableRoadmaps={allRoadmaps.filter((r) => {
|
||||
const isAlreadyAdded = resourceConfigs.find(
|
||||
(c) => c.resourceId === r.id
|
||||
);
|
||||
return !isAlreadyAdded;
|
||||
})}
|
||||
onClose={() => setIsAddingRoadmap(false)}
|
||||
/>
|
||||
)}
|
||||
<div className={'grid grid-cols-1 gap-3 sm:grid-cols-2'}>
|
||||
{changingRoadmapId && (
|
||||
<UpdateTeamResourceModal
|
||||
onClose={() => setChangingRoadmapId('')}
|
||||
resourceId={changingRoadmapId}
|
||||
resourceType={'roadmap'}
|
||||
teamId={team?._id!}
|
||||
setTeamResourceConfig={setResourceConfigs}
|
||||
defaultRemovedItems={
|
||||
resourceConfigs.find((c) => c.resourceId === changingRoadmapId)
|
||||
?.removed || []
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{resourceConfigs.map((resourceConfig) => {
|
||||
const { resourceId, removed: removedTopics } = resourceConfig;
|
||||
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 py-4'}>
|
||||
<a
|
||||
href={`/${resourceId}?t=${teamId}`}
|
||||
className="group mb-0.5 flex items-center justify-between text-base font-medium leading-none text-black"
|
||||
target={'_blank'}
|
||||
>
|
||||
{roadmapTitle}
|
||||
|
||||
<img
|
||||
alt={'link'}
|
||||
src={ExternalLinkIcon}
|
||||
className="ml-2 h-4 w-4 opacity-20 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</a>
|
||||
{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>
|
||||
|
||||
{ canManageCurrentTeam && (
|
||||
<div className={'flex w-full justify-between pt-2 pb-3 px-3'}>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
'text-xs text-gray-500 underline hover:text-black focus:outline-none'
|
||||
}
|
||||
onClick={() => {
|
||||
setRemovingRoadmapId('');
|
||||
setChangingRoadmapId(resourceId);
|
||||
}}
|
||||
>
|
||||
Customize
|
||||
</button>
|
||||
|
||||
{removingRoadmapId !== resourceId && (
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
'text-xs text-red-500 underline hover:text-black focus:outline-none disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-red-500'
|
||||
}
|
||||
disabled={resourceConfigs.length === 1}
|
||||
onClick={() => setRemovingRoadmapId(resourceId)}
|
||||
title={
|
||||
resourceConfigs.length === 1
|
||||
? 'You must have at least one roadmap.'
|
||||
: 'Delete roadmap from team'
|
||||
}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
|
||||
{removingRoadmapId === resourceId && (
|
||||
<span className="text-xs">
|
||||
Are you sure?{' '}
|
||||
<button
|
||||
onClick={() => onRemove(resourceId)}
|
||||
className="mx-0.5 text-red-500 underline underline-offset-1"
|
||||
>
|
||||
Yes
|
||||
</button>{' '}
|
||||
<button
|
||||
onClick={() => setRemovingRoadmapId('')}
|
||||
className="text-red-500 underline underline-offset-1"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{canManageCurrentTeam && (
|
||||
<button
|
||||
onClick={() => setIsAddingRoadmap(true)}
|
||||
className="group flex min-h-[110px] flex-col items-center justify-center rounded-md border border-dashed border-gray-300 transition-colors hover:border-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
<img
|
||||
alt="add"
|
||||
src={PlusIcon}
|
||||
className="mb-1 h-6 w-6 opacity-20 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
<span className="text-sm text-gray-400 transition-colors focus:outline-none group-hover:text-black">
|
||||
Add Roadmap
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { httpGet, httpPut } from '../../lib/http';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import { useAuth } from '../../hooks/use-auth';
|
||||
import UploadProfilePicture from '../UpdateProfile/UploadProfilePicture';
|
||||
import type { TeamDocument } from '../CreateTeam/CreateTeamForm';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { useTeamId } from '../../hooks/use-team-id';
|
||||
import { DeleteTeamPopup } from '../DeleteTeamPopup';
|
||||
import { $currentTeam, $isCurrentTeamAdmin } from '../../stores/team';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
export function UpdateTeamForm() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const isCurrentTeamAdmin = useStore($isCurrentTeamAdmin);
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [avatar, setAvatar] = useState('');
|
||||
const [website, setWebsite] = useState('');
|
||||
const [linkedIn, setLinkedIn] = useState('');
|
||||
const [gitHub, setGitHub] = useState('');
|
||||
const [teamType, setTeamType] = useState('');
|
||||
const [teamSize, setTeamSize] = useState('');
|
||||
const [roadmaps, setRoadmaps] = useState<string[]>([]);
|
||||
const [bestPractices, setBestPractices] = useState<string[]>([]);
|
||||
const validTeamSizes = [
|
||||
'0-1',
|
||||
'2-10',
|
||||
'11-50',
|
||||
'51-200',
|
||||
'201-500',
|
||||
'501-1000',
|
||||
'1000+',
|
||||
];
|
||||
const [isDisabled, setIsDisabled] = useState(false);
|
||||
const { teamId } = useTeamId();
|
||||
|
||||
useEffect(() => {
|
||||
setIsDisabled(!isCurrentTeamAdmin);
|
||||
}, [isCurrentTeamAdmin]);
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
if (!name || !teamType) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { response, error } = await httpPut(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-update-team/${teamId}`,
|
||||
{
|
||||
name,
|
||||
website,
|
||||
type: teamType,
|
||||
gitHubUrl: gitHub || undefined,
|
||||
...(teamType === 'company' && {
|
||||
teamSize,
|
||||
linkedInUrl: linkedIn || undefined,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
setIsLoading(false);
|
||||
toast.error(error.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
if (response) {
|
||||
await loadTeam();
|
||||
setIsLoading(false);
|
||||
toast.success('Team updated successfully');
|
||||
}
|
||||
};
|
||||
|
||||
async function loadTeam() {
|
||||
const { response, error } = await httpGet<TeamDocument>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team/${teamId}`
|
||||
);
|
||||
if (error || !response) {
|
||||
console.log(error);
|
||||
return;
|
||||
}
|
||||
|
||||
setName(response.name);
|
||||
setAvatar(response.avatar || '');
|
||||
setWebsite(response?.links?.website || '');
|
||||
setLinkedIn(response?.links?.linkedIn || '');
|
||||
setGitHub(response?.links?.github || '');
|
||||
setTeamType(response.type);
|
||||
if (response.teamSize) {
|
||||
setTeamSize(response.teamSize);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!teamId) {
|
||||
return;
|
||||
}
|
||||
loadTeam().finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}, [teamId]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<UploadProfilePicture
|
||||
isDisabled={isDisabled}
|
||||
type="logo"
|
||||
avatarUrl={
|
||||
avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}`
|
||||
: '/images/default-avatar.png'
|
||||
}
|
||||
teamId={teamId!}
|
||||
/>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label
|
||||
for="name"
|
||||
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
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={isDisabled}
|
||||
required
|
||||
value={name}
|
||||
onInput={(e) => setName((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">
|
||||
Website
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
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://roadmap.sh"
|
||||
disabled={isDisabled}
|
||||
value={website}
|
||||
onInput={(e) => setWebsite((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
{teamType === 'company' && (
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label
|
||||
for="linkedIn"
|
||||
className="text-sm leading-none text-slate-500"
|
||||
>
|
||||
LinkedIn URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="linkedIn"
|
||||
id="linkedIn"
|
||||
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://linkedin.com/company/roadmapsh"
|
||||
disabled={isDisabled}
|
||||
value={linkedIn}
|
||||
onInput={(e) => setLinkedIn((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label for="gitHub" className="text-sm leading-none text-slate-500">
|
||||
GitHub URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="gitHub"
|
||||
id="gitHub"
|
||||
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={isDisabled}
|
||||
value={gitHub}
|
||||
onInput={(e) => setGitHub((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label
|
||||
for="type"
|
||||
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
|
||||
>
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
name="type"
|
||||
id="type"
|
||||
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"
|
||||
disabled={isDisabled}
|
||||
value={teamType}
|
||||
onChange={(e) =>
|
||||
setTeamType((e.target as HTMLSelectElement).value as any)
|
||||
}
|
||||
>
|
||||
<option value="" selected>
|
||||
Select type
|
||||
</option>
|
||||
<option value="company">Company</option>
|
||||
<option value="study_group">Study Group</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{teamType === '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-["*"]'
|
||||
>
|
||||
Team 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={teamType === 'company'}
|
||||
disabled={isDisabled}
|
||||
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 w-full flex-col">
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
|
||||
disabled={isDisabled || isLoading}
|
||||
>
|
||||
{isLoading ? <Spinner /> : 'Update'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{!isCurrentTeamAdmin && (
|
||||
<p className="mt-2 rounded-lg border border-red-300 bg-red-50 p-2 text-sm text-red-700">
|
||||
Only team admins can update team information.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isCurrentTeamAdmin && (
|
||||
<>
|
||||
<hr class="my-8" />
|
||||
{isDeleting && (
|
||||
<DeleteTeamPopup
|
||||
onClose={() => {
|
||||
toast.success('Team deleted successfully');
|
||||
setIsDeleting(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<h2 class="text-xl font-bold sm:text-2xl">Delete Team</h2>
|
||||
<p class="mt-2 text-gray-400">
|
||||
Permanently delete this team and all of its resources.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => setIsDeleting(true)}
|
||||
data-popup="delete-team-popup"
|
||||
class="font-regular mt-4 w-full rounded-lg bg-red-600 py-2 text-base text-white outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-1"
|
||||
>
|
||||
Delete Team
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import type { FunctionalComponent } from 'preact';
|
||||
import { TeamDropdown } from './TeamDropdown/TeamDropdown';
|
||||
import ChevronDown from '../icons/dropdown.svg';
|
||||
import { useTeamId } from '../hooks/use-team-id';
|
||||
import TeamProgress from '../icons/team-progress.svg';
|
||||
import SettingsIcon from '../icons/cog.svg';
|
||||
import MapIcon from '../icons/map.svg';
|
||||
import GroupIcon from '../icons/group.svg';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { $canManageCurrentTeam } from '../stores/team';
|
||||
|
||||
export const TeamSidebar: FunctionalComponent<{
|
||||
activePageId: string;
|
||||
}> = ({ activePageId, children }) => {
|
||||
const [menuShown, setMenuShown] = useState(false);
|
||||
const canManageCurrentTeam = useStore($canManageCurrentTeam);
|
||||
|
||||
const { teamId } = useTeamId();
|
||||
|
||||
const sidebarLinks = [
|
||||
{
|
||||
title: 'Progress',
|
||||
href: `/team/progress?t=${teamId}`,
|
||||
id: 'progress',
|
||||
icon: TeamProgress,
|
||||
},
|
||||
{
|
||||
title: 'Roadmaps',
|
||||
href: `/team/roadmaps?t=${teamId}`,
|
||||
id: 'roadmaps',
|
||||
icon: MapIcon,
|
||||
},
|
||||
{
|
||||
title: 'Members',
|
||||
href: `/team/members?t=${teamId}`,
|
||||
id: 'members',
|
||||
icon: GroupIcon,
|
||||
},
|
||||
{
|
||||
title: 'Settings',
|
||||
href: `/team/settings?t=${teamId}`,
|
||||
id: 'settings',
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<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"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="true"
|
||||
onClick={() => setMenuShown(!menuShown)}
|
||||
>
|
||||
{
|
||||
sidebarLinks.find((sidebarLink) => sidebarLink.id === activePageId)
|
||||
?.title
|
||||
}
|
||||
<img alt="menu" src={ChevronDown} class="h-4 w-4" />
|
||||
</button>
|
||||
{menuShown && (
|
||||
<ul
|
||||
id="settings-menu-dropdown"
|
||||
class="absolute left-0 right-0 z-50 mt-1 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' : ''
|
||||
}`}
|
||||
>
|
||||
<img alt={'teams'} src={GroupIcon} class={`mr-2 h-4 w-4`} />
|
||||
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' : ''
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
alt={'menu icon'}
|
||||
src={sidebarLink.icon}
|
||||
className="mr-2 h-4 w-4"
|
||||
/>
|
||||
{sidebarLink.title}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="container flex min-h-screen items-stretch">
|
||||
<aside class="hidden w-44 shrink-0 border-r border-slate-200 py-10 md:block">
|
||||
<TeamDropdown />
|
||||
<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">
|
||||
<img
|
||||
alt="menu icon"
|
||||
src={sidebarLink.icon}
|
||||
className="mr-2 h-4 w-4"
|
||||
/>
|
||||
{sidebarLink.title}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
<div className="grow px-0 py-0 md:px-10 md:py-10">{children}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,228 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||
import type { TeamDocument } from '../CreateTeam/CreateTeamForm';
|
||||
import type { TeamResourceConfig } from '../CreateTeam/RoadmapSelector';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import DropdownIcon from '../../icons/dropdown.svg';
|
||||
import {
|
||||
clearResourceProgress,
|
||||
refreshProgressCounters,
|
||||
renderTopicProgress,
|
||||
} from '../../lib/resource-progress';
|
||||
import { renderResourceProgress } from '../../lib/resource-progress';
|
||||
import { deleteUrlParam, getUrlParams, setUrlParams } from '../../lib/browser';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { useAuth } from '../../hooks/use-auth';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
type TeamVersionsProps = {
|
||||
resourceId: string;
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
};
|
||||
|
||||
type TeamVersionsResponse = {
|
||||
team: TeamDocument;
|
||||
config: TeamResourceConfig[0];
|
||||
}[];
|
||||
|
||||
export function TeamVersions(props: TeamVersionsProps) {
|
||||
const { t: teamId } = getUrlParams();
|
||||
if (!isLoggedIn()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { resourceId, resourceType } = props;
|
||||
const user = useAuth();
|
||||
const toast = useToast();
|
||||
const teamDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isPreparing, setIsPreparing] = useState(true);
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [containerOpacity, setContainerOpacity] = useState(0);
|
||||
const [teamVersions, setTeamVersions] = useState<TeamVersionsResponse>([]);
|
||||
const [selectedTeamVersion, setSelectedTeamVersion] = useState<
|
||||
TeamVersionsResponse[0] | null
|
||||
>(null);
|
||||
const [shouldStartLoading, setShouldStartLoading] = useState(false);
|
||||
|
||||
let shouldShowAvatar = true;
|
||||
const selectedAvatar = selectedTeamVersion
|
||||
? selectedTeamVersion.team.avatar
|
||||
: user?.avatar;
|
||||
const selectedLabel = selectedTeamVersion
|
||||
? selectedTeamVersion.team.name
|
||||
: user?.name;
|
||||
|
||||
// Show avatar if team has one, or if user has one otherwise use first letter of name
|
||||
if (selectedTeamVersion?.team.avatar) {
|
||||
shouldShowAvatar = true;
|
||||
} else if (!selectedTeamVersion && user?.avatar) {
|
||||
shouldShowAvatar = true;
|
||||
} else {
|
||||
shouldShowAvatar = false;
|
||||
}
|
||||
|
||||
useOutsideClick(teamDropdownRef, () => {
|
||||
setIsDropdownOpen(false);
|
||||
});
|
||||
|
||||
useKeydown('Escape', () => {
|
||||
setIsDropdownOpen(false);
|
||||
});
|
||||
|
||||
async function loadTeamVersions() {
|
||||
const { response, error } = await httpGet<TeamVersionsResponse>(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-get-team-versions?${new URLSearchParams({
|
||||
resourceId,
|
||||
resourceType,
|
||||
})}`
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Failed to load team versions.');
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamVersions(response);
|
||||
if (teamId) {
|
||||
const foundVersion = response.find((v) => v.team._id === teamId) || null;
|
||||
setSelectedTeamVersion(foundVersion);
|
||||
setShouldStartLoading(true);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setIsPreparing(false);
|
||||
|
||||
setTimeout(() => {
|
||||
setContainerOpacity(100);
|
||||
}, 50);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadTeamVersions().finally(() => {
|
||||
//
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (isPreparing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldStartLoading) {
|
||||
return;
|
||||
}
|
||||
clearResourceProgress('removed');
|
||||
if (!selectedTeamVersion) {
|
||||
deleteUrlParam('t');
|
||||
renderResourceProgress(resourceType, resourceId).then();
|
||||
return;
|
||||
}
|
||||
|
||||
setUrlParams({ t: selectedTeamVersion.team._id! });
|
||||
|
||||
renderResourceProgress(resourceType, resourceId).then(() => {
|
||||
selectedTeamVersion.config?.removed?.forEach((topic) => {
|
||||
renderTopicProgress(topic, 'removed');
|
||||
});
|
||||
refreshProgressCounters();
|
||||
});
|
||||
setShouldStartLoading(true);
|
||||
}, [selectedTeamVersion]);
|
||||
|
||||
if (!teamVersions.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative h-7 transition-opacity duration-500 sm:h-auto opacity-${containerOpacity}`}
|
||||
>
|
||||
<button
|
||||
className="inline-flex h-7 items-center justify-between gap-1 rounded-md border px-1.5 py-1.5 text-xs font-medium hover:bg-gray-50 focus:outline-0 sm:h-8 sm:w-40 sm:px-3 sm:text-sm"
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
>
|
||||
<div className="hidden w-full items-center justify-between sm:flex">
|
||||
<span className="truncate">
|
||||
{selectedTeamVersion?.team.name || 'Team Versions'}
|
||||
</span>
|
||||
<img
|
||||
alt="Dropdown"
|
||||
src={DropdownIcon}
|
||||
class="h-3 w-3 sm:h-4 sm:w-4"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:hidden">
|
||||
{shouldShowAvatar ? (
|
||||
<img
|
||||
src={
|
||||
selectedAvatar
|
||||
? `${
|
||||
import.meta.env.PUBLIC_AVATAR_BASE_URL
|
||||
}/${selectedAvatar}`
|
||||
: '/images/default-avatar.png'
|
||||
}
|
||||
alt={`${selectedLabel} Avatar`}
|
||||
className="h-5 w-5 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-gray-200 text-xs">
|
||||
{selectedLabel?.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{isDropdownOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40 block bg-black/20 sm:hidden"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
ref={teamDropdownRef}
|
||||
className="fixed bottom-0 left-0 z-50 mt-1 h-fit w-full overflow-hidden rounded-md bg-white py-0.5 shadow-md sm:absolute sm:left-0 sm:right-0 sm:top-full sm:border"
|
||||
>
|
||||
<button
|
||||
className={`flex h-8 w-full items-center justify-between px-3 py-1.5 text-xs font-medium hover:bg-gray-100 sm:text-sm ${
|
||||
!selectedTeamVersion ? 'bg-gray-100' : 'bg-white'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedTeamVersion(null);
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="truncate">Personal</span>
|
||||
</div>
|
||||
</button>
|
||||
{teamVersions.map((team) => {
|
||||
const isSelectedTeam =
|
||||
selectedTeamVersion?.team._id === team.team._id;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`flex h-8 w-full items-center justify-between px-3 py-1.5 text-xs font-medium hover:bg-gray-100 sm:text-sm ${
|
||||
isSelectedTeam ? 'bg-gray-100' : 'bg-white'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedTeamVersion(team);
|
||||
setIsDropdownOpen(false);
|
||||
setShouldStartLoading(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="truncate">{team.team.name}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import ChevronDown from '../icons/dropdown.svg';
|
||||
import { httpGet } from '../lib/http';
|
||||
import { useTeamId } from '../hooks/use-team-id';
|
||||
import { useAuth } from '../hooks/use-auth';
|
||||
import { useOutsideClick } from '../hooks/use-outside-click';
|
||||
import type { TeamDocument } from './CreateTeam/CreateTeamForm';
|
||||
import { pageProgressMessage } from '../stores/page';
|
||||
import { useToast } from '../hooks/use-toast';
|
||||
|
||||
type TeamListResponse = TeamDocument[];
|
||||
|
||||
export function TeamsList() {
|
||||
const [teamList, setTeamList] = useState<TeamDocument[]>([]);
|
||||
const user = useAuth();
|
||||
const toast = useToast();
|
||||
async function getAllTeam() {
|
||||
const { response, error } = await httpGet<TeamListResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-teams`
|
||||
);
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamList(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getAllTeam().finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative max-w-[500px] mx-auto">
|
||||
<div className="w-full px-2 py-2">
|
||||
<div className={'mb-8 hidden md:block'}>
|
||||
<h2 className={'text-3xl font-bold sm:text-4xl'}>Teams</h2>
|
||||
<p className="mt-2 text-gray-400">
|
||||
Here are the teams you are part of
|
||||
</p>
|
||||
</div>
|
||||
<ul class="mb-3 flex flex-col gap-1">
|
||||
<li>
|
||||
<a
|
||||
className="flex w-full cursor-pointer items-center justify-between gap-2 truncate rounded border p-2 text-sm font-medium hover:border-gray-300 hover:bg-gray-50"
|
||||
href="/account"
|
||||
>
|
||||
<span className="flex flex-grow items-center gap-2">
|
||||
<img
|
||||
src={
|
||||
user?.avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${
|
||||
user?.avatar
|
||||
}`
|
||||
: '/images/default-avatar.png'
|
||||
}
|
||||
alt={user?.name || ''}
|
||||
className="h-6 w-6 rounded-full"
|
||||
/>
|
||||
<span className="truncate">Personal Account</span>
|
||||
</span>
|
||||
<span>→</span>
|
||||
</a>
|
||||
</li>
|
||||
{teamList.map((team) => (
|
||||
<li>
|
||||
<a
|
||||
className="flex w-full cursor-pointer items-center justify-between gap-2 rounded border p-2 text-sm font-medium hover:border-gray-300 hover:bg-gray-50"
|
||||
href={`/team/progress?t=${team._id}`}
|
||||
>
|
||||
<span className="flex flex-grow items-center gap-2">
|
||||
<img
|
||||
src={
|
||||
team.avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${
|
||||
team.avatar
|
||||
}`
|
||||
: '/images/default-avatar.png'
|
||||
}
|
||||
alt={team.name || ''}
|
||||
className="h-6 w-6 rounded-full"
|
||||
/>
|
||||
<span className="truncate">{team.name}</span>
|
||||
</span>
|
||||
<span>→</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<a
|
||||
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
|
||||
href="/team/new"
|
||||
>
|
||||
<span class='mr-2'>+</span>
|
||||
<span>New Team</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { $toastMessage } from '../stores/toast';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import { CheckIcon } from './ReactIcons/CheckIcon';
|
||||
import { ErrorIcon } from './ReactIcons/ErrorIcon';
|
||||
import { WarningIcon } from './ReactIcons/WarningIcon';
|
||||
import { InfoIcon } from './ReactIcons/InfoIcon';
|
||||
import { Spinner } from './ReactIcons/Spinner';
|
||||
|
||||
export interface Props {}
|
||||
|
||||
export function Toaster(props: Props) {
|
||||
const toastMessage = useStore($toastMessage);
|
||||
|
||||
useEffect(() => {
|
||||
if (toastMessage === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const removeMessage = setTimeout(() => {
|
||||
if (toastMessage?.type !== 'loading') {
|
||||
$toastMessage.set(undefined);
|
||||
}
|
||||
}, 2500);
|
||||
|
||||
return () => {
|
||||
clearTimeout(removeMessage);
|
||||
};
|
||||
}, [toastMessage]);
|
||||
|
||||
if (!toastMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
$toastMessage.set(undefined);
|
||||
}}
|
||||
className={`fixed bottom-5 left-1/2 max-w-[300px] animate-fade-slide-up min-w-[300px] sm:min-w-[auto] z-50`}
|
||||
>
|
||||
<div
|
||||
className={`flex -translate-x-1/2 transform cursor-pointer items-center gap-2 rounded-md border border-gray-200 bg-white py-3 pl-4 pr-5 text-black shadow-md hover:bg-gray-50`}
|
||||
>
|
||||
{toastMessage.type === 'success' && (
|
||||
<CheckIcon additionalClasses="h-5 w-5 shrink-0 relative top-[0.5px] text-green-500" />
|
||||
)}
|
||||
|
||||
{toastMessage.type === 'error' && (
|
||||
<ErrorIcon additionalClasses="h-5 w-5 shrink-0 relative top-[0.5px] text-red-500" />
|
||||
)}
|
||||
|
||||
{toastMessage.type === 'warning' && (
|
||||
<WarningIcon additionalClasses="h-5 w-5 shrink-0 relative top-[0.5px] text-orange-500" />
|
||||
)}
|
||||
|
||||
{toastMessage.type === 'info' && (
|
||||
<InfoIcon additionalClasses="h-5 w-5 shrink-0 relative top-[0.5px] text-blue-500" />
|
||||
)}
|
||||
|
||||
{toastMessage.type === 'loading' && <Spinner isDualRing={false} />}
|
||||
|
||||
<span className="flex-grow text-base">{toastMessage.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
type ContributionInputProps = {
|
||||
id: number;
|
||||
@@ -117,7 +116,6 @@ type ContributionFormProps = {
|
||||
|
||||
export function ContributionForm(props: ContributionFormProps) {
|
||||
const { onClose, resourceType, resourceId, topicId } = props;
|
||||
const toast = useToast();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [links, setLinks] = useState<
|
||||
{ id: number; title: string; link: string }[]
|
||||
@@ -146,7 +144,7 @@ export function ContributionForm(props: ContributionFormProps) {
|
||||
setIsSubmitting(false);
|
||||
|
||||
if (!response || error) {
|
||||
toast.error(error?.message || 'Something went wrong. Please try again.');
|
||||
alert(error?.message || 'Something went wrong. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,17 +9,14 @@ import { useToggleTopic } from '../../hooks/use-toggle-topic';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import {
|
||||
refreshProgressCounters,
|
||||
isTopicDone,
|
||||
renderTopicProgress,
|
||||
ResourceProgressType,
|
||||
ResourceType,
|
||||
updateResourceProgress as updateResourceProgressApi,
|
||||
} from '../../lib/resource-progress';
|
||||
import { pageProgressMessage, sponsorHidden } from '../../stores/page';
|
||||
import { TopicProgressButton } from './TopicProgressButton';
|
||||
import { ContributionForm } from './ContributionForm';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
export function TopicDetail() {
|
||||
const [contributionAlertMessage, setContributionAlertMessage] = useState('');
|
||||
@@ -28,17 +25,29 @@ export function TopicDetail() {
|
||||
const [isContributing, setIsContributing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [topicHtml, setTopicHtml] = useState('');
|
||||
const toast = useToast();
|
||||
|
||||
const isGuest = useMemo(() => !isLoggedIn(), []);
|
||||
const topicRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Details of the currently loaded topic
|
||||
const [topicId, setTopicId] = useState('');
|
||||
const [resourceStatus, setResourceStatus] = useState<ResourceProgressType>('pending')
|
||||
const [resourceId, setResourceId] = useState('');
|
||||
const [resourceType, setResourceType] = useState<ResourceType>('roadmap');
|
||||
|
||||
const showLoginPopup = () => {
|
||||
const popupEl = document.querySelector(`#login-popup`);
|
||||
if (!popupEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
popupEl.classList.remove('hidden');
|
||||
popupEl.classList.add('flex');
|
||||
const focusEl = popupEl.querySelector<HTMLElement>('[autofocus]');
|
||||
if (focusEl) {
|
||||
focusEl.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// Close the topic detail when user clicks outside the topic detail
|
||||
useOutsideClick(topicRef, () => {
|
||||
setIsActive(false);
|
||||
@@ -53,7 +62,7 @@ export function TopicDetail() {
|
||||
// Toggle topic is available even if the component UI is not active
|
||||
// This is used on the best practice screen where we have the checkboxes
|
||||
// to mark the topic as done/undone.
|
||||
useToggleTopic(({ topicId, resourceType, resourceId, status }) => {
|
||||
useToggleTopic(({ topicId, resourceType, resourceId }) => {
|
||||
if (isGuest) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
@@ -61,33 +70,35 @@ export function TopicDetail() {
|
||||
|
||||
pageProgressMessage.set('Updating');
|
||||
|
||||
updateResourceProgressApi(
|
||||
{
|
||||
topicId,
|
||||
resourceId,
|
||||
resourceType,
|
||||
},
|
||||
status === 'done' ? 'pending' : 'done'
|
||||
)
|
||||
// Toggle the topic status
|
||||
isTopicDone({ topicId, resourceId, resourceType })
|
||||
.then((oldIsDone) =>
|
||||
updateResourceProgressApi(
|
||||
{
|
||||
topicId,
|
||||
resourceId,
|
||||
resourceType,
|
||||
},
|
||||
oldIsDone ? 'pending' : 'done'
|
||||
)
|
||||
)
|
||||
.then(({ done = [] }) => {
|
||||
renderTopicProgress(
|
||||
topicId,
|
||||
done.includes(topicId) ? 'done' : 'pending'
|
||||
);
|
||||
refreshProgressCounters();
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
alert(err.message);
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
return;
|
||||
});
|
||||
|
||||
// Load the topic detail when the topic detail is active
|
||||
useLoadTopic(({ topicId, resourceType, resourceId, status }) => {
|
||||
useLoadTopic(({ topicId, resourceType, resourceId }) => {
|
||||
setIsLoading(true);
|
||||
setIsActive(true);
|
||||
sponsorHidden.set(true);
|
||||
@@ -96,7 +107,6 @@ export function TopicDetail() {
|
||||
setTopicId(topicId);
|
||||
setResourceType(resourceType);
|
||||
setResourceId(resourceId);
|
||||
setResourceStatus(status);
|
||||
|
||||
const topicPartial = topicId.replaceAll(':', '/');
|
||||
const topicUrl =
|
||||
@@ -176,7 +186,7 @@ export function TopicDetail() {
|
||||
topicId={topicId}
|
||||
resourceId={resourceId}
|
||||
resourceType={resourceType}
|
||||
status={resourceStatus}
|
||||
onShowLoginPopup={showLoginPopup}
|
||||
onClose={() => {
|
||||
setIsActive(false);
|
||||
setIsContributing(false);
|
||||
@@ -206,8 +216,7 @@ export function TopicDetail() {
|
||||
{/* Contribution */}
|
||||
<div className="mt-8 flex-1 border-t">
|
||||
<p class="mb-2 mt-2 text-sm leading-relaxed text-gray-400">
|
||||
Help others learn by submitting links to learn more about this
|
||||
topic{' '}
|
||||
Help others learn by submitting links to learn more about this topic{' '}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
|
||||
@@ -7,19 +7,18 @@ import { isLoggedIn } from '../../lib/jwt';
|
||||
import {
|
||||
ResourceProgressType,
|
||||
ResourceType,
|
||||
getTopicStatus,
|
||||
refreshProgressCounters,
|
||||
renderTopicProgress,
|
||||
updateResourceProgress,
|
||||
} from '../../lib/resource-progress';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
type TopicProgressButtonProps = {
|
||||
topicId: string;
|
||||
resourceId: string;
|
||||
resourceType: ResourceType;
|
||||
status: ResourceProgressType;
|
||||
|
||||
onShowLoginPopup: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
@@ -28,16 +27,14 @@ const statusColors: Record<ResourceProgressType, string> = {
|
||||
learning: 'bg-yellow-500',
|
||||
pending: 'bg-gray-300',
|
||||
skipped: 'bg-black',
|
||||
removed: ''
|
||||
};
|
||||
|
||||
export function TopicProgressButton(props: TopicProgressButtonProps) {
|
||||
const { topicId, resourceId, resourceType, status, onClose } = props;
|
||||
console.log(status)
|
||||
const { topicId, resourceId, resourceType, onClose, onShowLoginPopup } =
|
||||
props;
|
||||
|
||||
const toast = useToast();
|
||||
const [isUpdatingProgress, setIsUpdatingProgress] = useState(false);
|
||||
const [progress, setProgress] = useState<ResourceProgressType>(status);
|
||||
const [isUpdatingProgress, setIsUpdatingProgress] = useState(true);
|
||||
const [progress, setProgress] = useState<ResourceProgressType>('pending');
|
||||
const [showChangeStatus, setShowChangeStatus] = useState(false);
|
||||
|
||||
const changeStatusRef = useRef<HTMLDivElement>(null);
|
||||
@@ -48,6 +45,20 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
|
||||
|
||||
const isGuest = useMemo(() => !isLoggedIn(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!topicId || !resourceId || !resourceType) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdatingProgress(true);
|
||||
getTopicStatus({ topicId, resourceId, resourceType })
|
||||
.then((status) => {
|
||||
setIsUpdatingProgress(false);
|
||||
setProgress(status);
|
||||
})
|
||||
.catch(console.error);
|
||||
}, [topicId, resourceId, resourceType]);
|
||||
|
||||
// Mark as done
|
||||
useKeydown(
|
||||
'd',
|
||||
@@ -108,7 +119,7 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
|
||||
const handleUpdateResourceProgress = (progress: ResourceProgressType) => {
|
||||
if (isGuest) {
|
||||
onClose();
|
||||
showLoginPopup();
|
||||
onShowLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -128,7 +139,7 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
|
||||
refreshProgressCounters();
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message || 'Error updating progress');
|
||||
alert(err.message);
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
|
||||
@@ -86,8 +86,6 @@ export function UpdateProfileForm() {
|
||||
<p className="mt-2 text-gray-400">Update your profile details below.</p>
|
||||
</div>
|
||||
<UploadProfilePicture
|
||||
type="avatar"
|
||||
label="Profile picture"
|
||||
avatarUrl={
|
||||
avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}`
|
||||
|
||||
@@ -7,11 +7,7 @@ interface PreviewFile extends File {
|
||||
}
|
||||
|
||||
type UploadProfilePictureProps = {
|
||||
isDisabled?: boolean;
|
||||
avatarUrl: string;
|
||||
type: 'avatar' | 'logo';
|
||||
label?: string;
|
||||
teamId?: string;
|
||||
};
|
||||
|
||||
function getDimensions(file: File) {
|
||||
@@ -52,7 +48,7 @@ async function validateImage(file: File): Promise<string | null> {
|
||||
}
|
||||
|
||||
export default function UploadProfilePicture(props: UploadProfilePictureProps) {
|
||||
const { avatarUrl, teamId, type, isDisabled = false } = props;
|
||||
const { avatarUrl } = props;
|
||||
|
||||
const [file, setFile] = useState<PreviewFile | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
@@ -95,26 +91,14 @@ export default function UploadProfilePicture(props: UploadProfilePictureProps) {
|
||||
formData.append('avatar', file);
|
||||
|
||||
// FIXME: Use `httpCall` helper instead of fetch
|
||||
let res: Response;
|
||||
if (type === 'avatar') {
|
||||
res = await fetch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-upload-profile-picture`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
}
|
||||
);
|
||||
} else {
|
||||
res = await fetch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-upload-team-logo/${teamId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
}
|
||||
);
|
||||
}
|
||||
const res = await fetch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-upload-profile-picture`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
}
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
@@ -148,11 +132,9 @@ export default function UploadProfilePicture(props: UploadProfilePictureProps) {
|
||||
encType="multipart/form-data"
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
{props.label && (
|
||||
<label htmlFor="avatar" className="text-sm leading-none text-slate-500">
|
||||
{props.label}
|
||||
</label>
|
||||
)}
|
||||
<label htmlFor="avatar" className="text-sm leading-none text-slate-500">
|
||||
Profile Picture
|
||||
</label>
|
||||
<div className="mb-2 mt-2 flex items-center gap-2">
|
||||
<label
|
||||
htmlFor="avatar"
|
||||
@@ -170,9 +152,8 @@ export default function UploadProfilePicture(props: UploadProfilePictureProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!file && !isDisabled && (
|
||||
{!file && (
|
||||
<button
|
||||
disabled={isDisabled}
|
||||
type="button"
|
||||
className="absolute bottom-1 right-0 rounded bg-gray-600 px-2 py-1 text-xs leading-none text-gray-50 ring-2 ring-white"
|
||||
onClick={() => {
|
||||
@@ -185,7 +166,6 @@ export default function UploadProfilePicture(props: UploadProfilePictureProps) {
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
disabled={isDisabled}
|
||||
ref={inputRef}
|
||||
id="avatar"
|
||||
type="file"
|
||||
@@ -204,14 +184,14 @@ export default function UploadProfilePicture(props: UploadProfilePictureProps) {
|
||||
inputRef.current?.value && (inputRef.current.value = '');
|
||||
}}
|
||||
className="flex h-9 min-w-[96px] items-center justify-center rounded-md border border-red-300 bg-red-100 text-sm font-medium text-red-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isLoading || isDisabled}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex h-9 min-w-[96px] items-center justify-center rounded-md border border-gray-300 text-sm font-medium text-black disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isLoading || isDisabled}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Uploading..' : 'Upload'}
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
import type { VideoFileType } from '../lib/video';
|
||||
import YouTubeAlert from "./YouTubeAlert.astro";
|
||||
|
||||
export interface Props {
|
||||
video: VideoFileType;
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
class="bg-red-600 group-hover:bg-red-800 group-hover: px-1.5 py-0.5 rounded-sm text-white text-xs uppercase font-medium mr-2"
|
||||
>New</span
|
||||
>
|
||||
<span class="underline mr-1">We also have a YouTube channel with visual content</span>
|
||||
<span class="underline mr-1">Roadmap topics to be covered on YouTube</span>
|
||||
<span>»</span>
|
||||
</a>
|
||||
|
||||
@@ -4,7 +4,7 @@ pdfUrl: '/pdfs/best-practices/api-security.pdf'
|
||||
order: 2
|
||||
briefTitle: 'API Security'
|
||||
briefDescription: 'API Security Best Practices'
|
||||
isNew: false
|
||||
isNew: true
|
||||
isUpcoming: false
|
||||
title: 'API Security Best Practices'
|
||||
description: 'Detailed list of best practices to make your APIs secure'
|
||||
|
||||
@@ -4,7 +4,7 @@ pdfUrl: '/pdfs/best-practices/aws.pdf'
|
||||
order: 3
|
||||
briefTitle: 'AWS'
|
||||
briefDescription: 'AWS Best Practices'
|
||||
isNew: false
|
||||
isNew: true
|
||||
isUpcoming: false
|
||||
title: 'AWS Best Practices'
|
||||
description: 'Detailed list of best practices for Amazon Web Services (AWS)'
|
||||
|
||||
@@ -4,7 +4,7 @@ pdfUrl: '/pdfs/best-practices/code-review.pdf'
|
||||
order: 2
|
||||
briefTitle: 'Code Reviews'
|
||||
briefDescription: 'Code Review Best Practices'
|
||||
isNew: false
|
||||
isNew: true
|
||||
isUpcoming: false
|
||||
title: 'Code Review Best Practices'
|
||||
description: 'Detailed list of best practices for effective code reviews and quality'
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
Use a compression method such as Gzip or Brotli to reduce the size of your JavaScript files. With a smaller sizes file, users will be able to download the asset faster, resulting in improved performance.
|
||||
|
||||
- [Check GZIP compression](https://checkgzipcompression.net/)
|
||||
- [Check GZIP compression](https://checkgzipcompression.com/)
|
||||
- [Check Brotli Compression](https://tools.keycdn.com/brotli-test)
|
||||
- [Can I use... Brotli](https://caniuse.com/#feat=brotli)
|
||||
|
||||
@@ -4,7 +4,7 @@ pdfUrl: '/pdfs/best-practices/frontend-performance.pdf'
|
||||
order: 1
|
||||
briefTitle: 'Frontend Performance'
|
||||
briefDescription: 'Frontend Performance Best Practices'
|
||||
isNew: false
|
||||
isNew: true
|
||||
isUpcoming: false
|
||||
title: 'Frontend Performance Best Practices'
|
||||
description: 'Detailed list of best practices to improve your frontend performance'
|
||||
|
||||
@@ -33,7 +33,7 @@ OpenAI has been a major contributor to this space in the past few years with the
|
||||
|
||||
## Training an LLM Model
|
||||
|
||||
On a high level, training an LLM model involves three steps i.e. data collection, training and evaluation.
|
||||
On a high level, training an LLM model involves thre steps i.e. data collection, training and evaluation.
|
||||
|
||||
- **Data Collection** The first step is to collect the data that will be used to train the model. The data can be collected from various sources such as Wikipedia, news articles, books, websites etc.
|
||||
|
||||
@@ -63,8 +63,11 @@ Instruction Tuned LLMs = Base LLMs + Further Tuning + RLHF
|
||||
|
||||
To build an Instruction Tuned LLM, a Base LLM is taken and is further trained using a large dataset covering sample "Instructions" and how the model should perform as a result of those instructions. The model is then fine-tuned using a technique called "Reinforcement Learning with Human Feedback" (RLHF) which allows the model to learn from human feedback and improve its performance over time.
|
||||
|
||||
|
||||
## Conclusion
|
||||
|
||||
LLMs are a powerful tool that can be used to solve a wide range of language-related tasks. They are being used in various industries such as healthcare, finance, education etc. to automate processes and improve efficiency. LLMs have the potential to revolutionize the way we interact with computers and make our lives easier.
|
||||
|
||||
And that's it for this guide. We have some exciting AI related content coming up in the next few weeks. Stay tuned!
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
# Zones
|
||||
|
||||
- [Angular - NgZone](https://angular.io/guide/zone)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user