Add teams support

chore/cache
Kamran Ahmed 1 year ago
parent bbe8125fc1
commit 03ba0c384b
  1. 1
      package.json
  2. 8
      pnpm-lock.yaml
  3. BIN
      public/authors/kamran.jpeg
  4. BIN
      public/og-images/sql-roadmap.png
  5. 95
      src/components/AccountSidebar.astro
  6. 2
      src/components/Activity/ActivityPage.tsx
  7. 76
      src/components/Activity/ResourceProgress.tsx
  8. 174
      src/components/AddTeamRoadmap.tsx
  9. 7
      src/components/AuthenticationFlow/GitHubButton.tsx
  10. 7
      src/components/AuthenticationFlow/GoogleButton.tsx
  11. 7
      src/components/AuthenticationFlow/LinkedInButton.tsx
  12. 8
      src/components/Authenticator/authenticator.ts
  13. 42
      src/components/CommandMenu/CommandMenu.tsx
  14. 216
      src/components/CreateTeam/CreateTeamForm.tsx
  15. 44
      src/components/CreateTeam/NextButton.tsx
  16. 221
      src/components/CreateTeam/RoadmapSelector.tsx
  17. 135
      src/components/CreateTeam/RoleDropdown.tsx
  18. 122
      src/components/CreateTeam/Step0.tsx
  19. 252
      src/components/CreateTeam/Step1.tsx
  20. 59
      src/components/CreateTeam/Step2.tsx
  21. 198
      src/components/CreateTeam/Step3.tsx
  22. 26
      src/components/CreateTeam/Step4.tsx
  23. 206
      src/components/CreateTeam/UpdateTeamResourceModal.tsx
  24. 131
      src/components/DeleteTeamPopup.tsx
  25. 5
      src/components/FeaturedItems/MarkFavorite.tsx
  26. 6
      src/components/Footer.astro
  27. 13
      src/components/FrameRenderer/FrameRenderer.css
  28. 14
      src/components/FrameRenderer/renderer.ts
  29. 2
      src/components/Navigation/AccountDropdown.astro
  30. 1
      src/components/Navigation/navigation.ts
  31. 109
      src/components/Notification/NotificationPage.tsx
  32. 3
      src/components/PageSponsor.tsx
  33. 2
      src/components/ReactIcons/CheckIcon.tsx
  34. 26
      src/components/ReactIcons/ChevronDownIcon.tsx
  35. 41
      src/components/ReactIcons/ErrorIcon.tsx
  36. 41
      src/components/ReactIcons/InfoIcon.tsx
  37. 35
      src/components/ReactIcons/Spinner.tsx
  38. 27
      src/components/ReactIcons/TrashIcon.tsx
  39. 41
      src/components/ReactIcons/WarningIcon.tsx
  40. 176
      src/components/RespondInviteForm.tsx
  41. 99
      src/components/RoadmapHeader.astro
  42. 156
      src/components/SearchSelector.tsx
  43. 51
      src/components/Stepper.tsx
  44. 183
      src/components/TeamDropdown/TeamDropdown.tsx
  45. 121
      src/components/TeamMembers/InviteMemberPopup.tsx
  46. 40
      src/components/TeamMembers/LeaveTeamButton.tsx
  47. 103
      src/components/TeamMembers/MemberActionDropdown.tsx
  48. 15
      src/components/TeamMembers/RoleBadge.tsx
  49. 238
      src/components/TeamMembers/TeamMembersPage.tsx
  50. 109
      src/components/TeamMembers/UpdateMemberPopup.tsx
  51. 118
      src/components/TeamProgress/GroupRoadmapItem.tsx
  52. 105
      src/components/TeamProgress/MemberProgressItem.tsx
  53. 231
      src/components/TeamProgress/MemberProgressModal.tsx
  54. 169
      src/components/TeamProgress/TeamProgressPage.tsx
  55. 275
      src/components/TeamRoadmaps.tsx
  56. 291
      src/components/TeamSettings/UpdateTeamForm.tsx
  57. 142
      src/components/TeamSidebar.tsx
  58. 220
      src/components/TeamVersions/TeamVersions.tsx
  59. 102
      src/components/TeamsList.tsx
  60. 67
      src/components/Toast.tsx
  61. 4
      src/components/TopicDetail/ContributionForm.tsx
  62. 4
      src/components/TopicDetail/TopicDetail.tsx
  63. 8
      src/components/TopicDetail/TopicProgressButton.tsx
  64. 2
      src/components/UpdateProfile/UpdateProfileForm.tsx
  65. 50
      src/components/UpdateProfile/UploadProfilePicture.tsx
  66. 6
      src/directives/client-authenticated.mjs
  67. 5
      src/hooks/use-auth.ts
  68. 16
      src/hooks/use-params.ts
  69. 12
      src/hooks/use-team-id.ts
  70. 23
      src/hooks/use-toast.ts
  71. 3
      src/icons/accept.svg
  72. 1
      src/icons/building.svg
  73. 5
      src/icons/close-dark.svg
  74. 1
      src/icons/external-link.svg
  75. 1
      src/icons/group.svg
  76. 1
      src/icons/map.svg
  77. 1
      src/icons/more-vertical.svg
  78. 3
      src/icons/notification.svg
  79. 1
      src/icons/plus.svg
  80. 4
      src/icons/search.svg
  81. 1
      src/icons/team-progress.svg
  82. 1
      src/icons/users.svg
  83. 2
      src/layouts/BaseLayout.astro
  84. 26
      src/lib/browser.ts
  85. 14
      src/lib/http.ts
  86. 1
      src/lib/jwt.ts
  87. 21
      src/lib/resource-progress.ts
  88. 9
      src/pages/[roadmapId]/index.astro
  89. 2
      src/pages/about.astro
  90. 16
      src/pages/account/notification.astro
  91. 4
      src/pages/pages.json.ts
  92. 14
      src/pages/respond-invite.astro
  93. 16
      src/pages/team/index.astro
  94. 15
      src/pages/team/members.astro
  95. 11
      src/pages/team/new.astro
  96. 11
      src/pages/team/progress.astro
  97. 11
      src/pages/team/roadmaps.astro
  98. 15
      src/pages/team/settings.astro
  99. 15
      src/stores/team.ts
  100. 9
      src/stores/toast.ts
  101. Some files were not shown because too many files have changed in this diff Show More

