diff --git a/package.json b/package.json index 3f43950a8..f2480384a 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b0b3a0df..674d8c764 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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'} diff --git a/public/authors/kamran.jpeg b/public/authors/kamran.jpeg index a69fbeb89..7c12b2df9 100644 Binary files a/public/authors/kamran.jpeg and b/public/authors/kamran.jpeg differ diff --git a/public/og-images/sql-roadmap.png b/public/og-images/sql-roadmap.png index e56c66cd0..c4da5aa4b 100644 Binary files a/public/og-images/sql-roadmap.png and b/public/og-images/sql-roadmap.png differ diff --git a/src/components/AccountSidebar.astro b/src/components/AccountSidebar.astro index 85ed97070..1fac87fcc 100644 --- a/src/components/AccountSidebar.astro +++ b/src/components/AccountSidebar.astro @@ -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' > + + + + + + + + + + + { sidebarLinks.map((sidebarLink) => { const isActive = activePageId === sidebarLink.id; @@ -91,48 +104,52 @@ const sidebarLinks = [
- + ) + } -
+
diff --git a/src/components/Activity/ActivityPage.tsx b/src/components/Activity/ActivityPage.tsx index 851730d15..2373248ab 100644 --- a/src/components/Activity/ActivityPage.tsx +++ b/src/components/Activity/ActivityPage.tsx @@ -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; diff --git a/src/components/Activity/ResourceProgress.tsx b/src/components/Activity/ResourceProgress.tsx index 26b4f7a00..ff5535ad8 100644 --- a/src/components/Activity/ResourceProgress.tsx +++ b/src/components/Activity/ResourceProgress.tsx @@ -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) { )} {totalCount} total - {!isConfirming && ( - - )} + {isClearing && 'Processing...'} + + )} - {isConfirming && ( - - Are you sure?{' '} - {' '} - - + {isConfirming && ( + + Are you sure?{' '} + {' '} + + + )} + )}

