1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-08-22 08:53:01 +02:00

Remove unused comments

This commit is contained in:
Kamran Ahmed
2025-07-28 15:17:58 +01:00
parent b739deba99
commit cdf2ce6b11
74 changed files with 0 additions and 7230 deletions

View File

@@ -1,189 +0,0 @@
import CalendarHeatmap from 'react-calendar-heatmap';
import dayjs from 'dayjs';
import { formatActivityDate } from '../../lib/date';
import { Tooltip as ReactTooltip } from 'react-tooltip';
import 'react-calendar-heatmap/dist/styles.css';
import './AccountStreakHeatmap.css';
const legends = [
{ count: 1, color: 'bg-slate-600' },
{ count: 3, color: 'bg-slate-500' },
{ count: 5, color: 'bg-slate-400' },
{ count: 10, color: 'bg-slate-300' },
{ count: 20, color: 'bg-slate-200' },
];
type AccountStreakHeatmapProps = {};
export function AccountStreakHeatmap(props: AccountStreakHeatmapProps) {
const startDate = dayjs().subtract(6, 'months').toDate();
const endDate = dayjs().toDate();
return (
<div className="mt-4">
<CalendarHeatmap
startDate={startDate}
endDate={endDate}
values={[
{
date: '2024-08-01',
count: 4,
},
{
date: '2024-08-02',
count: 10,
},
{
date: '2024-08-03',
count: 5,
},
{
date: '2024-08-04',
count: 3,
},
{
date: '2024-08-05',
count: 7,
},
{
date: '2024-08-06',
count: 2,
},
{
date: '2024-08-07',
count: 6,
},
{
date: '2024-08-08',
count: 8,
},
{
date: '2024-08-09',
count: 9,
},
{
date: '2024-08-10',
count: 1,
},
{
date: '2024-08-11',
count: 3,
},
{
date: '2024-08-12',
count: 5,
},
{
date: '2024-08-13',
count: 7,
},
{
date: '2024-08-14',
count: 8,
},
{
date: '2024-08-15',
count: 2,
},
{
date: '2024-08-16',
count: 4,
},
{
date: '2024-08-17',
count: 6,
},
{
date: '2024-08-18',
count: 8,
},
{
date: '2024-08-19',
count: 10,
},
{
date: '2024-08-20',
count: 2,
},
{
date: '2024-08-21',
count: 4,
},
{
date: '2024-08-22',
count: 6,
},
{
date: '2024-08-23',
count: 8,
},
{
date: '2024-08-24',
count: 10,
},
{
date: '2024-08-25',
count: 30,
},
]}
classForValue={(value) => {
if (!value) {
return 'fill-slate-700 rounded-md [rx:2px] focus:outline-hidden';
}
const { count } = value;
if (count >= 20) {
return 'fill-slate-200 rounded-md [rx:2px] focus:outline-hidden';
} else if (count >= 10) {
return 'fill-slate-300 rounded-md [rx:2px] focus:outline-hidden';
} else if (count >= 5) {
return 'fill-slate-400 rounded-md [rx:2px] focus:outline-hidden';
} else if (count >= 3) {
return 'fill-slate-500 rounded-md [rx:2px] focus:outline-hidden';
} else {
return 'fill-slate-600 rounded-md [rx:2px] focus:outline-hidden';
}
}}
tooltipDataAttrs={(value: any) => {
if (!value || !value.date) {
return null;
}
const formattedDate = formatActivityDate(value.date);
return {
'data-tooltip-id': 'user-activity-tip',
'data-tooltip-content': `${value.count} Updates - ${formattedDate}`,
};
}}
/>
<ReactTooltip
id="user-activity-tip"
className="rounded-lg! bg-slate-900! p-1! px-2! text-xs!"
/>
<div className="mt-2 flex items-center justify-end">
<div className="flex items-center">
<span className="mr-2 text-xs text-slate-500">Less</span>
{legends.map((legend) => (
<div
key={legend.count}
className="flex items-center"
data-tooltip-id="user-activity-tip"
data-tooltip-content={`${legend.count} Updates`}
>
<div
className={`h-2.5 w-2.5 ${legend.color} mr-1 rounded-xs`}
></div>
</div>
))}
<span className="ml-2 text-xs text-slate-500">More</span>
<ReactTooltip
id="user-activity-tip"
className="rounded-lg! bg-slate-900! p-1! px-2! text-sm!"
/>
</div>
</div>
</div>
);
}

View File

@@ -1,56 +0,0 @@
type ActivityCountersType = {
done: {
today: number;
total: number;
};
learning: {
today: number;
total: number;
};
streak: {
count: number;
};
};
type ActivityCounterType = {
text: string;
count: string;
};
function ActivityCounter(props: ActivityCounterType) {
const { text, count } = props;
return (
<div className="relative flex flex-1 flex-row-reverse sm:flex-col px-0 sm:px-4 py-2 sm:py-4 text-center sm:pt-[1.62rem] items-center gap-2 sm:gap-0 justify-end">
<h2 className="text-base sm:text-5xl font-bold">
{count}
</h2>
<p className="mt-0 sm:mt-2 text-sm text-gray-400">{text}</p>
</div>
);
}
export function ActivityCounters(props: ActivityCountersType) {
const { done, learning, streak } = props;
return (
<div className="mx-0 -mt-5 sm:-mx-10 md:-mt-10">
<div className="flex flex-col sm:flex-row gap-0 sm:gap-2 divide-y sm:divide-y-0 divide-x-0 sm:divide-x border-b">
<ActivityCounter
text={'Topics Completed'}
count={`${done?.total || 0}`}
/>
<ActivityCounter
text={'Currently Learning'}
count={`${learning?.total || 0}`}
/>
<ActivityCounter
text={'Visit Streak'}
count={`${streak?.count || 0}d`}
/>
</div>
</div>
);
}

View File

@@ -1,258 +0,0 @@
import { useEffect, useState } from 'react';
import { httpGet } from '../../lib/http';
import { ActivityCounters } from './ActivityCounters';
import { ResourceProgress } from './ResourceProgress';
import { pageProgressMessage } from '../../stores/page';
import { EmptyActivity } from './EmptyActivity';
import { ActivityStream, type UserStreamActivity } from './ActivityStream';
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions';
import type { PageType } from '../CommandMenu/CommandMenu';
import { useToast } from '../../hooks/use-toast';
import { ProjectProgress } from './ProjectProgress';
type ProgressResponse = {
updatedAt: string;
title: string;
id: string;
learning: number;
skipped: number;
done: number;
total: number;
isCustomResource: boolean;
roadmapSlug?: string;
};
export type ActivityResponse = {
done: {
today: number;
total: number;
};
learning: {
today: number;
total: number;
roadmaps: ProgressResponse[];
bestPractices: ProgressResponse[];
customs: ProgressResponse[];
};
streak: {
count: number;
firstVisitAt: Date | null;
lastVisitAt: Date | null;
};
activity: {
type: 'done' | 'learning' | 'pending' | 'skipped';
createdAt: Date;
metadata: {
resourceId?: string;
resourceType?: 'roadmap' | 'best-practice';
topicId?: string;
topicLabel?: string;
resourceTitle?: string;
};
}[];
activities: UserStreamActivity[];
projects: ProjectStatusDocument[];
};
export function ActivityPage() {
const toast = useToast();
const [activity, setActivity] = useState<ActivityResponse>();
const [isLoading, setIsLoading] = useState(true);
const [projectDetails, setProjectDetails] = useState<PageType[]>([]);
async function loadActivity() {
const { error, response } = await httpGet<ActivityResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-stats`,
);
if (!response || error) {
console.error('Error loading activity');
console.error(error);
return;
}
setActivity(response);
}
async function loadAllProjectDetails() {
const { error, response } = await httpGet<PageType[]>(`/pages.json`);
if (error) {
toast.error(error.message || 'Something went wrong');
return;
}
if (!response) {
return [];
}
const allProjects = response.filter((page) => page.group === 'Projects');
setProjectDetails(allProjects);
}
useEffect(() => {
Promise.allSettled([loadActivity(), loadAllProjectDetails()]).finally(
() => {
pageProgressMessage.set('');
setIsLoading(false);
},
);
}, []);
const learningRoadmaps = activity?.learning.roadmaps || [];
const learningBestPractices = activity?.learning.bestPractices || [];
if (isLoading) {
return null;
}
const learningRoadmapsToShow = learningRoadmaps
.sort((a, b) => {
const updatedAtA = new Date(a.updatedAt);
const updatedAtB = new Date(b.updatedAt);
return updatedAtB.getTime() - updatedAtA.getTime();
})
.filter((roadmap) => roadmap.learning > 0 || roadmap.done > 0);
const learningBestPracticesToShow = learningBestPractices
.sort((a, b) => {
const updatedAtA = new Date(a.updatedAt);
const updatedAtB = new Date(b.updatedAt);
return updatedAtB.getTime() - updatedAtA.getTime();
})
.filter(
(bestPractice) => bestPractice.learning > 0 || bestPractice.done > 0,
);
const hasProgress =
learningRoadmapsToShow.length !== 0 ||
learningBestPracticesToShow.length !== 0;
const enrichedProjects = activity?.projects.map((project) => {
const projectDetail = projectDetails.find(
(page) => page.id === project.projectId,
);
return {
...project,
title: projectDetail?.title || 'N/A',
};
});
return (
<>
<ActivityCounters
done={activity?.done || { today: 0, total: 0 }}
learning={activity?.learning || { today: 0, total: 0 }}
streak={activity?.streak || { count: 0 }}
/>
<div className="mx-0 px-0 py-5 pb-0 md:-mx-10 md:px-8 md:py-8 md:pb-0">
{learningRoadmapsToShow.length === 0 &&
learningBestPracticesToShow.length === 0 && <EmptyActivity />}
{(learningRoadmapsToShow.length > 0 ||
learningBestPracticesToShow.length > 0) && (
<>
<h2 className="mb-3 text-xs uppercase text-gray-400">
Continue Following
</h2>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{learningRoadmaps
.sort((a, b) => {
const updatedAtA = new Date(a.updatedAt);
const updatedAtB = new Date(b.updatedAt);
return updatedAtB.getTime() - updatedAtA.getTime();
})
.filter((roadmap) => roadmap.learning > 0 || roadmap.done > 0)
.map((roadmap) => {
const learningCount = roadmap.learning || 0;
const doneCount = roadmap.done || 0;
const totalCount = roadmap.total || 0;
const skippedCount = roadmap.skipped || 0;
return (
<ResourceProgress
key={roadmap.id}
isCustomResource={roadmap.isCustomResource}
doneCount={
doneCount > totalCount ? totalCount : doneCount
}
learningCount={
learningCount > totalCount ? totalCount : learningCount
}
totalCount={totalCount}
skippedCount={skippedCount}
resourceId={roadmap.id}
resourceType={'roadmap'}
updatedAt={roadmap.updatedAt}
title={roadmap.title}
onCleared={() => {
pageProgressMessage.set('Updating activity');
loadActivity().finally(() => {
pageProgressMessage.set('');
});
}}
/>
);
})}
{learningBestPractices
.sort((a, b) => {
const updatedAtA = new Date(a.updatedAt);
const updatedAtB = new Date(b.updatedAt);
return updatedAtB.getTime() - updatedAtA.getTime();
})
.filter(
(bestPractice) =>
bestPractice.learning > 0 || bestPractice.done > 0,
)
.map((bestPractice) => (
<ResourceProgress
isCustomResource={bestPractice.isCustomResource}
key={bestPractice.id}
doneCount={bestPractice.done || 0}
totalCount={bestPractice.total || 0}
learningCount={bestPractice.learning || 0}
resourceId={bestPractice.id}
skippedCount={bestPractice.skipped || 0}
resourceType={'best-practice'}
title={bestPractice.title}
updatedAt={bestPractice.updatedAt}
onCleared={() => {
pageProgressMessage.set('Updating activity');
loadActivity().finally(() => {
pageProgressMessage.set('');
});
}}
/>
))}
</div>
</>
)}
</div>
{enrichedProjects && enrichedProjects?.length > 0 && (
<div className="mx-0 px-0 py-5 pb-0 md:-mx-10 md:px-8 md:py-8 md:pb-0">
<h2 className="mb-3 text-xs uppercase text-gray-400">
Your Projects
</h2>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{enrichedProjects.map((project) => (
<ProjectProgress key={project._id} projectStatus={project} />
))}
</div>
</div>
)}
{hasProgress && (
<ActivityStream activities={activity?.activities || []} />
)}
</>
);
}

View File

@@ -1,24 +0,0 @@
import { RoadmapIcon } from "../ReactIcons/RoadmapIcon";
export function EmptyActivity() {
return (
<div className="rounded-md">
<div className="flex flex-col items-center p-7 text-center">
<RoadmapIcon className="mb-2 h-14 w-14 opacity-10" />
<h2 className="text-lg sm:text-xl font-bold">No Progress</h2>
<p className="my-1 sm:my-2 max-w-[400px] text-gray-500 text-sm sm:text-base">
Progress will appear here as you start tracking your{' '}
<a href="/roadmaps" className="mt-4 text-blue-500 hover:underline">
Roadmaps
</a>{' '}
or{' '}
<a href="/best-practices" className="mt-4 text-blue-500 hover:underline">
Best Practices
</a>{' '}
progress.
</p>
</div>
</div>
);
}

View File

@@ -1,43 +0,0 @@
import { ChevronDownIcon } from '../ReactIcons/ChevronDownIcon';
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>
)}
<ChevronDownIcon className="relative top-[1px] h-[17px] w-[17px] opacity-40" />
</div>
);
}

View File

@@ -1,145 +0,0 @@
import { useEffect, useState } from 'react';
import { httpGet, httpPost } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
import { isLoggedIn } from '../../lib/jwt';
import { GitFork, Loader2, Map } from 'lucide-react';
import { showLoginPopup } from '../../lib/popup';
import type { RoadmapDocument } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
type CreateVersionProps = {
roadmapId: string;
};
export function CreateVersion(props: CreateVersionProps) {
const { roadmapId } = props;
const toast = useToast();
const [isLoading, setIsLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
const [userVersion, setUserVersion] = useState<RoadmapDocument>();
async function loadMyVersion() {
if (!isLoggedIn()) {
return;
}
setIsLoading(true);
const { response, error } = await httpGet<RoadmapDocument>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-my-version/${roadmapId}`,
{},
);
if (error || !response) {
setIsLoading(false);
return;
}
setIsLoading(false);
setUserVersion(response);
}
useEffect(() => {
loadMyVersion().finally(() => {
setIsLoading(false);
});
}, []);
async function createVersion() {
if (isCreating || !roadmapId) {
return;
}
if (!isLoggedIn()) {
showLoginPopup();
return;
}
setIsCreating(true);
const { response, error } = await httpPost<{ roadmapId: string }>(
`${import.meta.env.PUBLIC_API_URL}/v1-create-version/${roadmapId}`,
{},
);
if (error || !response) {
setIsCreating(false);
toast.error(error?.message || 'Failed to create version');
return;
}
window.location.href = `${
import.meta.env.PUBLIC_EDITOR_APP_URL
}/${response?.roadmapId}`;
}
if (isLoading) {
return (
<div className="h-[30px] w-[312px] animate-pulse rounded-md bg-gray-300"></div>
);
}
if (!isLoading && userVersion?._id) {
return (
<div className={'flex items-center'}>
<a
href={`/r/${userVersion?.slug}`}
className="flex items-center rounded-md border border-blue-400 bg-gray-50 px-2.5 py-1 text-xs font-medium text-blue-600 hover:bg-blue-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:hover:bg-gray-100 max-sm:hidden sm:text-sm"
>
<Map size="15px" className="mr-1.5" />
Visit your own version of this Roadmap
</a>
</div>
);
}
if (isConfirming) {
return (
<p className="flex h-[30px] items-center text-sm text-red-500">
Create and edit a custom roadmap from this roadmap?
<button
onClick={() => {
setIsConfirming(false);
createVersion().finally(() => null);
}}
className="ml-2 font-semibold underline underline-offset-2"
>
Yes
</button>
<span className="text-xs">&nbsp;/&nbsp;</span>
<button
className="font-semibold underline underline-offset-2"
onClick={() => setIsConfirming(false)}
>
No
</button>
</p>
);
}
return (
<button
disabled={isCreating}
className="flex items-center justify-center rounded-md border border-gray-300 bg-gray-50 px-2.5 py-1 text-xs font-medium text-black hover:bg-gray-200 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:hover:bg-gray-100 max-sm:hidden sm:text-sm"
onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
setIsConfirming(true);
}}
>
{isCreating ? (
<>
<Loader2 className="mr-2 h-3 w-3 animate-spin stroke-[2.5]" />
Please wait ..
</>
) : (
<>
<GitFork className="mr-1.5" size="16px" />
Create your own version of this roadmap
</>
)}
</button>
);
}

View File

@@ -1,16 +0,0 @@
---
import DeleteAccountPopup from "./DeleteAccountPopup.astro";
---
<DeleteAccountPopup />
<h2 class='text-xl font-bold sm:text-2xl'>Delete Account</h2>
<p class='mt-2 text-gray-400'>
Permanently remove your account from the roadmap.sh. This cannot be undone and all your progress and data will be lost.
</p>
<button
data-popup='delete-account-popup'
class="mt-4 w-full rounded-lg bg-red-600 py-2 text-base font-regular text-white outline-hidden focus:ring-2 focus:ring-red-500 focus:ring-offset-1"
>
Delete Account
</button>

View File

@@ -1,89 +0,0 @@
import { type FormEvent, useEffect, useState } from 'react';
import { httpDelete } from '../../lib/http';
import { logout } from '../../lib/auth';
export function DeleteAccountForm() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [confirmationText, setConfirmationText] = useState('');
useEffect(() => {
setError('');
setConfirmationText('');
}, []);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError('');
if (confirmationText.toUpperCase() !== 'DELETE') {
setError('Verification text does not match');
setIsLoading(false);
return;
}
const { response, error } = await httpDelete(
`${import.meta.env.PUBLIC_API_URL}/v1-delete-account`,
);
if (error || !response) {
setIsLoading(false);
setError(error?.message || 'Something went wrong');
return;
}
logout();
};
const handleClosePopup = () => {
setIsLoading(false);
setError('');
setConfirmationText('');
const deleteAccountPopup = document.getElementById('delete-account-popup');
deleteAccountPopup?.classList.add('hidden');
deleteAccountPopup?.classList.remove('flex');
};
return (
<form onSubmit={handleSubmit}>
<div className="my-4">
<input
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-hidden 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="grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading || confirmationText.toUpperCase() !== 'DELETE'}
className="grow cursor-pointer rounded-lg bg-red-500 py-2 text-white disabled:opacity-40"
>
{isLoading ? 'Please wait ..' : 'Confirm'}
</button>
</div>
</form>
);
}

View File

@@ -1,17 +0,0 @@
---
import Popup from '../Popup/Popup.astro';
import { DeleteAccountForm } from './DeleteAccountForm';
---
<Popup id='delete-account-popup' title='Delete Account' subtitle=''>
<div class='-mt-2.5'>
<p>
This will permanently delete your account and all your associated data
including your progress.
</p>
<p class="text-black font-medium -mb-2 mt-3 text-base">Please type "delete" to confirm.</p>
<DeleteAccountForm client:only="react" />
</div>
</Popup>

View File

