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:
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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 || []} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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"> / </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>
|
||||
);
|
||||
}
|
@@ -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>
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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(() => {});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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={`[](https://roadmap.sh)`.trim()}
|
||||
onCopy={() => markRoadCardDone()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<GitHubReadmeBanner />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -1,3 +0,0 @@
|
||||
export function CustomTeamRoadmap() {
|
||||
return null;
|
||||
}
|
@@ -1,3 +0,0 @@
|
||||
export function DefaultTeamRoadmap() {
|
||||
return null;
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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">·</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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
@@ -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();
|
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
@@ -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>
|
Reference in New Issue
Block a user