feat: implement user profile roadmap page

pull/5494/head
Arik Chakma 9 months ago
parent d95d30caf8
commit 5e48b4f7cd
  1. 28
      src/api/user.ts
  2. 6
      src/components/UpdateProfile/UpdatePublicProfileForm.tsx
  3. 20
      src/components/UserPublicProfile/PrivateProfileBanner.tsx
  4. 113
      src/components/UserPublicProfile/UserProfileRoadmap.tsx
  5. 146
      src/components/UserPublicProfile/UserProfileRoadmapRenderer.tsx
  6. 14
      src/components/UserPublicProfile/UserPublicProfilePage.tsx
  7. 16
      src/components/UserPublicProfile/UserPublicProgresses.tsx
  8. 42
      src/pages/u/[username]/[roadmapId]/index.astro
  9. 6
      src/pages/u/[username]/index.astro

@ -1,5 +1,6 @@
import { type APIContext } from 'astro'; import { type APIContext } from 'astro';
import { api } from './api.ts'; import { api } from './api.ts';
import type { ResourceType } from '../lib/resource-progress.ts';
export const allowedRoadmapVisibility = ['all', 'none', 'selected'] as const; export const allowedRoadmapVisibility = ['all', 'none', 'selected'] as const;
export type AllowedRoadmapVisibility = export type AllowedRoadmapVisibility =
@ -88,6 +89,18 @@ export type GetPublicProfileResponse = Omit<
isOwnProfile: boolean; isOwnProfile: boolean;
}; };
export type GetUserProfileRoadmapResponse = {
title: string;
topicCount: number;
roadmapSlug?: string;
isCustomResource?: boolean;
done: string[];
learning: string[];
skipped: string[];
nodes: any[];
edges: any[];
};
export function userApi(context: APIContext) { export function userApi(context: APIContext) {
return { return {
getPublicProfile: async function (username: string) { getPublicProfile: async function (username: string) {
@ -95,5 +108,20 @@ export function userApi(context: APIContext) {
`${import.meta.env.PUBLIC_API_URL}/v1-get-public-profile/${username}`, `${import.meta.env.PUBLIC_API_URL}/v1-get-public-profile/${username}`,
); );
}, },
getUserProfileRoadmap: async function (
username: string,
resourceId: string,
resourceType: ResourceType = 'roadmap',
) {
return api(context).get<GetUserProfileRoadmapResponse>(
`${
import.meta.env.PUBLIC_API_URL
}/v1-get-user-profile-roadmap/${username}`,
{
resourceId,
resourceType,
},
);
},
}; };
} }