@@ -1,52 +0,0 @@
import { useCopyText } from '../../hooks/use-copy-text';
import { CopyIcon, UserPlus2 } from 'lucide-react';
type EmptyFriendsProps = {
befriendUrl: string;
};
export function EmptyFriends(props: EmptyFriendsProps) {
const { befriendUrl } = props;
const { isCopied, copyText } = useCopyText();
return (
<div className="rounded-md">
<div className="mx-auto flex flex-col items-center p-7 text-center">
<UserPlus2 className="mb-2 h-[60px] w-[60px] opacity-10 sm:h-[120px] sm:w-[120px]" />
<h2 className="text-lg font-bold sm:text-xl">Invite your Friends</h2>
<p className="mb-4 mt-1 max-w-[400px] text-sm leading-relaxed text-gray-500">
Share the unique link below with your friends to track their skills
and progress.
</p>
<div className="flex w-full max-w-[352px] items-center justify-center gap-2 rounded-lg border-2 p-1 text-sm">
<input
onClick={(e) => {
e.currentTarget.select();
copyText(befriendUrl);
}}
type="text"
value={befriendUrl}
className="w-full border-none bg-transparent px-1.5 outline-hidden"
readOnly
/>
<button
className={`flex items-center justify-center gap-1 rounded-md border-0 p-2 px-4 text-sm text-black ${
isCopied
? 'bg-green-300 hover:bg-green-300'
: 'bg-gray-200 hover:bg-gray-300'
}`}
onClick={() => {
copyText(befriendUrl);
}}
>
<CopyIcon className="mr-1 h-4 w-4" />
{isCopied ? 'Copied' : 'Copy'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,337 +0,0 @@
import { useState } from 'react';
import type { ListFriendsResponse } from './FriendsPage';
import { DeleteUserIcon } from '../ReactIcons/DeleteUserIcon';
import { pageProgressMessage } from '../../stores/page';
import { httpDelete, httpPost } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
import { TrashIcon } from '../ReactIcons/TrashIcon';
import { AddedUserIcon } from '../ReactIcons/AddedUserIcon';
import { AddUserIcon } from '../ReactIcons/AddUserIcon';
import type { AllowedRoadmapRenderer } from '../../lib/roadmap';
type FriendProgressItemProps = {
friend: ListFriendsResponse[0];
onShowResourceProgress: (
resourceId: string,
isCustomResource?: boolean,
renderer?: AllowedRoadmapRenderer,
) => void;
onReload: () => void;
};
export function FriendProgressItem(props: FriendProgressItemProps) {
const { friend, onShowResourceProgress, onReload } = props;
const toast = useToast();
const [isConfirming, setIsConfirming] =
useState<ListFriendsResponse[0]['status']>();
async function deleteFriend(userId: string, successMessage: string) {
pageProgressMessage.set('Please wait...');
const { response, error } = await httpDelete(
`${import.meta.env.PUBLIC_API_URL}/v1-delete-friend/${userId}`,
{},
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
return;
}
toast.success(successMessage);
onReload();
}
async function addFriend(userId: string, successMessage: string) {
pageProgressMessage.set('Please wait...');
const { response, error } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-add-friend/${userId}`,
{},
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
return;
}
toast.success(successMessage);
onReload();
}
const roadmaps = (friend?.roadmaps || []).sort((a, b) => {
return b.done - a.done;
});
const [showAll, setShowAll] = useState(false);
const status = friend.status;
return (
<>
<div
className={`flex h-full min-h-[270px] flex-col overflow-hidden rounded-md border`}
key={friend.userId}
>
<div className={`relative flex items-center gap-3 border-b p-3`}>
<img
src={
friend.avatar
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${friend.avatar}`
: '/img/default-avatar.png'
}
alt={friend.name || ''}
className="h-8 w-8 rounded-full"
/>
<div className="inline-grid w-full">
<h3 className="truncate font-medium">{friend.name}</h3>
<p className="truncate text-sm text-gray-500">{friend.email}</p>
</div>
</div>
{friend.status === 'accepted' && (
<>
<div className="relative flex grow flex-col space-y-2 p-3">
{(showAll ? roadmaps : roadmaps.slice(0, 4)).map((progress) => {
return (
<button
onClick={() =>
onShowResourceProgress(
progress.resourceId,
progress.isCustomResource,
progress?.renderer,
)
}
className="group relative overflow-hidden rounded-md border p-2 hover:border-gray-300 hover:text-black focus:outline-hidden"
key={progress.resourceId}
>
<span className="relative z-10 flex items-center justify-between text-sm">
<span className="inline-grid">
<span className={'truncate'}>{progress.title}</span>
</span>
<span className="ml-1.5 shrink-0 text-xs text-gray-400">
{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>
);
})}
{roadmaps.length > 4 && !showAll && (
<button
onClick={() => setShowAll(true)}
className={'text-xs text-gray-400 underline'}
>
+ {roadmaps.length - 4} more
</button>
)}
{showAll && (
<button
onClick={() => setShowAll(false)}
className={'text-sm text-gray-400 underline'}
>
- Show less
</button>
)}
{roadmaps.length === 0 && (
<div className="text-sm text-gray-500">No progress</div>
)}
</div>
<>
{isConfirming !== 'accepted' && (
<button
className="flex w-full items-center justify-center border-t py-2 text-sm font-medium text-red-700 hover:bg-red-50/50 hover:text-red-500"
onClick={() => {
setIsConfirming('accepted');
}}
>
<TrashIcon className="mr-1 h-4 w-4" />
Remove Friend
</button>
)}
{isConfirming === 'accepted' && (
<span className="flex w-full items-center justify-center border-t py-2 text-sm text-red-700">
Are you sure?{' '}
<button
className="ml-2 font-medium text-red-700 underline underline-offset-2 hover:text-red-500"
onClick={() => {
deleteFriend(friend.userId, 'Friend removed').finally(
() => {
pageProgressMessage.set('');
},
);
}}
>
Yes
</button>{' '}
<button
className="ml-2 font-medium text-red-700 underline underline-offset-2 hover:text-red-500"
onClick={() => {
setIsConfirming(undefined);
}}
>
No
</button>
</span>
)}
</>
</>
)}
{friend.status === 'rejected' && (
<>
<div
className={'flex w-full grow items-center justify-center'}
>
<span className=" flex flex-col items-center text-red-500">
<DeleteUserIcon additionalClasses="mr-2 h-8 w-8 mb-1" />
Request Rejected
</span>
</div>
<span className="flex cursor-default items-center justify-center border-t py-2 text-center text-sm">
Changed your mind?{' '}
<button
className="ml-2 font-medium text-red-700 underline underline-offset-2 hover:text-red-500"
onClick={() => {
addFriend(friend.userId, 'Friend request accepted').finally(
() => {
pageProgressMessage.set('');
},
);
}}
>
Accept
</button>
</span>
</>
)}
{friend.status === 'got_rejected' && (
<>
<div
className={'flex w-full grow items-center justify-center'}
>
<span className=" flex flex-col items-center text-sm text-red-500">
<DeleteUserIcon additionalClasses="mr-2 h-8 w-8 mb-1" />
Request Rejected
</span>
</div>
<span className="flex cursor-default items-center justify-center border-t py-2.5 text-center text-sm">
<button
className="ml-2 flex items-center font-medium text-red-700 underline underline-offset-2 hover:text-red-500"
onClick={() => {
deleteFriend(friend.userId, 'Friend request removed').finally(
() => {
pageProgressMessage.set('');
},
);
}}
>
<TrashIcon className="mr-1 h-4 w-4" />
Delete Request
</button>
</span>
</>
)}
{friend.status === 'sent' && (
<>
<div
className={'flex w-full grow items-center justify-center'}
>
<span className=" flex flex-col items-center text-green-500">
<AddedUserIcon additionalClasses="mr-2 h-8 w-8 mb-1" />
Request Sent
</span>
</div>
<>
{isConfirming !== 'sent' && (
<button
className="flex w-full items-center justify-center border-t py-2 text-sm font-medium text-red-700 hover:bg-red-50/50 hover:text-red-500"
onClick={() => {
setIsConfirming('sent');
}}
>
<TrashIcon className="mr-1 h-4 w-4" />
Withdraw Request
</button>
)}
{isConfirming === 'sent' && (
<span className="flex w-full items-center justify-center border-t py-2 text-sm text-red-700">
Are you sure?{' '}
<button
className="ml-2 font-medium text-red-700 underline underline-offset-2 hover:text-red-500"
onClick={() => {
deleteFriend(
friend.userId,
'Friend request withdrawn',
).finally(() => {
pageProgressMessage.set('');
});
}}
>
Yes
</button>{' '}
<button
className="ml-2 font-medium text-red-700 underline underline-offset-2 hover:text-red-500"
onClick={() => {
setIsConfirming(undefined);
}}
>
No
</button>
</span>
)}
</>
</>
)}
{friend.status === 'received' && (
<>
<div
className={
'flex w-full grow flex-col items-center justify-center px-4'
}
>
<AddUserIcon additionalClasses="mr-2 h-8 w-8 mb-1 text-green-500" />
<span className="mb-3 text-green-600">Request Received</span>
<button
onClick={() => {
addFriend(friend.userId, 'Friend request accepted').finally(
() => {
pageProgressMessage.set('');
},
);
}}
className="mb-1 block w-full max-w-[150px] rounded-md bg-black py-1.5 text-sm text-white"
>
Accept
</button>
<button
onClick={() => {
deleteFriend(
friend.userId,
'Friend request rejected',
).finally(() => {
pageProgressMessage.set('');
});
}}
className="block w-full max-w-[150px] rounded-md border border-red-500 py-1 text-sm text-red-500"
>
Reject
</button>
</div>
</>
)}
</div>
</>
);
}

View File

@@ -1,238 +0,0 @@
import { useEffect, useState } from 'react';
import { pageProgressMessage } from '../../stores/page';
import { useAuth } from '../../hooks/use-auth';
import { AddUserIcon } from '../ReactIcons/AddUserIcon';
import { httpGet } from '../../lib/http';
import type { FriendshipStatus } from '../Befriend';
import { useToast } from '../../hooks/use-toast';
import { EmptyFriends } from './EmptyFriends';
import { FriendProgressItem } from './FriendProgressItem';
import { UserProgressModal } from '../UserProgress/UserProgressModal';
import { InviteFriendPopup } from './InviteFriendPopup';
import { UserCustomProgressModal } from '../UserProgress/UserCustomProgressModal';
import { UserIcon } from 'lucide-react';
import type { AllowedRoadmapRenderer } from '../../lib/roadmap';
type FriendResourceProgress = {
updatedAt: string;
title: string;
resourceId: string;
resourceType: string;
isCustomResource: boolean;
learning: number;
skipped: number;
done: number;
total: number;
renderer?: AllowedRoadmapRenderer;
};
export type ListFriendsResponse = {
userId: string;
name: string;
email: string;
avatar: string;
status: FriendshipStatus;
roadmaps: FriendResourceProgress[];
bestPractices: FriendResourceProgress[];
}[];
type GroupingType = {
label: string;
value: 'active' | 'requests' | 'sent';
statuses: FriendshipStatus[];
};
const groupingTypes: GroupingType[] = [
{ label: 'Active', value: 'active', statuses: ['accepted'] },
{ label: 'Requests', value: 'requests', statuses: ['received', 'rejected'] },
{ label: 'Sent', value: 'sent', statuses: ['sent', 'got_rejected'] },
];
export function FriendsPage() {
const toast = useToast();
const [showInviteFriendPopup, setShowInviteFriendPopup] = useState(false);
const [showFriendProgress, setShowFriendProgress] = useState<{
resourceId: string;
friend: ListFriendsResponse[0];
isCustomResource?: boolean;
renderer?: AllowedRoadmapRenderer;
}>();
const [isLoading, setIsLoading] = useState(true);
const [friends, setFriends] = useState<ListFriendsResponse>([]);
const [selectedGrouping, setSelectedGrouping] =
useState<GroupingType['value']>('active');
async function loadFriends() {
const { response, error } = await httpGet<ListFriendsResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-list-friends`,
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
return;
}
setFriends(response);
}
useEffect(() => {
loadFriends().finally(() => {
pageProgressMessage.set('');
setIsLoading(false);
});
}, []);
const user = useAuth();
const baseUrl = import.meta.env.DEV
? 'http://localhost:3000'
: 'https://roadmap.sh';
const befriendUrl = `${baseUrl}/befriend?u=${user?.id}`;
const selectedGroupingType = groupingTypes.find(
(grouping) => grouping.value === selectedGrouping,
);
const filteredFriends = friends.filter((friend) =>
selectedGroupingType?.statuses.includes(friend.status),
);
const receivedRequests = friends.filter(
(friend) => friend.status === 'received',
);
if (isLoading) {
return null;
}
if (!friends?.length) {
return <EmptyFriends befriendUrl={befriendUrl} />;
}
const progressModal =
showFriendProgress && showFriendProgress?.isCustomResource ? (
<UserCustomProgressModal
userId={showFriendProgress?.friend.userId}
resourceId={showFriendProgress.resourceId}
resourceType="roadmap"
isCustomResource={true}
onClose={() => setShowFriendProgress(undefined)}
/>
) : (
<UserProgressModal
userId={showFriendProgress?.friend.userId}
resourceId={showFriendProgress?.resourceId!}
resourceType={'roadmap'}
onClose={() => setShowFriendProgress(undefined)}
isCustomResource={showFriendProgress?.isCustomResource}
renderer={showFriendProgress?.renderer}
/>
);
return (
<div>
{showInviteFriendPopup && (
<InviteFriendPopup
befriendUrl={befriendUrl}
onClose={() => setShowInviteFriendPopup(false)}
/>
)}
{showFriendProgress && progressModal}
<div className="mb-4 flex flex-col items-stretch justify-between gap-2 sm:flex-row sm:items-center sm:gap-0">
<div className="flex items-center gap-2">
{groupingTypes.map((grouping) => {
let requestCount = 0;
if (grouping.value === 'requests') {
requestCount = receivedRequests.length;
}
return (
<button
key={grouping.value}
className={`relative flex items-center justify-center rounded-md border p-1 px-3 text-sm ${
selectedGrouping === grouping.value
? ' border-gray-400 bg-gray-200 '
: ''
} w-full sm:w-auto`}
onClick={() => setSelectedGrouping(grouping.value)}
>
{grouping.label}
{requestCount > 0 && (
<span className="ml-1.5 inline-flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] text-white">
{requestCount}
</span>
)}
</button>
);
})}
</div>
<button
onClick={() => {
setShowInviteFriendPopup(true);
}}
className="flex items-center justify-center gap-1.5 rounded-md border border-gray-400 bg-gray-50 p-1 px-2 text-sm hover:border-gray-500 hover:bg-gray-100"
>
<AddUserIcon additionalClasses="w-4 h-4" />
Invite Friends
</button>
</div>
{filteredFriends.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{filteredFriends.map((friend) => (
<FriendProgressItem
friend={friend}
onShowResourceProgress={(
resourceId,
isCustomResource,
renderer,
) => {
setShowFriendProgress({
resourceId,
friend,
isCustomResource,
renderer,
});
}}
key={friend.userId}
onReload={() => {
pageProgressMessage.set('Reloading friends ..');
loadFriends().finally(() => {
pageProgressMessage.set('');
});
}}
/>
))}
</div>
)}
{filteredFriends.length === 0 && (
<div className="flex flex-col items-center justify-center py-12">
<UserIcon size={'60px'} className="mb-3 w-12 opacity-20" />
<h2 className="text-lg font-semibold">
{selectedGrouping === 'active' && 'No friends yet'}
{selectedGrouping === 'sent' && 'No requests sent'}
{selectedGrouping === 'requests' && 'No requests received'}
</h2>
<p className="text-sm text-gray-500">
Invite your friends to join you on Roadmap
</p>
<button
onClick={() => {
setShowInviteFriendPopup(true);
}}
className="mt-4 flex items-center justify-center gap-1.5 rounded-md border border-gray-400 bg-gray-50 p-1 px-2 text-sm hover:border-gray-500 hover:bg-gray-100"
>
<AddUserIcon additionalClasses="w-4 h-4" />
Invite Friends
</button>
</div>
)}
</div>
);
}

View File

@@ -1,65 +0,0 @@
import type { MouseEvent } from 'react';
import { useRef } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { useCopyText } from '../../hooks/use-copy-text';
import { CopyIcon } from 'lucide-react';
type InviteFriendPopupProps = {
befriendUrl: string;
onClose: () => void;
};
export function InviteFriendPopup(props: InviteFriendPopupProps) {
const { onClose, befriendUrl } = props;
const { isCopied, copyText } = useCopyText();
const popupBodyRef = useRef<HTMLDivElement>(null);
const handleClosePopup = () => {
onClose();
};
useOutsideClick(popupBodyRef, handleClosePopup);
return (
<div className="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 className="relative h-full w-full max-w-md p-4 md:h-auto">
<div
ref={popupBodyRef}
className="popup-body relative rounded-lg bg-white p-4 shadow-sm"
>
<h3 className="mb-1.5 text-xl font-medium sm:text-2xl">Invite URL</h3>
<p className="mb-3 hidden text-sm leading-none text-gray-400 sm:block">
Share the link below with your friends to invite them.
</p>
<div className="mt-4 flex flex-col gap-2 sm:mt-4">
<input
readOnly={true}
className="mt-2 block w-full rounded-md border border-gray-300 px-3 py-2 outline-hidden placeholder:text-gray-400 focus:border-gray-400"
value={befriendUrl}
onClick={(e: MouseEvent<HTMLInputElement>) => {
(e?.target as HTMLInputElement)?.select();
copyText(befriendUrl);
}}
/>
<button
className={`flex items-center justify-center gap-1 rounded-md border-0 px-3 py-2.5 text-sm text-black ${
isCopied
? 'bg-green-300 hover:bg-green-300'
: 'bg-gray-200 hover:bg-gray-300'
}`}
onClick={() => {
copyText(befriendUrl);
}}
>
<CopyIcon className="mr-1 h-4 w-4" />
{isCopied ? 'Copied' : 'Copy URL'}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,125 +0,0 @@
import { useEffect, useState } from 'react';
import { httpGet, httpPatch } from '../../lib/http';
import { pageProgressMessage } from '../../stores/page';
import type { TeamMemberDocument } from '../TeamMembers/TeamMembersPage';
import { useToast } from '../../hooks/use-toast';
import { AcceptIcon } from '../ReactIcons/AcceptIcon.tsx';
import { XIcon } from 'lucide-react';
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/activity?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 className="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="mt-6 flex items-center justify-center">
<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 rounded-sm border p-1 hover:bg-gray-50 disabled:opacity-75"
onClick={() => respondInvitation('accept', notification?._id!)}
>
<AcceptIcon className="h-4 w-4" />
</button>
<button
type="button"
disabled={isLoading}
className="inline-flex rounded-sm border p-1 hover:bg-gray-50 disabled:opacity-75"
onClick={() => respondInvitation('reject', notification?._id!)}
>
<XIcon className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -1,104 +0,0 @@
import { useEffect, useState } from 'react';
import { httpGet, httpPatch, httpPost } from '../../lib/http';
import { sponsorHidden } from '../../stores/page';
import { useStore } from '@nanostores/react';
import { X } from 'lucide-react';
import { setViewSponsorCookie } from '../../lib/jwt';
import { isMobile } from '../../lib/is-mobile';
import Cookies from 'js-cookie';
import { getUrlUtmParams } from '../../lib/browser.ts';
export type BottomRightSponsorType = {
id: string;
company: string;
description: string;
gaLabel: string;
imageUrl: string;
pageUrl: string;
title: string;
url: string;
};
type V1GetSponsorResponse = {
id?: string;
href?: string;
sponsor?: BottomRightSponsorType;
};
type BottomRightSponsorProps = {
sponsor: BottomRightSponsorType;
onSponsorClick: () => void;
onSponsorImpression: () => void;
onSponsorHidden: () => void;
};
export function BottomRightSponsor(props: BottomRightSponsorProps) {
const { sponsor, onSponsorImpression, onSponsorClick, onSponsorHidden } =
props;
const [isHidden, setIsHidden] = useState(false);
useEffect(() => {
if (!sponsor) {
return;
}
onSponsorImpression();
}, []);
const { url, title, imageUrl, description, company, gaLabel } = sponsor;
const isRoadmapAd = title.toLowerCase() === 'advertise with us!';
if (isHidden) {
return null;
}
return (
<a
href={url}
target="_blank"
rel="noopener sponsored nofollow"
className="fixed bottom-0 left-0 right-0 z-50 flex bg-white shadow-lg outline-0 outline-transparent sm:bottom-[15px] sm:left-auto sm:right-[15px] sm:max-w-[350px]"
onClick={onSponsorClick}
>
<span
className="absolute right-1 top-1 text-gray-400 hover:text-gray-800 sm:right-1.5 sm:top-1.5 sm:text-gray-300"
aria-label="Close"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsHidden(true);
onSponsorHidden();
}}
>
<X className="h-5 w-5 sm:h-4 sm:w-4" />
</span>
<span>
<img
src={imageUrl}
className="block h-[106px] object-cover sm:h-[153px] sm:w-[118.18px]"
alt="Sponsor Banner"
/>
</span>
<span className="flex flex-1 flex-col justify-between text-xs sm:text-sm">
<span className="p-[10px]">
<span className="mb-0.5 block font-semibold">{title}</span>
<span className="block text-gray-500">{description}</span>
</span>
{!isRoadmapAd && (
<>
<span className="sponsor-footer hidden sm:block">
Partner Content
</span>
<span className="block pb-1 text-center text-[10px] uppercase text-gray-400 sm:hidden">
Partner Content
</span>
</>
)}
</span>
</a>
);
}

View File

@@ -1,195 +0,0 @@
import { useEffect, useState } from 'react';
import { httpGet, httpPatch } from '../../lib/http';
import { sponsorHidden } from '../../stores/page';
import { useStore } from '@nanostores/react';
import { setViewSponsorCookie } from '../../lib/jwt';
import { isMobile } from '../../lib/is-mobile';
import Cookies from 'js-cookie';
import { getUrlUtmParams } from '../../lib/browser.ts';
import { StickyTopSponsor } from './StickyTopSponsor.tsx';
import { BottomRightSponsor } from './BottomRightSponsor.tsx';
type PageSponsorType = {
company: string;
description: string;
gaLabel: string;
imageUrl: string;
pageUrl: string;
title: string;
url: string;
id: string;
};
export type StickyTopSponsorType = PageSponsorType & {
buttonText: string;
style?: {
fromColor?: string;
toColor?: string;
textColor?: string;
buttonBackgroundColor?: string;
buttonTextColor?: string;
};
};
export type BottomRightSponsorType = PageSponsorType;
type V1GetSponsorResponse = {
bottomRightAd?: BottomRightSponsorType;
stickyTopAd?: StickyTopSponsorType;
};
type PageSponsorsProps = {
gaPageIdentifier?: string;
};
const CLOSE_SPONSOR_KEY = 'sponsorClosed';
function markSponsorHidden(sponsorId: string) {
Cookies.set(`${CLOSE_SPONSOR_KEY}-${sponsorId}`, '1', {
path: '/',
expires: 1,
sameSite: 'lax',
secure: true,
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
});
}
function isSponsorMarkedHidden(sponsorId: string) {
return Cookies.get(`${CLOSE_SPONSOR_KEY}-${sponsorId}`) === '1';
}
export function PageSponsors(props: PageSponsorsProps) {
const { gaPageIdentifier } = props;
const $isSponsorHidden = useStore(sponsorHidden);
const [stickyTopSponsor, setStickyTopSponsor] =
useState<StickyTopSponsorType | null>();
const [bottomRightSponsor, setBottomRightSponsor] =
useState<BottomRightSponsorType | null>();
useEffect(() => {
const foundUtmParams = getUrlUtmParams();
if (!foundUtmParams.utmSource) {
return;
}
localStorage.setItem('utm_params', JSON.stringify(foundUtmParams));
}, []);
async function loadSponsor() {
const currentPath = window.location.pathname;
if (
currentPath === '/' ||
currentPath === '/best-practices' ||
currentPath === '/roadmaps' ||
currentPath.startsWith('/guides') ||
currentPath.startsWith('/videos') ||
currentPath.startsWith('/account') ||
currentPath.startsWith('/team/')
) {
return;
}
const { response, error } = await httpGet<V1GetSponsorResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-sponsor`,
{
href: window.location.pathname,
mobile: isMobile() ? 'true' : 'false',
},
);
if (error) {
console.error(error);
return;
}
setStickyTopSponsor(response?.stickyTopAd);
setBottomRightSponsor(response?.bottomRightAd);
}
async function logSponsorImpression(
sponsor: BottomRightSponsorType | StickyTopSponsorType,
) {
window.fireEvent({
category: 'SponsorImpression',
action: `${sponsor?.company} Impression`,
label:
sponsor?.gaLabel || `${gaPageIdentifier} / ${sponsor?.company} Link`,
});
}
async function clickSponsor(
sponsor: BottomRightSponsorType | StickyTopSponsorType,
) {
const { id: sponsorId, company, gaLabel } = sponsor;
const labelValue = gaLabel || `${gaPageIdentifier} / ${company} Link`;
window.fireEvent({
category: 'SponsorClick',
action: `${company} Redirect`,
label: labelValue,
value: labelValue,
});
const clickUrl = new URL(
`${import.meta.env.PUBLIC_API_URL}/v1-view-sponsor/${sponsorId}`,
);
const { response, error } = await httpPatch<{ status: 'ok' }>(
clickUrl.toString(),
{
mobile: isMobile(),
},
);
if (error || !response) {
console.error(error);
return;
}
setViewSponsorCookie(sponsorId);
}
useEffect(() => {
window.setTimeout(loadSponsor);
}, []);
if ($isSponsorHidden) {
return null;
}
return (
<div>
{stickyTopSponsor && !isSponsorMarkedHidden(stickyTopSponsor.id) && (
<StickyTopSponsor
sponsor={stickyTopSponsor}
onSponsorImpression={() => {
logSponsorImpression(stickyTopSponsor).catch(console.error);
}}
onSponsorClick={() => {
clickSponsor(stickyTopSponsor).catch(console.error);
}}
onSponsorHidden={() => {
markSponsorHidden(stickyTopSponsor.id);
}}
/>
)}
{bottomRightSponsor && !isSponsorMarkedHidden(bottomRightSponsor.id) && (
<BottomRightSponsor
sponsor={bottomRightSponsor}
onSponsorClick={() => {
clickSponsor(bottomRightSponsor).catch(console.error);
}}
onSponsorHidden={() => {
markSponsorHidden(bottomRightSponsor.id);
}}
onSponsorImpression={() => {
logSponsorImpression(bottomRightSponsor).catch(console.error);
}}
/>
)}
</div>
);
}

View File

@@ -1,91 +0,0 @@
import { cn } from '../../lib/classname.ts';
import { useScrollPosition } from '../../hooks/use-scroll-position.ts';
import { X } from 'lucide-react';
import type { StickyTopSponsorType } from './PageSponsors.tsx';
import { useEffect, useState } from 'react';
import { isOnboardingStripHidden } from '../../stores/page.ts';
type StickyTopSponsorProps = {
sponsor: StickyTopSponsorType;
onSponsorImpression: () => void;
onSponsorClick: () => void;
onSponsorHidden: () => void;
};
const SCROLL_DISTANCE = 100;
export function StickyTopSponsor(props: StickyTopSponsorProps) {
const { sponsor, onSponsorHidden, onSponsorImpression, onSponsorClick } =
props;
const { y: scrollY } = useScrollPosition();
const [isImpressionLogged, setIsImpressionLogged] = useState(false);
const [isHidden, setIsHidden] = useState(false);
useEffect(() => {
if (!sponsor) {
return;
}
// preload the image so that we don't see a flicker
const img = new Image();
img.src = sponsor.imageUrl;
// hide the onboarding strip when the sponsor is visible
isOnboardingStripHidden.set(true);
}, [sponsor]);
useEffect(() => {
if (scrollY < SCROLL_DISTANCE || isImpressionLogged) {
return;
}
setIsImpressionLogged(true);
onSponsorImpression();
}, [scrollY]);
if (scrollY < SCROLL_DISTANCE || isHidden) {
return null;
}
return (
<a
target="_blank"
href={sponsor.url}
onClick={onSponsorClick}
className={cn(
'fixed left-0 right-0 top-0 z-91 flex min-h-[45px] w-full flex-row items-center justify-center px-14 pb-2 pt-1.5 text-base font-medium text-yellow-950',
)}
style={{
backgroundImage: `linear-gradient(to bottom, ${sponsor.style?.fromColor}, ${sponsor.style?.toColor})`,
color: sponsor.style?.textColor,
}}
>
<img className="h-[23px]" src={sponsor.imageUrl} alt={'ad'} />
<span className="mx-3 truncate">{sponsor.description}</span>
<button
className="flex-truncate rounded-md px-3 py-1 text-sm transition-colors"
style={{
backgroundColor: sponsor.style?.buttonBackgroundColor,
color: sponsor.style?.buttonTextColor,
}}
>
{sponsor.buttonText}
</button>
<button
type="button"
className="absolute right-5 top-1/2 ml-1 -translate-y-1/2 px-1 py-1 opacity-70 hover:opacity-100"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsHidden(true);
onSponsorHidden();
}}
>
<X className="h-4 w-4" strokeWidth={3} />
</button>
</a>
);
}

