Add teams support
@ -36,6 +36,7 @@
|
||||
"preact": "^10.15.1",
|
||||
"rehype-external-links": "^2.1.0",
|
||||
"roadmap-renderer": "^1.0.6",
|
||||
"slugify": "^1.6.6",
|
||||
"tailwindcss": "^3.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
8
pnpm-lock.yaml
generated
@ -50,6 +50,9 @@ dependencies:
|
||||
roadmap-renderer:
|
||||
specifier: ^1.0.6
|
||||
version: 1.0.6
|
||||
slugify:
|
||||
specifier: ^1.6.6
|
||||
version: 1.6.6
|
||||
tailwindcss:
|
||||
specifier: ^3.3.2
|
||||
version: 3.3.2
|
||||
@ -5077,6 +5080,11 @@ packages:
|
||||
engines: {node: '>=12'}
|
||||
dev: false
|
||||
|
||||
/slugify@1.6.6:
|
||||
resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
dev: false
|
||||
|
||||
/smart-buffer@4.2.0:
|
||||
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
|
||||
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
|
||||
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 102 KiB |
@ -1,13 +1,15 @@
|
||||
---
|
||||
import AstroIcon from './AstroIcon.astro';
|
||||
|
||||
const { activePageId, activePageTitle } = Astro.props;
|
||||
import { TeamDropdown } from './TeamDropdown/TeamDropdown';
|
||||
|
||||
export interface Props {
|
||||
activePageId: string;
|
||||
activePageTitle: string;
|
||||
hasDesktopSidebar?: boolean;
|
||||
}
|
||||
|
||||
const { hasDesktopSidebar = true, activePageId, activePageTitle } = Astro.props;
|
||||
|
||||
const sidebarLinks = [
|
||||
{
|
||||
href: '/account',
|
||||
@ -64,6 +66,17 @@ const sidebarLinks = [
|
||||
id='settings-menu-dropdown'
|
||||
class='absolute left-0 right-0 z-10 mt-1 hidden space-y-1.5 bg-white p-2 shadow-lg'
|
||||
>
|
||||
<!--<li>-->
|
||||
<!-- <a-->
|
||||
<!-- href='/team'-->
|
||||
<!-- class={`flex w-full items-center rounded px-3 py-1.5 text-sm text-slate-900 hover:bg-slate-200 ${-->
|
||||
<!-- activePageId === 'team' ? 'bg-slate-100' : ''-->
|
||||
<!-- }`}-->
|
||||
<!-- >-->
|
||||
<!-- <AstroIcon icon={'users'} class={`h-4 w-4 mr-2`} />-->
|
||||
<!-- Teams-->
|
||||
<!-- </a>-->
|
||||
<!--</li>-->
|
||||
{
|
||||
sidebarLinks.map((sidebarLink) => {
|
||||
const isActive = activePageId === sidebarLink.id;
|
||||
@ -91,48 +104,52 @@ const sidebarLinks = [
|
||||
|
||||
<div class='container flex min-h-screen items-stretch'>
|
||||
<!-- Start Desktop Sidebar -->
|
||||
<aside class='hidden w-44 shrink-0 border-r border-slate-200 py-10 md:block'>
|
||||
<nav>
|
||||
<ul class='space-y-1'>
|
||||
{
|
||||
sidebarLinks.map((sidebarLink) => {
|
||||
const isActive = activePageId === sidebarLink.id;
|
||||
{
|
||||
hasDesktopSidebar && (
|
||||
<aside class='hidden w-[195px] shrink-0 border-r border-slate-200 py-10 md:block'>
|
||||
<TeamDropdown client:load />
|
||||
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
href={sidebarLink.href}
|
||||
class={`font-regular flex w-full items-center border-r-2 px-2 py-1.5 text-sm ${
|
||||
isActive
|
||||
? 'border-r-black bg-gray-100 text-black'
|
||||
: 'border-r-transparent text-gray-500 hover:border-r-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span class='flex flex-grow items-center'>
|
||||
<AstroIcon
|
||||
icon={sidebarLink.icon.glyph}
|
||||
class={`${sidebarLink.icon.classes} mr-2`}
|
||||
/>
|
||||
{sidebarLink.title}
|
||||
</span>
|
||||
<nav>
|
||||
<ul class='space-y-1'>
|
||||
{sidebarLinks.map((sidebarLink) => {
|
||||
const isActive = activePageId === sidebarLink.id;
|
||||
|
||||
{sidebarLink.isNew && !isActive && (
|
||||
<span class='relative mr-1 flex items-center'>
|
||||
<span class='relative rounded-full bg-gray-200 p-1 text-xs' />
|
||||
<span class='absolute bottom-0 left-0 right-0 top-0 animate-ping rounded-full bg-gray-400 p-1 text-xs' />
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
href={sidebarLink.href}
|
||||
class={`font-regular flex w-full items-center border-r-2 px-2 py-1.5 text-sm ${
|
||||
isActive
|
||||
? 'border-r-black bg-gray-100 text-black'
|
||||
: 'border-r-transparent text-gray-500 hover:border-r-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span class='flex flex-grow items-center'>
|
||||
<AstroIcon
|
||||
icon={sidebarLink.icon.glyph}
|
||||
class={`${sidebarLink.icon.classes} mr-2`}
|
||||
/>
|
||||
{sidebarLink.title}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{sidebarLink.isNew && !isActive && (
|
||||
<span class='relative mr-1 flex items-center'>
|
||||
<span class='relative rounded-full bg-gray-200 p-1 text-xs' />
|
||||
<span class='absolute bottom-0 left-0 right-0 top-0 animate-ping rounded-full bg-gray-400 p-1 text-xs' />
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
<!-- /End Desktop Sidebar -->
|
||||
|
||||
<div class='grow px-0 py-0 md:px-10 md:py-10'>
|
||||
<div class:list={['grow px-0 py-0 md:py-10', { 'md:px-10': hasDesktopSidebar, 'md:px-5': !hasDesktopSidebar }]}>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -5,7 +5,7 @@ import { ResourceProgress } from './ResourceProgress';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { EmptyActivity } from './EmptyActivity';
|
||||
|
||||
type ActivityResponse = {
|
||||
export type ActivityResponse = {
|
||||
done: {
|
||||
today: number;
|
||||
total: number;
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import { getRelativeTimeString } from '../../lib/date';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
type ResourceProgressType = {
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
@ -11,10 +12,13 @@ type ResourceProgressType = {
|
||||
doneCount: number;
|
||||
learningCount: number;
|
||||
skippedCount: number;
|
||||
onCleared: () => void;
|
||||
onCleared?: () => void;
|
||||
showClearButton?: boolean;
|
||||
};
|
||||
|
||||
export function ResourceProgress(props: ResourceProgressType) {
|
||||
const { showClearButton = true } = props;
|
||||
const toast = useToast();
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
|
||||
@ -41,7 +45,7 @@ export function ResourceProgress(props: ResourceProgressType) {
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
alert('Error clearing progress. Please try again.');
|
||||
toast.error('Error clearing progress. Please try again.');
|
||||
console.error(error);
|
||||
setIsClearing(false);
|
||||
return;
|
||||
@ -52,7 +56,9 @@ export function ResourceProgress(props: ResourceProgressType) {
|
||||
|
||||
setIsClearing(false);
|
||||
setIsConfirming(false);
|
||||
onCleared();
|
||||
if (onCleared) {
|
||||
onCleared();
|
||||
}
|
||||
}
|
||||
|
||||
const url =
|
||||
@ -101,38 +107,42 @@ export function ResourceProgress(props: ResourceProgressType) {
|
||||
)}
|
||||
<span>{totalCount} total</span>
|
||||
</span>
|
||||
{!isConfirming && (
|
||||
<button
|
||||
className="text-red-500 hover:text-red-800"
|
||||
onClick={() => setIsConfirming(true)}
|
||||
disabled={isClearing}
|
||||
>
|
||||
{!isClearing && (
|
||||
<>
|
||||
Clear Progress <span>×</span>
|
||||
</>
|
||||
{showClearButton && (
|
||||
<>
|
||||
{!isConfirming && (
|
||||
<button
|
||||
className="text-red-500 hover:text-red-800"
|
||||
onClick={() => setIsConfirming(true)}
|
||||
disabled={isClearing}
|
||||
>
|
||||
{!isClearing && (
|
||||
<>
|
||||
Clear Progress <span>×</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isClearing && 'Processing...'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isClearing && 'Processing...'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isConfirming && (
|
||||
<span>
|
||||
Are you sure?{' '}
|
||||
<button
|
||||
onClick={clearProgress}
|
||||
className="ml-1 mr-1 text-red-500 underline hover:text-red-800"
|
||||
>
|
||||
Yes
|
||||
</button>{' '}
|
||||
<button
|
||||
onClick={() => setIsConfirming(false)}
|
||||
className="text-red-500 underline hover:text-red-800"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</span>
|
||||
{isConfirming && (
|
||||
<span>
|
||||
Are you sure?{' '}
|
||||
<button
|
||||
onClick={clearProgress}
|
||||
className="ml-1 mr-1 text-red-500 underline hover:text-red-800"
|
||||
>
|
||||
Yes
|
||||
</button>{' '}
|
||||
<button
|
||||
onClick={() => setIsConfirming(false)}
|
||||
className="text-red-500 underline hover:text-red-800"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
174
src/components/AddTeamRoadmap.tsx
Normal file
@ -0,0 +1,174 @@
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import { useOutsideClick } from '../hooks/use-outside-click';
|
||||
import { OptionType, SearchSelector } from './SearchSelector';
|
||||
import type { PageType } from './CommandMenu/CommandMenu';
|
||||
import { CheckIcon } from './ReactIcons/CheckIcon';
|
||||
import { httpPut } from '../lib/http';
|
||||
import type { TeamResourceConfig } from './CreateTeam/RoadmapSelector';
|
||||
import { Spinner } from './ReactIcons/Spinner';
|
||||
|
||||
type AddTeamRoadmapProps = {
|
||||
teamId: string;
|
||||
allRoadmaps: PageType[];
|
||||
availableRoadmaps: PageType[];
|
||||
onClose: () => void;
|
||||
onMakeChanges: (roadmapId: string) => void;
|
||||
setResourceConfigs: (config: TeamResourceConfig) => void;
|
||||
};
|
||||
|
||||
export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
|
||||
const {
|
||||
teamId,
|
||||
onMakeChanges,
|
||||
onClose,
|
||||
allRoadmaps,
|
||||
availableRoadmaps,
|
||||
setResourceConfigs,
|
||||
} = props;
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedRoadmap, setSelectedRoadmap] = useState<string>('');
|
||||
const popupBodyEl = useRef<HTMLDivElement>(null);
|
||||
|
||||
async function addTeamResource(roadmapId: string) {
|
||||
if (!teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-update-team-resource-config/${teamId}`,
|
||||
{
|
||||
teamId: teamId,
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
removed: [],
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Error adding roadmap');
|
||||
return;
|
||||
}
|
||||
|
||||
setResourceConfigs(response);
|
||||
}
|
||||
|
||||
useOutsideClick(popupBodyEl, () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
const selectedRoadmapTitle = allRoadmaps.find(
|
||||
(roadmap) => roadmap.id === selectedRoadmap
|
||||
)?.title;
|
||||
|
||||
return (
|
||||
<div class="popup fixed left-0 right-0 top-0 z-50 flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
|
||||
<div class="relative h-full w-full max-w-md p-4 md:h-auto">
|
||||
<div
|
||||
ref={popupBodyEl}
|
||||
class="popup-body relative rounded-lg bg-white p-4 shadow"
|
||||
>
|
||||
{isLoading && (
|
||||
<>
|
||||
<div class="flex items-center justify-center gap-2 py-8">
|
||||
<Spinner isDualRing={false} className="h-4 w-4" />
|
||||
<h2 className="font-medium">Loading...</h2>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!isLoading && !error && selectedRoadmap && (
|
||||
<div className={'text-center'}>
|
||||
<CheckIcon additionalClasses="h-10 w-10 mx-auto opacity-20 mb-3 mt-4" />
|
||||
<h3 class="mb-1.5 text-2xl font-medium">
|
||||
{selectedRoadmapTitle} Added
|
||||
</h3>
|
||||
<p className="mb-4 text-sm leading-none text-gray-400">
|
||||
<button
|
||||
onClick={() => onMakeChanges(selectedRoadmap)}
|
||||
className="underline underline-offset-2 hover:text-gray-900"
|
||||
>
|
||||
Click here
|
||||
</button>{' '}
|
||||
to make changes to the roadmap.
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
class="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedRoadmap('');
|
||||
setError('');
|
||||
setIsLoading(false);
|
||||
}}
|
||||
type="button"
|
||||
class="flex-grow cursor-pointer rounded-lg bg-black py-2 text-center text-white"
|
||||
>
|
||||
+ Add More
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && error && (
|
||||
<>
|
||||
<h3 class="mb-1.5 text-2xl font-medium">Error</h3>
|
||||
<p className="mb-3 text-sm leading-none text-red-400">{error}</p>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
class="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!isLoading && !error && !selectedRoadmap && (
|
||||
<>
|
||||
<h3 class="mb-1.5 text-2xl font-medium">Add Roadmap</h3>
|
||||
<p className="mb-3 text-sm leading-none text-gray-400">
|
||||
Search and add a roadmap
|
||||
</p>
|
||||
|
||||
<SearchSelector
|
||||
options={availableRoadmaps.map((roadmap) => ({
|
||||
value: roadmap.id,
|
||||
label: roadmap.title,
|
||||
}))}
|
||||
onSelect={(option: OptionType) => {
|
||||
const roadmapId = option.value;
|
||||
addTeamResource(roadmapId).finally(() => {
|
||||
setIsLoading(false);
|
||||
setSelectedRoadmap(roadmapId);
|
||||
});
|
||||
}}
|
||||
inputClassName="mt-2 mb-2 block w-full rounded-md border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:border-gray-400"
|
||||
placeholder={'Search for roadmap'}
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
class="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -90,8 +90,13 @@ export function GitHubButton(props: GitHubButtonProps) {
|
||||
// For non authentication pages, we want to redirect back to the page
|
||||
// the user was on before they clicked the social login button
|
||||
if (!['/login', '/signup'].includes(window.location.pathname)) {
|
||||
const pagePath =
|
||||
window.location.pathname === '/respond-invite'
|
||||
? window.location.pathname + window.location.search
|
||||
: window.location.pathname;
|
||||
|
||||
localStorage.setItem(GITHUB_REDIRECT_AT, Date.now().toString());
|
||||
localStorage.setItem(GITHUB_LAST_PAGE, window.location.pathname);
|
||||
localStorage.setItem(GITHUB_LAST_PAGE, pagePath);
|
||||
}
|
||||
|
||||
window.location.href = response.loginUrl;
|
||||
|
@ -85,8 +85,13 @@ export function GoogleButton(props: GoogleButtonProps) {
|
||||
// For non authentication pages, we want to redirect back to the page
|
||||
// the user was on before they clicked the social login button
|
||||
if (!['/login', '/signup'].includes(window.location.pathname)) {
|
||||
const pagePath =
|
||||
window.location.pathname === '/respond-invite'
|
||||
? window.location.pathname + window.location.search
|
||||
: window.location.pathname;
|
||||
|
||||
localStorage.setItem(GOOGLE_REDIRECT_AT, Date.now().toString());
|
||||
localStorage.setItem(GOOGLE_LAST_PAGE, window.location.pathname);
|
||||
localStorage.setItem(GOOGLE_LAST_PAGE, pagePath);
|
||||
}
|
||||
|
||||
window.location.href = response.loginUrl;
|
||||
|
@ -85,8 +85,13 @@ export function LinkedInButton(props: LinkedInButtonProps) {
|
||||
// For non authentication pages, we want to redirect back to the page
|
||||
// the user was on before they clicked the social login button
|
||||
if (!['/login', '/signup'].includes(window.location.pathname)) {
|
||||
const pagePath =
|
||||
window.location.pathname === '/respond-invite'
|
||||
? window.location.pathname + window.location.search
|
||||
: window.location.pathname;
|
||||
|
||||
localStorage.setItem(LINKEDIN_REDIRECT_AT, Date.now().toString());
|
||||
localStorage.setItem(LINKEDIN_LAST_PAGE, window.location.pathname);
|
||||
localStorage.setItem(LINKEDIN_LAST_PAGE, pagePath);
|
||||
}
|
||||
|
||||
window.location.href = response.loginUrl;
|
||||
|
@ -33,9 +33,17 @@ function showHideGuestElements(hideOrShow: 'hide' | 'show' = 'hide') {
|
||||
function handleGuest() {
|
||||
const authenticatedRoutes = [
|
||||
'/account/update-profile',
|
||||
'/account/notification',
|
||||
'/account/update-password',
|
||||
'/account/settings',
|
||||
'/account/road-card',
|
||||
'/account',
|
||||
'/team',
|
||||
'/team/progress',
|
||||
'/team/roadmaps',
|
||||
'/team/new',
|
||||
'/team/members',
|
||||
'/team/settings'
|
||||
];
|
||||
|
||||
showHideAuthElements('hide');
|
||||
|
@ -6,11 +6,13 @@ import GuideIcon from '../../icons/guide.svg';
|
||||
import HomeIcon from '../../icons/home.svg';
|
||||
import RoadmapIcon from '../../icons/roadmap.svg';
|
||||
import UserIcon from '../../icons/user.svg';
|
||||
import GroupIcon from '../../icons/group.svg';
|
||||
import VideoIcon from '../../icons/video.svg';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
|
||||
type PageType = {
|
||||
export type PageType = {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
group: string;
|
||||
@ -19,23 +21,51 @@ type PageType = {
|
||||
};
|
||||
|
||||
const defaultPages: PageType[] = [
|
||||
{ url: '/', title: 'Home', group: 'Pages', icon: HomeIcon },
|
||||
{ id: 'home', url: '/', title: 'Home', group: 'Pages', icon: HomeIcon },
|
||||
{
|
||||
id: 'account',
|
||||
url: '/account',
|
||||
title: 'Account',
|
||||
group: 'Pages',
|
||||
icon: UserIcon,
|
||||
isProtected: true,
|
||||
},
|
||||
{ url: '/roadmaps', title: 'Roadmaps', group: 'Pages', icon: RoadmapIcon },
|
||||
{
|
||||
id: 'team',
|
||||
url: '/team',
|
||||
title: 'Teams',
|
||||
group: 'Pages',
|
||||
icon: GroupIcon,
|
||||
isProtected: true,
|
||||
},
|
||||
{
|
||||
id: 'roadmaps',
|
||||
url: '/roadmaps',
|
||||
title: 'Roadmaps',
|
||||
group: 'Pages',
|
||||
icon: RoadmapIcon,
|
||||
},
|
||||
{
|
||||
id: 'best-practices',
|
||||
url: '/best-practices',
|
||||
title: 'Best Practices',
|
||||
group: 'Pages',
|
||||
icon: BestPracticesIcon,
|
||||
},
|
||||
{ url: '/guides', title: 'Guides', group: 'Pages', icon: GuideIcon },
|
||||
{ url: '/videos', title: 'Videos', group: 'Pages', icon: VideoIcon },
|
||||
{
|
||||
id: 'guides',
|
||||
url: '/guides',
|
||||
title: 'Guides',
|
||||
group: 'Pages',
|
||||
icon: GuideIcon,
|
||||
},
|
||||
{
|
||||
id: 'videos',
|
||||
url: '/videos',
|
||||
title: 'Videos',
|
||||
group: 'Pages',
|
||||
icon: VideoIcon,
|
||||
},
|
||||
];
|
||||
|
||||
function shouldShowPage(page: PageType) {
|
||||
@ -188,7 +218,7 @@ export function CommandMenu() {
|
||||
<span class="mr-2 text-gray-400">{page.group}</span>
|
||||
)}
|
||||
{page.icon && (
|
||||
<img src={page.icon} class="mr-2 h-4 w-4" />
|
||||
<img alt={page.title} src={page.icon} class="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{page.title}
|
||||
</a>
|
||||
|
216
src/components/CreateTeam/CreateTeamForm.tsx
Normal file
@ -0,0 +1,216 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { Stepper } from '../Stepper';
|
||||
import { Step0, ValidTeamType } from './Step0';
|
||||
import { Step1, ValidTeamSize } from './Step1';
|
||||
import { Step2 } from './Step2';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { getUrlParams, setUrlParams } from '../../lib/browser';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import type { TeamResourceConfig } from './RoadmapSelector';
|
||||
import { Step3 } from './Step3';
|
||||
import { Step4 } from './Step4';
|
||||
import {useToast} from "../../hooks/use-toast";
|
||||
|
||||
export interface TeamDocument {
|
||||
_id?: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
creatorId: string;
|
||||
links: {
|
||||
website?: string;
|
||||
github?: string;
|
||||
linkedIn?: string;
|
||||
};
|
||||
type: ValidTeamType;
|
||||
canMemberSendInvite: boolean;
|
||||
teamSize?: ValidTeamSize;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export function CreateTeamForm() {
|
||||
// Can't use hook `useParams` because it runs asynchronously
|
||||
const { s: queryStepIndex, t: teamId } = getUrlParams();
|
||||
|
||||
const toast = useToast();
|
||||
const [team, setTeam] = useState<TeamDocument>();
|
||||
|
||||
const [loadingTeam, setLoadingTeam] = useState(!!teamId && !team?._id);
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
|
||||
async function loadTeam(
|
||||
teamIdToFetch: string,
|
||||
requiredStepIndex: number | string
|
||||
) {
|
||||
const { response, error } = await httpGet<TeamDocument>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team/${teamIdToFetch}`
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Error loading team');
|
||||
window.location.href = '/account';
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredStepIndexNumber = parseInt(requiredStepIndex as string, 10);
|
||||
const completedSteps = Array(requiredStepIndexNumber)
|
||||
.fill(1)
|
||||
.map((_, counter) => counter);
|
||||
|
||||
setTeam(response);
|
||||
setSelectedTeamType(response.type);
|
||||
setCompletedSteps(completedSteps);
|
||||
setStepIndex(requiredStepIndexNumber);
|
||||
|
||||
await loadTeamResourceConfig(teamIdToFetch);
|
||||
}
|
||||
|
||||
const [teamResourceConfig, setTeamResourceConfig] =
|
||||
useState<TeamResourceConfig>([]);
|
||||
|
||||
async function loadTeamResourceConfig(teamId: string) {
|
||||
const { error, response } = await httpGet<TeamResourceConfig>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-resource-config/${teamId}`
|
||||
);
|
||||
if (error || !Array.isArray(response)) {
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamResourceConfig(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!teamId || !queryStepIndex || team) {
|
||||
return;
|
||||
}
|
||||
|
||||
pageProgressMessage.set('Fetching team');
|
||||
setLoadingTeam(true);
|
||||
loadTeam(teamId, queryStepIndex).finally(() => {
|
||||
setLoadingTeam(false);
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
|
||||
// fetch team and move to step
|
||||
}, [teamId, queryStepIndex]);
|
||||
|
||||
const [selectedTeamType, setSelectedTeamType] = useState<ValidTeamType>(
|
||||
team?.type || 'company'
|
||||
);
|
||||
|
||||
const [completedSteps, setCompletedSteps] = useState([0]);
|
||||
if (loadingTeam) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let stepForm = null;
|
||||
if (stepIndex === 0) {
|
||||
stepForm = (
|
||||
<Step0
|
||||
team={team}
|
||||
selectedTeamType={selectedTeamType}
|
||||
setSelectedTeamType={setSelectedTeamType}
|
||||
onStepComplete={() => {
|
||||
if (team?._id) {
|
||||
setUrlParams({ t: team._id, s: '1' });
|
||||
}
|
||||
|
||||
setCompletedSteps([0]);
|
||||
setStepIndex(1);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (stepIndex === 1) {
|
||||
stepForm = (
|
||||
<Step1
|
||||
team={team}
|
||||
onBack={() => {
|
||||
if (team?._id) {
|
||||
setUrlParams({ t: team._id, s: '0' });
|
||||
}
|
||||
|
||||
setStepIndex(0);
|
||||
}}
|
||||
onStepComplete={(team: TeamDocument) => {
|
||||
const createdTeamId = team._id!;
|
||||
|
||||
setUrlParams({ t: createdTeamId, s: '2' });
|
||||
|
||||
setCompletedSteps([0, 1]);
|
||||
setStepIndex(2);
|
||||
setTeam(team);
|
||||
}}
|
||||
selectedTeamType={selectedTeamType}
|
||||
/>
|
||||
);
|
||||
} else if (stepIndex === 2) {
|
||||
stepForm = (
|
||||
<Step2
|
||||
team={team!}
|
||||
teamResourceConfig={teamResourceConfig}
|
||||
setTeamResourceConfig={setTeamResourceConfig}
|
||||
onBack={() => {
|
||||
if (team) {
|
||||
setUrlParams({ t: team._id!, s: '1' });
|
||||
}
|
||||
|
||||
setStepIndex(1);
|
||||
}}
|
||||
onNext={() => {
|
||||
setUrlParams({ t: teamId!, s: '3' });
|
||||
setCompletedSteps([0, 1, 2]);
|
||||
setStepIndex(3);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (stepIndex === 3) {
|
||||
stepForm = (
|
||||
<Step3
|
||||
team={team}
|
||||
onBack={() => {
|
||||
if (team) {
|
||||
setUrlParams({ t: team._id!, s: '2' });
|
||||
}
|
||||
|
||||
setStepIndex(2);
|
||||
}}
|
||||
onNext={() => {
|
||||
if (team) {
|
||||
setUrlParams({ t: team._id!, s: '4' });
|
||||
}
|
||||
|
||||
setCompletedSteps([0, 1, 2, 3]);
|
||||
setStepIndex(4);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (stepIndex === 4) {
|
||||
stepForm = <Step4 team={team!} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'mx-auto max-w-[700px] py-6'}>
|
||||
<div className={'mb-8 flex flex-col items-center'}>
|
||||
<h1 className={'text-4xl font-bold'}>Create Team</h1>
|
||||
<p className={'mt-2 text-gray-500'}>
|
||||
Complete the steps below to create your team
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-8 mt-8 flex w-full">
|
||||
<Stepper
|
||||
activeIndex={stepIndex}
|
||||
completeSteps={completedSteps}
|
||||
steps={[
|
||||
{ label: 'Type' },
|
||||
{ label: 'Details' },
|
||||
{ label: 'Skills' },
|
||||
{ label: 'Members' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{stepForm}
|
||||
</div>
|
||||
);
|
||||
}
|
44
src/components/CreateTeam/NextButton.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
|
||||
type NextButtonProps = {
|
||||
isLoading?: boolean;
|
||||
loadingMessage?: string;
|
||||
text: string;
|
||||
hasNextArrow?: boolean;
|
||||
onClick?: () => void;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export function NextButton(props: NextButtonProps) {
|
||||
const {
|
||||
isLoading = false,
|
||||
text = 'Next Step',
|
||||
type = 'button',
|
||||
loadingMessage = 'Please wait ..',
|
||||
onClick = () => null,
|
||||
hasNextArrow = true,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={isLoading}
|
||||
className={
|
||||
'rounded-md border border-black bg-black px-4 py-2 text-white disabled:opacity-50'
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className={'flex items-center justify-center'}>
|
||||
<Spinner />
|
||||
<span className="ml-2">{loadingMessage}</span>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{text}
|
||||
{hasNextArrow && <span className="ml-1">→</span>}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
221
src/components/CreateTeam/RoadmapSelector.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { SearchSelector } from '../SearchSelector';
|
||||
import { httpGet, httpPut } from '../../lib/http';
|
||||
import type { PageType } from '../CommandMenu/CommandMenu';
|
||||
import SearchIcon from '../../icons/search.svg';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
import { UpdateTeamResourceModal } from './UpdateTeamResourceModal';
|
||||
|
||||
export type TeamResourceConfig = {
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
removed: string[];
|
||||
}[];
|
||||
|
||||
type RoadmapSelectorProps = {
|
||||
team: TeamDocument;
|
||||
teamResourceConfig: TeamResourceConfig;
|
||||
setTeamResourceConfig: (config: TeamResourceConfig) => void;
|
||||
};
|
||||
|
||||
export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
const { team, teamResourceConfig = [], setTeamResourceConfig } = props;
|
||||
|
||||
const [allRoadmaps, setAllRoadmaps] = useState<PageType[]>([]);
|
||||
const [changingRoadmapId, setChangingRoadmapId] = useState<string>('');
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
async function loadAllRoadmaps() {
|
||||
const { error, response } = await httpGet<PageType[]>(`/pages.json`);
|
||||
|
||||
if (error) {
|
||||
setError(error.message || 'Something went wrong. Please try again!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allRoadmaps = response
|
||||
.filter((page) => page.group === 'Roadmaps')
|
||||
.sort((a, b) => {
|
||||
if (a.title === 'Android') return 1;
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
|
||||
setAllRoadmaps(allRoadmaps);
|
||||
return response;
|
||||
}
|
||||
|
||||
async function deleteResource(roadmapId: string) {
|
||||
if (!team?._id) {
|
||||
return;
|
||||
}
|
||||
|
||||
pageProgressMessage.set(`Deleting resource`);
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-delete-team-resource-config/${
|
||||
team._id
|
||||
}`,
|
||||
{
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Error deleting roadmap');
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamResourceConfig(response);
|
||||
}
|
||||
|
||||
async function onRemove(resourceId: string) {
|
||||
pageProgressMessage.set('Removing roadmap');
|
||||
|
||||
deleteResource(resourceId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}
|
||||
|
||||
async function addTeamResource(roadmapId: string) {
|
||||
if (!team?._id) {
|
||||
return;
|
||||
}
|
||||
|
||||
pageProgressMessage.set(`Adding roadmap to team`);
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-update-team-resource-config/${
|
||||
team._id
|
||||
}`,
|
||||
{
|
||||
teamId: team._id,
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
removed: [],
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Error adding roadmap');
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamResourceConfig(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadAllRoadmaps().finally();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{changingRoadmapId && (
|
||||
<UpdateTeamResourceModal
|
||||
onClose={() => setChangingRoadmapId('')}
|
||||
resourceId={changingRoadmapId}
|
||||
resourceType={'roadmap'}
|
||||
teamId={team?._id!}
|
||||
setTeamResourceConfig={setTeamResourceConfig}
|
||||
defaultRemovedItems={
|
||||
teamResourceConfig.find((c) => c.resourceId === changingRoadmapId)
|
||||
?.removed || []
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SearchSelector
|
||||
placeholder={`Search Roadmaps ..`}
|
||||
onSelect={(option) => {
|
||||
const roadmapId = option.value;
|
||||
addTeamResource(roadmapId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
options={allRoadmaps
|
||||
.filter((roadmap) => {
|
||||
return !teamResourceConfig
|
||||
.map((c) => c.resourceId)
|
||||
.includes(roadmap.id);
|
||||
})
|
||||
.map((roadmap) => ({
|
||||
value: roadmap.id,
|
||||
label: roadmap.title,
|
||||
}))}
|
||||
searchInputId={'roadmap-input'}
|
||||
inputClassName="mt-2 block w-full rounded-md border px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
/>
|
||||
|
||||
{!teamResourceConfig.length && (
|
||||
<div className="mt-4 rounded-md border px-4 py-12 text-center text-sm text-gray-700">
|
||||
<img
|
||||
alt={'search'}
|
||||
src={SearchIcon}
|
||||
className={'mx-auto mb-5 h-[42px] w-[42px] opacity-10'}
|
||||
/>
|
||||
<span className="block text-lg font-semibold text-black">
|
||||
No roadmaps selected.
|
||||
</span>
|
||||
<p className={'text-sm text-gray-400'}>
|
||||
Please search and add roadmaps from above
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{teamResourceConfig.length > 0 && (
|
||||
<div className="mt-4 grid grid-cols-3 flex-wrap gap-2.5">
|
||||
{teamResourceConfig.map(({ resourceId, removed: removedTopics }) => {
|
||||
const roadmapTitle =
|
||||
allRoadmaps.find((roadmap) => roadmap.id === resourceId)?.title ||
|
||||
'...';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start rounded-md border border-gray-300">
|
||||
<div className={'w-full px-3 pb-2 pt-4'}>
|
||||
<span className="mb-0.5 block text-base font-medium leading-none text-black">
|
||||
{roadmapTitle}
|
||||
</span>
|
||||
{removedTopics.length > 0 ? (
|
||||
<span className={'text-xs leading-none text-gray-900'}>
|
||||
{removedTopics.length} topic
|
||||
{removedTopics.length > 1 ? 's' : ''} removed
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs italic leading-none text-gray-400/60">
|
||||
No changes made ..
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={'flex w-full justify-between p-3'}>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
'text-xs text-gray-500 underline hover:text-black focus:outline-none'
|
||||
}
|
||||
onClick={() => setChangingRoadmapId(resourceId)}
|
||||
>
|
||||
Customize
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
'text-xs text-red-500 underline hover:text-black'
|
||||
}
|
||||
onClick={() => onRemove(resourceId)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
135
src/components/CreateTeam/RoleDropdown.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
import { ChevronDownIcon } from '../ReactIcons/ChevronDownIcon';
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
|
||||
const allowedRoles = [
|
||||
{
|
||||
name: 'Admin',
|
||||
value: 'admin',
|
||||
description: 'Can do everything',
|
||||
},
|
||||
{
|
||||
name: 'Manager',
|
||||
value: 'manager',
|
||||
description: 'Can manage team and skills',
|
||||
},
|
||||
{
|
||||
name: 'Member',
|
||||
value: 'member',
|
||||
description: 'Can view team and skills',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type AllowedRoles = (typeof allowedRoles)[number]['value'];
|
||||
|
||||
type RoleDropdownProps = {
|
||||
className?: string;
|
||||
selectedRole: string;
|
||||
setSelectedRole: (role: AllowedRoles) => void;
|
||||
};
|
||||
|
||||
export function RoleDropdown(props: RoleDropdownProps) {
|
||||
const { selectedRole, setSelectedRole, className = 'w-[120px]' } = props;
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
const [activeRoleIndex, setActiveRoleIndex] = useState(0);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
useOutsideClick(dropdownRef, () => {
|
||||
setIsMenuOpen(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<button
|
||||
type={'button'}
|
||||
onKeyDown={(e) => {
|
||||
const isUpOrDown = e.key === 'ArrowUp' || e.key === 'ArrowDown';
|
||||
if (isUpOrDown && !isMenuOpen) {
|
||||
e.preventDefault();
|
||||
setIsMenuOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const isEnter = e.key === 'Enter';
|
||||
if (isEnter && isMenuOpen) {
|
||||
e.preventDefault();
|
||||
setSelectedRole(allowedRoles[activeRoleIndex].value);
|
||||
setIsMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveRoleIndex((prev) => {
|
||||
const nextIndex = prev + 1;
|
||||
if (nextIndex >= allowedRoles.length) {
|
||||
return 0;
|
||||
}
|
||||
return nextIndex;
|
||||
});
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveRoleIndex((prev) => {
|
||||
const nextIndex = prev - 1;
|
||||
if (nextIndex < 0) {
|
||||
return allowedRoles.length - 1;
|
||||
}
|
||||
return nextIndex;
|
||||
});
|
||||
}
|
||||
}}
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
className={`flex h-full w-full cursor-default items-center justify-between rounded-md border px-4 ${
|
||||
isMenuOpen ? 'border-gray-300 bg-gray-100' : ''
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`capitalize ${
|
||||
selectedRole === 'admin' ? 'text-blue-600' : ''
|
||||
} ${selectedRole === 'manager' ? 'text-cyan-600' : ''}`}
|
||||
>
|
||||
{selectedRole || 'Select Role'}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={'relative top-0.5 ml-2 h-4 w-4 text-gray-400'}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isMenuOpen && (
|
||||
<div
|
||||
className="absolute z-10 mt-1 w-[200px] rounded-md border bg-white shadow-md"
|
||||
ref={dropdownRef}
|
||||
>
|
||||
<div
|
||||
className="py-1"
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="options-menu"
|
||||
>
|
||||
{allowedRoles.map((allowedRole, roleCounter) => (
|
||||
<button
|
||||
key={allowedRole.value}
|
||||
type={'button'}
|
||||
className={`w-full cursor-default px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 ${
|
||||
roleCounter === activeRoleIndex ? 'bg-gray-100' : 'bg-white'
|
||||
}`}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setIsMenuOpen(false);
|
||||
setSelectedRole(allowedRole.value);
|
||||
}}
|
||||
>
|
||||
<span className="block font-medium">{allowedRole.name}</span>
|
||||
<span className="block text-xs text-gray-400">
|
||||
{allowedRole.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
122
src/components/CreateTeam/Step0.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import BuildingIcon from '../../icons/building.svg';
|
||||
import UsersIcon from '../../icons/users.svg';
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
import { httpPut } from '../../lib/http';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { NextButton } from './NextButton';
|
||||
|
||||
export const validTeamTypes = [
|
||||
{
|
||||
value: 'company',
|
||||
label: 'Company',
|
||||
icon: BuildingIcon,
|
||||
description: 'Use roadmap.sh for your company',
|
||||
},
|
||||
{
|
||||
value: 'study_group',
|
||||
label: 'Study Group',
|
||||
icon: UsersIcon,
|
||||
description: 'Invite your friends and learn together',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type ValidTeamType = (typeof validTeamTypes)[number]['value'];
|
||||
|
||||
type Step0Props = {
|
||||
team?: TeamDocument;
|
||||
selectedTeamType: ValidTeamType;
|
||||
setSelectedTeamType: (teamType: ValidTeamType) => void;
|
||||
onStepComplete: () => void;
|
||||
};
|
||||
|
||||
export function Step0(props: Step0Props) {
|
||||
const { team, selectedTeamType, onStepComplete, setSelectedTeamType } = props;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
async function onNextClick() {
|
||||
if (!team) {
|
||||
onStepComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
const { response, error } = await httpPut(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-update-team/${team._id}`,
|
||||
{
|
||||
name: team.name,
|
||||
website: team?.links?.website || undefined,
|
||||
type: selectedTeamType,
|
||||
gitHubUrl: team?.links?.github || undefined,
|
||||
...(selectedTeamType === 'company' && {
|
||||
teamSize: team.teamSize,
|
||||
linkedInUrl: team?.links?.linkedIn || undefined,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setIsLoading(false);
|
||||
setError(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
setError('');
|
||||
onStepComplete();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'flex flex-row gap-3'}>
|
||||
{validTeamTypes.map((validTeamType) => (
|
||||
<button
|
||||
className={`flex flex-grow flex-col items-center rounded-lg border px-5 py-12 ${
|
||||
validTeamType.value == selectedTeamType
|
||||
? 'border-gray-400 bg-gray-100'
|
||||
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => setSelectedTeamType(validTeamType.value)}
|
||||
>
|
||||
<img
|
||||
alt={validTeamType.label}
|
||||
src={validTeamType.icon}
|
||||
className={`mb-3 h-12 w-12 opacity-10 ${
|
||||
validTeamType.value === selectedTeamType ? 'opacity-100' : ''
|
||||
}`}
|
||||
/>
|
||||
<span className="mb-1 block text-2xl font-bold">
|
||||
{validTeamType.label}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{validTeamType.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/*Error message*/}
|
||||
{error && <div className="mt-4 text-sm text-red-500">{error}</div>}
|
||||
|
||||
<div className="mt-4 flex flex-row items-center justify-between gap-2">
|
||||
<a
|
||||
href="/account"
|
||||
className={
|
||||
'rounded-md border border-red-400 bg-white px-8 py-2 text-red-500'
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
<NextButton
|
||||
type={'button'}
|
||||
onClick={onNextClick}
|
||||
isLoading={isLoading}
|
||||
text={'Next Step'}
|
||||
loadingMessage={'Updating team ..'}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
252
src/components/CreateTeam/Step1.tsx
Normal file
@ -0,0 +1,252 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { AppError, httpPost, httpPut } from '../../lib/http';
|
||||
import type { ValidTeamType } from './Step0';
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
import { NextButton } from './NextButton';
|
||||
|
||||
export const validTeamSizes = [
|
||||
'0-1',
|
||||
'2-10',
|
||||
'11-50',
|
||||
'51-200',
|
||||
'201-500',
|
||||
'501-1000',
|
||||
'1000+',
|
||||
] as const;
|
||||
|
||||
export type ValidTeamSize = (typeof validTeamSizes)[number];
|
||||
|
||||
type Step1Props = {
|
||||
team?: TeamDocument;
|
||||
selectedTeamType: ValidTeamType;
|
||||
onStepComplete: (team: TeamDocument) => void;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
export function Step1(props: Step1Props) {
|
||||
const { team, selectedTeamType, onBack, onStepComplete } = props;
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const nameRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!nameRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
nameRef.current.focus();
|
||||
}, [nameRef]);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [name, setName] = useState(team?.name || '');
|
||||
const [website, setWebsite] = useState(team?.links?.website || '');
|
||||
const [linkedInUrl, setLinkedInUrl] = useState(team?.links?.linkedIn || '');
|
||||
const [gitHubUrl, setGitHubUrl] = useState(team?.links?.github || '');
|
||||
const [teamSize, setTeamSize] = useState<ValidTeamSize>(
|
||||
team?.teamSize || ('' as any)
|
||||
);
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
if (!name || !selectedTeamType) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let response: TeamDocument | undefined;
|
||||
let error: AppError | undefined;
|
||||
|
||||
if (!team?._id) {
|
||||
({ response, error } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-create-team`,
|
||||
{
|
||||
name,
|
||||
website: website || undefined,
|
||||
type: selectedTeamType,
|
||||
gitHubUrl: gitHubUrl || undefined,
|
||||
...(selectedTeamType === 'company' && {
|
||||
teamSize,
|
||||
linkedInUrl: linkedInUrl || undefined,
|
||||
}),
|
||||
roadmapIds: [],
|
||||
bestPracticeIds: [],
|
||||
}
|
||||
));
|
||||
|
||||
if (error || !response?._id) {
|
||||
setError(error?.message || 'Something went wrong. Please try again.');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
onStepComplete(response as TeamDocument);
|
||||
} else {
|
||||
({ response, error } = await httpPut(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-update-team/${team._id}`,
|
||||
{
|
||||
name,
|
||||
website: website || undefined,
|
||||
type: selectedTeamType,
|
||||
gitHubUrl: gitHubUrl || undefined,
|
||||
...(selectedTeamType === 'company' && {
|
||||
teamSize,
|
||||
linkedInUrl: linkedInUrl || undefined,
|
||||
}),
|
||||
}
|
||||
));
|
||||
|
||||
if (error || (response as any)?.status !== 'ok') {
|
||||
setError(error?.message || 'Something went wrong. Please try again.');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
onStepComplete({
|
||||
...team,
|
||||
name,
|
||||
_id: team._id,
|
||||
links: {
|
||||
website: website || team?.links?.website,
|
||||
linkedIn: linkedInUrl || team?.links?.linkedIn,
|
||||
github: gitHubUrl || team?.links?.github,
|
||||
},
|
||||
type: selectedTeamType,
|
||||
teamSize: teamSize!,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="flex w-full flex-col">
|
||||
<label
|
||||
for="name"
|
||||
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
|
||||
>
|
||||
{selectedTeamType === 'company' ? 'Company Name' : 'Group Name'}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
ref={nameRef as any}
|
||||
autofocus={true}
|
||||
id="name"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="roadmap.sh"
|
||||
disabled={isLoading}
|
||||
required
|
||||
value={name}
|
||||
onInput={(e) => setName((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedTeamType === 'company' && (
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label
|
||||
for="website"
|
||||
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
|
||||
>
|
||||
Website
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
name="website"
|
||||
required
|
||||
id="website"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="https://roadmap.sh"
|
||||
disabled={isLoading}
|
||||
value={website}
|
||||
onInput={(e) => setWebsite((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTeamType === 'company' && (
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label for="website" className="text-sm leading-none text-slate-500">
|
||||
LinkedIn URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
name="website"
|
||||
id="website"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="https://www.linkedin.com/company/roadmapsh"
|
||||
disabled={isLoading}
|
||||
value={linkedInUrl}
|
||||
onInput={(e) =>
|
||||
setLinkedInUrl((e.target as HTMLInputElement).value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label for="website" className="text-sm leading-none text-slate-500">
|
||||
GitHub Organization URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
name="website"
|
||||
id="website"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="https://github.com/roadmapsh"
|
||||
disabled={isLoading}
|
||||
value={gitHubUrl}
|
||||
onInput={(e) => setGitHubUrl((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedTeamType === 'company' && (
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label
|
||||
for="team-size"
|
||||
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
|
||||
>
|
||||
Company Size
|
||||
</label>
|
||||
<select
|
||||
name="team-size"
|
||||
id="team-size"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
required={selectedTeamType === 'company'}
|
||||
disabled={isLoading}
|
||||
value={teamSize}
|
||||
onChange={(e) =>
|
||||
setTeamSize((e.target as HTMLSelectElement).value as any)
|
||||
}
|
||||
>
|
||||
<option value="" selected>
|
||||
Select team size
|
||||
</option>
|
||||
{validTeamSizes.map((size) => (
|
||||
<option value={size}>{size} people</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-row items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className={
|
||||
'rounded-md border border-red-400 bg-white px-4 py-2 text-red-500'
|
||||
}
|
||||
>
|
||||
<span className="mr-1">←</span>
|
||||
Previous Step
|
||||
</button>
|
||||
<NextButton
|
||||
isLoading={isLoading}
|
||||
text={'Next Step'}
|
||||
type={'submit'}
|
||||
loadingMessage={'Creating team ..'}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
59
src/components/CreateTeam/Step2.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { RoadmapSelector, TeamResourceConfig } from './RoadmapSelector';
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
|
||||
type Step2Props = {
|
||||
team: TeamDocument;
|
||||
teamResourceConfig: TeamResourceConfig;
|
||||
setTeamResourceConfig: (config: TeamResourceConfig) => void;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
};
|
||||
|
||||
export function Step2(props: Step2Props) {
|
||||
const { team, onBack, onNext, teamResourceConfig, setTeamResourceConfig } =
|
||||
props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<div className="mb-1 mt-2">
|
||||
<h2 className="mb-2 text-2xl font-bold">Select Roadmaps</h2>
|
||||
<p className="text-sm text-gray-700">
|
||||
Picks the roadmaps to be made available to your team for tracking.
|
||||
You can always add more later.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RoadmapSelector
|
||||
team={team}
|
||||
teamResourceConfig={teamResourceConfig}
|
||||
setTeamResourceConfig={setTeamResourceConfig}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-row items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className={
|
||||
'rounded-md border border-red-400 bg-white px-4 py-2 text-red-500'
|
||||
}
|
||||
>
|
||||
<span className="mr-1">←</span>
|
||||
Previous Step
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={teamResourceConfig.length === 0}
|
||||
onClick={onNext}
|
||||
className={
|
||||
'rounded-md border bg-black px-4 py-2 text-white disabled:opacity-50'
|
||||
}
|
||||
>
|
||||
Next Step
|
||||
<span className="ml-1">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
198
src/components/CreateTeam/Step3.tsx
Normal file
@ -0,0 +1,198 @@
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
import { NextButton } from './NextButton';
|
||||
import { TrashIcon } from '../ReactIcons/TrashIcon';
|
||||
import { AllowedRoles, RoleDropdown } from './RoleDropdown';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
|
||||
type Step3Props = {
|
||||
team?: TeamDocument;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
type InviteType = {
|
||||
id: string;
|
||||
email: string;
|
||||
role: AllowedRoles;
|
||||
};
|
||||
|
||||
function generateId() {
|
||||
return `${new Date().getTime()}`;
|
||||
}
|
||||
|
||||
export function Step3(props: Step3Props) {
|
||||
const { onNext, onBack, team } = props;
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [invitingTeam, setInvitingTeam] = useState(false);
|
||||
const emailInputRef = useRef(null);
|
||||
|
||||
const [users, setUsers] = useState<InviteType[]>([
|
||||
{
|
||||
id: generateId(),
|
||||
email: '',
|
||||
role: 'member',
|
||||
},
|
||||
]);
|
||||
|
||||
async function inviteTeam() {
|
||||
setInvitingTeam(true);
|
||||
const { error, response } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-invite-team/${team?._id}`,
|
||||
{
|
||||
members: users,
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Something went wrong');
|
||||
setInvitingTeam(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
onNext();
|
||||
}
|
||||
|
||||
function focusLastEmailInput() {
|
||||
if (!emailInputRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
(emailInputRef.current as HTMLInputElement).focus();
|
||||
}
|
||||
|
||||
function onSubmit(e: any) {
|
||||
e.preventDefault();
|
||||
|
||||
inviteTeam().finally(() => null);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
focusLastEmailInput();
|
||||
}, [users.length]);
|
||||
|
||||
return (
|
||||
<form className="mt-4 flex w-full flex-col" onSubmit={onSubmit}>
|
||||
<div class="mb-1 mt-2">
|
||||
<h2 class="mb-2 text-2xl font-bold">Invite your Team</h2>
|
||||
<p class="text-sm text-gray-700">
|
||||
Use the form below to invite your team members to your team. You can
|
||||
also invite them later.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-1">
|
||||
{users.map((user, userCounter) => {
|
||||
return (
|
||||
<div className="flex flex-row gap-2" key={user.id}>
|
||||
<input
|
||||
ref={userCounter === users.length - 1 ? emailInputRef : null}
|
||||
autofocus={true}
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
id="email"
|
||||
placeholder="Email"
|
||||
value={user.email}
|
||||
onChange={(e) => {
|
||||
const newUsers = users.map((u) => {
|
||||
if (u.id === user.id) {
|
||||
return {
|
||||
...u,
|
||||
email: (e.target as HTMLInputElement)?.value,
|
||||
};
|
||||
}
|
||||
|
||||
return u;
|
||||
});
|
||||
|
||||
setUsers(newUsers);
|
||||
}}
|
||||
className="flex-grow rounded-md border border-gray-200 bg-white px-4 py-2 text-gray-900"
|
||||
/>
|
||||
<RoleDropdown
|
||||
selectedRole={user.role}
|
||||
setSelectedRole={(role: AllowedRoles) => {
|
||||
const newUsers = users.map((u) => {
|
||||
if (u.id === user.id) {
|
||||
return {
|
||||
...u,
|
||||
role,
|
||||
};
|
||||
}
|
||||
|
||||
return u;
|
||||
});
|
||||
|
||||
setUsers(newUsers);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
disabled={users.length <= 1}
|
||||
type="button"
|
||||
className="rounded-md border border-red-200 bg-white px-4 py-2 text-red-500 hover:bg-red-100 disabled:opacity-30"
|
||||
onClick={() => {
|
||||
setUsers(users.filter((u) => u.id !== user.id));
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{users.length <= 30 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setUsers([
|
||||
...users,
|
||||
{ id: generateId(), email: '', role: 'member' },
|
||||
]);
|
||||
}}
|
||||
type="button"
|
||||
className="mt-2 rounded-md border border-dashed border-gray-400 py-2 text-sm text-gray-500 hover:border-gray-500 hover:text-gray-800"
|
||||
>
|
||||
+ Add another
|
||||
</button>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mt-2 text-sm font-medium text-red-500" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-row items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className={
|
||||
'rounded-md border border-red-400 bg-white px-4 py-2 text-red-500'
|
||||
}
|
||||
>
|
||||
<span className="mr-1">←</span>
|
||||
Previous Step
|
||||
</button>
|
||||
<div className={'flex gap-2'}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
className={
|
||||
'rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-500 hover:border-gray-400 hover:text-black'
|
||||
}
|
||||
>
|
||||
Skip for Now
|
||||
</button>
|
||||
<NextButton
|
||||
type={'submit'}
|
||||
isLoading={invitingTeam}
|
||||
text={'Send Invites'}
|
||||
loadingMessage={'Updating team ..'}
|
||||
hasNextArrow={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
26
src/components/CreateTeam/Step4.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { CheckIcon } from '../ReactIcons/CheckIcon';
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
|
||||
type Step4Props = {
|
||||
team: TeamDocument;
|
||||
};
|
||||
|
||||
export function Step4({ team }: Step4Props) {
|
||||
return (
|
||||
<div className="mt-4 flex flex-col rounded-xl border py-12 text-center">
|
||||
<div class="mb-1 flex flex-col items-center">
|
||||
<CheckIcon additionalClasses={'h-14 w-14 mb-4 opacity-100'} />
|
||||
<h2 class="mb-2 text-2xl font-bold">Team Created</h2>
|
||||
<p class="text-sm text-gray-700">
|
||||
Your team has been created. Happy learning!
|
||||
</p>
|
||||
<a
|
||||
href={`/team/progress?t=${team._id}`}
|
||||
class="mt-4 rounded-md bg-black px-5 py-1.5 text-sm text-white"
|
||||
>
|
||||
View Team
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
206
src/components/CreateTeam/UpdateTeamResourceModal.tsx
Normal file
@ -0,0 +1,206 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { wireframeJSONToSVG } from 'roadmap-renderer';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import { httpGet, httpPut } from '../../lib/http';
|
||||
import { renderTopicProgress } from '../../lib/resource-progress';
|
||||
import '../FrameRenderer/FrameRenderer.css';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import type { TeamResourceConfig } from './RoadmapSelector';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
export type ProgressMapProps = {
|
||||
teamId: string;
|
||||
resourceId: string;
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
defaultRemovedItems?: string[];
|
||||
setTeamResourceConfig: (config: TeamResourceConfig) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function UpdateTeamResourceModal(props: ProgressMapProps) {
|
||||
const {
|
||||
defaultRemovedItems = [],
|
||||
resourceId,
|
||||
resourceType,
|
||||
teamId,
|
||||
setTeamResourceConfig,
|
||||
onClose,
|
||||
} = props;
|
||||
|
||||
const containerEl = useRef<HTMLDivElement>(null);
|
||||
const popupBodyEl = useRef<HTMLDivElement>(null);
|
||||
|
||||
const toast = useToast();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const [removedItems, setRemovedItems] =
|
||||
useState<string[]>(defaultRemovedItems);
|
||||
|
||||
useEffect(() => {
|
||||
function onTopicClick(e: any) {
|
||||
const groupEl = e.target.closest('.clickable-group');
|
||||
const groupId = groupEl?.dataset?.groupId;
|
||||
|
||||
if (!groupId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedGroupId = groupId.replace(/^\d+-/, '');
|
||||
if (removedItems.includes(normalizedGroupId)) {
|
||||
setRemovedItems((prev) =>
|
||||
prev.filter((id) => id !== normalizedGroupId)
|
||||
);
|
||||
renderTopicProgress(normalizedGroupId, 'reset' as any);
|
||||
} else {
|
||||
setRemovedItems((prev) => [...prev, normalizedGroupId]);
|
||||
renderTopicProgress(normalizedGroupId, 'removed');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', onTopicClick);
|
||||
return () => {
|
||||
document.removeEventListener('click', onTopicClick);
|
||||
};
|
||||
}, [removedItems]);
|
||||
|
||||
let resourceJsonUrl = 'https://roadmap.sh';
|
||||
if (resourceType === 'roadmap') {
|
||||
resourceJsonUrl += `/${resourceId}.json`;
|
||||
} else {
|
||||
resourceJsonUrl += `/best-practices/${resourceId}.json`;
|
||||
}
|
||||
|
||||
async function renderResource(jsonUrl: string) {
|
||||
const res = await fetch(jsonUrl);
|
||||
const json = await res.json();
|
||||
const svg = await wireframeJSONToSVG(json, {
|
||||
fontURL: '/fonts/balsamiq.woff2',
|
||||
});
|
||||
|
||||
containerEl.current?.replaceChildren(svg);
|
||||
|
||||
// Render team configuration
|
||||
removedItems.forEach((topicId: string) => {
|
||||
renderTopicProgress(topicId, 'removed');
|
||||
});
|
||||
}
|
||||
|
||||
useKeydown('Escape', () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
useOutsideClick(popupBodyEl, () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
async function onSaveChanges() {
|
||||
if (removedItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdating(true);
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-update-team-resource-config/${teamId}`,
|
||||
{
|
||||
teamId: teamId,
|
||||
resourceId: resourceId,
|
||||
resourceType: resourceType,
|
||||
removed: removedItems,
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Error adding roadmap');
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamResourceConfig(response);
|
||||
onClose();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!containerEl.current ||
|
||||
!resourceJsonUrl ||
|
||||
!resourceId ||
|
||||
!resourceType ||
|
||||
!teamId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
renderResource(resourceJsonUrl)
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
toast.error('Something went wrong. Please try again!');
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div class="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
|
||||
<div class="relative mx-auto h-full w-full max-w-4xl p-4 md:h-auto">
|
||||
<div
|
||||
ref={popupBodyEl}
|
||||
class="popup-body relative rounded-lg bg-white shadow"
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'sticky top-0 mb-3 rounded-2xl border-4 border-white bg-black p-4'
|
||||
}
|
||||
>
|
||||
<p className="mb-2 text-gray-300">
|
||||
Click and select the items to remove from the roadmap.
|
||||
</p>
|
||||
<div className="flex flex-row items-center gap-1.5">
|
||||
<button
|
||||
disabled={removedItems.length === 0}
|
||||
onClick={() =>
|
||||
onSaveChanges().finally(() => setIsUpdating(false))
|
||||
}
|
||||
className={
|
||||
'rounded-md bg-blue-600 px-2.5 py-1.5 text-sm text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-400'
|
||||
}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<span className={'flex items-center gap-1.5'}>
|
||||
<Spinner
|
||||
className="h-3 w-3"
|
||||
innerFill="white"
|
||||
isDualRing={false}
|
||||
/>{' '}
|
||||
Saving ..
|
||||
</span>
|
||||
) : (
|
||||
'Save Changes'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md bg-gray-600 px-2.5 py-1.5 text-sm text-white hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={containerEl} className="px-4"></div>
|
||||
|
||||
{isLoading && (
|
||||
<div class="flex w-full justify-center">
|
||||
<Spinner
|
||||
isDualRing={false}
|
||||
className="mb-4 mt-2 h-4 w-4 animate-spin fill-blue-600 text-gray-200 sm:h-8 sm:w-8"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
131
src/components/DeleteTeamPopup.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { httpDelete } from '../lib/http';
|
||||
import type { TeamDocument } from './CreateTeam/CreateTeamForm';
|
||||
import { useTeamId } from '../hooks/use-team-id';
|
||||
import { useOutsideClick } from '../hooks/use-outside-click';
|
||||
import { useKeydown } from '../hooks/use-keydown';
|
||||
|
||||
type DeleteTeamPopupProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function DeleteTeamPopup(props: DeleteTeamPopupProps) {
|
||||
const { onClose } = props;
|
||||
|
||||
const popupBodyEl = useRef<HTMLDivElement>(null);
|
||||
const inputEl = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [confirmationText, setConfirmationText] = useState('');
|
||||
const { teamId } = useTeamId();
|
||||
|
||||
useOutsideClick(popupBodyEl, () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
useKeydown('Escape', () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
inputEl.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
if (confirmationText.toUpperCase() !== 'DELETE') {
|
||||
setError('Verification text does not match');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { response, error } = await httpDelete<TeamDocument>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-delete-team/${teamId}`
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setIsLoading(false);
|
||||
setError(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = '/account';
|
||||
};
|
||||
|
||||
const handleClosePopup = () => {
|
||||
setIsLoading(false);
|
||||
setError('');
|
||||
setConfirmationText('');
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="fixed left-0 right-0 top-0 z-50 flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
|
||||
<div class="relative h-full w-full max-w-md p-4 md:h-auto">
|
||||
<div
|
||||
ref={popupBodyEl}
|
||||
class="popup-body relative rounded-lg bg-white p-4 shadow"
|
||||
>
|
||||
<p>
|
||||
This will permanently delete your account and all your associated
|
||||
data including your progress.
|
||||
</p>
|
||||
|
||||
<p class="-mb-2 mt-3 text-base font-medium text-black">
|
||||
Please type "delete" to confirm.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="my-4">
|
||||
<input
|
||||
ref={inputEl}
|
||||
type="text"
|
||||
name="delete-account"
|
||||
id="delete-account"
|
||||
className="mt-2 block w-full rounded-md border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:border-gray-400"
|
||||
placeholder={'Type "delete" to confirm'}
|
||||
required
|
||||
autoFocus
|
||||
value={confirmationText}
|
||||
onInput={(e) =>
|
||||
setConfirmationText((e.target as HTMLInputElement).value)
|
||||
}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
onClick={handleClosePopup}
|
||||
className="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
isLoading || confirmationText.toUpperCase() !== 'DELETE'
|
||||
}
|
||||
className="flex-grow cursor-pointer rounded-lg bg-red-500 py-2 text-white disabled:opacity-40"
|
||||
>
|
||||
{isLoading ? 'Please wait ..' : 'Confirm'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
@ -5,6 +5,7 @@ import { isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { FavoriteIcon } from './FavoriteIcon';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
type MarkFavoriteType = {
|
||||
resourceType: ResourceType;
|
||||
@ -21,6 +22,7 @@ export function MarkFavorite({
|
||||
}: MarkFavoriteType) {
|
||||
const localStorageKey = `${resourceType}-${resourceId}-favorite`;
|
||||
|
||||
const toast = useToast();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isFavorite, setIsFavorite] = useState(
|
||||
favorite ?? localStorage.getItem(localStorageKey) === '1'
|
||||
@ -49,7 +51,8 @@ export function MarkFavorite({
|
||||
|
||||
if (error) {
|
||||
setIsLoading(false);
|
||||
return alert('Failed to update favorite status');
|
||||
toast.error('Failed to update favorite status');
|
||||
return;
|
||||
}
|
||||
|
||||
// Dispatching an event instead of setting the state because
|
||||
|
@ -72,14 +72,14 @@ import Icon from './AstroIcon.astro';
|
||||
target='_blank'
|
||||
class='hover:text-white'
|
||||
>
|
||||
<AstroIcon icon='youtube' class='inline-block h-4 w-4' />
|
||||
<AstroIcon icon='youtube' class='inline-block h-5 w-5' />
|
||||
</a>
|
||||
<a
|
||||
href='https://twitter.com/roadmapsh'
|
||||
target='_blank'
|
||||
class='ml-1.5 hover:text-white'
|
||||
class='ml-2 hover:text-white'
|
||||
>
|
||||
<AstroIcon icon='twitter-fill' class='inline-block h-3.5 w-3.5 fill-current' />
|
||||
<AstroIcon icon='twitter-fill' class='inline-block h-5 w-5 fill-current' />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
@ -79,6 +79,19 @@ svg .clickable-group.done[data-group-id^='check:'] rect {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
svg .removed rect {
|
||||
fill: #fdfdfd !important;
|
||||
stroke: #c4c4c4 !important;
|
||||
}
|
||||
|
||||
svg .removed text {
|
||||
fill: #9c9c9c !important;
|
||||
}
|
||||
|
||||
svg .removed g, svg .removed circle, svg .removed path {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/************************************
|
||||
Aspect ratio implementation
|
||||
*************************************/
|
||||
|
@ -206,14 +206,18 @@ export class Renderer {
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetGroup.classList.contains('removed')) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const isCurrentStatusDone = targetGroup.classList.contains('done');
|
||||
const normalizedGroupId = groupId.replace(/^\d+-/, '');
|
||||
this.updateTopicStatus(
|
||||
normalizedGroupId,
|
||||
!isCurrentStatusDone ? 'done' : 'pending'
|
||||
);
|
||||
normalizedGroupId,
|
||||
!isCurrentStatusDone ? 'done' : 'pending'
|
||||
);
|
||||
}
|
||||
|
||||
handleSvgClick(e: any) {
|
||||
@ -225,6 +229,10 @@ export class Renderer {
|
||||
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
if (targetGroup.classList.contains('removed')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (/^ext_link/.test(groupId)) {
|
||||
const externalLink = groupId.replace('ext_link:', '');
|
||||
|
||||
|
@ -18,7 +18,7 @@ import Icon from '../AstroIcon.astro';
|
||||
</button>
|
||||
|
||||
<div
|
||||
class='absolute right-0 z-10 mt-2 hidden w-48 rounded-md bg-slate-800 py-1 shadow-xl'
|
||||
class='absolute right-0 z-50 mt-2 hidden w-48 rounded-md bg-slate-800 py-1 shadow-xl'
|
||||
data-account-dropdown
|
||||
>
|
||||
<ul>
|
||||
|
@ -17,6 +17,7 @@ function bindEvents() {
|
||||
|
||||
// If the user clicks on the logout button, remove the token cookie
|
||||
if (dataset.logoutButton !== undefined) {
|
||||
e.preventDefault();
|
||||
logout();
|
||||
} else if (dataset.showMobileNav !== undefined) {
|
||||
document.querySelector('[data-mobile-nav]')?.classList.remove('hidden');
|
||||
|
109
src/components/Notification/NotificationPage.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { httpGet, httpPatch, httpPost } from '../../lib/http';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import type { TeamMemberDocument } from '../TeamMembers/TeamMembersPage';
|
||||
import XIcon from '../../icons/close-dark.svg';
|
||||
import AcceptIcon from '../../icons/accept.svg';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
interface NotificationList extends TeamMemberDocument {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function NotificationPage() {
|
||||
const toast = useToast();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [notifications, setNotifications] = useState<NotificationList[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const lostNotifications = async () => {
|
||||
const { error, response } = await httpGet<NotificationList[]>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-invitation-list`
|
||||
);
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
setNotifications(response);
|
||||
};
|
||||
|
||||
async function respondInvitation(status: 'accept' | 'reject', inviteId: string) {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
const { response, error } = await httpPatch<{ teamId: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-respond-invite/${inviteId}`, {
|
||||
status
|
||||
});
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Something went wrong')
|
||||
setIsLoading(false)
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 'accept') {
|
||||
window.location.href = `/team/progress?t=${response.teamId}`;
|
||||
} else {
|
||||
window.dispatchEvent(new CustomEvent('refresh-notification', {
|
||||
detail: {
|
||||
count: notifications.length - 1
|
||||
}
|
||||
}));
|
||||
setNotifications(notifications.filter((notification) => notification._id !== inviteId));
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
lostNotifications().finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div class="mb-8 hidden md:block">
|
||||
<h2 className="text-3xl font-bold sm:text-4xl">Notification</h2>
|
||||
<p className="mt-2 text-gray-400">Manage your notifications</p>
|
||||
</div>
|
||||
{
|
||||
notifications.length === 0 && (
|
||||
<div className="flex items-center justify-center mt-6">
|
||||
<p className="text-gray-400">
|
||||
No notifications, you can <a href="/team/new" className="text-blue-500 underline hover:no-underline">create a team</a> and invite your friends to join.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className="space-y-4">
|
||||
{notifications.map((notification) => (
|
||||
<div className="flex items-center justify-between rounded-md border p-2">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-gray-900">
|
||||
{notification.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button type="button"
|
||||
disabled={isLoading}
|
||||
className="inline-flex border p-1 rounded hover:bg-gray-50 disabled:opacity-75"
|
||||
onClick={() => respondInvitation('accept', notification?._id!)}
|
||||
>
|
||||
<img src={AcceptIcon} className="h-4 w-4" />
|
||||
</button>
|
||||
<button type="button"
|
||||
disabled={isLoading}
|
||||
className="inline-flex border p-1 rounded hover:bg-gray-50 disabled:opacity-75"
|
||||
onClick={() => respondInvitation('reject', notification?._id!)}
|
||||
>
|
||||
<img src={XIcon} className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -36,7 +36,8 @@ export function PageSponsor(props: PageSponsorProps) {
|
||||
currentPath === '/roadmaps' ||
|
||||
currentPath.startsWith('/guides') ||
|
||||
currentPath.startsWith('/videos') ||
|
||||
currentPath.startsWith('/account')
|
||||
currentPath.startsWith('/account') ||
|
||||
currentPath.startsWith('/team')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
@ -7,7 +7,7 @@ export function CheckIcon(props: CheckIconProps) {
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={`relative ${additionalClasses}]`}
|
||||
className={`relative ${additionalClasses}`}
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
stroke-width="0"
|
||||
|
26
src/components/ReactIcons/ChevronDownIcon.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
type ChevronDownIconProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ChevronDownIcon(props: ChevronDownIconProps) {
|
||||
const { className } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
41
src/components/ReactIcons/ErrorIcon.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
type ErrorIconProps = {
|
||||
additionalClasses?: string;
|
||||
};
|
||||
|
||||
export function ErrorIcon(props: ErrorIconProps) {
|
||||
const { additionalClasses = 'mr-2 top-[0.5px] w-[20px] h-[20px]' } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={`relative ${additionalClasses}`}
|
||||
>
|
||||
<path
|
||||
d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M15 9L9 15"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9 9L15 15"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
41
src/components/ReactIcons/InfoIcon.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
type InfoIconProps = {
|
||||
additionalClasses?: string;
|
||||
};
|
||||
|
||||
export function InfoIcon(props: InfoIconProps) {
|
||||
const { additionalClasses = 'mr-2 top-[0.5px] w-[20px] h-[20px]' } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={`relative ${additionalClasses}`}
|
||||
>
|
||||
<path
|
||||
d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 16V12"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 8H12.01"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
@ -1,20 +1,37 @@
|
||||
export function Spinner() {
|
||||
type SpinnerProps = {
|
||||
className?: string;
|
||||
isDualRing?: boolean;
|
||||
outerFill?: string;
|
||||
innerFill?: string;
|
||||
};
|
||||
|
||||
export function Spinner({
|
||||
className = '',
|
||||
isDualRing = true,
|
||||
outerFill = '#404040',
|
||||
innerFill = '#94a3b8',
|
||||
}: SpinnerProps) {
|
||||
|
||||
className += className?.includes('w-') ? '' : ' w-3.5 h-3.5';
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="h-3.5 w-3.5 animate-spin"
|
||||
className={`animate-spin ${className ?? ''}`}
|
||||
viewBox="0 0 93 93"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M46.5 93C72.1812 93 93 72.1812 93 46.5C93 20.8188 72.1812 0 46.5 0C20.8188 0 0 20.8188 0 46.5C0 72.1812 20.8188 93 46.5 93ZM46.5 77C63.3447 77 77 63.3447 77 46.5C77 29.6553 63.3447 16 46.5 16C29.6553 16 16 29.6553 16 46.5C16 63.3447 29.6553 77 46.5 77Z"
|
||||
style="fill: #404040;"
|
||||
></path>
|
||||
{isDualRing && (
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M46.5 93C72.1812 93 93 72.1812 93 46.5C93 20.8188 72.1812 0 46.5 0C20.8188 0 0 20.8188 0 46.5C0 72.1812 20.8188 93 46.5 93ZM46.5 77C63.3447 77 77 63.3447 77 46.5C77 29.6553 63.3447 16 46.5 16C29.6553 16 16 29.6553 16 46.5C16 63.3447 29.6553 77 46.5 77Z"
|
||||
style={`fill: ${outerFill};`}
|
||||
></path>
|
||||
)}
|
||||
<path
|
||||
d="M84.9746 49.5667C89.3257 49.9135 93.2042 46.6479 92.81 42.3008C92.3588 37.3251 91.1071 32.437 89.0872 27.8298C86.0053 20.7998 81.2311 14.6422 75.1905 9.90623C69.15 5.17027 62.031 2.00329 54.4687 0.687889C49.5126 -0.174203 44.467 -0.223422 39.5274 0.525737C35.2118 1.18024 32.966 5.72596 34.3411 9.86865V9.86865C35.7161 14.0113 40.2118 16.1424 44.5681 15.8677C46.9635 15.7166 49.3773 15.8465 51.7599 16.2609C56.7515 17.1291 61.4505 19.2196 65.4377 22.3456C69.4249 25.4717 72.5762 29.5362 74.6105 34.1764C75.5815 36.3912 76.2835 38.7044 76.7084 41.0666C77.4811 45.3626 80.6234 49.2199 84.9746 49.5667V49.5667Z"
|
||||
style="fill: #94a3b8;"
|
||||
style={`fill: ${innerFill};`}
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
|
27
src/components/ReactIcons/TrashIcon.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
type TrashIconProps = {
|
||||
className?: string;
|
||||
};
|
||||
export function TrashIcon(props: TrashIconProps) {
|
||||
const { className = '' } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
<line x1="10" x2="10" y1="11" y2="17" />
|
||||
<line x1="14" x2="14" y1="11" y2="17" />
|
||||
</svg>
|
||||
);
|
||||
}
|
41
src/components/ReactIcons/WarningIcon.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
type WarningIconProps = {
|
||||
additionalClasses?: string;
|
||||
};
|
||||
|
||||
export function WarningIcon(props: WarningIconProps) {
|
||||
const { additionalClasses = 'mr-2 top-[0.5px] w-[20px] h-[20px]' } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={`relative ${additionalClasses}`}
|
||||
>
|
||||
<path
|
||||
d="M21.7304 18.0002L13.7304 4.00022C13.556 3.69243 13.303 3.43641 12.9973 3.25829C12.6917 3.08017 12.3442 2.98633 11.9904 2.98633C11.6366 2.98633 11.2892 3.08017 10.9835 3.25829C10.6778 3.43641 10.4249 3.69243 10.2504 4.00022L2.25042 18.0002C2.0741 18.3056 1.98165 18.6521 1.98243 19.0047C1.98321 19.3573 2.0772 19.7035 2.25486 20.008C2.43253 20.3126 2.68757 20.5648 2.99411 20.7391C3.30066 20.9133 3.64783 21.0034 4.00042 21.0002H20.0004C20.3513 20.9999 20.6959 20.9072 20.9997 20.7315C21.3035 20.5558 21.5556 20.3033 21.7309 19.9993C21.9062 19.6954 21.9985 19.3506 21.9984 18.9997C21.9983 18.6488 21.9059 18.3041 21.7304 18.0002Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 9V13"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 17H12.01"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
176
src/components/RespondInviteForm.tsx
Normal file
@ -0,0 +1,176 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { httpGet, httpPatch } from '../lib/http';
|
||||
import BuildingIcon from '../icons/building.svg';
|
||||
import ErrorIcon from '../icons/error.svg';
|
||||
import { pageProgressMessage } from '../stores/page';
|
||||
import type { TeamDocument } from './CreateTeam/CreateTeamForm';
|
||||
import type { AllowedRoles } from './CreateTeam/RoleDropdown';
|
||||
import type { AllowedMemberStatus } from './TeamDropdown/TeamDropdown';
|
||||
import { isLoggedIn } from '../lib/jwt';
|
||||
import { showLoginPopup } from '../lib/popup';
|
||||
import { getUrlParams } from '../lib/browser';
|
||||
|
||||
type InvitationResponse = {
|
||||
team: TeamDocument;
|
||||
invite: {
|
||||
_id?: string;
|
||||
userId?: string;
|
||||
invitedEmail?: string;
|
||||
teamId: string;
|
||||
role: AllowedRoles;
|
||||
status: AllowedMemberStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
};
|
||||
|
||||
export function RespondInviteForm() {
|
||||
const { i: inviteId } = getUrlParams();
|
||||
|
||||
const [isLoadingInvite, setIsLoadingInvite] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [invite, setInvite] = useState<InvitationResponse>();
|
||||
const isAuthenticated = isLoggedIn();
|
||||
|
||||
async function loadInvitation(inviteId: string) {
|
||||
const { response, error } = await httpGet<InvitationResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-invitation/${inviteId}`
|
||||
);
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
setInvite(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (inviteId) {
|
||||
loadInvitation(inviteId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
setIsLoadingInvite(false);
|
||||
});
|
||||
} else {
|
||||
setIsLoadingInvite(false);
|
||||
setError('Missing invite ID in URL');
|
||||
pageProgressMessage.set('');
|
||||
}
|
||||
}, [inviteId]);
|
||||
|
||||
async function respondInvitation(status: 'accept' | 'reject') {
|
||||
pageProgressMessage.set('Please wait...');
|
||||
setError('');
|
||||
const { response, error } = await httpPatch<{ teamId: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-respond-invite/${inviteId}`,
|
||||
{
|
||||
status,
|
||||
}
|
||||
);
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 'reject') {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
window.location.href = `/team/progress?t=${response.teamId}`;
|
||||
}
|
||||
|
||||
if (isLoadingInvite) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!invite) {
|
||||
return (
|
||||
<div className="container text-center">
|
||||
<img
|
||||
alt={'error'}
|
||||
src={ErrorIcon}
|
||||
className="mx-auto mb-4 mt-24 w-20 opacity-20"
|
||||
/>
|
||||
|
||||
<h2 className={'mb-1 text-2xl font-bold'}>Error</h2>
|
||||
<p class="mb-4 text-base leading-6 text-gray-600">
|
||||
{error || 'There was a problem, please try again.'}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<a
|
||||
href="/"
|
||||
className="flex-grow cursor-pointer rounded-lg bg-gray-200 px-3 py-2 text-center"
|
||||
>
|
||||
Back to home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container text-center">
|
||||
<img
|
||||
alt={'join team'}
|
||||
src={BuildingIcon}
|
||||
className="mx-auto mb-4 mt-24 w-20 opacity-20"
|
||||
/>
|
||||
|
||||
<h2 className={'mb-1 text-2xl font-bold'}>Join Team</h2>
|
||||
<p class="mb-3 text-base leading-6 text-gray-600">
|
||||
You have been invited to join the team{' '}
|
||||
<strong id="team-name">{invite?.team?.name}</strong>.
|
||||
</p>
|
||||
|
||||
{!isAuthenticated && (
|
||||
<div class="mx-auto w-full duration-500 sm:max-w-md">
|
||||
<div class="flex w-full items-center gap-2">
|
||||
<button
|
||||
onClick={() => showLoginPopup()}
|
||||
data-popup="login-popup"
|
||||
type="button"
|
||||
class="flex-grow cursor-pointer rounded-lg bg-gray-200 px-3 py-2 text-center"
|
||||
>
|
||||
Login to respond
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAuthenticated && (
|
||||
<div className={`mx-auto w-full max-w-md`}>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
respondInvitation('accept').finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
})
|
||||
}
|
||||
className="flex-grow cursor-pointer rounded-lg bg-gray-200 px-3 py-2 text-center"
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
respondInvitation('reject').finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
})
|
||||
}
|
||||
className="flex-grow cursor-pointer rounded-lg bg-red-500 px-3 py-2 text-white disabled:opacity-40"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -7,6 +7,7 @@ import TopicSearch from './TopicSearch/TopicSearch.astro';
|
||||
import YouTubeAlert from './YouTubeAlert.astro';
|
||||
import ProgressHelpPopup from './ProgressHelpPopup.astro';
|
||||
import { MarkFavorite } from './FeaturedItems/MarkFavorite';
|
||||
import { TeamVersions } from './TeamVersions/TeamVersions';
|
||||
|
||||
export interface Props {
|
||||
title: string;
|
||||
@ -36,67 +37,67 @@ const isRoadmapReady = !isUpcoming;
|
||||
<LoginPopup />
|
||||
<ProgressHelpPopup />
|
||||
|
||||
<div class="border-b">
|
||||
<div class="container relative py-5 sm:py-12">
|
||||
<div class="mb-3 mt-0 sm:mb-4">
|
||||
<h1 class="mb-0.5 text-2xl font-bold sm:mb-2 sm:text-4xl">
|
||||
<div class='border-b'>
|
||||
<div class='container relative py-5 sm:py-12'>
|
||||
<div class='mb-3 mt-0 sm:mb-4'>
|
||||
<h1 class='mb-0.5 text-2xl font-bold sm:mb-2 sm:text-4xl'>
|
||||
{title}
|
||||
<MarkFavorite
|
||||
resourceId={roadmapId}
|
||||
resourceType="roadmap"
|
||||
className="text-gray-500 !opacity-100 hover:text-gray-600 [&>svg]:stroke-[0.4] [&>svg]:stroke-gray-400 hover:[&>svg]:stroke-gray-600 [&>svg]:h-4 [&>svg]:w-4 sm:[&>svg]:h-5 sm:[&>svg]:w-5 ml-1.5 relative focus:outline-0"
|
||||
resourceType='roadmap'
|
||||
className='text-gray-500 !opacity-100 hover:text-gray-600 [&>svg]:stroke-[0.4] [&>svg]:stroke-gray-400 hover:[&>svg]:stroke-gray-600 [&>svg]:h-4 [&>svg]:w-4 sm:[&>svg]:h-5 sm:[&>svg]:w-5 ml-1.5 relative focus:outline-0'
|
||||
client:load
|
||||
/>
|
||||
</h1>
|
||||
<p class="text-sm text-gray-500 sm:text-lg">{description}</p>
|
||||
<p class='text-sm text-gray-500 sm:text-lg'>{description}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<div class="flex gap-1 sm:gap-2">
|
||||
<div class='flex justify-between gap-2 sm:gap-0'>
|
||||
<div class='flex gap-1 sm:gap-2'>
|
||||
{
|
||||
!hasSearch && (
|
||||
<>
|
||||
<a
|
||||
href="/roadmaps"
|
||||
class="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"
|
||||
href='/roadmaps'
|
||||
class='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 class="hidden sm:inline"> All Roadmaps</span>
|
||||
←<span class='hidden sm:inline'> All Roadmaps</span>
|
||||
</a>
|
||||
|
||||
{isRoadmapReady && (
|
||||
<>
|
||||
<button
|
||||
data-guest-required
|
||||
data-popup="login-popup"
|
||||
class="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="Download Roadmap"
|
||||
data-popup='login-popup'
|
||||
class='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='Download Roadmap'
|
||||
>
|
||||
<Icon icon="download" />
|
||||
<span class="ml-2 hidden sm:inline">Download</span>
|
||||
<Icon icon='download' />
|
||||
<span class='ml-2 hidden sm:inline'>Download</span>
|
||||
</button>
|
||||
|
||||
<a
|
||||
data-auth-required
|
||||
class="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="Download Roadmap"
|
||||
target="_blank"
|
||||
class='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='Download Roadmap'
|
||||
target='_blank'
|
||||
href={`/pdfs/roadmaps/${roadmapId}.pdf`}
|
||||
>
|
||||
<Icon icon="download" />
|
||||
<span class="ml-2 hidden sm:inline">Download</span>
|
||||
<Icon icon='download' />
|
||||
<span class='ml-2 hidden sm:inline'>Download</span>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
data-guest-required
|
||||
data-popup="login-popup"
|
||||
class="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"
|
||||
data-popup='login-popup'
|
||||
class='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'
|
||||
>
|
||||
<Icon icon="email" />
|
||||
<span class="ml-2">Subscribe</span>
|
||||
<Icon icon='email' />
|
||||
<span class='ml-2'>Subscribe</span>
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
@ -106,30 +107,38 @@ const isRoadmapReady = !isUpcoming;
|
||||
hasSearch && (
|
||||
<a
|
||||
href={`/${roadmapId}`}
|
||||
class="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 Visual Roadmap"
|
||||
class='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 Visual Roadmap'
|
||||
>
|
||||
←
|
||||
<span class="inline"> Visual Roadmap</span>
|
||||
<span class='inline'> Visual Roadmap</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
{
|
||||
isRoadmapReady && (
|
||||
<a
|
||||
href={`https://github.com/kamranahmedse/developer-roadmap/issues/new?title=[Suggestion] ${title}`}
|
||||
target="_blank"
|
||||
class="inline-flex items-center justify-center 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="Suggest Changes"
|
||||
>
|
||||
<Icon icon="comment" class="h-3 w-3" />
|
||||
<span class="ml-2 hidden sm:inline">Suggest Changes</span>
|
||||
<span class="ml-2 inline sm:hidden">Suggest</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
<div class='flex items-center gap-1 sm:gap-2'>
|
||||
<TeamVersions
|
||||
resourceType='roadmap'
|
||||
resourceId={roadmapId}
|
||||
client:only
|
||||
/>
|
||||
|
||||
{
|
||||
isRoadmapReady && (
|
||||
<a
|
||||
href={`https://github.com/kamranahmedse/developer-roadmap/issues/new?title=[Suggestion] ${title}`}
|
||||
target='_blank'
|
||||
class='inline-flex items-center justify-center 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='Suggest Changes'
|
||||
>
|
||||
<Icon icon='comment' class='h-3 w-3' />
|
||||
<span class='ml-2 hidden sm:inline'>Suggest Changes</span>
|
||||
<span class='ml-2 inline sm:hidden'>Suggest</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: Roadmap Resources - Alert -->
|
||||
|
156
src/components/SearchSelector.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
|
||||
export type OptionType = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export function SearchSelector({
|
||||
options,
|
||||
onSelect,
|
||||
inputClassName,
|
||||
searchInputId,
|
||||
placeholder,
|
||||
}: {
|
||||
options: OptionType[];
|
||||
onSelect: (data: OptionType) => void;
|
||||
inputClassName?: string;
|
||||
searchInputId?: string;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<OptionType[]>([]);
|
||||
const [searchedText, setSearchedText] = useState('');
|
||||
const [activeCounter, setActiveCounter] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchedText.length === 0) {
|
||||
setSearchResults(options.slice(0, 5));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsActive(true);
|
||||
const normalizedSearchedText = searchedText.trim().toLowerCase();
|
||||
const results = options
|
||||
.filter((data) => {
|
||||
return data.label.toLowerCase().indexOf(normalizedSearchedText) !== -1;
|
||||
})
|
||||
.slice(0, 5);
|
||||
|
||||
setSearchResults(results);
|
||||
setActiveCounter(0);
|
||||
}, [searchedText]);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchResults(options.slice(0, 5));
|
||||
}, [options]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
const handleOutsideClick = (e: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target as Node) &&
|
||||
searchInputRef.current &&
|
||||
!searchInputRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setIsActive(false);
|
||||
setSearchedText('');
|
||||
setSearchResults(options.slice(0, 5));
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleOutsideClick);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleOutsideClick);
|
||||
};
|
||||
}
|
||||
}, [isActive]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
id={searchInputId}
|
||||
value={searchedText}
|
||||
className={`w-full ${inputClassName}`}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
onInput={(e) => {
|
||||
const value = (e.target as HTMLInputElement).value.trim();
|
||||
setSearchedText(value);
|
||||
}}
|
||||
onFocus={() => {
|
||||
setIsActive(true);
|
||||
setSearchResults(options.slice(0, 5));
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
const canGoNext = activeCounter < searchResults.length - 1;
|
||||
setActiveCounter(canGoNext ? activeCounter + 1 : 0);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
const canGoPrev = activeCounter > 0;
|
||||
setActiveCounter(
|
||||
canGoPrev ? activeCounter - 1 : searchResults.length - 1
|
||||
);
|
||||
} else if (e.key === 'Tab') {
|
||||
if (isActive) {
|
||||
e.preventDefault();
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setSearchedText('');
|
||||
setIsActive(false);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const activeData = searchResults[activeCounter];
|
||||
if (activeData) {
|
||||
onSelect(activeData);
|
||||
setSearchedText('');
|
||||
setIsActive(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{isActive && (
|
||||
<div
|
||||
class="absolute top-full z-50 mt-2 w-full rounded-md bg-gray-100 px-2 py-2"
|
||||
ref={dropdownRef}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{searchResults.length === 0 && (
|
||||
<div class="p-5 text-center text-sm text-gray-400">
|
||||
No results found
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchResults.map((result, counter) => {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
class={`flex w-full items-center rounded p-2 text-sm ${
|
||||
counter === activeCounter ? 'bg-gray-200' : ''
|
||||
}`}
|
||||
onMouseOver={() => setActiveCounter(counter)}
|
||||
onClick={() => {
|
||||
onSelect(result);
|
||||
setSearchedText('');
|
||||
setIsActive(false);
|
||||
}}
|
||||
>
|
||||
{result.label}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
51
src/components/Stepper.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { CheckIcon } from './ReactIcons/CheckIcon';
|
||||
|
||||
type StepperStep = {
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type StepperProps = {
|
||||
activeIndex: number;
|
||||
completeSteps: number[];
|
||||
steps: StepperStep[];
|
||||
};
|
||||
|
||||
export function Stepper(props: StepperProps) {
|
||||
const { steps, activeIndex = 0, completeSteps = [] } = props;
|
||||
|
||||
return (
|
||||
<ol className="flex w-full items-center text-gray-500">
|
||||
{steps.map((step, stepCounter) => {
|
||||
const isComplete = completeSteps.includes(stepCounter);
|
||||
const isActive = activeIndex === stepCounter;
|
||||
const isLast = stepCounter === (steps.length - 1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<li
|
||||
className={`flex items-center ${
|
||||
isComplete || isActive ? 'text-black' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{isComplete && (
|
||||
<CheckIcon
|
||||
additionalClasses={'mr-2 top-[0.5px] w-[18px] h-[18px]'}
|
||||
/>
|
||||
)}
|
||||
{!isComplete && (
|
||||
<span class="mr-2 font-semibold">{stepCounter + 1}</span>
|
||||
)}
|
||||
<span className="flex flex-grow">{step.label}</span>
|
||||
</li>
|
||||
{!isLast && (
|
||||
<li className={'mx-5 flex flex-grow rounded-md bg-gray-200'}>
|
||||
<span className={'h-1 w-full'} />
|
||||
</li>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
183
src/components/TeamDropdown/TeamDropdown.tsx
Normal file
@ -0,0 +1,183 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import ChevronDown from '../../icons/dropdown.svg';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { useAuth } from '../../hooks/use-auth';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import type { AllowedRoles } from '../CreateTeam/RoleDropdown';
|
||||
import { $currentTeam, $teamList } from '../../stores/team';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { useTeamId } from '../../hooks/use-team-id';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
const allowedStatus = ['invited', 'joined', 'rejected'] as const;
|
||||
export type AllowedMemberStatus = (typeof allowedStatus)[number];
|
||||
|
||||
export type UserTeamItem = {
|
||||
_id: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
roadmaps: string[];
|
||||
role: AllowedRoles;
|
||||
status: AllowedMemberStatus;
|
||||
memberId: string;
|
||||
};
|
||||
|
||||
export type TeamListResponse = UserTeamItem[];
|
||||
|
||||
export function TeamDropdown() {
|
||||
const user = useAuth();
|
||||
const { teamId } = useTeamId();
|
||||
|
||||
const teamList = useStore($teamList);
|
||||
const currentTeam = useStore($currentTeam);
|
||||
|
||||
const toast = useToast();
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
|
||||
const selectedAvatar = currentTeam ? currentTeam.avatar : user?.avatar;
|
||||
const selectedLabel = currentTeam ? currentTeam.name : user?.name;
|
||||
|
||||
useOutsideClick(dropdownRef, () => {
|
||||
setShowDropdown(false);
|
||||
});
|
||||
|
||||
async function getAllTeams() {
|
||||
const { response, error } = await httpGet<TeamListResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-teams`
|
||||
);
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
$teamList.set(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!teamId || !teamList) {
|
||||
return;
|
||||
}
|
||||
|
||||
$currentTeam.set(teamList.find((team) => team._id === teamId));
|
||||
}, [teamList, teamId]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
getAllTeams().finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const pendingTeamIds = teamList
|
||||
.filter((team) => team.status === 'invited')
|
||||
.map((team) => team._id);
|
||||
|
||||
if (
|
||||
!user?.email.endsWith('@insightpartners.com') &&
|
||||
!user?.email.endsWith('@roadmap.sh') &&
|
||||
!['arikchangma@gmail.com', 'kamranahmed.se@gmail.com'].includes(user?.email!)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative mr-2">
|
||||
<button
|
||||
className="flex w-full cursor-pointer items-center justify-between rounded border p-2 text-sm hover:bg-gray-100"
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
>
|
||||
{pendingTeamIds.length > 0 && (
|
||||
<span className="absolute -left-1.5 -top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-xs font-medium text-white">
|
||||
{pendingTeamIds.length}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoading && <Spinner className="h-4 w-4" isDualRing={false} />}
|
||||
{!isLoading && (
|
||||
<img
|
||||
src={
|
||||
selectedAvatar
|
||||
? `${
|
||||
import.meta.env.PUBLIC_AVATAR_BASE_URL
|
||||
}/${selectedAvatar}`
|
||||
: '/images/default-avatar.png'
|
||||
}
|
||||
alt=""
|
||||
className="h-4 w-4 rounded-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">
|
||||
{!isLoading && selectedLabel}
|
||||
{isLoading && 'Loading ..'}
|
||||
</span>
|
||||
</div>
|
||||
<img alt={'show dropdown'} src={ChevronDown} className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{showDropdown && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute top-full z-50 mt-2 w-full rounded-md bg-slate-800 px-2 py-2 text-white shadow-md"
|
||||
>
|
||||
<ul>
|
||||
<li>
|
||||
<a
|
||||
className="flex w-full cursor-pointer items-center gap-2 truncate rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
href="/account"
|
||||
>
|
||||
<span className="truncate">Personal Account</span>
|
||||
</a>
|
||||
</li>
|
||||
{teamList.map((team) => {
|
||||
let pageLink = '';
|
||||
if (team.status === 'invited') {
|
||||
pageLink = `/respond-invite?i=${team.memberId}`;
|
||||
} else if (team.status === 'joined') {
|
||||
pageLink = `/team/progress?t=${team._id}`;
|
||||
}
|
||||
|
||||
if (team.roadmaps.length === 0) {
|
||||
pageLink = `/team/new?t=${team._id}&s=2`;
|
||||
}
|
||||
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
href={`${pageLink}`}
|
||||
>
|
||||
<span className="flex-grow truncate">{team.name}</span>
|
||||
{pendingTeamIds.includes(team._id) && (
|
||||
<span className="flex rounded-md bg-red-500 px-2 text-xs text-white">
|
||||
Invite
|
||||
</span>
|
||||
)}
|
||||
|
||||
{team.roadmaps.length === 0 && (
|
||||
<span className="flex rounded-md bg-gray-500 px-2 text-xs text-white">
|
||||
Draft
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<a
|
||||
className="mt-2 flex w-full cursor-pointer items-center justify-center gap-2 rounded bg-gray-100 p-2 text-sm font-medium text-slate-800 hover:opacity-90"
|
||||
href="/team/new"
|
||||
>
|
||||
<span>+</span>
|
||||
<span>New Team</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<hr class='my-4' />
|
||||
</div>
|
||||
);
|
||||
}
|
121
src/components/TeamMembers/InviteMemberPopup.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import { useTeamId } from '../../hooks/use-team-id';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { AllowedRoles, RoleDropdown } from '../CreateTeam/RoleDropdown';
|
||||
|
||||
type InviteMemberPopupProps = {
|
||||
onInvited: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function InviteMemberPopup(props: InviteMemberPopupProps) {
|
||||
const { onClose, onInvited } = props;
|
||||
|
||||
const popupBodyRef = useRef<HTMLDivElement>(null);
|
||||
const emailRef = useRef<HTMLInputElement>(null);
|
||||
const [selectedRole, setSelectedRole] = useState<AllowedRoles>('member');
|
||||
const [email, setEmail] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const { teamId } = useTeamId();
|
||||
|
||||
useEffect(() => {
|
||||
emailRef?.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
const { response, error } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-invite-member/${teamId}`,
|
||||
{ email, role: selectedRole }
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setIsLoading(false);
|
||||
setError(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
handleClosePopup();
|
||||
onInvited();
|
||||
};
|
||||
|
||||
const handleClosePopup = () => {
|
||||
setIsLoading(false);
|
||||
setError('');
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
useOutsideClick(popupBodyRef, handleClosePopup);
|
||||
|
||||
return (
|
||||
<div class="popup fixed left-0 right-0 top-0 z-50 flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
|
||||
<div class="relative h-full w-full max-w-md p-4 md:h-auto">
|
||||
<div
|
||||
ref={popupBodyRef}
|
||||
class="popup-body relative rounded-lg bg-white p-4 shadow"
|
||||
>
|
||||
<h3 class="mb-1.5 text-xl sm:text-2xl font-medium">Invite Member</h3>
|
||||
<p className="mb-3 text-sm leading-none text-gray-400 hidden sm:block">
|
||||
Enter the email and role below to invite a member.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mt-0 sm:mt-4 my-4 flex flex-col gap-2">
|
||||
<input
|
||||
ref={emailRef}
|
||||
type="email"
|
||||
name="invite-member"
|
||||
id="invite-member"
|
||||
className="mt-2 block w-full rounded-md border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:border-gray-400"
|
||||
placeholder="Enter email address"
|
||||
required
|
||||
autoFocus
|
||||
value={email}
|
||||
onInput={(e) => setEmail((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
|
||||
<div className="flex h-[42px] w-full flex-col">
|
||||
<RoleDropdown
|
||||
className="h-full w-full"
|
||||
selectedRole={selectedRole}
|
||||
setSelectedRole={setSelectedRole}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className=" rounded-md border border-red-300 bg-red-50 p-2 text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
onClick={handleClosePopup}
|
||||
className="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !email}
|
||||
class="flex-grow cursor-pointer rounded-lg bg-black py-2 text-center text-white disabled:opacity-40"
|
||||
>
|
||||
{isLoading ? 'Please wait ..' : 'Invite'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
40
src/components/TeamMembers/LeaveTeamButton.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { useState } from "preact/hooks";
|
||||
import { httpDelete } from "../../lib/http";
|
||||
import { Spinner } from "../ReactIcons/Spinner";
|
||||
import { useToast } from "../../hooks/use-toast";
|
||||
|
||||
type LeaveTeamButtonProps = {
|
||||
teamId: string;
|
||||
};
|
||||
|
||||
export function LeaveTeamButton(props: LeaveTeamButtonProps) {
|
||||
const toast = useToast();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { teamId } = props;
|
||||
|
||||
async function leaveTeam() {
|
||||
setIsLoading(true);
|
||||
const { response, error } = await httpDelete(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-leave-team/${teamId}`,
|
||||
{}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setIsLoading(false);
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = '/account';
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
disabled={isLoading}
|
||||
onClick={leaveTeam}
|
||||
className="bg-gray-50 text-red-600 text-sm font-medium px-2 leading-none py-1.5 rounded-md border border-gray-200 h-7 flex items-center justify-center min-w-[95px]">
|
||||
{isLoading ? <Spinner isDualRing={false} /> : 'Leave team'}
|
||||
</button>
|
||||
)
|
||||
|
||||
}
|
103
src/components/TeamMembers/MemberActionDropdown.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import type { TeamMemberDocument } from './TeamMembersPage';
|
||||
import { httpDelete, httpPatch } from '../../lib/http';
|
||||
import MoreIcon from '../../icons/more-vertical.svg';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
export function MemberActionDropdown({
|
||||
member,
|
||||
onUpdateMember,
|
||||
onDeleteMember,
|
||||
isDisabled = false,
|
||||
}: {
|
||||
onDeleteMember: () => void;
|
||||
onUpdateMember: () => void;
|
||||
isDisabled: boolean;
|
||||
member: TeamMemberDocument;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useOutsideClick(menuRef, () => {
|
||||
setIsOpen(false);
|
||||
});
|
||||
|
||||
async function resendInvite() {
|
||||
const { response, error } = await httpPatch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-resend-invite/${member.teamId}/${
|
||||
member._id
|
||||
}`,
|
||||
{}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setIsLoading(false);
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
const actions = [
|
||||
{
|
||||
name: 'Delete',
|
||||
handleClick: () => {
|
||||
onDeleteMember();
|
||||
setIsOpen(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Update Role',
|
||||
handleClick: () => {
|
||||
onUpdateMember();
|
||||
setIsOpen(false);
|
||||
},
|
||||
},
|
||||
...(['invited'].includes(member.status)
|
||||
? [
|
||||
{
|
||||
name: 'Resend Invite',
|
||||
handleClick: resendInvite,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
disabled={isDisabled}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="ml-2 flex items-center opacity-60 transition-opacity hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
<img alt="menu" src={MoreIcon} className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="align-right absolute right-0 top-full z-50 mt-1 w-32 rounded-md bg-slate-800 px-2 py-2 text-white shadow-md"
|
||||
>
|
||||
<ul>
|
||||
{actions.map((action, index) => {
|
||||
return (
|
||||
<li key={index}>
|
||||
<button
|
||||
onClick={action.handleClick}
|
||||
disabled={isLoading}
|
||||
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
{action.name}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
15
src/components/TeamMembers/RoleBadge.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import type { AllowedRoles } from '../CreateTeam/RoleDropdown';
|
||||
|
||||
export function MemberRoleBadge({ role }: { role: AllowedRoles }) {
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs capitalize ${
|
||||
['admin'].includes(role)
|
||||
? 'bg-blue-100 text-blue-700 '
|
||||
: 'bg-gray-100 text-gray-700 '
|
||||
} ${['manager'].includes(role) ? 'bg-green-100 text-green-700' : ''}`}
|
||||
>
|
||||
{role}
|
||||
</span>
|
||||
);
|
||||
}
|
238
src/components/TeamMembers/TeamMembersPage.tsx
Normal file
@ -0,0 +1,238 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { httpDelete, httpGet } from '../../lib/http';
|
||||
import { MemberActionDropdown } from './MemberActionDropdown';
|
||||
import { useAuth } from '../../hooks/use-auth';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import type { TeamDocument } from '../CreateTeam/CreateTeamForm';
|
||||
import { LeaveTeamButton } from './LeaveTeamButton';
|
||||
import type { AllowedRoles } from '../CreateTeam/RoleDropdown';
|
||||
import type { AllowedMemberStatus } from '../TeamDropdown/TeamDropdown';
|
||||
import { InviteMemberPopup } from './InviteMemberPopup';
|
||||
import { getUrlParams } from '../../lib/browser';
|
||||
import { UpdateMemberPopup } from './UpdateMemberPopup';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { $canManageCurrentTeam } from '../../stores/team';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { MemberRoleBadge } from './RoleBadge';
|
||||
|
||||
export interface TeamMemberDocument {
|
||||
_id?: string;
|
||||
userId?: string;
|
||||
invitedEmail?: string;
|
||||
teamId: string;
|
||||
role: AllowedRoles;
|
||||
status: AllowedMemberStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface TeamMemberItem extends TeamMemberDocument {
|
||||
name: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
export function TeamMembersPage() {
|
||||
const { t: teamId } = getUrlParams();
|
||||
|
||||
const toast = useToast();
|
||||
const canManageCurrentTeam = useStore($canManageCurrentTeam);
|
||||
|
||||
const [memberToUpdate, setMemberToUpdate] = useState<TeamMemberItem>();
|
||||
const [isInvitingMember, setIsInvitingMember] = useState(false);
|
||||
const [teamMembers, setTeamMembers] = useState<TeamMemberItem[]>([]);
|
||||
const [team, setTeam] = useState<TeamDocument>();
|
||||
|
||||
const user = useAuth();
|
||||
|
||||
async function loadTeam() {
|
||||
const { response, error } = await httpGet<TeamDocument>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team/${teamId}`
|
||||
);
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
if (response) {
|
||||
setTeam(response);
|
||||
}
|
||||
}
|
||||
|
||||
async function getTeamMemberList() {
|
||||
const { response, error } = await httpGet<TeamMemberItem[]>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-member-list/${teamId}`
|
||||
);
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Failed to load team member list');
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamMembers(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.all([loadTeam(), getTeamMemberList()]).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}, [teamId]);
|
||||
|
||||
async function deleteMember(teamId: string, memberId: string) {
|
||||
pageProgressMessage.set('Deleting member');
|
||||
const { response, error } = await httpDelete(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-delete-member/${teamId}/${memberId}`,
|
||||
{}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Member has been deleted');
|
||||
await getTeamMemberList();
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{memberToUpdate && (
|
||||
<UpdateMemberPopup
|
||||
member={memberToUpdate}
|
||||
onUpdated={() => {
|
||||
pageProgressMessage.set('Refreshing members');
|
||||
getTeamMemberList().finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
setMemberToUpdate(undefined);
|
||||
toast.success('Member has been updated');
|
||||
}}
|
||||
onClose={() => {
|
||||
setMemberToUpdate(undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isInvitingMember && (
|
||||
<InviteMemberPopup
|
||||
onInvited={() => {
|
||||
toast.success('Invite sent');
|
||||
getTeamMemberList().then(() => null);
|
||||
setIsInvitingMember(false);
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsInvitingMember(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div className="rounded-b-sm rounded-t-md border">
|
||||
<div className="flex items-center justify-between gap-2 border-b p-3">
|
||||
<p className="hidden text-sm sm:block">
|
||||
{teamMembers.length} people in the team.
|
||||
</p>
|
||||
<p className="block text-sm sm:hidden">
|
||||
{teamMembers.length} members
|
||||
</p>
|
||||
<LeaveTeamButton teamId={team?._id!} />
|
||||
</div>
|
||||
{teamMembers.map((member, index) => {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-between gap-2 p-3 ${
|
||||
index === 0 ? '' : 'border-t'
|
||||
} ${member.status === 'invited' ? 'bg-gray-50' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={
|
||||
member.avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${
|
||||
member.avatar
|
||||
}`
|
||||
: '/images/default-avatar.png'
|
||||
}
|
||||
alt={member.name || ''}
|
||||
className="hidden h-10 w-10 rounded-full sm:block"
|
||||
/>
|
||||
<div>
|
||||
<span class={'mb-1 block sm:hidden'}>
|
||||
<MemberRoleBadge role={member.role} />
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
<h3 className="flex items-center font-medium">
|
||||
{member.name}
|
||||
{member.userId === user?.id && (
|
||||
<span className="ml-2 hidden text-xs font-normal text-blue-500 sm:inline">
|
||||
You
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<div className="ml-2 flex items-center gap-0.5">
|
||||
{member.status === 'invited' && (
|
||||
<span className="rounded-full bg-yellow-100 px-2 py-0.5 text-xs text-yellow-700">
|
||||
Invited
|
||||
</span>
|
||||
)}
|
||||
{member.status === 'rejected' && (
|
||||
<span className="rounded-full bg-red-100 px-2 py-0.5 text-xs text-red-700">
|
||||
Rejected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
{member.invitedEmail}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-sm">
|
||||
<span class={'hidden sm:block'}>
|
||||
<MemberRoleBadge role={member.role} />
|
||||
</span>
|
||||
{canManageCurrentTeam && (
|
||||
<MemberActionDropdown
|
||||
onDeleteMember={() => {
|
||||
deleteMember(teamId, member._id!).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
isDisabled={member.userId === user?.id}
|
||||
onUpdateMember={() => {
|
||||
setMemberToUpdate(member);
|
||||
}}
|
||||
member={member}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canManageCurrentTeam && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
disabled={teamMembers.length >= 25}
|
||||
onClick={() => setIsInvitingMember(true)}
|
||||
className="block w-full rounded-md border border-dashed border-gray-300 py-2 text-sm transition-colors hover:border-gray-600 hover:bg-gray-50 focus:outline-0"
|
||||
>
|
||||
+ Invite Member
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{teamMembers.length >= 25 && canManageCurrentTeam && (
|
||||
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">
|
||||
You have reached the maximum number of members in a team. Please reach
|
||||
out to us if you need more.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
109
src/components/TeamMembers/UpdateMemberPopup.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { httpPost, httpPut } from '../../lib/http';
|
||||
import { useTeamId } from '../../hooks/use-team-id';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { AllowedRoles, RoleDropdown } from '../CreateTeam/RoleDropdown';
|
||||
import type { TeamMemberDocument } from './TeamMembersPage';
|
||||
|
||||
type InviteMemberPopupProps = {
|
||||
member: TeamMemberDocument;
|
||||
onUpdated: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function UpdateMemberPopup(props: InviteMemberPopupProps) {
|
||||
const { onClose, onUpdated, member } = props;
|
||||
|
||||
const popupBodyRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedRole, setSelectedRole] = useState<AllowedRoles>(member.role);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const { teamId } = useTeamId();
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
const { response, error } = await httpPut(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-update-member-role/${teamId}/${
|
||||
member._id
|
||||
}`,
|
||||
{ role: selectedRole }
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setIsLoading(false);
|
||||
setError(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
onUpdated();
|
||||
};
|
||||
|
||||
const handleClosePopup = () => {
|
||||
setIsLoading(false);
|
||||
setError('');
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
useOutsideClick(popupBodyRef, handleClosePopup);
|
||||
|
||||
return (
|
||||
<div class="popup fixed left-0 right-0 top-0 z-50 flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
|
||||
<div class="relative h-full w-full max-w-md p-4 md:h-auto">
|
||||
<div
|
||||
ref={popupBodyRef}
|
||||
class="popup-body relative rounded-lg bg-white p-4 shadow"
|
||||
>
|
||||
<h3 class="mb-1.5 text-xl sm:text-2xl font-medium">Update Role</h3>
|
||||
<p className="mb-3 text-sm leading-none text-gray-400 hidden sm:block">
|
||||
Select the role to update for this member
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mt-0 sm:mt-4 my-4 flex flex-col gap-2">
|
||||
<span className="mt-2 block w-full rounded-md bg-gray-100 p-2">
|
||||
{member.invitedEmail}
|
||||
</span>
|
||||
|
||||
<div className="flex h-[42px] w-full flex-col">
|
||||
<RoleDropdown
|
||||
className="h-full w-full"
|
||||
selectedRole={selectedRole}
|
||||
setSelectedRole={setSelectedRole}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className=" rounded-md border border-red-300 bg-red-50 p-2 text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
onClick={handleClosePopup}
|
||||
className="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !selectedRole}
|
||||
class="flex-grow cursor-pointer rounded-lg bg-black py-2 text-center text-white disabled:opacity-40"
|
||||
>
|
||||
{isLoading ? 'Please wait ..' : 'Update Role'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
118
src/components/TeamProgress/GroupRoadmapItem.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import type { GroupByRoadmap, TeamMember } from './TeamProgressPage';
|
||||
import { MemberProgressModal } from './MemberProgressModal';
|
||||
import { getUrlParams } from '../../lib/browser';
|
||||
import ExternalLinkIcon from '../../icons/external-link.svg';
|
||||
|
||||
type GroupRoadmapItemProps = {
|
||||
roadmap: GroupByRoadmap;
|
||||
};
|
||||
|
||||
export function GroupRoadmapItem(props: GroupRoadmapItemProps) {
|
||||
const { members, resourceTitle, resourceId } = props.roadmap;
|
||||
const { t: teamId } = getUrlParams();
|
||||
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [detailResourceId, setDetailResourceId] = useState<string | null>(null);
|
||||
const [selectedMember, setSelectedMember] = useState<TeamMember | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
{detailResourceId && (
|
||||
<MemberProgressModal
|
||||
member={selectedMember!}
|
||||
teamId={teamId}
|
||||
resourceId={detailResourceId}
|
||||
resourceType={'roadmap'}
|
||||
onClose={() => {
|
||||
setDetailResourceId(null);
|
||||
setSelectedMember(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex h-full min-h-[270px] flex-col rounded-md border">
|
||||
<div className="flex items-center gap-3 border-b p-3">
|
||||
<div className="flex min-w-0 flex-grow items-center justify-between">
|
||||
<h3 className="truncate font-medium">{resourceTitle}</h3>
|
||||
<a
|
||||
href={`/${resourceId}?t=${teamId}`}
|
||||
className="group mb-0.5 flex shrink-0 items-center justify-between text-base font-medium leading-none text-black"
|
||||
target={'_blank'}
|
||||
>
|
||||
<img
|
||||
alt={'link'}
|
||||
src={ExternalLinkIcon}
|
||||
className="ml-2 h-4 w-4 opacity-20 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex grow flex-col space-y-2 p-3">
|
||||
{(showAll ? members : members.slice(0, 4)).map((member) => {
|
||||
if (!member.progress) return null;
|
||||
return (
|
||||
<button
|
||||
className="group relative w-full overflow-hidden rounded-md border p-2 hover:border-gray-300 hover:text-black focus:outline-none"
|
||||
key={member?.member._id}
|
||||
onClick={() => {
|
||||
setDetailResourceId(member?.progress?.resourceId!);
|
||||
setSelectedMember(member.member);
|
||||
}}
|
||||
>
|
||||
<span className="relative z-10 flex items-center justify-between gap-1 text-sm">
|
||||
<span className="inline-grid grid-cols-[20px_auto] gap-2">
|
||||
<img
|
||||
src={
|
||||
member.member.avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${
|
||||
member.member.avatar
|
||||
}`
|
||||
: '/images/default-avatar.png'
|
||||
}
|
||||
alt={member.member.name || ''}
|
||||
className="h-5 w-5 shrink-0 rounded-full"
|
||||
/>
|
||||
<span className="truncate">{member?.member?.name}</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-gray-400">
|
||||
{member?.progress?.done} / {member?.progress?.total}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="absolute inset-0 bg-gray-100 group-hover:bg-gray-200"
|
||||
style={{
|
||||
width: `${
|
||||
(member?.progress?.done / member?.progress?.total) * 100
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{members.length > 4 && !showAll && (
|
||||
<button
|
||||
onClick={() => setShowAll(true)}
|
||||
className={'text-sm text-gray-400 underline'}
|
||||
>
|
||||
+ {members.length - 4} more
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showAll && (
|
||||
<button
|
||||
onClick={() => setShowAll(false)}
|
||||
className={'text-sm text-gray-400 underline'}
|
||||
>
|
||||
- Show less
|
||||
</button>
|
||||
)}
|
||||
|
||||
{members.length === 0 && (
|
||||
<div className="text-sm text-gray-500">No progress</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
105
src/components/TeamProgress/MemberProgressItem.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import type { TeamMember } from './TeamProgressPage';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { MemberProgressModal } from './MemberProgressModal';
|
||||
|
||||
type MemberProgressItemProps = {
|
||||
teamId: string;
|
||||
member: TeamMember;
|
||||
};
|
||||
export function MemberProgressItem(props: MemberProgressItemProps) {
|
||||
const { member, teamId } = props;
|
||||
|
||||
const memberProgress = member?.progress?.sort((a, b) => {
|
||||
return b.done - a.done;
|
||||
});
|
||||
|
||||
const [detailResourceId, setDetailResourceId] = useState<string | null>(null);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{detailResourceId && (
|
||||
<MemberProgressModal
|
||||
member={member}
|
||||
teamId={teamId}
|
||||
resourceId={detailResourceId}
|
||||
resourceType={'roadmap'}
|
||||
onClose={() => {
|
||||
setDetailResourceId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex h-full min-h-[270px] flex-col rounded-md border"
|
||||
key={member._id}
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b p-3">
|
||||
<img
|
||||
src={
|
||||
member.avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${member.avatar}`
|
||||
: '/images/default-avatar.png'
|
||||
}
|
||||
alt={member.name || ''}
|
||||
className="h-8 w-8 rounded-full"
|
||||
/>
|
||||
<div className="inline-grid">
|
||||
<h3 className="truncate font-medium">{member.name}</h3>
|
||||
<p className="truncate text-sm text-gray-500">{member.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex grow flex-col space-y-2 p-3">
|
||||
{(showAll ? memberProgress : memberProgress.slice(0, 4)).map(
|
||||
(progress) => {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setDetailResourceId(progress.resourceId)}
|
||||
className="group relative overflow-hidden rounded-md border p-2 hover:border-gray-300 hover:text-black focus:outline-none"
|
||||
key={progress.resourceId}
|
||||
>
|
||||
<span className="relative z-10 flex items-center justify-between text-sm">
|
||||
<span className="inline-grid">
|
||||
<span className={'truncate'}>{progress.resourceTitle}</span>
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 shrink-0 ml-1.5">
|
||||
{progress.done} / {progress.total}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="absolute inset-0 bg-gray-100 group-hover:bg-gray-200"
|
||||
style={{
|
||||
width: `${(progress.done / progress.total) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
)}
|
||||
|
||||
{memberProgress.length > 4 && !showAll && (
|
||||
<button
|
||||
onClick={() => setShowAll(true)}
|
||||
className={'text-sm text-gray-400 underline'}
|
||||
>
|
||||
+ {memberProgress.length - 4} more
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showAll && (
|
||||
<button
|
||||
onClick={() => setShowAll(false)}
|
||||
className={'text-sm text-gray-400 underline'}
|
||||
>
|
||||
- Show less
|
||||
</button>
|
||||
)}
|
||||
|
||||
{memberProgress.length === 0 && (
|
||||
<div className="text-sm text-gray-500">No progress</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
231
src/components/TeamProgress/MemberProgressModal.tsx
Normal file
@ -0,0 +1,231 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { wireframeJSONToSVG } from 'roadmap-renderer';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import '../FrameRenderer/FrameRenderer.css';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import type { TeamMember } from './TeamProgressPage';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { renderTopicProgress } from '../../lib/resource-progress';
|
||||
import CloseIcon from '../../icons/close.svg';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
export type ProgressMapProps = {
|
||||
member: TeamMember;
|
||||
teamId: string;
|
||||
resourceId: string;
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
type MemberProgressResponse = {
|
||||
removed: string[];
|
||||
done: string[];
|
||||
learning: string[];
|
||||
skipped: string[];
|
||||
};
|
||||
|
||||
export function MemberProgressModal(props: ProgressMapProps) {
|
||||
const { resourceId, member, resourceType, teamId, onClose } = props;
|
||||
|
||||
const containerEl = useRef<HTMLDivElement>(null);
|
||||
const popupBodyEl = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [memberProgress, setMemberProgress] =
|
||||
useState<MemberProgressResponse>();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const toast = useToast();
|
||||
|
||||
let resourceJsonUrl = 'https://roadmap.sh';
|
||||
if (resourceType === 'roadmap') {
|
||||
resourceJsonUrl += `/${resourceId}.json`;
|
||||
} else {
|
||||
resourceJsonUrl += `/best-practices/${resourceId}.json`;
|
||||
}
|
||||
|
||||
async function getMemberProgress(
|
||||
teamId: string,
|
||||
memberId: string,
|
||||
resourceType: string,
|
||||
resourceId: string
|
||||
) {
|
||||
const { error, response } = await httpGet<MemberProgressResponse>(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-get-member-resource-progress/${teamId}/${memberId}?resourceType=${resourceType}&resourceId=${resourceId}`
|
||||
);
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Failed to get member progress');
|
||||
return;
|
||||
}
|
||||
|
||||
setMemberProgress(response);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function renderResource(jsonUrl: string) {
|
||||
const res = await fetch(jsonUrl);
|
||||
const json = await res.json();
|
||||
const svg = await wireframeJSONToSVG(json, {
|
||||
fontURL: '/fonts/balsamiq.woff2',
|
||||
});
|
||||
|
||||
containerEl.current?.replaceChildren(svg);
|
||||
}
|
||||
|
||||
useKeydown('Escape', () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
useOutsideClick(popupBodyEl, () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!containerEl.current ||
|
||||
!resourceJsonUrl ||
|
||||
!resourceId ||
|
||||
!resourceType ||
|
||||
!teamId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
renderResource(resourceJsonUrl),
|
||||
getMemberProgress(teamId, member._id, resourceType, resourceId),
|
||||
])
|
||||
.then(([_, memberProgress = {}]) => {
|
||||
const {
|
||||
removed = [],
|
||||
done = [],
|
||||
learning = [],
|
||||
skipped = [],
|
||||
} = memberProgress;
|
||||
|
||||
done.forEach((id: string) => renderTopicProgress(id, 'done'));
|
||||
learning.forEach((id: string) => renderTopicProgress(id, 'learning'));
|
||||
skipped.forEach((id: string) => renderTopicProgress(id, 'skipped'));
|
||||
removed.forEach((id: string) => renderTopicProgress(id, 'removed'));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
toast.error(err?.message || 'Something went wrong. Please try again!');
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const currProgress = member.progress.find((p) => p.resourceId === resourceId);
|
||||
const memberDone = currProgress?.done || 0;
|
||||
const memberLearning = currProgress?.learning || 0;
|
||||
const memberSkipped = currProgress?.skipped || 0;
|
||||
const memberTotal = currProgress?.total || 0;
|
||||
|
||||
const progressPercentage = Math.round((memberDone / memberTotal) * 100);
|
||||
|
||||
return (
|
||||
<div class="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
|
||||
<div class="relative mx-auto h-full w-full max-w-4xl p-4 md:h-auto">
|
||||
<div
|
||||
ref={popupBodyEl}
|
||||
class="popup-body relative rounded-lg bg-white shadow"
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="mb-5 mt-0 text-left md:mt-4 md:text-center">
|
||||
<h2 className={'mb-1 text-lg font-bold md:text-2xl'}>
|
||||
{member.name}'s Progress
|
||||
</h2>
|
||||
<p
|
||||
className={
|
||||
'hidden text-xs text-gray-500 sm:text-sm md:block md:text-base'
|
||||
}
|
||||
>
|
||||
You are looking at {member.name}'s progress.{' '}
|
||||
<a
|
||||
target={'_blank'}
|
||||
href={`/${resourceId}?t=${teamId}`}
|
||||
className="text-blue-600 underline"
|
||||
>
|
||||
View your progress
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p className={'block text-gray-500 md:hidden'}>
|
||||
View your progress
|
||||
<a
|
||||
target={'_blank'}
|
||||
href={`/${resourceId}?t=${teamId}`}
|
||||
className="text-blue-600 underline"
|
||||
>
|
||||
on the roadmap page.
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<p class="-mx-4 mb-3 flex items-center justify-start border-b border-t py-2 text-sm sm:hidden px-4">
|
||||
<span class="mr-2.5 block rounded-sm bg-yellow-200 px-1 py-0.5 text-xs font-medium uppercase text-yellow-900">
|
||||
<span>{progressPercentage}</span>% Done
|
||||
</span>
|
||||
|
||||
<span>
|
||||
<span>{memberDone}</span> of <span>{memberTotal}</span> done
|
||||
</span>
|
||||
</p>
|
||||
<p class="-mx-4 mb-3 hidden items-center justify-center border-b border-t py-2 text-sm sm:flex">
|
||||
<span class="mr-2.5 block rounded-sm bg-yellow-200 px-1 py-0.5 text-xs font-medium uppercase text-yellow-900">
|
||||
<span>{progressPercentage}</span>% Done
|
||||
</span>
|
||||
|
||||
<span>
|
||||
<span>{memberDone}</span> completed
|
||||
</span>
|
||||
<span class="mx-1.5 text-gray-400">·</span>
|
||||
<span>
|
||||
<span data-progress-learning="">{memberLearning}</span> in
|
||||
progress
|
||||
</span>
|
||||
|
||||
{memberSkipped > 0 && (
|
||||
<>
|
||||
<span class="mx-1.5 text-gray-400">·</span>
|
||||
<span>
|
||||
<span data-progress-skipped="">{memberSkipped}</span>{' '}
|
||||
skipped
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<span class="mx-1.5 text-gray-400">·</span>
|
||||
<span>
|
||||
<span data-progress-total="">{memberTotal}</span> Total
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div ref={containerEl} className="px-4 pb-2"></div>
|
||||
|
||||
{isLoading && (
|
||||
<div class="flex w-full justify-center">
|
||||
<Spinner
|
||||
isDualRing={false}
|
||||
className="mb-4 mt-2 h-4 w-4 animate-spin fill-blue-600 text-gray-200 sm:h-8 sm:w-8"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="popup-close absolute right-2.5 top-3 ml-auto inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900 sm:hidden"
|
||||
onClick={onClose}
|
||||
>
|
||||
<img src={CloseIcon} className="h-4 w-4" />
|
||||
<span class="sr-only">Close modal</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
169
src/components/TeamProgress/TeamProgressPage.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { useTeamId } from '../../hooks/use-team-id';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { MemberProgressItem } from './MemberProgressItem';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { $currentTeam } from '../../stores/team';
|
||||
import { GroupRoadmapItem } from './GroupRoadmapItem';
|
||||
import { setUrlParams } from '../../lib/browser';
|
||||
import { getUrlParams } from '../../lib/browser';
|
||||
import { $toastMessage } from '../../stores/toast';
|
||||
|
||||
export type UserProgress = {
|
||||
resourceTitle: string;
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
resourceId: string;
|
||||
isFavorite: boolean;
|
||||
done: number;
|
||||
learning: number;
|
||||
skipped: number;
|
||||
total: number;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type TeamMember = {
|
||||
_id: string;
|
||||
role: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
progress: UserProgress[];
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type GroupByRoadmap = {
|
||||
resourceId: string;
|
||||
resourceTitle: string;
|
||||
resourceType: string;
|
||||
members: {
|
||||
member: TeamMember;
|
||||
progress: UserProgress | undefined;
|
||||
}[];
|
||||
};
|
||||
|
||||
const groupingTypes = [
|
||||
{ label: 'Members', value: 'member' },
|
||||
{ label: 'Roadmaps', value: 'roadmap' },
|
||||
] as const;
|
||||
|
||||
export function TeamProgressPage() {
|
||||
const { teamId } = useTeamId();
|
||||
const { gb: groupBy } = getUrlParams();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const toast = useToast();
|
||||
const currentTeam = useStore($currentTeam);
|
||||
|
||||
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
|
||||
const [selectedGrouping, setSelectedGrouping] = useState<
|
||||
'roadmap' | 'member'
|
||||
>(groupBy || 'member');
|
||||
|
||||
async function getTeamProgress() {
|
||||
const { response, error } = await httpGet<TeamMember[]>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-progress/${teamId}`
|
||||
);
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Failed to get team progress');
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamMembers(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
getTeamProgress().finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [teamId]);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!teamId) {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedGrouping) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUrlParams({ gb: selectedGrouping });
|
||||
}, [selectedGrouping]);
|
||||
|
||||
const groupByRoadmap: GroupByRoadmap[] = [];
|
||||
for (const roadmap of currentTeam?.roadmaps || []) {
|
||||
const members: GroupByRoadmap['members'] = [];
|
||||
for (const member of teamMembers) {
|
||||
const progress = member.progress.find(
|
||||
(progress) => progress.resourceId === roadmap
|
||||
);
|
||||
if (!progress) {
|
||||
continue;
|
||||
}
|
||||
members.push({
|
||||
member,
|
||||
progress,
|
||||
});
|
||||
}
|
||||
|
||||
if (!members.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
groupByRoadmap.push({
|
||||
resourceId: roadmap,
|
||||
resourceTitle: members?.[0].progress?.resourceTitle || '',
|
||||
resourceType: 'roadmap',
|
||||
members,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
{groupingTypes.map((grouping) => (
|
||||
<button
|
||||
className={`rounded-md border p-1 px-2 text-sm ${
|
||||
selectedGrouping === grouping.value
|
||||
? ' border-gray-400 bg-gray-200 '
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => setSelectedGrouping(grouping.value)}
|
||||
>
|
||||
{grouping.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{selectedGrouping === 'roadmap' && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{groupByRoadmap.map((roadmap) => {
|
||||
return (
|
||||
<GroupRoadmapItem key={roadmap.resourceId} roadmap={roadmap} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{selectedGrouping === 'member' && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{teamMembers.map((member) => (
|
||||
<MemberProgressItem teamId={teamId} member={member} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
275
src/components/TeamRoadmaps.tsx
Normal file
@ -0,0 +1,275 @@
|
||||
import { getUrlParams } from '../lib/browser';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import type { TeamDocument } from './CreateTeam/CreateTeamForm';
|
||||
import type { TeamResourceConfig } from './CreateTeam/RoadmapSelector';
|
||||
import { httpGet, httpPut } from '../lib/http';
|
||||
import { pageProgressMessage } from '../stores/page';
|
||||
import ExternalLinkIcon from '../icons/external-link.svg';
|
||||
import PlusIcon from '../icons/plus.svg';
|
||||
import type { PageType } from './CommandMenu/CommandMenu';
|
||||
import { UpdateTeamResourceModal } from './CreateTeam/UpdateTeamResourceModal';
|
||||
import { AddTeamRoadmap } from './AddTeamRoadmap';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { $canManageCurrentTeam } from '../stores/team';
|
||||
import {useToast} from "../hooks/use-toast";
|
||||
|
||||
export function TeamRoadmaps() {
|
||||
const { t: teamId } = getUrlParams();
|
||||
|
||||
const canManageCurrentTeam = useStore($canManageCurrentTeam);
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const [removingRoadmapId, setRemovingRoadmapId] = useState<string>('');
|
||||
const [isAddingRoadmap, setIsAddingRoadmap] = useState(false);
|
||||
const [changingRoadmapId, setChangingRoadmapId] = useState<string>('');
|
||||
const [team, setTeam] = useState<TeamDocument>();
|
||||
const [resourceConfigs, setResourceConfigs] = useState<TeamResourceConfig>(
|
||||
[]
|
||||
);
|
||||
const [allRoadmaps, setAllRoadmaps] = useState<PageType[]>([]);
|
||||
|
||||
async function loadAllRoadmaps() {
|
||||
const { error, response } = await httpGet<PageType[]>(`/pages.json`);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allRoadmaps = response
|
||||
.filter((page) => page.group === 'Roadmaps')
|
||||
.sort((a, b) => {
|
||||
if (a.title === 'Android') return 1;
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
|
||||
setAllRoadmaps(allRoadmaps);
|
||||
return response;
|
||||
}
|
||||
|
||||
async function loadTeam(teamIdToFetch: string) {
|
||||
const { response, error } = await httpGet<TeamDocument>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team/${teamIdToFetch}`
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error('Error loading team');
|
||||
window.location.href = '/account';
|
||||
return;
|
||||
}
|
||||
|
||||
setTeam(response);
|
||||
}
|
||||
|
||||
async function loadTeamResourceConfig(teamId: string) {
|
||||
const { error, response } = await httpGet<TeamResourceConfig>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-resource-config/${teamId}`
|
||||
);
|
||||
if (error || !Array.isArray(response)) {
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
setResourceConfigs(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
loadTeam(teamId),
|
||||
loadTeamResourceConfig(teamId),
|
||||
loadAllRoadmaps(),
|
||||
]).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}, [teamId]);
|
||||
|
||||
async function deleteResource(roadmapId: string) {
|
||||
if (!team?._id) {
|
||||
return;
|
||||
}
|
||||
|
||||
pageProgressMessage.set(`Deleting roadmap from team`);
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-delete-team-resource-config/${
|
||||
team._id
|
||||
}`,
|
||||
{
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Roadmap removed');
|
||||
setResourceConfigs(response);
|
||||
}
|
||||
|
||||
async function onRemove(resourceId: string) {
|
||||
pageProgressMessage.set('Removing roadmap');
|
||||
|
||||
deleteResource(resourceId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isAddingRoadmap && (
|
||||
<AddTeamRoadmap
|
||||
onMakeChanges={(roadmapId) => {
|
||||
setChangingRoadmapId(roadmapId);
|
||||
setIsAddingRoadmap(false);
|
||||
}}
|
||||
teamId={team?._id!}
|
||||
setResourceConfigs={setResourceConfigs}
|
||||
allRoadmaps={allRoadmaps}
|
||||
availableRoadmaps={allRoadmaps.filter((r) => {
|
||||
const isAlreadyAdded = resourceConfigs.find(
|
||||
(c) => c.resourceId === r.id
|
||||
);
|
||||
return !isAlreadyAdded;
|
||||
})}
|
||||
onClose={() => setIsAddingRoadmap(false)}
|
||||
/>
|
||||
)}
|
||||
<div className={'grid grid-cols-1 gap-3 sm:grid-cols-2'}>
|
||||
{changingRoadmapId && (
|
||||
<UpdateTeamResourceModal
|
||||
onClose={() => setChangingRoadmapId('')}
|
||||
resourceId={changingRoadmapId}
|
||||
resourceType={'roadmap'}
|
||||
teamId={team?._id!}
|
||||
setTeamResourceConfig={setResourceConfigs}
|
||||
defaultRemovedItems={
|
||||
resourceConfigs.find((c) => c.resourceId === changingRoadmapId)
|
||||
?.removed || []
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{resourceConfigs.map((resourceConfig) => {
|
||||
const { resourceId, removed: removedTopics } = resourceConfig;
|
||||
const roadmapTitle =
|
||||
allRoadmaps.find((roadmap) => roadmap.id === resourceId)?.title ||
|
||||
'...';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start rounded-md border border-gray-300">
|
||||
<div className={'w-full px-3 py-4'}>
|
||||
<a
|
||||
href={`/${resourceId}?t=${teamId}`}
|
||||
className="group mb-0.5 flex items-center justify-between text-base font-medium leading-none text-black"
|
||||
target={'_blank'}
|
||||
>
|
||||
{roadmapTitle}
|
||||
|
||||
<img
|
||||
alt={'link'}
|
||||
src={ExternalLinkIcon}
|
||||
className="ml-2 h-4 w-4 opacity-20 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</a>
|
||||
{removedTopics.length > 0 ? (
|
||||
<span className={'text-xs leading-none text-gray-900'}>
|
||||
{removedTopics.length} topic
|
||||
{removedTopics.length > 1 ? 's' : ''} removed
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs italic leading-none text-gray-400/60">
|
||||
No changes made ..
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ canManageCurrentTeam && (
|
||||
<div className={'flex w-full justify-between pt-2 pb-3 px-3'}>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
'text-xs text-gray-500 underline hover:text-black focus:outline-none'
|
||||
}
|
||||
onClick={() => {
|
||||
setRemovingRoadmapId('');
|
||||
setChangingRoadmapId(resourceId);
|
||||
}}
|
||||
>
|
||||
Customize
|
||||
</button>
|
||||
|
||||
{removingRoadmapId !== resourceId && (
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
'text-xs text-red-500 underline hover:text-black focus:outline-none disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-red-500'
|
||||
}
|
||||
disabled={resourceConfigs.length === 1}
|
||||
onClick={() => setRemovingRoadmapId(resourceId)}
|
||||
title={
|
||||
resourceConfigs.length === 1
|
||||
? 'You must have at least one roadmap.'
|
||||
: 'Delete roadmap from team'
|
||||
}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
|
||||
{removingRoadmapId === resourceId && (
|
||||
<span className="text-xs">
|
||||
Are you sure?{' '}
|
||||
<button
|
||||
onClick={() => onRemove(resourceId)}
|
||||
className="mx-0.5 text-red-500 underline underline-offset-1"
|
||||
>
|
||||
Yes
|
||||
</button>{' '}
|
||||
<button
|
||||
onClick={() => setRemovingRoadmapId('')}
|
||||
className="text-red-500 underline underline-offset-1"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{canManageCurrentTeam && (
|
||||
<button
|
||||
onClick={() => setIsAddingRoadmap(true)}
|
||||
className="group flex min-h-[110px] flex-col items-center justify-center rounded-md border border-dashed border-gray-300 transition-colors hover:border-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
<img
|
||||
alt="add"
|
||||
src={PlusIcon}
|
||||
className="mb-1 h-6 w-6 opacity-20 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
<span className="text-sm text-gray-400 transition-colors focus:outline-none group-hover:text-black">
|
||||
Add Roadmap
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
291
src/components/TeamSettings/UpdateTeamForm.tsx
Normal file
@ -0,0 +1,291 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { httpGet, httpPut } from '../../lib/http';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import { useAuth } from '../../hooks/use-auth';
|
||||
import UploadProfilePicture from '../UpdateProfile/UploadProfilePicture';
|
||||
import type { TeamDocument } from '../CreateTeam/CreateTeamForm';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { useTeamId } from '../../hooks/use-team-id';
|
||||
import { DeleteTeamPopup } from '../DeleteTeamPopup';
|
||||
import { $currentTeam, $isCurrentTeamAdmin } from '../../stores/team';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
export function UpdateTeamForm() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const isCurrentTeamAdmin = useStore($isCurrentTeamAdmin);
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [avatar, setAvatar] = useState('');
|
||||
const [website, setWebsite] = useState('');
|
||||
const [linkedIn, setLinkedIn] = useState('');
|
||||
const [gitHub, setGitHub] = useState('');
|
||||
const [teamType, setTeamType] = useState('');
|
||||
const [teamSize, setTeamSize] = useState('');
|
||||
const [roadmaps, setRoadmaps] = useState<string[]>([]);
|
||||
const [bestPractices, setBestPractices] = useState<string[]>([]);
|
||||
const validTeamSizes = [
|
||||
'0-1',
|
||||
'2-10',
|
||||
'11-50',
|
||||
'51-200',
|
||||
'201-500',
|
||||
'501-1000',
|
||||
'1000+',
|
||||
];
|
||||
const [isDisabled, setIsDisabled] = useState(false);
|
||||
const { teamId } = useTeamId();
|
||||
|
||||
useEffect(() => {
|
||||
setIsDisabled(!isCurrentTeamAdmin);
|
||||
}, [isCurrentTeamAdmin]);
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
if (!name || !teamType) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { response, error } = await httpPut(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-update-team/${teamId}`,
|
||||
{
|
||||
name,
|
||||
website,
|
||||
type: teamType,
|
||||
gitHubUrl: gitHub || undefined,
|
||||
...(teamType === 'company' && {
|
||||
teamSize,
|
||||
linkedInUrl: linkedIn || undefined,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
setIsLoading(false);
|
||||
toast.error(error.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
if (response) {
|
||||
await loadTeam();
|
||||
setIsLoading(false);
|
||||
toast.success('Team updated successfully');
|
||||
}
|
||||
};
|
||||
|
||||
async function loadTeam() {
|
||||
const { response, error } = await httpGet<TeamDocument>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team/${teamId}`
|
||||
);
|
||||
if (error || !response) {
|
||||
console.log(error);
|
||||
return;
|
||||
}
|
||||
|
||||
setName(response.name);
|
||||
setAvatar(response.avatar || '');
|
||||
setWebsite(response?.links?.website || '');
|
||||
setLinkedIn(response?.links?.linkedIn || '');
|
||||
setGitHub(response?.links?.github || '');
|
||||
setTeamType(response.type);
|
||||
if (response.teamSize) {
|
||||
setTeamSize(response.teamSize);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!teamId) {
|
||||
return;
|
||||
}
|
||||
loadTeam().finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}, [teamId]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<UploadProfilePicture
|
||||
isDisabled={isDisabled}
|
||||
type="logo"
|
||||
avatarUrl={
|
||||
avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}`
|
||||
: '/images/default-avatar.png'
|
||||
}
|
||||
teamId={teamId!}
|
||||
/>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label
|
||||
for="name"
|
||||
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="roadmap.sh"
|
||||
disabled={isDisabled}
|
||||
required
|
||||
value={name}
|
||||
onInput={(e) => setName((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label for="website" className="text-sm leading-none text-slate-500">
|
||||
Website
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="website"
|
||||
id="website"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="https://roadmap.sh"
|
||||
disabled={isDisabled}
|
||||
value={website}
|
||||
onInput={(e) => setWebsite((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
{teamType === 'company' && (
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label
|
||||
for="linkedIn"
|
||||
className="text-sm leading-none text-slate-500"
|
||||
>
|
||||
LinkedIn URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="linkedIn"
|
||||
id="linkedIn"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="https://linkedin.com/company/roadmapsh"
|
||||
disabled={isDisabled}
|
||||
value={linkedIn}
|
||||
onInput={(e) => setLinkedIn((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label for="gitHub" className="text-sm leading-none text-slate-500">
|
||||
GitHub URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="gitHub"
|
||||
id="gitHub"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="https://github.com/roadmapsh"
|
||||
disabled={isDisabled}
|
||||
value={gitHub}
|
||||
onInput={(e) => setGitHub((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label
|
||||
for="type"
|
||||
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
|
||||
>
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
name="type"
|
||||
id="type"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
disabled={isDisabled}
|
||||
value={teamType}
|
||||
onChange={(e) =>
|
||||
setTeamType((e.target as HTMLSelectElement).value as any)
|
||||
}
|
||||
>
|
||||
<option value="" selected>
|
||||
Select type
|
||||
</option>
|
||||
<option value="company">Company</option>
|
||||
<option value="study_group">Study Group</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{teamType === 'company' && (
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label
|
||||
for="team-size"
|
||||
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
|
||||
>
|
||||
Team size
|
||||
</label>
|
||||
<select
|
||||
name="team-size"
|
||||
id="team-size"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
required={teamType === 'company'}
|
||||
disabled={isDisabled}
|
||||
value={teamSize}
|
||||
onChange={(e) =>
|
||||
setTeamSize((e.target as HTMLSelectElement).value as any)
|
||||
}
|
||||
>
|
||||
<option value="" selected>
|
||||
Select team size
|
||||
</option>
|
||||
{validTeamSizes.map((size) => (
|
||||
<option value={size}>{size} people</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
|
||||
disabled={isDisabled || isLoading}
|
||||
>
|
||||
{isLoading ? <Spinner /> : 'Update'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{!isCurrentTeamAdmin && (
|
||||
<p className="mt-2 rounded-lg border border-red-300 bg-red-50 p-2 text-sm text-red-700">
|
||||
Only team admins can update team information.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isCurrentTeamAdmin && (
|
||||
<>
|
||||
<hr class="my-8" />
|
||||
{isDeleting && (
|
||||
<DeleteTeamPopup
|
||||
onClose={() => {
|
||||
toast.success('Team deleted successfully');
|
||||
setIsDeleting(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<h2 class="text-xl font-bold sm:text-2xl">Delete Team</h2>
|
||||
<p class="mt-2 text-gray-400">
|
||||
Permanently delete this team and all of its resources.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => setIsDeleting(true)}
|
||||
data-popup="delete-team-popup"
|
||||
class="font-regular mt-4 w-full rounded-lg bg-red-600 py-2 text-base text-white outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-1"
|
||||
>
|
||||
Delete Team
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
142
src/components/TeamSidebar.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import type { FunctionalComponent } from 'preact';
|
||||
import { TeamDropdown } from './TeamDropdown/TeamDropdown';
|
||||
import ChevronDown from '../icons/dropdown.svg';
|
||||
import { useTeamId } from '../hooks/use-team-id';
|
||||
import TeamProgress from '../icons/team-progress.svg';
|
||||
import SettingsIcon from '../icons/cog.svg';
|
||||
import MapIcon from '../icons/map.svg';
|
||||
import GroupIcon from '../icons/group.svg';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { $canManageCurrentTeam } from '../stores/team';
|
||||
|
||||
export const TeamSidebar: FunctionalComponent<{
|
||||
activePageId: string;
|
||||
}> = ({ activePageId, children }) => {
|
||||
const [menuShown, setMenuShown] = useState(false);
|
||||
const canManageCurrentTeam = useStore($canManageCurrentTeam);
|
||||
|
||||
const { teamId } = useTeamId();
|
||||
|
||||
const sidebarLinks = [
|
||||
{
|
||||
title: 'Progress',
|
||||
href: `/team/progress?t=${teamId}`,
|
||||
id: 'progress',
|
||||
icon: TeamProgress,
|
||||
},
|
||||
{
|
||||
title: 'Roadmaps',
|
||||
href: `/team/roadmaps?t=${teamId}`,
|
||||
id: 'roadmaps',
|
||||
icon: MapIcon,
|
||||
},
|
||||
{
|
||||
title: 'Members',
|
||||
href: `/team/members?t=${teamId}`,
|
||||
id: 'members',
|
||||
icon: GroupIcon,
|
||||
},
|
||||
{
|
||||
title: 'Settings',
|
||||
href: `/team/settings?t=${teamId}`,
|
||||
id: 'settings',
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="relative mb-5 block border-b p-4 shadow-inner md:hidden">
|
||||
<button
|
||||
class="flex h-10 w-full items-center justify-between rounded-md border bg-white px-2 text-center text-sm font-medium text-gray-900"
|
||||
id="settings-menu"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="true"
|
||||
onClick={() => setMenuShown(!menuShown)}
|
||||
>
|
||||
{
|
||||
sidebarLinks.find((sidebarLink) => sidebarLink.id === activePageId)
|
||||
?.title
|
||||
}
|
||||
<img alt="menu" src={ChevronDown} class="h-4 w-4" />
|
||||
</button>
|
||||
{menuShown && (
|
||||
<ul
|
||||
id="settings-menu-dropdown"
|
||||
class="absolute left-0 right-0 z-50 mt-1 space-y-1.5 bg-white p-2 shadow-lg"
|
||||
>
|
||||
<li>
|
||||
<a
|
||||
href="/team"
|
||||
class={`flex w-full items-center rounded px-3 py-1.5 text-sm text-slate-900 hover:bg-slate-200 ${
|
||||
activePageId === 'team' ? 'bg-slate-100' : ''
|
||||
}`}
|
||||
>
|
||||
<img alt={'teams'} src={GroupIcon} class={`mr-2 h-4 w-4`} />
|
||||
Teams
|
||||
</a>
|
||||
</li>
|
||||
{sidebarLinks.map((sidebarLink) => {
|
||||
const isActive = activePageId === sidebarLink.id;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
href={sidebarLink.href}
|
||||
class={`flex w-full items-center rounded px-3 py-1.5 text-sm text-slate-900 hover:bg-slate-200 ${
|
||||
isActive ? 'bg-slate-100' : ''
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
alt={'menu icon'}
|
||||
src={sidebarLink.icon}
|
||||
className="mr-2 h-4 w-4"
|
||||
/>
|
||||
{sidebarLink.title}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="container flex min-h-screen items-stretch">
|
||||
<aside class="hidden w-44 shrink-0 border-r border-slate-200 py-10 md:block">
|
||||
<TeamDropdown />
|
||||
<nav>
|
||||
<ul class="space-y-1">
|
||||
{sidebarLinks.map((sidebarLink) => {
|
||||
const isActive = activePageId === sidebarLink.id;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
href={sidebarLink.href}
|
||||
class={`font-regular flex w-full items-center border-r-2 px-2 py-1.5 text-sm ${
|
||||
isActive
|
||||
? 'border-r-black bg-gray-100 text-black'
|
||||
: 'border-r-transparent text-gray-500 hover:border-r-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span class="flex flex-grow items-center">
|
||||
<img
|
||||
alt="menu icon"
|
||||
src={sidebarLink.icon}
|
||||
className="mr-2 h-4 w-4"
|
||||
/>
|
||||
{sidebarLink.title}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
<div className="grow px-0 py-0 md:px-10 md:py-10">{children}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
220
src/components/TeamVersions/TeamVersions.tsx
Normal file
@ -0,0 +1,220 @@
|
||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||
import type { TeamDocument } from '../CreateTeam/CreateTeamForm';
|
||||
import type { TeamResourceConfig } from '../CreateTeam/RoadmapSelector';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import DropdownIcon from '../../icons/dropdown.svg';
|
||||
import {
|
||||
clearResourceProgress,
|
||||
refreshProgressCounters,
|
||||
renderTopicProgress,
|
||||
} from '../../lib/resource-progress';
|
||||
import { renderResourceProgress } from '../../lib/resource-progress';
|
||||
import { deleteUrlParam, getUrlParams, setUrlParams } from '../../lib/browser';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { useAuth } from '../../hooks/use-auth';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
type TeamVersionsProps = {
|
||||
resourceId: string;
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
};
|
||||
|
||||
type TeamVersionsResponse = {
|
||||
team: TeamDocument;
|
||||
config: TeamResourceConfig[0];
|
||||
}[];
|
||||
|
||||
export function TeamVersions(props: TeamVersionsProps) {
|
||||
const { t: teamId } = getUrlParams();
|
||||
if (!isLoggedIn()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { resourceId, resourceType } = props;
|
||||
const user = useAuth();
|
||||
const toast = useToast();
|
||||
const teamDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isPreparing, setIsPreparing] = useState(true);
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [containerOpacity, setContainerOpacity] = useState(0);
|
||||
const [teamVersions, setTeamVersions] = useState<TeamVersionsResponse>([]);
|
||||
const [selectedTeamVersion, setSelectedTeamVersion] = useState<
|
||||
TeamVersionsResponse[0] | null
|
||||
>(null);
|
||||
let shouldShowAvatar = true;
|
||||
const selectedAvatar = selectedTeamVersion
|
||||
? selectedTeamVersion.team.avatar
|
||||
: user?.avatar;
|
||||
const selectedLabel = selectedTeamVersion
|
||||
? selectedTeamVersion.team.name
|
||||
: user?.name;
|
||||
|
||||
// Show avatar if team has one, or if user has one otherwise use first letter of name
|
||||
if (selectedTeamVersion?.team.avatar) {
|
||||
shouldShowAvatar = true;
|
||||
} else if (!selectedTeamVersion && user?.avatar) {
|
||||
shouldShowAvatar = true;
|
||||
} else {
|
||||
shouldShowAvatar = false;
|
||||
}
|
||||
|
||||
useOutsideClick(teamDropdownRef, () => {
|
||||
setIsDropdownOpen(false);
|
||||
});
|
||||
|
||||
useKeydown('Escape', () => {
|
||||
setIsDropdownOpen(false);
|
||||
});
|
||||
|
||||
async function loadTeamVersions() {
|
||||
const { response, error } = await httpGet<TeamVersionsResponse>(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-get-team-versions?${new URLSearchParams({
|
||||
resourceId,
|
||||
resourceType,
|
||||
})}`
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Failed to load team versions.');
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamVersions(response);
|
||||
if (teamId) {
|
||||
const foundVersion = response.find((v) => v.team._id === teamId) || null;
|
||||
setSelectedTeamVersion(foundVersion);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setIsPreparing(false);
|
||||
|
||||
setTimeout(() => {
|
||||
setContainerOpacity(100);
|
||||
}, 50);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadTeamVersions().finally(() => {
|
||||
//
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (isPreparing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
clearResourceProgress();
|
||||
if (!selectedTeamVersion) {
|
||||
deleteUrlParam('t');
|
||||
renderResourceProgress(resourceType, resourceId).then();
|
||||
return;
|
||||
}
|
||||
|
||||
setUrlParams({ t: selectedTeamVersion.team._id! });
|
||||
|
||||
renderResourceProgress(resourceType, resourceId).then(() => {
|
||||
selectedTeamVersion.config?.removed?.forEach((topic) => {
|
||||
renderTopicProgress(topic, 'removed');
|
||||
});
|
||||
refreshProgressCounters();
|
||||
});
|
||||
}, [selectedTeamVersion]);
|
||||
|
||||
if (!teamVersions.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative h-7 transition-opacity duration-500 sm:h-auto opacity-${containerOpacity}`}
|
||||
>
|
||||
<button
|
||||
className="inline-flex h-7 items-center justify-between gap-1 rounded-md border px-1.5 py-1.5 text-xs font-medium hover:bg-gray-50 focus:outline-0 sm:h-8 sm:w-40 sm:px-3 sm:text-sm"
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
>
|
||||
<div className="hidden w-full items-center justify-between sm:flex">
|
||||
<span className="truncate">
|
||||
{selectedTeamVersion?.team.name || 'Team Versions'}
|
||||
</span>
|
||||
<img
|
||||
alt="Dropdown"
|
||||
src={DropdownIcon}
|
||||
class="h-3 w-3 sm:h-4 sm:w-4"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:hidden">
|
||||
{shouldShowAvatar ? (
|
||||
<img
|
||||
src={
|
||||
selectedAvatar
|
||||
? `${
|
||||
import.meta.env.PUBLIC_AVATAR_BASE_URL
|
||||
}/${selectedAvatar}`
|
||||
: '/images/default-avatar.png'
|
||||
}
|
||||
alt={`${selectedLabel} Avatar`}
|
||||
className="h-5 w-5 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-gray-200 text-xs">
|
||||
{selectedLabel?.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{isDropdownOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40 block bg-black/20 sm:hidden"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
ref={teamDropdownRef}
|
||||
className="fixed bottom-0 left-0 z-50 mt-1 h-fit w-full overflow-hidden rounded-md bg-white py-0.5 shadow-md sm:absolute sm:left-0 sm:right-0 sm:top-full sm:border"
|
||||
>
|
||||
<button
|
||||
className={`flex h-8 w-full items-center justify-between px-3 py-1.5 text-xs font-medium hover:bg-gray-100 sm:text-sm ${
|
||||
!selectedTeamVersion ? 'bg-gray-100' : 'bg-white'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedTeamVersion(null);
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="truncate">Personal</span>
|
||||
</div>
|
||||
</button>
|
||||
{teamVersions.map((team) => {
|
||||
const isSelectedTeam =
|
||||
selectedTeamVersion?.team._id === team.team._id;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`flex h-8 w-full items-center justify-between px-3 py-1.5 text-xs font-medium hover:bg-gray-100 sm:text-sm ${
|
||||
isSelectedTeam ? 'bg-gray-100' : 'bg-white'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedTeamVersion(team);
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="truncate">{team.team.name}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
102
src/components/TeamsList.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import ChevronDown from '../icons/dropdown.svg';
|
||||
import { httpGet } from '../lib/http';
|
||||
import { useTeamId } from '../hooks/use-team-id';
|
||||
import { useAuth } from '../hooks/use-auth';
|
||||
import { useOutsideClick } from '../hooks/use-outside-click';
|
||||
import type { TeamDocument } from './CreateTeam/CreateTeamForm';
|
||||
import { pageProgressMessage } from '../stores/page';
|
||||
import { useToast } from '../hooks/use-toast';
|
||||
|
||||
type TeamListResponse = TeamDocument[];
|
||||
|
||||
export function TeamsList() {
|
||||
const [teamList, setTeamList] = useState<TeamDocument[]>([]);
|
||||
const user = useAuth();
|
||||
const toast = useToast();
|
||||
async function getAllTeam() {
|
||||
const { response, error } = await httpGet<TeamListResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-teams`
|
||||
);
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamList(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getAllTeam().finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative max-w-[500px] mx-auto">
|
||||
<div className="w-full px-2 py-2">
|
||||
<div className={'mb-8 hidden md:block'}>
|
||||
<h2 className={'text-3xl font-bold sm:text-4xl'}>Teams</h2>
|
||||
<p className="mt-2 text-gray-400">
|
||||
Here are the teams you are part of
|
||||
</p>
|
||||
</div>
|
||||
<ul class="mb-3 flex flex-col gap-1">
|
||||
<li>
|
||||
<a
|
||||
className="flex w-full cursor-pointer items-center justify-between gap-2 truncate rounded border p-2 text-sm font-medium hover:border-gray-300 hover:bg-gray-50"
|
||||
href="/account"
|
||||
>
|
||||
<span className="flex flex-grow items-center gap-2">
|
||||
<img
|
||||
src={
|
||||
user?.avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${
|
||||
user?.avatar
|
||||
}`
|
||||
: '/images/default-avatar.png'
|
||||
}
|
||||
alt={user?.name || ''}
|
||||
className="h-6 w-6 rounded-full"
|
||||
/>
|
||||
<span className="truncate">Personal Account</span>
|
||||
</span>
|
||||
<span>→</span>
|
||||
</a>
|
||||
</li>
|
||||
{teamList.map((team) => (
|
||||
<li>
|
||||
<a
|
||||
className="flex w-full cursor-pointer items-center justify-between gap-2 rounded border p-2 text-sm font-medium hover:border-gray-300 hover:bg-gray-50"
|
||||
href={`/team/progress?t=${team._id}`}
|
||||
>
|
||||
<span className="flex flex-grow items-center gap-2">
|
||||
<img
|
||||
src={
|
||||
team.avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${
|
||||
team.avatar
|
||||
}`
|
||||
: '/images/default-avatar.png'
|
||||
}
|
||||
alt={team.name || ''}
|
||||
className="h-6 w-6 rounded-full"
|
||||
/>
|
||||
<span className="truncate">{team.name}</span>
|
||||
</span>
|
||||
<span>→</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<a
|
||||
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
|
||||
href="/team/new"
|
||||
>
|
||||
<span class='mr-2'>+</span>
|
||||
<span>New Team</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
67
src/components/Toast.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { $toastMessage } from '../stores/toast';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import { CheckIcon } from './ReactIcons/CheckIcon';
|
||||
import { ErrorIcon } from './ReactIcons/ErrorIcon';
|
||||
import { WarningIcon } from './ReactIcons/WarningIcon';
|
||||
import { InfoIcon } from './ReactIcons/InfoIcon';
|
||||
import { Spinner } from './ReactIcons/Spinner';
|
||||
|
||||
export interface Props {}
|
||||
|
||||
export function Toaster(props: Props) {
|
||||
const toastMessage = useStore($toastMessage);
|
||||
|
||||
useEffect(() => {
|
||||
if (toastMessage === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const removeMessage = setTimeout(() => {
|
||||
if (toastMessage?.type !== 'loading') {
|
||||
// $toastMessage.set(undefined);
|
||||
}
|
||||
}, 2500);
|
||||
|
||||
return () => {
|
||||
clearTimeout(removeMessage);
|
||||
};
|
||||
}, [toastMessage]);
|
||||
|
||||
if (!toastMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
$toastMessage.set(undefined);
|
||||
}}
|
||||
className={`fixed bottom-5 left-1/2 max-w-[300px] animate-fade-slide-up min-w-[300px] sm:min-w-[auto] z-50`}
|
||||
>
|
||||
<div
|
||||
className={`flex -translate-x-1/2 transform cursor-pointer items-center gap-2 rounded-md border border-gray-200 bg-white py-3 pl-4 pr-5 text-black shadow-md hover:bg-gray-50`}
|
||||
>
|
||||
{toastMessage.type === 'success' && (
|
||||
<CheckIcon additionalClasses="h-5 w-5 shrink-0 relative top-[0.5px] text-green-500" />
|
||||
)}
|
||||
|
||||
{toastMessage.type === 'error' && (
|
||||
<ErrorIcon additionalClasses="h-5 w-5 shrink-0 relative top-[0.5px] text-red-500" />
|
||||
)}
|
||||
|
||||
{toastMessage.type === 'warning' && (
|
||||
<WarningIcon additionalClasses="h-5 w-5 shrink-0 relative top-[0.5px] text-orange-500" />
|
||||
)}
|
||||
|
||||
{toastMessage.type === 'info' && (
|
||||
<InfoIcon additionalClasses="h-5 w-5 shrink-0 relative top-[0.5px] text-blue-500" />
|
||||
)}
|
||||
|
||||
{toastMessage.type === 'loading' && <Spinner isDualRing={false} />}
|
||||
|
||||
<span className="flex-grow text-base">{toastMessage.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
type ContributionInputProps = {
|
||||
id: number;
|
||||
@ -116,6 +117,7 @@ type ContributionFormProps = {
|
||||
|
||||
export function ContributionForm(props: ContributionFormProps) {
|
||||
const { onClose, resourceType, resourceId, topicId } = props;
|
||||
const toast = useToast();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [links, setLinks] = useState<
|
||||
{ id: number; title: string; link: string }[]
|
||||
@ -144,7 +146,7 @@ export function ContributionForm(props: ContributionFormProps) {
|
||||
setIsSubmitting(false);
|
||||
|
||||
if (!response || error) {
|
||||
alert(error?.message || 'Something went wrong. Please try again.');
|
||||
toast.error(error?.message || 'Something went wrong. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -19,6 +19,7 @@ import { pageProgressMessage, sponsorHidden } from '../../stores/page';
|
||||
import { TopicProgressButton } from './TopicProgressButton';
|
||||
import { ContributionForm } from './ContributionForm';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
export function TopicDetail() {
|
||||
const [contributionAlertMessage, setContributionAlertMessage] = useState('');
|
||||
@ -27,6 +28,7 @@ export function TopicDetail() {
|
||||
const [isContributing, setIsContributing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [topicHtml, setTopicHtml] = useState('');
|
||||
const toast = useToast();
|
||||
|
||||
const isGuest = useMemo(() => !isLoggedIn(), []);
|
||||
const topicRef = useRef<HTMLDivElement>(null);
|
||||
@ -78,7 +80,7 @@ export function TopicDetail() {
|
||||
refreshProgressCounters();
|
||||
})
|
||||
.catch((err) => {
|
||||
alert(err.message);
|
||||
toast.error(err.message);
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
|
@ -13,6 +13,7 @@ import {
|
||||
updateResourceProgress,
|
||||
} from '../../lib/resource-progress';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
type TopicProgressButtonProps = {
|
||||
topicId: string;
|
||||
@ -27,12 +28,13 @@ const statusColors: Record<ResourceProgressType, string> = {
|
||||
learning: 'bg-yellow-500',
|
||||
pending: 'bg-gray-300',
|
||||
skipped: 'bg-black',
|
||||
removed: ''
|
||||
};
|
||||
|
||||
export function TopicProgressButton(props: TopicProgressButtonProps) {
|
||||
const { topicId, resourceId, resourceType, onClose } =
|
||||
props;
|
||||
const { topicId, resourceId, resourceType, onClose } = props;
|
||||
|
||||
const toast = useToast();
|
||||
const [isUpdatingProgress, setIsUpdatingProgress] = useState(true);
|
||||
const [progress, setProgress] = useState<ResourceProgressType>('pending');
|
||||
const [showChangeStatus, setShowChangeStatus] = useState(false);
|
||||
@ -139,7 +141,7 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
|
||||
refreshProgressCounters();
|
||||
})
|
||||
.catch((err) => {
|
||||
alert(err.message);
|
||||
toast.error(err.message || 'Error updating progress');
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
|
@ -86,6 +86,8 @@ export function UpdateProfileForm() {
|
||||
<p className="mt-2 text-gray-400">Update your profile details below.</p>
|
||||
</div>
|
||||
<UploadProfilePicture
|
||||
type="avatar"
|
||||
label="Profile picture"
|
||||
avatarUrl={
|
||||
avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}`
|
||||
|
@ -7,7 +7,11 @@ interface PreviewFile extends File {
|
||||
}
|
||||
|
||||
type UploadProfilePictureProps = {
|
||||
isDisabled?: boolean;
|
||||
avatarUrl: string;
|
||||
type: 'avatar' | 'logo';
|
||||
label?: string;
|
||||
teamId?: string;
|
||||
};
|
||||
|
||||
function getDimensions(file: File) {
|
||||
@ -48,7 +52,7 @@ async function validateImage(file: File): Promise<string | null> {
|
||||
}
|
||||
|
||||
export default function UploadProfilePicture(props: UploadProfilePictureProps) {
|
||||
const { avatarUrl } = props;
|
||||
const { avatarUrl, teamId, type, isDisabled = false } = props;
|
||||
|
||||
const [file, setFile] = useState<PreviewFile | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
@ -91,14 +95,26 @@ export default function UploadProfilePicture(props: UploadProfilePictureProps) {
|
||||
formData.append('avatar', file);
|
||||
|
||||
// FIXME: Use `httpCall` helper instead of fetch
|
||||
const res = await fetch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-upload-profile-picture`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
}
|
||||
);
|
||||
let res: Response;
|
||||
if (type === 'avatar') {
|
||||
res = await fetch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-upload-profile-picture`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
}
|
||||
);
|
||||
} else {
|
||||
res = await fetch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-upload-team-logo/${teamId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
@ -132,9 +148,11 @@ export default function UploadProfilePicture(props: UploadProfilePictureProps) {
|
||||
encType="multipart/form-data"
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<label htmlFor="avatar" className="text-sm leading-none text-slate-500">
|
||||
Profile Picture
|
||||
</label>
|
||||
{props.label && (
|
||||
<label htmlFor="avatar" className="text-sm leading-none text-slate-500">
|
||||
{props.label}
|
||||
</label>
|
||||
)}
|
||||
<div className="mb-2 mt-2 flex items-center gap-2">
|
||||
<label
|
||||
htmlFor="avatar"
|
||||
@ -152,8 +170,9 @@ export default function UploadProfilePicture(props: UploadProfilePictureProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!file && (
|
||||
{!file && !isDisabled && (
|
||||
<button
|
||||
disabled={isDisabled}
|
||||
type="button"
|
||||
className="absolute bottom-1 right-0 rounded bg-gray-600 px-2 py-1 text-xs leading-none text-gray-50 ring-2 ring-white"
|
||||
onClick={() => {
|
||||
@ -166,6 +185,7 @@ export default function UploadProfilePicture(props: UploadProfilePictureProps) {
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
disabled={isDisabled}
|
||||
ref={inputRef}
|
||||
id="avatar"
|
||||
type="file"
|
||||
@ -184,14 +204,14 @@ export default function UploadProfilePicture(props: UploadProfilePictureProps) {
|
||||
inputRef.current?.value && (inputRef.current.value = '');
|
||||
}}
|
||||
className="flex h-9 min-w-[96px] items-center justify-center rounded-md border border-red-300 bg-red-100 text-sm font-medium text-red-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isDisabled}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex h-9 min-w-[96px] items-center justify-center rounded-md border border-gray-300 text-sm font-medium text-black disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isDisabled}
|
||||
>
|
||||
{isLoading ? 'Uploading..' : 'Upload'}
|
||||
</button>
|
||||
|
@ -1,5 +1,9 @@
|
||||
/**
|
||||
* @type {import('astro').ClientDirective}
|
||||
*/
|
||||
export default async (load, opts) => {
|
||||
const isAuthenticated = document.cookie.toString().indexOf('__roadmapsh_jt__') !== -1;
|
||||
const isAuthenticated =
|
||||
document.cookie.toString().indexOf('__roadmapsh_jt__') !== -1;
|
||||
if (isAuthenticated) {
|
||||
const hydrate = await load();
|
||||
await hydrate();
|
||||
|
@ -1,12 +1,11 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import { TOKEN_COOKIE_NAME, decodeToken } from '../lib/jwt';
|
||||
import { decodeToken, TOKEN_COOKIE_NAME } from '../lib/jwt';
|
||||
|
||||
export function useAuth() {
|
||||
const token = Cookies.get(TOKEN_COOKIE_NAME);
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const user = decodeToken(token);
|
||||
|
||||
return user;
|
||||
return decodeToken(token);
|
||||
}
|
||||
|
16
src/hooks/use-params.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
export function useParams<T = Record<string, any>>(): T {
|
||||
const [params, setParams] = useState<T>({} as T);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const paramsObj: Record<string, any> = {};
|
||||
for (const [key, value] of params.entries()) {
|
||||
paramsObj[key] = value;
|
||||
}
|
||||
setParams(paramsObj as T);
|
||||
}, []);
|
||||
|
||||
return params
|
||||
}
|
12
src/hooks/use-team-id.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
export function useTeamId() {
|
||||
const [teamId, setTeamId] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
const searchTeamId =
|
||||
new URLSearchParams(window.location.search).get('t') || null;
|
||||
setTeamId(searchTeamId);
|
||||
}, []);
|
||||
|
||||
return { teamId };
|
||||
}
|
23
src/hooks/use-toast.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { $toastMessage } from '../stores/toast';
|
||||
|
||||
export function useToast() {
|
||||
function success(message: string) {
|
||||
$toastMessage.set({ type: 'success', message });
|
||||
}
|
||||
function error(message: string) {
|
||||
$toastMessage.set({ type: 'error', message });
|
||||
}
|
||||
function info(message: string) {
|
||||
$toastMessage.set({ type: 'info', message });
|
||||
}
|
||||
|
||||
function warning(message: string) {
|
||||
$toastMessage.set({ type: 'warning', message });
|
||||
}
|
||||
|
||||
function loading(message: string) {
|
||||
$toastMessage.set({ type: 'loading', message });
|
||||
}
|
||||
|
||||
return { success, error, info, warning, loading, $toastMessage };
|
||||
}
|
3
src/icons/accept.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="#000" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
After Width: | Height: | Size: 211 B |
1
src/icons/building.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg data-v-56bd7dfc="" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-building-2"><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"></path><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"></path><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"></path><path d="M10 6h4"></path><path d="M10 10h4"></path><path d="M10 14h4"></path><path d="M10 18h4"></path></svg>
|
After Width: | Height: | Size: 516 B |
5
src/icons/close-dark.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg aria-hidden="true" class="w-5 h-5" fill="#000" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 385 B |
1
src/icons/external-link.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-external-link"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" x2="21" y1="14" y2="3"/></svg>
|
After Width: | Height: | Size: 364 B |
1
src/icons/group.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-users"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
After Width: | Height: | Size: 372 B |
1
src/icons/map.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map"><polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21"/><line x1="9" x2="9" y1="3" y2="18"/><line x1="15" x2="15" y1="6" y2="21"/></svg>
|
After Width: | Height: | Size: 346 B |
1
src/icons/more-vertical.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m12 16.495c1.242 0 2.25 1.008 2.25 2.25s-1.008 2.25-2.25 2.25-2.25-1.008-2.25-2.25 1.008-2.25 2.25-2.25zm0-6.75c1.242 0 2.25 1.008 2.25 2.25s-1.008 2.25-2.25 2.25-2.25-1.008-2.25-2.25 1.008-2.25 2.25-2.25zm0-6.75c1.242 0 2.25 1.008 2.25 2.25s-1.008 2.25-2.25 2.25-2.25-1.008-2.25-2.25 1.008-2.25 2.25-2.25z"/></svg>
|
After Width: | Height: | Size: 470 B |
3
src/icons/notification.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
|
||||
</svg>
|
After Width: | Height: | Size: 407 B |
1
src/icons/plus.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-plus-2"><path d="M4 22h14a2 2 0 0 0 2-2V7.5L14.5 2H6a2 2 0 0 0-2 2v4"/><polyline points="14 2 14 8 20 8"/><path d="M3 15h6"/><path d="M6 12v6"/></svg>
|
After Width: | Height: | Size: 357 B |
@ -1 +1,3 @@
|
||||
<svg class='h-3 w-3' viewBox="0 0 24 24" focusable="false"><path fill="currentColor" d="M23.414,20.591l-4.645-4.645a10.256,10.256,0,1,0-2.828,2.829l4.645,4.644a2.025,2.025,0,0,0,2.828,0A2,2,0,0,0,23.414,20.591ZM10.25,3.005A7.25,7.25,0,1,1,3,10.255,7.258,7.258,0,0,1,10.25,3.005Z"></path></svg>
|
||||
<svg class="h-3 w-3" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M23.4145 20.5913L18.7695 15.9463C20.1838 13.8291 20.7601 11.2616 20.3862 8.74311C20.0123 6.22462 18.715 3.93524 16.7466 2.32029C14.7782 0.705331 12.2795 -0.119724 9.73651 0.00560621C7.19351 0.130936 4.78803 1.19769 2.98799 2.99837C1.18795 4.79905 0.122047 7.2049 -0.00238424 9.74795C-0.126815 12.291 0.699123 14.7894 2.31477 16.7572C3.93042 18.725 6.22026 20.0215 8.73889 20.3945C11.2575 20.7675 13.8248 20.1903 15.9415 18.7753L20.5865 23.4193C20.9647 23.7882 21.4721 23.9947 22.0005 23.9947C22.5288 23.9947 23.0363 23.7882 23.4145 23.4193C23.7894 23.0442 24 22.5356 24 22.0053C24 21.4749 23.7894 20.9663 23.4145 20.5913ZM10.2505 3.00527C11.6844 3.00527 13.0861 3.43047 14.2784 4.22711C15.4706 5.02375 16.3999 6.15605 16.9486 7.48081C17.4973 8.80558 17.6409 10.2633 17.3612 11.6697C17.0814 13.076 16.3909 14.3679 15.377 15.3818C14.3631 16.3957 13.0712 17.0862 11.6649 17.366C10.2585 17.6457 8.80078 17.5021 7.47602 16.9534C6.15125 16.4047 5.01896 15.4754 4.22232 14.2832C3.42568 13.0909 3.00047 11.6892 3.00047 10.2553C3.00259 8.3331 3.7671 6.49026 5.12628 5.13108C6.48546 3.7719 8.3283 3.00739 10.2505 3.00527Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 294 B After Width: | Height: | Size: 1.2 KiB |
1
src/icons/team-progress.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-line-chart"><path d="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/></svg>
|
After Width: | Height: | Size: 271 B |
1
src/icons/users.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg data-v-f24af897="" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide-icon customizable"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M22 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
|
After Width: | Height: | Size: 422 B |
@ -7,6 +7,7 @@ import Footer from '../components/Footer.astro';
|
||||
import Navigation from '../components/Navigation/Navigation.astro';
|
||||
import OpenSourceBanner from '../components/OpenSourceBanner.astro';
|
||||
import { PageProgress } from '../components/PageProgress';
|
||||
import { Toaster } from '../components/Toast';
|
||||
import { PageSponsor } from '../components/PageSponsor';
|
||||
import { siteConfig } from '../lib/config';
|
||||
import '../styles/global.css';
|
||||
@ -155,6 +156,7 @@ const gaPageIdentifier = Astro.url.pathname
|
||||
<slot name="login-popup">
|
||||
<LoginPopup />
|
||||
</slot>
|
||||
<Toaster client:idle />
|
||||
<CommandMenu client:idle />
|
||||
<PageProgress initialMessage={initialLoadingMessage} client:idle />
|
||||
<PageSponsor
|
||||
|
26
src/lib/browser.ts
Normal file
@ -0,0 +1,26 @@
|
||||
export function getUrlParams() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const paramsObj: Record<string, any> = {};
|
||||
for (const [key, value] of params.entries()) {
|
||||
paramsObj[key] = value;
|
||||
}
|
||||
|
||||
return paramsObj;
|
||||
}
|
||||
|
||||
export function deleteUrlParam(key: string) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete(key);
|
||||
window.history.pushState(null, '', url.toString());
|
||||
}
|
||||
|
||||
export function setUrlParams(params: Record<string, string>) {
|
||||
const url = new URL(window.location.href);
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
url.searchParams.delete(key);
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
|
||||
window.history.pushState(null, '', url.toString());
|
||||
}
|
@ -5,11 +5,13 @@ import { TOKEN_COOKIE_NAME } from './jwt';
|
||||
type HttpOptionsType = RequestInit | { headers: Record<string, any> };
|
||||
|
||||
type AppResponse = Record<string, any>;
|
||||
type FetchError = {
|
||||
|
||||
export type FetchError = {
|
||||
status: number;
|
||||
message: string;
|
||||
};
|
||||
type AppError = {
|
||||
|
||||
export type AppError = {
|
||||
status: number;
|
||||
message: string;
|
||||
errors?: { message: string; location: string }[];
|
||||
@ -44,7 +46,7 @@ export async function httpCall<
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${Cookies.get(TOKEN_COOKIE_NAME)}`,
|
||||
'fp': fingerprint.visitorId,
|
||||
fp: fingerprint.visitorId,
|
||||
...(options?.headers ?? {}),
|
||||
}),
|
||||
});
|
||||
@ -65,6 +67,12 @@ export async function httpCall<
|
||||
if (data.status === 401) {
|
||||
Cookies.remove(TOKEN_COOKIE_NAME);
|
||||
window.location.reload();
|
||||
return { response: undefined, error: data as ErrorType };
|
||||
}
|
||||
|
||||
if (data.status === 403) {
|
||||
window.location.href = '/account';
|
||||
return { response: undefined, error: data as ErrorType };
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -7,6 +7,7 @@ export type TokenPayload = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
};
|
||||
|
||||
export function decodeToken(token: string): TokenPayload {
|
||||
|
@ -4,7 +4,7 @@ import { TOKEN_COOKIE_NAME } from './jwt';
|
||||
import Element = astroHTML.JSX.Element;
|
||||
|
||||
export type ResourceType = 'roadmap' | 'best-practice';
|
||||
export type ResourceProgressType = 'done' | 'learning' | 'pending' | 'skipped';
|
||||
export type ResourceProgressType = 'done' | 'learning' | 'pending' | 'skipped' | 'removed';
|
||||
|
||||
type TopicMeta = {
|
||||
topicId: string;
|
||||
@ -188,6 +188,8 @@ export function renderTopicProgress(
|
||||
const isLearning = topicProgress === 'learning';
|
||||
const isSkipped = topicProgress === 'skipped';
|
||||
const isDone = topicProgress === 'done';
|
||||
const isRemoved = topicProgress === 'removed';
|
||||
|
||||
const matchingElements: Element[] = [];
|
||||
|
||||
// Elements having sort order in the beginning of the group id
|
||||
@ -227,12 +229,22 @@ export function renderTopicProgress(
|
||||
} else if (isSkipped) {
|
||||
element.classList.add('skipped');
|
||||
element.classList.remove('done', 'learning');
|
||||
} else if (isRemoved) {
|
||||
element.classList.add('removed');
|
||||
element.classList.remove('done', 'learning', 'skipped');
|
||||
} else {
|
||||
element.classList.remove('done', 'skipped', 'learning');
|
||||
element.classList.remove('done', 'skipped', 'learning', 'removed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function clearResourceProgress() {
|
||||
const clickableElements = document.querySelectorAll('.clickable-group')
|
||||
for (const clickableElement of clickableElements) {
|
||||
clickableElement.classList.remove('done', 'skipped', 'learning', 'removed');
|
||||
}
|
||||
}
|
||||
|
||||
export async function renderResourceProgress(
|
||||
resourceType: ResourceType,
|
||||
resourceId: string
|
||||
@ -288,8 +300,11 @@ export function refreshProgressCounters() {
|
||||
'[data-group-id^="check:"].skipped'
|
||||
).length;
|
||||
|
||||
const totalRemoved = document.querySelectorAll(
|
||||
'.clickable-group.removed'
|
||||
).length;
|
||||
const totalItems =
|
||||
totalClickable - externalLinks - roadmapSwitchers - checkBoxes;
|
||||
totalClickable - externalLinks - roadmapSwitchers - checkBoxes - totalRemoved;
|
||||
|
||||
const totalDone =
|
||||
document.querySelectorAll('.clickable-group.done').length -
|
||||
|
@ -9,10 +9,10 @@ import { TopicDetail } from '../../components/TopicDetail/TopicDetail';
|
||||
import UpcomingForm from '../../components/UpcomingForm.astro';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import {
|
||||
generateArticleSchema,
|
||||
generateFAQSchema,
|
||||
generateArticleSchema,
|
||||
generateFAQSchema,
|
||||
} from '../../lib/jsonld-schema';
|
||||
import { RoadmapFrontmatter,getRoadmapIds } from '../../lib/roadmap';
|
||||
import { RoadmapFrontmatter, getRoadmapIds } from '../../lib/roadmap';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const roadmapIds = await getRoadmapIds();
|
||||
@ -60,7 +60,8 @@ if (roadmapFAQs.length) {
|
||||
permalink={`/${roadmapId}`}
|
||||
title={roadmapData?.seo?.title}
|
||||
briefTitle={roadmapData.briefTitle}
|
||||
ogImageUrl={roadmapData?.seo?.ogImageUrl || 'https://roadmap.sh/images/og-img.png'}
|
||||
ogImageUrl={roadmapData?.seo?.ogImageUrl ||
|
||||
'https://roadmap.sh/images/og-img.png'}
|
||||
description={roadmapData.seo.description}
|
||||
keywords={roadmapData.seo.keywords}
|
||||
noIndex={roadmapData.isUpcoming}
|
||||
|
@ -9,7 +9,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
<img
|
||||
style='--aspect-ratio:170/170'
|
||||
src='/authors/kamran.jpeg'
|
||||
class='h-[170px] w-[170px] rounded-md mr-5 hidden sm:block'
|
||||
class='h-[170px] w-[170px] rounded-full mr-6 hidden sm:block'
|
||||
alt='Kamran Ahmed'
|
||||
/>
|
||||
<div>
|
||||
|
16
src/pages/account/notification.astro
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
import AccountSidebar from '../../components/AccountSidebar.astro';
|
||||
import { NotificationPage } from '../../components/Notification/NotificationPage';
|
||||
import AccountLayout from '../../layouts/AccountLayout.astro';
|
||||
---
|
||||
|
||||
<AccountLayout
|
||||
title='Notification'
|
||||
description=''
|
||||
noIndex={true}
|
||||
initialLoadingMessage={'Loading notification'}
|
||||
>
|
||||
<AccountSidebar activePageId='notification' activePageTitle='Notification'>
|
||||
<NotificationPage client:load />
|
||||
</AccountSidebar>
|
||||
</AccountLayout>
|
@ -12,21 +12,25 @@ export async function get() {
|
||||
return {
|
||||
body: JSON.stringify([
|
||||
...roadmaps.map((roadmap) => ({
|
||||
id: roadmap.id,
|
||||
url: `/${roadmap.id}`,
|
||||
title: roadmap.frontmatter.briefTitle,
|
||||
group: 'Roadmaps',
|
||||
})),
|
||||
...bestPractices.map((bestPractice) => ({
|
||||
id: bestPractice.id,
|
||||
url: `/best-practices/${bestPractice.id}`,
|
||||
title: bestPractice.frontmatter.briefTitle,
|
||||
group: 'Best Practices',
|
||||
})),
|
||||
...guides.map((guide) => ({
|
||||
id: guide.id,
|
||||
url: `/guides/${guide.id}`,
|
||||
title: guide.frontmatter.title,
|
||||
group: 'Guides',
|
||||
})),
|
||||
...videos.map((guide) => ({
|
||||
id: guide.id,
|
||||
url: `/videos/${guide.id}`,
|
||||
title: guide.frontmatter.title,
|
||||
group: 'Videos',
|
||||
|
14
src/pages/respond-invite.astro
Normal file
@ -0,0 +1,14 @@
|
||||
---
|
||||
import AccountLayout from '../layouts/AccountLayout.astro';
|
||||
import { RespondInviteForm } from '../components/RespondInviteForm';
|
||||
import LoginPopup from "../components/AuthenticationFlow/LoginPopup.astro";
|
||||
---
|
||||
|
||||
<AccountLayout
|
||||
title='Respond Invite'
|
||||
noIndex={true}
|
||||
initialLoadingMessage={'Loading invite'}
|
||||
>
|
||||
<LoginPopup />
|
||||
<RespondInviteForm client:only />
|
||||
</AccountLayout>
|
16
src/pages/team/index.astro
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
import AccountSidebar from '../../components/AccountSidebar.astro';
|
||||
import { TeamsList } from '../../components/TeamsList.tsx';
|
||||
import { ActivityPage } from '../../components/Activity/ActivityPage';
|
||||
import AccountLayout from '../../layouts/AccountLayout.astro';
|
||||
---
|
||||
|
||||
<AccountLayout
|
||||
title='Update Profile'
|
||||
noIndex={true}
|
||||
initialLoadingMessage={'Loading teams'}
|
||||
>
|
||||
<AccountSidebar hasDesktopSidebar={false} activePageId='team' activePageTitle='Teams'>
|
||||
<TeamsList client:load />
|
||||
</AccountSidebar>
|
||||
</AccountLayout>
|
15
src/pages/team/members.astro
Normal file
@ -0,0 +1,15 @@
|
||||
---
|
||||
import { TeamSidebar } from '../../components/TeamSidebar';
|
||||
import { TeamMembersPage } from '../../components/TeamMembers/TeamMembersPage';
|
||||
import AccountLayout from '../../layouts/AccountLayout.astro';
|
||||
---
|
||||
|
||||
<AccountLayout
|
||||
title='Team Members'
|
||||
noIndex={true}
|
||||
initialLoadingMessage='Loading members'
|
||||
>
|
||||
<TeamSidebar activePageId='members' client:load>
|
||||
<TeamMembersPage client:only />
|
||||
</TeamSidebar>
|
||||
</AccountLayout>
|
11
src/pages/team/new.astro
Normal file
@ -0,0 +1,11 @@
|
||||
---
|
||||
import AccountSidebar from '../../components/AccountSidebar.astro';
|
||||
import AccountLayout from '../../layouts/AccountLayout.astro';
|
||||
import { CreateTeamForm } from '../../components/CreateTeam/CreateTeamForm';
|
||||
---
|
||||
|
||||
<AccountLayout title='Create Team' noIndex={true}>
|
||||
<AccountSidebar hasDesktopSidebar={false} activePageId='create-team' activePageTitle='Create Team'>
|
||||
<CreateTeamForm client:only />
|
||||
</AccountSidebar>
|
||||
</AccountLayout>
|
11
src/pages/team/progress.astro
Normal file
@ -0,0 +1,11 @@
|
||||
---
|
||||
import { TeamSidebar } from '../../components/TeamSidebar';
|
||||
import { TeamProgressPage } from '../../components/TeamProgress/TeamProgressPage';
|
||||
import AccountLayout from '../../layouts/AccountLayout.astro';
|
||||
---
|
||||
|
||||
<AccountLayout title='Team Progress' noIndex={true} initialLoadingMessage='Loading Progress'>
|
||||
<TeamSidebar activePageId='progress' client:load>
|
||||
<TeamProgressPage client:only />
|
||||
</TeamSidebar>
|
||||
</AccountLayout>
|
11
src/pages/team/roadmaps.astro
Normal file
@ -0,0 +1,11 @@
|
||||
---
|
||||
import { TeamSidebar } from '../../components/TeamSidebar';
|
||||
import { TeamRoadmaps } from '../../components/TeamRoadmaps';
|
||||
import AccountLayout from '../../layouts/AccountLayout.astro';
|
||||
---
|
||||
|
||||
<AccountLayout title='Roadmaps' noIndex={true} initialLoadingMessage='Loading Roadmaps'>
|
||||
<TeamSidebar activePageId='roadmaps' client:load>
|
||||
<TeamRoadmaps client:only />
|
||||
</TeamSidebar>
|
||||
</AccountLayout>
|
15
src/pages/team/settings.astro
Normal file
@ -0,0 +1,15 @@
|
||||
---
|
||||
import { TeamSidebar } from '../../components/TeamSidebar';
|
||||
import { UpdateTeamForm } from '../../components/TeamSettings/UpdateTeamForm';
|
||||
import AccountLayout from '../../layouts/AccountLayout.astro';
|
||||
---
|
||||
|
||||
<AccountLayout
|
||||
title='Team Settings'
|
||||
noIndex={true}
|
||||
initialLoadingMessage='Loading Settings'
|
||||
>
|
||||
<TeamSidebar activePageId='settings' client:load>
|
||||
<UpdateTeamForm client:load />
|
||||
</TeamSidebar>
|
||||
</AccountLayout>
|
15
src/stores/team.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { atom, computed } from 'nanostores';
|
||||
import type { UserTeamItem } from '../components/TeamDropdown/TeamDropdown';
|
||||
|
||||
export const $teamList = atom<UserTeamItem[]>([]);
|
||||
export const $currentTeam = atom<UserTeamItem | undefined>();
|
||||
|
||||
export const $currentTeamRole = computed($currentTeam, (team) => team?.role);
|
||||
|
||||
export const $isCurrentTeamAdmin = computed($currentTeamRole, (role) =>
|
||||
['admin'].includes(role!)
|
||||
);
|
||||
|
||||
export const $canManageCurrentTeam = computed($currentTeamRole, (role) =>
|
||||
['admin', 'manager'].includes(role!)
|
||||
);
|
9
src/stores/toast.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info' | 'warning' | 'loading';
|
||||
export type ToastMessage = {
|
||||
type: ToastType;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export const $toastMessage = atom<ToastMessage | undefined>(undefined);
|