Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 102 KiB |
@ -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> |
||||
); |
||||
} |
@ -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">→</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">←</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">←</span> |
||||
Previous Step |
||||
</button> |
||||
<button |
||||
type="submit" |
||||
disabled={teamResourceConfig.length === 0} |
||||
onClick={onNext} |
||||
className={ |
||||
'rounded-md border bg-black px-4 py-2 text-white disabled:opacity-50' |
||||
} |
||||
> |
||||
Next Step |
||||
<span className="ml-1">→</span> |
||||
</button> |
||||
</div> |
||||
</> |
||||
); |
||||
} |
@ -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">←</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> |
||||
</> |
||||
); |
||||
} |
@ -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> |
||||
); |
||||
} |
@ -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> |
||||
); |
||||
} |
@ -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> |
||||
); |
||||
} |
@ -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 |
||||
<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>→</span> |
||||
</a> |
||||
</li> |
||||
{teamList.map((team) => ( |
||||
<li> |
||||
<a |
||||
className="flex w-full cursor-pointer items-center justify-between gap-2 rounded border p-2 text-sm font-medium hover:border-gray-300 hover:bg-gray-50" |
||||
href={`/team/progress?t=${team._id}`} |
||||
> |
||||
<span className="flex flex-grow items-center gap-2"> |
||||
<img |
||||
src={ |
||||
team.avatar |
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${ |
||||
team.avatar |
||||
}` |
||||
: '/images/default-avatar.png' |
||||
} |
||||
alt={team.name || ''} |
||||
className="h-6 w-6 rounded-full" |
||||
/> |
||||
<span className="truncate">{team.name}</span> |
||||
</span> |
||||
<span>→</span> |
||||
</a> |
||||
</li> |
||||
))} |
||||
</ul> |
||||
<a |
||||
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400" |
||||
href="/team/new" |
||||
> |
||||
<span class='mr-2'>+</span> |
||||
<span>New Team</span> |
||||
</a> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
@ -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,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 }; |
||||
} |
After Width: | Height: | Size: 211 B |
After Width: | Height: | Size: 516 B |
After Width: | Height: | Size: 385 B |
After Width: | Height: | Size: 364 B |
After Width: | Height: | Size: 372 B |
After Width: | Height: | Size: 346 B |
After Width: | Height: | Size: 470 B |
After Width: | Height: | Size: 407 B |
After Width: | Height: | Size: 357 B |
Before Width: | Height: | Size: 294 B After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 271 B |
After Width: | Height: | Size: 422 B |
@ -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()); |
||||
} |
@ -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> |
@ -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); |