@ -36,6 +36,7 @@
"preact": "^10.15.1",
"rehype-external-links": "^2.1.0",
"roadmap-renderer": "^1.0.6",
"slugify": "^1.6.6",
"tailwindcss": "^3.3.2"
},
"devDependencies": {

@ -50,6 +50,9 @@ dependencies:
roadmap-renderer:
specifier: ^1.0.6
version: 1.0.6
slugify:
specifier: ^1.6.6
version: 1.6.6
tailwindcss:
specifier: ^3.3.2
version: 3.3.2
@ -5077,6 +5080,11 @@ packages:
engines: {node: '>=12'}
dev: false
/slugify@1.6.6:
resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==}
engines: {node: '>=8.0.0'}
dev: false
/smart-buffer@4.2.0:
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 102 KiB

@ -1,13 +1,15 @@
---
import AstroIcon from './AstroIcon.astro';
const { activePageId, activePageTitle } = Astro.props;
import { TeamDropdown } from './TeamDropdown/TeamDropdown';
export interface Props {
activePageId: string;
activePageTitle: string;
hasDesktopSidebar?: boolean;
}
const { hasDesktopSidebar = true, activePageId, activePageTitle } = Astro.props;
const sidebarLinks = [
{
href: '/account',
@ -64,6 +66,17 @@ 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;
@ -91,48 +104,52 @@ const sidebarLinks = [
<div class='container flex min-h-screen items-stretch'>
<!-- Start Desktop Sidebar -->
<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;
{
hasDesktopSidebar && (
<aside class='hidden w-[195px] shrink-0 border-r border-slate-200 py-10 md:block'>
<TeamDropdown client:load />
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>
<nav>
<ul class='space-y-1'>
{sidebarLinks.map((sidebarLink) => {
const isActive = activePageId === sidebarLink.id;
{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' />
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>
)}
</a>
</li>
);
})
}
</ul>
</nav>
</aside>
{sidebarLink.isNew && !isActive && (
<span class='relative mr-1 flex items-center'>
<span class='relative rounded-full bg-gray-200 p-1 text-xs' />
<span class='absolute bottom-0 left-0 right-0 top-0 animate-ping rounded-full bg-gray-400 p-1 text-xs' />
</span>
)}
</a>
</li>
);
})}
</ul>
</nav>
</aside>
)
}
<!-- /End Desktop Sidebar -->
<div class='grow px-0 py-0 md:px-10 md:py-10'>
<div class:list={['grow px-0 py-0 md:py-10', { 'md:px-10': hasDesktopSidebar, 'md:px-5': !hasDesktopSidebar }]}>
<slot />
</div>
</div>

