diff --git a/src/api/user.ts b/src/api/user.ts index be6fb600f..3fc3f7198 100644 --- a/src/api/user.ts +++ b/src/api/user.ts @@ -1,5 +1,6 @@ import { type APIContext } from 'astro'; import { api } from './api.ts'; +import type { ResourceType } from '../lib/resource-progress.ts'; export const allowedRoadmapVisibility = ['all', 'none', 'selected'] as const; export type AllowedRoadmapVisibility = @@ -88,6 +89,18 @@ export type GetPublicProfileResponse = Omit< 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) { return { 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}`, ); }, + getUserProfileRoadmap: async function ( + username: string, + resourceId: string, + resourceType: ResourceType = 'roadmap', + ) { + return api(context).get( + `${ + import.meta.env.PUBLIC_API_URL + }/v1-get-user-profile-roadmap/${username}`, + { + resourceId, + resourceType, + }, + ); + }, }; } diff --git a/src/components/UpdateProfile/UpdatePublicProfileForm.tsx b/src/components/UpdateProfile/UpdatePublicProfileForm.tsx index 231d9fd2d..acd0e366a 100644 --- a/src/components/UpdateProfile/UpdatePublicProfileForm.tsx +++ b/src/components/UpdateProfile/UpdatePublicProfileForm.tsx @@ -186,7 +186,7 @@ export function UpdatePublicProfileForm() {

Public Profile

- {profileVisibility === 'public' && publicProfileUrl && ( + {publicProfileUrl && ( @@ -247,7 +247,9 @@ export function UpdatePublicProfileForm() { id="username" className="w-full px-3 py-2 outline-none 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" onChange={(e) => setUsername((e.target as HTMLInputElement).value) } @@ -266,7 +268,7 @@ export function UpdatePublicProfileForm() { isSelected={isAllRoadmapsSelected} onClick={() => { setRoadmapVisibility('all'); - setRoadmaps([...profileRoadmaps.map((r) => r.id)]); + setRoadmaps([...publicRoadmaps.map((r) => r.id)]); }} /> ; + +export function PrivateProfileBanner(props: PrivateProfileBannerProps) { + const { isOwnProfile, profileVisibility } = props; + + if (isOwnProfile && profileVisibility === 'private') { + return ( +
+ Your profile is private. Only you can see this page. +
+ ); + } + + return null; +} diff --git a/src/components/UserPublicProfile/UserProfileRoadmap.tsx b/src/components/UserPublicProfile/UserProfileRoadmap.tsx new file mode 100644 index 000000000..ee61c8917 --- /dev/null +++ b/src/components/UserPublicProfile/UserProfileRoadmap.tsx @@ -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 ( + <> + +
+ + +

+ {title} +

+

+ Skills {name} has mastered on the {title?.toLowerCase()}. +

+
+ +
+

+ + {progressPercentage}% Done + + + + + {done.length} completed + + · + + {learning.length} in progress + + · + + {skipped.length} skipped + + · + + {topicCount} Total + + + + {totalMarked} of {topicCount} Done + +

+
+ + + + ); +} diff --git a/src/components/UserPublicProfile/UserProfileRoadmapRenderer.tsx b/src/components/UserPublicProfile/UserProfileRoadmapRenderer.tsx new file mode 100644 index 000000000..aa6103f83 --- /dev/null +++ b/src/components/UserPublicProfile/UserProfileRoadmapRenderer.tsx @@ -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(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 ( +
+
+ {isCustomResource ? ( + ) => { + 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" + /> + ) : ( +
+ )} + + {isLoading && ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/components/UserPublicProfile/UserPublicProfilePage.tsx b/src/components/UserPublicProfile/UserPublicProfilePage.tsx index 4c3c45ad7..45245ec09 100644 --- a/src/components/UserPublicProfile/UserPublicProfilePage.tsx +++ b/src/components/UserPublicProfile/UserPublicProfilePage.tsx @@ -1,4 +1,5 @@ import type { GetPublicProfileResponse } from '../../api/user'; +import { PrivateProfileBanner } from './PrivateProfileBanner'; import { UserActivityHeatmap } from './UserPublicActivityHeatmap'; import { UserPublicProfileHeader } from './UserPublicProfileHeader'; import { UserPublicProgresses } from './UserPublicProgresses'; @@ -10,15 +11,10 @@ export function UserPublicProfilePage(props: UserPublicProfilePageProps) { return ( <> - {isOwnProfile && ( -
- Only you can see this, you can update your profile from{' '} - - here - - . -
- )} +
diff --git a/src/components/UserPublicProfile/UserPublicProgresses.tsx b/src/components/UserPublicProfile/UserPublicProgresses.tsx index 3058b0c64..f6a767bf0 100644 --- a/src/components/UserPublicProfile/UserPublicProgresses.tsx +++ b/src/components/UserPublicProfile/UserPublicProgresses.tsx @@ -1,6 +1,4 @@ -import { useState } from 'react'; import type { GetPublicProfileResponse } from '../../api/user'; -import { SelectionButton } from '../RoadCard/SelectionButton'; import { UserPublicProgressStats } from './UserPublicProgressStats'; type UserPublicProgressesProps = { @@ -10,7 +8,7 @@ type UserPublicProgressesProps = { }; export function UserPublicProgresses(props: UserPublicProgressesProps) { - const { roadmaps: roadmapProgresses, username, publicConfig } = props; + const { roadmaps: roadmapProgresses = [], username, publicConfig } = props; const { roadmapVisibility, customRoadmapVisibility } = publicConfig! || {}; const roadmaps = roadmapProgresses.filter( @@ -25,50 +23,62 @@ export function UserPublicProgresses(props: UserPublicProgressesProps) { {roadmapVisibility !== 'none' && ( <>

My Skills

-
    - {roadmaps.map((roadmap, counter) => ( -
  • - -
  • - ))} -
+ {roadmaps?.length === 0 ? ( +
+ No skills added yet. +
+ ) : ( +
    + {roadmaps.map((roadmap, counter) => ( +
  • + +
  • + ))} +
+ )} )} {customRoadmapVisibility !== 'none' && ( <>

My Roadmaps

-
    - {customRoadmaps.map((roadmap, counter) => ( -
  • - -
  • - ))} -
+ {customRoadmaps?.length === 0 ? ( +
+ No roadmaps added yet. +
+ ) : ( +
    + {customRoadmaps.map((roadmap, counter) => ( +
  • + +
  • + ))} +
+ )} )}
diff --git a/src/pages/u/[username]/[roadmapId]/index.astro b/src/pages/u/[username]/[roadmapId]/index.astro new file mode 100644 index 000000000..a5a37d1e7 --- /dev/null +++ b/src/pages/u/[username]/[roadmapId]/index.astro @@ -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 { + 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'); +} +--- + + + + diff --git a/src/pages/u/[username]/index.astro b/src/pages/u/[username]/index.astro index 8721a8677..e96280c70 100644 --- a/src/pages/u/[username]/index.astro +++ b/src/pages/u/[username]/index.astro @@ -3,7 +3,11 @@ import { userApi } from '../../../api/user'; import AccountLayout from '../../../layouts/AccountLayout.astro'; import { UserPublicProfilePage } from '../../../components/UserPublicProfile/UserPublicProfilePage'; -const { username } = Astro.params; +interface Params extends Record { + username: string; +} + +const { username } = Astro.params as Params; if (!username) { return Astro.redirect('/404'); }