computer-scienceangular-roadmapbackend-roadmapblockchain-roadmapdba-roadmapdeveloper-roadmapdevops-roadmapfrontend-roadmapgo-roadmaphactoberfestjava-roadmapjavascript-roadmapnodejs-roadmappython-roadmapqa-roadmapreact-roadmaproadmapstudy-planvue-roadmapweb3-roadmap
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
652 lines
21 KiB
652 lines
21 KiB
import { getUrlParams } from '../../lib/browser'; |
|
import { useEffect, useState } from 'react'; |
|
import type { TeamDocument } from '../CreateTeam/CreateTeamForm'; |
|
import type { TeamResourceConfig } from '../CreateTeam/RoadmapSelector'; |
|
import { httpGet, httpPut } from '../../lib/http'; |
|
import { pageProgressMessage } from '../../stores/page'; |
|
import type { PageType } from '../CommandMenu/CommandMenu'; |
|
import { useStore } from '@nanostores/react'; |
|
import { $canManageCurrentTeam } from '../../stores/team'; |
|
import { useToast } from '../../hooks/use-toast'; |
|
import { SelectRoadmapModal } from '../CreateTeam/SelectRoadmapModal'; |
|
import { PickRoadmapOptionModal } from '../TeamRoadmaps/PickRoadmapOptionModal'; |
|
import type { AllowedRoadmapVisibility } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal'; |
|
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal'; |
|
import { |
|
ExternalLink, |
|
Globe, |
|
LockIcon, |
|
type LucideIcon, |
|
Package, |
|
PackageMinus, |
|
PenSquare, |
|
Shapes, |
|
Users, |
|
} from 'lucide-react'; |
|
import { RoadmapActionDropdown } from './RoadmapActionDropdown'; |
|
import { UpdateTeamResourceModal } from '../CreateTeam/UpdateTeamResourceModal'; |
|
import { ShareOptionsModal } from '../ShareOptions/ShareOptionsModal'; |
|
import { cn } from '../../lib/classname'; |
|
import { RoadmapIcon } from '../ReactIcons/RoadmapIcon.tsx'; |
|
|
|
export function TeamRoadmaps() { |
|
const { t: teamId } = getUrlParams(); |
|
|
|
const canManageCurrentTeam = useStore($canManageCurrentTeam); |
|
|
|
const toast = useToast(); |
|
|
|
const [isLoading, setIsLoading] = useState(true); |
|
const [isPickingOptions, setIsPickingOptions] = useState(false); |
|
const [isAddingRoadmap, setIsAddingRoadmap] = useState(false); |
|
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false); |
|
const [changingRoadmapId, setChangingRoadmapId] = useState<string>(''); |
|
const [team, setTeam] = useState<TeamDocument>(); |
|
const [teamResources, setTeamResources] = useState<TeamResourceConfig>([]); |
|
const [allRoadmaps, setAllRoadmaps] = useState<PageType[]>([]); |
|
const [selectedResource, setSelectedResource] = useState< |
|
TeamResourceConfig[0] | null |
|
>(null); |
|
|
|
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; |
|
} |
|
|
|
setTeamResources(response); |
|
} |
|
|
|
useEffect(() => { |
|
if (!teamId) { |
|
return; |
|
} |
|
|
|
setIsLoading(true); |
|
Promise.all([ |
|
loadTeam(teamId), |
|
loadTeamResourceConfig(teamId), |
|
loadAllRoadmaps(), |
|
]).finally(() => { |
|
pageProgressMessage.set(''); |
|
setIsLoading(false); |
|
}); |
|
}, [teamId]); |
|
|
|
async function deleteResource(roadmapId: string) { |
|
if (!team?._id) { |
|
return; |
|
} |
|
|
|
toast.loading('Deleting roadmap'); |
|
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'); |
|
setTeamResources(response); |
|
} |
|
|
|
async function onAdd(roadmapId: string) { |
|
if (!teamId) { |
|
return; |
|
} |
|
|
|
toast.loading('Adding roadmap'); |
|
pageProgressMessage.set('Adding roadmap'); |
|
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) { |
|
toast.error(error?.message || 'Error adding roadmap'); |
|
return; |
|
} |
|
|
|
setTeamResources(response); |
|
toast.success('Roadmap added'); |
|
} |
|
|
|
async function onRemove(resourceId: string) { |
|
pageProgressMessage.set('Removing roadmap'); |
|
|
|
deleteResource(resourceId).finally(() => { |
|
pageProgressMessage.set(''); |
|
}); |
|
} |
|
|
|
useEffect(() => { |
|
function handleCustomRoadmapCreated(event: Event) { |
|
const { roadmapId } = (event as CustomEvent)?.detail; |
|
if (!roadmapId) { |
|
return; |
|
} |
|
|
|
loadAllRoadmaps().finally(() => {}); |
|
onAdd(roadmapId).finally(() => { |
|
pageProgressMessage.set(''); |
|
}); |
|
} |
|
window.addEventListener( |
|
'custom-roadmap-created', |
|
handleCustomRoadmapCreated, |
|
); |
|
|
|
return () => { |
|
window.removeEventListener( |
|
'custom-roadmap-created', |
|
handleCustomRoadmapCreated, |
|
); |
|
}; |
|
}, []); |
|
|
|
if (!team) { |
|
return null; |
|
} |
|
|
|
const pickRoadmapOptionModal = isPickingOptions && ( |
|
<PickRoadmapOptionModal |
|
onClose={() => setIsPickingOptions(false)} |
|
showDefaultRoadmapsModal={() => { |
|
setIsAddingRoadmap(true); |
|
setIsPickingOptions(false); |
|
}} |
|
showCreateCustomRoadmapModal={() => { |
|
setIsCreatingRoadmap(true); |
|
setIsPickingOptions(false); |
|
}} |
|
/> |
|
); |
|
|
|
const addRoadmapModal = isAddingRoadmap && ( |
|
<SelectRoadmapModal |
|
onClose={() => setIsAddingRoadmap(false)} |
|
teamResourceConfig={teamResources} |
|
allRoadmaps={allRoadmaps} |
|
teamId={teamId} |
|
onRoadmapAdd={(roadmapId: string) => { |
|
onAdd(roadmapId).finally(() => { |
|
pageProgressMessage.set(''); |
|
}); |
|
}} |
|
onRoadmapRemove={(roadmapId: string) => { |
|
if (confirm('Are you sure you want to remove this roadmap?')) { |
|
onRemove(roadmapId).finally(() => {}); |
|
} |
|
}} |
|
/> |
|
); |
|
|
|
const createRoadmapModal = isCreatingRoadmap && ( |
|
<CreateRoadmapModal |
|
teamId={teamId} |
|
onClose={() => { |
|
setIsCreatingRoadmap(false); |
|
}} |
|
onCreated={() => { |
|
loadTeamResourceConfig(teamId).finally(() => null); |
|
setIsCreatingRoadmap(false); |
|
}} |
|
/> |
|
); |
|
|
|
const placeholderRoadmaps = teamResources.filter( |
|
(c: TeamResourceConfig[0]) => c.isCustomResource && !c.topics, |
|
); |
|
const customRoadmaps = teamResources.filter( |
|
(c: TeamResourceConfig[0]) => c.isCustomResource && c.topics, |
|
); |
|
const defaultRoadmaps = teamResources.filter( |
|
(c: TeamResourceConfig[0]) => !c.isCustomResource, |
|
); |
|
|
|
const hasRoadmaps = |
|
customRoadmaps.length > 0 || |
|
defaultRoadmaps.length > 0 || |
|
(placeholderRoadmaps.length > 0 && canManageCurrentTeam); |
|
if (!hasRoadmaps && !isLoading) { |
|
return ( |
|
<div className="flex flex-col items-center p-4 py-20"> |
|
{pickRoadmapOptionModal} |
|
{addRoadmapModal} |
|
{createRoadmapModal} |
|
|
|
<RoadmapIcon className="mb-4 h-24 w-24 opacity-10" /> |
|
|
|
<h3 className="mb-1 text-2xl font-bold text-gray-900">No roadmaps</h3> |
|
<p className="text-base text-gray-500"> |
|
{canManageCurrentTeam |
|
? 'Add a roadmap to start tracking your team' |
|
: 'Ask your team admin to add some roadmaps'} |
|
</p> |
|
|
|
{canManageCurrentTeam && ( |
|
<button |
|
className="mt-4 rounded-lg bg-black px-4 py-2 font-medium text-white hover:bg-gray-900" |
|
onClick={() => setIsPickingOptions(true)} |
|
> |
|
Add roadmap |
|
</button> |
|
)} |
|
</div> |
|
); |
|
} |
|
|
|
const customizeRoadmapModal = changingRoadmapId && ( |
|
<UpdateTeamResourceModal |
|
onClose={() => setChangingRoadmapId('')} |
|
resourceId={changingRoadmapId} |
|
resourceType={'roadmap'} |
|
teamId={team?._id!} |
|
setTeamResourceConfig={setTeamResources} |
|
defaultRemovedItems={ |
|
defaultRoadmaps.find((c) => c.resourceId === changingRoadmapId) |
|
?.removed || [] |
|
} |
|
/> |
|
); |
|
|
|
const shareSettingsModal = selectedResource && ( |
|
<ShareOptionsModal |
|
description={selectedResource.description!} |
|
visibility={selectedResource.visibility!} |
|
sharedTeamMemberIds={selectedResource.sharedTeamMemberIds!} |
|
sharedFriendIds={selectedResource.sharedFriendIds!} |
|
teamId={teamId} |
|
roadmapId={selectedResource.resourceId} |
|
onShareSettingsUpdate={(shareSettings) => { |
|
setTeamResources((prev) => { |
|
return prev.map((c) => { |
|
if (c.resourceId !== selectedResource.resourceId) { |
|
return c; |
|
} |
|
|
|
return { |
|
...c, |
|
...shareSettings, |
|
}; |
|
}); |
|
}); |
|
}} |
|
onClose={() => setSelectedResource(null)} |
|
/> |
|
); |
|
|
|
return ( |
|
<div> |
|
{pickRoadmapOptionModal} |
|
{addRoadmapModal} |
|
{createRoadmapModal} |
|
{customizeRoadmapModal} |
|
{shareSettingsModal} |
|
|
|
{canManageCurrentTeam && placeholderRoadmaps.length > 0 && ( |
|
<div className="mb-5"> |
|
<div className="mb-2 flex items-center justify-between"> |
|
<h3 className="flex w-full items-center justify-between text-xs uppercase text-gray-400"> |
|
<span className="flex">Placeholder Roadmaps</span> |
|
<span className="normal-case"> |
|
Total {placeholderRoadmaps.length} roadmap(s) |
|
</span> |
|
</h3> |
|
</div> |
|
<div className="flex flex-col divide-y rounded-md border"> |
|
{placeholderRoadmaps.map( |
|
(resourceConfig: TeamResourceConfig[0]) => { |
|
return ( |
|
<div |
|
className="grid grid-cols-1 p-2.5 sm:grid-cols-[auto_173px]" |
|
key={resourceConfig.resourceId} |
|
> |
|
<div className="mb-3 grid sm:mb-0"> |
|
<p className="mb-1.5 truncate text-base font-medium leading-tight text-black"> |
|
{resourceConfig.title} |
|
</p> |
|
<span className="text-xs italic leading-none text-gray-400/60"> |
|
Placeholder roadmap |
|
</span> |
|
</div> |
|
|
|
{canManageCurrentTeam && ( |
|
<div className="flex items-center justify-start gap-2 sm:justify-end"> |
|
<RoadmapActionDropdown |
|
onUpdateSharing={() => { |
|
setSelectedResource(resourceConfig); |
|
}} |
|
onDelete={() => { |
|
if ( |
|
confirm( |
|
'Are you sure you want to remove this roadmap?', |
|
) |
|
) { |
|
onRemove(resourceConfig.resourceId).finally( |
|
() => {}, |
|
); |
|
} |
|
}} |
|
/> |
|
<a |
|
href={`${import.meta.env.PUBLIC_EDITOR_APP_URL}/${ |
|
resourceConfig.resourceId |
|
}`} |
|
className={ |
|
'flex gap-2 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-none' |
|
} |
|
target={'_blank'} |
|
> |
|
<PenSquare className="inline-block h-4 w-4" /> |
|
Create Roadmap |
|
</a> |
|
</div> |
|
)} |
|
</div> |
|
); |
|
}, |
|
)} |
|
</div> |
|
</div> |
|
)} |
|
|
|
{customRoadmaps.length > 0 && ( |
|
<div className="mb-5"> |
|
<div className="mb-2 flex items-center justify-between"> |
|
<h3 className="flex w-full items-center justify-between text-xs uppercase text-gray-400"> |
|
<span className="flex">Custom Roadmaps</span> |
|
<span className="normal-case"> |
|
Total {customRoadmaps.length} roadmap(s) |
|
</span> |
|
</h3> |
|
</div> |
|
<div className="flex flex-col divide-y rounded-md border"> |
|
{customRoadmaps.map((resourceConfig: TeamResourceConfig[0]) => { |
|
const editorLink = `${import.meta.env.PUBLIC_EDITOR_APP_URL}/${ |
|
resourceConfig.resourceId |
|
}`; |
|
|
|
return ( |
|
<div |
|
className={cn( |
|
'grid grid-cols-1 p-2.5', |
|
canManageCurrentTeam |
|
? 'sm:grid-cols-[auto_172px]' |
|
: 'sm:grid-cols-[auto_110px]', |
|
)} |
|
key={resourceConfig.resourceId} |
|
> |
|
<div className="mb-3 grid grid-cols-1 sm:mb-0"> |
|
<p className="mb-1.5 truncate text-base font-medium leading-tight text-black"> |
|
{resourceConfig.title} |
|
</p> |
|
<span className="flex items-center text-xs leading-none text-gray-400"> |
|
<VisibilityBadge |
|
visibility={resourceConfig.visibility!} |
|
sharedTeamMemberIds={resourceConfig.sharedTeamMemberIds} |
|
sharedFriendIds={resourceConfig.sharedFriendIds} |
|
/> |
|
<span className="mx-2 font-semibold">·</span> |
|
<Shapes size={16} className="mr-1 inline-block h-4 w-4" /> |
|
{resourceConfig.topics} topic |
|
</span> |
|
</div> |
|
<div className="mr-1 flex items-center justify-start sm:justify-end"> |
|
{canManageCurrentTeam && ( |
|
<RoadmapActionDropdown |
|
onUpdateSharing={() => { |
|
setSelectedResource(resourceConfig); |
|
}} |
|
onCustomize={() => { |
|
window.open(editorLink, '_blank'); |
|
}} |
|
onDelete={() => { |
|
if ( |
|
confirm( |
|
'Are you sure you want to remove this roadmap?', |
|
) |
|
) { |
|
onRemove(resourceConfig.resourceId).finally( |
|
() => {}, |
|
); |
|
} |
|
}} |
|
/> |
|
)} |
|
|
|
<a |
|
href={`/r/${resourceConfig.roadmapSlug}`} |
|
className={ |
|
'ml-2 flex items-center gap-2 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-none' |
|
} |
|
target={'_blank'} |
|
> |
|
<ExternalLink className="inline-block h-4 w-4" /> |
|
Visit |
|
</a> |
|
{canManageCurrentTeam && ( |
|
<a |
|
href={editorLink} |
|
className={ |
|
'ml-2 flex items-center gap-2 rounded-md border border-gray-800 bg-gray-900 px-2.5 py-1.5 text-xs text-white hover:bg-gray-800 focus:outline-none' |
|
} |
|
target={'_blank'} |
|
> |
|
<PenSquare className="inline-block h-4 w-4" /> |
|
Edit |
|
</a> |
|
)} |
|
</div> |
|
</div> |
|
); |
|
})} |
|
</div> |
|
</div> |
|
)} |
|
|
|
{defaultRoadmaps.length > 0 && ( |
|
<div> |
|
<div className="mb-2 flex items-center justify-between"> |
|
<h3 className="flex w-full items-center justify-between text-xs uppercase text-gray-400"> |
|
<span className="flex">Default Roadmaps</span> |
|
<span className="normal-case"> |
|
Total {defaultRoadmaps.length} roadmap(s) |
|
</span> |
|
</h3> |
|
</div> |
|
<div className="flex flex-col divide-y rounded-md border"> |
|
{defaultRoadmaps.map((resourceConfig: TeamResourceConfig[0]) => { |
|
return ( |
|
<div |
|
className="grid grid-cols-1 p-3 sm:grid-cols-[auto_110px]" |
|
key={resourceConfig.resourceId} |
|
> |
|
<div className="mb-3 grid grid-cols-1 sm:mb-0"> |
|
<p className="mb-1.5 truncate text-base font-medium leading-tight text-black"> |
|
{resourceConfig.title} |
|
</p> |
|
<span className="flex items-center text-xs leading-none text-gray-400"> |
|
{resourceConfig?.removed?.length > 0 && ( |
|
<> |
|
<PackageMinus |
|
size={16} |
|
className="mr-1 inline-block h-4 w-4" |
|
/> |
|
{resourceConfig.removed.length} topics removed |
|
</> |
|
)} |
|
|
|
{!resourceConfig?.removed?.length && ( |
|
<> |
|
<Package |
|
size={16} |
|
className="mr-1 inline-block h-4 w-4" |
|
/> |
|
No changes made |
|
</> |
|
)} |
|
</span> |
|
</div> |
|
<div className="mr-1 flex items-center justify-start sm:justify-end"> |
|
{canManageCurrentTeam && ( |
|
<RoadmapActionDropdown |
|
onCustomize={() => { |
|
setChangingRoadmapId(resourceConfig.resourceId); |
|
}} |
|
onDelete={() => { |
|
if ( |
|
confirm( |
|
'Are you sure you want to remove this roadmap?', |
|
) |
|
) { |
|
onRemove(resourceConfig.resourceId).finally( |
|
() => {}, |
|
); |
|
} |
|
}} |
|
/> |
|
)} |
|
|
|
<a |
|
href={`/${resourceConfig.resourceId}`} |
|
className={ |
|
'ml-2 flex items-center gap-2 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-none' |
|
} |
|
target={'_blank'} |
|
> |
|
<ExternalLink className="inline-block h-4 w-4" /> |
|
Visit |
|
</a> |
|
</div> |
|
</div> |
|
); |
|
})} |
|
</div> |
|
</div> |
|
)} |
|
|
|
{canManageCurrentTeam && ( |
|
<div className="mt-5"> |
|
<button |
|
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" |
|
onClick={() => setIsPickingOptions(true)} |
|
> |
|
+ Add new Roadmap |
|
</button> |
|
</div> |
|
)} |
|
</div> |
|
); |
|
} |
|
|
|
type VisibilityLabelProps = { |
|
visibility: AllowedRoadmapVisibility; |
|
sharedTeamMemberIds?: string[]; |
|
sharedFriendIds?: string[]; |
|
}; |
|
|
|
const visibilityDetails: Record< |
|
AllowedRoadmapVisibility, |
|
{ |
|
icon: LucideIcon; |
|
label: string; |
|
} |
|
> = { |
|
public: { |
|
icon: Globe, |
|
label: 'Public', |
|
}, |
|
me: { |
|
icon: LockIcon, |
|
label: 'Only me', |
|
}, |
|
team: { |
|
icon: Users, |
|
label: 'Team Member(s)', |
|
}, |
|
friends: { |
|
icon: Users, |
|
label: 'Friend(s)', |
|
}, |
|
} as const; |
|
|
|
export function VisibilityBadge(props: VisibilityLabelProps) { |
|
const { visibility, sharedTeamMemberIds = [], sharedFriendIds = [] } = props; |
|
|
|
const { label, icon: Icon } = visibilityDetails[visibility]; |
|
|
|
return ( |
|
<span |
|
className={`inline-flex items-center gap-1.5 whitespace-nowrap text-xs font-normal`} |
|
> |
|
<Icon className="inline-block h-3 w-3" /> |
|
<div className="flex items-center"> |
|
{visibility === 'team' && sharedTeamMemberIds?.length > 0 && ( |
|
<span className="mr-1">{sharedTeamMemberIds.length}</span> |
|
)} |
|
{visibility === 'friends' && sharedFriendIds?.length > 0 && ( |
|
<span className="mr-1">{sharedFriendIds.length}</span> |
|
)} |
|
{label} |
|
</div> |
|
</span> |
|
); |
|
}
|
|
|