@ -5,7 +5,7 @@ import { ResourceProgress } from './ResourceProgress';
import { pageProgressMessage } from '../../stores/page';
import { EmptyActivity } from './EmptyActivity';
type ActivityResponse = {
export type ActivityResponse = {
done: {
today: number;
total: number;

@ -1,6 +1,7 @@
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';
@ -11,10 +12,13 @@ type ResourceProgressType = {
doneCount: number;
learningCount: number;
skippedCount: number;
onCleared: () => void;
onCleared?: () => void;
showClearButton?: boolean;
};
export function ResourceProgress(props: ResourceProgressType) {
const { showClearButton = true } = props;
const toast = useToast();
const [isClearing, setIsClearing] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
@ -41,7 +45,7 @@ export function ResourceProgress(props: ResourceProgressType) {
);
if (error || !response) {
alert('Error clearing progress. Please try again.');
toast.error('Error clearing progress. Please try again.');
console.error(error);
setIsClearing(false);
return;
@ -52,7 +56,9 @@ export function ResourceProgress(props: ResourceProgressType) {
setIsClearing(false);
setIsConfirming(false);
onCleared();
if (onCleared) {
onCleared();
}
}
const url =
@ -101,38 +107,42 @@ export function ResourceProgress(props: ResourceProgressType) {
)}
<span>{totalCount} total</span>
</span>
{!isConfirming && (
<button
className="text-red-500 hover:text-red-800"
onClick={() => setIsConfirming(true)}
disabled={isClearing}
>
{!isClearing && (
<>
Clear Progress <span>&times;</span>
</>
)}
{showClearButton && (
<>
{!isConfirming && (
<button
className="text-red-500 hover:text-red-800"
onClick={() => setIsConfirming(true)}
disabled={isClearing}
>
{!isClearing && (
<>
Clear Progress <span>&times;</span>
</>
)}
{isClearing && 'Processing...'}
</button>
)}
{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>
{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>

@ -0,0 +1,174 @@
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,8 +90,13 @@ 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, window.location.pathname);
localStorage.setItem(GITHUB_LAST_PAGE, pagePath);
}
window.location.href = response.loginUrl;

@ -85,8 +85,13 @@ 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, window.location.pathname);
localStorage.setItem(GOOGLE_LAST_PAGE, pagePath);
}
window.location.href = response.loginUrl;

@ -85,8 +85,13 @@ export function LinkedInButton(props: LinkedInButtonProps) {
// 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, window.location.pathname);
localStorage.setItem(LINKEDIN_LAST_PAGE, pagePath);
}
window.location.href = response.loginUrl;

@ -33,9 +33,17 @@ 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');

@ -6,11 +6,13 @@ 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';
type PageType = {
export type PageType = {
id: string;
url: string;
title: string;
group: string;
@ -19,23 +21,51 @@ type PageType = {
};
const defaultPages: PageType[] = [
{ url: '/', title: 'Home', group: 'Pages', icon: HomeIcon },
{ id: 'home', 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,
},
{ url: '/guides', title: 'Guides', group: 'Pages', icon: GuideIcon },
{ url: '/videos', title: 'Videos', group: 'Pages', icon: VideoIcon },
{
id: 'guides',
url: '/guides',
title: 'Guides',
group: 'Pages',
icon: GuideIcon,
},
{
id: 'videos',
url: '/videos',
title: 'Videos',
group: 'Pages',
icon: VideoIcon,
},
];
function shouldShowPage(page: PageType) {
@ -188,7 +218,7 @@ export function CommandMenu() {
<span class="mr-2 text-gray-400">{page.group}</span>
)}
{page.icon && (
<img src={page.icon} class="mr-2 h-4 w-4" />
<img alt={page.title} src={page.icon} class="mr-2 h-4 w-4" />
)}
{page.title}
</a>

@ -0,0 +1,216 @@
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>
);
}

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

@ -0,0 +1,221 @@
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>
);
}

@ -0,0 +1,135 @@
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>
);
}

@ -0,0 +1,122 @@
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>
</>
);
}

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

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

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

@ -0,0 +1,26 @@
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>
);
}

@ -0,0 +1,206 @@
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>
);
}

@ -0,0 +1,131 @@
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>
</>
);
}

@ -5,6 +5,7 @@ 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;
@ -21,6 +22,7 @@ export function MarkFavorite({
}: MarkFavoriteType) {
const localStorageKey = `${resourceType}-${resourceId}-favorite`;
const toast = useToast();
const [isLoading, setIsLoading] = useState(false);
const [isFavorite, setIsFavorite] = useState(
favorite ?? localStorage.getItem(localStorageKey) === '1'
@ -49,7 +51,8 @@ export function MarkFavorite({
if (error) {
setIsLoading(false);
return alert('Failed to update favorite status');
toast.error('Failed to update favorite status');
return;
}
// Dispatching an event instead of setting the state because

@ -72,14 +72,14 @@ import Icon from './AstroIcon.astro';
target='_blank'
class='hover:text-white'
>
<AstroIcon icon='youtube' class='inline-block h-4 w-4' />
<AstroIcon icon='youtube' class='inline-block h-5 w-5' />
</a>
<a
href='https://twitter.com/roadmapsh'
target='_blank'
class='ml-1.5 hover:text-white'
class='ml-2 hover:text-white'
>
<AstroIcon icon='twitter-fill' class='inline-block h-3.5 w-3.5 fill-current' />
<AstroIcon icon='twitter-fill' class='inline-block h-5 w-5 fill-current' />
</a>
</p>
</div>

@ -79,6 +79,19 @@ 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
*************************************/

@ -206,14 +206,18 @@ export class Renderer {
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'
);
normalizedGroupId,
!isCurrentStatusDone ? 'done' : 'pending'
);
}
handleSvgClick(e: any) {
@ -225,6 +229,10 @@ export class Renderer {
e.stopImmediatePropagation();
if (targetGroup.classList.contains('removed')) {
return;
}
if (/^ext_link/.test(groupId)) {
const externalLink = groupId.replace('ext_link:', '');

@ -18,7 +18,7 @@ import Icon from '../AstroIcon.astro';
</button>
<div
class='absolute right-0 z-10 mt-2 hidden w-48 rounded-md bg-slate-800 py-1 shadow-xl'
class='absolute right-0 z-50 mt-2 hidden w-48 rounded-md bg-slate-800 py-1 shadow-xl'
data-account-dropdown
>
<ul>

@ -17,6 +17,7 @@ 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');

@ -0,0 +1,109 @@
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,7 +36,8 @@ export function PageSponsor(props: PageSponsorProps) {
currentPath === '/roadmaps' ||
currentPath.startsWith('/guides') ||
currentPath.startsWith('/videos') ||
currentPath.startsWith('/account')
currentPath.startsWith('/account') ||
currentPath.startsWith('/team')
) {
return;
}

@ -7,7 +7,7 @@ export function CheckIcon(props: CheckIconProps) {
return (
<svg
className={`relative ${additionalClasses}]`}
className={`relative ${additionalClasses}`}
stroke="currentColor"
fill="currentColor"
stroke-width="0"

@ -0,0 +1,26 @@
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>
);
}

@ -0,0 +1,41 @@
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>
);
}

@ -0,0 +1,41 @@
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,20 +1,37 @@
export function Spinner() {
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="h-3.5 w-3.5 animate-spin"
className={`animate-spin ${className ?? ''}`}
viewBox="0 0 93 93"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<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: #404040;"
></path>
{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: #94a3b8;"
style={`fill: ${innerFill};`}
></path>
</svg>
);

@ -0,0 +1,27 @@
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>
);
}

@ -0,0 +1,41 @@
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>
);
}