View File

@@ -1,57 +0,0 @@
import { useEffect, useState } from 'react';
import { UpdateEmailForm } from '../UpdateEmail/UpdateEmailForm';
import UpdatePasswordForm from '../UpdatePassword/UpdatePasswordForm';
import { pageProgressMessage } from '../../stores/page';
import { httpGet } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
export function ProfileSettingsPage() {
const toast = useToast();
const [authProvider, setAuthProvider] = useState('');
const [currentEmail, setCurrentEmail] = useState('');
const [newEmail, setNewEmail] = useState('');
const loadProfile = async () => {
const { error, response } = await httpGet(
`${import.meta.env.PUBLIC_API_URL}/v1-me`,
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
return;
}
const { authProvider, email, newEmail } = response;
setAuthProvider(authProvider);
setCurrentEmail(email);
setNewEmail(newEmail || '');
};
useEffect(() => {
loadProfile().finally(() => {
pageProgressMessage.set('');
});
}, []);
return (
<>
<UpdatePasswordForm authProvider={authProvider} />
<hr className="my-8" />
<UpdateEmailForm
authProvider={authProvider}
currentEmail={currentEmail}
newEmail={newEmail}
key={newEmail}
onSendVerificationCode={(newEmail) => {
setNewEmail(newEmail);
loadProfile().finally(() => {});
}}
onVerificationCancel={() => {
loadProfile().finally(() => {});
}}
/>
</>
);
}

View File

@@ -1,24 +0,0 @@
type AcceptIconProps = {
className?: string;
};
export function AcceptIcon(props: AcceptIconProps) {
const { className } = props;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="#000"
className={className}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
);
}

View File

@@ -1,27 +0,0 @@
type CheckIconProps = {
additionalClasses?: string;
};
export function AddUserIcon(props: CheckIconProps) {
const { additionalClasses = 'mr-2 w-[20px] h-[20px]' } = props;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`relative ${additionalClasses}`}
>
<path d="M14 19a6 6 0 0 0-12 0" />
<circle cx="8" cy="9" r="4" />
<line x1="19" x2="19" y1="8" y2="14" />
<line x1="22" x2="16" y1="11" y2="11" />
</svg>
);
}

View File

@@ -1,39 +0,0 @@
import type { SVGProps } from 'react';
import React from 'react';
export function BookEmoji(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 36 36"
{...props}
>
<path
fill="#3e721d"
d="M35 26a4 4 0 0 1-4 4H5a4 4 0 0 1-4-4V6.313C1 4.104 6.791 0 9 0h20.625C32.719 0 35 2.312 35 5.375z"
></path>
<path
fill="#ccd6dd"
d="M33 30a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V6c0-4.119-.021-4 5-4h21a4 4 0 0 1 4 4z"
></path>
<path
fill="#e1e8ed"
d="M31 31a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3h24a3 3 0 0 1 3 3z"
></path>
<path
fill="#5c913b"
d="M31 32a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V10a4 4 0 0 1 4-4h21a4 4 0 0 1 4 4z"
></path>
<path
fill="#77b255"
d="M29 32a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V12a4 4 0 0 1 4-4h19.335C27.544 8 29 9.456 29 11.665z"
></path>
<path
fill="#3e721d"
d="M6 6C4.312 6 4.269 4.078 5 3.25C5.832 2.309 7.125 2 9.438 2H11V0H8.281C4.312 0 1 2.5 1 5.375V32a4 4 0 0 0 4 4h2V6z"
></path>
</svg>
);
}

View File

@@ -1,36 +0,0 @@
import React from 'react';
import type { SVGProps } from 'react';
export function BuildEmoji(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 36 36"
{...props}
>
<path
fill="#66757f"
d="M28.25 8.513a.263.263 0 0 0-.263-.263h-.475a.263.263 0 0 0-.263.263v11.475c0 .145.117.263.263.263h.475a.263.263 0 0 0 .263-.263z"
></path>
<g fill="#f19020">
<circle cx={27.75} cy={19.75} r={1.5}></circle>
<circle cx={27.75} cy={22.25} r={1}></circle>
</g>
<path
fill="#bd2032"
d="M33.25 8.25h-4.129L9.946.29L9.944.289h-.001c-.016-.007-.032-.005-.047-.01C9.849.265 9.802.25 9.75.25h-.002a.5.5 0 0 0-.19.038a.5.5 0 0 0-.122.082c-.012.009-.026.014-.037.025a.5.5 0 0 0-.11.164V.56c-.004.009-.003.02-.006.029l-5.541 7.81l-.006.014a.99.99 0 0 0-.486.837v2a1 1 0 0 0 1 1h1.495L2.031 34H.25v2h18.958v-2h-1.74l-3.713-21.75H33.25a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1m-21.769 4L9.75 13.639L8.02 12.25zM9.75 21.3l3.667 2.404l-3.667 2l-3.667-2zm-3.639.71l.474-2.784l1.866 1.223zm4.938-1.561l1.87-1.225l.477 2.789zm-1.299-.866l-2.828-1.885l2.828-2.322l2.828 2.322zm-2.563-3.887l.362-2.127l1.131.928zm3.633-1.198l1.132-.929l.364 2.13zM5.073 8.25L9.25 2.362V6.25h-2a1 1 0 0 0-1 1v1zm.53 16.738l2.73 1.489l-3.29 1.794zM15.443 34H4.067l.686-4.024L9.75 27.25l5.006 2.731zm-1.54-9.015l.562 3.291l-3.298-1.799zM13.25 8.25v-1a1 1 0 0 0-1-1h-2V1.499L26.513 8.25zm2 3h-1.16v-2h1.16zm3 0h-2v-2h2zm3 0h-2v-2h2zm3 0h-2v-2h2zm3 0h-2v-2h2zm3 0h-2v-2h2zm3-.5a.5.5 0 0 1-.5.5h-1.5v-2h1.5a.5.5 0 0 1 .5.5z"
></path>
<path
fill="#4b545d"
d="M12.25 7.25h-2a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h3v-4z"
></path>
<path fill="#cdd7df" d="M11.25 7.25h2v4h-2z"></path>
<path
fill="#66757f"
d="M34.844 24v-1H20.656v1h.844v2.469h-.844v1h14.188v-1H34V24z"
></path>
</svg>
);
}

View File

@@ -1,37 +0,0 @@
// twitter bulb emoji
import type { SVGProps } from 'react';
type BulbEmojiProps = SVGProps<SVGSVGElement>;
export function BulbEmoji(props: BulbEmojiProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 36 36"
{...props}
>
<path
fill="#FFD983"
d="M29 11.06c0 6.439-5 7.439-5 13.44c0 3.098-3.123 3.359-5.5 3.359c-2.053 0-6.586-.779-6.586-3.361C11.914 18.5 7 17.5 7 11.06C7 5.029 12.285.14 18.083.14C23.883.14 29 5.029 29 11.06"
></path>
<path
fill="#CCD6DD"
d="M22.167 32.5c0 .828-2.234 2.5-4.167 2.5s-4.167-1.672-4.167-2.5S16.066 32 18 32s4.167-.328 4.167.5"
></path>
<path
fill="#FFCC4D"
d="M22.707 10.293a1 1 0 0 0-1.414 0L18 13.586l-3.293-3.293a.999.999 0 1 0-1.414 1.414L17 15.414V26a1 1 0 1 0 2 0V15.414l3.707-3.707a1 1 0 0 0 0-1.414"
></path>
<path
fill="#99AAB5"
d="M24 31a2 2 0 0 1-2 2h-8a2 2 0 0 1-2-2v-6h12z"
></path>
<path
fill="#CCD6DD"
d="M11.999 32a1 1 0 0 1-.163-1.986l12-2a.994.994 0 0 1 1.15.822a1 1 0 0 1-.822 1.15l-12 2a1 1 0 0 1-.165.014m0-4a1 1 0 0 1-.163-1.986l12-2a.995.995 0 0 1 1.15.822a1 1 0 0 1-.822 1.15l-12 2a1 1 0 0 1-.165.014"
></path>
</svg>
);
}

View File

@@ -1,6 +0,0 @@
import React from 'react';
import type { SVGProps } from 'react';
export function CheckEmoji(props: SVGProps<SVGSVGElement>) {
return (<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 36 36" {...props}><path fill="#77b255" d="M36 32a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h28a4 4 0 0 1 4 4z"></path><path fill="#fff" d="M29.28 6.362a2.5 2.5 0 0 0-3.458.736L14.936 23.877l-5.029-4.65a2.5 2.5 0 1 0-3.394 3.671l7.209 6.666c.48.445 1.09.665 1.696.665c.673 0 1.534-.282 2.099-1.139c.332-.506 12.5-19.27 12.5-19.27a2.5 2.5 0 0 0-.737-3.458"></path></svg>);
}

View File

@@ -1,24 +0,0 @@
import type { SVGProps } from 'react';
import React from 'react';
export function ConstructionEmoji(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 36 36"
{...props}
>
<path
fill="#ffcc4d"
d="M36 15a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h28a4 4 0 0 1 4 4z"
></path>
<path
fill="#292f33"
d="M6 3H4a4 4 0 0 0-4 4v2zm6 0L0 15c0 1.36.682 2.558 1.72 3.28L17 3zM7 19h5L28 3h-5zm16 0L35.892 6.108A4 4 0 0 0 33.64 3.36L18 19zm13-4v-3l-7 7h3a4 4 0 0 0 4-4"
></path>
<path fill="#99aab5" d="M4 19h5v14H4zm23 0h5v14h-5z"></path>
</svg>
);
}

View File

@@ -1,23 +0,0 @@
interface MailIconProps {
className?: string;
}
export function MailIcon(props: MailIconProps) {
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"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<rect width="20" height="16" x="2" y="4" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</svg>
);
}

View File

@@ -1,19 +0,0 @@
import type { SVGProps } from 'react';
export function RankBadgeIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
width="11"
height="11"
viewBox="0 0 11 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M0 0L11 0V10.0442L5.73392 6.32786L0 10.0442L0 0Z"
fill="currentColor"
></path>
</svg>
);
}

View File

@@ -1,19 +0,0 @@
type YouTubeIconProps = {
className?: string;
};
export function YouTubeIcon(props: YouTubeIconProps) {
const { className } = props;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="currentColor"
className={className}
>
<path d="M19.615 3.184c-3.604-.246-11.631-.245-15.23 0C.488 3.45.029 5.804 0 12c.029 6.185.484 8.549 4.385 8.816 3.6.245 11.626.246 15.23 0C23.512 20.55 23.971 18.196 24 12c-.029-6.185-.484-8.549-4.385-8.816zM9 16V8l8 3.993L9 16z" />
</svg>
);
}

View File

@@ -1,15 +0,0 @@
export function GitHubReadmeBanner() {
return (
<p className="mt-3 rounded-md border p-2 text-sm w-full bg-yellow-100 border-yellow-400 text-yellow-900">
Add this badge to your{' '}
<a
href="https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-github-profile/customizing-your-profile/managing-your-profile-readme"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline hover:text-blue-800"
>
GitHub profile readme.
</a>
</p>
);
}

View File

