mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2026-03-14 18:51:53 +08:00
Compare commits
19 Commits
chore/cach
...
chore/upda
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f5f96a6b7 | ||
|
|
14f9ad9530 | ||
|
|
076b866430 | ||
|
|
7aca57c3e4 | ||
|
|
36cd03f14f | ||
|
|
5bc33cb527 | ||
|
|
5d3202e065 | ||
|
|
5cf286a753 | ||
|
|
0addc56123 | ||
|
|
3182e2a599 | ||
|
|
ff96644751 | ||
|
|
8c7fb8cab5 | ||
|
|
f61d360ee7 | ||
|
|
081e16eb9b | ||
|
|
eefce5c6a5 | ||
|
|
29d91be094 | ||
|
|
8ee56576ea | ||
|
|
8e945f5e1c | ||
|
|
ac48f4c441 |
8
public/images/cursors/add.svg
Normal file
8
public/images/cursors/add.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg width="63" height="24" viewBox="0 0 63 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="63" height="24" rx="7" fill="#563AFF"/>
|
||||
<path d="M27.2629 16.7273H25.2856L28.2984 8H30.6763L33.6848 16.7273H31.7075L29.5214 9.99432H29.4533L27.2629 16.7273ZM27.1393 13.2969H31.8098V14.7372H27.1393V13.2969Z" fill="white"/>
|
||||
<path d="M37.829 16.7273H34.7352V8H37.8545C38.7324 8 39.4881 8.17472 40.1216 8.52415C40.7551 8.87074 41.2423 9.36932 41.5832 10.0199C41.927 10.6705 42.0989 11.4489 42.0989 12.3551C42.0989 13.2642 41.927 14.0455 41.5832 14.6989C41.2423 15.3523 40.7523 15.8537 40.1131 16.2031C39.4767 16.5526 38.7153 16.7273 37.829 16.7273ZM36.5804 15.1463H37.7523C38.2977 15.1463 38.7565 15.0497 39.1287 14.8565C39.5037 14.6605 39.7849 14.358 39.9724 13.9489C40.1628 13.5369 40.2579 13.0057 40.2579 12.3551C40.2579 11.7102 40.1628 11.1832 39.9724 10.7741C39.7849 10.3651 39.5051 10.0639 39.1329 9.87074C38.7608 9.67756 38.302 9.58097 37.7565 9.58097H36.5804V15.1463Z" fill="white"/>
|
||||
<path d="M46.5594 16.7273H43.4657V8H46.585C47.4628 8 48.2185 8.17472 48.8521 8.52415C49.4856 8.87074 49.9728 9.36932 50.3137 10.0199C50.6574 10.6705 50.8293 11.4489 50.8293 12.3551C50.8293 13.2642 50.6574 14.0455 50.3137 14.6989C49.9728 15.3523 49.4827 15.8537 48.8435 16.2031C48.2072 16.5526 47.4458 16.7273 46.5594 16.7273ZM45.3109 15.1463H46.4827C47.0282 15.1463 47.487 15.0497 47.8592 14.8565C48.2342 14.6605 48.5154 14.358 48.7029 13.9489C48.8932 13.5369 48.9884 13.0057 48.9884 12.3551C48.9884 11.7102 48.8932 11.1832 48.7029 10.7741C48.5154 10.3651 48.2356 10.0639 47.8634 9.87074C47.4913 9.67756 47.0324 9.58097 46.487 9.58097H45.3109V15.1463Z" fill="white"/>
|
||||
<path d="M10 12H18" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 8V16" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
5
public/images/cursors/remove.svg
Normal file
5
public/images/cursors/remove.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="89" height="24" viewBox="0 0 89 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="89" height="24" rx="7" fill="black"/>
|
||||
<path d="M23.8217 17V7.54545H27.5518C28.2659 7.54545 28.8752 7.67318 29.38 7.92862C29.8878 8.18099 30.274 8.53954 30.5387 9.00426C30.8065 9.46591 30.9403 10.0091 30.9403 10.6339C30.9403 11.2617 30.8049 11.8018 30.5341 12.2543C30.2633 12.7036 29.8709 13.0483 29.3569 13.2884C28.846 13.5284 28.2274 13.6484 27.5011 13.6484H25.0036V12.0419H27.1779C27.5595 12.0419 27.8765 11.9896 28.1289 11.8849C28.3813 11.7803 28.569 11.6233 28.6921 11.4141C28.8183 11.2048 28.8814 10.9447 28.8814 10.6339C28.8814 10.32 28.8183 10.0553 28.6921 9.83984C28.569 9.62441 28.3797 9.46129 28.1243 9.3505C27.8719 9.23662 27.5534 9.17969 27.1687 9.17969H25.8207V17H23.8217ZM28.9276 12.6974L31.2773 17H29.0707L26.7717 12.6974H28.9276ZM32.353 17V7.54545H38.7237V9.19354H34.3519V11.4464H38.396V13.0945H34.3519V15.3519H38.7422V17H32.353ZM40.3129 7.54545H42.7781L45.3818 13.8977H45.4926L48.0963 7.54545H50.5615V17H48.6226V10.8462H48.5441L46.0974 16.9538H44.7771L42.3303 10.8232H42.2519V17H40.3129V7.54545ZM60.8967 12.2727C60.8967 13.3037 60.7012 14.1809 60.3104 14.9041C59.9226 15.6274 59.3932 16.1798 58.7223 16.5614C58.0545 16.94 57.3035 17.1293 56.4695 17.1293C55.6293 17.1293 54.8752 16.9384 54.2074 16.5568C53.5395 16.1752 53.0117 15.6228 52.6239 14.8995C52.2362 14.1763 52.0423 13.3007 52.0423 12.2727C52.0423 11.2417 52.2362 10.3646 52.6239 9.64134C53.0117 8.91809 53.5395 8.36719 54.2074 7.98864C54.8752 7.60701 55.6293 7.41619 56.4695 7.41619C57.3035 7.41619 58.0545 7.60701 58.7223 7.98864C59.3932 8.36719 59.9226 8.91809 60.3104 9.64134C60.7012 10.3646 60.8967 11.2417 60.8967 12.2727ZM58.87 12.2727C58.87 11.6049 58.77 11.0417 58.57 10.5831C58.373 10.1245 58.0945 9.77675 57.7344 9.53977C57.3743 9.30279 56.9527 9.1843 56.4695 9.1843C55.9863 9.1843 55.5646 9.30279 55.2045 9.53977C54.8445 9.77675 54.5644 10.1245 54.3643 10.5831C54.1674 11.0417 54.0689 11.6049 54.0689 12.2727C54.0689 12.9406 54.1674 13.5038 54.3643 13.9624C54.5644 14.4209 54.8445 14.7687 55.2045 15.0057C55.5646 15.2427 55.9863 15.3612 56.4695 15.3612C56.9527 15.3612 57.3743 15.2427 57.7344 15.0057C58.0945 14.7687 58.373 14.4209 58.57 13.9624C58.77 13.5038 58.87 12.9406 58.87 12.2727ZM63.5523 7.54545L65.8374 14.7287H65.9252L68.2149 7.54545H70.4308L67.1716 17H64.5956L61.3318 7.54545H63.5523ZM71.5688 17V7.54545H77.9395V9.19354H73.5677V11.4464H77.6118V13.0945H73.5677V15.3519H77.958V17H71.5688Z" fill="white"/>
|
||||
<path d="M8 12L17 12" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -18,6 +18,7 @@ export type PageType = {
|
||||
group: string;
|
||||
icon?: string;
|
||||
isProtected?: boolean;
|
||||
metadata?: Record<string, any>;
|
||||
};
|
||||
|
||||
const defaultPages: PageType[] = [
|
||||
|
||||
47
src/components/CreateTeam/NotDropdown.tsx
Normal file
47
src/components/CreateTeam/NotDropdown.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import ChevronDownIcon from '../../icons/chevron-down.svg';
|
||||
|
||||
type NotDropdownProps = {
|
||||
onClick: () => void;
|
||||
selectedCount: number;
|
||||
singularName: string;
|
||||
pluralName: string;
|
||||
};
|
||||
|
||||
export function NotDropdown(props: NotDropdownProps) {
|
||||
const { onClick, selectedCount, singularName, pluralName } = props;
|
||||
|
||||
const singularOrPlural = selectedCount === 1 ? singularName : pluralName;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-text items-center justify-between rounded-md border border-gray-300 px-3 py-2.5 hover:border-gray-400/50 hover:bg-gray-50"
|
||||
role="button"
|
||||
onClick={onClick}
|
||||
>
|
||||
{selectedCount > 0 && (
|
||||
<div className="flex flex-col">
|
||||
<p className="mb-1.5 text-base font-medium text-gray-800">
|
||||
{selectedCount} {singularOrPlural} selected
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
Click to add or change selection
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedCount === 0 && (
|
||||
<div className="flex flex-col">
|
||||
<p className="text-base text-gray-400">
|
||||
Click to select {pluralName}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<img
|
||||
alt={singularName}
|
||||
src={ChevronDownIcon}
|
||||
className={'relative top-[1px] h-[17px] w-[17px] opacity-40'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
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 ChevronDownIcon from '../../icons/chevron-down.svg';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
import { UpdateTeamResourceModal } from './UpdateTeamResourceModal';
|
||||
import { SelectRoadmapModal } from './SelectRoadmapModal';
|
||||
import { NotDropdown } from './NotDropdown';
|
||||
|
||||
export type TeamResourceConfig = {
|
||||
resourceId: string;
|
||||
@@ -14,14 +15,15 @@ export type TeamResourceConfig = {
|
||||
}[];
|
||||
|
||||
type RoadmapSelectorProps = {
|
||||
team: TeamDocument;
|
||||
teamId: string;
|
||||
teamResourceConfig: TeamResourceConfig;
|
||||
setTeamResourceConfig: (config: TeamResourceConfig) => void;
|
||||
};
|
||||
|
||||
export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
const { team, teamResourceConfig = [], setTeamResourceConfig } = props;
|
||||
const { teamId, teamResourceConfig = [], setTeamResourceConfig } = props;
|
||||
|
||||
const [showSelectRoadmapModal, setShowSelectRoadmapModal] = useState(false);
|
||||
const [allRoadmaps, setAllRoadmaps] = useState<PageType[]>([]);
|
||||
const [changingRoadmapId, setChangingRoadmapId] = useState<string>('');
|
||||
const [error, setError] = useState<string>('');
|
||||
@@ -50,15 +52,15 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
}
|
||||
|
||||
async function deleteResource(roadmapId: string) {
|
||||
if (!team?._id) {
|
||||
if (!teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
pageProgressMessage.set(`Deleting resource`);
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-delete-team-resource-config/${
|
||||
team._id
|
||||
}`,
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-delete-team-resource-config/${teamId}`,
|
||||
{
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
@@ -82,17 +84,17 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
}
|
||||
|
||||
async function addTeamResource(roadmapId: string) {
|
||||
if (!team?._id) {
|
||||
if (!teamId) {
|
||||
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
|
||||
}`,
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-update-team-resource-config/${teamId}`,
|
||||
{
|
||||
teamId: team._id,
|
||||
teamId: teamId,
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
removed: [],
|
||||
@@ -118,7 +120,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
onClose={() => setChangingRoadmapId('')}
|
||||
resourceId={changingRoadmapId}
|
||||
resourceType={'roadmap'}
|
||||
teamId={team?._id!}
|
||||
teamId={teamId}
|
||||
setTeamResourceConfig={setTeamResourceConfig}
|
||||
defaultRemovedItems={
|
||||
teamResourceConfig.find((c) => c.resourceId === changingRoadmapId)
|
||||
@@ -126,43 +128,38 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showSelectRoadmapModal && (
|
||||
<SelectRoadmapModal
|
||||
onClose={() => setShowSelectRoadmapModal(false)}
|
||||
teamResourceConfig={teamResourceConfig}
|
||||
allRoadmaps={allRoadmaps}
|
||||
teamId={teamId}
|
||||
onRoadmapAdd={(roadmapId) => {
|
||||
addTeamResource(roadmapId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
onRoadmapRemove={(roadmapId) => {
|
||||
onRemove(roadmapId).finally(() => {});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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"
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<NotDropdown
|
||||
onClick={() => {
|
||||
setShowSelectRoadmapModal(true);
|
||||
}}
|
||||
selectedCount={teamResourceConfig.length}
|
||||
singularName={'roadmap'}
|
||||
pluralName={'roadmaps'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!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>
|
||||
<p className={'mb-3 mt-2 text-base text-gray-400'}>
|
||||
No roadmaps selected.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{teamResourceConfig.length > 0 && (
|
||||
|
||||
152
src/components/CreateTeam/SelectRoadmapModal.tsx
Normal file
152
src/components/CreateTeam/SelectRoadmapModal.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import type { PageType } from '../CommandMenu/CommandMenu';
|
||||
import type { TeamResourceConfig } from './RoadmapSelector';
|
||||
import CloseIcon from '../../icons/close.svg';
|
||||
import { SelectRoadmapModalItem } from './SelectRoadmapModalItem';
|
||||
|
||||
export type SelectRoadmapModalProps = {
|
||||
teamId: string;
|
||||
allRoadmaps: PageType[];
|
||||
onClose: () => void;
|
||||
teamResourceConfig: TeamResourceConfig;
|
||||
onRoadmapAdd: (roadmapId: string) => void;
|
||||
onRoadmapRemove: (roadmapId: string) => void;
|
||||
};
|
||||
|
||||
export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
|
||||
const {
|
||||
onClose,
|
||||
allRoadmaps,
|
||||
onRoadmapAdd,
|
||||
onRoadmapRemove,
|
||||
teamResourceConfig,
|
||||
} = props;
|
||||
const popupBodyEl = useRef<HTMLDivElement>(null);
|
||||
const searchInputEl = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [searchResults, setSearchResults] = useState<PageType[]>(allRoadmaps);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
|
||||
useKeydown('Escape', () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
useOutsideClick(popupBodyEl, () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchInputEl.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
searchInputEl.current.focus();
|
||||
}, [searchInputEl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchText.length === 0) {
|
||||
setSearchResults(allRoadmaps);
|
||||
return;
|
||||
}
|
||||
|
||||
const searchResults = allRoadmaps.filter((roadmap) => {
|
||||
return (
|
||||
roadmap.title.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
roadmap.id.toLowerCase().includes(searchText.toLowerCase())
|
||||
);
|
||||
});
|
||||
setSearchResults(searchResults);
|
||||
}, [searchText, allRoadmaps]);
|
||||
|
||||
const roleBasedRoadmaps = searchResults.filter((roadmap) =>
|
||||
roadmap?.metadata?.tags?.includes('role-roadmap')
|
||||
);
|
||||
const skillBasedRoadmaps = searchResults.filter((roadmap) =>
|
||||
roadmap?.metadata?.tags?.includes('skill-roadmap')
|
||||
);
|
||||
|
||||
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-2xl p-4 md:h-auto">
|
||||
<div
|
||||
ref={popupBodyEl}
|
||||
class="popup-body relative mt-4 overflow-hidden rounded-lg bg-white shadow"
|
||||
>
|
||||
<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-100 hover:text-gray-900"
|
||||
onClick={onClose}
|
||||
>
|
||||
<img alt={'close'} src={CloseIcon} className="h-4 w-4" />
|
||||
<span class="sr-only">Close modal</span>
|
||||
</button>
|
||||
<input
|
||||
ref={searchInputEl}
|
||||
type="text"
|
||||
placeholder="Search roadmaps"
|
||||
className="block w-full border-b px-5 pb-3.5 pt-4 outline-none placeholder:text-gray-400"
|
||||
value={searchText}
|
||||
onInput={(e) => setSearchText((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<div className="min-h-[200px] p-4">
|
||||
<span className="block pb-3 text-xs uppercase text-gray-400">
|
||||
Role Based Roadmaps
|
||||
</span>
|
||||
{roleBasedRoadmaps.length === 0 && (
|
||||
<p className="mb-1 flex h-full items-start text-sm italic text-gray-400"></p>
|
||||
)}
|
||||
{roleBasedRoadmaps.length > 0 && (
|
||||
<div className="mb-5 flex flex-wrap items-center gap-2">
|
||||
{roleBasedRoadmaps.map((roadmap) => {
|
||||
const isSelected = !!teamResourceConfig.find(
|
||||
(r) => r.resourceId === roadmap.id
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectRoadmapModalItem
|
||||
title={roadmap.title}
|
||||
isSelected={isSelected}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
onRoadmapRemove(roadmap.id);
|
||||
} else {
|
||||
onRoadmapAdd(roadmap.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<span className="block pb-3 text-xs uppercase text-gray-400">
|
||||
Skill Based Roadmaps
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{skillBasedRoadmaps.map((roadmap) => {
|
||||
const isSelected = !!teamResourceConfig.find(
|
||||
(r) => r.resourceId === roadmap.id
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectRoadmapModalItem
|
||||
title={roadmap.title}
|
||||
isSelected={isSelected}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
onRoadmapRemove(roadmap.id);
|
||||
} else {
|
||||
onRoadmapAdd(roadmap.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/components/CreateTeam/SelectRoadmapModalItem.tsx
Normal file
34
src/components/CreateTeam/SelectRoadmapModalItem.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { SelectRoadmapModalProps } from './SelectRoadmapModal';
|
||||
|
||||
type SelectRoadmapModalItemProps = {
|
||||
title: string;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export function SelectRoadmapModalItem(props: SelectRoadmapModalItemProps) {
|
||||
const { isSelected, onClick, title } = props;
|
||||
return (
|
||||
<button
|
||||
className={`group flex min-h-[35px] items-stretch overflow-hidden rounded-md text-sm ${
|
||||
!isSelected
|
||||
? 'border border-gray-300 hover:bg-gray-100'
|
||||
: 'bg-black text-white transition-colors hover:bg-gray-700'
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="flex items-center px-3">{title}</span>
|
||||
{isSelected && (
|
||||
<span className="flex items-center bg-gray-700 px-3 text-xs text-white transition-colors">
|
||||
×
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!isSelected && (
|
||||
<span className="flex items-center bg-gray-100 px-2.5 text-xs text-gray-500">
|
||||
+
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -10,13 +10,13 @@ export const validTeamTypes = [
|
||||
value: 'company',
|
||||
label: 'Company',
|
||||
icon: BuildingIcon,
|
||||
description: 'Use roadmap.sh for your company',
|
||||
description: 'Track the skills and learning progress of the tech team at your company',
|
||||
},
|
||||
{
|
||||
value: 'study_group',
|
||||
label: 'Study Group',
|
||||
icon: UsersIcon,
|
||||
description: 'Invite your friends and learn together',
|
||||
description: 'Invite your friends or course-mates and track your learning progress together',
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -87,10 +87,10 @@ export function Step0(props: Step0Props) {
|
||||
validTeamType.value === selectedTeamType ? 'opacity-100' : ''
|
||||
}`}
|
||||
/>
|
||||
<span className="mb-1 block text-2xl font-bold">
|
||||
<span className="mb-2 block text-2xl font-bold">
|
||||
{validTeamType.label}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
<span className="text-sm text-gray-500 leading-[21px]">
|
||||
{validTeamType.description}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -5,10 +5,12 @@ import type { TeamDocument } from './CreateTeamForm';
|
||||
import { NextButton } from './NextButton';
|
||||
|
||||
export const validTeamSizes = [
|
||||
'0-1',
|
||||
'2-10',
|
||||
'11-50',
|
||||
'51-200',
|
||||
'1-5',
|
||||
'6-10',
|
||||
'11-25',
|
||||
'26-50',
|
||||
'51-100',
|
||||
'101-200',
|
||||
'201-500',
|
||||
'501-1000',
|
||||
'1000+',
|
||||
@@ -134,7 +136,7 @@ export function Step1(props: Step1Props) {
|
||||
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"
|
||||
placeholder="Roadmap Inc."
|
||||
disabled={isLoading}
|
||||
required
|
||||
value={name}
|
||||
@@ -167,7 +169,7 @@ export function Step1(props: Step1Props) {
|
||||
{selectedTeamType === 'company' && (
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label for="website" className="text-sm leading-none text-slate-500">
|
||||
LinkedIn URL
|
||||
Company LinkedIn URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
@@ -206,7 +208,7 @@ export function Step1(props: Step1Props) {
|
||||
for="team-size"
|
||||
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
|
||||
>
|
||||
Company Size
|
||||
Tech Team Size
|
||||
</label>
|
||||
<select
|
||||
name="team-size"
|
||||
@@ -229,6 +231,12 @@ export function Step1(props: Step1Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<span className="text-sm text-red-500">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-row items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -17,15 +17,14 @@ export function Step2(props: Step2Props) {
|
||||
<>
|
||||
<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>
|
||||
<h2 className="mb-1.5 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.
|
||||
You can always add and customize your roadmaps later.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RoadmapSelector
|
||||
team={team}
|
||||
teamId={team._id!}
|
||||
teamResourceConfig={teamResourceConfig}
|
||||
setTeamResourceConfig={setTeamResourceConfig}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
import { useToast } from '../hooks/use-toast';
|
||||
|
||||
type DeleteTeamPopupProps = {
|
||||
onClose: () => void;
|
||||
@@ -12,6 +13,7 @@ type DeleteTeamPopupProps = {
|
||||
export function DeleteTeamPopup(props: DeleteTeamPopupProps) {
|
||||
const { onClose } = props;
|
||||
|
||||
const toast = useToast();
|
||||
const popupBodyEl = useRef<HTMLDivElement>(null);
|
||||
const inputEl = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -53,6 +55,7 @@ export function DeleteTeamPopup(props: DeleteTeamPopupProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Team deleted successfully');
|
||||
window.location.href = '/account';
|
||||
};
|
||||
|
||||
@@ -72,9 +75,9 @@ export function DeleteTeamPopup(props: DeleteTeamPopupProps) {
|
||||
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.
|
||||
<h2 class="text-2xl font-semibold text-black">Delete Team</h2>
|
||||
<p className="text-gray-500">
|
||||
This will permanently delete your team and all associated data.
|
||||
</p>
|
||||
|
||||
<p class="-mb-2 mt-3 text-base font-medium text-black">
|
||||
|
||||
@@ -20,11 +20,12 @@ export function MarkFavorite({
|
||||
favorite,
|
||||
className,
|
||||
}: MarkFavoriteType) {
|
||||
const localStorageKey = `${resourceType}-${resourceId}-favorite`;
|
||||
|
||||
const toast = useToast();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isFavorite, setIsFavorite] = useState(
|
||||
favorite || false
|
||||
favorite ?? localStorage.getItem(localStorageKey) === '1'
|
||||
);
|
||||
|
||||
async function toggleFavoriteHandler(e: Event) {
|
||||
@@ -81,6 +82,7 @@ export function MarkFavorite({
|
||||
} = (e as CustomEvent).detail;
|
||||
if (id === resourceId && type === resourceType) {
|
||||
setIsFavorite(fav);
|
||||
localStorage.setItem(localStorageKey, fav ? '1' : '0');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -122,3 +122,7 @@ svg .removed g, svg .removed circle, svg .removed path {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/*.clickable-group:hover {*/
|
||||
/* cursor: url(/images/cursors/add.svg) 5 5, move;*/
|
||||
/*}*/
|
||||
@@ -228,18 +228,8 @@ export class Renderer {
|
||||
}
|
||||
|
||||
e.stopImmediatePropagation();
|
||||
let status: ResourceProgressType = 'pending';
|
||||
if (targetGroup.classList.contains('done')) {
|
||||
status = 'done';
|
||||
} else if (targetGroup.classList.contains('learning')) {
|
||||
status = 'learning';
|
||||
} else if (targetGroup.classList.contains('skipped')) {
|
||||
status = 'skipped';
|
||||
} else if (targetGroup.classList.contains('removed')) {
|
||||
status = 'removed';
|
||||
}
|
||||
|
||||
if (status === 'removed') {
|
||||
if (targetGroup.classList.contains('removed')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -273,7 +263,6 @@ export class Renderer {
|
||||
topicId: groupId.replace('check:', ''),
|
||||
resourceType: this.resourceType,
|
||||
resourceId: this.resourceId,
|
||||
status,
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -311,7 +300,6 @@ export class Renderer {
|
||||
topicId: normalizedGroupId,
|
||||
resourceId: this.resourceId,
|
||||
resourceType: this.resourceType,
|
||||
status,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
22
src/components/ReactIcons/CloseIcon.tsx
Normal file
22
src/components/ReactIcons/CloseIcon.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
type CloseIconProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function CloseIcon(props: CloseIconProps) {
|
||||
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 d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
---
|
||||
import { ClearProgress } from './Activity/ClearProgress';
|
||||
import AstroIcon from './AstroIcon.astro';
|
||||
import Icon from './AstroIcon.astro';
|
||||
import ResourceProgressStats from './ResourceProgressStats.astro';
|
||||
|
||||
@@ -79,7 +79,7 @@ export function TeamDropdown() {
|
||||
if (
|
||||
!user?.email.endsWith('@insightpartners.com') &&
|
||||
!user?.email.endsWith('@roadmap.sh') &&
|
||||
!['arikchangma@gmail.com', 'kamranahmed.se@gmail.com'].includes(user?.email!)
|
||||
!['arikchangma@gmail.com', 'kamranahmed.se@gmail.com', 'stephen.chetcuti@gmail.com'].includes(user?.email!)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -95,15 +95,14 @@ export function TeamDropdown() {
|
||||
{pendingTeamIds.length}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="inline-grid grid-cols-[16px_auto] items-center gap-1.5 mr-1.5">
|
||||
{isLoading && <Spinner className="h-4 w-4" isDualRing={false} />}
|
||||
{!isLoading && (
|
||||
<img
|
||||
src={
|
||||
selectedAvatar
|
||||
? `${
|
||||
import.meta.env.PUBLIC_AVATAR_BASE_URL
|
||||
}/${selectedAvatar}`
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL
|
||||
}/${selectedAvatar}`
|
||||
: '/images/default-avatar.png'
|
||||
}
|
||||
alt=""
|
||||
@@ -140,28 +139,18 @@ export function TeamDropdown() {
|
||||
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>
|
||||
<span className="flex-grow min-w-0 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>
|
||||
);
|
||||
|
||||
@@ -1,40 +1,30 @@
|
||||
import { useState } from "preact/hooks";
|
||||
import { httpDelete } from "../../lib/http";
|
||||
import { Spinner } from "../ReactIcons/Spinner";
|
||||
import { useToast } from "../../hooks/use-toast";
|
||||
import { useState } from 'preact/hooks';
|
||||
import { LeaveTeamPopup } from './LeaveTeamPopup';
|
||||
|
||||
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';
|
||||
}
|
||||
const [showLeaveTeamPopup, setShowLeaveTeamPopup] = useState(false);
|
||||
|
||||
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>
|
||||
)
|
||||
|
||||
<>
|
||||
{showLeaveTeamPopup && (
|
||||
<LeaveTeamPopup
|
||||
onClose={() => {
|
||||
setShowLeaveTeamPopup(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowLeaveTeamPopup(true);
|
||||
}}
|
||||
className="flex h-7 min-w-[95px] items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-2 py-1.5 text-sm font-medium leading-none text-red-600"
|
||||
>
|
||||
Leave team
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
124
src/components/TeamMembers/LeaveTeamPopup.tsx
Normal file
124
src/components/TeamMembers/LeaveTeamPopup.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { httpDelete } from '../../lib/http';
|
||||
import { useTeamId } from '../../hooks/use-team-id';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
|
||||
type LeaveTeamPopupProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function LeaveTeamPopup(props: LeaveTeamPopupProps) {
|
||||
const { onClose } = props;
|
||||
|
||||
const popupBodyRef = useRef<HTMLDivElement>(null);
|
||||
const confirmationEl = useRef<HTMLInputElement>(null);
|
||||
const [confirmationText, setConfirmationText] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const { teamId } = useTeamId();
|
||||
|
||||
useEffect(() => {
|
||||
setError('');
|
||||
setConfirmationText('');
|
||||
confirmationEl?.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
if (confirmationText.toUpperCase() !== 'LEAVE') {
|
||||
setError('Verification text does not match');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { response, error } = await httpDelete(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-leave-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();
|
||||
};
|
||||
|
||||
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"
|
||||
>
|
||||
<h2 class="text-2xl font-semibold text-black">
|
||||
Leave Team
|
||||
</h2>
|
||||
<p className="text-gray-500">
|
||||
You will lose access to the team, the roadmaps and progress of other team members.
|
||||
</p>
|
||||
<p className="-mb-2 mt-3 text-base font-medium text-black">
|
||||
Please type "leave" to confirm.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="my-4">
|
||||
<input
|
||||
ref={confirmationEl}
|
||||
type="text"
|
||||
name="leave-team"
|
||||
id="leave-team"
|
||||
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 "leave" 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() !== 'LEAVE'
|
||||
}
|
||||
className="flex-grow cursor-pointer rounded-lg bg-red-500 py-2 text-white disabled:opacity-40"
|
||||
>
|
||||
{isLoading ? 'Please wait ..' : 'Leave Team'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -163,8 +163,8 @@ export function TeamMembersPage() {
|
||||
<MemberRoleBadge role={member.role} />
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
<h3 className="flex items-center font-medium">
|
||||
{member.name}
|
||||
<h3 className="inline-grid grid-cols-[auto_auto] items-center font-medium">
|
||||
<span className="truncate">{member.name}</span>
|
||||
{member.userId === user?.id && (
|
||||
<span className="ml-2 hidden text-xs font-normal text-blue-500 sm:inline">
|
||||
You
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { GroupByRoadmap, TeamMember } from './TeamProgressPage';
|
||||
import { MemberProgressModal } from './MemberProgressModal';
|
||||
import { getUrlParams } from '../../lib/browser';
|
||||
import ExternalLinkIcon from '../../icons/external-link.svg';
|
||||
import { useAuth } from '../../hooks/use-auth';
|
||||
|
||||
type GroupRoadmapItemProps = {
|
||||
roadmap: GroupByRoadmap;
|
||||
@@ -11,6 +12,7 @@ type GroupRoadmapItemProps = {
|
||||
export function GroupRoadmapItem(props: GroupRoadmapItemProps) {
|
||||
const { members, resourceTitle, resourceId } = props.roadmap;
|
||||
const { t: teamId } = getUrlParams();
|
||||
const user = useAuth();
|
||||
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [detailResourceId, setDetailResourceId] = useState<string | null>(null);
|
||||
@@ -49,10 +51,15 @@ export function GroupRoadmapItem(props: GroupRoadmapItemProps) {
|
||||
</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;
|
||||
const isMyProgress = user?.email === member?.member?.email;
|
||||
|
||||
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"
|
||||
className={`group relative w-full overflow-hidden rounded-md border p-2 hover:border-gray-300 hover:text-black focus:outline-none ${isMyProgress ? 'border-green-500 hover:border-green-600' : ''}`}
|
||||
key={member?.member._id}
|
||||
onClick={() => {
|
||||
setDetailResourceId(member?.progress?.resourceId!);
|
||||
@@ -60,7 +67,7 @@ export function GroupRoadmapItem(props: GroupRoadmapItemProps) {
|
||||
}}
|
||||
>
|
||||
<span className="relative z-10 flex items-center justify-between gap-1 text-sm">
|
||||
<span className="inline-grid grid-cols-[20px_auto] gap-2">
|
||||
<span className="inline-grid grid-cols-[20px_auto] gap-3">
|
||||
<img
|
||||
src={
|
||||
member.member.avatar
|
||||
@@ -72,14 +79,16 @@ export function GroupRoadmapItem(props: GroupRoadmapItemProps) {
|
||||
alt={member.member.name || ''}
|
||||
className="h-5 w-5 shrink-0 rounded-full"
|
||||
/>
|
||||
<span className="truncate">{member?.member?.name}</span>
|
||||
<span className="inline-grid grid-cols-[auto,32px] items-center">
|
||||
<span className="truncate mr-[5px]">{member?.member?.name}</span>
|
||||
</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"
|
||||
className={`absolute inset-0 ${isMyProgress ? 'bg-green-100 group-hover:bg-green-200' : 'bg-gray-100 group-hover:bg-gray-200'}`}
|
||||
style={{
|
||||
width: `${
|
||||
(member?.progress?.done / member?.progress?.total) * 100
|
||||
|
||||
@@ -5,9 +5,10 @@ import { MemberProgressModal } from './MemberProgressModal';
|
||||
type MemberProgressItemProps = {
|
||||
teamId: string;
|
||||
member: TeamMember;
|
||||
isMyProgress?: boolean;
|
||||
};
|
||||
export function MemberProgressItem(props: MemberProgressItemProps) {
|
||||
const { member, teamId } = props;
|
||||
const { member, teamId, isMyProgress = false } = props;
|
||||
|
||||
const memberProgress = member?.progress?.sort((a, b) => {
|
||||
return b.done - a.done;
|
||||
@@ -31,10 +32,10 @@ export function MemberProgressItem(props: MemberProgressItemProps) {
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex h-full min-h-[270px] flex-col rounded-md border"
|
||||
className={`flex h-full min-h-[270px] flex-col overflow-hidden rounded-md border`}
|
||||
key={member._id}
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b p-3">
|
||||
<div className={`relative flex items-center gap-3 border-b p-3`}>
|
||||
<img
|
||||
src={
|
||||
member.avatar
|
||||
@@ -44,8 +45,18 @@ export function MemberProgressItem(props: MemberProgressItemProps) {
|
||||
alt={member.name || ''}
|
||||
className="h-8 w-8 rounded-full"
|
||||
/>
|
||||
<div className="inline-grid">
|
||||
<h3 className="truncate font-medium">{member.name}</h3>
|
||||
<div className="inline-grid w-full">
|
||||
{!isMyProgress && (
|
||||
<h3 className="truncate font-medium">{member.name}</h3>
|
||||
)}
|
||||
{isMyProgress && (
|
||||
<div className="inline-grid grid-cols-[auto,32px] items-center gap-1.5">
|
||||
<h3 className="truncate font-medium">{member.name}</h3>
|
||||
<span className="rounded-md bg-red-500 py-0.5 px-1 text-xs text-white">
|
||||
You
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="truncate text-sm text-gray-500">{member.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -60,9 +71,11 @@ export function MemberProgressItem(props: MemberProgressItemProps) {
|
||||
>
|
||||
<span className="relative z-10 flex items-center justify-between text-sm">
|
||||
<span className="inline-grid">
|
||||
<span className={'truncate'}>{progress.resourceTitle}</span>
|
||||
<span className={'truncate'}>
|
||||
{progress.resourceTitle}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 shrink-0 ml-1.5">
|
||||
<span className="ml-1.5 shrink-0 text-xs text-gray-400">
|
||||
{progress.done} / {progress.total}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -6,9 +6,19 @@ 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 {
|
||||
ResourceProgressType,
|
||||
ResourceType,
|
||||
renderTopicProgress,
|
||||
updateResourceProgress,
|
||||
} from '../../lib/resource-progress';
|
||||
import CloseIcon from '../../icons/close.svg';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { useAuth } from '../../hooks/use-auth';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { ProgressHint } from './ProgressHint';
|
||||
import QuestionIcon from '../../icons/question.svg';
|
||||
import { InfoIcon } from '../ReactIcons/InfoIcon';
|
||||
|
||||
export type ProgressMapProps = {
|
||||
member: TeamMember;
|
||||
@@ -27,10 +37,13 @@ type MemberProgressResponse = {
|
||||
|
||||
export function MemberProgressModal(props: ProgressMapProps) {
|
||||
const { resourceId, member, resourceType, teamId, onClose } = props;
|
||||
const user = useAuth();
|
||||
const isCurrentUser = user?.email === member.email;
|
||||
|
||||
const containerEl = useRef<HTMLDivElement>(null);
|
||||
const popupBodyEl = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [showProgressHint, setShowProgressHint] = useState(false);
|
||||
const [memberProgress, setMemberProgress] =
|
||||
useState<MemberProgressResponse>();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -75,10 +88,16 @@ export function MemberProgressModal(props: ProgressMapProps) {
|
||||
}
|
||||
|
||||
useKeydown('Escape', () => {
|
||||
if (showProgressHint) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
});
|
||||
|
||||
useOutsideClick(popupBodyEl, () => {
|
||||
if (showProgressHint) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
});
|
||||
|
||||
@@ -119,10 +138,128 @@ export function MemberProgressModal(props: ProgressMapProps) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
function updateTopicStatus(topicId: string, newStatus: ResourceProgressType) {
|
||||
if (!resourceId || !resourceType || !isCurrentUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
pageProgressMessage.set('Updating progress');
|
||||
updateResourceProgress(
|
||||
{
|
||||
resourceId: resourceId,
|
||||
resourceType: resourceType as ResourceType,
|
||||
topicId,
|
||||
},
|
||||
newStatus
|
||||
)
|
||||
.then(() => {
|
||||
renderTopicProgress(topicId, newStatus);
|
||||
getMemberProgress(teamId, member._id, resourceType, resourceId).then(
|
||||
(data) => {
|
||||
setMemberProgress(data);
|
||||
}
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
alert('Something went wrong, please try again.');
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async function handleRightClick(e: MouseEvent) {
|
||||
const targetGroup = (e.target as HTMLElement)?.closest('g');
|
||||
if (!targetGroup) {
|
||||
return;
|
||||
}
|
||||
const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : '';
|
||||
if (!groupId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetGroup.classList.contains('removed')) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
const isCurrentStatusDone = targetGroup.classList.contains('done');
|
||||
const normalizedGroupId = groupId.replace(/^\d+-/, '');
|
||||
updateTopicStatus(
|
||||
normalizedGroupId,
|
||||
!isCurrentStatusDone ? 'done' : 'pending'
|
||||
);
|
||||
}
|
||||
|
||||
async function handleClick(e: MouseEvent) {
|
||||
const targetGroup = (e.target as HTMLElement)?.closest('g');
|
||||
if (!targetGroup) {
|
||||
return;
|
||||
}
|
||||
const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : '';
|
||||
if (!groupId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetGroup.classList.contains('removed')) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
const normalizedGroupId = groupId.replace(/^\d+-/, '');
|
||||
|
||||
const isCurrentStatusLearning = targetGroup.classList.contains('learning');
|
||||
const isCurrentStatusSkipped = targetGroup.classList.contains('skipped');
|
||||
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
updateTopicStatus(
|
||||
normalizedGroupId,
|
||||
!isCurrentStatusLearning ? 'learning' : 'pending'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.altKey) {
|
||||
e.preventDefault();
|
||||
updateTopicStatus(
|
||||
normalizedGroupId,
|
||||
!isCurrentStatusSkipped ? 'skipped' : 'pending'
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCurrentUser || !containerEl.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
containerEl.current?.addEventListener('contextmenu', handleRightClick);
|
||||
containerEl.current?.addEventListener('click', handleClick);
|
||||
|
||||
return () => {
|
||||
containerEl.current?.removeEventListener('contextmenu', handleRightClick);
|
||||
containerEl.current?.removeEventListener('click', handleClick);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const removedTopics = memberProgress?.removed || [];
|
||||
const memberDone =
|
||||
memberProgress?.done.filter((id) => !removedTopics.includes(id)).length ||
|
||||
0;
|
||||
const memberLearning =
|
||||
memberProgress?.learning.filter((id) => !removedTopics.includes(id))
|
||||
.length || 0;
|
||||
const memberSkipped =
|
||||
memberProgress?.skipped.filter((id) => !removedTopics.includes(id))
|
||||
.length || 0;
|
||||
|
||||
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);
|
||||
@@ -134,38 +271,65 @@ export function MemberProgressModal(props: ProgressMapProps) {
|
||||
ref={popupBodyEl}
|
||||
class="popup-body relative rounded-lg bg-white shadow"
|
||||
>
|
||||
{showProgressHint && (
|
||||
<ProgressHint
|
||||
onClose={() => {
|
||||
setShowProgressHint(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<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"
|
||||
{isCurrentUser ? (
|
||||
<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'}>
|
||||
Your Progress
|
||||
</h2>
|
||||
<p className={'text-gray-500'}>
|
||||
You can{' '}
|
||||
<button
|
||||
className="inline-flex items-center text-blue-600 underline"
|
||||
onClick={() => {
|
||||
setShowProgressHint(true);
|
||||
}}
|
||||
>
|
||||
follow these instructions
|
||||
</button>{' '}
|
||||
to update your progress below.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<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'
|
||||
}
|
||||
>
|
||||
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">
|
||||
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 px-4 py-2 text-sm sm:hidden">
|
||||
<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>
|
||||
|
||||
70
src/components/TeamProgress/ProgressHint.tsx
Normal file
70
src/components/TeamProgress/ProgressHint.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useRef } from 'preact/hooks';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import { CloseIcon } from '../ReactIcons/CloseIcon';
|
||||
|
||||
type ProgressHintProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function ProgressHint(props: ProgressHintProps) {
|
||||
const { onClose } = props;
|
||||
const containerEl = useRef<HTMLDivElement>(null);
|
||||
|
||||
useOutsideClick(containerEl, onClose);
|
||||
useKeydown('Escape', () => {
|
||||
onClose();
|
||||
});
|
||||
return (
|
||||
<div className="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 className="relative flex h-full w-full items-center justify-center">
|
||||
<div
|
||||
className="relative w-full max-w-lg rounded-md border border-yellow-300 bg-yellow-50 px-3 py-3 text-gray-500"
|
||||
ref={containerEl}
|
||||
>
|
||||
<span className="mb-1.5 block text-xs font-medium uppercase text-green-600">
|
||||
Update Progress
|
||||
</span>
|
||||
<p className="text-sm">Use the keyboard shortcuts listed below.</p>
|
||||
|
||||
<ul className="mb-1.5 mt-3 flex flex-col gap-1">
|
||||
<li className="text-sm leading-loose">
|
||||
<kbd className="rounded-md bg-gray-900 px-2 py-1.5 text-xs text-white">
|
||||
Right Mouse Click
|
||||
</kbd>{' '}
|
||||
to mark as Done.
|
||||
</li>
|
||||
<li className="text-sm leading-loose">
|
||||
<kbd className="rounded-md bg-gray-900 px-2 py-1.5 text-xs text-white">
|
||||
Shift
|
||||
</kbd>{' '}
|
||||
+{' '}
|
||||
<kbd className="rounded-md bg-gray-900 px-2 py-1.5 text-xs text-white">
|
||||
Click
|
||||
</kbd>{' '}
|
||||
to mark as in progress.
|
||||
</li>
|
||||
<li className="text-sm leading-loose">
|
||||
<kbd className="rounded-md bg-gray-900 px-2 py-1.5 text-xs text-white">
|
||||
Option / Alt
|
||||
</kbd>{' '}
|
||||
+{' '}
|
||||
<kbd className="rounded-md bg-gray-900 px-2 py-1.5 text-xs text-white">
|
||||
Click
|
||||
</kbd>{' '}
|
||||
to mark as skipped.
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-1.5 top-1.5 ml-auto inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-yellow-200 hover:text-yellow-900"
|
||||
onClick={onClose}
|
||||
>
|
||||
<CloseIcon />
|
||||
<span class="sr-only">Close modal</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,9 +7,8 @@ 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';
|
||||
import { getUrlParams, setUrlParams } from '../../lib/browser';
|
||||
import { useAuth } from '../../hooks/use-auth';
|
||||
|
||||
export type UserProgress = {
|
||||
resourceTitle: string;
|
||||
@@ -55,6 +54,7 @@ export function TeamProgressPage() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const toast = useToast();
|
||||
const currentTeam = useStore($currentTeam);
|
||||
const user = useAuth();
|
||||
|
||||
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
|
||||
const [selectedGrouping, setSelectedGrouping] = useState<
|
||||
@@ -70,7 +70,17 @@ export function TeamProgressPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamMembers(response);
|
||||
setTeamMembers(
|
||||
response.sort((a, b) => {
|
||||
if (a.email === user?.email) {
|
||||
return -1;
|
||||
}
|
||||
if (b.email === user?.email) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -78,10 +88,12 @@ export function TeamProgressPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
getTeamProgress().finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
setIsLoading(false);
|
||||
});
|
||||
getTeamProgress().then(
|
||||
() => {
|
||||
pageProgressMessage.set('');
|
||||
setIsLoading(false);
|
||||
}
|
||||
);
|
||||
}, [teamId]);
|
||||
|
||||
if (isLoading) {
|
||||
@@ -134,11 +146,10 @@ export function TeamProgressPage() {
|
||||
<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 '
|
||||
: ''
|
||||
}`}
|
||||
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}
|
||||
@@ -159,7 +170,11 @@ export function TeamProgressPage() {
|
||||
{selectedGrouping === 'member' && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{teamMembers.map((member) => (
|
||||
<MemberProgressItem teamId={teamId} member={member} />
|
||||
<MemberProgressItem
|
||||
teamId={teamId}
|
||||
member={member}
|
||||
isMyProgress={member?.email === user?.email}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,13 +5,14 @@ import type { TeamResourceConfig } from './CreateTeam/RoadmapSelector';
|
||||
import { httpGet, httpPut } from '../lib/http';
|
||||
import { pageProgressMessage } from '../stores/page';
|
||||
import ExternalLinkIcon from '../icons/external-link.svg';
|
||||
import RoadmapIcon from '../icons/roadmap.svg';
|
||||
import PlusIcon from '../icons/plus.svg';
|
||||
import type { PageType } from './CommandMenu/CommandMenu';
|
||||
import { UpdateTeamResourceModal } from './CreateTeam/UpdateTeamResourceModal';
|
||||
import { AddTeamRoadmap } from './AddTeamRoadmap';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { $canManageCurrentTeam } from '../stores/team';
|
||||
import {useToast} from "../hooks/use-toast";
|
||||
import { useToast } from '../hooks/use-toast';
|
||||
import { SelectRoadmapModal } from './CreateTeam/SelectRoadmapModal';
|
||||
|
||||
export function TeamRoadmaps() {
|
||||
const { t: teamId } = getUrlParams();
|
||||
@@ -20,6 +21,7 @@ export function TeamRoadmaps() {
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [removingRoadmapId, setRemovingRoadmapId] = useState<string>('');
|
||||
const [isAddingRoadmap, setIsAddingRoadmap] = useState(false);
|
||||
const [changingRoadmapId, setChangingRoadmapId] = useState<string>('');
|
||||
@@ -83,12 +85,14 @@ export function TeamRoadmaps() {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
Promise.all([
|
||||
loadTeam(teamId),
|
||||
loadTeamResourceConfig(teamId),
|
||||
loadAllRoadmaps(),
|
||||
]).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [teamId]);
|
||||
|
||||
@@ -97,6 +101,7 @@ export function TeamRoadmaps() {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.loading('Deleting roadmap');
|
||||
pageProgressMessage.set(`Deleting roadmap from team`);
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-delete-team-resource-config/${
|
||||
@@ -117,6 +122,35 @@ export function TeamRoadmaps() {
|
||||
setResourceConfigs(response);
|
||||
}
|
||||
|
||||
async function onAdd(roadmapId: string) {
|
||||
if (!teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.loading('Adding roadmap');
|
||||
pageProgressMessage.set('Adding roadmap');
|
||||
setIsLoading(true);
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-update-team-resource-config/${teamId}`,
|
||||
{
|
||||
teamId: teamId,
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
removed: [],
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Error adding roadmap');
|
||||
return;
|
||||
}
|
||||
|
||||
setResourceConfigs(response);
|
||||
toast.success('Roadmap added');
|
||||
}
|
||||
|
||||
async function onRemove(resourceId: string) {
|
||||
pageProgressMessage.set('Removing roadmap');
|
||||
|
||||
@@ -129,26 +163,69 @@ export function TeamRoadmaps() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const addRoadmapModal = isAddingRoadmap && (
|
||||
<SelectRoadmapModal
|
||||
onClose={() => setIsAddingRoadmap(false)}
|
||||
teamResourceConfig={resourceConfigs}
|
||||
allRoadmaps={allRoadmaps}
|
||||
teamId={teamId}
|
||||
onRoadmapAdd={(roadmapId) => {
|
||||
onAdd(roadmapId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
onRoadmapRemove={(roadmapId) => {
|
||||
if (confirm('Are you sure you want to remove this roadmap?')) {
|
||||
onRemove(roadmapId).finally(() => {});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (resourceConfigs.length === 0 && !isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center p-4 py-20">
|
||||
{addRoadmapModal}
|
||||
<img
|
||||
alt="roadmap"
|
||||
src={RoadmapIcon}
|
||||
className="mb-4 h-24 w-24 opacity-10"
|
||||
/>
|
||||
<h3 className="mb-1 text-2xl font-bold text-gray-900">No roadmaps</h3>
|
||||
<p className="text-base text-gray-500">
|
||||
{canManageCurrentTeam
|
||||
? 'Add a roadmap to start tracking your team'
|
||||
: 'Ask your team admin to add some roadmaps'}
|
||||
</p>
|
||||
|
||||
{canManageCurrentTeam && (
|
||||
<button
|
||||
className="mt-4 rounded-lg bg-black px-4 py-2 font-medium text-white hover:bg-gray-900"
|
||||
onClick={() => setIsAddingRoadmap(true)}
|
||||
>
|
||||
Add roadmap
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{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)}
|
||||
/>
|
||||
)}
|
||||
{addRoadmapModal}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className={'text-gray-400'}>
|
||||
{resourceConfigs.length} roadmap(s) selected
|
||||
</span>
|
||||
{canManageCurrentTeam && (
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-gray-500 underline hover:bg-gray-100 hover:text-gray-900"
|
||||
onClick={() => setIsAddingRoadmap(true)}
|
||||
>
|
||||
Add / Remove Roadmaps
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className={'grid grid-cols-1 gap-3 sm:grid-cols-2'}>
|
||||
{changingRoadmapId && (
|
||||
<UpdateTeamResourceModal
|
||||
@@ -198,8 +275,8 @@ export function TeamRoadmaps() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ canManageCurrentTeam && (
|
||||
<div className={'flex w-full justify-between pt-2 pb-3 px-3'}>
|
||||
{canManageCurrentTeam && (
|
||||
<div className={'flex w-full justify-between px-3 pb-3 pt-2'}>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
@@ -219,13 +296,7 @@ export function TeamRoadmaps() {
|
||||
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>
|
||||
|
||||
@@ -267,7 +267,6 @@ export function UpdateTeamForm() {
|
||||
{isDeleting && (
|
||||
<DeleteTeamPopup
|
||||
onClose={() => {
|
||||
toast.success('Team deleted successfully');
|
||||
setIsDeleting(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -8,13 +8,14 @@ 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';
|
||||
import { $canManageCurrentTeam, $currentTeam } from '../stores/team';
|
||||
import { WarningIcon } from './ReactIcons/WarningIcon';
|
||||
|
||||
export const TeamSidebar: FunctionalComponent<{
|
||||
activePageId: string;
|
||||
}> = ({ activePageId, children }) => {
|
||||
const [menuShown, setMenuShown] = useState(false);
|
||||
const canManageCurrentTeam = useStore($canManageCurrentTeam);
|
||||
const currentTeam = useStore($currentTeam);
|
||||
|
||||
const { teamId } = useTeamId();
|
||||
|
||||
@@ -30,6 +31,7 @@ export const TeamSidebar: FunctionalComponent<{
|
||||
href: `/team/roadmaps?t=${teamId}`,
|
||||
id: 'roadmaps',
|
||||
icon: MapIcon,
|
||||
hasWarning: currentTeam?.roadmaps?.length === 0,
|
||||
},
|
||||
{
|
||||
title: 'Members',
|
||||
@@ -120,13 +122,21 @@ export const TeamSidebar: FunctionalComponent<{
|
||||
: '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 class="flex flex-grow items-center justify-between">
|
||||
<span className="flex">
|
||||
<img
|
||||
alt="menu icon"
|
||||
src={sidebarLink.icon}
|
||||
className="mr-2 h-4 w-4"
|
||||
/>
|
||||
{sidebarLink.title}
|
||||
</span>
|
||||
{sidebarLink.hasWarning && (
|
||||
<span class="relative mr-1 flex items-center">
|
||||
<span class="relative rounded-full bg-red-200 p-1 text-xs" />
|
||||
<span class="absolute bottom-0 left-0 right-0 top-0 animate-ping rounded-full bg-red-400 p-1 text-xs" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -44,8 +44,6 @@ export function TeamVersions(props: TeamVersionsProps) {
|
||||
const [selectedTeamVersion, setSelectedTeamVersion] = useState<
|
||||
TeamVersionsResponse[0] | null
|
||||
>(null);
|
||||
const [shouldStartLoading, setShouldStartLoading] = useState(false);
|
||||
|
||||
let shouldShowAvatar = true;
|
||||
const selectedAvatar = selectedTeamVersion
|
||||
? selectedTeamVersion.team.avatar
|
||||
@@ -90,7 +88,6 @@ export function TeamVersions(props: TeamVersionsProps) {
|
||||
if (teamId) {
|
||||
const foundVersion = response.find((v) => v.team._id === teamId) || null;
|
||||
setSelectedTeamVersion(foundVersion);
|
||||
setShouldStartLoading(true);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -113,10 +110,7 @@ export function TeamVersions(props: TeamVersionsProps) {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldStartLoading) {
|
||||
return;
|
||||
}
|
||||
clearResourceProgress('removed');
|
||||
clearResourceProgress();
|
||||
if (!selectedTeamVersion) {
|
||||
deleteUrlParam('t');
|
||||
renderResourceProgress(resourceType, resourceId).then();
|
||||
@@ -131,7 +125,6 @@ export function TeamVersions(props: TeamVersionsProps) {
|
||||
});
|
||||
refreshProgressCounters();
|
||||
});
|
||||
setShouldStartLoading(true);
|
||||
}, [selectedTeamVersion]);
|
||||
|
||||
if (!teamVersions.length) {
|
||||
@@ -211,7 +204,6 @@ export function TeamVersions(props: TeamVersionsProps) {
|
||||
onClick={() => {
|
||||
setSelectedTeamVersion(team);
|
||||
setIsDropdownOpen(false);
|
||||
setShouldStartLoading(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
|
||||
@@ -37,7 +37,7 @@ export function Toaster(props: Props) {
|
||||
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`}
|
||||
className={`fixed bottom-5 left-1/2 z-50 min-w-[300px] max-w-[300px] animate-fade-slide-up sm:min-w-[auto]`}
|
||||
>
|
||||
<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`}
|
||||
|
||||
@@ -9,9 +9,9 @@ import { useToggleTopic } from '../../hooks/use-toggle-topic';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import {
|
||||
isTopicDone,
|
||||
refreshProgressCounters,
|
||||
renderTopicProgress,
|
||||
ResourceProgressType,
|
||||
ResourceType,
|
||||
updateResourceProgress as updateResourceProgressApi,
|
||||
} from '../../lib/resource-progress';
|
||||
@@ -35,7 +35,6 @@ export function TopicDetail() {
|
||||
|
||||
// Details of the currently loaded topic
|
||||
const [topicId, setTopicId] = useState('');
|
||||
const [resourceStatus, setResourceStatus] = useState<ResourceProgressType>('pending')
|
||||
const [resourceId, setResourceId] = useState('');
|
||||
const [resourceType, setResourceType] = useState<ResourceType>('roadmap');
|
||||
|
||||
@@ -53,7 +52,7 @@ export function TopicDetail() {
|
||||
// Toggle topic is available even if the component UI is not active
|
||||
// This is used on the best practice screen where we have the checkboxes
|
||||
// to mark the topic as done/undone.
|
||||
useToggleTopic(({ topicId, resourceType, resourceId, status }) => {
|
||||
useToggleTopic(({ topicId, resourceType, resourceId }) => {
|
||||
if (isGuest) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
@@ -61,14 +60,18 @@ export function TopicDetail() {
|
||||
|
||||
pageProgressMessage.set('Updating');
|
||||
|
||||
updateResourceProgressApi(
|
||||
{
|
||||
topicId,
|
||||
resourceId,
|
||||
resourceType,
|
||||
},
|
||||
status === 'done' ? 'pending' : 'done'
|
||||
)
|
||||
// Toggle the topic status
|
||||
isTopicDone({ topicId, resourceId, resourceType })
|
||||
.then((oldIsDone) =>
|
||||
updateResourceProgressApi(
|
||||
{
|
||||
topicId,
|
||||
resourceId,
|
||||
resourceType,
|
||||
},
|
||||
oldIsDone ? 'pending' : 'done'
|
||||
)
|
||||
)
|
||||
.then(({ done = [] }) => {
|
||||
renderTopicProgress(
|
||||
topicId,
|
||||
@@ -83,11 +86,10 @@ export function TopicDetail() {
|
||||
.finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
return;
|
||||
});
|
||||
|
||||
// Load the topic detail when the topic detail is active
|
||||
useLoadTopic(({ topicId, resourceType, resourceId, status }) => {
|
||||
useLoadTopic(({ topicId, resourceType, resourceId }) => {
|
||||
setIsLoading(true);
|
||||
setIsActive(true);
|
||||
sponsorHidden.set(true);
|
||||
@@ -96,7 +98,6 @@ export function TopicDetail() {
|
||||
setTopicId(topicId);
|
||||
setResourceType(resourceType);
|
||||
setResourceId(resourceId);
|
||||
setResourceStatus(status);
|
||||
|
||||
const topicPartial = topicId.replaceAll(':', '/');
|
||||
const topicUrl =
|
||||
@@ -176,7 +177,6 @@ export function TopicDetail() {
|
||||
topicId={topicId}
|
||||
resourceId={resourceId}
|
||||
resourceType={resourceType}
|
||||
status={resourceStatus}
|
||||
onClose={() => {
|
||||
setIsActive(false);
|
||||
setIsContributing(false);
|
||||
@@ -206,8 +206,7 @@ export function TopicDetail() {
|
||||
{/* Contribution */}
|
||||
<div className="mt-8 flex-1 border-t">
|
||||
<p class="mb-2 mt-2 text-sm leading-relaxed text-gray-400">
|
||||
Help others learn by submitting links to learn more about this
|
||||
topic{' '}
|
||||
Help others learn by submitting links to learn more about this topic{' '}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { isLoggedIn } from '../../lib/jwt';
|
||||
import {
|
||||
ResourceProgressType,
|
||||
ResourceType,
|
||||
getTopicStatus,
|
||||
refreshProgressCounters,
|
||||
renderTopicProgress,
|
||||
updateResourceProgress,
|
||||
@@ -18,7 +19,6 @@ type TopicProgressButtonProps = {
|
||||
topicId: string;
|
||||
resourceId: string;
|
||||
resourceType: ResourceType;
|
||||
status: ResourceProgressType;
|
||||
|
||||
onClose: () => void;
|
||||
};
|
||||
@@ -32,12 +32,11 @@ const statusColors: Record<ResourceProgressType, string> = {
|
||||
};
|
||||
|
||||
export function TopicProgressButton(props: TopicProgressButtonProps) {
|
||||
const { topicId, resourceId, resourceType, status, onClose } = props;
|
||||
console.log(status)
|
||||
const { topicId, resourceId, resourceType, onClose } = props;
|
||||
|
||||
const toast = useToast();
|
||||
const [isUpdatingProgress, setIsUpdatingProgress] = useState(false);
|
||||
const [progress, setProgress] = useState<ResourceProgressType>(status);
|
||||
const [isUpdatingProgress, setIsUpdatingProgress] = useState(true);
|
||||
const [progress, setProgress] = useState<ResourceProgressType>('pending');
|
||||
const [showChangeStatus, setShowChangeStatus] = useState(false);
|
||||
|
||||
const changeStatusRef = useRef<HTMLDivElement>(null);
|
||||
@@ -48,6 +47,20 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
|
||||
|
||||
const isGuest = useMemo(() => !isLoggedIn(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!topicId || !resourceId || !resourceType) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdatingProgress(true);
|
||||
getTopicStatus({ topicId, resourceId, resourceType })
|
||||
.then((status) => {
|
||||
setIsUpdatingProgress(false);
|
||||
setProgress(status);
|
||||
})
|
||||
.catch(console.error);
|
||||
}, [topicId, resourceId, resourceType]);
|
||||
|
||||
// Mark as done
|
||||
useKeydown(
|
||||
'd',
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import type {
|
||||
ResourceProgressType,
|
||||
ResourceType,
|
||||
} from '../lib/resource-progress';
|
||||
import type { ResourceType } from '../lib/resource-progress';
|
||||
|
||||
type CallbackType = (data: {
|
||||
resourceType: ResourceType;
|
||||
resourceId: string;
|
||||
topicId: string;
|
||||
status: ResourceProgressType;
|
||||
}) => void;
|
||||
|
||||
export function useLoadTopic(callback: CallbackType) {
|
||||
useEffect(() => {
|
||||
function handleTopicClick(e: any) {
|
||||
const { resourceType, resourceId, topicId, status } = e.detail;
|
||||
console.log('handleTopicClick', e.detail);
|
||||
const { resourceType, resourceId, topicId } = e.detail;
|
||||
|
||||
callback({
|
||||
resourceType,
|
||||
resourceId,
|
||||
topicId,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import type { ResourceProgressType, ResourceType } from '../lib/resource-progress';
|
||||
import type { ResourceType } from '../lib/resource-progress';
|
||||
|
||||
type CallbackType = (data: {
|
||||
resourceType: ResourceType;
|
||||
resourceId: string;
|
||||
topicId: string;
|
||||
status: ResourceProgressType
|
||||
}) => void;
|
||||
|
||||
export function useToggleTopic(callback: CallbackType) {
|
||||
useEffect(() => {
|
||||
function handleToggleTopic(e: any) {
|
||||
const { resourceType, resourceId, topicId, status } = e.detail;
|
||||
const { resourceType, resourceId, topicId } = e.detail;
|
||||
|
||||
callback({
|
||||
resourceType,
|
||||
resourceId,
|
||||
topicId,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +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'
|
||||
| 'removed';
|
||||
export type ResourceProgressType = 'done' | 'learning' | 'pending' | 'skipped' | 'removed';
|
||||
|
||||
type TopicMeta = {
|
||||
topicId: string;
|
||||
@@ -17,6 +12,35 @@ type TopicMeta = {
|
||||
resourceId: string;
|
||||
};
|
||||
|
||||
export async function isTopicDone(topic: TopicMeta): Promise<boolean> {
|
||||
const { topicId, resourceType, resourceId } = topic;
|
||||
const { done = [] } =
|
||||
(await getResourceProgress(resourceType, resourceId)) || {};
|
||||
|
||||
return done?.includes(topicId);
|
||||
}
|
||||
|
||||
export async function getTopicStatus(
|
||||
topic: TopicMeta
|
||||
): Promise<ResourceProgressType> {
|
||||
const { topicId, resourceType, resourceId } = topic;
|
||||
const progressResult = await getResourceProgress(resourceType, resourceId);
|
||||
|
||||
if (progressResult?.done?.includes(topicId)) {
|
||||
return 'done';
|
||||
}
|
||||
|
||||
if (progressResult?.learning?.includes(topicId)) {
|
||||
return 'learning';
|
||||
}
|
||||
|
||||
if (progressResult?.skipped?.includes(topicId)) {
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
export async function updateResourceProgress(
|
||||
topic: TopicMeta,
|
||||
progressType: ResourceProgressType
|
||||
@@ -39,6 +63,14 @@ export async function updateResourceProgress(
|
||||
throw new Error(error?.message || 'Something went wrong');
|
||||
}
|
||||
|
||||
setResourceProgress(
|
||||
resourceType,
|
||||
resourceId,
|
||||
response.done,
|
||||
response.learning,
|
||||
response.skipped
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -55,7 +87,35 @@ export async function getResourceProgress(
|
||||
};
|
||||
}
|
||||
|
||||
return loadFreshProgress(resourceType, resourceId);
|
||||
const progressKey = `${resourceType}-${resourceId}-progress`;
|
||||
const isFavoriteKey = `${resourceType}-${resourceId}-favorite`;
|
||||
|
||||
const rawIsFavorite = localStorage.getItem(isFavoriteKey);
|
||||
const isFavorite = JSON.parse(rawIsFavorite || '0') === 1;
|
||||
|
||||
const rawProgress = localStorage.getItem(progressKey);
|
||||
const progress = JSON.parse(rawProgress || 'null');
|
||||
|
||||
const progressTimestamp = progress?.timestamp;
|
||||
const diff = new Date().getTime() - parseInt(progressTimestamp || '0', 10);
|
||||
const isProgressExpired = diff > 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
if (!progress || isProgressExpired) {
|
||||
return loadFreshProgress(resourceType, resourceId);
|
||||
}
|
||||
|
||||
// Dispatch event to update favorite status in the MarkFavorite component
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('mark-favorite', {
|
||||
detail: {
|
||||
resourceType,
|
||||
resourceId,
|
||||
isFavorite
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return progress;
|
||||
}
|
||||
|
||||
async function loadFreshProgress(
|
||||
@@ -78,24 +138,49 @@ async function loadFreshProgress(
|
||||
done: [],
|
||||
learning: [],
|
||||
skipped: [],
|
||||
isFavorite: false,
|
||||
};
|
||||
}
|
||||
|
||||
setResourceProgress(
|
||||
resourceType,
|
||||
resourceId,
|
||||
response?.done || [],
|
||||
response?.learning || [],
|
||||
response?.skipped || [],
|
||||
);
|
||||
|
||||
// Dispatch event to update favorite status in the MarkFavorite component
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('mark-favorite', {
|
||||
detail: {
|
||||
resourceType,
|
||||
resourceId,
|
||||
isFavorite: response.isFavorite,
|
||||
},
|
||||
isFavorite: response.isFavorite
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export function setResourceProgress(
|
||||
resourceType: 'roadmap' | 'best-practice',
|
||||
resourceId: string,
|
||||
done: string[],
|
||||
learning: string[],
|
||||
skipped: string[],
|
||||
): void {
|
||||
localStorage.setItem(
|
||||
`${resourceType}-${resourceId}-progress`,
|
||||
JSON.stringify({
|
||||
done,
|
||||
learning,
|
||||
skipped,
|
||||
timestamp: new Date().getTime(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function renderTopicProgress(
|
||||
topicId: string,
|
||||
topicProgress: ResourceProgressType
|
||||
@@ -153,10 +238,10 @@ export function renderTopicProgress(
|
||||
});
|
||||
}
|
||||
|
||||
export function clearResourceProgress(progress: ResourceProgressType) {
|
||||
const clickableElements = document.querySelectorAll('.clickable-group');
|
||||
export function clearResourceProgress() {
|
||||
const clickableElements = document.querySelectorAll('.clickable-group')
|
||||
for (const clickableElement of clickableElements) {
|
||||
clickableElement.classList.remove(progress);
|
||||
clickableElement.classList.remove('done', 'skipped', 'learning', 'removed');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,21 +304,17 @@ export function refreshProgressCounters() {
|
||||
'.clickable-group.removed'
|
||||
).length;
|
||||
const totalItems =
|
||||
totalClickable -
|
||||
externalLinks -
|
||||
roadmapSwitchers -
|
||||
checkBoxes -
|
||||
totalRemoved;
|
||||
totalClickable - externalLinks - roadmapSwitchers - checkBoxes - totalRemoved;
|
||||
|
||||
const totalDone =
|
||||
document.querySelectorAll('.clickable-group.done').length -
|
||||
totalCheckBoxesDone;
|
||||
const totalLearning =
|
||||
document.querySelectorAll('.clickable-group.learning').length -
|
||||
totalCheckBoxesLearning;
|
||||
const totalSkipped =
|
||||
document.querySelectorAll('.clickable-group.skipped').length -
|
||||
totalCheckBoxesSkipped;
|
||||
const totalLearning = document.querySelectorAll(
|
||||
'.clickable-group.learning'
|
||||
).length - totalCheckBoxesLearning;
|
||||
const totalSkipped = document.querySelectorAll(
|
||||
'.clickable-group.skipped'
|
||||
).length - totalCheckBoxesSkipped;
|
||||
|
||||
const doneCountEls = document.querySelectorAll('[data-progress-done]');
|
||||
if (doneCountEls.length > 0) {
|
||||
|
||||
@@ -16,6 +16,9 @@ export async function get() {
|
||||
url: `/${roadmap.id}`,
|
||||
title: roadmap.frontmatter.briefTitle,
|
||||
group: 'Roadmaps',
|
||||
metadata: {
|
||||
tags: roadmap.frontmatter.tags,
|
||||
},
|
||||
})),
|
||||
...bestPractices.map((bestPractice) => ({
|
||||
id: bestPractice.id,
|
||||
|
||||
Reference in New Issue
Block a user