feat: profile pages, custom roadmap pages and SSR (#5494)
* Update * Add stats and health endpoints * Add pre-render * fix: redirect to the error page * Fix generate-renderer issue * Rename * Fix best practice topics not loading * Handle SSR for static pages * Refactor faqs * Refactor best practices * Fix absolute import * Fix stats * Add custom roadmap page * Minor UI change * feat: custom roadmap slug routes (#4987) * feat: replace roadmap slug * fix: remove roadmap slug * feat: username route * fix: user public page * feat: show roadmap progress * feat: update public profile * fix: replace with toast * feat: user public profile page * feat: implement profile form * feat: implement user profile roadmap page * refactor: remove logs * fix: increase progress gap * fix: remove title margin * fix: breakpoint for roadmaps * Update dependencies * Upgrade dependencies * fix: improper avatars * fix: heatmap focus * wip: remove `getStaticPaths` * fix: add disable props * wip * feat: add email icon * fix: update pnpm lock * fix: implement author page * Fix beginner roadmaps not working * Changes to form * Refactor profile and form * Refactor public profile form * Rearrange sidebar items * Update UI for public form * Minor text update * Refactor public profile form * Error page for user * Revamp UI for profile page * Add public profile page * Fix vite warnings * Add private profile banner * feat: on blur check username * Update fetch depth * Add error detail * Use hybrid mode of rendering * Do not pre-render stats pages * Update deployment workflow * Update deployment workflow --------- Co-authored-by: Arik Chakma <arikchangma@gmail.com>pull/5495/head
parent
b029eebd7b
commit
ad6002a514
67 changed files with 2365 additions and 399 deletions
@ -1,41 +1,74 @@ |
|||||||
name: App Deployment |
name: Deploy to EC2 |
||||||
on: |
on: |
||||||
|
workflow_dispatch: # allow manual run |
||||||
push: |
push: |
||||||
branches: [ master ] |
branches: |
||||||
env: |
- master |
||||||
PUBLIC_API_URL: "https://api.roadmap.sh" |
|
||||||
PUBLIC_EDITOR_APP_URL: "https://draw.roadmap.sh" |
|
||||||
PUBLIC_AVATAR_BASE_URL: "https://dodrc8eu8m09s.cloudfront.net/avatars" |
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
|
||||||
CI: true |
|
||||||
jobs: |
jobs: |
||||||
build: |
deploy: |
||||||
runs-on: ubuntu-latest |
runs-on: ubuntu-latest |
||||||
steps: |
steps: |
||||||
- uses: actions/checkout@v4 |
- name: Checkout code |
||||||
with: |
uses: actions/checkout@v2 |
||||||
persist-credentials: false |
with: |
||||||
- uses: actions/setup-node@v1 |
fetch-depth: 2 |
||||||
with: |
- uses: actions/setup-node@v1 |
||||||
node-version: 18 |
with: |
||||||
- name: Prepare Draw Repository |
node-version: 20 |
||||||
run: | |
- uses: pnpm/action-setup@v3.0.0 |
||||||
git clone https://${{ secrets.GH_PAT }}@github.com/roadmapsh/web-draw.git .temp/web-draw --depth 1 |
with: |
||||||
- uses: pnpm/action-setup@v2.2.2 |
version: 8.15.6 |
||||||
with: |
|
||||||
version: 7.13.4 |
# -------------------- |
||||||
- name: Setup Environment |
# Setup configuration |
||||||
run: | |
# -------------------- |
||||||
pnpm install |
- name: Prepare configuration files |
||||||
- name: Generate meta and build |
run: | |
||||||
run: | |
git clone https://${{ secrets.GH_PAT }}@github.com/roadmapsh/infra-config.git configuration --depth 1 |
||||||
npm run generate-renderer |
- name: Copy configuration files |
||||||
npm run build |
run: | |
||||||
touch ./dist/.nojekyll |
cp configuration/dist/github/developer-roadmap.env .env |
||||||
echo 'roadmap.sh' > ./dist/CNAME |
|
||||||
- name: Deploy to GH Pages |
# -------------------- |
||||||
run: | |
# Prepare the build |
||||||
git config user.email "kamranahmed.se@gmail.com" |
# -------------------- |
||||||
git config user.name "Kamran Ahmed" |
- name: Install dependencies |
||||||
git remote set-url origin https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git |
run: | |
||||||
npm run deploy |
pnpm install |
||||||
|
- name: Generate build |
||||||
|
run: | |
||||||
|
git clone https://${{ secrets.GH_PAT }}@github.com/roadmapsh/web-draw.git .temp/web-draw --depth 1 |
||||||
|
npm run generate-renderer |
||||||
|
npm run build |
||||||
|
|
||||||
|
# -------------------- |
||||||
|
# Deploy to EC2 |
||||||
|
# -------------------- |
||||||
|
- uses: webfactory/ssh-agent@v0.7.0 |
||||||
|
with: |
||||||
|
ssh-private-key: ${{ secrets.EC2_PRIVATE_KEY }} |
||||||
|
- name: Deploy app to EC2 |
||||||
|
run: | |
||||||
|
rsync -apvz --delete --no-times --exclude "configuration" -e "ssh -o StrictHostKeyChecking=no" -p ./ ${{ secrets.EC2_USERNAME }}@${{ secrets.EC2_HOST }}:/var/www/roadmap.sh/ |
||||||
|
- name: Restart PM2 |
||||||
|
uses: appleboy/ssh-action@master |
||||||
|
with: |
||||||
|
host: ${{ secrets.EC2_HOST }} |
||||||
|
username: ${{ secrets.EC2_USERNAME }} |
||||||
|
key: ${{ secrets.EC2_PRIVATE_KEY }} |
||||||
|
script: | |
||||||
|
cd /var/www/roadmap.sh |
||||||
|
sudo pm2 restart web-roadmap |
||||||
|
|
||||||
|
# -------------------- |
||||||
|
# Clear Cloudfront Caching |
||||||
|
# -------------------- |
||||||
|
- name: Clear Cloudfront Caching |
||||||
|
run: | |
||||||
|
curl -L \ |
||||||
|
-X POST \ |
||||||
|
-H "Accept: application/vnd.github+json" \ |
||||||
|
-H "Authorization: Bearer ${{ secrets.GH_PAT }}" \ |
||||||
|
-H "X-GitHub-Api-Version: 2022-11-28" \ |
||||||
|
https://api.github.com/repos/roadmapsh/infra-ansible/actions/workflows/playbook.yml/dispatches \ |
||||||
|
-d '{ "ref":"master", "inputs": { "playbook": "roadmap_web.yml", "tags": "cloudfront", "is_verbose": false } }' |
@ -1,72 +0,0 @@ |
|||||||
name: Deploy to EC2 |
|
||||||
on: |
|
||||||
workflow_dispatch: # allow manual run |
|
||||||
push: |
|
||||||
branches: |
|
||||||
- feat/ssr |
|
||||||
jobs: |
|
||||||
deploy: |
|
||||||
runs-on: ubuntu-latest |
|
||||||
steps: |
|
||||||
- name: Checkout code |
|
||||||
uses: actions/checkout@v2 |
|
||||||
- uses: actions/setup-node@v1 |
|
||||||
with: |
|
||||||
node-version: 20 |
|
||||||
- uses: pnpm/action-setup@v3.0.0 |
|
||||||
with: |
|
||||||
version: 8.15.6 |
|
||||||
|
|
||||||
# -------------------- |
|
||||||
# Setup configuration |
|
||||||
# -------------------- |
|
||||||
- name: Prepare configuration files |
|
||||||
run: | |
|
||||||
git clone https://${{ secrets.GH_PAT }}@github.com/roadmapsh/infra-config.git configuration --depth 1 |
|
||||||
- name: Copy configuration files |
|
||||||
run: | |
|
||||||
cp configuration/dist/github/developer-roadmap.env .env |
|
||||||
|
|
||||||
# -------------------- |
|
||||||
# Prepare the build |
|
||||||
# -------------------- |
|
||||||
- name: Install dependencies |
|
||||||
run: | |
|
||||||
pnpm install |
|
||||||
- name: Generate build |
|
||||||
run: | |
|
||||||
git clone https://${{ secrets.GH_PAT }}@github.com/roadmapsh/web-draw.git .temp/web-draw --depth 1 |
|
||||||
npm run generate-renderer |
|
||||||
npm run build |
|
||||||
|
|
||||||
# -------------------- |
|
||||||
# Deploy to EC2 |
|
||||||
# -------------------- |
|
||||||
- uses: webfactory/ssh-agent@v0.7.0 |
|
||||||
with: |
|
||||||
ssh-private-key: ${{ secrets.EC2_PRIVATE_KEY }} |
|
||||||
- name: Deploy app to EC2 |
|
||||||
run: | |
|
||||||
rsync -apvz --delete --no-times --exclude "configuration" -e "ssh -o StrictHostKeyChecking=no" -p ./ ${{ secrets.EC2_USERNAME }}@${{ secrets.EC2_HOST }}:/var/www/v2.roadmap.sh/ |
|
||||||
- name: Restart PM2 |
|
||||||
uses: appleboy/ssh-action@master |
|
||||||
with: |
|
||||||
host: ${{ secrets.EC2_HOST }} |
|
||||||
username: ${{ secrets.EC2_USERNAME }} |
|
||||||
key: ${{ secrets.EC2_PRIVATE_KEY }} |
|
||||||
script: | |
|
||||||
cd /var/www/v2.roadmap.sh |
|
||||||
sudo pm2 restart web-roadmap |
|
||||||
|
|
||||||
# -------------------- |
|
||||||
# Clear Cloudfront Caching |
|
||||||
# -------------------- |
|
||||||
- name: Clear Cloudfront Caching |
|
||||||
run: | |
|
||||||
curl -L \ |
|
||||||
-X POST \ |
|
||||||
-H "Accept: application/vnd.github+json" \ |
|
||||||
-H "Authorization: Bearer ${{ secrets.GH_PAT }}" \ |
|
||||||
-H "X-GitHub-Api-Version: 2022-11-28" \ |
|
||||||
https://api.github.com/repos/roadmapsh/infra-ansible/actions/workflows/playbook.yml/dispatches \ |
|
||||||
-d '{ "ref":"master", "inputs": { "playbook": "roadmap_web.yml", "tags": "cloudfront", "is_verbose": false } }' |
|
@ -0,0 +1,154 @@ |
|||||||
|
import Cookies from 'js-cookie'; |
||||||
|
import { TOKEN_COOKIE_NAME } from '../lib/jwt.ts'; |
||||||
|
import type { APIContext } from 'astro'; |
||||||
|
|
||||||
|
type HttpOptionsType = RequestInit | { headers: Record<string, any> }; |
||||||
|
|
||||||
|
type AppResponse = Record<string, any>; |
||||||
|
|
||||||
|
export type FetchError = { |
||||||
|
status: number; |
||||||
|
message: string; |
||||||
|
}; |
||||||
|
|
||||||
|
export type AppError = { |
||||||
|
status: number; |
||||||
|
message: string; |
||||||
|
errors?: { message: string; location: string }[]; |
||||||
|
}; |
||||||
|
|
||||||
|
export type ApiReturn<ResponseType, ErrorType> = { |
||||||
|
response?: ResponseType; |
||||||
|
error?: ErrorType | FetchError; |
||||||
|
}; |
||||||
|
|
||||||
|
export function api(context: APIContext) { |
||||||
|
const token = context.cookies.get(TOKEN_COOKIE_NAME)?.value; |
||||||
|
|
||||||
|
async function apiCall<ResponseType = AppResponse, ErrorType = AppError>( |
||||||
|
url: string, |
||||||
|
options?: HttpOptionsType, |
||||||
|
): Promise<ApiReturn<ResponseType, ErrorType>> { |
||||||
|
try { |
||||||
|
const response = await fetch(url, { |
||||||
|
credentials: 'include', |
||||||
|
...options, |
||||||
|
headers: new Headers({ |
||||||
|
'Content-Type': 'application/json', |
||||||
|
Accept: 'application/json', |
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}), |
||||||
|
...(options?.headers ?? {}), |
||||||
|
}), |
||||||
|
}); |
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const doesAcceptHtml = options?.headers?.['Accept'] === 'text/html'; |
||||||
|
|
||||||
|
const data = doesAcceptHtml |
||||||
|
? await response.text() |
||||||
|
: await response.json(); |
||||||
|
|
||||||
|
if (response.ok) { |
||||||
|
return { |
||||||
|
response: data as ResponseType, |
||||||
|
error: undefined, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
// Logout user if token is invalid
|
||||||
|
if (data.status === 401) { |
||||||
|
context.cookies.delete(TOKEN_COOKIE_NAME); |
||||||
|
context.redirect(context.request.url); |
||||||
|
|
||||||
|
return { response: undefined, error: data as ErrorType }; |
||||||
|
} |
||||||
|
|
||||||
|
if (data.status === 403) { |
||||||
|
return { response: undefined, error: data as ErrorType }; |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
response: undefined, |
||||||
|
error: data as ErrorType, |
||||||
|
}; |
||||||
|
} catch (error: any) { |
||||||
|
return { |
||||||
|
response: undefined, |
||||||
|
error: { |
||||||
|
status: 0, |
||||||
|
message: error.message, |
||||||
|
}, |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
get: function apiGet<ResponseType = AppResponse, ErrorType = AppError>( |
||||||
|
url: string, |
||||||
|
queryParams?: Record<string, any>, |
||||||
|
options?: HttpOptionsType, |
||||||
|
): Promise<ApiReturn<ResponseType, ErrorType>> { |
||||||
|
const searchParams = new URLSearchParams(queryParams).toString(); |
||||||
|
const queryUrl = searchParams ? `${url}?${searchParams}` : url; |
||||||
|
|
||||||
|
return apiCall<ResponseType, ErrorType>(queryUrl, { |
||||||
|
...options, |
||||||
|
method: 'GET', |
||||||
|
}); |
||||||
|
}, |
||||||
|
post: async function apiPost< |
||||||
|
ResponseType = AppResponse, |
||||||
|
ErrorType = AppError, |
||||||
|
>( |
||||||
|
url: string, |
||||||
|
body: Record<string, any>, |
||||||
|
options?: HttpOptionsType, |
||||||
|
): Promise<ApiReturn<ResponseType, ErrorType>> { |
||||||
|
return apiCall<ResponseType, ErrorType>(url, { |
||||||
|
...options, |
||||||
|
method: 'POST', |
||||||
|
body: JSON.stringify(body), |
||||||
|
}); |
||||||
|
}, |
||||||
|
patch: async function apiPatch< |
||||||
|
ResponseType = AppResponse, |
||||||
|
ErrorType = AppError, |
||||||
|
>( |
||||||
|
url: string, |
||||||
|
body: Record<string, any>, |
||||||
|
options?: HttpOptionsType, |
||||||
|
): Promise<ApiReturn<ResponseType, ErrorType>> { |
||||||
|
return apiCall<ResponseType, ErrorType>(url, { |
||||||
|
...options, |
||||||
|
method: 'PATCH', |
||||||
|
body: JSON.stringify(body), |
||||||
|
}); |
||||||
|
}, |
||||||
|
put: async function apiPut< |
||||||
|
ResponseType = AppResponse, |
||||||
|
ErrorType = AppError, |
||||||
|
>( |
||||||
|
url: string, |
||||||
|
body: Record<string, any>, |
||||||
|
options?: HttpOptionsType, |
||||||
|
): Promise<ApiReturn<ResponseType, ErrorType>> { |
||||||
|
return apiCall<ResponseType, ErrorType>(url, { |
||||||
|
...options, |
||||||
|
method: 'PUT', |
||||||
|
body: JSON.stringify(body), |
||||||
|
}); |
||||||
|
}, |
||||||
|
delete: async function apiDelete< |
||||||
|
ResponseType = AppResponse, |
||||||
|
ErrorType = AppError, |
||||||
|
>( |
||||||
|
url: string, |
||||||
|
options?: HttpOptionsType, |
||||||
|
): Promise<ApiReturn<ResponseType, ErrorType>> { |
||||||
|
return apiCall<ResponseType, ErrorType>(url, { |
||||||
|
...options, |
||||||
|
method: 'DELETE', |
||||||
|
}); |
||||||
|
}, |
||||||
|
}; |
||||||
|
} |
@ -0,0 +1,124 @@ |
|||||||
|
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 = |
||||||
|
(typeof allowedRoadmapVisibility)[number]; |
||||||
|
|
||||||
|
export const allowedCustomRoadmapVisibility = [ |
||||||
|
'all', |
||||||
|
'none', |
||||||
|
'selected', |
||||||
|
] as const; |
||||||
|
export type AllowedCustomRoadmapVisibility = |
||||||
|
(typeof allowedCustomRoadmapVisibility)[number]; |
||||||
|
|
||||||
|
export const allowedProfileVisibility = ['public', 'private'] as const; |
||||||
|
export type AllowedProfileVisibility = |
||||||
|
(typeof allowedProfileVisibility)[number]; |
||||||
|
|
||||||
|
export interface UserDocument { |
||||||
|
_id?: string; |
||||||
|
name: string; |
||||||
|
email: string; |
||||||
|
avatar?: string; |
||||||
|
password: string; |
||||||
|
isEnabled: boolean; |
||||||
|
authProvider: 'github' | 'google' | 'email' | 'linkedin'; |
||||||
|
metadata: Record<string, any>; |
||||||
|
calculatedStats: { |
||||||
|
activityCount: number; |
||||||
|
totalVisitCount: number; |
||||||
|
longestVisitStreak: number; |
||||||
|
currentVisitStreak: number; |
||||||
|
updatedAt: Date; |
||||||
|
}; |
||||||
|
verificationCode: string; |
||||||
|
resetPasswordCode: string; |
||||||
|
isSyncedWithSendy: boolean; |
||||||
|
links?: { |
||||||
|
github?: string; |
||||||
|
linkedin?: string; |
||||||
|
twitter?: string; |
||||||
|
website?: string; |
||||||
|
}; |
||||||
|
username?: string; |
||||||
|
profileVisibility: AllowedProfileVisibility; |
||||||
|
publicConfig?: { |
||||||
|
isAvailableForHire: boolean; |
||||||
|
isEmailVisible: boolean; |
||||||
|
headline: string; |
||||||
|
roadmaps: string[]; |
||||||
|
customRoadmaps: string[]; |
||||||
|
roadmapVisibility: AllowedRoadmapVisibility; |
||||||
|
customRoadmapVisibility: AllowedCustomRoadmapVisibility; |
||||||
|
}; |
||||||
|
resetPasswordCodeAt: string; |
||||||
|
verifiedAt: string; |
||||||
|
createdAt: string; |
||||||
|
updatedAt: string; |
||||||
|
} |
||||||
|
|
||||||
|
export type UserActivityCount = { |
||||||
|
activityCount: Record<string, number>; |
||||||
|
totalActivityCount: number; |
||||||
|
}; |
||||||
|
|
||||||
|
type ProgressResponse = { |
||||||
|
updatedAt: string; |
||||||
|
title: string; |
||||||
|
id: string; |
||||||
|
learning: number; |
||||||
|
skipped: number; |
||||||
|
done: number; |
||||||
|
total: number; |
||||||
|
isCustomResource?: boolean; |
||||||
|
roadmapSlug?: string; |
||||||
|
}; |
||||||
|
|
||||||
|
export type GetPublicProfileResponse = Omit< |
||||||
|
UserDocument, |
||||||
|
'password' | 'verificationCode' | 'resetPasswordCode' | 'resetPasswordCodeAt' |
||||||
|
> & { |
||||||
|
activity: UserActivityCount; |
||||||
|
roadmaps: ProgressResponse[]; |
||||||
|
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) { |
||||||
|
return api(context).get<GetPublicProfileResponse>( |
||||||
|
`${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, |
||||||
|
}, |
||||||
|
); |
||||||
|
}, |
||||||
|
}; |
||||||
|
} |
@ -0,0 +1,87 @@ |
|||||||
|
import { useState } from 'react'; |
||||||
|
import type { AllowedProfileVisibility } from '../../api/user'; |
||||||
|
import { httpGet, httpPost } from '../../lib/http'; |
||||||
|
import { useToast } from '../../hooks/use-toast'; |
||||||
|
import { CheckIcon, Loader2, X, XCircle } from 'lucide-react'; |
||||||
|
|
||||||
|
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 checkIsUnique = async (username: string) => { |
||||||
|
if (isLoading || username.length < 3) { |
||||||
|
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); |
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="flex w-full flex-col"> |
||||||
|
<label htmlFor="username" className="text-sm leading-none text-slate-500"> |
||||||
|
Username |
||||||
|
</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-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)} |
||||||
|
onBlur={(e) => checkIsUnique((e.target as HTMLInputElement).value)} |
||||||
|
required={profileVisibility === 'public'} |
||||||
|
/> |
||||||
|
|
||||||
|
<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> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,505 @@ |
|||||||
|
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, Eye, EyeOff } 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'; |
||||||
|
|
||||||
|
type RoadmapType = { |
||||||
|
id: string; |
||||||
|
title: string; |
||||||
|
isCustomResource: boolean; |
||||||
|
}; |
||||||
|
|
||||||
|
type GetProfileSettingsResponse = Pick< |
||||||
|
UserDocument, |
||||||
|
'username' | 'profileVisibility' | 'publicConfig' | 'links' |
||||||
|
>; |
||||||
|
|
||||||
|
export function UpdatePublicProfileForm() { |
||||||
|
const [profileVisibility, setProfileVisibility] = |
||||||
|
useState<AllowedProfileVisibility>('private'); |
||||||
|
|
||||||
|
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 [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 [github, setGithub] = useState(''); |
||||||
|
const [twitter, setTwitter] = useState(''); |
||||||
|
const [linkedin, setLinkedin] = useState(''); |
||||||
|
const [website, setWebsite] = useState(''); |
||||||
|
|
||||||
|
const [profileRoadmaps, setProfileRoadmaps] = useState<RoadmapType[]>([]); |
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false); |
||||||
|
|
||||||
|
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, |
||||||
|
}, |
||||||
|
); |
||||||
|
|
||||||
|
if (error || !response) { |
||||||
|
setIsLoading(false); |
||||||
|
toast.error(error?.message || 'Something went wrong'); |
||||||
|
|
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
await loadProfileSettings(); |
||||||
|
toast.success('Profile updated successfully'); |
||||||
|
}; |
||||||
|
|
||||||
|
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 { |
||||||
|
links, |
||||||
|
username, |
||||||
|
profileVisibility: defaultProfileVisibility, |
||||||
|
publicConfig, |
||||||
|
} = response; |
||||||
|
|
||||||
|
setPublicProfileUrl(username ? `/u/${username}` : ''); |
||||||
|
setUsername(username || ''); |
||||||
|
setCurrentUsername(username || ''); |
||||||
|
setGithub(links?.github || ''); |
||||||
|
setTwitter(links?.twitter || ''); |
||||||
|
setLinkedin(links?.linkedin || ''); |
||||||
|
setWebsite(links?.website || ''); |
||||||
|
setProfileVisibility(defaultProfileVisibility || 'private'); |
||||||
|
setHeadline(publicConfig?.headline || ''); |
||||||
|
setRoadmapVisibility(publicConfig?.roadmapVisibility || 'none'); |
||||||
|
setCustomRoadmapVisibility(publicConfig?.customRoadmapVisibility || 'none'); |
||||||
|
setCustomRoadmaps(publicConfig?.customRoadmaps || []); |
||||||
|
setRoadmaps(publicConfig?.roadmaps || []); |
||||||
|
setCustomRoadmapVisibility(publicConfig?.customRoadmapVisibility || 'none'); |
||||||
|
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); |
||||||
|
}; |
||||||
|
|
||||||
|
const updateProfileVisibility = async ( |
||||||
|
visibility: AllowedProfileVisibility, |
||||||
|
) => { |
||||||
|
pageProgressMessage.set('Updating profile visibility'); |
||||||
|
setIsLoading(true); |
||||||
|
|
||||||
|
const { error } = await httpPatch( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-update-public-profile-visibility`, |
||||||
|
{ |
||||||
|
profileVisibility: visibility, |
||||||
|
}, |
||||||
|
); |
||||||
|
|
||||||
|
if (error) { |
||||||
|
setIsLoading(false); |
||||||
|
toast.error(error.message || 'Something went wrong'); |
||||||
|
|
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
setProfileVisibility(visibility); |
||||||
|
setIsLoading(false); |
||||||
|
pageProgressMessage.set(''); |
||||||
|
}; |
||||||
|
|
||||||
|
// 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, |
||||||
|
); |
||||||
|
const publicRoadmaps = profileRoadmaps.filter((r) => !r.isCustomResource); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="-mx-10 mt-10 border-t px-10 pt-10"> |
||||||
|
{isCreatingRoadmap && ( |
||||||
|
<CreateRoadmapModal onClose={() => setIsCreatingRoadmap(false)} /> |
||||||
|
)} |
||||||
|
|
||||||
|
<div className="mb-1 flex flex-col justify-between gap-2 sm:flex-row"> |
||||||
|
<div className="flex flex-grow flex-col items-start gap-2 sm:flex-row"> |
||||||
|
<h3 className="mr-1 text-xl font-bold sm:text-3xl"> |
||||||
|
Personal Profile |
||||||
|
</h3> |
||||||
|
{publicProfileUrl && ( |
||||||
|
<a |
||||||
|
href={publicProfileUrl} |
||||||
|
target="_blank" |
||||||
|
className="flex h-[30px] shrink-0 flex-row items-center gap-1 rounded-lg border border-black pl-1.5 pr-2.5 text-sm transition-colors hover:bg-black hover:text-white" |
||||||
|
> |
||||||
|
<ArrowUpRight className="h-3 w-3 stroke-[3]" /> |
||||||
|
Visit |
||||||
|
</a> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
<VisibilityDropdown |
||||||
|
visibility={profileVisibility} |
||||||
|
setVisibility={setProfileVisibility} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<p className="mt-2 hidden text-sm text-gray-400 sm:mt-0 sm:block sm:text-base"> |
||||||
|
Set up your public profile to showcase your learning progress. |
||||||
|
</p> |
||||||
|
|
||||||
|
<form className="mt-6 space-y-4 pb-10" onSubmit={handleSubmit}> |
||||||
|
<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-sm outline-none 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-sm outline-none 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-sm outline-none 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-sm outline-none 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="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-sm outline-none 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-2 hover:bg-gray-100"> |
||||||
|
<input |
||||||
|
type="checkbox" |
||||||
|
name="isEmailVisible" |
||||||
|
id="isEmailVisible" |
||||||
|
checked={isEmailVisible} |
||||||
|
onChange={(e) => setIsEmailVisible(e.target.checked)} |
||||||
|
/> |
||||||
|
<label |
||||||
|
htmlFor="isEmailVisible" |
||||||
|
className="flex-grow cursor-pointer py-1.5" |
||||||
|
> |
||||||
|
Make my email public |
||||||
|
</label> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="flex select-none items-center gap-2 rounded-md border px-2 hover:bg-gray-100"> |
||||||
|
<input |
||||||
|
type="checkbox" |
||||||
|
name="isAvailableForHire" |
||||||
|
id="isAvailableForHire" |
||||||
|
checked={isAvailableForHire} |
||||||
|
onChange={(e) => setIsAvailableForHire(e.target.checked)} |
||||||
|
/> |
||||||
|
<label |
||||||
|
htmlFor="isAvailableForHire" |
||||||
|
className="flex-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-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400" |
||||||
|
> |
||||||
|
{isLoading ? 'Please wait...' : 'Update Public Profile'} |
||||||
|
</button> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,99 @@ |
|||||||
|
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> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,22 @@ |
|||||||
|
import type { GetPublicProfileResponse } from '../../api/user'; |
||||||
|
import { Lock } from 'lucide-react'; |
||||||
|
|
||||||
|
type PrivateProfileBannerProps = Pick< |
||||||
|
GetPublicProfileResponse, |
||||||
|
'isOwnProfile' | 'profileVisibility' |
||||||
|
>; |
||||||
|
|
||||||
|
export function PrivateProfileBanner(props: PrivateProfileBannerProps) { |
||||||
|
const { isOwnProfile, profileVisibility } = props; |
||||||
|
|
||||||
|
if (isOwnProfile && profileVisibility === 'private') { |
||||||
|
return ( |
||||||
|
<div className="-mb-4 -mt-5 rounded-lg border border-yellow-400 bg-yellow-100 p-2 text-center text-sm font-medium"> |
||||||
|
<Lock className="-mt-1 mr-1.5 inline-block h-4 w-4" /> |
||||||
|
Your profile is private. Only you can see this page. |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
@ -0,0 +1,109 @@ |
|||||||
|
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; |
||||||
|
|
||||||
|
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: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,110 @@ |
|||||||
|
import CalendarHeatmap from 'react-calendar-heatmap'; |
||||||
|
import { Tooltip as ReactTooltip } from 'react-tooltip'; |
||||||
|
import 'react-calendar-heatmap/dist/styles.css'; |
||||||
|
import 'react-tooltip/dist/react-tooltip.css'; |
||||||
|
import { formatActivityDate, formatMonthDate } from '../../lib/date'; |
||||||
|
import type { UserActivityCount } from '../../api/user'; |
||||||
|
import dayjs from 'dayjs'; |
||||||
|
|
||||||
|
type UserActivityHeatmapProps = { |
||||||
|
activity: UserActivityCount; |
||||||
|
joinedAt: string; |
||||||
|
}; |
||||||
|
|
||||||
|
const legends = [ |
||||||
|
{ count: '1-2', color: 'bg-gray-200' }, |
||||||
|
{ count: '3-4', color: 'bg-gray-300' }, |
||||||
|
{ count: '5-9', color: 'bg-gray-500' }, |
||||||
|
{ count: '10-19', color: 'bg-gray-600' }, |
||||||
|
{ count: '20+', color: 'bg-gray-800' }, |
||||||
|
]; |
||||||
|
|
||||||
|
export function UserActivityHeatmap(props: UserActivityHeatmapProps) { |
||||||
|
const { activity } = props; |
||||||
|
const data = Object.entries(activity.activityCount).map(([date, count]) => ({ |
||||||
|
date, |
||||||
|
count, |
||||||
|
})); |
||||||
|
|
||||||
|
const startDate = dayjs().subtract(1, 'year').toDate(); |
||||||
|
const endDate = dayjs().toDate(); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="rounded-lg border bg-white p-4"> |
||||||
|
<div className="-mx-4 mb-8 flex justify-between border-b px-4 pb-3"> |
||||||
|
<div className=""> |
||||||
|
<h2 className="mb-0.5 font-semibold">Activity</h2> |
||||||
|
<p className="text-sm text-gray-500"> |
||||||
|
Progress updates over the past year |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
<span className="text-sm text-gray-400"> |
||||||
|
Member since: {formatMonthDate(props.joinedAt)} |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
<CalendarHeatmap |
||||||
|
startDate={startDate} |
||||||
|
endDate={endDate} |
||||||
|
values={data} |
||||||
|
classForValue={(value) => { |
||||||
|
if (!value) { |
||||||
|
return 'fill-gray-100 rounded-md [rx:2px] focus:outline-none'; |
||||||
|
} |
||||||
|
|
||||||
|
const { count } = value; |
||||||
|
if (count >= 20) { |
||||||
|
return 'fill-gray-800 rounded-md [rx:2px] focus:outline-none'; |
||||||
|
} else if (count >= 10) { |
||||||
|
return 'fill-gray-600 rounded-md [rx:2px] focus:outline-none'; |
||||||
|
} else if (count >= 5) { |
||||||
|
return 'fill-gray-500 rounded-md [rx:2px] focus:outline-none'; |
||||||
|
} else if (count >= 3) { |
||||||
|
return 'fill-gray-300 rounded-md [rx:2px] focus:outline-none'; |
||||||
|
} else { |
||||||
|
return 'fill-gray-200 rounded-md [rx:2px] focus:outline-none'; |
||||||
|
} |
||||||
|
}} |
||||||
|
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-gray-900 !p-1 !px-2 !text-sm" |
||||||
|
/> |
||||||
|
|
||||||
|
<div className="mt-4 flex items-center justify-between"> |
||||||
|
<span className="text-sm text-gray-400"> |
||||||
|
Number of topics marked as learning, or completed by day |
||||||
|
</span> |
||||||
|
<div className="flex items-center"> |
||||||
|
<span className="mr-2 text-xs text-gray-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-3 w-3 ${legend.color} mr-1 rounded-sm`}></div> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
<span className="ml-2 text-xs text-gray-500">More</span> |
||||||
|
<ReactTooltip |
||||||
|
id="user-activity-tip" |
||||||
|
className="!rounded-lg !bg-gray-900 !p-1 !px-2 !text-sm" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,65 @@ |
|||||||
|
import { Github, Globe, LinkedinIcon, Mail, Twitter } from 'lucide-react'; |
||||||
|
import type { GetPublicProfileResponse } from '../../api/user'; |
||||||
|
|
||||||
|
type UserPublicProfileHeaderProps = { |
||||||
|
userDetails: GetPublicProfileResponse; |
||||||
|
}; |
||||||
|
|
||||||
|
export function UserPublicProfileHeader(props: UserPublicProfileHeaderProps) { |
||||||
|
const { userDetails } = props; |
||||||
|
|
||||||
|
const { name, links, publicConfig, avatar, email } = userDetails; |
||||||
|
const { headline, isAvailableForHire, isEmailVisible } = publicConfig!; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="flex items-center gap-6 container bg-white border p-8 rounded-xl"> |
||||||
|
<img |
||||||
|
src={ |
||||||
|
avatar |
||||||
|
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}` |
||||||
|
: '/images/default-avatar.png' |
||||||
|
} |
||||||
|
alt={name} |
||||||
|
className="h-32 w-32 rounded-full" |
||||||
|
/> |
||||||
|
|
||||||
|
<div> |
||||||
|
{isAvailableForHire && ( |
||||||
|
<span className="mb-1 inline-block rounded-md bg-green-100 px-2 py-1 text-sm text-green-700"> |
||||||
|
Available for hire |
||||||
|
</span> |
||||||
|
)} |
||||||
|
<h1 className="text-3xl font-bold">{name}</h1> |
||||||
|
<p className="mt-1 text-base text-gray-500">{headline}</p> |
||||||
|
<div className="mt-3 flex items-center gap-2"> |
||||||
|
{links?.github && <UserLink href={links?.github} icon={Github} />} |
||||||
|
{links?.linkedin && ( |
||||||
|
<UserLink href={links?.linkedin} icon={LinkedinIcon} /> |
||||||
|
)} |
||||||
|
{links?.twitter && <UserLink href={links?.twitter} icon={Twitter} />} |
||||||
|
{links?.website && <UserLink href={links?.website} icon={Globe} />} |
||||||
|
{isEmailVisible && <UserLink href={`mailto:${email}`} icon={Mail} />} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
type UserLinkProps = { |
||||||
|
href: string; |
||||||
|
icon: typeof Github; |
||||||
|
}; |
||||||
|
|
||||||
|
export function UserLink(props: UserLinkProps) { |
||||||
|
const { href, icon: Icon } = props; |
||||||
|
|
||||||
|
return ( |
||||||
|
<a |
||||||
|
target="_blank" |
||||||
|
href={href} |
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-md border" |
||||||
|
> |
||||||
|
<Icon className="h-3.5 w-3.5 shrink-0 stroke-2" /> |
||||||
|
</a> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,39 @@ |
|||||||
|
import type { GetPublicProfileResponse } from '../../api/user'; |
||||||
|
import { PrivateProfileBanner } from './PrivateProfileBanner'; |
||||||
|
import { UserActivityHeatmap } from './UserPublicActivityHeatmap'; |
||||||
|
import { UserPublicProfileHeader } from './UserPublicProfileHeader'; |
||||||
|
import { UserPublicProgresses } from './UserPublicProgresses'; |
||||||
|
|
||||||
|
type UserPublicProfilePageProps = GetPublicProfileResponse; |
||||||
|
|
||||||
|
export function UserPublicProfilePage(props: UserPublicProfilePageProps) { |
||||||
|
const { |
||||||
|
activity, |
||||||
|
username, |
||||||
|
isOwnProfile, |
||||||
|
profileVisibility, |
||||||
|
_id: userId, |
||||||
|
createdAt, |
||||||
|
} = props; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="bg-gray-200/40 min-h-full flex-grow pt-10 pb-36"> |
||||||
|
<div className="container flex flex-col gap-8"> |
||||||
|
<PrivateProfileBanner |
||||||
|
isOwnProfile={isOwnProfile} |
||||||
|
profileVisibility={profileVisibility} |
||||||
|
/> |
||||||
|
|
||||||
|
<UserPublicProfileHeader userDetails={props!} /> |
||||||
|
|
||||||
|
<UserActivityHeatmap joinedAt={createdAt} activity={activity!} /> |
||||||
|
<UserPublicProgresses |
||||||
|
username={username!} |
||||||
|
userId={userId!} |
||||||
|
roadmaps={props.roadmaps} |
||||||
|
publicConfig={props.publicConfig} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,70 @@ |
|||||||
|
import { getPercentage } from '../../helper/number'; |
||||||
|
import { getRelativeTimeString } from '../../lib/date'; |
||||||
|
|
||||||
|
type UserPublicProgressStats = { |
||||||
|
resourceType: 'roadmap'; |
||||||
|
resourceId: string; |
||||||
|
title: string; |
||||||
|
updatedAt: string; |
||||||
|
totalCount: number; |
||||||
|
doneCount: number; |
||||||
|
learningCount: number; |
||||||
|
skippedCount: number; |
||||||
|
showClearButton?: boolean; |
||||||
|
isCustomResource?: boolean; |
||||||
|
roadmapSlug?: string; |
||||||
|
username: string; |
||||||
|
userId: string; |
||||||
|
}; |
||||||
|
|
||||||
|
export function UserPublicProgressStats(props: UserPublicProgressStats) { |
||||||
|
const { |
||||||
|
updatedAt, |
||||||
|
resourceId, |
||||||
|
title, |
||||||
|
totalCount, |
||||||
|
learningCount, |
||||||
|
doneCount, |
||||||
|
skippedCount, |
||||||
|
roadmapSlug, |
||||||
|
isCustomResource = false, |
||||||
|
username, |
||||||
|
userId, |
||||||
|
} = props; |
||||||
|
|
||||||
|
// Currently we only support roadmap not (best-practices)
|
||||||
|
const url = isCustomResource |
||||||
|
? `/r/${roadmapSlug}` |
||||||
|
: `/${resourceId}?s=${userId}`; |
||||||
|
const totalMarked = doneCount + skippedCount; |
||||||
|
const progressPercentage = getPercentage(totalMarked, totalCount); |
||||||
|
|
||||||
|
return ( |
||||||
|
<a |
||||||
|
href={url} |
||||||
|
target="_blank" |
||||||
|
className="group block rounded-md border p-2.5" |
||||||
|
> |
||||||
|
<h3 className="flex-1 cursor-pointer truncate text-lg font-medium"> |
||||||
|
{title} |
||||||
|
</h3> |
||||||
|
<div className="relative mt-5 h-1 w-full overflow-hidden rounded-full bg-black/5"> |
||||||
|
<div |
||||||
|
className={`absolute left-0 top-0 h-full bg-black/40`} |
||||||
|
style={{ |
||||||
|
width: `${progressPercentage}%`, |
||||||
|
}} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="mt-2 flex items-center justify-between gap-2"> |
||||||
|
<span className="text-sm text-gray-600"> |
||||||
|
{progressPercentage}% completed |
||||||
|
</span> |
||||||
|
<span className="text-sm text-gray-400"> |
||||||
|
Last updated {getRelativeTimeString(updatedAt)} |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
</a> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,112 @@ |
|||||||
|
import type { GetPublicProfileResponse } from '../../api/user'; |
||||||
|
import { UserPublicProgressStats } from './UserPublicProgressStats'; |
||||||
|
import { getPercentage } from '../../helper/number.ts'; |
||||||
|
|
||||||
|
type UserPublicProgressesProps = { |
||||||
|
userId: string; |
||||||
|
username: string; |
||||||
|
roadmaps: GetPublicProfileResponse['roadmaps']; |
||||||
|
publicConfig: GetPublicProfileResponse['publicConfig']; |
||||||
|
}; |
||||||
|
|
||||||
|
export function UserPublicProgresses(props: UserPublicProgressesProps) { |
||||||
|
const { |
||||||
|
roadmaps: roadmapProgresses = [], |
||||||
|
username, |
||||||
|
publicConfig, |
||||||
|
userId, |
||||||
|
} = props; |
||||||
|
const { roadmapVisibility, customRoadmapVisibility } = publicConfig! || {}; |
||||||
|
|
||||||
|
const roadmaps = roadmapProgresses.filter( |
||||||
|
(roadmap) => !roadmap.isCustomResource, |
||||||
|
); |
||||||
|
const customRoadmaps = roadmapProgresses.filter( |
||||||
|
(roadmap) => roadmap.isCustomResource, |
||||||
|
); |
||||||
|
|
||||||
|
// <UserPublicProgressStats
|
||||||
|
// updatedAt={roadmap.updatedAt}
|
||||||
|
// title={roadmap.title}
|
||||||
|
// totalCount={roadmap.total}
|
||||||
|
// doneCount={roadmap.done}
|
||||||
|
// learningCount={roadmap.learning}
|
||||||
|
// skippedCount={roadmap.skipped}
|
||||||
|
// resourceId={roadmap.id}
|
||||||
|
// resourceType="roadmap"
|
||||||
|
// roadmapSlug={roadmap.roadmapSlug}
|
||||||
|
// username={username!}
|
||||||
|
// isCustomResource={true}
|
||||||
|
// userId={userId}
|
||||||
|
// />
|
||||||
|
|
||||||
|
return ( |
||||||
|
<div> |
||||||
|
{customRoadmapVisibility !== 'none' && customRoadmaps?.length > 0 && ( |
||||||
|
<div className="mb-5"> |
||||||
|
<h2 className="mb-2 text-xs uppercase tracking-wide text-gray-400"> |
||||||
|
Roadmaps made by me |
||||||
|
</h2> |
||||||
|
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2 md:grid-cols-3"> |
||||||
|
{customRoadmaps.map((roadmap, counter) => { |
||||||
|
const doneCount = roadmap.done; |
||||||
|
const skippedCount = roadmap.skipped; |
||||||
|
const totalCount = roadmap.total; |
||||||
|
|
||||||
|
const totalMarked = doneCount + skippedCount; |
||||||
|
const progressPercentage = getPercentage(totalMarked, totalCount); |
||||||
|
|
||||||
|
return ( |
||||||
|
<a |
||||||
|
target="_blank" |
||||||
|
href={`/r/${roadmap.roadmapSlug}`} |
||||||
|
key={roadmap.id + counter} |
||||||
|
className="rounded-md border bg-white px-3 py-2 text-left text-sm shadow-sm transition-all hover:border-gray-300 hover:bg-gray-50" |
||||||
|
> |
||||||
|
{roadmap.title} |
||||||
|
</a> |
||||||
|
); |
||||||
|
})} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
{roadmapVisibility !== 'none' && roadmaps.length > 0 && ( |
||||||
|
<> |
||||||
|
<h2 className="mb-2 text-xs uppercase tracking-wide text-gray-400"> |
||||||
|
Skills I have mastered |
||||||
|
</h2> |
||||||
|
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2 md:grid-cols-3"> |
||||||
|
{roadmaps.map((roadmap, counter) => { |
||||||
|
const percentageDone = getPercentage( |
||||||
|
roadmap.done + roadmap.skipped, |
||||||
|
roadmap.total, |
||||||
|
); |
||||||
|
|
||||||
|
return ( |
||||||
|
<a |
||||||
|
target="_blank" |
||||||
|
key={roadmap.id + counter} |
||||||
|
href={`/${roadmap.id}?s=${userId}`} |
||||||
|
className="relative group border-gray-300 flex items-center justify-between rounded-md border bg-white px-3 py-2 text-left text-sm transition-all hover:border-gray-400 overflow-hidden" |
||||||
|
> |
||||||
|
<span className="flex-grow truncate">{roadmap.title}</span> |
||||||
|
<span className="text-xs text-gray-400"> |
||||||
|
{parseInt(percentageDone, 10)}% |
||||||
|
</span> |
||||||
|
|
||||||
|
<span |
||||||
|
className="absolute transition-colors left-0 top-0 block h-full cursor-pointer rounded-tl-md bg-black/5 group-hover:bg-black/10" |
||||||
|
style={{ |
||||||
|
width: `${percentageDone}%`, |
||||||
|
}} |
||||||
|
></span> |
||||||
|
</a> |
||||||
|
); |
||||||
|
})} |
||||||
|
</div> |
||||||
|
</> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,9 @@ |
|||||||
|
export function getPercentage(portion: number, total: number): string { |
||||||
|
if (total <= 0 || portion <= 0) { |
||||||
|
return '0'; |
||||||
|
} else if (portion > total) { |
||||||
|
return '100'; |
||||||
|
} |
||||||
|
|
||||||
|
return ((portion / total) * 100).toFixed(2); |
||||||
|
} |
@ -0,0 +1,26 @@ |
|||||||
|
--- |
||||||
|
import BaseLayout from '../../layouts/BaseLayout.astro'; |
||||||
|
import { CustomRoadmap } from '../../components/CustomRoadmap/CustomRoadmap'; |
||||||
|
import { SkeletonRoadmapHeader } from '../../components/CustomRoadmap/SkeletonRoadmapHeader'; |
||||||
|
import Loader from '../../components/Loader.astro'; |
||||||
|
import ProgressHelpPopup from '../../components/ProgressHelpPopup.astro'; |
||||||
|
|
||||||
|
export const prerender = false; |
||||||
|
|
||||||
|
const { customRoadmapSlug } = Astro.params; |
||||||
|
--- |
||||||
|
|
||||||
|
<BaseLayout title='Roadmaps'> |
||||||
|
<ProgressHelpPopup /> |
||||||
|
<div> |
||||||
|
<div class='flex min-h-[550px] flex-col'> |
||||||
|
<div data-roadmap-loader class='flex w-full grow flex-col'> |
||||||
|
<SkeletonRoadmapHeader /> |
||||||
|
<div class='flex grow items-center justify-center'> |
||||||
|
<Loader /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<CustomRoadmap slug={customRoadmapSlug} client:only='react' /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</BaseLayout> |
@ -0,0 +1,61 @@ |
|||||||
|
--- |
||||||
|
import { FrownIcon } from 'lucide-react'; |
||||||
|
import { userApi } from '../../api/user'; |
||||||
|
import AccountLayout from '../../layouts/AccountLayout.astro'; |
||||||
|
import { UserPublicProfilePage } from '../../components/UserPublicProfile/UserPublicProfilePage'; |
||||||
|
import OpenSourceBanner from '../../components/OpenSourceBanner.astro'; |
||||||
|
import Footer from '../../components/Footer.astro'; |
||||||
|
|
||||||
|
export const prerender = false; |
||||||
|
|
||||||
|
interface Params extends Record<string, string | undefined> { |
||||||
|
username: string; |
||||||
|
} |
||||||
|
|
||||||
|
const { username } = Astro.params as Params; |
||||||
|
if (!username) { |
||||||
|
return Astro.redirect('/404'); |
||||||
|
} |
||||||
|
|
||||||
|
const userClient = userApi(Astro as any); |
||||||
|
const { response: userDetails, error } = |
||||||
|
await userClient.getPublicProfile(username); |
||||||
|
|
||||||
|
let errorMessage = ''; |
||||||
|
if (error || !userDetails) { |
||||||
|
errorMessage = error?.message || 'User not found'; |
||||||
|
} |
||||||
|
--- |
||||||
|
|
||||||
|
<AccountLayout title={userDetails?.name} errorMessage={errorMessage}> |
||||||
|
{!errorMessage && <UserPublicProfilePage {...userDetails} client:load />} |
||||||
|
{ |
||||||
|
errorMessage && ( |
||||||
|
<div class='container my-24 flex flex-col'> |
||||||
|
<picture> |
||||||
|
<source |
||||||
|
srcset='https://fonts.gstatic.com/s/e/notoemoji/latest/1f61e/512.webp' |
||||||
|
type='image/webp' |
||||||
|
/> |
||||||
|
<img |
||||||
|
src='https://fonts.gstatic.com/s/e/notoemoji/latest/1f61e/512.gif' |
||||||
|
alt='😞' |
||||||
|
width='120' |
||||||
|
height='120' |
||||||
|
/> |
||||||
|
</picture> |
||||||
|
<h2 class='my-2 text-2xl font-bold sm:my-3 sm:text-4xl'> |
||||||
|
Problem loading user! |
||||||
|
</h2> |
||||||
|
<p class='text-lg'> |
||||||
|
<span class='rounded-md bg-red-600 px-2 py-1 text-white'> |
||||||
|
{errorMessage} |
||||||
|
</span> |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
<OpenSourceBanner /> |
||||||
|
<Footer /> |
||||||
|
</AccountLayout> |
@ -0,0 +1,7 @@ |
|||||||
|
import { execSync } from 'child_process'; |
||||||
|
|
||||||
|
export const prerender = false; |
||||||
|
|
||||||
|
export async function GET() { |
||||||
|
return new Response(JSON.stringify({}), {}); |
||||||
|
} |
@ -0,0 +1,33 @@ |
|||||||
|
import { execSync } from 'child_process'; |
||||||
|
|
||||||
|
export const prerender = false; |
||||||
|
|
||||||
|
export async function GET() { |
||||||
|
const commitHash = execSync('git rev-parse HEAD').toString().trim(); |
||||||
|
const commitDate = execSync('git log -1 --format=%cd').toString().trim(); |
||||||
|
const commitMessage = execSync('git log -1 --format=%B').toString().trim(); |
||||||
|
|
||||||
|
const prevCommitHash = execSync('git rev-parse HEAD~1').toString().trim(); |
||||||
|
const prevCommitDate = execSync('git log -1 --format=%cd HEAD~1') |
||||||
|
.toString() |
||||||
|
.trim(); |
||||||
|
const prevCommitMessage = execSync('git log -1 --format=%B HEAD~1') |
||||||
|
.toString() |
||||||
|
.trim(); |
||||||
|
|
||||||
|
return new Response( |
||||||
|
JSON.stringify({ |
||||||
|
current: { |
||||||
|
hash: commitHash, |
||||||
|
date: commitDate, |
||||||
|
message: commitMessage, |
||||||
|
}, |
||||||
|
previous: { |
||||||
|
hash: prevCommitHash, |
||||||
|
date: prevCommitDate, |
||||||
|
message: prevCommitMessage, |
||||||
|
}, |
||||||
|
}), |
||||||
|
{}, |
||||||
|
); |
||||||
|
} |
Loading…
Reference in new issue