parent
d95d30caf8
commit
5e48b4f7cd
9 changed files with 414 additions and 53 deletions
@ -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">·</span> |
||||||
|
<span> |
||||||
|
<span>{learning.length}</span> in progress |
||||||
|
</span> |
||||||
|
<span className="mx-1.5 text-gray-400">·</span> |
||||||
|
<span> |
||||||
|
<span>{skipped.length}</span> skipped |
||||||
|
</span> |
||||||
|
<span className="mx-1.5 text-gray-400">·</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> |
||||||
|
); |
||||||
|
} |
@ -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> |
Loading…
Reference in new issue