@@ -1,197 +0,0 @@
import { useState } from 'react';
import { useCopyText } from '../../hooks/use-copy-text';
import { useAuth } from '../../hooks/use-auth';
import { RoadmapSelect } from './RoadmapSelect';
import { GitHubReadmeBanner } from './GitHubReadmeBanner';
import { downloadImage } from '../../helper/download-image';
import { SelectionButton } from './SelectionButton';
import { StepCounter } from './StepCounter';
import { Editor } from './Editor';
import { CopyIcon } from 'lucide-react';
import { httpPatch } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
type StepLabelProps = {
label: string;
};
function StepLabel(props: StepLabelProps) {
const { label } = props;
return (
<span className="mb-3 flex items-center gap-2 text-sm leading-none text-gray-400">
{label}
</span>
);
}
export function RoadCardPage() {
const user = useAuth();
const toast = useToast();
const { isCopied, copyText } = useCopyText();
const [roadmaps, setRoadmaps] = useState<string[]>([]);
const [version, setVersion] = useState<'tall' | 'wide'>('tall');
const [variant, setVariant] = useState<'dark' | 'light'>('dark');
const markRoadCardDone = async () => {
const { error } = await httpPatch(
`${import.meta.env.PUBLIC_API_URL}/v1-update-onboarding-config`,
{
id: 'roadCard',
status: 'done',
},
);
if (error) {
toast.error(error?.message || 'Something went wrong');
}
};
if (!user) {
return null;
}
const badgeUrl = new URL(
`${import.meta.env.PUBLIC_APP_URL}/card/${version}/${user?.id}`,
);
badgeUrl.searchParams.set('variant', variant);
if (roadmaps.length > 0) {
badgeUrl.searchParams.set('roadmaps', roadmaps.join(','));
}
return (
<>
<div className="mx-0 flex items-start gap-4 border-b px-0 pb-4 pt-2 sm:-mx-10 sm:px-10">
<StepCounter step={1} />
<div>
<StepLabel label="Pick progress to show (Max. 4)" />
<div className="flex flex-wrap">
<RoadmapSelect
selectedRoadmaps={roadmaps}
setSelectedRoadmaps={setRoadmaps}
/>
</div>
</div>
</div>
<div className="mx-0 flex items-start gap-4 border-b px-0 py-4 sm:-mx-10 sm:px-10">
<StepCounter step={2} />
<div>
<StepLabel label="Select Mode (Dark vs Light)" />
<div className="flex gap-2">
<SelectionButton
text={'Dark'}
isDisabled={false}
isSelected={variant === 'dark'}
onClick={() => {
setVariant('dark');
}}
/>
<SelectionButton
text={'Light'}
isDisabled={false}
isSelected={variant === 'light'}
onClick={() => {
setVariant('light');
}}
/>
</div>
</div>
</div>
<div className="mx-0 flex items-start gap-4 border-b px-0 py-4 sm:-mx-10 sm:px-10">
<StepCounter step={3} />
<div>
<StepLabel label="Select Version" />
<div className="flex gap-2">
<SelectionButton
text={'Tall'}
isDisabled={false}
isSelected={version === 'tall'}
onClick={() => {
setVersion('tall');
}}
/>
<SelectionButton
text={'Wide'}
isDisabled={false}
isSelected={version === 'wide'}
onClick={() => {
setVersion('wide');
}}
/>
</div>
</div>
</div>
<div className="mx-0 flex items-start gap-4 border-b px-0 py-4 sm:-mx-10 sm:px-10">
<StepCounter step={4} />
<div className="grow">
<StepLabel label="Share your #RoadCard with others" />
<div className={'rounded-md border bg-gray-50 p-2 text-center'}>
<a
href={badgeUrl.toString()}
target="_blank"
rel="noopener noreferrer"
className={`relative block hover:cursor-pointer ${
version === 'tall' ? ' max-w-[270px] ' : ' w-full '
}`}
>
<img src={badgeUrl.toString()} alt="RoadCard" />
</a>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<button
className="flex items-center justify-center rounded-sm border border-gray-300 p-1.5 px-2 text-sm font-medium"
onClick={() => {
downloadImage({
url: badgeUrl.toString(),
name: 'road-card',
scale: 4,
});
markRoadCardDone();
}}
>
Download
</button>
<button
disabled={isCopied}
className="flex cursor-pointer items-center justify-center rounded-sm border border-gray-300 p-1.5 px-2 text-sm font-medium disabled:bg-blue-50"
onClick={() => {
copyText(badgeUrl.toString());
markRoadCardDone();
}}
>
<CopyIcon size={16} className="mr-1 inline-block h-4 w-4" />
{isCopied ? 'Copied!' : 'Copy Link'}
</button>
</div>
<div className="mt-3 flex flex-col gap-3">
<Editor
title={'HTML'}
text={`<a href="https://roadmap.sh"><img src="${badgeUrl}" alt="roadmap.sh"/></a>`.trim()}
onCopy={() => markRoadCardDone()}
/>
<Editor
title={'Markdown'}
text={`[![roadmap.sh](${badgeUrl})](https://roadmap.sh)`.trim()}
onCopy={() => markRoadCardDone()}
/>
</div>
<GitHubReadmeBanner />
</div>
</div>
</>
);
}

View File

@@ -1,78 +0,0 @@
import { httpGet } from '../../lib/http';
import { useEffect, useState } from 'react';
import { pageProgressMessage } from '../../stores/page';
import { SelectionButton } from './SelectionButton';
import type { UserProgressResponse } from '../Roadmaps/RoadmapsPage';
type RoadmapSelectProps = {
selectedRoadmaps: string[];
setSelectedRoadmaps: (updatedRoadmaps: string[]) => void;
};
export function RoadmapSelect(props: RoadmapSelectProps) {
const { selectedRoadmaps, setSelectedRoadmaps } = props;
const [progressList, setProgressList] = useState<UserProgressResponse>();
const fetchProgress = async () => {
const { response, error } = await httpGet<UserProgressResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-all-progress`,
);
if (error || !response) {
return;
}
setProgressList(response);
};
useEffect(() => {
fetchProgress().finally(() => {
pageProgressMessage.set('');
});
}, []);
const canSelectMore = selectedRoadmaps.length < 4;
const allProgress =
progressList?.filter(
(progress) =>
progress.resourceType === 'roadmap' &&
progress.resourceId &&
progress.resourceTitle,
) || [];
return (
<div className="flex flex-wrap gap-1">
{allProgress?.length === 0 && (
<p className="text-sm italic text-gray-400">
No progress tracked so far.
</p>
)}
{allProgress?.map((progress) => {
const isSelected = selectedRoadmaps.includes(progress.resourceId);
const canSelect = isSelected || canSelectMore;
return (
<SelectionButton
key={progress.resourceId}
text={progress.resourceTitle}
isDisabled={!canSelect}
isSelected={isSelected}
onClick={() => {
if (isSelected) {
setSelectedRoadmaps(
selectedRoadmaps.filter(
(roadmap) => roadmap !== progress.resourceId,
),
);
} else if (selectedRoadmaps.length < 4) {
setSelectedRoadmaps([...selectedRoadmaps, progress.resourceId]);
}
}}
/>
);
})}
</div>
);
}

View File

@@ -1,40 +0,0 @@
import type { ButtonHTMLAttributes } from 'react';
import { cn } from '../../lib/classname';
import type { LucideIcon } from 'lucide-react';
type SelectionButtonProps = {
icon?: LucideIcon;
text: string;
isDisabled: boolean;
isSelected: boolean;
onClick: () => void;
} & ButtonHTMLAttributes<HTMLButtonElement>;
export function SelectionButton(props: SelectionButtonProps) {
const {
icon: Icon,
text,
isDisabled,
isSelected,
onClick,
className,
...rest
} = props;
return (
<button
{...rest}
className={cn(
'rounded-md flex items-center border p-1 px-2 text-sm',
isSelected ? 'border-gray-500 bg-gray-300' : '',
!isDisabled ? 'cursor-pointer' : 'cursor-not-allowed opacity-40',
className,
)}
disabled={isDisabled}
onClick={onClick}
>
{Icon && <Icon size={13} className="mr-1.5" />}
{text}
</button>
);
}

View File

@@ -1,17 +0,0 @@
type StepCounterProps = {
step: number;
};
export function StepCounter(props: StepCounterProps) {
const { step } = props;
return (
<span
className={
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-300 text-white'
}
>
{step}
</span>
);
}

View File

@@ -1,215 +0,0 @@
import { useEffect, useState } from 'react';
import { httpGet } from '../../lib/http';
import { pageProgressMessage } from '../../stores/page';
import { getUrlParams } from '../../lib/browser';
import { useToast } from '../../hooks/use-toast';
import type { TeamMemberDocument } from '../TeamMembers/TeamMembersPage';
import type { UserProgress } from '../TeamProgress/TeamProgressPage';
import type { TeamActivityStreamDocument } from '../TeamActivity/TeamActivityPage';
import { ResourceProgress } from '../Activity/ResourceProgress';
import { ActivityStream } from '../Activity/ActivityStream';
import { MemberRoleBadge } from '../TeamMembers/RoleBadge';
import { TeamMemberEmptyPage } from './TeamMemberEmptyPage';
import { Pagination } from '../Pagination/Pagination';
import type { ResourceType } from '../../lib/resource-progress';
import { MemberProgressModal } from '../TeamProgress/MemberProgressModal';
import { useStore } from '@nanostores/react';
import { $currentTeam } from '../../stores/team';
import { MemberCustomProgressModal } from '../TeamProgress/MemberCustomProgressModal';
type GetTeamMemberProgressesResponse = TeamMemberDocument & {
name: string;
avatar: string;
email: string;
progresses: UserProgress[];
};
type GetTeamMemberActivityResponse = {
data: TeamActivityStreamDocument[];
totalCount: number;
totalPages: number;
currPage: number;
perPage: number;
};
export function TeamMemberDetailsPage() {
const { t: teamId, m: memberId } = getUrlParams() as { t: string; m: string };
const toast = useToast();
const currentTeam = useStore($currentTeam);
const [memberProgress, setMemberProgress] =
useState<GetTeamMemberProgressesResponse | null>(null);
const [memberActivity, setMemberActivity] =
useState<GetTeamMemberActivityResponse | null>(null);
const [currPage, setCurrPage] = useState(1);
const [selectedResource, setSelectedResource] = useState<{
resourceId: string;
resourceType: ResourceType;
isCustomResource?: boolean;
} | null>(null);
const loadMemberProgress = async () => {
const { response, error } = await httpGet<GetTeamMemberProgressesResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-member-progresses/${teamId}/${memberId}`,
);
if (error || !response) {
pageProgressMessage.set('');
toast.error(error?.message || 'Failed to load team member');
return;
}
setMemberProgress(response);
};
const loadMemberActivity = async (currPage: number = 1) => {
const { response, error } = await httpGet<GetTeamMemberActivityResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-member-activity/${teamId}/${memberId}`,
{
currPage,
},
);
if (error || !response) {
pageProgressMessage.set('');
toast.error(error?.message || 'Failed to load team member activity');
return;
}
setMemberActivity(response);
setCurrPage(response?.currPage || 1);
};
useEffect(() => {
if (!teamId) {
return;
}
Promise.allSettled([loadMemberProgress(), loadMemberActivity()]).finally(
() => {
pageProgressMessage.set('');
},
);
}, [teamId]);
if (!teamId || !memberId || !memberProgress || !memberActivity) {
return null;
}
const avatarUrl = memberProgress?.avatar
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${memberProgress?.avatar}`
: '/img/default-avatar.png';
const ProgressModal =
selectedResource && !selectedResource.isCustomResource
? MemberProgressModal
: MemberCustomProgressModal;
return (
<>
{selectedResource && (
<ProgressModal
teamId={teamId}
member={{
...memberProgress,
_id: memberId,
updatedAt: new Date(memberProgress.updatedAt).toISOString(),
progress: memberProgress.progresses,
}}
resourceId={selectedResource.resourceId}
resourceType={selectedResource.resourceType}
isCustomResource={selectedResource.isCustomResource}
onClose={() => setSelectedResource(null)}
onShowMyProgress={() => {
window.location.href = `/team/member?t=${teamId}&m=${currentTeam?.memberId}`;
}}
/>
)}
<div className="mb-8 flex items-center gap-3">
<img
src={avatarUrl}
alt={memberProgress?.name}
className="h-14 w-14 rounded-full"
/>
<div>
<h1 className="mt-1 text-2xl font-medium">{memberProgress?.name}</h1>
<p className="text-sm text-gray-500">{memberProgress?.email}</p>
</div>
</div>
{memberProgress?.progresses && memberProgress?.progresses?.length > 0 ? (
<>
<h2 className="mb-3 text-xs uppercase text-gray-400">
Progress Overview
</h2>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{memberProgress?.progresses?.map((progress) => {
const learningCount = progress.learning || 0;
const doneCount = progress.done || 0;
const totalCount = progress.total || 0;
const skippedCount = progress.skipped || 0;
return (
<ResourceProgress
key={progress.resourceId}
isCustomResource={progress.isCustomResource!}
doneCount={doneCount > totalCount ? totalCount : doneCount}
learningCount={
learningCount > totalCount ? totalCount : learningCount
}
totalCount={totalCount}
skippedCount={skippedCount}
resourceId={progress.resourceId}
resourceType={'roadmap'}
updatedAt={progress.updatedAt}
title={progress.resourceTitle}
roadmapSlug={progress.roadmapSlug}
showActions={false}
onResourceClick={() => {
setSelectedResource({
resourceId: progress.resourceId,
resourceType: progress.resourceType,
isCustomResource: progress.isCustomResource,
});
}}
/>
);
})}
</div>
</>
) : (
<TeamMemberEmptyPage teamId={teamId} />
)}
{memberActivity?.data && memberActivity?.data?.length > 0 ? (
<>
<ActivityStream
className="mt-8 p-0 md:m-0 md:mb-4 md:mt-8 md:p-0"
activities={
memberActivity?.data?.flatMap((act) => act.activity) || []
}
onResourceClick={(resourceId, resourceType, isCustomResource) => {
setSelectedResource({
resourceId,
resourceType,
isCustomResource,
});
}}
/>
<Pagination
currPage={currPage}
totalPages={memberActivity?.totalPages || 1}
totalCount={memberActivity?.totalCount || 0}
perPage={memberActivity?.perPage || 10}
onPageChange={(page) => {
pageProgressMessage.set('Loading Activity');
loadMemberActivity(page).finally(() => {
pageProgressMessage.set('');
});
}}
/>
</>
) : null}
</>
);
}

View File

@@ -1,22 +0,0 @@
import { RoadmapIcon } from '../ReactIcons/RoadmapIcon';
type TeamMemberEmptyPageProps = {
teamId: string;
};
export function TeamMemberEmptyPage(props: TeamMemberEmptyPageProps) {
const { teamId } = props;
return (
<div className="rounded-md">
<div className="flex flex-col items-center p-7 text-center">
<RoadmapIcon className="mb-2 h-14 w-14 opacity-10" />
<h2 className="text-lg font-bold sm:text-xl">No Progress</h2>
<p className="my-1 max-w-[400px] text-balance text-sm text-gray-500 sm:my-2 sm:text-base">
Progress will appear here as they start tracking their roadmaps.
</p>
</div>
</div>
);
}

View File

@@ -1,30 +0,0 @@
import { useState } from 'react';
import { LeaveTeamPopup } from './LeaveTeamPopup';
type LeaveTeamButtonProps = {
teamId: string;
};
export function LeaveTeamButton(props: LeaveTeamButtonProps) {
const [showLeaveTeamPopup, setShowLeaveTeamPopup] = useState(false);
return (
<>
{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>
</>
);
}

View File

@@ -1,124 +0,0 @@
import { type FormEvent, useEffect, useRef, useState } from 'react';
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: FormEvent<HTMLFormElement>) => {
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?c=tl';
};
const handleClosePopup = () => {
setIsLoading(false);
setError('');
setConfirmationText('');
onClose();
};
useOutsideClick(popupBodyRef, handleClosePopup);
return (
<div className="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 className="relative h-full w-full max-w-md p-4 md:h-auto">
<div
ref={popupBodyRef}
className="popup-body relative rounded-lg bg-white p-4 shadow-sm"
>
<h2 className="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-hidden 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="grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center"
>
Cancel
</button>
<button
type="submit"
disabled={
isLoading || confirmationText.toUpperCase() !== 'LEAVE'
}
className="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>
);
}

View File

@@ -1,109 +0,0 @@
import { useRef, useState } from 'react';
import type { TeamMemberDocument } from './TeamMembersPage';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { MoreVerticalIcon } from '../ReactIcons/MoreVerticalIcon.tsx';
export function MemberActionDropdown({
member,
onUpdateMember,
onDeleteMember,
onResendInvite,
isDisabled = false,
onSendProgressReminder,
allowProgressReminder = false,
allowUpdateRole = true,
}: {
onDeleteMember: () => void;
onUpdateMember: () => void;
onResendInvite: () => void;
onSendProgressReminder: () => void;
isDisabled: boolean;
allowProgressReminder: boolean;
allowUpdateRole: boolean;
member: TeamMemberDocument;
}) {
const menuRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useOutsideClick(menuRef, () => {
setIsOpen(false);
});
const actions = [
...(allowUpdateRole
? [
{
name: 'Update Role',
handleClick: () => {
onUpdateMember();
setIsOpen(false);
},
},
]
: []),
...(allowProgressReminder
? [
{
name: 'Send Progress Reminder',
handleClick: () => {
onSendProgressReminder();
setIsOpen(false);
},
},
]
: []),
...(['invited'].includes(member.status)
? [
{
name: 'Resend Invite',
handleClick: () => {
onResendInvite();
setIsOpen(false);
},
},
]
: []),
{
name: 'Delete',
handleClick: () => {
onDeleteMember();
setIsOpen(false);
},
},
];
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"
>
<MoreVerticalIcon className="h-4 w-4" />
</button>
{isOpen && (
<div
ref={menuRef}
className="align-right absolute right-0 top-full z-50 mt-1 w-[200px] 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-sm p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
{action.name}
</button>
</li>
);
})}
</ul>
</div>
)}
</div>
);
}

View File

@@ -1,25 +0,0 @@
import { cn } from '../../lib/classname';
import type { AllowedRoles } from '../CreateTeam/RoleDropdown';
type RoleBadgeProps = {
role: AllowedRoles;
className?: string;
};
export function MemberRoleBadge(props: RoleBadgeProps) {
const { role, className } = props;
return (
<span
className={cn(
`items-center rounded-full px-2 py-0.5 text-xs capitalize sm:flex ${
['admin'].includes(role)
? 'bg-blue-100 text-blue-700 '
: 'bg-gray-100 text-gray-700 '
} ${['manager'].includes(role) ? 'bg-green-100 text-green-700' : ''}`,
className,
)}
>
{role}
</span>
);
}

View File