@ -186,7 +186,7 @@ export function UpdatePublicProfileForm() {
<div className="mt-16 flex items-center justify-between gap-2"> <div className="mt-16 flex items-center justify-between gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="text-3xl font-bold">Public Profile</h3> <h3 className="text-3xl font-bold">Public Profile</h3>
{profileVisibility === 'public' && publicProfileUrl && ( {publicProfileUrl && (
<a href={publicProfileUrl} target="_blank" className="shrink-0"> <a href={publicProfileUrl} target="_blank" className="shrink-0">
<ArrowUpRight className="h-6 w-6 stroke-[3]" /> <ArrowUpRight className="h-6 w-6 stroke-[3]" />
</a> </a>
@ -247,7 +247,9 @@ export function UpdatePublicProfileForm() {
id="username" id="username"
className="w-full px-3 py-2 outline-none placeholder:text-gray-400" className="w-full px-3 py-2 outline-none placeholder:text-gray-400"
placeholder="johndoe" placeholder="johndoe"
spellCheck={false}
value={username} value={username}
title="Username must be at least 3 characters long and can only contain letters, numbers, and underscores"
onChange={(e) => onChange={(e) =>
setUsername((e.target as HTMLInputElement).value) setUsername((e.target as HTMLInputElement).value)
} }
@ -266,7 +268,7 @@ export function UpdatePublicProfileForm() {
isSelected={isAllRoadmapsSelected} isSelected={isAllRoadmapsSelected}
onClick={() => { onClick={() => {
setRoadmapVisibility('all'); setRoadmapVisibility('all');
setRoadmaps([...profileRoadmaps.map((r) => r.id)]); setRoadmaps([...publicRoadmaps.map((r) => r.id)]);
}} }}
/> />
<SelectionButton <SelectionButton

@ -0,0 +1,20 @@
import type { GetPublicProfileResponse } from '../../api/user';
type PrivateProfileBannerProps = Pick<
GetPublicProfileResponse,
'isOwnProfile' | 'profileVisibility'
>;
export function PrivateProfileBanner(props: PrivateProfileBannerProps) {
const { isOwnProfile, profileVisibility } = props;
if (isOwnProfile && profileVisibility === 'private') {
return (
<div className="border-b border-yellow-400 bg-yellow-100 p-2 text-center text-sm font-medium">
Your profile is private. Only you can see this page.
</div>
);
}
return null;
}

@ -0,0 +1,113 @@
import type {
GetUserProfileRoadmapResponse,
GetPublicProfileResponse,
} from '../../api/user';
import { getPercentage } from '../../helper/number';
import { PrivateProfileBanner } from './PrivateProfileBanner';
import { UserProfileRoadmapRenderer } from './UserProfileRoadmapRenderer';
type UserProfileRoadmapProps = GetUserProfileRoadmapResponse &
Pick<
GetPublicProfileResponse,
'username' | 'name' | 'isOwnProfile' | 'profileVisibility'
> & {
resourceId: string;
};
export function UserProfileRoadmap(props: UserProfileRoadmapProps) {
const {
username,
name,
title,
resourceId,
isCustomResource,
done = [],
skipped = [],
learning = [],
topicCount,
isOwnProfile,
profileVisibility,
} = props;
console.log('UserProfileRoadmap', props);
const trackProgressRoadmapUrl = isCustomResource
? `/r/${resourceId}`
: `/${resourceId}`;
const totalMarked = done.length + skipped.length;
const progressPercentage = getPercentage(totalMarked, topicCount);
return (
<>
<PrivateProfileBanner
isOwnProfile={isOwnProfile}
profileVisibility={profileVisibility}
/>
<div className="container mt-5">
<div className="flex items-center justify-between gap-2">
<p className="flex items-center gap-1 text-sm">
<a
href={`/u/${username}`}
className="text-gray-600 hover:text-gray-800"
>
{username}
</a>
<span>/</span>
<a
href={`/u/${username}/${resourceId}`}
className="text-gray-600 hover:text-gray-800"
>
{resourceId}
</a>
</p>
<a
href={trackProgressRoadmapUrl}
className="rounded-md border px-2.5 py-1 text-sm font-medium"
>
Track your Progress
</a>
</div>
<h2 className="mt-10 text-2xl font-bold sm:mb-2 sm:text-4xl">
{title}
</h2>
<p className="mt-2 text-sm text-gray-500 sm:text-lg">
Skills {name} has mastered on the {title?.toLowerCase()}.
</p>
</div>
<div className="relative z-50 mt-10 hidden items-center justify-between border-y bg-white px-2 py-1.5 sm:flex">
<p className="container flex text-sm">
<span className="mr-2.5 rounded-sm bg-yellow-200 px-1 py-0.5 text-xs font-medium uppercase text-yellow-900">
<span data-progress-percentage="">{progressPercentage}</span>% Done
</span>
<span className="itesm-center hidden md:flex">
<span>
<span>{done.length}</span> completed
</span>
<span className="mx-1.5 text-gray-400">&middot;</span>
<span>
<span>{learning.length}</span> in progress
</span>
<span className="mx-1.5 text-gray-400">&middot;</span>
<span>
<span>{skipped.length}</span> skipped
</span>
<span className="mx-1.5 text-gray-400">&middot;</span>
<span>
<span>{topicCount}</span> Total
</span>
</span>
<span className="md:hidden">
<span>{totalMarked}</span> of <span>{topicCount}</span> Done
</span>
</p>
</div>
<UserProfileRoadmapRenderer {...props} resourceType="roadmap" />
</>
);
}

@ -0,0 +1,146 @@
import { useEffect, useRef, useState, type RefObject } from 'react';
import '../FrameRenderer/FrameRenderer.css';
import { Spinner } from '../ReactIcons/Spinner';
import {
renderTopicProgress,
topicSelectorAll,
} from '../../lib/resource-progress';
import { useToast } from '../../hooks/use-toast';
import { replaceChildren } from '../../lib/dom.ts';
import type { GetUserProfileRoadmapResponse } from '../../api/user.ts';
import { ReadonlyEditor } from '../../../editor/readonly-editor.tsx';
import { cn } from '../../lib/classname.ts';
export type UserProfileRoadmapRendererProps = GetUserProfileRoadmapResponse & {
resourceId: string;
resourceType: 'roadmap' | 'best-practice';
};
export function UserProfileRoadmapRenderer(
props: UserProfileRoadmapRendererProps,
) {
const {
resourceId,
resourceType,
done,
skipped,
learning,
edges,
nodes,
isCustomResource,
} = props;
const containerEl = useRef<HTMLDivElement>(null);
const [isLoading, setIsLoading] = useState(!isCustomResource);
const toast = useToast();
let resourceJsonUrl = 'https://roadmap.sh';
if (resourceType === 'roadmap') {
resourceJsonUrl += `/${resourceId}.json`;
} else {
resourceJsonUrl += `/best-practices/${resourceId}.json`;
}
async function renderResource(jsonUrl: string) {
const res = await fetch(jsonUrl, {});
const json = await res.json();
const { wireframeJSONToSVG } = await import('roadmap-renderer');
const svg: SVGElement | null = await wireframeJSONToSVG(json, {
fontURL: '/fonts/balsamiq.woff2',
});
replaceChildren(containerEl.current!, svg);
}
useEffect(() => {
if (
!containerEl.current ||
!resourceJsonUrl ||
!resourceId ||
!resourceType ||
isCustomResource
) {
return;
}
setIsLoading(true);
renderResource(resourceJsonUrl)
.then(() => {
done.forEach((id: string) => renderTopicProgress(id, 'done'));
learning.forEach((id: string) => renderTopicProgress(id, 'learning'));
skipped.forEach((id: string) => renderTopicProgress(id, 'skipped'));
setIsLoading(false);
})
.catch((err) => {
console.error(err);
toast.error(err?.message || 'Something went wrong. Please try again!');
})
.finally(() => {
setIsLoading(false);
});
}, []);
return (
<div id="customized-roadmap">
<div
className={cn(
'bg-white',
isCustomResource ? 'w-full' : 'container relative !max-w-[1000px]',
)}
>
{isCustomResource ? (
<ReadonlyEditor
roadmap={{
nodes,
edges,
}}
className="min-h-[1000px]"
onRendered={(wrapperRef: RefObject<HTMLDivElement>) => {
done?.forEach((topicId: string) => {
topicSelectorAll(topicId, wrapperRef?.current!).forEach(
(el) => {
el.classList.add('done');
},
);
});
learning?.forEach((topicId: string) => {
topicSelectorAll(topicId, wrapperRef?.current!).forEach(
(el) => {
el.classList.add('learning');
},
);
});
skipped?.forEach((topicId: string) => {
topicSelectorAll(topicId, wrapperRef?.current!).forEach(
(el) => {
el.classList.add('skipped');
},
);
});
}}
fontFamily="Balsamiq Sans"
fontURL="/fonts/balsamiq.woff2"
/>
) : (
<div
id={'resource-svg-wrap'}
ref={containerEl}
className="pointer-events-none px-4 pb-2"
/>
)}
{isLoading && (
<div className="flex w-full justify-center">
<Spinner
isDualRing={false}
className="mb-4 mt-2 h-4 w-4 animate-spin fill-blue-600 text-gray-200 sm:h-8 sm:w-8"
/>
</div>
)}
</div>
</div>
);
}

@ -1,4 +1,5 @@
import type { GetPublicProfileResponse } from '../../api/user'; import type { GetPublicProfileResponse } from '../../api/user';
import { PrivateProfileBanner } from './PrivateProfileBanner';
import { UserActivityHeatmap } from './UserPublicActivityHeatmap'; import { UserActivityHeatmap } from './UserPublicActivityHeatmap';
import { UserPublicProfileHeader } from './UserPublicProfileHeader'; import { UserPublicProfileHeader } from './UserPublicProfileHeader';
import { UserPublicProgresses } from './UserPublicProgresses'; import { UserPublicProgresses } from './UserPublicProgresses';
@ -10,15 +11,10 @@ export function UserPublicProfilePage(props: UserPublicProfilePageProps) {
return ( return (
<> <>
{isOwnProfile && ( <PrivateProfileBanner
<div className="border-b border-yellow-400 bg-yellow-100 p-2 text-center text-sm font-medium"> isOwnProfile={isOwnProfile}
Only you can see this, you can update your profile from{' '} profileVisibility={profileVisibility}
<a href="/account/update-profile" className="underline"> />
here
</a>
.
</div>
)}
<section className="container mt-5 pb-10"> <section className="container mt-5 pb-10">
<UserPublicProfileHeader userDetails={props!} /> <UserPublicProfileHeader userDetails={props!} />
<div className="mt-10"> <div className="mt-10">

@ -1,6 +1,4 @@
import { useState } from 'react';
import type { GetPublicProfileResponse } from '../../api/user'; import type { GetPublicProfileResponse } from '../../api/user';
import { SelectionButton } from '../RoadCard/SelectionButton';
import { UserPublicProgressStats } from './UserPublicProgressStats'; import { UserPublicProgressStats } from './UserPublicProgressStats';
type UserPublicProgressesProps = { type UserPublicProgressesProps = {
@ -10,7 +8,7 @@ type UserPublicProgressesProps = {
}; };
export function UserPublicProgresses(props: UserPublicProgressesProps) { export function UserPublicProgresses(props: UserPublicProgressesProps) {
const { roadmaps: roadmapProgresses, username, publicConfig } = props; const { roadmaps: roadmapProgresses = [], username, publicConfig } = props;
const { roadmapVisibility, customRoadmapVisibility } = publicConfig! || {}; const { roadmapVisibility, customRoadmapVisibility } = publicConfig! || {};
const roadmaps = roadmapProgresses.filter( const roadmaps = roadmapProgresses.filter(
@ -25,6 +23,11 @@ export function UserPublicProgresses(props: UserPublicProgressesProps) {
{roadmapVisibility !== 'none' && ( {roadmapVisibility !== 'none' && (
<> <>
<h2 className="text-xs uppercase text-gray-400">My Skills</h2> <h2 className="text-xs uppercase text-gray-400">My Skills</h2>
{roadmaps?.length === 0 ? (
<div className="mt-4 text-sm text-gray-500">
No skills added yet.
</div>
) : (
<ul className="mt-4 grid grid-cols-2 gap-2"> <ul className="mt-4 grid grid-cols-2 gap-2">
{roadmaps.map((roadmap, counter) => ( {roadmaps.map((roadmap, counter) => (
<li key={roadmap.id + counter}> <li key={roadmap.id + counter}>
@ -44,12 +47,18 @@ export function UserPublicProgresses(props: UserPublicProgressesProps) {
</li> </li>
))} ))}
</ul> </ul>
)}
</> </>
)} )}
{customRoadmapVisibility !== 'none' && ( {customRoadmapVisibility !== 'none' && (
<> <>
<h2 className="mt-6 text-xs uppercase text-gray-400">My Roadmaps</h2> <h2 className="mt-6 text-xs uppercase text-gray-400">My Roadmaps</h2>
{customRoadmaps?.length === 0 ? (
<div className="mt-4 text-sm text-gray-500">
No roadmaps added yet.
</div>
) : (
<ul className="mt-4 grid grid-cols-2 gap-2"> <ul className="mt-4 grid grid-cols-2 gap-2">
{customRoadmaps.map((roadmap, counter) => ( {customRoadmaps.map((roadmap, counter) => (
<li key={roadmap.id + counter}> <li key={roadmap.id + counter}>
@ -69,6 +78,7 @@ export function UserPublicProgresses(props: UserPublicProgressesProps) {
</li> </li>
))} ))}
</ul> </ul>
)}
</> </>
)} )}
</div> </div>

@ -0,0 +1,42 @@
---
import { userApi } from '../../../../api/user';
import AccountLayout from '../../../../layouts/AccountLayout.astro';
import { UserProfileRoadmap } from '../../../../components/UserPublicProfile/UserProfileRoadmap';
interface Params extends Record<string, string | undefined> {
username: string;
roadmapId: string;
}
const { username, roadmapId } = Astro.params as Params;
if (!username) {
return Astro.redirect('/404');
}
const userClient = userApi(Astro as any);
const { response: userDetails, error } =
await userClient.getPublicProfile(username);
if (error || !userDetails) {
return Astro.redirect('/404');
}
const { response: roadmapDetails, error: progressError } =
await userClient.getUserProfileRoadmap(username, roadmapId);
if (progressError || !roadmapDetails) {
return Astro.redirect('/404');
}
---
<AccountLayout title={`${roadmapDetails?.title} | ${userDetails?.name}`}>
<UserProfileRoadmap
{...roadmapDetails}
username={username}
name={userDetails?.name}
resourceId={roadmapId}
isOwnProfile={userDetails?.isOwnProfile}
profileVisibility={userDetails?.profileVisibility}
client:load
/>
</AccountLayout>

@ -3,7 +3,11 @@ import { userApi } from '../../../api/user';
import AccountLayout from '../../../layouts/AccountLayout.astro'; import AccountLayout from '../../../layouts/AccountLayout.astro';
import { UserPublicProfilePage } from '../../../components/UserPublicProfile/UserPublicProfilePage'; import { UserPublicProfilePage } from '../../../components/UserPublicProfile/UserPublicProfilePage';
const { username } = Astro.params; interface Params extends Record<string, string | undefined> {
username: string;
}
const { username } = Astro.params as Params;
if (!username) { if (!username) {
return Astro.redirect('/404'); return Astro.redirect('/404');
} }

Loading…
Cancel
Save