Allow creating custom roadmaps (#4486)
* wip: custom roadmap renderer * wip: custom roadmap events * wip: roadmap content * wip: svg styles * wip: custom roadmap progress * Render progress * Shortcut progress * Progress Tracking styles * wip: edit and share button * fix: disabled the share button * wip: content links rendering * Fix progress share * Replace disabled with `canShare` * wip: show custom roadmaps * wip: users all roadmaps * fix: create roadmap api * chore: roadmap sidebar icon * wip: content links * Update links color * Create roadmap home * Create Roadmap button * Roadmap type * chore: share progress modal * wip: share roadmap * wip: change visibility * chore: custom roadmap progress in activity * wip: custom roadmap share progress * chore: friend's roadmap * wip: custom roadmap skeleton * chore: roadmap title * Restricted Page * fix: skeleton loading width * Fix create roadmap button * chore: remove user id * chore: pick roadmap and share * chore: open new tab on create roadmap * chore: change share title * chore: use team id from params * chore: team roadmap create modal * chore: create team roadmap * chore: custom roadmap modal * chore: placeholde roadmaps * chore: roadmap hint * chore: visibility label * chore: public roadmap * chore: empty screen * chore: team progress * chore: create roadmap responsive * chore: form error * chore: multi user history * wip: manage custom roadmap * chore: empty roadmap list * chore: custom roadmap visit * chore: shared roadmaps * chore: shared roadmaps * chore: empty screen and topic title * chore: show progress bar * Implement Error in topic details * Add Modal close button * fix: link groups * Refactor roadmap creation * Refactor roadmap creation * Refactor team creation * Refactor team roadmaps * Refactor team creation roadmap selection * Refactor * Refactor team roadmap loading * Refactor team roadmaps * Refactor team roadmaps listing * Refactor Account dropdown * Updates * Refactor Account dropdown * Fix Team name overflow * Change Icon color * Update team dropdown * Minor UI fixes * Fix minor UI * Flicker fix in team dropdown * Roadmap action dropdown with responsiveness * Team roadmaps listing * Update team settings * Team roadmaps listing * fix: remove visibility change * Update roadmap options modal * Add dummy renderer * Add renderer script * Add generate renderer script * Add generate renderer * wip: add share settings * Update * Update UI * Update Minor UI * Fix team issue * Update Personal roadmaps UI * Add Roadmap Secret * Update teams type * Rearrange sections * Change Secret name * Add action button on roadmap detail page --------- Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>roadmap/copy
parent
d45c8f9cb2
commit
8310671123
94 changed files with 5760 additions and 1072 deletions
@ -1,2 +1,3 @@ |
||||
PUBLIC_API_URL=http://api.roadmap.sh |
||||
PUBLIC_AVATAR_BASE_URL=https://dodrc8eu8m09s.cloudfront.net/avatars |
||||
PUBLIC_EDITOR_APP_URL= |
@ -0,0 +1,14 @@ |
||||
export function Renderer(props: any) { |
||||
return ( |
||||
<div className="fixed bottom-0 left-0 right-0 top-0 z-[9999] border bg-white p-5 text-black"> |
||||
<h2 className="mb-2 text-xl font-semibold">Private Component</h2> |
||||
<p className="mb-4"> |
||||
Renderer is a private component. If you are a collaborator and have |
||||
access to it. Run the following command: |
||||
</p> |
||||
<code className="mt-5 rounded-md bg-gray-800 p-2 text-white"> |
||||
npm run generate-renderer |
||||
</code> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,5 @@ |
||||
export function renderFlowJSON(data: any, options?: any) { |
||||
console.warn("renderFlowJSON is not implemented"); |
||||
console.warn("run the following command to generate the renderer:"); |
||||
console.warn("> npm run generate-renderer"); |
||||
} |
@ -0,0 +1,31 @@ |
||||
#!/usr/bin/env bash |
||||
|
||||
set -e |
||||
|
||||
rm -rf .temp |
||||
git clone git@github.com:roadmapsh/web-draw.git .temp/web-draw |
||||
|
||||
rm -rf renderer |
||||
mkdir renderer |
||||
|
||||
# copy the files at /src/editor/renderer/* to /renderer |
||||
# while replacing any existing files |
||||
cp -rf .temp/web-draw/src/editor/renderer/* renderer |
||||
|
||||
# Add @ts-nocheck to the top of each ts and tsx file |
||||
# so that the typescript compiler doesn't complain |
||||
# about the missing types |
||||
find renderer -type f \( -name "*.ts" -o -name "*.tsx" \) -print0 | while IFS= read -r -d '' file; do |
||||
if [ -f "$file" ]; then |
||||
echo "// @ts-nocheck" > temp |
||||
cat "$file" >> temp |
||||
mv temp "$file" |
||||
echo "Added @ts-nocheck to $file" |
||||
fi |
||||
done |
||||
|
||||
# remove the temporary directory |
||||
rm -rf .temp |
||||
|
||||
# ignore the worktree changes for the renderer directory |
||||
git update-index --skip-worktree renderer/* |
@ -0,0 +1,53 @@ |
||||
import { Plus } from 'lucide-react'; |
||||
import { isLoggedIn } from '../../../lib/jwt'; |
||||
import { showLoginPopup } from '../../../lib/popup'; |
||||
import { cn } from '../../../lib/classname'; |
||||
import { |
||||
type AllowedCustomRoadmapType, |
||||
type AllowedRoadmapVisibility, |
||||
CreateRoadmapModal, |
||||
} from './CreateRoadmapModal'; |
||||
import { useState } from 'react'; |
||||
|
||||
type CreateRoadmapButtonProps = { |
||||
className?: string; |
||||
type?: AllowedCustomRoadmapType; |
||||
}; |
||||
|
||||
export function CreateRoadmapButton(props: CreateRoadmapButtonProps) { |
||||
const { className, type } = props; |
||||
|
||||
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false); |
||||
|
||||
function toggleCreateRoadmapHandler() { |
||||
if (!isLoggedIn()) { |
||||
return showLoginPopup(); |
||||
} |
||||
|
||||
setIsCreatingRoadmap(true); |
||||
} |
||||
|
||||
return ( |
||||
<> |
||||
{isCreatingRoadmap && ( |
||||
<CreateRoadmapModal |
||||
type={type} |
||||
onClose={() => { |
||||
setIsCreatingRoadmap(false); |
||||
}} |
||||
/> |
||||
)} |
||||
|
||||
<button |
||||
className={cn( |
||||
'flex h-full w-full items-center justify-center gap-1 overflow-hidden rounded-md border border-dashed border-gray-800 p-3 text-sm text-gray-400 hover:border-gray-600 hover:bg-gray-900 hover:text-gray-300', |
||||
className |
||||
)} |
||||
onClick={toggleCreateRoadmapHandler} |
||||
> |
||||
<Plus size={16} /> |
||||
Create a new roadmap |
||||
</button> |
||||
</> |
||||
); |
||||
} |
@ -0,0 +1,275 @@ |
||||
import { |
||||
type FormEvent, |
||||
type MouseEvent, |
||||
useEffect, |
||||
useRef, |
||||
useState, |
||||
} from 'react'; |
||||
import { Loader2 } from 'lucide-react'; |
||||
import { Modal } from '../../Modal'; |
||||
import { useToast } from '../../../hooks/use-toast'; |
||||
import { httpPost } from '../../../lib/http'; |
||||
import { cn } from '../../../lib/classname'; |
||||
import { allowedVisibilityLabels } from '../ShareRoadmapModal'; |
||||
|
||||
export const allowedRoadmapVisibility = [ |
||||
'me', |
||||
'friends', |
||||
'team', |
||||
'public', |
||||
] as const; |
||||
export type AllowedRoadmapVisibility = |
||||
(typeof allowedRoadmapVisibility)[number]; |
||||
export const allowedCustomRoadmapType = ['role', 'skill'] as const; |
||||
export type AllowedCustomRoadmapType = |
||||
(typeof allowedCustomRoadmapType)[number]; |
||||
|
||||
export interface RoadmapDocument { |
||||
_id?: string; |
||||
title: string; |
||||
description?: string; |
||||
creatorId: string; |
||||
teamId?: string; |
||||
type: AllowedCustomRoadmapType; |
||||
visibility: AllowedRoadmapVisibility; |
||||
sharedFriendIds?: string[]; |
||||
sharedTeamMemberIds?: string[]; |
||||
nodes: any[]; |
||||
edges: any[]; |
||||
createdAt: Date; |
||||
updatedAt: Date; |
||||
canManage: boolean; |
||||
isCustomResource: boolean; |
||||
} |
||||
|
||||
interface CreateRoadmapModalProps { |
||||
onClose: () => void; |
||||
onCreated?: (roadmap: RoadmapDocument) => void; |
||||
teamId?: string; |
||||
type?: AllowedCustomRoadmapType; |
||||
visibility?: AllowedRoadmapVisibility; |
||||
} |
||||
|
||||
export function CreateRoadmapModal(props: CreateRoadmapModalProps) { |
||||
const { onClose, onCreated, teamId, type: defaultType = 'role' } = props; |
||||
|
||||
const titleRef = useRef<HTMLInputElement>(null); |
||||
const toast = useToast(); |
||||
|
||||
const [isLoading, setIsLoading] = useState(false); |
||||
const [title, setTitle] = useState(''); |
||||
const [description, setDescription] = useState(''); |
||||
const [type, setType] = useState<AllowedCustomRoadmapType>(defaultType); |
||||
const isInvalidDescription = description?.trim().length > 80; |
||||
|
||||
async function handleSubmit( |
||||
e: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement>, |
||||
redirect: boolean = true |
||||
) { |
||||
e.preventDefault(); |
||||
if (isLoading) { |
||||
return; |
||||
} |
||||
|
||||
if (title.trim() === '' || isInvalidDescription || !type) { |
||||
toast.error('Please fill all the fields'); |
||||
return; |
||||
} |
||||
|
||||
setIsLoading(true); |
||||
const { response, error } = await httpPost<RoadmapDocument>( |
||||
`${import.meta.env.PUBLIC_API_URL}/v1-create-roadmap`, |
||||
{ |
||||
title, |
||||
description, |
||||
type, |
||||
...(teamId && { |
||||
teamId, |
||||
}), |
||||
nodes: [], |
||||
edges: [], |
||||
} |
||||
); |
||||
|
||||
if (error) { |
||||
setIsLoading(false); |
||||
toast.error(error?.message || 'Something went wrong, please try again'); |
||||
return; |
||||
} |
||||
|
||||
toast.success('Roadmap created successfully'); |
||||
if (redirect) { |
||||
window.location.href = `${import.meta.env.PUBLIC_EDITOR_APP_URL}/${ |
||||
response?._id |
||||
}`;
|
||||
return; |
||||
} |
||||
|
||||
if (onCreated) { |
||||
onCreated(response as RoadmapDocument); |
||||
return; |
||||
} |
||||
|
||||
onClose(); |
||||
|
||||
setTitle(''); |
||||
setDescription(''); |
||||
setType('role'); |
||||
setIsLoading(false); |
||||
} |
||||
|
||||
useEffect(() => { |
||||
titleRef.current?.focus(); |
||||
}, []); |
||||
|
||||
return ( |
||||
<Modal |
||||
onClose={onClose} |
||||
bodyClassName="p-4" |
||||
wrapperClassName={cn(teamId && 'max-w-lg')} |
||||
> |
||||
<div className="mb-4"> |
||||
<h2 className="text-lg font-medium text-gray-900">Create Roadmap</h2> |
||||
<p className="mt-1 text-sm text-gray-500"> |
||||
Add a title and description to your roadmap. |
||||
</p> |
||||
</div> |
||||
<form onSubmit={handleSubmit}> |
||||
<div className="mt-4"> |
||||
<label |
||||
htmlFor="title" |
||||
className="block text-xs uppercase text-gray-400" |
||||
> |
||||
Roadmap Title |
||||
</label> |
||||
<div className="mt-1"> |
||||
<input |
||||
ref={titleRef} |
||||
type="text" |
||||
name="title" |
||||
id="title" |
||||
required |
||||
className="block w-full rounded-md border border-gray-300 px-2.5 py-2 outline-none focus:border-black sm:text-sm" |
||||
placeholder="Enter Title" |
||||
value={title} |
||||
onChange={(e) => setTitle(e.target.value)} |
||||
/> |
||||
</div> |
||||
</div> |
||||
<div className="mt-4"> |
||||
<label |
||||
htmlFor="description" |
||||
className="block text-xs uppercase text-gray-400" |
||||
> |
||||
Description |
||||
</label> |
||||
<div className="relative mt-1"> |
||||
<textarea |
||||
id="description" |
||||
name="description" |
||||
required |
||||
className={cn( |
||||
'block h-24 w-full resize-none rounded-md border border-gray-300 px-2.5 py-2 outline-none focus:border-black sm:text-sm', |
||||
isInvalidDescription && 'border-red-300 bg-red-100' |
||||
)} |
||||
placeholder="Enter Description" |
||||
value={description} |
||||
onChange={(e) => setDescription(e.target.value)} |
||||
/> |
||||
<div className="absolute bottom-2 right-2 text-xs text-gray-400"> |
||||
{description.length}/80 |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<div className="mt-4"> |
||||
<label |
||||
htmlFor="type" |
||||
className="block text-xs uppercase text-gray-400" |
||||
> |
||||
Type |
||||
</label> |
||||
<div className="mt-1"> |
||||
<select |
||||
id="type" |
||||
name="type" |
||||
required |
||||
className="block w-full rounded-md border border-gray-300 px-2.5 py-2 outline-none focus:border-black sm:text-sm" |
||||
value={type} |
||||
onChange={(e) => |
||||
setType(e.target.value as AllowedCustomRoadmapType) |
||||
} |
||||
> |
||||
{allowedCustomRoadmapType.map((type) => ( |
||||
<option key={type} value={type}> |
||||
{type.charAt(0).toUpperCase() + type.slice(1)} Based Roadmap |
||||
</option> |
||||
))} |
||||
</select> |
||||
</div> |
||||
</div> |
||||
|
||||
<div |
||||
className={cn('mt-4 flex justify-between gap-2', teamId && 'mt-8')} |
||||
> |
||||
<button |
||||
onClick={onClose} |
||||
type="button" |
||||
className={cn( |
||||
'block h-9 rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-black outline-none hover:border-gray-300 hover:bg-gray-50 focus:border-gray-300 focus:bg-gray-100', |
||||
!teamId && 'w-full' |
||||
)} |
||||
> |
||||
Cancel |
||||
</button> |
||||
|
||||
<div className={cn('flex items-center gap-2', !teamId && 'w-full')}> |
||||
{teamId && !isLoading && ( |
||||
<button |
||||
disabled={isLoading} |
||||
type="button" |
||||
onClick={(e) => handleSubmit(e, false)} |
||||
className="flex h-9 items-center justify-center rounded-md border border-black bg-white px-4 py-2 text-sm font-medium text-black outline-none hover:bg-black hover:text-white focus:bg-black focus:text-white" |
||||
> |
||||
{isLoading ? ( |
||||
<Loader2 size={16} className="animate-spin" /> |
||||
) : ( |
||||
'Save as Placeholder' |
||||
)} |
||||
</button> |
||||
)} |
||||
|
||||
<button |
||||
disabled={isLoading} |
||||
type="submit" |
||||
className={cn( |
||||
'flex h-9 items-center justify-center rounded-md border border-transparent bg-black px-4 py-2 text-sm font-medium text-white outline-none hover:bg-gray-800 focus:bg-gray-800', |
||||
teamId ? 'hidden sm:flex' : 'w-full' |
||||
)} |
||||
> |
||||
{isLoading ? ( |
||||
<Loader2 size={16} className="animate-spin" /> |
||||
) : teamId ? ( |
||||
'Continue to Editor' |
||||
) : ( |
||||
'Create' |
||||
)} |
||||
</button> |
||||
</div> |
||||
</div> |
||||
{teamId && ( |
||||
<> |
||||
<p className="mt-4 hidden rounded-md border border-orange-200 bg-orange-50 p-2.5 text-sm text-orange-600 sm:block"> |
||||
Preparing the roadmap might take some time, feel free to save it |
||||
as a placeholder and anyone with the role <strong>admin</strong>{' '} |
||||
or <strong>manager</strong> can prepare it later. |
||||
</p> |
||||
<p className="mt-4 rounded-md border border-orange-200 bg-orange-50 p-2.5 text-sm text-orange-600 sm:hidden"> |
||||
Create a placeholder now and prepare it later. |
||||
</p> |
||||
</> |
||||
)} |
||||
</form> |
||||
</Modal> |
||||
); |
||||
} |
@ -0,0 +1,121 @@ |
||||
import { useEffect, useState } from 'react'; |
||||
import { getUrlParams } from '../../lib/browser'; |
||||
import { |
||||
type AppError, |
||||
type FetchError, |
||||
httpGet, |
||||
httpPost, |
||||
} from '../../lib/http'; |
||||
import { RoadmapHeader } from './RoadmapHeader'; |
||||
import { RoadmapRenderer } from './RoadmapRenderer'; |
||||
import { TopicDetail } from '../TopicDetail/TopicDetail'; |
||||
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal'; |
||||
import { currentRoadmap } from '../../stores/roadmap'; |
||||
import { UserProgressModal } from '../UserProgress/UserProgressModal'; |
||||
import { RestrictedPage } from './RestrictedPage'; |
||||
import { isLoggedIn } from '../../lib/jwt'; |
||||
|
||||
export const allowedLinkTypes = [ |
||||
'video', |
||||
'article', |
||||
'opensource', |
||||
'course', |
||||
'website', |
||||
'podcast', |
||||
] as const; |
||||
|
||||
export type AllowedLinkTypes = (typeof allowedLinkTypes)[number]; |
||||
|
||||
export interface RoadmapContentDocument { |
||||
_id?: string; |
||||
roadmapId: string; |
||||
nodeId: string; |
||||
title: string; |
||||
description: string; |
||||
links: { |
||||
id: string; |
||||
type: AllowedLinkTypes; |
||||
title: string; |
||||
url: string; |
||||
}[]; |
||||
} |
||||
|
||||
export function hideRoadmapLoader() { |
||||
const loaderEl = document.querySelector( |
||||
'[data-roadmap-loader]' |
||||
) as HTMLElement; |
||||
if (loaderEl) { |
||||
loaderEl.remove(); |
||||
} |
||||
} |
||||
|
||||
export function CustomRoadmap() { |
||||
const { id, secret } = getUrlParams() as { id: string; secret: string }; |
||||
|
||||
const [isLoading, setIsLoading] = useState(true); |
||||
const [roadmap, setRoadmap] = useState<RoadmapDocument | null>(null); |
||||
const [error, setError] = useState<AppError | FetchError | undefined>(); |
||||
|
||||
async function getRoadmap() { |
||||
setIsLoading(true); |
||||
|
||||
const roadmapUrl = new URL( |
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-roadmap/${id}` |
||||
); |
||||
if (secret) { |
||||
roadmapUrl.searchParams.set('secret', secret); |
||||
} |
||||
|
||||
const { response, error } = await httpGet<RoadmapDocument>( |
||||
roadmapUrl.toString() |
||||
); |
||||
|
||||
if (error || !response) { |
||||
setError(error); |
||||
setIsLoading(false); |
||||
return; |
||||
} |
||||
|
||||
document.title = `${response.title} - roadmap.sh`; |
||||
|
||||
setRoadmap(response); |
||||
currentRoadmap.set(response); |
||||
setIsLoading(false); |
||||
} |
||||
|
||||
async function trackVisit() { |
||||
if (!isLoggedIn()) return; |
||||
await httpPost(`${import.meta.env.PUBLIC_API_URL}/v1-visit`, { |
||||
resourceId: id, |
||||
resourceType: 'roadmap', |
||||
}); |
||||
} |
||||
|
||||
useEffect(() => { |
||||
getRoadmap().finally(() => { |
||||
hideRoadmapLoader(); |
||||
}); |
||||
trackVisit().then(); |
||||
}, []); |
||||
|
||||
if (isLoading) { |
||||
return null; |
||||
} |
||||
|
||||
if (error) { |
||||
return <RestrictedPage error={error} />; |
||||
} |
||||
|
||||
return ( |
||||
<> |
||||
<RoadmapHeader /> |
||||
<RoadmapRenderer roadmap={roadmap!} /> |
||||
<TopicDetail canSubmitContribution={false} /> |
||||
<UserProgressModal |
||||
resourceId={roadmap?._id!} |
||||
resourceType="roadmap" |
||||
isCustomResource={true} |
||||
/> |
||||
</> |
||||
); |
||||
} |
@ -0,0 +1,12 @@ |
||||
import { CircleSlash } from 'lucide-react'; |
||||
|
||||
export function EmptyRoadmap() { |
||||
return ( |
||||
<div className="flex h-full items-center justify-center"> |
||||
<div className="flex flex-col items-center"> |
||||
<CircleSlash className="mx-auto h-20 w-20 text-gray-400" /> |
||||
<h3 className="mt-4">This roadmap is currently empty.</h3> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,94 @@ |
||||
import MoreIcon from '../../icons/more-vertical.svg'; |
||||
import { useRef, useState } from 'react'; |
||||
import { useOutsideClick } from '../../hooks/use-outside-click'; |
||||
import { Lock, MoreVertical, Shapes, Trash2 } from 'lucide-react'; |
||||
|
||||
type PersonalRoadmapActionDropdownProps = { |
||||
onDelete?: () => void; |
||||
onCustomize?: () => void; |
||||
onUpdateSharing?: () => void; |
||||
}; |
||||
|
||||
export function PersonalRoadmapActionDropdown(props: PersonalRoadmapActionDropdownProps) { |
||||
const { onDelete, onUpdateSharing, onCustomize } = props; |
||||
|
||||
const menuRef = useRef<HTMLDivElement>(null); |
||||
const [isOpen, setIsOpen] = useState(false); |
||||
|
||||
useOutsideClick(menuRef, () => { |
||||
setIsOpen(false); |
||||
}); |
||||
|
||||
return ( |
||||
<div className="relative"> |
||||
<button |
||||
disabled={false} |
||||
onClick={() => setIsOpen(!isOpen)} |
||||
className="hidden items-center opacity-60 transition-opacity hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-30 sm:flex" |
||||
> |
||||
<img alt="menu" src={MoreIcon.src} className="h-4 w-4" /> |
||||
</button> |
||||
|
||||
<button |
||||
disabled={false} |
||||
onClick={() => setIsOpen(!isOpen)} |
||||
className="flex items-center gap-1 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-none sm:hidden" |
||||
> |
||||
<MoreVertical size={14} /> |
||||
Options |
||||
</button> |
||||
|
||||
{isOpen && ( |
||||
<div |
||||
ref={menuRef} |
||||
className="align-right absolute right-auto top-full z-50 mt-1 w-[140px] rounded-md bg-slate-800 px-2 py-2 text-white shadow-md sm:right-0" |
||||
> |
||||
<ul> |
||||
{onUpdateSharing && ( |
||||
<li> |
||||
<button |
||||
onClick={() => { |
||||
setIsOpen(false); |
||||
onUpdateSharing(); |
||||
}} |
||||
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700" |
||||
> |
||||
<Lock size={14} className="mr-2" /> |
||||
Sharing |
||||
</button> |
||||
</li> |
||||
)} |
||||
{onCustomize && ( |
||||
<li> |
||||
<button |
||||
onClick={() => { |
||||
setIsOpen(false); |
||||
onCustomize(); |
||||
}} |
||||
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700" |
||||
> |
||||
<Shapes size={14} className="mr-2" /> |
||||
Customize |
||||
</button> |
||||
</li> |
||||
)} |
||||
{onDelete && ( |
||||
<li> |
||||
<button |
||||
onClick={() => { |
||||
setIsOpen(false); |
||||
onDelete(); |
||||
}} |
||||
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700" |
||||
> |
||||
<Trash2 size={14} className="mr-2" /> |
||||
Delete |
||||
</button> |
||||
</li> |
||||
)} |
||||
</ul> |
||||
</div> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,238 @@ |
||||
import { httpDelete } from '../../lib/http'; |
||||
import { pageProgressMessage } from '../../stores/page'; |
||||
import { |
||||
ExternalLink, |
||||
Shapes, |
||||
type LucideIcon, |
||||
Globe, |
||||
LockIcon, |
||||
Users, |
||||
} from 'lucide-react'; |
||||
import { useToast } from '../../hooks/use-toast'; |
||||
import { |
||||
type AllowedRoadmapVisibility, |
||||
type RoadmapDocument, |
||||
} from './CreateRoadmap/CreateRoadmapModal'; |
||||
import RoadmapIcon from '../../icons/roadmap.svg'; |
||||
import { PersonalRoadmapActionDropdown } from './PersonalRoadmapActionDropdown'; |
||||
import type { GetRoadmapListResponse } from './RoadmapListPage'; |
||||
import { useState, type Dispatch, type SetStateAction } from 'react'; |
||||
import { ShareOptionsModal } from '../ShareOptions/ShareOptionsModal'; |
||||
|
||||
type PersonalRoadmapListType = { |
||||
roadmaps: GetRoadmapListResponse['personalRoadmaps']; |
||||
onDelete: (roadmapId: string) => void; |
||||
setAllRoadmaps: Dispatch<SetStateAction<GetRoadmapListResponse>>; |
||||
}; |
||||
|
||||
export function PersonalRoadmapList(props: PersonalRoadmapListType) { |
||||
const { roadmaps: roadmapList, onDelete, setAllRoadmaps } = props; |
||||
|
||||
const toast = useToast(); |
||||
|
||||
const [selectedRoadmap, setSelectedRoadmap] = useState< |
||||
GetRoadmapListResponse['personalRoadmaps'][number] | null |
||||
>(null); |
||||
|
||||
async function deleteRoadmap(roadmapId: string) { |
||||
const { response, error } = await httpDelete<RoadmapDocument[]>( |
||||
`${import.meta.env.PUBLIC_API_URL}/v1-delete-roadmap/${roadmapId}` |
||||
); |
||||
|
||||
if (error || !response) { |
||||
console.error(error); |
||||
toast.error(error?.message || 'Something went wrong, please try again'); |
||||
|
||||
return; |
||||
} |
||||
|
||||
toast.success('Roadmap deleted'); |
||||
onDelete(roadmapId); |
||||
} |
||||
|
||||
async function onRemove(roadmapId: string) { |
||||
pageProgressMessage.set('Deleting roadmap'); |
||||
|
||||
deleteRoadmap(roadmapId).finally(() => { |
||||
pageProgressMessage.set(''); |
||||
}); |
||||
} |
||||
|
||||
const shareSettingsModal = selectedRoadmap && ( |
||||
<ShareOptionsModal |
||||
visibility={selectedRoadmap.visibility} |
||||
sharedFriendIds={selectedRoadmap.sharedFriendIds} |
||||
sharedTeamMemberIds={selectedRoadmap.sharedTeamMemberIds} |
||||
roadmapId={selectedRoadmap._id!} |
||||
onClose={() => setSelectedRoadmap(null)} |
||||
onShareSettingsUpdate={(settings) => { |
||||
setAllRoadmaps((prev) => { |
||||
return { |
||||
...prev, |
||||
personalRoadmaps: prev.personalRoadmaps.map((roadmap) => { |
||||
if (roadmap._id === selectedRoadmap._id) { |
||||
return { |
||||
...roadmap, |
||||
...settings, |
||||
}; |
||||
} |
||||
|
||||
return roadmap; |
||||
}), |
||||
}; |
||||
}); |
||||
}} |
||||
/> |
||||
); |
||||
|
||||
if (roadmapList.length === 0) { |
||||
return ( |
||||
<div className="flex flex-col items-center p-4 py-20"> |
||||
<img |
||||
alt="roadmap" |
||||
src={RoadmapIcon.src} |
||||
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"> |
||||
Create a roadmap to get started |
||||
</p> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<div> |
||||
{shareSettingsModal} |
||||
<div className="mb-3 flex items-center justify-between"> |
||||
<span className={'text-sm text-gray-400'}> |
||||
{roadmapList.length} custom roadmap(s) |
||||
</span> |
||||
</div> |
||||
<ul className="flex flex-col divide-y rounded-md border"> |
||||
{roadmapList.map((roadmap) => { |
||||
return ( |
||||
<CustomRoadmapItem |
||||
key={roadmap._id!} |
||||
roadmap={roadmap} |
||||
onRemove={onRemove} |
||||
setSelectedRoadmap={setSelectedRoadmap} |
||||
/> |
||||
); |
||||
})} |
||||
</ul> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
type CustomRoadmapItemProps = { |
||||
roadmap: GetRoadmapListResponse['personalRoadmaps'][number]; |
||||
onRemove: (roadmapId: string) => Promise<void>; |
||||
setSelectedRoadmap: ( |
||||
roadmap: GetRoadmapListResponse['personalRoadmaps'][number] | null |
||||
) => void; |
||||
}; |
||||
|
||||
function CustomRoadmapItem(props: CustomRoadmapItemProps) { |
||||
const { roadmap, onRemove, setSelectedRoadmap } = props; |
||||
|
||||
const editorLink = `${import.meta.env.PUBLIC_EDITOR_APP_URL}/${roadmap._id}`; |
||||
|
||||
return ( |
||||
<li |
||||
className="grid grid-cols-1 p-2.5 sm:grid-cols-[auto_110px]" |
||||
key={roadmap._id!} |
||||
> |
||||
<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"> |
||||
{roadmap.title} |
||||
</p> |
||||
<span className="flex items-center text-xs leading-none text-gray-400"> |
||||
<VisibilityBadge |
||||
visibility={roadmap.visibility!} |
||||
sharedFriendIds={roadmap.sharedFriendIds} |
||||
/> |
||||
<span className="mx-2 font-semibold">·</span> |
||||
<Shapes size={16} className="mr-1 inline-block h-4 w-4" /> |
||||
{roadmap.topics} topic |
||||
</span> |
||||
</div> |
||||
<div className="mr-1 flex items-center justify-start sm:justify-end"> |
||||
<PersonalRoadmapActionDropdown |
||||
onUpdateSharing={() => { |
||||
setSelectedRoadmap(roadmap); |
||||
}} |
||||
onCustomize={() => { |
||||
window.open(editorLink, '_blank'); |
||||
}} |
||||
onDelete={() => { |
||||
if (confirm('Are you sure you want to remove this roadmap?')) { |
||||
onRemove(roadmap._id!).finally(() => {}); |
||||
} |
||||
}} |
||||
/> |
||||
|
||||
<a |
||||
href={`/r?id=${roadmap._id}`} |
||||
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> |
||||
</li> |
||||
); |
||||
} |
||||
|
||||
type VisibilityLabelProps = { |
||||
visibility: AllowedRoadmapVisibility; |
||||
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; |
||||
|
||||
function VisibilityBadge(props: VisibilityLabelProps) { |
||||
const { visibility, 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 === 'friends' && sharedFriendIds?.length > 0 && ( |
||||
<span className="mr-1">{sharedFriendIds.length}</span> |
||||
)} |
||||
{label} |
||||
</div> |
||||
</span> |
||||
); |
||||
} |
@ -0,0 +1,111 @@ |
||||
import { HelpCircle } from 'lucide-react'; |
||||
import { cn } from '../../lib/classname'; |
||||
import type { ResourceType } from '../../lib/resource-progress'; |
||||
import { useState } from 'react'; |
||||
import { useStore } from '@nanostores/react'; |
||||
import { canManageCurrentRoadmap, currentRoadmap } from '../../stores/roadmap'; |
||||
import { ShareOptionsModal } from '../ShareOptions/ShareOptionsModal'; |
||||
|
||||
type ResourceProgressStatsProps = { |
||||
resourceId: string; |
||||
resourceType: ResourceType; |
||||
isSecondaryBanner?: boolean; |
||||
}; |
||||
|
||||
export function ResourceProgressStats(props: ResourceProgressStatsProps) { |
||||
const { isSecondaryBanner = false } = props; |
||||
|
||||
const [isSharing, setIsSharing] = useState(false); |
||||
|
||||
const $canManageCurrentRoadmap = useStore(canManageCurrentRoadmap); |
||||
const $currentRoadmap = useStore(currentRoadmap); |
||||
|
||||
return ( |
||||
<> |
||||
{isSharing && $canManageCurrentRoadmap && $currentRoadmap && ( |
||||
<ShareOptionsModal |
||||
visibility={$currentRoadmap?.visibility} |
||||
teamId={$currentRoadmap?.teamId} |
||||
roadmapId={$currentRoadmap?._id!} |
||||
sharedFriendIds={$currentRoadmap?.sharedFriendIds || []} |
||||
sharedTeamMemberIds={$currentRoadmap?.sharedTeamMemberIds || []} |
||||
onClose={() => setIsSharing(false)} |
||||
onShareSettingsUpdate={(settings) => { |
||||
currentRoadmap.set({ |
||||
...$currentRoadmap, |
||||
...settings, |
||||
}); |
||||
}} |
||||
/> |
||||
)} |
||||
<div |
||||
data-progress-nums-container="" |
||||
className={cn( |
||||
'striped-loader relative hidden items-center justify-between bg-white px-2 py-1.5 sm:flex', |
||||
{ |
||||
'rounded-bl-md rounded-br-md': isSecondaryBanner, |
||||
'rounded-md': !isSecondaryBanner, |
||||
} |
||||
)} |
||||
> |
||||
<p |
||||
className="flex text-sm opacity-0 transition-opacity duration-300" |
||||
data-progress-nums="" |
||||
> |
||||
<span className="mr-2.5 rounded-sm bg-yellow-200 px-1 py-0.5 text-xs font-medium uppercase text-yellow-900"> |
||||
<span data-progress-percentage="">0</span>% Done |
||||
</span> |
||||
|
||||
<span className="itesm-center hidden md:flex"> |
||||
<span> |
||||
<span data-progress-done="">0</span> completed |
||||
</span> |
||||
<span className="mx-1.5 text-gray-400">·</span> |
||||
<span> |
||||
<span data-progress-learning="">0</span> in progress |
||||
</span> |
||||
<span className="mx-1.5 text-gray-400">·</span> |
||||
<span> |
||||
<span data-progress-skipped="">0</span> skipped |
||||
</span> |
||||
<span className="mx-1.5 text-gray-400">·</span> |
||||
<span> |
||||
<span data-progress-total="">0</span> Total |
||||
</span> |
||||
</span> |
||||
<span className="md:hidden"> |
||||
<span data-progress-done="">0</span> of{' '} |
||||
<span data-progress-total="">0</span> Done |
||||
</span> |
||||
</p> |
||||
|
||||
<div |
||||
className="flex items-center gap-3 opacity-0 transition-opacity duration-300" |
||||
data-progress-nums="" |
||||
> |
||||
<button |
||||
data-popup="progress-help" |
||||
className="flex items-center gap-1 text-sm font-medium text-gray-500 opacity-0 transition-opacity hover:text-black" |
||||
data-progress-nums="" |
||||
> |
||||
<HelpCircle className="h-3.5 w-3.5 stroke-[2.5px]" /> |
||||
Track Progress |
||||
</button> |
||||
</div> |
||||
</div> |
||||
|
||||
<div |
||||
data-progress-nums-container="" |
||||
className="striped-loader relative -mb-2 flex items-center justify-between rounded-md border bg-white px-2 py-1.5 text-sm text-gray-700 sm:hidden" |
||||
> |
||||
<span |
||||
data-progress-nums="" |
||||
className="text-gray-500 opacity-0 transition-opacity duration-300" |
||||
> |
||||
<span data-progress-done="">0</span> of{' '} |
||||
<span data-progress-total="">0</span> Done |
||||
</span> |
||||
</div> |
||||
</> |
||||
); |
||||
} |
@ -0,0 +1,52 @@ |
||||
import { ShieldBan } from 'lucide-react'; |
||||
import type { FetchError } from '../../lib/http'; |
||||
|
||||
type RestrictedPageProps = { |
||||
error: FetchError; |
||||
}; |
||||
|
||||
export function RestrictedPage(props: RestrictedPageProps) { |
||||
const { error } = props; |
||||
|
||||
if (error.status === 404) { |
||||
return ( |
||||
<ErrorMessage |
||||
icon={<ShieldBan className="h-16 w-16" />} |
||||
title="Roadmap not found" |
||||
message="The roadmap you are looking for does not exist or has been deleted." |
||||
/> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<ErrorMessage |
||||
icon={<ShieldBan className="h-16 w-16" />} |
||||
title="Restricted Access" |
||||
message={error?.message} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
type ErrorMessageProps = { |
||||
title: string; |
||||
message: string; |
||||
icon: React.ReactNode; |
||||
}; |
||||
|
||||
function ErrorMessage(props: ErrorMessageProps) { |
||||
const { title, message, icon } = props; |
||||
return ( |
||||
<div className="flex grow flex-col items-center justify-center"> |
||||
{icon} |
||||
<h2 className="mt-4 text-2xl font-semibold">{title}</h2> |
||||
<p>{message || 'This roadmap is not available for public access.'}</p> |
||||
|
||||
<a |
||||
href="/" |
||||
className="mt-4 font-medium underline underline-offset-2 hover:no-underline" |
||||
> |
||||
← Go back to home |
||||
</a> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,85 @@ |
||||
import { useRef, useState } from 'react'; |
||||
import { useOutsideClick } from '../../hooks/use-outside-click'; |
||||
import { Lock, MoreVertical, Shapes, Trash2 } from 'lucide-react'; |
||||
|
||||
type RoadmapActionButtonProps = { |
||||
onDelete?: () => void; |
||||
onCustomize?: () => void; |
||||
onUpdateSharing?: () => void; |
||||
}; |
||||
|
||||
export function RoadmapActionButton(props: RoadmapActionButtonProps) { |
||||
const { onDelete, onUpdateSharing, onCustomize } = props; |
||||
|
||||
const menuRef = useRef<HTMLDivElement>(null); |
||||
const [isOpen, setIsOpen] = useState(false); |
||||
|
||||
useOutsideClick(menuRef, () => { |
||||
setIsOpen(false); |
||||
}); |
||||
|
||||
return ( |
||||
<div className="relative"> |
||||
<button |
||||
disabled={false} |
||||
onClick={() => setIsOpen(!isOpen)} |
||||
className="inline-flex items-center justify-center rounded-md bg-gray-500 py-1.5 pl-2 pr-2 text-xs font-medium text-white hover:bg-gray-600 sm:pl-1.5 sm:pr-3 sm:text-sm" |
||||
> |
||||
<MoreVertical className="mr-0 h-4 w-4 stroke-[2.5] sm:mr-1.5" /> |
||||
<span className="hidden sm:inline">Actions</span> |
||||
</button> |
||||
|
||||
{isOpen && ( |
||||
<div |
||||
ref={menuRef} |
||||
className="align-right absolute right-0 top-full z-50 mt-1 w-[140px] rounded-md bg-slate-800 px-2 py-2 text-white shadow-md" |
||||
> |
||||
<ul> |
||||
{onUpdateSharing && ( |
||||
<li> |
||||
<button |
||||
onClick={() => { |
||||
setIsOpen(false); |
||||
onUpdateSharing(); |
||||
}} |
||||
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700" |
||||
> |
||||
<Lock size={14} className="mr-2" /> |
||||
Sharing |
||||
</button> |
||||
</li> |
||||
)} |
||||
{onCustomize && ( |
||||
<li> |
||||
<button |
||||
onClick={() => { |
||||
setIsOpen(false); |
||||
onCustomize(); |
||||
}} |
||||
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700" |
||||
> |
||||
<Shapes size={14} className="mr-2" /> |
||||
Customize |
||||
</button> |
||||
</li> |
||||
)} |
||||
{onDelete && ( |
||||
<li> |
||||
<button |
||||
onClick={() => { |
||||
setIsOpen(false); |
||||
onDelete(); |
||||
}} |
||||
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700" |
||||
> |
||||
<Trash2 size={14} className="mr-2" /> |
||||
Delete |
||||
</button> |
||||
</li> |
||||
)} |
||||
</ul> |
||||
</div> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,142 @@ |
||||
import { RoadmapHint } from './RoadmapHint'; |
||||
import { useStore } from '@nanostores/react'; |
||||
import { canManageCurrentRoadmap, currentRoadmap } from '../../stores/roadmap'; |
||||
import { ShareOptionsModal } from '../ShareOptions/ShareOptionsModal'; |
||||
import { useState } from 'react'; |
||||
import { pageProgressMessage } from '../../stores/page'; |
||||
import { httpDelete, httpPut } from '../../lib/http'; |
||||
import { type TeamResourceConfig } from '../CreateTeam/RoadmapSelector'; |
||||
import { useToast } from '../../hooks/use-toast'; |
||||
import { RoadmapActionButton } from './RoadmapActionButton'; |
||||
|
||||
type RoadmapHeaderProps = {}; |
||||
|
||||
export function RoadmapHeader(props: RoadmapHeaderProps) { |
||||
const $canManageCurrentRoadmap = useStore(canManageCurrentRoadmap); |
||||
const $currentRoadmap = useStore(currentRoadmap); |
||||
|
||||
const { title, description, _id: roadmapId } = useStore(currentRoadmap) || {}; |
||||
|
||||
const [isSharing, setIsSharing] = useState(false); |
||||
const toast = useToast(); |
||||
|
||||
async function deleteResource() { |
||||
pageProgressMessage.set('Deleting roadmap'); |
||||
|
||||
const teamId = $currentRoadmap?.teamId; |
||||
const baseApiUrl = import.meta.env.PUBLIC_API_URL; |
||||
|
||||
let error, response; |
||||
if (teamId) { |
||||
({ error, response } = await httpPut<TeamResourceConfig>( |
||||
`${baseApiUrl}/v1-delete-team-resource-config/${teamId}`, |
||||
{ |
||||
resourceId: roadmapId, |
||||
resourceType: 'roadmap', |
||||
} |
||||
)); |
||||
} else { |
||||
({ error, response } = await httpDelete<TeamResourceConfig>( |
||||
`${baseApiUrl}/v1-delete-roadmap/${roadmapId}` |
||||
)); |
||||
} |
||||
|
||||
if (error || !response) { |
||||
toast.error(error?.message || 'Something went wrong'); |
||||
return; |
||||
} |
||||
|
||||
toast.success('Roadmap removed'); |
||||
if (!teamId) { |
||||
window.location.href = '/account/roadmaps'; |
||||
} else { |
||||
window.location.href = `/team/roadmaps?t=${teamId}`; |
||||
} |
||||
} |
||||
|
||||
return ( |
||||
<div className="border-b"> |
||||
<div className="container relative py-5 sm:py-12"> |
||||
<div className="mb-3 mt-0 sm:mb-4"> |
||||
<h1 className="text-2xl font-bold sm:mb-2 sm:text-4xl">{title}</h1> |
||||
<p className="mt-0.5 text-sm text-gray-500 sm:text-lg"> |
||||
{description} |
||||
</p> |
||||
</div> |
||||
|
||||
<div className="flex justify-between gap-2 sm:gap-0"> |
||||
<div className="flex gap-1 sm:gap-2"> |
||||
<a |
||||
href="/roadmaps" |
||||
className="rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm" |
||||
aria-label="Back to All Roadmaps" |
||||
> |
||||
←<span className="hidden sm:inline"> All Roadmaps</span> |
||||
</a> |
||||
|
||||
<button |
||||
data-guest-required |
||||
data-popup="login-popup" |
||||
className="inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm" |
||||
aria-label="Subscribe for Updates" |
||||
> |
||||
<span className="ml-2">Subscribe</span> |
||||
</button> |
||||
</div> |
||||
{$canManageCurrentRoadmap && ( |
||||
<div className="flex items-center gap-2"> |
||||
{isSharing && $currentRoadmap && ( |
||||
<ShareOptionsModal |
||||
visibility={$currentRoadmap?.visibility} |
||||
teamId={$currentRoadmap?.teamId} |
||||
roadmapId={$currentRoadmap?._id!} |
||||
sharedFriendIds={$currentRoadmap?.sharedFriendIds || []} |
||||
sharedTeamMemberIds={ |
||||
$currentRoadmap?.sharedTeamMemberIds || [] |
||||
} |
||||
onClose={() => setIsSharing(false)} |
||||
onShareSettingsUpdate={(settings) => { |
||||
currentRoadmap.set({ |
||||
...$currentRoadmap, |
||||
...settings, |
||||
}); |
||||
}} |
||||
/> |
||||
)} |
||||
|
||||
<RoadmapActionButton |
||||
onDelete={() => { |
||||
const confirmation = window.confirm( |
||||
'Are you sure you want to delete this roadmap?' |
||||
); |
||||
|
||||
if (!confirmation) { |
||||
return; |
||||
} |
||||
|
||||
deleteResource().finally(() => null); |
||||
}} |
||||
onCustomize={() => { |
||||
const editorLink = `${ |
||||
import.meta.env.PUBLIC_EDITOR_APP_URL |
||||
}/${$currentRoadmap?._id}`;
|
||||
|
||||
window.open(editorLink, '_blank'); |
||||
}} |
||||
onUpdateSharing={() => { |
||||
setIsSharing(true); |
||||
}} |
||||
/> |
||||
</div> |
||||
)} |
||||
</div> |
||||
|
||||
<RoadmapHint |
||||
roadmapTitle={title!} |
||||
hasTNSBanner={false} |
||||
roadmapId={roadmapId!} |
||||
/> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,48 @@ |
||||
import { cn } from '../../lib/classname'; |
||||
import { ResourceProgressStats } from './ResourceProgressStats'; |
||||
|
||||
type RoadmapHintProps = { |
||||
roadmapId: string; |
||||
roadmapTitle: string; |
||||
hasTNSBanner?: boolean; |
||||
tnsBannerLink?: string; |
||||
}; |
||||
|
||||
export function RoadmapHint(props: RoadmapHintProps) { |
||||
const { |
||||
roadmapTitle, |
||||
roadmapId, |
||||
hasTNSBanner = false, |
||||
tnsBannerLink = '', |
||||
} = props; |
||||
|
||||
return ( |
||||
<div |
||||
className={cn('mb-0 mt-4 rounded-md border-0 sm:mt-7 sm:border', { |
||||
'sm:-mb-[82px]': hasTNSBanner, |
||||
'sm:-mb-[65px]': !hasTNSBanner, |
||||
})} |
||||
> |
||||
{hasTNSBanner && ( |
||||
<div className="hidden border-b bg-gray-100 px-2 py-1.5 sm:block"> |
||||
<p className="text-sm"> |
||||
Get the latest {roadmapTitle} news from our sister site{' '} |
||||
<a |
||||
href={tnsBannerLink} |
||||
target="_blank" |
||||
className="font-semibold underline" |
||||
> |
||||
TheNewStack.io |
||||
</a> |
||||
</p> |
||||
</div> |
||||
)} |
||||
|
||||
<ResourceProgressStats |
||||
isSecondaryBanner={hasTNSBanner} |
||||
resourceId={roadmapId} |
||||
resourceType="roadmap" |
||||
/> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,134 @@ |
||||
import { useEffect, useState } from 'react'; |
||||
import { httpGet } from '../../lib/http'; |
||||
import { pageProgressMessage } from '../../stores/page'; |
||||
import { |
||||
CreateRoadmapModal, |
||||
type RoadmapDocument, |
||||
} from './CreateRoadmap/CreateRoadmapModal'; |
||||
import { PersonalRoadmapList } from './PersonalRoadmapList'; |
||||
import { useToast } from '../../hooks/use-toast'; |
||||
import { SharedRoadmapList } from './SharedRoadmapList'; |
||||
import type { FriendshipStatus } from '../Befriend'; |
||||
|
||||
export type FriendUserType = { |
||||
id: string; |
||||
name: string; |
||||
avatar: string; |
||||
status: FriendshipStatus; |
||||
}; |
||||
|
||||
export type GetRoadmapListResponse = { |
||||
personalRoadmaps: (RoadmapDocument & { |
||||
topics: number; |
||||
})[]; |
||||
sharedRoadmaps: (RoadmapDocument & { |
||||
topics: number; |
||||
creator: FriendUserType; |
||||
})[]; |
||||
}; |
||||
|
||||
type TabType = { |
||||
label: string; |
||||
value: 'personal' | 'shared'; |
||||
}; |
||||
|
||||
const tabTypes: TabType[] = [ |
||||
{ label: 'Personal', value: 'personal' }, |
||||
{ label: 'Shared by Friends', value: 'shared' }, |
||||
]; |
||||
|
||||
export function RoadmapListPage() { |
||||
const toast = useToast(); |
||||
|
||||
const [isLoading, setIsLoading] = useState(true); |
||||
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false); |
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType['value']>('personal'); |
||||
const [allRoadmaps, setAllRoadmaps] = useState<GetRoadmapListResponse>({ |
||||
personalRoadmaps: [], |
||||
sharedRoadmaps: [], |
||||
}); |
||||
|
||||
async function loadRoadmapList() { |
||||
setIsLoading(true); |
||||
const { response, error } = await httpGet<GetRoadmapListResponse>( |
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-roadmap-list` |
||||
); |
||||
|
||||
if (error || !response) { |
||||
console.error(error); |
||||
toast.error(error?.message || 'Something went wrong, please try again'); |
||||
return; |
||||
} |
||||
|
||||
setAllRoadmaps( |
||||
response! || { |
||||
personalRoadmaps: [], |
||||
sharedRoadmaps: [], |
||||
} |
||||
); |
||||
} |
||||
|
||||
useEffect(() => { |
||||
loadRoadmapList().finally(() => { |
||||
setIsLoading(false); |
||||
pageProgressMessage.set(''); |
||||
}); |
||||
}, []); |
||||
|
||||
if (isLoading) { |
||||
return null; |
||||
} |
||||
|
||||
return ( |
||||
<div> |
||||
{isCreatingRoadmap && ( |
||||
<CreateRoadmapModal onClose={() => setIsCreatingRoadmap(false)} /> |
||||
)} |
||||
|
||||
<div className="mb-6 flex items-center justify-between"> |
||||
<div className="flex items-center gap-2"> |
||||
{tabTypes.map((tab) => { |
||||
return ( |
||||
<button |
||||
key={tab.value} |
||||
className={`relative flex items-center justify-center rounded-md border p-1 px-3 text-sm ${ |
||||
activeTab === tab.value ? ' border-gray-400 bg-gray-200 ' : '' |
||||
} w-full sm:w-auto`}
|
||||
onClick={() => setActiveTab(tab.value)} |
||||
> |
||||
{tab.label} |
||||
</button> |
||||
); |
||||
})} |
||||
</div> |
||||
<button |
||||
className={`relative flex w-full items-center justify-center rounded-md border p-1 px-3 text-sm sm:w-auto`} |
||||
onClick={() => setIsCreatingRoadmap(true)} |
||||
> |
||||
+ Create Roadmap |
||||
</button> |
||||
</div> |
||||
|
||||
<div className="mt-4"> |
||||
{activeTab === 'personal' && ( |
||||
<PersonalRoadmapList |
||||
roadmaps={allRoadmaps?.personalRoadmaps} |
||||
setAllRoadmaps={setAllRoadmaps} |
||||
onDelete={(roadmapId) => { |
||||
setAllRoadmaps({ |
||||
...allRoadmaps, |
||||
personalRoadmaps: allRoadmaps.personalRoadmaps.filter( |
||||
(r) => r._id !== roadmapId |
||||
), |
||||
}); |
||||
}} |
||||
/> |
||||
)} |
||||
{activeTab === 'shared' && ( |
||||
<SharedRoadmapList roadmaps={allRoadmaps?.sharedRoadmaps} /> |
||||
)} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,53 @@ |
||||
svg text tspan { |
||||
-webkit-font-smoothing: antialiased; |
||||
-moz-osx-font-smoothing: grayscale; |
||||
text-rendering: optimizeSpeed; |
||||
} |
||||
|
||||
svg > g[data-type='topic'], |
||||
svg > g[data-type='subtopic'], |
||||
svg > g > g[data-type='link-item'], |
||||
svg > g[data-type='button'] { |
||||
cursor: pointer; |
||||
} |
||||
|
||||
svg > g[data-type='topic']:hover > rect { |
||||
fill: #d6d700; |
||||
} |
||||
|
||||
svg > g[data-type='subtopic']:hover > rect { |
||||
fill: #f3c950; |
||||
} |
||||
svg > g[data-type='button']:hover { |
||||
opacity: 0.8; |
||||
} |
||||
|
||||
svg .done rect { |
||||
fill: #cbcbcb !important; |
||||
} |
||||
|
||||
svg .done text, |
||||
svg .skipped text { |
||||
text-decoration: line-through; |
||||
} |
||||
|
||||
svg > g[data-type='topic'].learning > rect + text, |
||||
svg > g[data-type='topic'].done > rect + text { |
||||
fill: black; |
||||
} |
||||
|
||||
svg > g[data-type='subtipic'].done > rect + text, |
||||
svg > g[data-type='subtipic'].learning > rect + text { |
||||
fill: #cbcbcb; |
||||
} |
||||
|
||||
svg .learning rect { |
||||
fill: #dad1fd !important; |
||||
} |
||||
svg .learning text { |
||||
text-decoration: underline; |
||||
} |
||||
|
||||
svg .skipped rect { |
||||
fill: #496b69 !important; |
||||
} |
@ -0,0 +1,177 @@ |
||||
import { useCallback, useEffect, useRef, useState } from 'react'; |
||||
import { Renderer } from '../../../renderer'; |
||||
import './RoadmapRenderer.css'; |
||||
import { |
||||
renderResourceProgress, |
||||
updateResourceProgress, |
||||
type ResourceProgressType, |
||||
renderTopicProgress, |
||||
refreshProgressCounters, |
||||
} from '../../lib/resource-progress'; |
||||
import { pageProgressMessage } from '../../stores/page'; |
||||
import { useToast } from '../../hooks/use-toast'; |
||||
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal'; |
||||
import { EmptyRoadmap } from './EmptyRoadmap'; |
||||
import { isLoggedIn } from '../../lib/jwt'; |
||||
import { httpPost } from '../../lib/http'; |
||||
|
||||
type RoadmapRendererProps = { |
||||
roadmap: RoadmapDocument; |
||||
}; |
||||
|
||||
type RoadmapNodeDetails = { |
||||
nodeId: string; |
||||
nodeType: string; |
||||
targetGroup: SVGElement; |
||||
}; |
||||
|
||||
export function getNodeDetails( |
||||
svgElement: SVGElement |
||||
): RoadmapNodeDetails | null { |
||||
const targetGroup = (svgElement?.closest('g') as SVGElement) || {}; |
||||
|
||||
const nodeId = targetGroup?.dataset?.nodeId; |
||||
const nodeType = targetGroup?.dataset?.type; |
||||
if (!nodeId || !nodeType) return null; |
||||
|
||||
return { nodeId, nodeType, targetGroup }; |
||||
} |
||||
|
||||
export const allowedClickableNodeTypes = [ |
||||
'topic', |
||||
'subtopic', |
||||
'button', |
||||
'link-item', |
||||
]; |
||||
|
||||
export function RoadmapRenderer(props: RoadmapRendererProps) { |
||||
const { roadmap } = props; |
||||
const roadmapRef = useRef<HTMLDivElement>(null); |
||||
const roadmapId = roadmap._id!; |
||||
|
||||
const toast = useToast(); |
||||
const [hideRenderer, setHideRenderer] = useState(false); |
||||
|
||||
async function updateTopicStatus( |
||||
topicId: string, |
||||
newStatus: ResourceProgressType |
||||
) { |
||||
pageProgressMessage.set('Updating progress'); |
||||
updateResourceProgress( |
||||
{ |
||||
resourceId: roadmapId, |
||||
resourceType: 'roadmap', |
||||
topicId, |
||||
}, |
||||
newStatus |
||||
) |
||||
.then(() => { |
||||
renderTopicProgress(topicId, newStatus); |
||||
}) |
||||
.catch((err) => { |
||||
toast.error('Something went wrong, please try again.'); |
||||
console.error(err); |
||||
}) |
||||
.finally(() => { |
||||
pageProgressMessage.set(''); |
||||
refreshProgressCounters(); |
||||
}); |
||||
|
||||
return; |
||||
} |
||||
|
||||
const handleSvgClick = useCallback((e: MouseEvent) => { |
||||
const target = e.target as SVGElement; |
||||
const { nodeId, nodeType, targetGroup } = getNodeDetails(target) || {}; |
||||
if (!nodeId || !nodeType || !allowedClickableNodeTypes.includes(nodeType)) |
||||
return; |
||||
|
||||
if (nodeType === 'button' || nodeType === 'link-item') { |
||||
const link = targetGroup?.dataset?.link || ''; |
||||
const isExternalLink = link.startsWith('http'); |
||||
if (isExternalLink) { |
||||
window.open(link, '_blank'); |
||||
} else { |
||||
window.location.href = link; |
||||
} |
||||
return; |
||||
} |
||||
|
||||
const isCurrentStatusLearning = targetGroup?.classList.contains('learning'); |
||||
const isCurrentStatusSkipped = targetGroup?.classList.contains('skipped'); |
||||
|
||||
if (e.shiftKey) { |
||||
e.preventDefault(); |
||||
updateTopicStatus( |
||||
nodeId, |
||||
isCurrentStatusLearning ? 'pending' : 'learning' |
||||
); |
||||
return; |
||||
} else if (e.altKey) { |
||||
e.preventDefault(); |
||||
updateTopicStatus(nodeId, isCurrentStatusSkipped ? 'pending' : 'skipped'); |
||||
return; |
||||
} |
||||
|
||||
window.dispatchEvent( |
||||
new CustomEvent('roadmap.node.click', { |
||||
detail: { |
||||
topicId: nodeId, |
||||
resourceId: roadmap?._id, |
||||
resourceType: 'roadmap', |
||||
isCustomResource: true, |
||||
}, |
||||
}) |
||||
); |
||||
}, []); |
||||
|
||||
const handleSvgRightClick = useCallback((e: MouseEvent) => { |
||||
e.preventDefault(); |
||||
|
||||
const target = e.target as SVGElement; |
||||
const { nodeId, nodeType, targetGroup } = getNodeDetails(target) || {}; |
||||
if (!nodeId || !nodeType || !allowedClickableNodeTypes.includes(nodeType)) |
||||
return; |
||||
|
||||
if (nodeType === 'button' || nodeType === 'link-item') { |
||||
return; |
||||
} |
||||
|
||||
const isCurrentStatusDone = targetGroup?.classList.contains('done'); |
||||
updateTopicStatus(nodeId, isCurrentStatusDone ? 'pending' : 'done'); |
||||
}, []); |
||||
|
||||
useEffect(() => { |
||||
if (!roadmapRef?.current) return; |
||||
roadmapRef?.current?.addEventListener('click', handleSvgClick); |
||||
roadmapRef?.current?.addEventListener('contextmenu', handleSvgRightClick); |
||||
|
||||
return () => { |
||||
roadmapRef?.current?.removeEventListener('click', handleSvgClick); |
||||
roadmapRef?.current?.removeEventListener( |
||||
'contextmenu', |
||||
handleSvgRightClick |
||||
); |
||||
}; |
||||
}, []); |
||||
|
||||
return ( |
||||
<div className="flex grow bg-gray-50 pb-8 pt-4 sm:pt-12"> |
||||
<div className="container !max-w-[1000px]"> |
||||
<Renderer |
||||
ref={roadmapRef} |
||||
roadmap={{ nodes: roadmap?.nodes!, edges: roadmap?.edges! }} |
||||
onRendered={() => { |
||||
renderResourceProgress('roadmap', roadmapId).then(() => { |
||||
if (roadmap?.nodes?.length === 0) { |
||||
setHideRenderer(true); |
||||
roadmapRef?.current?.classList.add('hidden'); |
||||
} |
||||
}); |
||||
}} |
||||
/> |
||||
{hideRenderer && <EmptyRoadmap />} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,162 @@ |
||||
import { useState } from 'react'; |
||||
import { useStore } from '@nanostores/react'; |
||||
import { Check, Copy, Loader2 } from 'lucide-react'; |
||||
|
||||
import { Modal } from '../Modal'; |
||||
import type { AllowedRoadmapVisibility } from './CreateRoadmap/CreateRoadmapModal'; |
||||
import { cn } from '../../lib/classname'; |
||||
import { httpPatch } from '../../lib/http'; |
||||
import { useToast } from '../../hooks/use-toast'; |
||||
import { useCopyText } from '../../hooks/use-copy-text'; |
||||
import { currentRoadmap, isCurrentRoadmapPersonal } from '../../stores/roadmap'; |
||||
|
||||
type ShareRoadmapModalProps = { |
||||
onClose: () => void; |
||||
}; |
||||
|
||||
export const allowedVisibilityLabels: { |
||||
id: AllowedRoadmapVisibility; |
||||
label: string; |
||||
}[] = [ |
||||
{ |
||||
id: 'me', |
||||
label: 'Only visible to me', |
||||
}, |
||||
{ |
||||
id: 'public', |
||||
label: 'Anyone with the link', |
||||
}, |
||||
{ |
||||
id: 'team', |
||||
label: 'Visible to team members', |
||||
}, |
||||
{ |
||||
id: 'friends', |
||||
label: 'Only friends can view', |
||||
}, |
||||
]; |
||||
|
||||
export function ShareRoadmapModal(props: ShareRoadmapModalProps) { |
||||
const { onClose } = props; |
||||
|
||||
const toast = useToast(); |
||||
const $currentRoadmap = useStore(currentRoadmap); |
||||
const $isCurrentRoadmapPersonal = useStore(isCurrentRoadmapPersonal); |
||||
const roadmapId = $currentRoadmap?._id!; |
||||
|
||||
const { copyText, isCopied } = useCopyText(); |
||||
const [visibility, setVisibility] = useState($currentRoadmap?.visibility); |
||||
const [isLoading, setIsLoading] = useState(false); |
||||
|
||||
async function updateVisibility(newVisibility: AllowedRoadmapVisibility) { |
||||
setIsLoading(true); |
||||
const { response, error } = await httpPatch( |
||||
`${import.meta.env.PUBLIC_API_URL}/v1-update-roadmap-visibility/${ |
||||
$currentRoadmap?._id |
||||
}`,
|
||||
{ |
||||
visibility: newVisibility, |
||||
} |
||||
); |
||||
|
||||
if (error) { |
||||
console.error(error); |
||||
toast.error(error?.message || 'Something went wrong, please try again'); |
||||
setIsLoading(false); |
||||
return; |
||||
} |
||||
|
||||
setIsLoading(false); |
||||
toast.success('Visibility updated'); |
||||
setVisibility(newVisibility); |
||||
currentRoadmap.set({ |
||||
...$currentRoadmap!, |
||||
visibility: newVisibility, |
||||
}); |
||||
} |
||||
|
||||
function handleCopy() { |
||||
const isDev = import.meta.env.DEV; |
||||
const url = new URL( |
||||
isDev ? 'http://localhost:3000/r' : 'https://roadmap.sh/r' |
||||
); |
||||
url.searchParams.set('id', roadmapId); |
||||
copyText(url.toString()); |
||||
} |
||||
|
||||
return ( |
||||
<Modal onClose={onClose}> |
||||
<div className="p-4 pb-0"> |
||||
<h1 className="text-lg font-medium leading-5 text-gray-900"> |
||||
Updating {$currentRoadmap?.title} |
||||
</h1> |
||||
</div> |
||||
|
||||
<ul className="mt-4 border-t"> |
||||
{allowedVisibilityLabels.map((v) => { |
||||
if (v.id === 'team' && $isCurrentRoadmapPersonal) { |
||||
return null; |
||||
} else if (v.id === 'friends' && !$isCurrentRoadmapPersonal) { |
||||
return null; |
||||
} |
||||
|
||||
return ( |
||||
<li key={v.id}> |
||||
<button |
||||
disabled={v.id === visibility || isLoading} |
||||
key={v.id} |
||||
className={cn( |
||||
'relative flex w-full items-center border-b p-2.5 px-4 text-sm text-gray-700 hover:bg-gray-200 hover:text-gray-900 disabled:cursor-not-allowed', |
||||
v.id === visibility && |
||||
'bg-gray-900 text-white hover:bg-gray-900 hover:text-white' |
||||
)} |
||||
onClick={() => updateVisibility(v.id)} |
||||
> |
||||
{v.label} |
||||
|
||||
{v.id === visibility && ( |
||||
<span className="absolute bottom-0 right-0 top-0 flex w-8 items-center justify-center"> |
||||
<span className="h-2 w-2 rounded-full bg-green-500" /> |
||||
</span> |
||||
)} |
||||
</button> |
||||
</li> |
||||
); |
||||
})} |
||||
</ul> |
||||
|
||||
<div className="flex items-center justify-between p-4"> |
||||
<button |
||||
disabled={isLoading} |
||||
className="flex h-9 items-center rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-black outline-none hover:border-gray-300 hover:bg-gray-50 focus:border-gray-300 focus:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-70" |
||||
onClick={onClose} |
||||
> |
||||
{isLoading ? ( |
||||
<> |
||||
<Loader2 size={14} className="mr-2 animate-spin stroke-[2.5]" /> |
||||
Saving |
||||
</> |
||||
) : ( |
||||
'Cancel' |
||||
)} |
||||
</button> |
||||
<button |
||||
className="flex h-9 items-center justify-center rounded-md border border-transparent bg-gray-900 px-4 py-2 text-sm font-medium text-white outline-none hover:bg-gray-800 focus:bg-gray-800" |
||||
onClick={handleCopy} |
||||
> |
||||
{isCopied ? ( |
||||
<> |
||||
<Check size={14} className="mr-2 stroke-[2.5]" /> |
||||
Copied |
||||
</> |
||||
) : ( |
||||
<> |
||||
<Copy size={14} className="mr-2 stroke-[2.5]" /> |
||||
Copy Link |
||||
</> |
||||
)} |
||||
</button> |
||||
</div> |
||||
</Modal> |
||||
); |
||||
} |
@ -0,0 +1,118 @@ |
||||
import { ExternalLinkIcon, Map, Plus } from 'lucide-react'; |
||||
import RoadmapIcon from '../../icons/roadmap.svg'; |
||||
import type { GetRoadmapListResponse } from './RoadmapListPage'; |
||||
|
||||
type GroupByCreator = { |
||||
creator: GetRoadmapListResponse['sharedRoadmaps'][number]['creator']; |
||||
roadmaps: GetRoadmapListResponse['sharedRoadmaps']; |
||||
}; |
||||
|
||||
type SharedRoadmapListProps = { |
||||
roadmaps: GetRoadmapListResponse['sharedRoadmaps']; |
||||
}; |
||||
|
||||
export function SharedRoadmapList(props: SharedRoadmapListProps) { |
||||
const { roadmaps: sharedRoadmaps } = props; |
||||
|
||||
const allUniqueCreatorIds = new Set( |
||||
sharedRoadmaps.map((roadmap) => roadmap.creator.id) |
||||
); |
||||
|
||||
const groupByCreator: GroupByCreator[] = []; |
||||
for (const creatorId of allUniqueCreatorIds) { |
||||
const creator = sharedRoadmaps.find( |
||||
(roadmap) => roadmap.creator.id === creatorId |
||||
)?.creator; |
||||
if (!creator) { |
||||
continue; |
||||
} |
||||
|
||||
groupByCreator.push({ |
||||
creator, |
||||
roadmaps: sharedRoadmaps.filter( |
||||
(roadmap) => roadmap.creator.id === creatorId |
||||
), |
||||
}); |
||||
} |
||||
|
||||
if (sharedRoadmaps.length === 0) { |
||||
return ( |
||||
<div className="flex flex-col items-center p-4 py-20"> |
||||
<Map 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"> |
||||
Roadmaps from your friends will appear here |
||||
</p> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<div> |
||||
<div className="mb-3 flex items-center justify-between"> |
||||
<span className={'text-sm text-gray-400'}> |
||||
{sharedRoadmaps.length} shared roadmap(s) |
||||
</span> |
||||
</div> |
||||
<div> |
||||
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2"> |
||||
{groupByCreator.map((group) => { |
||||
const creator = group.creator; |
||||
return ( |
||||
<li |
||||
key={creator.id} |
||||
className="flex flex-col items-start overflow-hidden rounded-md border border-gray-300" |
||||
> |
||||
<div className="relative flex w-full items-center gap-3 p-3"> |
||||
<img |
||||
src={ |
||||
creator.avatar |
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${ |
||||
creator.avatar |
||||
}` |
||||
: '/images/default-avatar.png' |
||||
} |
||||
alt={creator.name || ''} |
||||
className="h-8 w-8 rounded-full" |
||||
/> |
||||
<div> |
||||
<h3 className="truncate font-medium">{creator.name}</h3> |
||||
<p className="truncate text-sm text-gray-500"> |
||||
{group?.roadmaps?.length || 0} shared roadmap(s) |
||||
</p> |
||||
</div> |
||||
</div> |
||||
|
||||
<ul className="w-full"> |
||||
{group?.roadmaps?.map((roadmap) => { |
||||
return ( |
||||
<li |
||||
key={roadmap._id} |
||||
className="relative flex w-full border-t" |
||||
> |
||||
<a |
||||
href={`/r?id=${roadmap._id}`} |
||||
className="group inline-grid w-full grid-cols-[auto,16px] items-center justify-between gap-2 px-3 py-2 text-sm text-gray-600 transition-colors hover:bg-gray-100 hover:text-black" |
||||
target={'_blank'} |
||||
> |
||||
<span className="w-full truncate"> |
||||
{roadmap.title} |
||||
</span> |
||||
|
||||
<ExternalLinkIcon |
||||
size={16} |
||||
className="opacity-20 transition-opacity group-hover:opacity-100" |
||||
/> |
||||
</a> |
||||
</li> |
||||
); |
||||
})} |
||||
</ul> |
||||
</li> |
||||
); |
||||
})} |
||||
</ul> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,28 @@ |
||||
export function SkeletonRoadmapHeader() { |
||||
return ( |
||||
<div className="border-b"> |
||||
<div className="container relative py-5 sm:py-12"> |
||||
<div className="mb-3 mt-0 sm:mb-4"> |
||||
<div className="h-8 w-1/2 animate-pulse rounded-md bg-gray-300 sm:mb-2 sm:h-10" /> |
||||
<div className="mt-0.5 h-5 w-1/3 animate-pulse rounded-md bg-gray-200 sm:h-7" /> |
||||
</div> |
||||
|
||||
<div className="flex justify-between gap-2 sm:gap-0"> |
||||
<div className="h-7 w-[35.04px] sm:w-32 animate-pulse rounded-md bg-gray-300 sm:h-8" /> |
||||
<div className="h-7 w-[32px] sm:w-[89.73px] animate-pulse rounded-md bg-gray-300 sm:h-8" /> |
||||
</div> |
||||
|
||||
<div className="mb-0 mt-4 rounded-md border-0 sm:-mb-[65px] sm:mt-7 sm:border"> |
||||
<div |
||||
data-progress-nums-container |
||||
className="striped-loader relative hidden h-8 items-center justify-between rounded-md bg-white sm:flex" |
||||
/> |
||||
<div |
||||
data-progress-nums-container |
||||
className="striped-loader relative -mb-2 flex h-[34px] items-center justify-between rounded-md border bg-white px-2 py-1.5 text-sm text-gray-700 sm:hidden" |
||||
/> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,46 @@ |
||||
import { type ReactNode, useRef } from 'react'; |
||||
import { useOutsideClick } from '../hooks/use-outside-click'; |
||||
import { useKeydown } from '../hooks/use-keydown'; |
||||
import { cn } from '../lib/classname'; |
||||
|
||||
type ModalProps = { |
||||
onClose: () => void; |
||||
children: ReactNode; |
||||
bodyClassName?: string; |
||||
wrapperClassName?: string; |
||||
}; |
||||
|
||||
export function Modal(props: ModalProps) { |
||||
const { onClose, children, bodyClassName, wrapperClassName } = props; |
||||
|
||||
const popupBodyEl = useRef<HTMLDivElement>(null); |
||||
|
||||
useKeydown('Escape', () => { |
||||
onClose(); |
||||
}); |
||||
|
||||
useOutsideClick(popupBodyEl, () => { |
||||
onClose(); |
||||
}); |
||||
|
||||
return ( |
||||
<div className="popup fixed left-0 right-0 top-0 z-[99] flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50"> |
||||
<div |
||||
className={cn( |
||||
'relative h-full w-full max-w-md p-4 md:h-auto', |
||||
wrapperClassName |
||||
)} |
||||
> |
||||
<div |
||||
ref={popupBodyEl} |
||||
className={cn( |
||||
'popup-body relative h-full rounded-lg bg-white shadow', |
||||
bodyClassName |
||||
)} |
||||
> |
||||
{children} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,52 @@ |
||||
import { useRef, useState } from 'react'; |
||||
import { ChevronDown } from 'lucide-react'; |
||||
import { isLoggedIn } from '../../lib/jwt'; |
||||
import { AccountDropdownList } from './AccountDropdownList'; |
||||
import { DropdownTeamList } from './DropdownTeamList'; |
||||
import { useOutsideClick } from '../../hooks/use-outside-click'; |
||||
|
||||
export function AccountDropdown() { |
||||
const dropdownRef = useRef(null); |
||||
|
||||
const [showDropdown, setShowDropdown] = useState(false); |
||||
const [isTeamsOpen, setIsTeamsOpen] = useState(false); |
||||
|
||||
useOutsideClick(dropdownRef, () => { |
||||
setShowDropdown(false); |
||||
setIsTeamsOpen(false); |
||||
}); |
||||
|
||||
if (!isLoggedIn()) { |
||||
return null; |
||||
} |
||||
|
||||
return ( |
||||
<div className="relative z-50 animate-fade-in"> |
||||
<button |
||||
className="flex h-8 w-40 items-center justify-center gap-1.5 rounded-full bg-gradient-to-r from-purple-500 to-purple-700 px-4 py-2 text-sm font-medium text-white hover:from-purple-500 hover:to-purple-600" |
||||
onClick={() => { |
||||
setIsTeamsOpen(false); |
||||
setShowDropdown(!showDropdown); |
||||
}} |
||||
> |
||||
<span className="inline-flex items-center"> |
||||
Account <span className="text-gray-300">/</span> Teams |
||||
</span> |
||||
<ChevronDown className="h-4 w-4 shrink-0 stroke-[2.5px]" /> |
||||
</button> |
||||
|
||||
{showDropdown && ( |
||||
<div |
||||
ref={dropdownRef} |
||||
className="absolute right-0 z-50 mt-2 min-h-[152px] w-48 rounded-md bg-slate-800 py-1 shadow-xl" |
||||
> |
||||
{isTeamsOpen ? ( |
||||
<DropdownTeamList setIsTeamsOpen={setIsTeamsOpen} /> |
||||
) : ( |
||||
<AccountDropdownList setIsTeamsOpen={setIsTeamsOpen} /> |
||||
)} |
||||
</div> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,49 @@ |
||||
import { ChevronRight } from 'lucide-react'; |
||||
import { logout } from './navigation'; |
||||
|
||||
type AccountDropdownListProps = { |
||||
setIsTeamsOpen: (isOpen: boolean) => void; |
||||
}; |
||||
|
||||
export function AccountDropdownList(props: AccountDropdownListProps) { |
||||
const { setIsTeamsOpen } = props; |
||||
|
||||
return ( |
||||
<ul> |
||||
<li className="px-1"> |
||||
<a |
||||
href="/account" |
||||
className="block rounded pl-4 pr-2 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700" |
||||
> |
||||
Profile |
||||
</a> |
||||
</li> |
||||
<li className="px-1"> |
||||
<a |
||||
href="/account/friends" |
||||
className="block rounded pl-4 pr-2 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700" |
||||
> |
||||
Friends |
||||
</a> |
||||
</li> |
||||
<li className="px-1"> |
||||
<button |
||||
className="group flex w-full items-center justify-between rounded pl-4 pr-2 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700" |
||||
onClick={() => setIsTeamsOpen(true)} |
||||
> |
||||
Teams |
||||
<ChevronRight className="h-4 w-4 shrink-0 stroke-[2.5px] text-slate-400 group-hover:text-white" /> |
||||
</button> |
||||
</li> |
||||
<li className="px-1"> |
||||
<button |
||||
className="block w-full rounded pl-4 pr-2 py-2 text-left text-sm font-medium text-slate-100 hover:bg-slate-700" |
||||
type="button" |
||||
onClick={logout} |
||||
> |
||||
Logout |
||||
</button> |
||||
</li> |
||||
</ul> |
||||
); |
||||
} |
@ -0,0 +1,110 @@ |
||||
import { ChevronLeft, Loader2, Plus, Users } from 'lucide-react'; |
||||
import { $teamList } from '../../stores/team'; |
||||
import { httpGet } from '../../lib/http'; |
||||
import type { TeamListResponse } from '../TeamDropdown/TeamDropdown'; |
||||
import { useToast } from '../../hooks/use-toast'; |
||||
import { useStore } from '@nanostores/react'; |
||||
import { useEffect, useState } from 'react'; |
||||
import { Spinner } from '../ReactIcons/Spinner'; |
||||
|
||||
type DropdownTeamListProps = { |
||||
setIsTeamsOpen: (isOpen: boolean) => void; |
||||
}; |
||||
|
||||
export function DropdownTeamList(props: DropdownTeamListProps) { |
||||
const { setIsTeamsOpen } = props; |
||||
|
||||
const toast = useToast(); |
||||
const teamList = useStore($teamList); |
||||
|
||||
const [isLoading, setIsLoading] = useState(true); |
||||
|
||||
async function getAllTeams() { |
||||
if (teamList.length > 0) { |
||||
return; |
||||
} |
||||
|
||||
setIsLoading(true); |
||||
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(() => { |
||||
getAllTeams().finally(() => setIsLoading(false)); |
||||
}, []); |
||||
|
||||
const loadingIndicator = isLoading && ( |
||||
<div className="mt-2 flex animate-pulse flex-col gap-1 px-1 text-center"> |
||||
<div className="h-[35px] rounded-md bg-gray-700"></div> |
||||
<div className="h-[35px] rounded-md bg-gray-700"></div> |
||||
<div className="h-[35px] rounded-md bg-gray-700"></div> |
||||
</div> |
||||
); |
||||
|
||||
return ( |
||||
<> |
||||
<div className="flex items-center justify-between px-2"> |
||||
<button |
||||
className="mt-1 flex h-5 w-5 items-center justify-center rounded text-slate-400 hover:bg-slate-50/10 hover:text-slate-50" |
||||
onClick={() => setIsTeamsOpen(false)} |
||||
> |
||||
<ChevronLeft className="h-4 w-4 stroke-[2.5px]" /> |
||||
</button> |
||||
<a |
||||
className="mt-1 flex h-5 w-5 items-center justify-center rounded text-slate-400 hover:bg-slate-50/10 hover:text-slate-50" |
||||
href="/team/new" |
||||
> |
||||
<Plus className="h-4 w-4 stroke-[2.5px]" /> |
||||
</a> |
||||
</div> |
||||
|
||||
{loadingIndicator} |
||||
{!isLoading && ( |
||||
<ul className="mt-2"> |
||||
{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}`; |
||||
} |
||||
|
||||
return ( |
||||
<li key={team._id} className="px-1"> |
||||
<a |
||||
href={pageLink} |
||||
className="block truncate rounded px-4 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700" |
||||
> |
||||
{team.name} |
||||
</a> |
||||
</li> |
||||
); |
||||
})} |
||||
|
||||
{teamList.length === 0 && !isLoading && ( |
||||
<li className="mt-2 px-1 text-center"> |
||||
<p className="block rounded px-4 py-2 text-sm font-medium text-slate-500"> |
||||
<Users className="mx-auto mb-2 h-7 w-7 text-slate-600" /> |
||||
No teams found.{' '} |
||||
<a |
||||
className="font-medium text-slate-400 underline underline-offset-2 hover:text-slate-300" |
||||
href="/team/new" |
||||
> |
||||
Create a team |
||||
</a> |
||||
. |
||||
</p> |
||||
</li> |
||||
)} |
||||
</ul> |
||||
)} |
||||
</> |
||||
); |
||||
} |
@ -0,0 +1,64 @@ |
||||
import { CheckCircle, Copy } from 'lucide-react'; |
||||
import { useCopyText } from '../../hooks/use-copy-text'; |
||||
import { cn } from '../../lib/classname'; |
||||
|
||||
type CopyRoadmapLinkProps = { |
||||
roadmapId: string; |
||||
onClose: () => void; |
||||
}; |
||||
|
||||
export function CopyRoadmapLink(props: CopyRoadmapLinkProps) { |
||||
const { roadmapId, onClose } = props; |
||||
|
||||
const shareLink = `${ |
||||
import.meta.env.PUBLIC_ROADMAP_WEB_URL |
||||
}/r?id=${roadmapId}`;
|
||||
const { copyText, isCopied } = useCopyText(); |
||||
|
||||
return ( |
||||
<div className="flex grow flex-col justify-center"> |
||||
<div className="mt-5 flex grow flex-col items-center justify-center gap-1.5"> |
||||
<CheckCircle className="h-14 w-14 text-green-500" /> |
||||
<h3 className="text-xl font-medium">Sharing Settings Updated</h3> |
||||
</div> |
||||
|
||||
<input |
||||
type="text" |
||||
className="mt-6 w-full rounded-md border bg-gray-50 p-2 px-2.5 text-gray-700 focus:outline-none" |
||||
value={shareLink} |
||||
readOnly |
||||
onClick={(e) => { |
||||
e.currentTarget.select(); |
||||
copyText(shareLink); |
||||
}} |
||||
/> |
||||
<p className="mt-1 text-sm text-gray-400"> |
||||
You can share the above link with anyone who has access |
||||
</p> |
||||
|
||||
<div className="mt-4 flex flex-col items-center justify-end gap-2"> |
||||
<button |
||||
className={cn( |
||||
'flex w-full items-center justify-center gap-1.5 rounded bg-black px-4 py-2.5 text-sm font-medium text-white hover:opacity-80', |
||||
isCopied && 'bg-green-300 text-green-800' |
||||
)} |
||||
disabled={isCopied} |
||||
onClick={() => { |
||||
copyText(shareLink); |
||||
}} |
||||
> |
||||
<Copy className="h-3.5 w-3.5 stroke-[2.5]" /> |
||||
{isCopied ? 'Copied' : 'Copy'} |
||||
</button> |
||||
<button |
||||
className={cn( |
||||
'flex w-full items-center justify-center gap-1.5 rounded border border-black px-4 py-2 text-sm font-medium hover:bg-gray-100' |
||||
)} |
||||
onClick={onClose} |
||||
> |
||||
Close |
||||
</button> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,160 @@ |
||||
import { useEffect, useState } from 'react'; |
||||
import { useToast } from '../../hooks/use-toast'; |
||||
import { UserItem } from './UserItem'; |
||||
import { Users2 } from 'lucide-react'; |
||||
import {httpGet} from "../../lib/http"; |
||||
|
||||
export type FriendshipStatus = |
||||
| 'none' |
||||
| 'sent' |
||||
| 'received' |
||||
| 'accepted' |
||||
| 'rejected' |
||||
| 'got_rejected'; |
||||
|
||||
type FriendResourceProgress = { |
||||
updatedAt: string; |
||||
title: string; |
||||
resourceId: string; |
||||
resourceType: string; |
||||
learning: number; |
||||
skipped: number; |
||||
done: number; |
||||
total: number; |
||||
}; |
||||
|
||||
export type ListFriendsResponse = { |
||||
userId: string; |
||||
name: string; |
||||
email: string; |
||||
avatar: string; |
||||
status: FriendshipStatus; |
||||
roadmaps: FriendResourceProgress[]; |
||||
bestPractices: FriendResourceProgress[]; |
||||
}[]; |
||||
|
||||
type ShareFriendListProps = { |
||||
setFriends: (friends: ListFriendsResponse) => void; |
||||
friends: ListFriendsResponse; |
||||
sharedFriendIds: string[]; |
||||
setSharedFriendIds: (friendIds: string[]) => void; |
||||
}; |
||||
|
||||
export function ShareFriendList(props: ShareFriendListProps) { |
||||
const { setFriends, friends, sharedFriendIds, setSharedFriendIds } = props; |
||||
const toast = useToast(); |
||||
|
||||
const [isLoading, setIsLoading] = useState(true); |
||||
|
||||
async function loadFriends() { |
||||
if (friends.length > 0) { |
||||
return; |
||||
} |
||||
|
||||
setIsLoading(true); |
||||
const { response, error } = await httpGet<ListFriendsResponse>( |
||||
`${import.meta.env.PUBLIC_API_URL}/v1-list-friends` |
||||
); |
||||
|
||||
if (error || !response) { |
||||
toast.error(error?.message || 'Something went wrong'); |
||||
return; |
||||
} |
||||
|
||||
setFriends(response.filter((friend) => friend.status === 'accepted')); |
||||
} |
||||
|
||||
useEffect(() => { |
||||
loadFriends().finally(() => { |
||||
setIsLoading(false); |
||||
}); |
||||
}, []); |
||||
|
||||
const loadingFriends = isLoading && ( |
||||
<ul className="mt-2 grid grid-cols-3 gap-1.5"> |
||||
{[...Array(3)].map((_, idx) => ( |
||||
<li |
||||
key={idx} |
||||
className="flex animate-pulse items-center gap-2.5 rounded-md border p-2" |
||||
> |
||||
<div className="relative top-[1px] h-10 w-10 shrink-0 rounded-full bg-gray-200" /> |
||||
<div className="inline-grid w-full"> |
||||
<div className="h-5 w-2/4 rounded bg-gray-200" /> |
||||
<div className="mt-1 h-5 w-3/4 rounded bg-gray-200" /> |
||||
</div> |
||||
</li> |
||||
))} |
||||
</ul> |
||||
); |
||||
|
||||
return ( |
||||
<> |
||||
{(friends.length > 0 || isLoading) && ( |
||||
<div className="flex items-center justify-between gap-2"> |
||||
<p className="text-sm">Select Friends to share the roadmap with</p> |
||||
|
||||
<label className="flex items-center gap-2 text-sm"> |
||||
<input |
||||
type="checkbox" |
||||
checked={sharedFriendIds.length === friends.length} |
||||
onChange={(e) => { |
||||
if (e.target.checked) { |
||||
setSharedFriendIds(friends.map((f) => f.userId)); |
||||
} else { |
||||
setSharedFriendIds([]); |
||||
} |
||||
}} |
||||
/> |
||||
<span className="text-sm">Select all</span> |
||||
</label> |
||||
</div> |
||||
)} |
||||
|
||||
{loadingFriends} |
||||
{friends.length > 0 && !isLoading && ( |
||||
<ul className="mt-2 grid grid-cols-3 gap-1.5"> |
||||
{friends.map((friend) => { |
||||
const isSelected = sharedFriendIds?.includes(friend.userId); |
||||
return ( |
||||
<li key={friend.userId}> |
||||
<UserItem |
||||
user={{ |
||||
name: friend.name, |
||||
avatar: friend.avatar, |
||||
email: friend.email, |
||||
}} |
||||
isSelected={isSelected} |
||||
onClick={() => { |
||||
if (isSelected) { |
||||
setSharedFriendIds( |
||||
sharedFriendIds.filter((id) => id !== friend.userId) |
||||
); |
||||
} else { |
||||
setSharedFriendIds([...sharedFriendIds, friend.userId]); |
||||
} |
||||
}} |
||||
/> |
||||
</li> |
||||
); |
||||
})} |
||||
</ul> |
||||
)} |
||||
|
||||
{friends.length === 0 && !isLoading && ( |
||||
<div className="flex h-full flex-grow flex-col items-center justify-center rounded-md border bg-gray-50 text-center"> |
||||
<Users2 className="mb-3 h-10 w-10 text-gray-300" /> |
||||
<p className="font-semibold text-gray-500"> |
||||
You do not have any friends yet. <br />{' '} |
||||
<a |
||||
target="_blank" |
||||
className="underline underline-offset-2" |
||||
href={`${import.meta.env.PUBLIC_ROADMAP_WEB_URL}/account/friends`} |
||||
> |
||||
Invite your friends to share roadmaps with. |
||||
</a> |
||||
</p> |
||||
</div> |
||||
)} |
||||
</> |
||||
); |
||||
} |
@ -0,0 +1,311 @@ |
||||
import { type ReactNode, useCallback, useState } from 'react'; |
||||
import { Globe2, Loader2, Lock } from 'lucide-react'; |
||||
import { type ListFriendsResponse, ShareFriendList } from './ShareFriendList'; |
||||
import { TransferToTeamList } from './TransferToTeamList'; |
||||
import { ShareOptionTabs } from './ShareOptionsTab'; |
||||
import { |
||||
ShareTeamMemberList, |
||||
type TeamMemberList, |
||||
} from './ShareTeamMemberList'; |
||||
import { CopyRoadmapLink } from './CopyRoadmapLink'; |
||||
import { useToast } from '../../hooks/use-toast'; |
||||
import type { AllowedRoadmapVisibility } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal'; |
||||
import { httpPatch } from '../../lib/http'; |
||||
import { Modal } from '../Modal'; |
||||
import { cn } from '../../lib/classname'; |
||||
import type { UserTeamItem } from '../TeamDropdown/TeamDropdown'; |
||||
|
||||
export type OnShareSettingsUpdate = (options: { |
||||
visibility: AllowedRoadmapVisibility; |
||||
sharedTeamMemberIds: string[]; |
||||
sharedFriendIds: string[]; |
||||
}) => void; |
||||
|
||||
type ShareOptionsModalProps = { |
||||
onClose: () => void; |
||||
visibility: AllowedRoadmapVisibility; |
||||
sharedFriendIds?: string[]; |
||||
sharedTeamMemberIds?: string[]; |
||||
teamId?: string; |
||||
roadmapId?: string; |
||||
|
||||
onShareSettingsUpdate: OnShareSettingsUpdate; |
||||
}; |
||||
|
||||
export function ShareOptionsModal(props: ShareOptionsModalProps) { |
||||
const { |
||||
roadmapId, |
||||
onClose, |
||||
visibility: defaultVisibility, |
||||
sharedTeamMemberIds: defaultSharedMemberIds = [], |
||||
sharedFriendIds: defaultSharedFriendIds = [], |
||||
teamId, |
||||
onShareSettingsUpdate, |
||||
} = props; |
||||
|
||||
const toast = useToast(); |
||||
|
||||
const [isLoading, setIsLoading] = useState(false); |
||||
const [isSettingsUpdated, setIsSettingsUpdated] = useState(false); |
||||
const [friends, setFriends] = useState<ListFriendsResponse>([]); |
||||
const [teams, setTeams] = useState<UserTeamItem[]>([]); |
||||
const [members, setMembers] = useState<TeamMemberList[]>([]); |
||||
|
||||
const [visibility, setVisibility] = useState(defaultVisibility); |
||||
const [sharedTeamMemberIds, setSharedTeamMemberIds] = useState<string[]>( |
||||
defaultSharedMemberIds |
||||
); |
||||
const [sharedFriendIds, setSharedFriendIds] = useState<string[]>( |
||||
defaultSharedFriendIds |
||||
); |
||||
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null); |
||||
|
||||
const canTransferRoadmap = visibility === 'team' && !teamId; |
||||
let isUpdateDisabled = false; |
||||
// Disable update button if there are no friends to share with
|
||||
if (visibility === 'friends' && sharedFriendIds.length === 0) { |
||||
isUpdateDisabled = true; |
||||
// Disable update button if there are no team to transfer
|
||||
} else if (canTransferRoadmap && !selectedTeamId) { |
||||
isUpdateDisabled = true; |
||||
// Disable update button if there are no members to share with
|
||||
} else if ( |
||||
visibility === 'team' && |
||||
teamId && |
||||
sharedTeamMemberIds.length === 0 |
||||
) { |
||||
isUpdateDisabled = true; |
||||
} |
||||
|
||||
const handleShareChange: OnShareSettingsUpdate = async ({ |
||||
sharedFriendIds, |
||||
visibility, |
||||
sharedTeamMemberIds, |
||||
}) => { |
||||
setIsLoading(true); |
||||
|
||||
if (visibility === 'friends' && sharedFriendIds.length === 0) { |
||||
toast.error('Please select at least one friend'); |
||||
return; |
||||
} else if ( |
||||
visibility === 'team' && |
||||
teamId && |
||||
sharedTeamMemberIds.length === 0 |
||||
) { |
||||
toast.error('Please select at least one member'); |
||||
return; |
||||
} |
||||
|
||||
const { response, error } = await httpPatch( |
||||
`${ |
||||
import.meta.env.PUBLIC_API_URL |
||||
}/v1-update-roadmap-visibility/${roadmapId}`,
|
||||
{ |
||||
visibility, |
||||
sharedFriendIds, |
||||
sharedTeamMemberIds, |
||||
} |
||||
); |
||||
|
||||
if (error) { |
||||
toast.error(error?.message || 'Something went wrong, please try again'); |
||||
return; |
||||
} |
||||
|
||||
setIsLoading(false); |
||||
setIsSettingsUpdated(true); |
||||
onShareSettingsUpdate({ sharedFriendIds, visibility, sharedTeamMemberIds }); |
||||
}; |
||||
|
||||
const handleTransferToTeam = useCallback( |
||||
async (teamId: string) => { |
||||
if (!roadmapId) { |
||||
return; |
||||
} |
||||
|
||||
setIsLoading(true); |
||||
const { response, error } = await httpPatch( |
||||
`${import.meta.env.PUBLIC_API_URL}/v1-transfer-roadmap/${roadmapId}`, |
||||
{ |
||||
teamId, |
||||
} |
||||
); |
||||
|
||||
if (error) { |
||||
setIsLoading(false); |
||||
toast.error(error?.message || 'Something went wrong, please try again'); |
||||
return; |
||||
} |
||||
|
||||
window.location.reload(); |
||||
}, |
||||
[roadmapId] |
||||
); |
||||
|
||||
if (isSettingsUpdated) { |
||||
return ( |
||||
<Modal |
||||
onClose={onClose} |
||||
wrapperClassName="max-w-lg" |
||||
bodyClassName="p-4 flex flex-col" |
||||
> |
||||
<CopyRoadmapLink roadmapId={roadmapId!} onClose={onClose} /> |
||||
</Modal> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<Modal |
||||
onClose={() => { |
||||
if (isLoading) { |
||||
return; |
||||
} |
||||
onClose(); |
||||
}} |
||||
wrapperClassName="max-w-3xl" |
||||
bodyClassName="p-4 flex flex-col min-h-[400px]" |
||||
> |
||||
<div className="mb-4"> |
||||
<h3 className="mb-1 text-xl font-semibold">Update Sharing Settings</h3> |
||||
<p className="text-sm text-gray-500"> |
||||
Pick and modify who can access this roadmap. |
||||
</p> |
||||
</div> |
||||
|
||||
<ShareOptionTabs |
||||
visibility={visibility} |
||||
setVisibility={setVisibility} |
||||
teamId={teamId} |
||||
onChange={(visibility) => { |
||||
setSelectedTeamId(null); |
||||
|
||||
if (['me', 'public'].includes(visibility)) { |
||||
setSharedTeamMemberIds([]); |
||||
setSharedFriendIds([]); |
||||
} else if (visibility === 'friends') { |
||||
setSharedFriendIds( |
||||
defaultSharedFriendIds.length > 0 ? defaultSharedFriendIds : [] |
||||
); |
||||
} else if (visibility === 'team' && teamId) { |
||||
setSharedTeamMemberIds( |
||||
defaultSharedMemberIds?.length > 0 ? defaultSharedMemberIds : [] |
||||
); |
||||
setSharedFriendIds([]); |
||||
} else { |
||||
setSharedFriendIds([]); |
||||
setSharedTeamMemberIds([]); |
||||
} |
||||
}} |
||||
/> |
||||
|
||||
<div className="mt-4 flex grow flex-col"> |
||||
{visibility === 'public' && ( |
||||
<div className="flex h-full flex-grow flex-col items-center justify-center rounded-md border bg-gray-50 text-center"> |
||||
<Globe2 className="mb-3 h-10 w-10 text-gray-300" /> |
||||
<p className="font-medium text-gray-500"> |
||||
Anyone with the link can access. |
||||
</p> |
||||
</div> |
||||
)} |
||||
{visibility === 'me' && ( |
||||
<div className="flex h-full flex-grow flex-col items-center justify-center rounded-md border bg-gray-50 text-center"> |
||||
<Lock className="mb-3 h-10 w-10 text-gray-300" /> |
||||
<p className="font-medium text-gray-500"> |
||||
Only you will be able to access. |
||||
</p> |
||||
</div> |
||||
)} |
||||
|
||||
{/* For Personal Roadmap */} |
||||
{visibility === 'friends' && ( |
||||
<ShareFriendList |
||||
friends={friends} |
||||
setFriends={setFriends} |
||||
sharedFriendIds={sharedFriendIds} |
||||
setSharedFriendIds={setSharedFriendIds} |
||||
/> |
||||
)} |
||||
{canTransferRoadmap && ( |
||||
<TransferToTeamList |
||||
teams={teams} |
||||
setTeams={setTeams} |
||||
selectedTeamId={selectedTeamId} |
||||
setSelectedTeamId={setSelectedTeamId} |
||||
/> |
||||
)} |
||||
|
||||
{/* For Team Roadmap */} |
||||
{visibility === 'team' && teamId && ( |
||||
<ShareTeamMemberList |
||||
teamId={teamId} |
||||
sharedTeamMemberIds={sharedTeamMemberIds} |
||||
setSharedTeamMemberIds={setSharedTeamMemberIds} |
||||
members={members} |
||||
setMembers={setMembers} |
||||
/> |
||||
)} |
||||
</div> |
||||
|
||||
<div className="mt-2 flex items-center justify-between gap-1.5"> |
||||
<button |
||||
className="flex items-center justify-center gap-1.5 rounded-md border px-3.5 py-1.5 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-75" |
||||
disabled={isLoading} |
||||
onClick={onClose} |
||||
> |
||||
Close |
||||
</button> |
||||
|
||||
{canTransferRoadmap ? ( |
||||
<UpdateAction |
||||
disabled={isUpdateDisabled || isLoading} |
||||
onClick={() => { |
||||
handleTransferToTeam(selectedTeamId!).then(() => null); |
||||
}} |
||||
> |
||||
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />} |
||||
Transfer |
||||
</UpdateAction> |
||||
) : ( |
||||
<UpdateAction |
||||
disabled={isUpdateDisabled || isLoading} |
||||
onClick={() => { |
||||
handleShareChange({ |
||||
visibility, |
||||
sharedTeamMemberIds: |
||||
visibility === 'team' ? sharedTeamMemberIds : [], |
||||
sharedFriendIds: |
||||
visibility === 'friends' ? sharedFriendIds : [], |
||||
}); |
||||
}} |
||||
> |
||||
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />} |
||||
Update Sharing Settings |
||||
</UpdateAction> |
||||
)} |
||||
</div> |
||||
</Modal> |
||||
); |
||||
} |
||||
|
||||
function UpdateAction(props: { |
||||
onClick: () => void; |
||||
disabled?: boolean; |
||||
children: ReactNode; |
||||
className?: string; |
||||
}) { |
||||
const { onClick, disabled, children, className } = props; |
||||
|
||||
return ( |
||||
<button |
||||
className={cn( |
||||
'flex min-w-[120px] items-center justify-center gap-1.5 rounded-md border border-gray-900 bg-gray-900 px-4 py-2 text-white hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-75', |
||||
disabled && 'border-gray-700 bg-gray-700 text-white hover:bg-gray-700', |
||||
className |
||||
)} |
||||
disabled={disabled} |
||||
onClick={onClick} |
||||
> |
||||
{children} |
||||
</button> |
||||
); |
||||
} |
@ -0,0 +1,129 @@ |
||||
import { |
||||
ArrowLeftRight, |
||||
Check, |
||||
Globe2, |
||||
Lock, |
||||
Users, |
||||
Users2, |
||||
} from 'lucide-react'; |
||||
import type { AllowedRoadmapVisibility } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal'; |
||||
import { cn } from '../../lib/classname'; |
||||
|
||||
export const allowedVisibilityLabels: { |
||||
id: AllowedRoadmapVisibility; |
||||
label: string; |
||||
long: string; |
||||
icon: typeof Lock; |
||||
}[] = [ |
||||
{ |
||||
id: 'me', |
||||
label: 'Only me', |
||||
long: 'Only visible to me', |
||||
icon: Lock, |
||||
}, |
||||
{ |
||||
id: 'public', |
||||
label: 'Public', |
||||
long: 'Anyone can view', |
||||
icon: Globe2, |
||||
}, |
||||
{ |
||||
id: 'friends', |
||||
label: 'Only friends', |
||||
long: 'Only friends can view', |
||||
icon: Users, |
||||
}, |
||||
{ |
||||
id: 'team', |
||||
label: 'Only Members', |
||||
long: 'Visible to team members', |
||||
icon: Users2, |
||||
}, |
||||
]; |
||||
|
||||
type ShareOptionTabsProps = { |
||||
visibility: AllowedRoadmapVisibility; |
||||
setVisibility: (visibility: AllowedRoadmapVisibility) => void; |
||||
teamId?: string; |
||||
|
||||
onChange: (visibility: AllowedRoadmapVisibility) => void; |
||||
}; |
||||
|
||||
export function ShareOptionTabs(props: ShareOptionTabsProps) { |
||||
const { visibility, setVisibility, teamId, onChange } = props; |
||||
|
||||
const handleClick = (visibility: AllowedRoadmapVisibility) => { |
||||
setVisibility(visibility); |
||||
onChange(visibility); |
||||
}; |
||||
|
||||
return ( |
||||
<div className="flex justify-between"> |
||||
<ul className="flex w-full items-center gap-1.5"> |
||||
{allowedVisibilityLabels.map((v) => { |
||||
if (v.id === 'friends' && teamId) { |
||||
return null; |
||||
} else if (v.id === 'team' && !teamId) { |
||||
return null; |
||||
} |
||||
|
||||
const isActive = v.id === visibility; |
||||
return ( |
||||
<li key={v.id}> |
||||
<OptionTab |
||||
label={v.label} |
||||
isActive={isActive} |
||||
icon={v.icon} |
||||
onClick={() => { |
||||
handleClick(v.id); |
||||
}} |
||||
/> |
||||
</li> |
||||
); |
||||
})} |
||||
</ul> |
||||
{!teamId && ( |
||||
<div className="grow"> |
||||
<OptionTab |
||||
label="Transfer to team" |
||||
icon={ArrowLeftRight} |
||||
isActive={visibility === 'team'} |
||||
onClick={() => { |
||||
handleClick('team'); |
||||
}} |
||||
className='border-red-300 text-red-600 hover:border-red-200 hover:bg-red-50 data-[active="true"]:border-red-600 data-[active="true"]:bg-red-600 data-[active="true"]:text-white' |
||||
/> |
||||
</div> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
type OptionTabProps = { |
||||
label: string; |
||||
isActive: boolean; |
||||
onClick: () => void; |
||||
icon: typeof Lock; |
||||
className?: string; |
||||
}; |
||||
|
||||
function OptionTab(props: OptionTabProps) { |
||||
const { label, isActive, onClick, icon: Icon, className } = props; |
||||
|
||||
return ( |
||||
<button |
||||
className={cn( |
||||
'flex items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm text-black hover:border-gray-300 hover:bg-gray-100', |
||||
'data-[active="true"]:border-gray-500 data-[active="true"]:bg-gray-200 data-[active="true"]:text-black', |
||||
className |
||||
)} |
||||
data-active={isActive} |
||||
disabled={isActive} |
||||
onClick={onClick} |
||||
> |
||||
{!isActive && <Icon className="h-4 w-4" />} |
||||
{isActive && <Check className="h-4 w-4" />} |
||||
<span className="whitespace-nowrap">{label}</span> |
||||
</button> |
||||
); |
||||
} |
@ -0,0 +1,164 @@ |
||||
import { useEffect, useState } from 'react'; |
||||
import { useToast } from '../../hooks/use-toast'; |
||||
import { UserItem } from './UserItem'; |
||||
import { Users } from 'lucide-react'; |
||||
import { httpGet } from '../../lib/http'; |
||||
|
||||
const allowedRoles = ['admin', 'manager', 'member'] as const; |
||||
const allowedStatus = ['invited', 'joined', 'rejected'] as const; |
||||
|
||||
export type AllowedMemberRoles = (typeof allowedRoles)[number]; |
||||
export type AllowedMemberStatus = (typeof allowedStatus)[number]; |
||||
|
||||
export interface TeamMemberDocument { |
||||
_id?: string; |
||||
userId?: string; |
||||
invitedEmail?: string; |
||||
teamId: string; |
||||
role: AllowedMemberRoles; |
||||
status: AllowedMemberStatus; |
||||
progressReminderCount: number; |
||||
lastProgressReminderAt?: Date; |
||||
lastResendInviteAt?: Date; |
||||
resendInviteCount?: number; |
||||
createdAt: Date; |
||||
updatedAt: Date; |
||||
} |
||||
|
||||
export interface TeamMemberList extends TeamMemberDocument { |
||||
name: string; |
||||
avatar: string; |
||||
hasProgress: boolean; |
||||
} |
||||
|
||||
type ShareTeamMemberListProps = { |
||||
teamId: string; |
||||
setMembers: (members: TeamMemberList[]) => void; |
||||
members: TeamMemberList[]; |
||||
sharedTeamMemberIds: string[]; |
||||
setSharedTeamMemberIds: (sharedTeamMemberIds: string[]) => void; |
||||
}; |
||||
|
||||
export function ShareTeamMemberList(props: ShareTeamMemberListProps) { |
||||
const { |
||||
setMembers, |
||||
members, |
||||
sharedTeamMemberIds, |
||||
setSharedTeamMemberIds, |
||||
teamId, |
||||
} = props; |
||||
|
||||
const toast = useToast(); |
||||
|
||||
const [isLoading, setIsLoading] = useState(true); |
||||
|
||||
async function loadTeamMembers() { |
||||
if (members?.length > 0) { |
||||
return; |
||||
} |
||||
|
||||
setIsLoading(true); |
||||
const { response, error } = await httpGet<TeamMemberList[]>( |
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-member-list/${teamId}` |
||||
); |
||||
|
||||
if (error || !response) { |
||||
toast.error(error?.message || 'Something went wrong'); |
||||
return; |
||||
} |
||||
|
||||
setMembers(response); |
||||
} |
||||
|
||||
useEffect(() => { |
||||
loadTeamMembers().finally(() => { |
||||
setIsLoading(false); |
||||
}); |
||||
}, []); |
||||
|
||||
const loadingMembers = isLoading && ( |
||||
<ul className="mt-2 grid grid-cols-3 gap-2.5"> |
||||
{[...Array(3)].map((_, idx) => ( |
||||
<li |
||||
key={idx} |
||||
className="flex min-h-[62px] animate-pulse items-center gap-2 rounded-md border p-2" |
||||
> |
||||
<div className="h-8 w-8 shrink-0 rounded-full bg-gray-200" /> |
||||
<div className="inline-grid w-full"> |
||||
<div className="h-5 w-2/4 rounded bg-gray-200" /> |
||||
<div className="mt-1 h-5 w-3/4 rounded bg-gray-200" /> |
||||
</div> |
||||
</li> |
||||
))} |
||||
</ul> |
||||
); |
||||
|
||||
return ( |
||||
<> |
||||
{(members.length > 0 || isLoading) && ( |
||||
<div className="flex items-center justify-between gap-2"> |
||||
<p className="text-sm">Select Members</p> |
||||
|
||||
<label className="flex items-center gap-2"> |
||||
<input |
||||
type="checkbox" |
||||
checked={sharedTeamMemberIds.length === members.length} |
||||
onChange={(e) => { |
||||
if (e.target.checked) { |
||||
setSharedTeamMemberIds(members.map((member) => member._id!)); |
||||
} else { |
||||
setSharedTeamMemberIds([]); |
||||
} |
||||
}} |
||||
/> |
||||
<span className="text-sm">Select all</span> |
||||
</label> |
||||
</div> |
||||
)} |
||||
|
||||
{loadingMembers} |
||||
{members?.length > 0 && !isLoading && ( |
||||
<ul className="mt-2 grid grid-cols-3 gap-2.5"> |
||||
{members?.map((member) => { |
||||
const isSelected = sharedTeamMemberIds?.includes( |
||||
member._id?.toString()! |
||||
); |
||||
return ( |
||||
<li key={member.userId}> |
||||
<UserItem |
||||
user={{ |
||||
name: member.name, |
||||
avatar: member.avatar, |
||||
email: member.invitedEmail!, |
||||
}} |
||||
isSelected={isSelected} |
||||
onClick={() => { |
||||
if (isSelected) { |
||||
setSharedTeamMemberIds( |
||||
sharedTeamMemberIds.filter( |
||||
(id) => id !== member._id?.toString()! |
||||
) |
||||
); |
||||
} else { |
||||
setSharedTeamMemberIds([ |
||||
...sharedTeamMemberIds, |
||||
member._id?.toString()!, |
||||
]); |
||||
} |
||||
}} |
||||
/> |
||||
</li> |
||||
); |
||||
})} |
||||
</ul> |
||||
)} |
||||
|
||||
{members.length === 0 && !isLoading && ( |
||||
<div className="flex grow flex-col items-center justify-center gap-2"> |
||||
<Users className="h-12 w-12 text-gray-500" /> |
||||
<p className="text-gray-500">No members have been added yet.</p> |
||||
</div> |
||||
)} |
||||
</> |
||||
); |
||||
} |
@ -0,0 +1,114 @@ |
||||
import { useEffect, useState } from 'react'; |
||||
import { useToast } from '../../hooks/use-toast'; |
||||
import { Users2 } from 'lucide-react'; |
||||
import { httpGet } from '../../lib/http'; |
||||
import { cn } from '../../lib/classname'; |
||||
import type { UserTeamItem } from '../TeamDropdown/TeamDropdown'; |
||||
|
||||
type TransferToTeamListProps = { |
||||
teams: UserTeamItem[]; |
||||
setTeams: (teams: UserTeamItem[]) => void; |
||||
|
||||
selectedTeamId: string | null; |
||||
setSelectedTeamId: (teamId: string | null) => void; |
||||
}; |
||||
|
||||
export function TransferToTeamList(props: TransferToTeamListProps) { |
||||
const { teams, setTeams, selectedTeamId, setSelectedTeamId } = props; |
||||
|
||||
const toast = useToast(); |
||||
|
||||
const [isLoading, setIsLoading] = useState(true); |
||||
|
||||
async function getAllTeams() { |
||||
if (teams.length > 0) { |
||||
return; |
||||
} |
||||
|
||||
const { response, error } = await httpGet<UserTeamItem[]>( |
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-teams` |
||||
); |
||||
if (error || !response) { |
||||
toast.error(error?.message || 'Something went wrong'); |
||||
return; |
||||
} |
||||
|
||||
setTeams( |
||||
response.filter((team) => ['admin', 'manager'].includes(team.role)) |
||||
); |
||||
} |
||||
|
||||
useEffect(() => { |
||||
getAllTeams().finally(() => setIsLoading(false)); |
||||
}, []); |
||||
|
||||
const loadingTeams = isLoading && ( |
||||
<ul className="mt-2 grid grid-cols-3 gap-1.5"> |
||||
{[...Array(3)].map((_, index) => ( |
||||
<li key={index}> |
||||
<div className="relative flex w-full items-center gap-2 rounded-md border p-2"> |
||||
<div className="h-6 w-6 shrink-0 animate-pulse rounded-full bg-gray-200" /> |
||||
<div className="inline-grid w-full"> |
||||
<div className="h-4 animate-pulse rounded bg-gray-200" /> |
||||
</div> |
||||
</div> |
||||
</li> |
||||
))} |
||||
</ul> |
||||
); |
||||
|
||||
return ( |
||||
<> |
||||
{(teams.length > 0 || isLoading) && ( |
||||
<p className="text-sm">Select a team to transfer this roadmap to</p> |
||||
)} |
||||
|
||||
{loadingTeams} |
||||
{teams.length > 0 && !isLoading && ( |
||||
<ul className="mt-2 grid grid-cols-3 gap-1.5"> |
||||
{teams.map((team) => { |
||||
const isSelected = team._id === selectedTeamId; |
||||
|
||||
return ( |
||||
<li key={team._id}> |
||||
<button |
||||
className={cn( |
||||
'relative flex w-full items-center gap-2.5 rounded-lg border p-2.5', |
||||
isSelected && 'border-gray-500 bg-gray-100 text-black' |
||||
)} |
||||
onClick={() => { |
||||
setSelectedTeamId(team._id); |
||||
}} |
||||
> |
||||
<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 shrink-0 rounded-full" |
||||
/> |
||||
<div className="inline-grid w-full"> |
||||
<h3 className="truncate text-left font-normal"> |
||||
{team.name} |
||||
</h3> |
||||
</div> |
||||
</button> |
||||
</li> |
||||
); |
||||
})} |
||||
</ul> |
||||
)} |
||||
|
||||
{teams.length === 0 && !isLoading && ( |
||||
<div className="flex grow flex-col items-center justify-center gap-2"> |
||||
<Users2 className="h-12 w-12 text-gray-500" /> |
||||
<p className="text-gray-500">You are not a member of any team.</p> |
||||
</div> |
||||
)} |
||||
</> |
||||
); |
||||
} |
@ -0,0 +1,46 @@ |
||||
import { cn } from '../../lib/classname'; |
||||
|
||||
type UserItemProps = { |
||||
user: { |
||||
name: string; |
||||
email: string; |
||||
avatar: string; |
||||
}; |
||||
onClick: () => void; |
||||
isSelected: boolean; |
||||
}; |
||||
|
||||
export function UserItem(props: UserItemProps) { |
||||
const { user, onClick, isSelected } = props; |
||||
|
||||
return ( |
||||
<button |
||||
className={cn( |
||||
'relative flex w-full items-center gap-2.5 rounded-lg border p-2.5', |
||||
isSelected && 'border-gray-500 bg-gray-300 text-black' |
||||
)} |
||||
onClick={onClick} |
||||
> |
||||
<img |
||||
src={ |
||||
user.avatar |
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${user.avatar}` |
||||
: '/images/default-avatar.png' |
||||
} |
||||
alt={user.name || ''} |
||||
className="relative top-[1px] h-10 w-10 shrink-0 rounded-full" |
||||
/> |
||||
<div className="inline-grid w-full"> |
||||
<h3 className="truncate text-left font-semibold">{user.name}</h3> |
||||
<p |
||||
className={cn( |
||||
'truncate text-left text-sm text-gray-500', |
||||
isSelected && 'text-gray-700' |
||||
)} |
||||
> |
||||
{user.email} |
||||
</p> |
||||
</div> |
||||
</button> |
||||
); |
||||
} |
@ -0,0 +1,3 @@ |
||||
export function CustomTeamRoadmap() { |
||||
return null; |
||||
} |
@ -0,0 +1,3 @@ |
||||
export function DefaultTeamRoadmap() { |
||||
return null; |
||||
} |
@ -1,346 +0,0 @@ |
||||
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 ExternalLinkIcon from '../icons/external-link.svg'; |
||||
import RoadmapIcon from '../icons/roadmap.svg'; |
||||
import PlusIcon from '../icons/plus.svg'; |
||||
import type { PageType } from './CommandMenu/CommandMenu'; |
||||
import { UpdateTeamResourceModal } from './CreateTeam/UpdateTeamResourceModal'; |
||||
import { useStore } from '@nanostores/react'; |
||||
import { $canManageCurrentTeam } from '../stores/team'; |
||||
import { useToast } from '../hooks/use-toast'; |
||||
import { SelectRoadmapModal } from './CreateTeam/SelectRoadmapModal'; |
||||
|
||||
export function TeamRoadmaps() { |
||||
const { t: teamId } = getUrlParams(); |
||||
|
||||
const canManageCurrentTeam = useStore($canManageCurrentTeam); |
||||
|
||||
const toast = useToast(); |
||||
|
||||
const [isLoading, setIsLoading] = useState(true); |
||||
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; |
||||
} |
||||
|
||||
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'); |
||||
setResourceConfigs(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; |
||||
} |
||||
|
||||
setResourceConfigs(response); |
||||
toast.success('Roadmap added'); |
||||
} |
||||
|
||||
async function onRemove(resourceId: string) { |
||||
pageProgressMessage.set('Removing roadmap'); |
||||
|
||||
deleteResource(resourceId).finally(() => { |
||||
pageProgressMessage.set(''); |
||||
}); |
||||
} |
||||
|
||||
if (!team) { |
||||
return null; |
||||
} |
||||
|
||||
const addRoadmapModal = isAddingRoadmap && ( |
||||
<SelectRoadmapModal |
||||
onClose={() => setIsAddingRoadmap(false)} |
||||
teamResourceConfig={resourceConfigs} |
||||
allRoadmaps={allRoadmaps} |
||||
teamId={teamId} |
||||
onRoadmapAdd={(roadmapId) => { |
||||
onAdd(roadmapId).finally(() => { |
||||
pageProgressMessage.set(''); |
||||
}); |
||||
}} |
||||
onRoadmapRemove={(roadmapId) => { |
||||
if (confirm('Are you sure you want to remove this roadmap?')) { |
||||
onRemove(roadmapId).finally(() => {}); |
||||
} |
||||
}} |
||||
/> |
||||
); |
||||
|
||||
if (resourceConfigs.length === 0 && !isLoading) { |
||||
return ( |
||||
<div className="flex flex-col items-center p-4 py-20"> |
||||
{addRoadmapModal} |
||||
<img |
||||
alt="roadmap" |
||||
src={RoadmapIcon.src} |
||||
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={() => setIsAddingRoadmap(true)} |
||||
> |
||||
Add roadmap |
||||
</button> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<div> |
||||
{addRoadmapModal} |
||||
<div className="mb-3 flex items-center justify-between"> |
||||
<span className={'text-gray-400'}> |
||||
{resourceConfigs.length} roadmap(s) selected |
||||
</span> |
||||
{canManageCurrentTeam && ( |
||||
<button |
||||
className="flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-gray-500 underline hover:bg-gray-100 hover:text-gray-900" |
||||
onClick={() => setIsAddingRoadmap(true)} |
||||
> |
||||
Add / Remove Roadmaps |
||||
</button> |
||||
)} |
||||
</div> |
||||
<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 key={resourceId} 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.src} |
||||
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 px-3 pb-3 pt-2'}> |
||||
<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' |
||||
} |
||||
onClick={() => setRemovingRoadmapId(resourceId)} |
||||
> |
||||
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.src} |
||||
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,39 @@ |
||||
import { Modal } from '../Modal'; |
||||
import { Map, Shapes } from 'lucide-react'; |
||||
|
||||
type PickRoadmapOptionModalProps = { |
||||
onClose: () => void; |
||||
showDefaultRoadmapsModal: () => void; |
||||
showCreateCustomRoadmapModal: () => void; |
||||
}; |
||||
|
||||
export function PickRoadmapOptionModal(props: PickRoadmapOptionModalProps) { |
||||
const { onClose, showDefaultRoadmapsModal, showCreateCustomRoadmapModal } = |
||||
props; |
||||
|
||||
return ( |
||||
<Modal onClose={onClose} bodyClassName="p-4"> |
||||
<h2 className="mb-0.5 text-left text-2xl font-semibold">Pick an Option</h2> |
||||
<p className="text-left text-sm text-gray-500 mb-4"> |
||||
Choose from default roadmaps or create from scratch. |
||||
</p> |
||||
|
||||
<div className="flex flex-col gap-2"> |
||||
<button |
||||
className="text-base flex items-center rounded-md border border-gray-300 p-2 px-4 text-left font-medium hover:bg-gray-100" |
||||
onClick={showDefaultRoadmapsModal} |
||||
> |
||||
<Map className="mr-2 inline-block" size={20} /> |
||||
Use a Default Roadmap |
||||
</button> |
||||
<button |
||||
className="text-base flex items-center rounded-md border border-gray-300 p-2 px-4 text-left font-medium hover:bg-gray-100" |
||||
onClick={showCreateCustomRoadmapModal} |
||||
> |
||||
<Shapes className="mr-2 inline-block" size={20} /> |
||||
Create from Scratch |
||||
</button> |
||||
</div> |
||||
</Modal> |
||||
); |
||||
} |
@ -0,0 +1,93 @@ |
||||
import MoreIcon from '../../icons/more-vertical.svg'; |
||||
import { useRef, useState } from 'react'; |
||||
import { useOutsideClick } from '../../hooks/use-outside-click'; |
||||
import { Lock, MoreVertical, Shapes, Trash2 } from 'lucide-react'; |
||||
|
||||
type RoadmapActionDropdownProps = { |
||||
onDelete?: () => void; |
||||
onCustomize?: () => void; |
||||
onUpdateSharing?: () => void; |
||||
}; |
||||
|
||||
export function RoadmapActionDropdown(props: RoadmapActionDropdownProps) { |
||||
const { onDelete, onUpdateSharing, onCustomize } = props; |
||||
|
||||
const menuRef = useRef<HTMLDivElement>(null); |
||||
const [isOpen, setIsOpen] = useState(false); |
||||
|
||||
useOutsideClick(menuRef, () => { |
||||
setIsOpen(false); |
||||
}); |
||||
|
||||
return ( |
||||
<div className="relative"> |
||||
<button |
||||
disabled={false} |
||||
onClick={() => setIsOpen(!isOpen)} |
||||
className="hidden items-center opacity-60 transition-opacity hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-30 sm:flex" |
||||
> |
||||
<img alt="menu" src={MoreIcon.src} className="h-4 w-4" /> |
||||
</button> |
||||
<button |
||||
disabled={false} |
||||
onClick={() => setIsOpen(!isOpen)} |
||||
className="flex items-center gap-1 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-none sm:hidden" |
||||
> |
||||
<MoreVertical size={14} /> |
||||
Options |
||||
</button> |
||||
|
||||
{isOpen && ( |
||||
<div |
||||
ref={menuRef} |
||||
className="align-right absolute right-auto top-full z-50 mt-1 w-[140px] rounded-md bg-slate-800 px-2 py-2 text-white shadow-md sm:right-0" |
||||
> |
||||
<ul> |
||||
{onUpdateSharing && ( |
||||
<li> |
||||
<button |
||||
onClick={() => { |
||||
setIsOpen(false); |
||||
onUpdateSharing(); |
||||
}} |
||||
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700" |
||||
> |
||||
<Lock size={14} className="mr-2" /> |
||||
Sharing |
||||
</button> |
||||
</li> |
||||
)} |
||||
{onCustomize && ( |
||||
<li> |
||||
<button |
||||
onClick={() => { |
||||
setIsOpen(false); |
||||
onCustomize(); |
||||
}} |
||||
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700" |
||||
> |
||||
<Shapes size={14} className="mr-2" /> |
||||
Customize |
||||
</button> |
||||
</li> |
||||
)} |
||||
{onDelete && ( |
||||
<li> |
||||
<button |
||||
onClick={() => { |
||||
setIsOpen(false); |
||||
onDelete(); |
||||
}} |
||||
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700" |
||||
> |
||||
<Trash2 size={14} className="mr-2" /> |
||||
Delete |
||||
</button> |
||||
</li> |
||||
)} |
||||
</ul> |
||||
</div> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,636 @@ |
||||
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 RoadmapIcon from '../../icons/roadmap.svg'; |
||||
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'; |
||||
|
||||
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} |
||||
|
||||
<img |
||||
alt="roadmap" |
||||
src={RoadmapIcon.src} |
||||
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 |
||||
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="grid grid-cols-1 p-2.5 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?id=${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> |
||||
)} |
||||
|
||||
{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> |
||||
); |
||||
} |
@ -0,0 +1,61 @@ |
||||
import { type ReactNode } from 'react'; |
||||
import { clsx } from 'clsx'; |
||||
|
||||
type TooltipProps = { |
||||
children: ReactNode; |
||||
position?: |
||||
| 'right-center' |
||||
| 'right-top' |
||||
| 'right-bottom' |
||||
| 'left-center' |
||||
| 'left-top' |
||||
| 'left-bottom' |
||||
| 'top-center' |
||||
| 'top-left' |
||||
| 'top-right' |
||||
| 'bottom-center' |
||||
| 'bottom-left' |
||||
| 'bottom-right'; |
||||
}; |
||||
|
||||
export function Tooltip(props: TooltipProps) { |
||||
const { children, position = 'right-center' } = props; |
||||
|
||||
let positionClass = ''; |
||||
if (position === 'right-center') { |
||||
positionClass = 'left-full top-1/2 -translate-y-1/2 translate-x-1 '; |
||||
} else if (position === 'top-center') { |
||||
positionClass = 'bottom-full left-1/2 -translate-x-1/2 -translate-y-0.5'; |
||||
} else if (position === 'bottom-center') { |
||||
positionClass = 'top-full left-1/2 -translate-x-1/2 translate-y-0.5'; |
||||
} else if (position === 'left-center') { |
||||
positionClass = 'right-full top-1/2 -translate-y-1/2 -translate-x-1'; |
||||
} else if (position === 'right-top') { |
||||
positionClass = 'left-full top-0'; |
||||
} else if (position === 'right-bottom') { |
||||
positionClass = 'left-full bottom-0'; |
||||
} else if (position === 'left-top') { |
||||
positionClass = 'right-full top-0'; |
||||
} else if (position === 'left-bottom') { |
||||
positionClass = 'right-full bottom-0'; |
||||
} else if (position === 'top-left') { |
||||
positionClass = 'bottom-full left-0'; |
||||
} else if (position === 'top-right') { |
||||
positionClass = 'bottom-full right-0'; |
||||
} else if (position === 'bottom-left') { |
||||
positionClass = 'top-full left-0'; |
||||
} else if (position === 'bottom-right') { |
||||
positionClass = 'top-full right-0'; |
||||
} |
||||
|
||||
return ( |
||||
<span |
||||
className={clsx( |
||||
'pointer-events-none absolute z-10 block w-max transform rounded-md bg-gray-900 px-2 py-1 text-sm font-medium text-white opacity-0 shadow-sm duration-100 group-hover:opacity-100', |
||||
positionClass |
||||
)} |
||||
> |
||||
{children} |
||||
</span> |
||||
); |
||||
} |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,6 @@ |
||||
import { clsx, type ClassValue } from 'clsx'; |
||||
import { twMerge } from 'tailwind-merge'; |
||||
|
||||
export function cn(...inputs: ClassValue[]) { |
||||
return twMerge(clsx(inputs)); |
||||
} |
@ -0,0 +1,15 @@ |
||||
--- |
||||
import AccountSidebar from '../../components/AccountSidebar.astro'; |
||||
import AccountLayout from '../../layouts/AccountLayout.astro'; |
||||
import { RoadmapListPage } from '../../components/CustomRoadmap/RoadmapListPage'; |
||||
--- |
||||
|
||||
<AccountLayout |
||||
title='Roadmaps' |
||||
noIndex={true} |
||||
initialLoadingMessage='Loading roadmaps' |
||||
> |
||||
<AccountSidebar activePageId='roadmaps' activePageTitle='Roadmaps'> |
||||
<RoadmapListPage client:only='react' /> |
||||
</AccountSidebar> |
||||
</AccountLayout> |
@ -0,0 +1,22 @@ |
||||
--- |
||||
import BaseLayout from '../../layouts/BaseLayout.astro'; |
||||
import { CustomRoadmap } from '../../components/CustomRoadmap/CustomRoadmap'; |
||||
import { SkeletonRoadmapHeader } from '../../components/CustomRoadmap/SkeletonRoadmapHeader'; |
||||
import Loader from '../../components/Loader.astro'; |
||||
import ProgressHelpPopup from '../../components/ProgressHelpPopup.astro'; |
||||
--- |
||||
|
||||
<BaseLayout title='Roadmaps'> |
||||
<ProgressHelpPopup /> |
||||
<div> |
||||
<div class='flex min-h-[550px] flex-col'> |
||||
<div data-roadmap-loader class='flex w-full grow flex-col'> |
||||
<SkeletonRoadmapHeader /> |
||||
<div class='flex grow items-center justify-center'> |
||||
<Loader /> |
||||
</div> |
||||
</div> |
||||
<CustomRoadmap client:only='react' /> |
||||
</div> |
||||
</div> |
||||
</BaseLayout> |
@ -0,0 +1,12 @@ |
||||
import { atom, computed } from 'nanostores'; |
||||
import { type RoadmapDocument } from '../components/CustomRoadmap/CreateRoadmap/CreateRoadmapModal'; |
||||
|
||||
export const currentRoadmap = atom<RoadmapDocument | undefined>(undefined); |
||||
export const isCurrentRoadmapPersonal = computed( |
||||
currentRoadmap, |
||||
(roadmap) => !roadmap?.teamId |
||||
); |
||||
export const canManageCurrentRoadmap = computed( |
||||
currentRoadmap, |
||||
(roadmap) => roadmap?.canManage |
||||
); |
Loading…
Reference in new issue