@@ -1,137 +0,0 @@
import { MailIcon } from '../ReactIcons/MailIcon';
import { MemberActionDropdown } from './MemberActionDropdown';
import { MemberRoleBadge } from './RoleBadge';
import type { TeamMemberItem } from './TeamMembersPage';
import { $canManageCurrentTeam, $currentTeam } from '../../stores/team';
import { useStore } from '@nanostores/react';
import { useAuth } from '../../hooks/use-auth';
import { cn } from '../../lib/classname';
type TeamMemberProps = {
member: TeamMemberItem;
userId: string;
index: number;
teamId: string;
canViewProgress: boolean;
canManageCurrentTeam: boolean;
onDeleteMember: () => void;
onUpdateMember: () => void;
onSendProgressReminder: () => void;
onResendInvite: () => void;
};
export function TeamMemberItem(props: TeamMemberProps) {
const {
member,
index,
onResendInvite,
onUpdateMember,
canManageCurrentTeam,
userId,
onDeleteMember,
onSendProgressReminder,
canViewProgress = true,
} = props;
const currentTeam = useStore($currentTeam);
const canManageTeam = useStore($canManageCurrentTeam);
const showNoProgressBadge = canViewProgress && !member.hasProgress && member.status === 'joined';
const allowProgressReminder =
canManageTeam &&
!member.hasProgress &&
member.status === 'joined' &&
member.userId !== userId;
const isPersonalProgressOnly =
currentTeam?.personalProgressOnly &&
currentTeam.role === 'member' &&
String(member._id) !== currentTeam.memberId;
return (
<div
className={`flex items-center justify-between gap-2 p-3 ${
index === 0 ? '' : 'border-t'
}`}
>
<div className="flex items-center gap-3">
<img
src={
member.avatar
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${member.avatar}`
: '/img/default-avatar.png'
}
alt={member.name || ''}
className="hidden h-10 w-10 rounded-full sm:block"
/>
<div>
<div className="mb-1 flex items-center gap-2 sm:hidden">
<MemberRoleBadge role={member.role} />
</div>
<div className="flex items-center">
<h3 className="inline-grid grid-cols-[auto_auto_auto] items-center font-medium">
<a
href={`/team/member?t=${member.teamId}&m=${member._id}`}
className={cn(
'truncate',
isPersonalProgressOnly
? 'pointer-events-none cursor-default no-underline'
: '',
)}
onClick={(e) => {
if (isPersonalProgressOnly) {
e.preventDefault();
}
}}
aria-disabled={isPersonalProgressOnly}
>
{member.name}
</a>
{showNoProgressBadge && (
<span className="ml-2 rounded-full bg-red-400 px-2 py-0.5 text-xs font-normal text-white">
No Progress
</span>
)}
{member.userId === userId && (
<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="truncate text-sm text-gray-500">
{member.invitedEmail}
</p>
</div>
</div>
<div className="flex shrink-0 items-center text-sm">
<span className={'hidden sm:block'}>
<MemberRoleBadge role={member.role} />
</span>
{canManageCurrentTeam && (
<MemberActionDropdown
allowUpdateRole={member.status !== 'rejected'}
allowProgressReminder={allowProgressReminder}
onResendInvite={onResendInvite}
onSendProgressReminder={onSendProgressReminder}
onDeleteMember={onDeleteMember}
isDisabled={member.userId === userId}
onUpdateMember={onUpdateMember}
member={member}
/>
)}
</div>
</div>
);
}

View File

@@ -1,338 +0,0 @@
import { useEffect, useState } from 'react';
import { httpDelete, httpGet, httpPatch } from '../../lib/http';
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/react';
import { $canManageCurrentTeam } from '../../stores/team';
import { useToast } from '../../hooks/use-toast';
import { TeamMemberItem } from './TeamMemberItem';
export interface TeamMemberDocument {
_id?: string;
userId?: string;
invitedEmail?: string;
teamId: string;
role: AllowedRoles;
status: AllowedMemberStatus;
createdAt: Date;
updatedAt: Date;
}
export interface UserResourceProgressDocument {
_id?: string;
userId: string;
resourceId: string;
resourceType: 'roadmap' | 'best-practice';
isFavorite?: boolean;
done: string[];
learning: string[];
skipped: string[];
createdAt: Date;
updatedAt: Date;
}
export interface TeamMemberItem extends TeamMemberDocument {
name: string;
avatar: string;
hasProgress: boolean;
}
const MAX_MEMBER_COUNT = 200;
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();
}
async function resendInvite(teamId: string, memberId: string) {
pageProgressMessage.set('Resending Invite');
const { response, error } = await httpPatch(
`${
import.meta.env.PUBLIC_API_URL
}/v1-resend-invite/${teamId}/${memberId}`,
{},
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
return;
}
toast.success('Invite has been sent');
}
async function handleSendReminder(teamId: string, memberId: string) {
pageProgressMessage.set('Sending Reminder');
const { response, error } = await httpPatch(
`${
import.meta.env.PUBLIC_API_URL
}/v1-send-progress-reminder/${teamId}/${memberId}`,
{},
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
return;
}
toast.success('Reminder has been sent');
}
const joinedMembers = teamMembers.filter(
(member) => member.status === 'joined',
);
const invitedMembers = teamMembers.filter(
(member) => member.status === 'invited',
);
const rejectedMembers = teamMembers.filter(
(member) => member.status === 'rejected',
);
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-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>
{joinedMembers.map((member, index) => {
return (
<TeamMemberItem
key={index}
member={member}
index={index}
teamId={teamId}
userId={user?.id!}
canViewProgress={
canManageCurrentTeam ||
!team?.personalProgressOnly ||
String(member.userId) === user?.id
}
onResendInvite={() => {
resendInvite(teamId, member._id!).finally(() => {
pageProgressMessage.set('');
});
}}
canManageCurrentTeam={canManageCurrentTeam}
onDeleteMember={() => {
deleteMember(teamId, member._id!).finally(() => {
pageProgressMessage.set('');
});
}}
onUpdateMember={() => {
setMemberToUpdate(member);
}}
onSendProgressReminder={() => {
handleSendReminder(teamId, member._id!).finally(() => {
pageProgressMessage.set('');
});
}}
/>
);
})}
</div>
{invitedMembers.length > 0 && (
<div className="mt-6">
<h3 className="text-xs uppercase text-gray-400">Invited Members</h3>
<div className="mt-2 rounded-md border">
{invitedMembers.map((member, index) => {
return (
<TeamMemberItem
key={index}
member={member}
index={index}
teamId={teamId}
userId={user?.id!}
canViewProgress={false}
onResendInvite={() => {
resendInvite(teamId, member._id!).finally(() => {
pageProgressMessage.set('');
});
}}
canManageCurrentTeam={canManageCurrentTeam}
onDeleteMember={() => {
deleteMember(teamId, member._id!).finally(() => {
pageProgressMessage.set('');
});
}}
onUpdateMember={() => {
setMemberToUpdate(member);
}}
onSendProgressReminder={() => {
handleSendReminder(teamId, member._id!).finally(() => {
pageProgressMessage.set('');
});
}}
/>
);
})}
</div>
</div>
)}
{rejectedMembers.length > 0 && (
<div className="mt-6">
<h3 className="text-xs uppercase text-gray-400">
Rejected Invites
</h3>
<div className="mt-2 rounded-b-sm rounded-t-md border">
{rejectedMembers.map((member, index) => {
return (
<TeamMemberItem
key={index}
member={member}
index={index}
teamId={teamId}
canViewProgress={false}
userId={user?.id!}
onResendInvite={() => {
resendInvite(teamId, member._id!).finally(() => {
pageProgressMessage.set('');
});
}}
canManageCurrentTeam={canManageCurrentTeam}
onDeleteMember={() => {
deleteMember(teamId, member._id!).finally(() => {
pageProgressMessage.set('');
});
}}
onUpdateMember={() => {
setMemberToUpdate(member);
}}
onSendProgressReminder={() => {
handleSendReminder(teamId, member._id!).finally(() => {
pageProgressMessage.set('');
});
}}
/>
);
})}
</div>
</div>
)}
</div>
{canManageCurrentTeam && (
<div className="mt-4">
<button
disabled={teamMembers.length >= MAX_MEMBER_COUNT}
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 >= MAX_MEMBER_COUNT && 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>
);
}

View File

@@ -1,111 +0,0 @@
import { type FormEvent, useRef, useState } from 'react';
import { httpPut } from '../../lib/http';
import { useTeamId } from '../../hooks/use-team-id';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { type 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: FormEvent<HTMLFormElement>) => {
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 className="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 className="relative h-full w-full max-w-md p-4 md:h-auto">
<div
ref={popupBodyRef}
className="popup-body relative rounded-lg bg-white p-4 shadow-sm"
>
<h3 className="mb-1.5 text-xl font-medium sm:text-2xl">
Update Role
</h3>
<p className="mb-3 hidden text-sm leading-none text-gray-400 sm:block">
Select the role to update for this member
</p>
<form onSubmit={handleSubmit}>
<div className="my-4 mt-0 flex flex-col gap-2 sm:mt-4">
<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="grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading || !selectedRole}
className="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>
);
}

View File

@@ -1,3 +0,0 @@
export function CustomTeamRoadmap() {
return null;
}

View File

@@ -1,3 +0,0 @@
export function DefaultTeamRoadmap() {
return null;
}

View File

@@ -1,93 +0,0 @@
import { useRef, useState } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { Lock, MoreVertical, Shapes, Trash2 } from 'lucide-react';
import { MoreVerticalIcon } from '../ReactIcons/MoreVerticalIcon.tsx';
type RoadmapActionDropdownProps = {
onDelete?: () => void;
onCustomize?: () => void;
onUpdateSharing?: () => void;
};
export function RoadmapActionDropdown(props: RoadmapActionDropdownProps) {
const { onDelete, onUpdateSharing, onCustomize } = props;
const menuRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
useOutsideClick(menuRef, () => {
setIsOpen(false);
});
return (
<div className="relative">
<button
disabled={false}
onClick={() => setIsOpen(!isOpen)}
className="hidden items-center opacity-60 transition-opacity hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-30 sm:flex"
>
<MoreVerticalIcon className={'h-4 w-4'} />
</button>
<button
disabled={false}
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-1 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-hidden sm:hidden"
>
<MoreVertical size={14} />
Options
</button>
{isOpen && (
<div
ref={menuRef}
className="align-right absolute right-auto top-full z-50 mt-1 w-[140px] rounded-md bg-slate-800 px-2 py-2 text-white shadow-md sm:right-0"
>
<ul>
{onUpdateSharing && (
<li>
<button
onClick={() => {
setIsOpen(false);
onUpdateSharing();
}}
className="flex w-full cursor-pointer items-center rounded-sm p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<Lock size={14} className="mr-2" />
Sharing
</button>
</li>
)}
{onCustomize && (
<li>
<button
onClick={() => {
setIsOpen(false);
onCustomize();
}}
className="flex w-full cursor-pointer items-center rounded-sm p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<Shapes size={14} className="mr-2" />
Customize
</button>
</li>
)}
{onDelete && (
<li>
<button
onClick={() => {
setIsOpen(false);
onDelete();
}}
className="flex w-full cursor-pointer items-center rounded-sm p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<Trash2 size={14} className="mr-2" />
Delete
</button>
</li>
)}
</ul>
</div>
)}
</div>
);
}

View File

@@ -1,690 +0,0 @@
import { getUrlParams } from '../../lib/browser';
import { useEffect, useState } from 'react';
import type { TeamDocument } from '../CreateTeam/CreateTeamForm';
import type { TeamResourceConfig } from '../CreateTeam/RoadmapSelector';
import { httpGet, httpPut } from '../../lib/http';
import { pageProgressMessage } from '../../stores/page';
import type { PageType } from '../CommandMenu/CommandMenu';
import { useStore } from '@nanostores/react';
import { $canManageCurrentTeam } from '../../stores/team';
import { useToast } from '../../hooks/use-toast';
import { SelectRoadmapModal } from '../CreateTeam/SelectRoadmapModal';
import { PickRoadmapOptionModal } from '../TeamRoadmaps/PickRoadmapOptionModal';
import type { AllowedRoadmapVisibility } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
import {
ExternalLink,
Globe,
LockIcon,
type LucideIcon,
Package,
PackageMinus,
PenSquare,
Shapes,
Users,
} from 'lucide-react';
import { RoadmapActionDropdown } from './RoadmapActionDropdown';
import { UpdateTeamResourceModal } from '../CreateTeam/UpdateTeamResourceModal';
import { ShareOptionsModal } from '../ShareOptions/ShareOptionsModal';
import { cn } from '../../lib/classname';
import { RoadmapIcon } from '../ReactIcons/RoadmapIcon.tsx';
import { ContentConfirmationModal } from '../CreateTeam/ContentConfirmationModal.tsx';
export function TeamRoadmaps() {
const { t: teamId } = getUrlParams();
const canManageCurrentTeam = useStore($canManageCurrentTeam);
const toast = useToast();
const [isLoading, setIsLoading] = useState(true);
const [isPickingOptions, setIsPickingOptions] = useState(false);
const [isAddingRoadmap, setIsAddingRoadmap] = useState(false);
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
const [changingRoadmapId, setChangingRoadmapId] = useState<string>('');
const [team, setTeam] = useState<TeamDocument>();
const [teamResources, setTeamResources] = useState<TeamResourceConfig>([]);
const [allRoadmaps, setAllRoadmaps] = useState<PageType[]>([]);
const [selectedResource, setSelectedResource] = useState<
TeamResourceConfig[0] | null
>(null);
const [confirmationContentId, setConfirmationContentId] = useState('');
async function loadAllRoadmaps() {
const { error, response } = await httpGet<PageType[]>(`/pages.json`);
if (error) {
toast.error(error.message || 'Something went wrong');
return;
}
if (!response) {
return [];
}
const allRoadmaps = response
.filter((page) => page.group === 'Roadmaps')
.sort((a, b) => {
if (a.title === 'Android') return 1;
return a.title.localeCompare(b.title);
});
setAllRoadmaps(allRoadmaps);
return response;
}
async function loadTeam(teamIdToFetch: string) {
const { response, error } = await httpGet<TeamDocument>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-team/${teamIdToFetch}`,
);
if (error || !response) {
toast.error('Error loading team');
window.location.href = '/account';
return;
}
setTeam(response);
}
async function loadTeamResourceConfig(teamId: string) {
const { error, response } = await httpGet<TeamResourceConfig>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-resource-config/${teamId}`,
);
if (error || !Array.isArray(response)) {
console.error(error);
return;
}
setTeamResources(response);
}
useEffect(() => {
if (!teamId) {
return;
}
setIsLoading(true);
Promise.all([
loadTeam(teamId),
loadTeamResourceConfig(teamId),
loadAllRoadmaps(),
]).finally(() => {
pageProgressMessage.set('');
setIsLoading(false);
});
}, [teamId]);
async function deleteResource(roadmapId: string) {
if (!team?._id) {
return;
}
toast.loading('Deleting roadmap');
pageProgressMessage.set(`Deleting roadmap from team`);
const { error, response } = await httpPut<TeamResourceConfig>(
`${import.meta.env.PUBLIC_API_URL}/v1-delete-team-resource-config/${
team._id
}`,
{
resourceId: roadmapId,
resourceType: 'roadmap',
},
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
return;
}
toast.success('Roadmap removed');
setTeamResources(response);
}
async function onAdd(roadmapId: string, shouldCopyContent = false) {
if (!teamId) {
return;
}
toast.loading('Adding roadmap');
pageProgressMessage.set('Adding roadmap');
setIsLoading(true);
const roadmap = allRoadmaps.find((r) => r.id === roadmapId);
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: [],
renderer: roadmap?.renderer || 'balsamiq',
shouldCopyContent,
},
);
if (error || !response) {
toast.error(error?.message || 'Error adding roadmap');
return;
}
setTeamResources(response);
toast.success('Roadmap added');
if (roadmap?.renderer === 'editor') {
setIsAddingRoadmap(false);
}
}
async function onRemove(resourceId: string) {
pageProgressMessage.set('Removing roadmap');
deleteResource(resourceId).finally(() => {
pageProgressMessage.set('');
});
}
useEffect(() => {
function handleCustomRoadmapCreated(event: Event) {
const { roadmapId } = (event as CustomEvent)?.detail;
if (!roadmapId) {
return;
}
loadAllRoadmaps().finally(() => {});
onAdd(roadmapId).finally(() => {
pageProgressMessage.set('');
});
}
window.addEventListener(
'custom-roadmap-created',
handleCustomRoadmapCreated,
);
return () => {
window.removeEventListener(
'custom-roadmap-created',
handleCustomRoadmapCreated,
);
};
}, []);
if (!team) {
return null;
}
const pickRoadmapOptionModal = isPickingOptions && (
<PickRoadmapOptionModal
onClose={() => setIsPickingOptions(false)}
showDefaultRoadmapsModal={() => {
setIsAddingRoadmap(true);
setIsPickingOptions(false);
}}
showCreateCustomRoadmapModal={() => {
setIsCreatingRoadmap(true);
setIsPickingOptions(false);
}}
/>
);
const filteredAllRoadmaps = allRoadmaps.filter(
(r) => !teamResources.find((c) => c?.defaultRoadmapId === r.id),
);
const addRoadmapModal = isAddingRoadmap && (
<SelectRoadmapModal
onClose={() => setIsAddingRoadmap(false)}
teamResourceConfig={teamResources.map((c) => c.resourceId)}
allRoadmaps={filteredAllRoadmaps.filter((r) => r.renderer === 'editor')}
teamId={teamId}
onRoadmapAdd={(roadmapId: string) => {
const isEditorRoadmap = allRoadmaps.find(
(r) => r.id === roadmapId && r.renderer === 'editor',
);
if (!isEditorRoadmap) {
onAdd(roadmapId).finally(() => {
pageProgressMessage.set('');
});
return;
}
setIsAddingRoadmap(false);
setConfirmationContentId(roadmapId);
}}
onRoadmapRemove={(roadmapId: string) => {
if (confirm('Are you sure you want to remove this roadmap?')) {
onRemove(roadmapId).finally(() => {});
}
}}
/>
);
const confirmationContentIdModal = confirmationContentId && (
<ContentConfirmationModal
onClose={() => {
setConfirmationContentId('');
}}
onClick={(shouldCopy) => {
onAdd(confirmationContentId, shouldCopy).finally(() => {
pageProgressMessage.set('');
setConfirmationContentId('');
});
}}
/>
);
const createRoadmapModal = isCreatingRoadmap && (
<CreateRoadmapModal
teamId={teamId}
onClose={() => {
setIsCreatingRoadmap(false);
}}
onCreated={() => {
loadTeamResourceConfig(teamId).finally(() => null);
setIsCreatingRoadmap(false);
}}
/>
);
const placeholderRoadmaps = teamResources.filter(
(c: TeamResourceConfig[0]) => c.isCustomResource && !c.topics,
);
const customRoadmaps = teamResources.filter(
(c: TeamResourceConfig[0]) => c.isCustomResource && c.topics,
);
const defaultRoadmaps = teamResources.filter(
(c: TeamResourceConfig[0]) => !c.isCustomResource,
);
const hasRoadmaps =
customRoadmaps.length > 0 ||
defaultRoadmaps.length > 0 ||
(placeholderRoadmaps.length > 0 && canManageCurrentTeam);
if (!hasRoadmaps && !isLoading) {
return (
<div className="flex flex-col items-center p-4 py-20">
{pickRoadmapOptionModal}
{addRoadmapModal}
{createRoadmapModal}
{confirmationContentIdModal}
<RoadmapIcon className="mb-3 h-14 w-14 opacity-10" />
<h3 className="mb-1 text-xl 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-3 rounded-md bg-black px-3 py-1.5 font-medium text-white hover:bg-gray-900 text-sm"
onClick={() => setIsPickingOptions(true)}
>
Add roadmap
</button>
)}
</div>
);
}
const customizeRoadmapModal = changingRoadmapId && (
<UpdateTeamResourceModal
onClose={() => setChangingRoadmapId('')}
resourceId={changingRoadmapId}
resourceType={'roadmap'}
teamId={team?._id!}
setTeamResourceConfig={setTeamResources}
defaultRemovedItems={
defaultRoadmaps.find((c) => c.resourceId === changingRoadmapId)
?.removed || []
}
/>
);
const shareSettingsModal = selectedResource && (
<ShareOptionsModal
description={selectedResource.description!}
visibility={selectedResource.visibility!}
sharedTeamMemberIds={selectedResource.sharedTeamMemberIds!}
sharedFriendIds={selectedResource.sharedFriendIds!}
teamId={teamId}
roadmapId={selectedResource.resourceId}
onShareSettingsUpdate={(shareSettings) => {
setTeamResources((prev) => {
return prev.map((c) => {
if (c.resourceId !== selectedResource.resourceId) {
return c;
}
return {
...c,
...shareSettings,
};
});
});
}}
onClose={() => setSelectedResource(null)}
/>
);
return (
<div>
{pickRoadmapOptionModal}
{addRoadmapModal}
{createRoadmapModal}
{customizeRoadmapModal}
{shareSettingsModal}
{confirmationContentIdModal}
{canManageCurrentTeam && placeholderRoadmaps.length > 0 && (
<div className="mb-5">
<div className="mb-2 flex items-center justify-between">
<h3 className="flex w-full items-center justify-between text-xs uppercase text-gray-400">
<span className="flex">Placeholder Roadmaps</span>
<span className="normal-case">
Total {placeholderRoadmaps.length} roadmap(s)
</span>
</h3>
</div>
<div className="flex flex-col divide-y rounded-md border">
{placeholderRoadmaps.map(
(resourceConfig: TeamResourceConfig[0]) => {
return (
<div
className="grid grid-cols-1 p-2.5 sm:grid-cols-[auto_173px]"
key={resourceConfig.resourceId}
>
<div className="mb-3 grid sm:mb-0">
<p className="mb-1.5 truncate text-base font-medium leading-tight text-black">
{resourceConfig.title}
</p>
<span className="text-xs italic leading-none text-gray-400/60">
Placeholder roadmap
</span>
</div>
{canManageCurrentTeam && (
<div className="flex items-center justify-start gap-2 sm:justify-end">
<RoadmapActionDropdown
onUpdateSharing={() => {
setSelectedResource(resourceConfig);
}}
onDelete={() => {
if (
confirm(
'Are you sure you want to remove this roadmap?',
)
) {
onRemove(resourceConfig.resourceId).finally(
() => {},
);
}
}}
/>
<a
href={`${import.meta.env.PUBLIC_EDITOR_APP_URL}/${
resourceConfig.resourceId
}`}
className={
'flex gap-2 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-hidden'
}
target={'_blank'}
>
<PenSquare className="inline-block h-4 w-4" />
Create Roadmap
</a>
</div>
)}
</div>
);
},
)}
</div>
</div>
)}
{customRoadmaps.length > 0 && (
<div className="mb-5">
<div className="mb-2 flex items-center justify-between">
<h3 className="flex w-full items-center justify-between text-xs uppercase text-gray-400">
<span className="flex">Custom Roadmaps</span>
<span className="normal-case">
Total {customRoadmaps.length} roadmap(s)
</span>
</h3>
</div>
<div className="flex flex-col divide-y rounded-md border">
{customRoadmaps.map((resourceConfig: TeamResourceConfig[0]) => {
const editorLink = `${import.meta.env.PUBLIC_EDITOR_APP_URL}/${
resourceConfig.resourceId
}`;
return (
<div
className={cn(
'grid grid-cols-1 p-2.5',
canManageCurrentTeam
? 'sm:grid-cols-[auto_172px]'
: 'sm:grid-cols-[auto_110px]',
)}
key={resourceConfig.resourceId}
>
<div className="mb-3 grid grid-cols-1 sm:mb-0">
<p className="mb-1.5 truncate text-base font-medium leading-tight text-black">
{resourceConfig.title}
</p>
<span className="flex items-center text-xs leading-none text-gray-400">
<VisibilityBadge
visibility={resourceConfig.visibility!}
sharedTeamMemberIds={resourceConfig.sharedTeamMemberIds}
sharedFriendIds={resourceConfig.sharedFriendIds}
/>
<span className="mx-2 font-semibold">&middot;</span>
<Shapes size={16} className="mr-1 inline-block h-4 w-4" />
{resourceConfig.topics} topic
</span>
</div>
<div className="mr-1 flex items-center justify-start sm:justify-end">
{canManageCurrentTeam && (
<RoadmapActionDropdown
onUpdateSharing={() => {
setSelectedResource(resourceConfig);
}}
onCustomize={() => {
window.open(editorLink, '_blank');
}}
onDelete={() => {
if (
confirm(
'Are you sure you want to remove this roadmap?',
)
) {
onRemove(resourceConfig.resourceId).finally(
() => {},
);
}
}}
/>
)}
<a
href={`/r/${resourceConfig.roadmapSlug}`}
className={
'ml-2 flex items-center gap-2 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-hidden'
}
target={'_blank'}
>
<ExternalLink className="inline-block h-4 w-4" />
Visit
</a>
{canManageCurrentTeam && (
<a
href={editorLink}
className={
'ml-2 flex items-center gap-2 rounded-md border border-gray-800 bg-gray-900 px-2.5 py-1.5 text-xs text-white hover:bg-gray-800 focus:outline-hidden'
}
target={'_blank'}
>
<PenSquare className="inline-block h-4 w-4" />
Edit
</a>
)}
</div>
</div>
);
})}
</div>
</div>
)}
{defaultRoadmaps.length > 0 && (
<div>
<div className="mb-2 flex items-center justify-between">
<h3 className="flex w-full items-center justify-between text-xs uppercase text-gray-400">
<span className="flex">Default Roadmaps</span>
<span className="normal-case">
Total {defaultRoadmaps.length} roadmap(s)
</span>
</h3>
</div>
<div className="flex flex-col divide-y rounded-md border">
{defaultRoadmaps.map((resourceConfig: TeamResourceConfig[0]) => {
return (
<div
className="grid grid-cols-1 p-3 sm:grid-cols-[auto_110px]"
key={resourceConfig.resourceId}
>
<div className="mb-3 grid grid-cols-1 sm:mb-0">
<p className="mb-1.5 truncate text-base font-medium leading-tight text-black">
{resourceConfig.title}
</p>
<span className="flex items-center text-xs leading-none text-gray-400">
{resourceConfig?.removed?.length > 0 && (
<>
<PackageMinus
size={16}
className="mr-1 inline-block h-4 w-4"
/>
{resourceConfig.removed.length} topics removed
</>
)}
{!resourceConfig?.removed?.length && (
<>
<Package
size={16}
className="mr-1 inline-block h-4 w-4"
/>
No changes made
</>
)}
</span>
</div>
<div className="mr-1 flex items-center justify-start sm:justify-end">
{canManageCurrentTeam && (
<RoadmapActionDropdown
onCustomize={() => {
setChangingRoadmapId(resourceConfig.resourceId);
}}
onDelete={() => {
if (
confirm(
'Are you sure you want to remove this roadmap?',
)
) {
onRemove(resourceConfig.resourceId).finally(
() => {},
);
}
}}
/>
)}
<a
href={`/${resourceConfig.resourceId}?t=${teamId}`}
className={
'ml-2 flex items-center gap-2 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-hidden'
}
target={'_blank'}
>
<ExternalLink className="inline-block h-4 w-4" />
Visit
</a>
</div>
</div>
);
})}
</div>
</div>
)}
{canManageCurrentTeam && (
<div className="mt-5">
<button
className="block w-full rounded-md border border-dashed border-gray-300 py-2 text-sm transition-colors hover:border-gray-600 hover:bg-gray-50 focus:outline-0"
onClick={() => setIsPickingOptions(true)}
>
+ Add new Roadmap
</button>
</div>
)}
</div>
);
}
type VisibilityLabelProps = {
visibility: AllowedRoadmapVisibility;
sharedTeamMemberIds?: string[];
sharedFriendIds?: string[];
};
const visibilityDetails: Record<
AllowedRoadmapVisibility,
{
icon: LucideIcon;
label: string;
}
> = {
public: {
icon: Globe,
label: 'Public',
},
me: {
icon: LockIcon,
label: 'Only me',
},
team: {
icon: Users,
label: 'Team Member(s)',
},
friends: {
icon: Users,
label: 'Friend(s)',
},
} as const;
export function VisibilityBadge(props: VisibilityLabelProps) {
const { visibility, sharedTeamMemberIds = [], sharedFriendIds = [] } = props;
const { label, icon: Icon } = visibilityDetails[visibility];
return (
<span
className={`inline-flex items-center gap-1.5 whitespace-nowrap text-xs font-normal`}
>
<Icon className="inline-block h-3 w-3" />
<div className="flex items-center">
{visibility === 'team' && sharedTeamMemberIds?.length > 0 && (
<span className="mr-1">{sharedTeamMemberIds.length}</span>
)}
{visibility === 'friends' && sharedFriendIds?.length > 0 && (
<span className="mr-1">{sharedFriendIds.length}</span>
)}
{label}
</div>
</span>
);
}

View File

@@ -1,321 +0,0 @@
import { type FormEvent, useEffect, useState } from 'react';
import { httpGet, httpPut } from '../../lib/http';
import { Spinner } from '../ReactIcons/Spinner';
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 { $isCurrentTeamAdmin } from '../../stores/team';
import { useStore } from '@nanostores/react';
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 [personalProgressOnly, setPersonalProgressOnly] = useState(false);
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: FormEvent<HTMLFormElement>) => {
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,
personalProgressOnly,
...(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);
setPersonalProgressOnly(response.personalProgressOnly ?? false);
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}`
: '/img/default-avatar.png'
}
teamId={teamId!}
/>
<form onSubmit={handleSubmit}>
<div className="mt-4 flex w-full flex-col">
<label
htmlFor="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-xs outline-hidden 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
htmlFor="website"
className={`text-sm leading-none text-slate-500 ${
teamType === 'company' ? 'after:content-["*"]' : ''
}`}
>
Website
</label>
<input
required={teamType === 'company'}
type="text"
name="website"
id="website"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-xs outline-hidden 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
htmlFor="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-xs outline-hidden 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
htmlFor="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-xs outline-hidden 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
htmlFor="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 h-[42px] w-full rounded-lg border border-gray-300 px-3 py-2 shadow-xs outline-hidden 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="">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
htmlFor="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 h-[42px] w-full rounded-lg border border-gray-300 px-3 py-2 shadow-xs outline-hidden 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 h-[42px] w-full items-center rounded-lg border border-gray-300 px-3 py-2 shadow-xs">
<label
htmlFor="personal-progress-only"
className="flex items-center gap-2 text-sm leading-none text-slate-500"
>
<input
type="checkbox"
name="personal-progress-only"
id="personal-progress-only"
disabled={isDisabled}
checked={personalProgressOnly}
onChange={(e) =>
setPersonalProgressOnly((e.target as HTMLInputElement).checked)
}
/>
<span>Members can only see their personal progress</span>
</label>
</div>
{personalProgressOnly && (
<p className="mt-2 rounded-lg border border-orange-300 bg-orange-50 p-2 text-sm text-orange-700">
Only admins and managers will be able to see the progress of members
</p>
)}
<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-hidden 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 className="my-8" />
{isDeleting && (
<DeleteTeamPopup
onClose={() => {
setIsDeleting(false);
}}
/>
)}
<h2 className="text-xl font-bold sm:text-2xl">Delete Team</h2>
<p className="mt-2 text-gray-400">
Permanently delete this team and all of its resources.
</p>
<button
onClick={() => setIsDeleting(true)}
data-popup="delete-team-popup"
className="font-regular mt-4 w-full rounded-lg bg-red-600 py-2 text-base text-white outline-hidden focus:ring-2 focus:ring-red-500 focus:ring-offset-1"
>
Delete Team
</button>
</>
)}
</div>
);
}

