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: |
||||
workflow_dispatch: # allow manual run |
||||
push: |
||||
branches: [ master ] |
||||
env: |
||||
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 |
||||
branches: |
||||
- master |
||||
jobs: |
||||
build: |
||||
deploy: |
||||
runs-on: ubuntu-latest |
||||
steps: |
||||
- uses: actions/checkout@v4 |
||||
- name: Checkout code |
||||
uses: actions/checkout@v2 |
||||
with: |
||||
persist-credentials: false |
||||
fetch-depth: 2 |
||||
- uses: actions/setup-node@v1 |
||||
with: |
||||
node-version: 18 |
||||
- name: Prepare Draw Repository |
||||
run: | |
||||
git clone https://${{ secrets.GH_PAT }}@github.com/roadmapsh/web-draw.git .temp/web-draw --depth 1 |
||||
- uses: pnpm/action-setup@v2.2.2 |
||||
node-version: 20 |
||||
- uses: pnpm/action-setup@v3.0.0 |
||||
with: |
||||
version: 7.13.4 |
||||
- name: Setup Environment |
||||
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 meta and build |
||||
- 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 |
||||
touch ./dist/.nojekyll |
||||
echo 'roadmap.sh' > ./dist/CNAME |
||||
- name: Deploy to GH Pages |
||||
|
||||
# -------------------- |
||||
# 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: | |
||||
git config user.email "kamranahmed.se@gmail.com" |
||||
git config user.name "Kamran Ahmed" |
||||
git remote set-url origin https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git |
||||
npm run deploy |
||||
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