@ -0,0 +1,176 @@
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>
);
}

@ -7,6 +7,7 @@ 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;
@ -36,67 +37,67 @@ 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">
<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-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"
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>
<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 gap-2 sm:gap-0'>
<div class='flex gap-1 sm:gap-2'>
{
!hasSearch && (
<>
<a
href="/roadmaps"
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 Roadmaps"
href='/roadmaps'
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 Roadmaps'
>
&larr;<span class="hidden sm:inline">&nbsp;All Roadmaps</span>
&larr;<span class='hidden sm:inline'>&nbsp;All Roadmaps</span>
</a>
{isRoadmapReady && (
<>
<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='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'
>
<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>
<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"
target="_blank"
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/roadmaps/${roadmapId}.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>
</>
)
@ -106,30 +107,38 @@ const isRoadmapReady = !isUpcoming;
hasSearch && (
<a
href={`/${roadmapId}`}
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 Visual Roadmap"
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 Visual Roadmap'
>
&larr;
<span class="inline">&nbsp;Visual Roadmap</span>
<span class='inline'>&nbsp;Visual Roadmap</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 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>
</div>
<!-- Desktop: Roadmap Resources - Alert -->

@ -0,0 +1,156 @@
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>
);
}

@ -0,0 +1,51 @@
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>
);
}

@ -0,0 +1,183 @@
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>
);
}

@ -0,0 +1,121 @@
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>
);
}

@ -0,0 +1,40 @@
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>
)
}

@ -0,0 +1,103 @@
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>
);
}

@ -0,0 +1,15 @@
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>
);
}

@ -0,0 +1,238 @@
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>
);
}

@ -0,0 +1,109 @@
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>
);
}

@ -0,0 +1,118 @@
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>
</>
);
}

@ -0,0 +1,105 @@
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>
</>
);
}

@ -0,0 +1,231 @@
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&nbsp;
<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>
);
}

@ -0,0 +1,169 @@
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>
);
}

@ -0,0 +1,275 @@
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>
);
}

@ -0,0 +1,291 @@
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>
);
}

@ -0,0 +1,142 @@
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>
</>
);
};

@ -0,0 +1,220 @@
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);
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);
}
setTimeout(() => {
setIsPreparing(false);
setTimeout(() => {
setContainerOpacity(100);
}, 50);
}, 0);
}
useEffect(() => {
loadTeamVersions().finally(() => {
//
});
}, []);
if (isPreparing) {
return null;
}
useEffect(() => {
clearResourceProgress();
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();
});
}, [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);
}}
>
<div className="flex w-full items-center justify-between">
<span className="truncate">{team.team.name}</span>
</div>
</button>
);
})}
</div>
</>
)}
</div>
);
}

@ -0,0 +1,102 @@
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>&rarr;</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>&rarr;</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>
);
}