View File

@@ -1,230 +0,0 @@
import { useState, useEffect, useRef } from 'react';
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';
import { DropdownIcon } from '../ReactIcons/DropdownIcon';
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: boolean;
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 {
shouldShowAvatar = !!(!selectedTeamVersion && user?.avatar);
}
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(() => {
//
});
}, []);
useEffect(() => {
if (!selectedTeamVersion) {
return;
}
clearResourceProgress();
// teams have customizations. Assigning #customized-roadmap to roadmapSvgWrap
// makes those customizations visible and removes extra boxes
const roadmapSvgWrap: HTMLElement =
document.getElementById('resource-svg-wrap')?.parentElement ||
document.createElement('div');
if (!selectedTeamVersion) {
deleteUrlParam('t');
renderResourceProgress(resourceType, resourceId).then();
roadmapSvgWrap.id = '';
} else {
setUrlParams({ t: selectedTeamVersion.team._id! });
renderResourceProgress(resourceType, resourceId).then(() => {
selectedTeamVersion.config?.removed?.forEach((topic) => {
renderTopicProgress(topic, 'removed');
});
refreshProgressCounters();
roadmapSvgWrap.id = 'customized-roadmap';
});
}
}, [selectedTeamVersion]);
if (isPreparing) {
return null;
}
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>
<DropdownIcon className="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}`
: '/img/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: TeamVersionsResponse[0]) => {
const isSelectedTeam =
selectedTeamVersion?.team._id === team.team._id;
return (
<button
key={team?.team?._id}
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>
);
}

View File

@@ -1,19 +0,0 @@
---
import Icon from '../AstroIcon.astro';
---
<script src='./topics.js'></script>
<div class='sm:-mb-[68px] mt-5 sm:mt-6 relative'>
<input
autofocus
type='text'
id='search-topic-input'
class='border border-gray-300 text-gray-900 text-sm sm:text-md rounded-md focus:ring-blue-500 focus:border-blue-500 block w-full px-2.5 sm:px-3 py-2'
placeholder='Search for a topic'
/>
<span class='absolute top-1/2 -translate-y-1/2 right-4 flex items-center text-sm text-gray-500'>
<Icon icon='search' />
</span>
</div>

View File

@@ -1,46 +0,0 @@
class Topics {
constructor() {
this.topicSearchId = 'search-topic-input';
this.onDOMLoaded = this.onDOMLoaded.bind(this);
this.init = this.init.bind(this);
this.filterTopicNodes = this.filterTopicNodes.bind(this);
}
get topicSearchEl() {
return document.getElementById(this.topicSearchId);
}
filterTopicNodes(e) {
const value = e.target.value.trim().toLowerCase();
if (!value) {
document
.querySelectorAll(`[data-topic]`)
.forEach((item) => item.classList.remove('hidden'));
return;
}
document
.querySelectorAll(`[data-topic]`)
.forEach((item) => item.classList.add('hidden'));
document
.querySelectorAll(`[data-topic*="${value}"]`)
.forEach((item) => item.classList.remove('hidden'));
}
onDOMLoaded() {
if (!this.topicSearchEl) {
return;
}
this.topicSearchEl.addEventListener('keyup', this.filterTopicNodes);
}
init() {
window.addEventListener('DOMContentLoaded', this.onDOMLoaded);
}
}
const topicRef = new Topics();
topicRef.init();

View File

@@ -1,245 +0,0 @@
import { type FormEvent, useState } from 'react';
import { httpPatch } from '../../lib/http';
import { pageProgressMessage } from '../../stores/page';
import { useToast } from '../../hooks/use-toast';
import { cn } from '../../lib/classname';
import { ArrowUpRight, X } from 'lucide-react';
type UpdateEmailFormProps = {
authProvider: string;
currentEmail: string;
newEmail?: string;
onSendVerificationCode?: (newEmail: string) => void;
onVerificationCancel?: () => void;
};
export function UpdateEmailForm(props: UpdateEmailFormProps) {
const {
authProvider,
currentEmail,
newEmail: defaultNewEmail = '',
onSendVerificationCode,
onVerificationCancel,
} = props;
const toast = useToast();
const [isLoading, setIsLoading] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(defaultNewEmail !== '');
const [newEmail, setNewEmail] = useState(defaultNewEmail);
const [isResendDone, setIsResendDone] = useState(false);
const handleSentVerificationCode = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!newEmail || !newEmail.includes('@') || isSubmitted) {
return;
}
setIsLoading(true);
pageProgressMessage.set('Sending verification code');
const { response, error } = await httpPatch(
`${import.meta.env.PUBLIC_API_URL}/v1-update-user-email`,
{ email: newEmail },
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
setIsLoading(false);
pageProgressMessage.set('');
return;
}
pageProgressMessage.set('');
setIsLoading(false);
setIsSubmitted(true);
onSendVerificationCode?.(newEmail);
};
const handleResendVerificationCode = async () => {
if (isResendDone) {
toast.error('You have already resent the verification code');
return;
}
setIsLoading(true);
pageProgressMessage.set('Resending verification code');
const { response, error } = await httpPatch(
`${import.meta.env.PUBLIC_API_URL}/v1-resend-email-verification-code`,
{ email: newEmail },
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
setIsLoading(false);
pageProgressMessage.set('');
return;
}
toast.success('Verification code has been resent');
pageProgressMessage.set('');
setIsResendDone(true);
setIsLoading(false);
};
const handleCancelEmailVerification = async () => {
setIsLoading(true);
pageProgressMessage.set('Cancelling email verification');
const { response, error } = await httpPatch(
`${import.meta.env.PUBLIC_API_URL}/v1-cancel-email-verification`,
{},
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
setIsLoading(false);
pageProgressMessage.set('');
return;
}
pageProgressMessage.set('');
onVerificationCancel?.();
setIsSubmitted(false);
setNewEmail('');
setIsLoading(false);
};
if (authProvider && authProvider !== 'email') {
return (
<div className="block">
<h2 className="text-xl font-bold sm:text-2xl">Update Email</h2>
<p className="mt-2 text-gray-400">
You have used {authProvider} when signing up. Please set your password
first.
</p>
<div className="mt-4 flex w-full flex-col">
<label
htmlFor="current-email"
className="text-sm leading-none text-slate-500"
>
Current Email
</label>
<input
type="email"
name="current-email"
id="current-email"
autoComplete="current-email"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-xs outline-hidden placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
required
disabled
value={currentEmail}
/>
</div>
<p className="mt-3 rounded-lg border border-red-600 px-2 py-1 text-red-600">
Please set your password first to update your email.
</p>
</div>
);
}
return (
<>
<div className="mb-8 block">
<h2 className="text-xl font-bold sm:text-2xl">Update Email</h2>
<p className="mt-2 text-gray-400">
Use the form below to update your email.
</p>
</div>
<form onSubmit={handleSentVerificationCode} className="space-y-4">
<div className="flex w-full flex-col">
<label
htmlFor="current-email"
className="text-sm leading-none text-slate-500"
>
Current Email
</label>
<input
type="email"
name="current-email"
id="current-email"
autoComplete="current-email"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-xs outline-hidden placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
required
disabled
value={currentEmail}
/>
</div>
<div
className={cn('flex w-full flex-col', {
'rounded-lg border border-green-500 p-3': isSubmitted,
})}
>
<div className="flex items-center justify-between gap-2">
<label
htmlFor="new-email"
className="text-sm leading-none text-slate-500"
>
New Email
</label>
{isSubmitted && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleResendVerificationCode}
disabled={isLoading || isResendDone}
className="flex items-center gap-1 text-sm font-medium leading-none text-green-600 transition-colors hover:text-green-700"
>
<span className="hidden sm:block">
Resend Verification Link
</span>
<span className="sm:hidden">Resend Code</span>
</button>
</div>
)}
</div>
<input
type="email"
name="new-email"
id="new-email"
autoComplete={'new-email'}
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-xs outline-hidden placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
required
placeholder="Enter new email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
disabled={isSubmitted}
/>
{!isSubmitted && (
<button
type="submit"
disabled={
isLoading || !newEmail || !newEmail.includes('@') || isSubmitted
}
className="mt-3 inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-hidden focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
>
{isLoading ? 'Please wait...' : 'Send Verification Link'}
</button>
)}
{isSubmitted && (
<>
<button
type="button"
onClick={handleCancelEmailVerification}
disabled={isLoading}
className="font-regular mt-4 w-full rounded-lg border border-red-600 py-2 text-sm text-red-600 outline-hidden transition-colors hover:bg-red-500 hover:text-white focus:ring-2 focus:ring-red-500 focus:ring-offset-1"
>
Cancel Update
</button>
<div className="mt-3 flex items-center gap-2 rounded-lg bg-green-100 p-4">
<span className="text-sm text-green-800">
A verification link has been sent to your{' '}
<span>new email address</span>. Please follow the instructions
in email to verify and update your email.
</span>
</div>
</>
)}
</div>
</form>
</>
);
}

View File