diff --git a/src/components/AddTeamRoadmap.tsx b/src/components/AddTeamRoadmap.tsx new file mode 100644 index 000000000..d3492931b --- /dev/null +++ b/src/components/AddTeamRoadmap.tsx @@ -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(''); + const popupBodyEl = useRef(null); + + async function addTeamResource(roadmapId: string) { + if (!teamId) { + return; + } + + setIsLoading(true); + const { error, response } = await httpPut( + `${ + 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 ( + + ); +} diff --git a/src/components/AuthenticationFlow/GitHubButton.tsx b/src/components/AuthenticationFlow/GitHubButton.tsx index 4f7825ae1..4b0377490 100644 --- a/src/components/AuthenticationFlow/GitHubButton.tsx +++ b/src/components/AuthenticationFlow/GitHubButton.tsx @@ -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; diff --git a/src/components/AuthenticationFlow/GoogleButton.tsx b/src/components/AuthenticationFlow/GoogleButton.tsx index bde439fd7..81715ac05 100644 --- a/src/components/AuthenticationFlow/GoogleButton.tsx +++ b/src/components/AuthenticationFlow/GoogleButton.tsx @@ -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; diff --git a/src/components/AuthenticationFlow/LinkedInButton.tsx b/src/components/AuthenticationFlow/LinkedInButton.tsx index 625d09d6d..b378d79a0 100644 --- a/src/components/AuthenticationFlow/LinkedInButton.tsx +++ b/src/components/AuthenticationFlow/LinkedInButton.tsx @@ -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; diff --git a/src/components/Authenticator/authenticator.ts b/src/components/Authenticator/authenticator.ts index d38bd695e..91860b30e 100644 --- a/src/components/Authenticator/authenticator.ts +++ b/src/components/Authenticator/authenticator.ts @@ -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'); diff --git a/src/components/CommandMenu/CommandMenu.tsx b/src/components/CommandMenu/CommandMenu.tsx index fb74de0f6..522134de8 100644 --- a/src/components/CommandMenu/CommandMenu.tsx +++ b/src/components/CommandMenu/CommandMenu.tsx @@ -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() { {page.group} )} {page.icon && ( - + {page.title} )} {page.title} diff --git a/src/components/CreateTeam/CreateTeamForm.tsx b/src/components/CreateTeam/CreateTeamForm.tsx new file mode 100644 index 000000000..88a4f87d3 --- /dev/null +++ b/src/components/CreateTeam/CreateTeamForm.tsx @@ -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(); + + 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( + `${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([]); + + async function loadTeamResourceConfig(teamId: string) { + const { error, response } = await httpGet( + `${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( + team?.type || 'company' + ); + + const [completedSteps, setCompletedSteps] = useState([0]); + if (loadingTeam) { + return null; + } + + let stepForm = null; + if (stepIndex === 0) { + stepForm = ( + { + if (team?._id) { + setUrlParams({ t: team._id, s: '1' }); + } + + setCompletedSteps([0]); + setStepIndex(1); + }} + /> + ); + } else if (stepIndex === 1) { + stepForm = ( + { + 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 = ( + { + 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 = ( + { + 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 = ; + } + + return ( +
+
+

Create Team

+

+ Complete the steps below to create your team +

+
+
+ +
+ + {stepForm} +
+ ); +} diff --git a/src/components/CreateTeam/NextButton.tsx b/src/components/CreateTeam/NextButton.tsx new file mode 100644 index 000000000..1dd0a00ba --- /dev/null +++ b/src/components/CreateTeam/NextButton.tsx @@ -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 ( + + ); +} diff --git a/src/components/CreateTeam/RoadmapSelector.tsx b/src/components/CreateTeam/RoadmapSelector.tsx new file mode 100644 index 000000000..bec08ff38 --- /dev/null +++ b/src/components/CreateTeam/RoadmapSelector.tsx @@ -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([]); + const [changingRoadmapId, setChangingRoadmapId] = useState(''); + const [error, setError] = useState(''); + + async function loadAllRoadmaps() { + const { error, response } = await httpGet(`/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( + `${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( + `${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 ( +
+ {changingRoadmapId && ( + setChangingRoadmapId('')} + resourceId={changingRoadmapId} + resourceType={'roadmap'} + teamId={team?._id!} + setTeamResourceConfig={setTeamResourceConfig} + defaultRemovedItems={ + teamResourceConfig.find((c) => c.resourceId === changingRoadmapId) + ?.removed || [] + } + /> + )} + + { + 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 && ( +
+ {'search'} + + No roadmaps selected. + +

+ Please search and add roadmaps from above +

+
+ )} + + {teamResourceConfig.length > 0 && ( +
+ {teamResourceConfig.map(({ resourceId, removed: removedTopics }) => { + const roadmapTitle = + allRoadmaps.find((roadmap) => roadmap.id === resourceId)?.title || + '...'; + + return ( +
+
+ + {roadmapTitle} + + {removedTopics.length > 0 ? ( + + {removedTopics.length} topic + {removedTopics.length > 1 ? 's' : ''} removed + + ) : ( + + No changes made .. + + )} +
+ +
+ + + +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/components/CreateTeam/RoleDropdown.tsx b/src/components/CreateTeam/RoleDropdown.tsx new file mode 100644 index 000000000..395e2517c --- /dev/null +++ b/src/components/CreateTeam/RoleDropdown.tsx @@ -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 ( +
+ + + {isMenuOpen && ( +
+
+ {allowedRoles.map((allowedRole, roleCounter) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/src/components/CreateTeam/Step0.tsx b/src/components/CreateTeam/Step0.tsx new file mode 100644 index 000000000..68bee0337 --- /dev/null +++ b/src/components/CreateTeam/Step0.tsx @@ -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(); + + 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 ( + <> +
+ {validTeamTypes.map((validTeamType) => ( + + ))} +
+ + {/*Error message*/} + {error &&
{error}
} + + + + ); +} diff --git a/src/components/CreateTeam/Step1.tsx b/src/components/CreateTeam/Step1.tsx new file mode 100644 index 000000000..2be5b7138 --- /dev/null +++ b/src/components/CreateTeam/Step1.tsx @@ -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(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( + 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 ( +
+
+ + setName((e.target as HTMLInputElement).value)} + /> +
+ + {selectedTeamType === 'company' && ( +
+ + setWebsite((e.target as HTMLInputElement).value)} + /> +
+ )} + + {selectedTeamType === 'company' && ( +
+ + + setLinkedInUrl((e.target as HTMLInputElement).value) + } + /> +
+ )} + +
+ + setGitHubUrl((e.target as HTMLInputElement).value)} + /> +
+ + {selectedTeamType === 'company' && ( +
+ + +
+ )} + +
+ + +
+
+ ); +} diff --git a/src/components/CreateTeam/Step2.tsx b/src/components/CreateTeam/Step2.tsx new file mode 100644 index 000000000..723456cd8 --- /dev/null +++ b/src/components/CreateTeam/Step2.tsx @@ -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 ( + <> +
+
+

Select Roadmaps

+

+ Picks the roadmaps to be made available to your team for tracking. + You can always add more later. +

+
+ + +
+ +
+ + +
+ + ); +} diff --git a/src/components/CreateTeam/Step3.tsx b/src/components/CreateTeam/Step3.tsx new file mode 100644 index 000000000..320b0ba4f --- /dev/null +++ b/src/components/CreateTeam/Step3.tsx @@ -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([ + { + 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 ( +
+
+

Invite your Team

+

+ Use the form below to invite your team members to your team. You can + also invite them later. +

+
+
+ {users.map((user, userCounter) => { + return ( +
+ { + 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" + /> + { + const newUsers = users.map((u) => { + if (u.id === user.id) { + return { + ...u, + role, + }; + } + + return u; + }); + + setUsers(newUsers); + }} + /> + +
+ ); + })} +
+ {users.length <= 30 && ( + + )} + + {error && ( +
+ {error} +
+ )} + +
+ +
+ + +
+
+
+ ); +} diff --git a/src/components/CreateTeam/Step4.tsx b/src/components/CreateTeam/Step4.tsx new file mode 100644 index 000000000..97e68de04 --- /dev/null +++ b/src/components/CreateTeam/Step4.tsx @@ -0,0 +1,26 @@ +import { CheckIcon } from '../ReactIcons/CheckIcon'; +import type { TeamDocument } from './CreateTeamForm'; + +type Step4Props = { + team: TeamDocument; +}; + +export function Step4({ team }: Step4Props) { + return ( +
+
+ +

Team Created

+

+ Your team has been created. Happy learning! +

+ + View Team + +
+
+ ); +} diff --git a/src/components/CreateTeam/UpdateTeamResourceModal.tsx b/src/components/CreateTeam/UpdateTeamResourceModal.tsx new file mode 100644 index 000000000..137c53fb9 --- /dev/null +++ b/src/components/CreateTeam/UpdateTeamResourceModal.tsx @@ -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(null); + const popupBodyEl = useRef(null); + + const toast = useToast(); + const [isLoading, setIsLoading] = useState(true); + const [isUpdating, setIsUpdating] = useState(false); + + const [removedItems, setRemovedItems] = + useState(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( + `${ + 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 ( +
+
+ +
+
+ ); +} diff --git a/src/components/DeleteTeamPopup.tsx b/src/components/DeleteTeamPopup.tsx new file mode 100644 index 000000000..106d7db93 --- /dev/null +++ b/src/components/DeleteTeamPopup.tsx @@ -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(null); + const inputEl = useRef(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( + `${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 ( + <> +
+
+ +
+
+ + ); +} diff --git a/src/components/FeaturedItems/MarkFavorite.tsx b/src/components/FeaturedItems/MarkFavorite.tsx index e85853d71..d0b98d74c 100644 --- a/src/components/FeaturedItems/MarkFavorite.tsx +++ b/src/components/FeaturedItems/MarkFavorite.tsx @@ -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 diff --git a/src/components/Footer.astro b/src/components/Footer.astro index b0448c062..ca5d54144 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -72,14 +72,14 @@ import Icon from './AstroIcon.astro'; target='_blank' class='hover:text-white' > - + - +

diff --git a/src/components/FrameRenderer/FrameRenderer.css b/src/components/FrameRenderer/FrameRenderer.css index eb1d48d03..9fec6945b 100644 --- a/src/components/FrameRenderer/FrameRenderer.css +++ b/src/components/FrameRenderer/FrameRenderer.css @@ -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 *************************************/ diff --git a/src/components/FrameRenderer/renderer.ts b/src/components/FrameRenderer/renderer.ts index 63ad29a77..aa6e0a72d 100644 --- a/src/components/FrameRenderer/renderer.ts +++ b/src/components/FrameRenderer/renderer.ts @@ -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:', ''); diff --git a/src/components/Navigation/AccountDropdown.astro b/src/components/Navigation/AccountDropdown.astro index c960cecb9..527b774a6 100644 --- a/src/components/Navigation/AccountDropdown.astro +++ b/src/components/Navigation/AccountDropdown.astro @@ -18,7 +18,7 @@ import Icon from '../AstroIcon.astro';