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_API_URL=http://api.roadmap.sh |
||||||
PUBLIC_AVATAR_BASE_URL=https://dodrc8eu8m09s.cloudfront.net/avatars |
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