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