@ -0,0 +1,67 @@
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,5 +1,6 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { httpPost } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
type ContributionInputProps = {
id: number;
@ -116,6 +117,7 @@ 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 }[]
@ -144,7 +146,7 @@ export function ContributionForm(props: ContributionFormProps) {
setIsSubmitting(false);
if (!response || error) {
alert(error?.message || 'Something went wrong. Please try again.');
toast.error(error?.message || 'Something went wrong. Please try again.');
return;
}

@ -19,6 +19,7 @@ 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('');
@ -27,6 +28,7 @@ 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);
@ -78,7 +80,7 @@ export function TopicDetail() {
refreshProgressCounters();
})
.catch((err) => {
alert(err.message);
toast.error(err.message);
console.error(err);
})
.finally(() => {

@ -13,6 +13,7 @@ import {
updateResourceProgress,
} from '../../lib/resource-progress';
import { showLoginPopup } from '../../lib/popup';
import { useToast } from '../../hooks/use-toast';
type TopicProgressButtonProps = {
topicId: string;
@ -27,12 +28,13 @@ 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, onClose } =
props;
const { topicId, resourceId, resourceType, onClose } = props;
const toast = useToast();
const [isUpdatingProgress, setIsUpdatingProgress] = useState(true);
const [progress, setProgress] = useState<ResourceProgressType>('pending');
const [showChangeStatus, setShowChangeStatus] = useState(false);
@ -139,7 +141,7 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
refreshProgressCounters();
})
.catch((err) => {
alert(err.message);
toast.error(err.message || 'Error updating progress');
console.error(err);
})
.finally(() => {

@ -86,6 +86,8 @@ 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,7 +7,11 @@ interface PreviewFile extends File {
}
type UploadProfilePictureProps = {
isDisabled?: boolean;
avatarUrl: string;
type: 'avatar' | 'logo';
label?: string;
teamId?: string;
};
function getDimensions(file: File) {
@ -48,7 +52,7 @@ async function validateImage(file: File): Promise<string | null> {
}
export default function UploadProfilePicture(props: UploadProfilePictureProps) {
const { avatarUrl } = props;
const { avatarUrl, teamId, type, isDisabled = false } = props;
const [file, setFile] = useState<PreviewFile | null>(null);
const [error, setError] = useState('');
@ -91,14 +95,26 @@ export default function UploadProfilePicture(props: UploadProfilePictureProps) {
formData.append('avatar', file);
// FIXME: Use `httpCall` helper instead of fetch
const res = await fetch(
`${import.meta.env.PUBLIC_API_URL}/v1-upload-profile-picture`,
{
method: 'POST',
body: formData,
credentials: 'include',
}
);
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',
}
);
}
if (res.ok) {
window.location.reload();
@ -132,9 +148,11 @@ export default function UploadProfilePicture(props: UploadProfilePictureProps) {
encType="multipart/form-data"
className="flex flex-col gap-2"
>
<label htmlFor="avatar" className="text-sm leading-none text-slate-500">
Profile Picture
</label>
{props.label && (
<label htmlFor="avatar" className="text-sm leading-none text-slate-500">
{props.label}
</label>
)}
<div className="mb-2 mt-2 flex items-center gap-2">
<label
htmlFor="avatar"
@ -152,8 +170,9 @@ export default function UploadProfilePicture(props: UploadProfilePictureProps) {
/>
</div>
{!file && (
{!file && !isDisabled && (
<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={() => {
@ -166,6 +185,7 @@ export default function UploadProfilePicture(props: UploadProfilePictureProps) {
)}
</label>
<input
disabled={isDisabled}
ref={inputRef}
id="avatar"
type="file"
@ -184,14 +204,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}
disabled={isLoading || isDisabled}
>
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}
disabled={isLoading || isDisabled}
>
{isLoading ? 'Uploading..' : 'Upload'}
</button>

@ -1,5 +1,9 @@
/**
* @type {import('astro').ClientDirective}
*/
export default async (load, opts) => {
const isAuthenticated = document.cookie.toString().indexOf('__roadmapsh_jt__') !== -1;
const isAuthenticated =
document.cookie.toString().indexOf('__roadmapsh_jt__') !== -1;
if (isAuthenticated) {
const hydrate = await load();
await hydrate();

@ -1,12 +1,11 @@
import Cookies from 'js-cookie';
import { TOKEN_COOKIE_NAME, decodeToken } from '../lib/jwt';
import { decodeToken, TOKEN_COOKIE_NAME } from '../lib/jwt';
export function useAuth() {
const token = Cookies.get(TOKEN_COOKIE_NAME);
if (!token) {
return null;
}
const user = decodeToken(token);
return user;
return decodeToken(token);
}

@ -0,0 +1,16 @@
import { useEffect, useState } from "preact/hooks";
export function useParams<T = Record<string, any>>(): T {
const [params, setParams] = useState<T>({} as T);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const paramsObj: Record<string, any> = {};
for (const [key, value] of params.entries()) {
paramsObj[key] = value;
}
setParams(paramsObj as T);
}, []);
return params
}

@ -0,0 +1,12 @@
import { useEffect, useState } from 'preact/hooks';
export function useTeamId() {
const [teamId, setTeamId] = useState<string | null>(null);
useEffect(() => {
const searchTeamId =
new URLSearchParams(window.location.search).get('t') || null;
setTeamId(searchTeamId);
}, []);
return { teamId };
}

@ -0,0 +1,23 @@
import { $toastMessage } from '../stores/toast';
export function useToast() {
function success(message: string) {
$toastMessage.set({ type: 'success', message });
}
function error(message: string) {
$toastMessage.set({ type: 'error', message });
}
function info(message: string) {
$toastMessage.set({ type: 'info', message });
}
function warning(message: string) {
$toastMessage.set({ type: 'warning', message });
}
function loading(message: string) {
$toastMessage.set({ type: 'loading', message });
}
return { success, error, info, warning, loading, $toastMessage };
}

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#000" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>

After

Width:  |  Height:  |  Size: 211 B

@ -0,0 +1 @@
<svg data-v-56bd7dfc="" 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" class="lucide lucide-building-2"><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"></path><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"></path><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"></path><path d="M10 6h4"></path><path d="M10 10h4"></path><path d="M10 14h4"></path><path d="M10 18h4"></path></svg>

After

Width:  |  Height:  |  Size: 516 B

@ -0,0 +1,5 @@
<svg aria-hidden="true" class="w-5 h-5" fill="#000" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"></path>
</svg>

After

Width:  |  Height:  |  Size: 385 B

@ -0,0 +1 @@
<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" class="lucide lucide-external-link"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" x2="21" y1="14" y2="3"/></svg>

After

Width:  |  Height:  |  Size: 364 B

@ -0,0 +1 @@
<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" class="lucide lucide-users"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>

After

Width:  |  Height:  |  Size: 372 B

@ -0,0 +1 @@
<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" class="lucide lucide-map"><polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21"/><line x1="9" x2="9" y1="3" y2="18"/><line x1="15" x2="15" y1="6" y2="21"/></svg>

After

Width:  |  Height:  |  Size: 346 B

@ -0,0 +1 @@
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m12 16.495c1.242 0 2.25 1.008 2.25 2.25s-1.008 2.25-2.25 2.25-2.25-1.008-2.25-2.25 1.008-2.25 2.25-2.25zm0-6.75c1.242 0 2.25 1.008 2.25 2.25s-1.008 2.25-2.25 2.25-2.25-1.008-2.25-2.25 1.008-2.25 2.25-2.25zm0-6.75c1.242 0 2.25 1.008 2.25 2.25s-1.008 2.25-2.25 2.25-2.25-1.008-2.25-2.25 1.008-2.25 2.25-2.25z"/></svg>

After

Width:  |  Height:  |  Size: 470 B

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
</svg>

After

Width:  |  Height:  |  Size: 407 B

@ -0,0 +1 @@
<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" class="lucide lucide-file-plus-2"><path d="M4 22h14a2 2 0 0 0 2-2V7.5L14.5 2H6a2 2 0 0 0-2 2v4"/><polyline points="14 2 14 8 20 8"/><path d="M3 15h6"/><path d="M6 12v6"/></svg>

After

Width:  |  Height:  |  Size: 357 B

@ -1 +1,3 @@
<svg class='h-3 w-3' viewBox="0 0 24 24" focusable="false"><path fill="currentColor" d="M23.414,20.591l-4.645-4.645a10.256,10.256,0,1,0-2.828,2.829l4.645,4.644a2.025,2.025,0,0,0,2.828,0A2,2,0,0,0,23.414,20.591ZM10.25,3.005A7.25,7.25,0,1,1,3,10.255,7.258,7.258,0,0,1,10.25,3.005Z"></path></svg>
<svg class="h-3 w-3" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M23.4145 20.5913L18.7695 15.9463C20.1838 13.8291 20.7601 11.2616 20.3862 8.74311C20.0123 6.22462 18.715 3.93524 16.7466 2.32029C14.7782 0.705331 12.2795 -0.119724 9.73651 0.00560621C7.19351 0.130936 4.78803 1.19769 2.98799 2.99837C1.18795 4.79905 0.122047 7.2049 -0.00238424 9.74795C-0.126815 12.291 0.699123 14.7894 2.31477 16.7572C3.93042 18.725 6.22026 20.0215 8.73889 20.3945C11.2575 20.7675 13.8248 20.1903 15.9415 18.7753L20.5865 23.4193C20.9647 23.7882 21.4721 23.9947 22.0005 23.9947C22.5288 23.9947 23.0363 23.7882 23.4145 23.4193C23.7894 23.0442 24 22.5356 24 22.0053C24 21.4749 23.7894 20.9663 23.4145 20.5913ZM10.2505 3.00527C11.6844 3.00527 13.0861 3.43047 14.2784 4.22711C15.4706 5.02375 16.3999 6.15605 16.9486 7.48081C17.4973 8.80558 17.6409 10.2633 17.3612 11.6697C17.0814 13.076 16.3909 14.3679 15.377 15.3818C14.3631 16.3957 13.0712 17.0862 11.6649 17.366C10.2585 17.6457 8.80078 17.5021 7.47602 16.9534C6.15125 16.4047 5.01896 15.4754 4.22232 14.2832C3.42568 13.0909 3.00047 11.6892 3.00047 10.2553C3.00259 8.3331 3.7671 6.49026 5.12628 5.13108C6.48546 3.7719 8.3283 3.00739 10.2505 3.00527Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 294 B

After

Width:  |  Height:  |  Size: 1.2 KiB

@ -0,0 +1 @@
<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" class="lucide lucide-line-chart"><path d="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/></svg>

After

Width:  |  Height:  |  Size: 271 B

@ -0,0 +1 @@
<svg data-v-f24af897="" 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" class="lucide-icon customizable"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M22 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>

After

Width:  |  Height:  |  Size: 422 B

@ -7,6 +7,7 @@ import Footer from '../components/Footer.astro';
import Navigation from '../components/Navigation/Navigation.astro';
import OpenSourceBanner from '../components/OpenSourceBanner.astro';
import { PageProgress } from '../components/PageProgress';
import { Toaster } from '../components/Toast';
import { PageSponsor } from '../components/PageSponsor';
import { siteConfig } from '../lib/config';
import '../styles/global.css';
@ -155,6 +156,7 @@ const gaPageIdentifier = Astro.url.pathname
<slot name="login-popup">
<LoginPopup />
</slot>
<Toaster client:idle />
<CommandMenu client:idle />
<PageProgress initialMessage={initialLoadingMessage} client:idle />
<PageSponsor

@ -0,0 +1,26 @@
export function getUrlParams() {
const params = new URLSearchParams(window.location.search);
const paramsObj: Record<string, any> = {};
for (const [key, value] of params.entries()) {
paramsObj[key] = value;
}
return paramsObj;
}
export function deleteUrlParam(key: string) {
const url = new URL(window.location.href);
url.searchParams.delete(key);
window.history.pushState(null, '', url.toString());
}
export function setUrlParams(params: Record<string, string>) {
const url = new URL(window.location.href);
for (const [key, value] of Object.entries(params)) {
url.searchParams.delete(key);
url.searchParams.set(key, value);
}
window.history.pushState(null, '', url.toString());
}

@ -5,11 +5,13 @@ import { TOKEN_COOKIE_NAME } from './jwt';
type HttpOptionsType = RequestInit | { headers: Record<string, any> };
type AppResponse = Record<string, any>;
type FetchError = {
export type FetchError = {
status: number;
message: string;
};
type AppError = {
export type AppError = {
status: number;
message: string;
errors?: { message: string; location: string }[];
@ -44,7 +46,7 @@ export async function httpCall<
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: `Bearer ${Cookies.get(TOKEN_COOKIE_NAME)}`,
'fp': fingerprint.visitorId,
fp: fingerprint.visitorId,
...(options?.headers ?? {}),
}),
});
@ -65,6 +67,12 @@ export async function httpCall<
if (data.status === 401) {
Cookies.remove(TOKEN_COOKIE_NAME);
window.location.reload();
return { response: undefined, error: data as ErrorType };
}
if (data.status === 403) {
window.location.href = '/account';
return { response: undefined, error: data as ErrorType };
}
return {

@ -7,6 +7,7 @@ export type TokenPayload = {
id: string;
email: string;
name: string;
avatar: string;
};
export function decodeToken(token: string): TokenPayload {

@ -4,7 +4,7 @@ import { TOKEN_COOKIE_NAME } from './jwt';
import Element = astroHTML.JSX.Element;
export type ResourceType = 'roadmap' | 'best-practice';
export type ResourceProgressType = 'done' | 'learning' | 'pending' | 'skipped';
export type ResourceProgressType = 'done' | 'learning' | 'pending' | 'skipped' | 'removed';
type TopicMeta = {
topicId: string;
@ -188,6 +188,8 @@ export function renderTopicProgress(
const isLearning = topicProgress === 'learning';
const isSkipped = topicProgress === 'skipped';
const isDone = topicProgress === 'done';
const isRemoved = topicProgress === 'removed';
const matchingElements: Element[] = [];
// Elements having sort order in the beginning of the group id
@ -227,12 +229,22 @@ export function renderTopicProgress(
} else if (isSkipped) {
element.classList.add('skipped');
element.classList.remove('done', 'learning');
} else if (isRemoved) {
element.classList.add('removed');
element.classList.remove('done', 'learning', 'skipped');
} else {
element.classList.remove('done', 'skipped', 'learning');
element.classList.remove('done', 'skipped', 'learning', 'removed');
}
});
}
export function clearResourceProgress() {
const clickableElements = document.querySelectorAll('.clickable-group')
for (const clickableElement of clickableElements) {
clickableElement.classList.remove('done', 'skipped', 'learning', 'removed');
}
}
export async function renderResourceProgress(
resourceType: ResourceType,
resourceId: string
@ -288,8 +300,11 @@ export function refreshProgressCounters() {
'[data-group-id^="check:"].skipped'
).length;
const totalRemoved = document.querySelectorAll(
'.clickable-group.removed'
).length;
const totalItems =
totalClickable - externalLinks - roadmapSwitchers - checkBoxes;
totalClickable - externalLinks - roadmapSwitchers - checkBoxes - totalRemoved;
const totalDone =
document.querySelectorAll('.clickable-group.done').length -

@ -9,10 +9,10 @@ import { TopicDetail } from '../../components/TopicDetail/TopicDetail';
import UpcomingForm from '../../components/UpcomingForm.astro';
import BaseLayout from '../../layouts/BaseLayout.astro';
import {
generateArticleSchema,
generateFAQSchema,
generateArticleSchema,
generateFAQSchema,
} from '../../lib/jsonld-schema';
import { RoadmapFrontmatter,getRoadmapIds } from '../../lib/roadmap';
import { RoadmapFrontmatter, getRoadmapIds } from '../../lib/roadmap';
export async function getStaticPaths() {
const roadmapIds = await getRoadmapIds();
@ -60,7 +60,8 @@ if (roadmapFAQs.length) {
permalink={`/${roadmapId}`}
title={roadmapData?.seo?.title}
briefTitle={roadmapData.briefTitle}
ogImageUrl={roadmapData?.seo?.ogImageUrl || 'https://roadmap.sh/images/og-img.png'}
ogImageUrl={roadmapData?.seo?.ogImageUrl ||
'https://roadmap.sh/images/og-img.png'}
description={roadmapData.seo.description}
keywords={roadmapData.seo.keywords}
noIndex={roadmapData.isUpcoming}

@ -9,7 +9,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
<img
style='--aspect-ratio:170/170'
src='/authors/kamran.jpeg'
class='h-[170px] w-[170px] rounded-md mr-5 hidden sm:block'
class='h-[170px] w-[170px] rounded-full mr-6 hidden sm:block'
alt='Kamran Ahmed'
/>
<div>

@ -0,0 +1,16 @@
---
import AccountSidebar from '../../components/AccountSidebar.astro';
import { NotificationPage } from '../../components/Notification/NotificationPage';
import AccountLayout from '../../layouts/AccountLayout.astro';
---
<AccountLayout
title='Notification'
description=''
noIndex={true}
initialLoadingMessage={'Loading notification'}
>
<AccountSidebar activePageId='notification' activePageTitle='Notification'>
<NotificationPage client:load />
</AccountSidebar>
</AccountLayout>

@ -12,21 +12,25 @@ export async function get() {
return {
body: JSON.stringify([
...roadmaps.map((roadmap) => ({
id: roadmap.id,
url: `/${roadmap.id}`,
title: roadmap.frontmatter.briefTitle,
group: 'Roadmaps',
})),
...bestPractices.map((bestPractice) => ({
id: bestPractice.id,
url: `/best-practices/${bestPractice.id}`,
title: bestPractice.frontmatter.briefTitle,
group: 'Best Practices',
})),
...guides.map((guide) => ({
id: guide.id,
url: `/guides/${guide.id}`,
title: guide.frontmatter.title,
group: 'Guides',
})),
...videos.map((guide) => ({
id: guide.id,
url: `/videos/${guide.id}`,
title: guide.frontmatter.title,
group: 'Videos',

@ -0,0 +1,14 @@
---
import AccountLayout from '../layouts/AccountLayout.astro';
import { RespondInviteForm } from '../components/RespondInviteForm';
import LoginPopup from "../components/AuthenticationFlow/LoginPopup.astro";
---
<AccountLayout
title='Respond Invite'
noIndex={true}
initialLoadingMessage={'Loading invite'}
>
<LoginPopup />
<RespondInviteForm client:only />
</AccountLayout>

@ -0,0 +1,16 @@
---
import AccountSidebar from '../../components/AccountSidebar.astro';
import { TeamsList } from '../../components/TeamsList.tsx';
import { ActivityPage } from '../../components/Activity/ActivityPage';
import AccountLayout from '../../layouts/AccountLayout.astro';
---
<AccountLayout
title='Update Profile'
noIndex={true}
initialLoadingMessage={'Loading teams'}
>
<AccountSidebar hasDesktopSidebar={false} activePageId='team' activePageTitle='Teams'>
<TeamsList client:load />
</AccountSidebar>
</AccountLayout>

@ -0,0 +1,15 @@
---
import { TeamSidebar } from '../../components/TeamSidebar';
import { TeamMembersPage } from '../../components/TeamMembers/TeamMembersPage';
import AccountLayout from '../../layouts/AccountLayout.astro';
---
<AccountLayout
title='Team Members'
noIndex={true}
initialLoadingMessage='Loading members'
>
<TeamSidebar activePageId='members' client:load>
<TeamMembersPage client:only />
</TeamSidebar>
</AccountLayout>

@ -0,0 +1,11 @@
---
import AccountSidebar from '../../components/AccountSidebar.astro';
import AccountLayout from '../../layouts/AccountLayout.astro';
import { CreateTeamForm } from '../../components/CreateTeam/CreateTeamForm';
---
<AccountLayout title='Create Team' noIndex={true}>
<AccountSidebar hasDesktopSidebar={false} activePageId='create-team' activePageTitle='Create Team'>
<CreateTeamForm client:only />
</AccountSidebar>
</AccountLayout>

@ -0,0 +1,11 @@
---
import { TeamSidebar } from '../../components/TeamSidebar';
import { TeamProgressPage } from '../../components/TeamProgress/TeamProgressPage';
import AccountLayout from '../../layouts/AccountLayout.astro';
---
<AccountLayout title='Team Progress' noIndex={true} initialLoadingMessage='Loading Progress'>
<TeamSidebar activePageId='progress' client:load>
<TeamProgressPage client:only />
</TeamSidebar>
</AccountLayout>

@ -0,0 +1,11 @@
---
import { TeamSidebar } from '../../components/TeamSidebar';
import { TeamRoadmaps } from '../../components/TeamRoadmaps';
import AccountLayout from '../../layouts/AccountLayout.astro';
---
<AccountLayout title='Roadmaps' noIndex={true} initialLoadingMessage='Loading Roadmaps'>
<TeamSidebar activePageId='roadmaps' client:load>
<TeamRoadmaps client:only />
</TeamSidebar>
</AccountLayout>

@ -0,0 +1,15 @@
---
import { TeamSidebar } from '../../components/TeamSidebar';
import { UpdateTeamForm } from '../../components/TeamSettings/UpdateTeamForm';
import AccountLayout from '../../layouts/AccountLayout.astro';
---
<AccountLayout
title='Team Settings'
noIndex={true}
initialLoadingMessage='Loading Settings'
>
<TeamSidebar activePageId='settings' client:load>
<UpdateTeamForm client:load />
</TeamSidebar>
</AccountLayout>

@ -0,0 +1,15 @@
import { atom, computed } from 'nanostores';
import type { UserTeamItem } from '../components/TeamDropdown/TeamDropdown';
export const $teamList = atom<UserTeamItem[]>([]);
export const $currentTeam = atom<UserTeamItem | undefined>();
export const $currentTeamRole = computed($currentTeam, (team) => team?.role);
export const $isCurrentTeamAdmin = computed($currentTeamRole, (role) =>
['admin'].includes(role!)
);
export const $canManageCurrentTeam = computed($currentTeamRole, (role) =>
['admin', 'manager'].includes(role!)
);

@ -0,0 +1,9 @@
import { atom } from 'nanostores';
export type ToastType = 'success' | 'error' | 'info' | 'warning' | 'loading';
export type ToastMessage = {
type: ToastType;
message: string;
};
export const $toastMessage = atom<ToastMessage | undefined>(undefined);

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

Loading…
Cancel
Save