@@ -1,149 +0,0 @@
import { type FormEvent, useState } from 'react';
import { httpPost } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
type UpdatePasswordFormProps = {
authProvider: string;
};
export default function UpdatePasswordForm(props: UpdatePasswordFormProps) {
const { authProvider } = props;
const toast = useToast();
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newPasswordConfirmation, setNewPasswordConfirmation] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
if (newPassword !== newPasswordConfirmation) {
toast.error('Passwords do not match');
setIsLoading(false);
return;
}
const { response, error } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-update-password`,
{
oldPassword: authProvider === 'email' ? currentPassword : 'social-auth',
password: newPassword,
confirmPassword: newPasswordConfirmation,
},
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
setIsLoading(false);
return;
}
setCurrentPassword('');
setNewPassword('');
setNewPasswordConfirmation('');
toast.success('Password updated successfully');
setIsLoading(false);
setTimeout(() => {
window.location.reload();
}, 1000);
};
return (
<form onSubmit={handleSubmit}>
<div className="mb-8 hidden md:block">
<h2 className="text-3xl font-bold sm:text-4xl">Password</h2>
<p className="mt-2 text-gray-400">
Use the form below to update your password.
</p>
</div>
<div className="space-y-4">
{authProvider === 'email' && (
<div className="flex w-full flex-col">
<label
htmlFor="current-password"
className="text-sm leading-none text-slate-500"
>
Current Password
</label>
<input
disabled={authProvider !== 'email'}
type="password"
name="current-password"
id="current-password"
autoComplete={'current-password'}
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-xs outline-hidden placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-100"
required
minLength={6}
placeholder="Current password"
value={currentPassword}
onInput={(e) =>
setCurrentPassword((e.target as HTMLInputElement).value)
}
/>
</div>
)}
<div className="flex w-full flex-col">
<label
htmlFor="new-password"
className="text-sm leading-none text-slate-500"
>
New Password
</label>
<input
type="password"
name="new-password"
id="new-password"
autoComplete={'new-password'}
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-xs outline-hidden placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
required
minLength={6}
placeholder="New password"
value={newPassword}
onInput={(e) =>
setNewPassword((e.target as HTMLInputElement).value)
}
/>
</div>
<div className="flex w-full flex-col">
<label
htmlFor="new-password-confirmation"
className="text-sm leading-none text-slate-500"
>
Confirm New Password
</label>
<input
type="password"
name="new-password-confirmation"
id="new-password-confirmation"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-xs outline-hidden placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
autoComplete={'new-password'}
required
minLength={6}
placeholder="Confirm New Password"
value={newPasswordConfirmation}
onInput={(e) =>
setNewPasswordConfirmation((e.target as HTMLInputElement).value)
}
/>
</div>
<button
type="submit"
disabled={
isLoading || !newPassword || newPassword !== newPasswordConfirmation
}
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-hidden focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
>
{isLoading ? 'Please wait...' : 'Update Password'}
</button>
</div>
</form>
);
}

View File

@@ -1,155 +0,0 @@
import { useEffect, useState } from 'react';
import type { AllowedProfileVisibility } from '../../api/user';
import { httpPost } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
import { CheckIcon, Loader2, X } from 'lucide-react';
import { useDebounceValue } from '../../hooks/use-debounce.ts';
type ProfileUsernameProps = {
username: string;
setUsername: (username: string) => void;
profileVisibility: AllowedProfileVisibility;
currentUsername?: string;
};
export function ProfileUsername(props: ProfileUsernameProps) {
const { username, setUsername, profileVisibility, currentUsername } = props;
const toast = useToast();
const [isLoading, setIsLoading] = useState(false);
const [isUnique, setIsUnique] = useState<boolean | null>(null);
const debouncedUsername = useDebounceValue(username, 500);
useEffect(() => {
checkIsUnique(debouncedUsername).then();
}, [debouncedUsername]);
const checkIsUnique = async (username: string) => {
if (isLoading || !username) {
return;
}
if (username.length < 3) {
setIsUnique(false);
return;
}
if (currentUsername && username === currentUsername && isUnique !== false) {
setIsUnique(true);
return;
}
setIsLoading(true);
const { response, error } = await httpPost<{
isUnique: boolean;
}>(`${import.meta.env.PUBLIC_API_URL}/v1-check-is-unique-username`, {
username,
});
if (error || !response) {
setIsUnique(null);
setIsLoading(false);
toast.error(error?.message || 'Something went wrong. Please try again.');
return;
}
setIsUnique(response.isUnique);
setIsLoading(false);
};
const USERNAME_REGEX = /^[a-zA-Z0-9]*$/;
const isUserNameValid = (value: string) =>
USERNAME_REGEX.test(value) && value.length <= 20;
return (
<div className="flex w-full flex-col">
<label
htmlFor="username"
className="flex min-h-[16.5px] items-center justify-between text-sm leading-none text-slate-500"
>
<span>Profile URL</span>
{!isLoading && (
<span className="flex items-center">
{currentUsername &&
(currentUsername === username || !username || !isUnique) && (
<span className="text-xs">
Current URL{' '}
<a
href={`${import.meta.env.DEV ? 'http://localhost:3000' : 'https://roadmap.sh'}/u/${currentUsername}`}
target="_blank"
className={
'ml-0.5 rounded-md border border-purple-500 px-1.5 py-0.5 font-mono text-xs font-medium text-purple-700 transition-colors hover:bg-purple-500 hover:text-white'
}
>
roadmap.sh/u/{currentUsername}
</a>
</span>
)}
{currentUsername !== username && username && isUnique && (
<span className="text-xs text-green-600">
URL after update{' '}
<span
className={
'ml-0.5 rounded-md border border-purple-500 px-1.5 py-0.5 text-xs font-medium text-purple-700 transition-colors'
}
>
roadmap.sh/u/{username}
</span>
</span>
)}
</span>
)}
</label>
<div className="mt-2 flex items-center overflow-hidden rounded-lg border border-gray-300">
<span className="border-r border-gray-300 bg-gray-100 p-2">
roadmap.sh/u/
</span>
<div className="relative grow">
<input
type="text"
name="username"
id="username"
className="w-full px-3 py-2 outline-hidden placeholder:text-gray-400"
placeholder="johndoe"
spellCheck={false}
value={username}
title="Username must be at least 3 characters long and can only contain letters, numbers, and underscores"
onKeyDown={(e) => {
// only allow letters, numbers
const keyCode = e.key;
if (
!isUserNameValid(keyCode) &&
!['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight'].includes(
keyCode,
)
) {
e.preventDefault();
}
}}
onInput={(e) => {
const value = (e.target as HTMLInputElement).value?.trim();
if (!isUserNameValid(value)) {
return;
}
setUsername((e.target as HTMLInputElement).value.toLowerCase());
}}
required={profileVisibility === 'public'}
/>
{username && (
<span className="absolute bottom-0 right-0 top-0 flex items-center px-2">
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isUnique === false ? (
<X className="h-4 w-4 text-red-500" />
) : isUnique === true ? (
<CheckIcon className="h-4 w-4 text-green-500" />
) : null}
</span>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,48 +0,0 @@
import { CheckCircle, FileBadge } from 'lucide-react';
const ideas = [
'Add a link to your profile in your social media bio',
'Include your profile link in your resume to showcase your skills',
'Add a link to your profile in your email signature',
'Showcase your skills in your GitHub profile',
'Share your profile with potential employers',
];
export function SkillProfileAlert() {
return (
<div className="relative mb-5 rounded-lg bg-yellow-200 px-3 py-3 text-sm text-yellow-800">
<FileBadge className="absolute hidden sm:block bottom-3 right-3 h-20 w-20 stroke-2 text-yellow-500 opacity-50" />
<h2 className="mb-1 text-base font-semibold">
Announcing Skill Profiles!{' '}
</h2>
<p className="text-sm">
Create your skill profile to showcase your skills or learning progress.
Here are some of the ways you can use your skill profile:
</p>
<div className="my-3 ml-2 flex flex-col gap-1 sm:ml-3">
{ideas.map((idea) => (
<p
key={idea}
className="flex flex-row items-start gap-1.5 sm:items-center"
>
<CheckCircle className="relative top-[3px] h-3.5 w-3.5 shrink-0 stroke-[2.5] sm:top-0" />
<span>{idea}</span>
</p>
))}
</div>
<p className="text-sm">
Make sure to mark your expertise{' '}
<a
href="/roadmaps"
target="_blank"
className="font-semibold underline underline-offset-2"
>
in the roadmaps.
</a>
</p>
</div>
);
}

View File

@@ -1,154 +0,0 @@
import { type FormEvent, useEffect, useState } from 'react';
import { httpGet, httpPost } from '../../lib/http';
import { pageProgressMessage } from '../../stores/page';
import UploadProfilePicture from './UploadProfilePicture';
export function UpdateProfileForm() {
const [name, setName] = useState('');
const [avatar, setAvatar] = useState('');
const [email, setEmail] = useState('');
const [username, setUsername] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError('');
setSuccess('');
const { response, error } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-update-profile`,
{
name,
},
);
if (error || !response) {
setIsLoading(false);
setError(error?.message || 'Something went wrong');
return;
}
await loadProfile();
setSuccess('Profile updated successfully');
};
const loadProfile = async () => {
setIsLoading(true);
const { error, response } = await httpGet(
`${import.meta.env.PUBLIC_API_URL}/v1-me`,
);
if (error || !response) {
setIsLoading(false);
setError(error?.message || 'Something went wrong');
return;
}
const { name, email, avatar, username } = response;
setName(name);
setEmail(email);
setUsername(username);
setAvatar(avatar || '');
setIsLoading(false);
};
// Make a request to the backend to fill in the form with the current values
useEffect(() => {
loadProfile().finally(() => {
pageProgressMessage.set('');
});
}, []);
return (
<div>
<div className="mb-8 hidden md:block">
<h2 className="text-2xl font-bold sm:text-3xl">Basic Information</h2>
<p className="mt-0.5 text-gray-400">
Update and set up your public profile below.
</p>
</div>
<UploadProfilePicture
type="avatar"
label="Profile picture"
avatarUrl={
avatar
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}`
: '/img/default-avatar.png'
}
/>
<form className="mt-4 space-y-4" onSubmit={handleSubmit}>
<div className="flex w-full flex-col">
<label
htmlFor="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 appearance-none rounded-lg border border-gray-300 px-3 py-2 shadow-xs outline-hidden placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
required
placeholder="John Doe"
value={name}
onInput={(e) => setName((e.target as HTMLInputElement).value)}
/>
</div>
<div className="flex w-full flex-col">
<div className="flex items-center justify-between">
<label
htmlFor="email"
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
>
Email
</label>
<a
href="/account/settings"
className="text-xs text-purple-700 underline hover:text-purple-800"
>
Visit settings page to change email
</a>
</div>
<input
type="email"
name="email"
id="email"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-xs outline-hidden placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
required
disabled
placeholder="john@example.com"
value={email}
/>
</div>
{error && (
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">{error}</p>
)}
{success && (
<p className="mt-2 rounded-lg bg-green-100 p-2 text-green-700">
{success}
</p>
)}
<button
type="submit"
disabled={isLoading}
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-hidden focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
>
{isLoading ? 'Please wait...' : 'Update Information'}
</button>
</form>
</div>
);
}

View File

@@ -1,639 +0,0 @@
import { type FormEvent, useEffect, useState } from 'react';
import { httpGet, httpPatch } from '../../lib/http';
import { pageProgressMessage } from '../../stores/page';
import type {
AllowedCustomRoadmapVisibility,
AllowedProfileVisibility,
AllowedRoadmapVisibility,
UserDocument,
} from '../../api/user';
import { SelectionButton } from '../RoadCard/SelectionButton';
import {
ArrowUpRight,
Check,
CheckCircle,
Copy,
Eye,
EyeOff,
FileBadge,
Trophy,
} from 'lucide-react';
import { useToast } from '../../hooks/use-toast';
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
import { VisibilityDropdown } from './VisibilityDropdown.tsx';
import { ProfileUsername } from './ProfileUsername.tsx';
import UploadProfilePicture from './UploadProfilePicture.tsx';
import { SkillProfileAlert } from './SkillProfileAlert.tsx';
import { useCopyText } from '../../hooks/use-copy-text.ts';
import { cn } from '../../lib/classname.ts';
type RoadmapType = {
id: string;
title: string;
isCustomResource: boolean;
};
type GetProfileSettingsResponse = Pick<
UserDocument,
'username' | 'profileVisibility' | 'publicConfig' | 'links'
>;
export function UpdatePublicProfileForm() {
const [profileVisibility, setProfileVisibility] =
useState<AllowedProfileVisibility>('public');
const toast = useToast();
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
const [publicProfileUrl, setPublicProfileUrl] = useState('');
const [isAvailableForHire, setIsAvailableForHire] = useState(false);
const [isEmailVisible, setIsEmailVisible] = useState(true);
const [headline, setHeadline] = useState('');
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [roadmapVisibility, setRoadmapVisibility] =
useState<AllowedRoadmapVisibility>('all');
const [customRoadmapVisibility, setCustomRoadmapVisibility] =
useState<AllowedCustomRoadmapVisibility>('all');
const [roadmaps, setRoadmaps] = useState<string[]>([]);
const [customRoadmaps, setCustomRoadmaps] = useState<string[]>([]);
const [currentUsername, setCurrentUsername] = useState('');
const [name, setName] = useState('');
const [avatar, setAvatar] = useState('');
const [github, setGithub] = useState('');
const [twitter, setTwitter] = useState('');
const [linkedin, setLinkedin] = useState('');
const [dailydev, setDailydev] = useState('');
const [website, setWebsite] = useState('');
const [profileRoadmaps, setProfileRoadmaps] = useState<RoadmapType[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isProfileUpdated, setIsProfileUpdated] = useState(false);
const { isCopied, copyText } = useCopyText();
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
const { response, error } = await httpPatch(
`${import.meta.env.PUBLIC_API_URL}/v1-update-public-profile-config`,
{
isAvailableForHire,
isEmailVisible,
profileVisibility,
headline,
username,
roadmapVisibility,
customRoadmapVisibility,
roadmaps,
customRoadmaps,
github,
twitter,
linkedin,
website,
name,
email,
dailydev,
},
);
if (error || !response) {
setIsLoading(false);
toast.error(error?.message || 'Something went wrong');
return;
}
await loadProfileSettings();
toast.success('Profile updated successfully');
setIsProfileUpdated(true);
};
const loadProfileSettings = async () => {
setIsLoading(true);
const { error, response } = await httpGet<UserDocument>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-profile-settings`,
);
if (error || !response) {
setIsLoading(false);
toast.error(error?.message || 'Something went wrong');
return;
}
const {
name,
email,
links,
username,
profileVisibility: defaultProfileVisibility,
publicConfig,
avatar,
} = response;
setAvatar(avatar || '');
setPublicProfileUrl(username ? `/u/${username}` : '');
setUsername(username || '');
setCurrentUsername(username || '');
setName(name || '');
setEmail(email || '');
setGithub(links?.github || '');
setTwitter(links?.twitter || '');
setLinkedin(links?.linkedin || '');
setDailydev(links?.dailydev || '');
setWebsite(links?.website || '');
setProfileVisibility(defaultProfileVisibility || 'public');
setHeadline(publicConfig?.headline || '');
setRoadmapVisibility(publicConfig?.roadmapVisibility || 'all');
setCustomRoadmapVisibility(publicConfig?.customRoadmapVisibility || 'all');
setCustomRoadmaps(publicConfig?.customRoadmaps || []);
setRoadmaps(publicConfig?.roadmaps || []);
setIsAvailableForHire(publicConfig?.isAvailableForHire || false);
setIsEmailVisible(publicConfig?.isEmailVisible ?? true);
setIsLoading(false);
};
const loadProfileRoadmaps = async () => {
setIsLoading(true);
const { error, response } = await httpGet<{
roadmaps: RoadmapType[];
}>(`${import.meta.env.PUBLIC_API_URL}/v1-get-profile-roadmaps`);
if (error || !response) {
setIsLoading(false);
toast.error(error?.message || 'Something went wrong');
return;
}
setProfileRoadmaps(response?.roadmaps || []);
setIsLoading(false);
};
// Make a request to the backend to fill in the form with the current values
useEffect(() => {
Promise.all([loadProfileSettings(), loadProfileRoadmaps()]).finally(() => {
pageProgressMessage.set('');
});
}, []);
const publicCustomRoadmaps = profileRoadmaps.filter(
(r) => r.isCustomResource && r.id && r.title,
);
const publicRoadmaps = profileRoadmaps.filter(
(r) => !r.isCustomResource && r.id && r.title,
);
return (
<div>
{isCreatingRoadmap && (
<CreateRoadmapModal onClose={() => setIsCreatingRoadmap(false)} />
)}
<SkillProfileAlert />
<div className="mb-8 flex flex-col justify-between gap-2 sm:mb-1 sm:flex-row">
<div className="flex grow flex-row items-center gap-2 sm:items-center">
<h3 className="mr-1 text-xl font-bold sm:text-3xl">Skill Profile</h3>
{publicProfileUrl && (
<>
<a
href={publicProfileUrl}
target="_blank"
className="flex shrink-0 flex-row items-center gap-1 rounded-lg border border-black py-0.5 pl-1.5 pr-2.5 text-xs uppercase transition-colors hover:bg-black hover:text-white"
>
<ArrowUpRight className="h-3 w-3 stroke-3" />
Visit
</a>
<button
onClick={() => {
copyText(`${window.location.origin}${publicProfileUrl}`);
}}
className={cn(
'flex shrink-0 flex-row items-center gap-1 rounded-lg border border-black py-0.5 pl-1.5 pr-2.5 text-xs uppercase transition-colors hover:bg-black hover:text-white',
{
'bg-black text-white': isCopied,
},
)}
>
{!isCopied && <Copy className="h-3 w-3 stroke-[2.5]" />}
{isCopied && <Check className="h-3 w-3 stroke-[2.5]" />}
{!isCopied ? 'Copy URL' : 'Copied!'}
</button>
</>
)}
</div>
<VisibilityDropdown
visibility={profileVisibility}
setVisibility={setProfileVisibility}
/>
</div>
<p className="mb-8 mt-2 hidden text-sm text-gray-400 sm:mt-0 sm:block sm:text-base">
Create your skill profile to showcase your skills.
</p>
<UploadProfilePicture
type="avatar"
label="Profile picture"
avatarUrl={
avatar
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}`
: '/img/default-avatar.png'
}
/>
<form className="mt-6 space-y-4 pb-10" onSubmit={handleSubmit}>
<div className="flex w-full flex-col">
<label
htmlFor="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 appearance-none rounded-lg border border-gray-300 px-3 py-2 shadow-xs outline-hidden placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
required
placeholder="John Doe"
value={name}
onInput={(e) => setName((e.target as HTMLInputElement).value)}
/>
</div>
<div className="flex w-full flex-col">
<div className="flex items-center justify-between">
<label
htmlFor="email"
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
>
Email
</label>
<a
href="/account/settings"
className="text-xs text-purple-700 underline hover:text-purple-800"
>
Visit settings page to change email
</a>
</div>
<input
type="email"
name="email"
id="email"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-xs outline-hidden placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
required
disabled
placeholder="john@example.com"
value={email}
/>
<div className="flex items-center justify-end gap-2 rounded-md text-xs text-gray-400">
<div className="flex select-none items-center justify-end gap-2 rounded-md text-xs text-gray-400">
<input
type="checkbox"
name="isEmailVisible"
id="isEmailVisible"
checked={isEmailVisible}
onChange={(e) => setIsEmailVisible(e.target.checked)}
/>
<label
htmlFor="isEmailVisible"
className="grow cursor-pointer py-1.5"
>
Show my email on profile
</label>
</div>
</div>
</div>
<div className="flex w-full flex-col">
<label
htmlFor="headline"
className="text-sm leading-none text-slate-500"
>
Headline
</label>
<input
type="text"
name="headline"
id="headline"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-xs outline-hidden placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="Full Stack Developer"
value={headline}
onChange={(e) => setHeadline((e.target as HTMLInputElement).value)}
required={profileVisibility === 'public'}
/>
</div>
<ProfileUsername
username={username}
setUsername={setUsername}
profileVisibility={profileVisibility}
currentUsername={currentUsername}
/>
<div className="rounded-md border p-4">
<h3 className="text-sm font-medium">
Which roadmap progresses do you want to show on your profile?
</h3>
<div className="mt-3 flex flex-wrap items-center gap-2">
<SelectionButton
type="button"
text="All Progress"
icon={Eye}
isDisabled={false}
isSelected={roadmapVisibility === 'all'}
onClick={() => {
setRoadmapVisibility('all');
setRoadmaps([]);
}}
/>
<SelectionButton
type="button"
icon={EyeOff}
text="Hide my Progress"
isDisabled={false}
isSelected={roadmapVisibility === 'none'}
onClick={() => {
setRoadmapVisibility('none');
setRoadmaps([]);
}}
/>
</div>
<h3 className="mt-4 text-sm text-gray-400">
Or select the roadmaps you want to show
</h3>
{publicRoadmaps.length > 0 ? (
<div className="mt-3 flex flex-wrap items-center gap-2">
{publicRoadmaps.map((r) => (
<SelectionButton
type="button"
key={r.id}
text={r.title}
isDisabled={false}
isSelected={roadmaps.includes(r.id)}
onClick={() => {
if (roadmapVisibility !== 'selected') {
setRoadmapVisibility('selected');
}
if (roadmaps.includes(r.id)) {
setRoadmaps(roadmaps.filter((id) => id !== r.id));
} else {
setRoadmaps([...roadmaps, r.id]);
}
}}
/>
))}
</div>
) : (
<p className="mt-2 rounded-lg bg-yellow-100 p-2 text-sm text-yellow-700">
Update{' '}
<a
target="_blank"
className="font-medium underline underline-offset-2 hover:text-yellow-800"
href="/roadmaps"
>
your progress on roadmaps
</a>{' '}
to show your learning activity.
</p>
)}
</div>
<div className="rounded-md border p-4">
<h3 className="text-sm font-medium">
Pick your custom roadmaps to show on your profile
</h3>
<div className="mt-3 flex flex-wrap items-center gap-2">
<SelectionButton
type="button"
text="All Roadmaps"
icon={Eye}
isDisabled={false}
isSelected={customRoadmapVisibility === 'all'}
onClick={() => {
setCustomRoadmapVisibility('all');
setCustomRoadmaps([]);
}}
/>
<SelectionButton
type="button"
text="Hide my Roadmaps"
icon={EyeOff}
isDisabled={false}
isSelected={customRoadmapVisibility === 'none'}
onClick={() => {
setCustomRoadmapVisibility('none');
setCustomRoadmaps([]);
}}
/>
</div>
<h3 className="mt-4 text-sm text-gray-400">
Or select the custom roadmaps you want to show
</h3>
{publicCustomRoadmaps.length > 0 ? (
<div className="mt-3 flex flex-wrap items-center gap-2">
{publicCustomRoadmaps.map((r) => (
<SelectionButton
type="button"
key={r.id}
text={r.title}
isDisabled={false}
isSelected={customRoadmaps.includes(r.id)}
onClick={() => {
if (customRoadmapVisibility !== 'selected') {
setCustomRoadmapVisibility('selected');
}
if (customRoadmaps.includes(r.id)) {
setCustomRoadmaps(
customRoadmaps.filter((id) => id !== r.id),
);
} else {
setCustomRoadmaps([...customRoadmaps, r.id]);
}
}}
/>
))}
</div>
) : (
<p className="mt-2 rounded-lg bg-yellow-100 p-2 text-sm text-yellow-700">
You do not have any custom roadmaps.{' '}
<button
type={'button'}
className="font-medium underline underline-offset-2 hover:text-yellow-800"
onClick={() => {
setIsCreatingRoadmap(true);
}}
>
Create one now
</button>
.
</p>
)}
</div>
<div className="flex w-full flex-col">
<label
htmlFor="github"
className="text-sm leading-none text-slate-500"
>
Github
</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-xs outline-hidden placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="https://github.com/username"
value={github}
onChange={(e) => setGithub((e.target as HTMLInputElement).value)}
/>
</div>
<div className="flex w-full flex-col">
<label
htmlFor="twitter"
className="text-sm leading-none text-slate-500"
>
Twitter
</label>
<input
type="text"
name="twitter"
id="twitter"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-xs outline-hidden placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="https://twitter.com/username"
value={twitter}
onChange={(e) => setTwitter((e.target as HTMLInputElement).value)}
/>
</div>
<div className="flex w-full flex-col">
<label
htmlFor="linkedin"
className="text-sm leading-none text-slate-500"
>
LinkedIn
</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-xs outline-hidden placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="https://www.linkedin.com/in/username/"
value={linkedin}
onChange={(e) => setLinkedin((e.target as HTMLInputElement).value)}
/>
</div>
<div className="flex w-full flex-col">
<label
htmlFor="dailydev"
className="text-sm leading-none text-slate-500"
>
daily.dev
</label>
<input
type="text"
name="dailydev"
id="dailydev"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-xs outline-hidden placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="https://app.daily.dev/username"
value={dailydev}
onChange={(e) => setDailydev((e.target as HTMLInputElement).value)}
/>
</div>
<div className="flex w-full flex-col">
<label
htmlFor="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-xs outline-hidden placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="https://example.com"
value={website}
onChange={(e) => setWebsite((e.target as HTMLInputElement).value)}
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex select-none items-center gap-2 rounded-md border px-3 hover:bg-gray-100">
<input
type="checkbox"
name="isAvailableForHire"
id="isAvailableForHire"
checked={isAvailableForHire}
onChange={(e) => setIsAvailableForHire(e.target.checked)}
/>
<label
htmlFor="isAvailableForHire"
className="grow cursor-pointer py-1.5"
>
Available for Hire
</label>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-hidden focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
>
{isLoading ? 'Please wait..' : 'Save Profile'}
</button>
{isProfileUpdated && publicProfileUrl && (
<div className="flex items-center gap-2">
<button
type="button"
className={cn(
'flex shrink-0 flex-row items-center gap-1 rounded-lg border border-black py-1.5 pl-2.5 pr-3.5 text-xs uppercase text-black transition-colors hover:bg-black hover:text-white',
isCopied
? 'border-green-600 bg-green-600 text-white hover:bg-green-600 hover:text-white'
: '',
)}
onClick={() => {
copyText(`${window.location.origin}${publicProfileUrl}`);
}}
>
{isCopied ? (
<>
<CheckCircle className="size-4" />
Copied Profile URL
</>
) : (
<>
<Copy className="size-4" />
Copy Profile URL
</>
)}
</button>
<a
className="flex shrink-0 flex-row items-center gap-1 rounded-lg border border-black py-1.5 pl-2.5 pr-3.5 text-xs uppercase text-black transition-colors hover:bg-black hover:text-white"
href={publicProfileUrl}
target="_blank"
>
<ArrowUpRight className="size-4" />
View Profile
</a>
</div>
)}
</form>
</div>
);
}

View File

@@ -1,225 +0,0 @@
import { type ChangeEvent, type FormEvent, useEffect, useRef, useState } from 'react';
import { TOKEN_COOKIE_NAME, removeAuthToken } from '../../lib/jwt';
interface PreviewFile extends File {
preview: string;
}
type UploadProfilePictureProps = {
isDisabled?: boolean;
avatarUrl: string;
type: 'avatar' | 'logo';
label?: string;
teamId?: string;
};
function getDimensions(file: File) {
return new Promise<{
width: number;
height: number;
}>((resolve) => {
const img = new Image();
img.onload = () => {
resolve({ width: img.width, height: img.height });
};
img.onerror = () => {
resolve({ width: 0, height: 0 });
};
img.src = URL.createObjectURL(file);
});
}
async function validateImage(file: File): Promise<string | null> {
const dimensions = await getDimensions(file);
if (dimensions.width > 3000 || dimensions.height > 3000) {
return 'Image dimensions are too big. Maximum 3000x3000 pixels.';
}
if (dimensions.width < 100 || dimensions.height < 100) {
return 'Image dimensions are too small. Minimum 100x100 pixels.';
}
if (file.size > 1024 * 1024) {
return 'Image size is too big. Maximum 1MB.';
}
return null;
}
export default function UploadProfilePicture(props: UploadProfilePictureProps) {
const { avatarUrl, teamId, type, isDisabled = false } = props;
const [file, setFile] = useState<PreviewFile | null>(null);
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const onImageChange = async (e: ChangeEvent<HTMLInputElement>) => {
setError('');
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) {
return;
}
const error = await validateImage(file);
if (error) {
setError(error);
return;
}
setFile(
Object.assign(file, {
preview: URL.createObjectURL(file),
})
);
};
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError('');
setIsLoading(true);
if (!file) {
return;
}
const formData = new FormData();
formData.append('name', 'avatar');
formData.append('avatar', file);
// FIXME: Use `httpCall` helper instead of fetch
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();
return;
}
const data = await res.json();
setError(data?.message || 'Something went wrong');
setIsLoading(false);
// Logout user if token is invalid
if (data.status === 401) {
removeAuthToken();
window.location.reload();
}
};
useEffect(() => {
// Necessary to revoke the preview URL when the component unmounts for avoiding memory leaks
return () => {
if (file) {
URL.revokeObjectURL(file.preview);
}
};
}, [file]);
return (
<form
onSubmit={handleSubmit}
encType="multipart/form-data"
className="flex flex-col gap-2"
>
{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"
title="Change profile picture"
className="relative cursor-pointer"
>
<div className="relative block h-24 w-24 items-center overflow-hidden rounded-full">
<img
className="absolute inset-0 h-full w-full bg-gray-100 object-cover text-sm leading-8 text-red-700"
src={file?.preview || avatarUrl}
alt={file?.name ?? 'Error!'}
loading="lazy"
decoding="async"
onLoad={() => file && URL.revokeObjectURL(file.preview)}
/>
</div>
{!file && !isDisabled && (
<button
disabled={isDisabled}
type="button"
className="absolute bottom-1 right-0 rounded-sm bg-gray-600 px-2 py-1 text-xs leading-none text-gray-50 ring-2 ring-white"
onClick={() => {
if (isLoading) return;
inputRef.current?.click();
}}
>
Edit
</button>
)}
</label>
<input
disabled={isDisabled}
ref={inputRef}
id="avatar"
type="file"
name="avatar"
accept="image/png, image/jpeg, image/jpg, image/pjpeg"
className="hidden"
onChange={onImageChange}
/>
{file && (
<div className="ml-5 flex items-center gap-2">
<button
type="button"
onClick={() => {
setFile(null);
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 || 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 || isDisabled}
>
{isLoading ? 'Uploading..' : 'Upload'}
</button>
</div>
)}
</div>
{error && (
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">{error}</p>
)}
</form>
);
}

View File

@@ -1,99 +0,0 @@
import { ChevronDown, Globe, LockIcon } from 'lucide-react';
import { type AllowedProfileVisibility } from '../../api/user.ts';
import { pageProgressMessage } from '../../stores/page.ts';
import { httpPatch } from '../../lib/http.ts';
import { useToast } from '../../hooks/use-toast.ts';
import { useRef, useState } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click.ts';
import { cn } from '../../lib/classname.ts';
type VisibilityDropdownProps = {
visibility: AllowedProfileVisibility;
setVisibility: (visibility: AllowedProfileVisibility) => void;
};
export function VisibilityDropdown(props: VisibilityDropdownProps) {
const { visibility, setVisibility } = props;
const toast = useToast();
const dropdownRef = useRef<HTMLDivElement>(null);
useOutsideClick(dropdownRef, () => {
setIsVisibilityDropdownOpen(false);
});
const [isVisibilityDropdownOpen, setIsVisibilityDropdownOpen] =
useState(false);
async function updateProfileVisibility(visibility: AllowedProfileVisibility) {
pageProgressMessage.set('Updating profile visibility');
setIsVisibilityDropdownOpen(false);
const { error } = await httpPatch(
`${import.meta.env.PUBLIC_API_URL}/v1-update-public-profile-visibility`,
{
profileVisibility: visibility,
},
);
if (error) {
toast.error(error.message || 'Something went wrong');
return;
}
pageProgressMessage.set('');
setVisibility(visibility);
}
return (
<div className="relative">
<button
onClick={() => {
setIsVisibilityDropdownOpen(true);
}}
className={cn(
'flex items-center gap-1 rounded-lg border border-black py-1 pl-1.5 pr-2 text-sm capitalize text-black',
{
invisible: isVisibilityDropdownOpen,
},
)}
>
{visibility === 'public' && <Globe className='mr-1' size={13} />}
{visibility === 'private' && <LockIcon className='mr-1' size={13} />}
{visibility}
<ChevronDown size={13} className="ml-1" />
</button>
{isVisibilityDropdownOpen && (
<div
className="absolute right-0 top-0 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg"
ref={dropdownRef}
>
<button
className={cn(
'flex w-full items-center gap-2 py-2.5 pl-3 pr-3.5 text-left text-sm hover:bg-gray-100',
{
'bg-gray-200': visibility === 'public',
},
)}
onClick={() => updateProfileVisibility('public')}
>
<Globe size={13} />
Public
</button>
<button
className={cn(
'flex w-full items-center gap-2 py-2.5 pl-3 pr-3.5 text-left text-sm hover:bg-gray-100',
{
'bg-gray-200': visibility === 'private',
},
)}
onClick={() => updateProfileVisibility('private')}
>
<LockIcon size={13} />
Private
</button>
</div>
)}
</div>
);
}

View File

@@ -1,16 +0,0 @@
---
import AccountSidebar from '../../components/AccountSidebar.astro';
import AccountLayout from '../../layouts/AccountLayout.astro';
import { BillingPage } from '../../components/Billing/BillingPage';
---
<AccountLayout
title='Billing'
description=''
noIndex={true}
initialLoadingMessage={'Loading billing details'}
>
<AccountSidebar activePageId='billing' activePageTitle='Billing'>
<BillingPage client:load />
</AccountSidebar>
</AccountLayout>

View File

@@ -1,16 +0,0 @@
---
import AccountSidebar from '../../components/AccountSidebar.astro';
import AccountLayout from '../../layouts/AccountLayout.astro';
import { FriendsPage } from '../../components/Friends/FriendsPage';
---
<AccountLayout
title='Friends'
noIndex={true}
initialLoadingMessage='Loading friends'
permalink="/account/friends"
>
<AccountSidebar activePageId='friends' activePageTitle='Friends'>
<FriendsPage client:only="react" />
</AccountSidebar>
</AccountLayout>

View File

@@ -1,16 +0,0 @@
---
import AccountSidebar from '../../components/AccountSidebar.astro';
import { ActivityPage } from '../../components/Activity/ActivityPage';
import AccountLayout from '../../layouts/AccountLayout.astro';
---
<AccountLayout
title='Activity'
noIndex={true}
initialLoadingMessage={'Loading activity'}
permalink="/account"
>
<AccountSidebar activePageId='activity' activePageTitle='Activity'>
<ActivityPage client:only='react' />
</AccountSidebar>
</AccountLayout>

View File

@@ -1,16 +0,0 @@
---
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>

View File

@@ -1,16 +0,0 @@
---
import AccountSidebar from '../../components/AccountSidebar.astro';
import AccountLayout from '../../layouts/AccountLayout.astro';
import { RoadCardPage } from '../../components/RoadCard/RoadCardPage';
---
<AccountLayout
title='Road Card'
noIndex={true}
initialLoadingMessage='Preparing card'
permalink="/account/road-card"
>
<AccountSidebar activePageId='road-card' activePageTitle='Road Card'>
<RoadCardPage client:only="react" />
</AccountSidebar>
</AccountLayout>

View File

@@ -1,16 +0,0 @@
---
import AccountSidebar from '../../components/AccountSidebar.astro';
import AccountLayout from '../../layouts/AccountLayout.astro';
import { RoadmapListPage } from '../../components/CustomRoadmap/RoadmapListPage';
---
<AccountLayout
title='Roadmaps'
noIndex={true}
initialLoadingMessage='Loading roadmaps'
permalink="/account/roadmaps"
>
<AccountSidebar activePageId='roadmaps' activePageTitle='Roadmaps'>
<RoadmapListPage client:only='react' />
</AccountSidebar>
</AccountLayout>

View File

@@ -1,20 +0,0 @@
---
import AccountSidebar from '../../components/AccountSidebar.astro';
import AccountLayout from '../../layouts/AccountLayout.astro';
import DeleteAccount from '../../components/DeleteAccount/DeleteAccount.astro';
import { ProfileSettingsPage } from '../../components/ProfileSettings/ProfileSettingsPage';
---
<AccountLayout
title='Settings'
description=''
noIndex={true}
initialLoadingMessage={'Loading settings'}
permalink="/account/settings"
>
<AccountSidebar activePageId='settings' activePageTitle='Settings'>
<ProfileSettingsPage client:load />
<hr class='my-8' />
<DeleteAccount />
</AccountSidebar>
</AccountLayout>

View File

@@ -1,16 +0,0 @@
---
import AccountSidebar from '../../components/AccountSidebar.astro';
import { UpdatePublicProfileForm } from '../../components/UpdateProfile/UpdatePublicProfileForm';
import AccountLayout from '../../layouts/AccountLayout.astro';
---
<AccountLayout
title='Update Profile'
noIndex={true}
initialLoadingMessage={'Loading profile'}
permalink="/account/update-profile"
>
<AccountSidebar activePageId='profile' activePageTitle='Profile'>
<UpdatePublicProfileForm client:load />
</AccountSidebar>
</AccountLayout>

View File

@@ -1,15 +0,0 @@
---
import { TeamSidebar } from '../../components/TeamSidebar';
import { TeamActivityPage } from '../../components/TeamActivity/TeamActivityPage';
import AccountLayout from '../../layouts/AccountLayout.astro';
---
<AccountLayout
title='Team Activity'
noIndex={true}
initialLoadingMessage='Loading activity'
>
<TeamSidebar activePageId='activity' client:load>
<TeamActivityPage client:only='react' />
</TeamSidebar>
</AccountLayout>

View File

@@ -1,69 +0,0 @@
---
import { DashboardPage } from '../../components/Dashboard/DashboardPage';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getAllBestPractices } from '../../lib/best-practice';
import { getRoadmapsByTag } from '../../lib/roadmap';
const roleRoadmaps = await getRoadmapsByTag('role-roadmap');
const skillRoadmaps = await getRoadmapsByTag('skill-roadmap');
const bestPractices = await getAllBestPractices();
const enrichedRoleRoadmaps = roleRoadmaps
.filter((roadmapItem) => !roadmapItem.frontmatter.isHidden)
.map((roadmap) => {
const { frontmatter } = roadmap;
return {
id: roadmap.id,
url: `/${roadmap.id}`,
title: frontmatter.briefTitle,
description: frontmatter.briefDescription,
relatedRoadmapIds: frontmatter.relatedRoadmaps,
renderer: frontmatter.renderer,
metadata: {
tags: frontmatter.tags,
},
};
});
const enrichedSkillRoadmaps = skillRoadmaps
.filter((roadmapItem) => !roadmapItem.frontmatter.isHidden)
.map((roadmap) => {
const { frontmatter } = roadmap;
return {
id: roadmap.id,
url: `/${roadmap.id}`,
title:
frontmatter.briefTitle === 'Go' ? 'Go Roadmap' : frontmatter.briefTitle,
description: frontmatter.briefDescription,
relatedRoadmapIds: frontmatter.relatedRoadmaps,
renderer: frontmatter.renderer,
metadata: {
tags: frontmatter.tags,
},
};
});
const enrichedBestPractices = bestPractices.map((bestPractice) => {
const { frontmatter } = bestPractice;
return {
id: bestPractice.id,
url: `/best-practices/${bestPractice.id}`,
title: frontmatter.briefTitle,
description: frontmatter.briefDescription,
};
});
---
<BaseLayout title='Team' noIndex={true} permalink="/team">
<DashboardPage
builtInRoleRoadmaps={enrichedRoleRoadmaps}
builtInSkillRoadmaps={enrichedSkillRoadmaps}
builtInBestPractices={enrichedBestPractices}
isTeamPage={true}
client:load
/>
<div slot='open-source-banner'></div>
<div slot="changelog-banner" />
</BaseLayout>

View File

@@ -1,15 +0,0 @@
---
import { TeamSidebar } from '../../components/TeamSidebar';
import AccountLayout from '../../layouts/AccountLayout.astro';
import { TeamMemberDetailsPage } from '../../components/TeamMemberDetails/TeamMemberDetailsPage';
---
<AccountLayout
title='Team Members'
noIndex={true}
initialLoadingMessage='Loading member'
>
<TeamSidebar activePageId='members' client:load>
<TeamMemberDetailsPage client:only='react' />
</TeamSidebar>
</AccountLayout>

View File

@@ -1,15 +0,0 @@
---
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="react" />
</TeamSidebar>
</AccountLayout>

View File

@@ -1,11 +0,0 @@
---
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="react" />
</AccountSidebar>
</AccountLayout>

View File

@@ -1,15 +0,0 @@
---
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='react' />
</TeamSidebar>
</AccountLayout>

View File

@@ -1,11 +0,0 @@
---
import { TeamSidebar } from '../../components/TeamSidebar';
import { TeamRoadmaps } from '../../components/TeamRoadmapsList/TeamRoadmaps';
import AccountLayout from '../../layouts/AccountLayout.astro';
---
<AccountLayout title='Roadmaps' noIndex={true} initialLoadingMessage='Loading Roadmaps'>
<TeamSidebar activePageId='roadmaps' client:load>
<TeamRoadmaps client:only="react" />
</TeamSidebar>
</AccountLayout>

View File

@@ -1,15 +0,0 @@
---
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>