600 lines
20 KiB
TypeScript
600 lines
20 KiB
TypeScript
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,
|
|
Check,
|
|
CheckCircle,
|
|
Copy,
|
|
Eye,
|
|
EyeOff,
|
|
FileBadge,
|
|
Trophy,
|
|
} 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';
|
|
import UploadProfilePicture from './UploadProfilePicture.tsx';
|
|
import { SkillProfileAlert } from './SkillProfileAlert.tsx';
|
|
import { useCopyText } from '../../hooks/use-copy-text.ts';
|
|
import { cn } from '../../lib/classname.ts';
|
|
|
|
type RoadmapType = {
|
|
id: string;
|
|
title: string;
|
|
isCustomResource: boolean;
|
|
};
|
|
|
|
type GetProfileSettingsResponse = Pick<
|
|
UserDocument,
|
|
'username' | 'profileVisibility' | 'publicConfig' | 'links'
|
|
>;
|
|
|
|
export function UpdatePublicProfileForm() {
|
|
const [profileVisibility, setProfileVisibility] =
|
|
useState<AllowedProfileVisibility>('public');
|
|
|
|
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 [email, setEmail] = 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 [name, setName] = useState('');
|
|
|
|
const [avatar, setAvatar] = useState('');
|
|
const [github, setGithub] = useState('');
|
|
const [twitter, setTwitter] = useState('');
|
|
const [linkedin, setLinkedin] = useState('');
|
|
const [dailydev, setDailydev] = useState('');
|
|
const [website, setWebsite] = useState('');
|
|
|
|
const [profileRoadmaps, setProfileRoadmaps] = useState<RoadmapType[]>([]);
|
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
const { isCopied, copyText } = useCopyText();
|
|
|
|
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,
|
|
name,
|
|
email,
|
|
dailydev,
|
|
},
|
|
);
|
|
|
|
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 {
|
|
name,
|
|
email,
|
|
links,
|
|
username,
|
|
profileVisibility: defaultProfileVisibility,
|
|
publicConfig,
|
|
avatar,
|
|
} = response;
|
|
|
|
setAvatar(avatar || '');
|
|
setPublicProfileUrl(username ? `/u/${username}` : '');
|
|
setUsername(username || '');
|
|
setCurrentUsername(username || '');
|
|
setName(name || '');
|
|
setEmail(email || '');
|
|
setGithub(links?.github || '');
|
|
setTwitter(links?.twitter || '');
|
|
setLinkedin(links?.linkedin || '');
|
|
setDailydev(links?.dailydev || '');
|
|
setWebsite(links?.website || '');
|
|
setProfileVisibility(defaultProfileVisibility || 'public');
|
|
setHeadline(publicConfig?.headline || '');
|
|
setRoadmapVisibility(publicConfig?.roadmapVisibility || 'all');
|
|
setCustomRoadmapVisibility(publicConfig?.customRoadmapVisibility || 'all');
|
|
setCustomRoadmaps(publicConfig?.customRoadmaps || []);
|
|
setRoadmaps(publicConfig?.roadmaps || []);
|
|
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);
|
|
};
|
|
|
|
// 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>
|
|
{isCreatingRoadmap && (
|
|
<CreateRoadmapModal onClose={() => setIsCreatingRoadmap(false)} />
|
|
)}
|
|
|
|
<SkillProfileAlert />
|
|
|
|
<div className="mb-8 flex flex-col justify-between gap-2 sm:mb-1 sm:flex-row">
|
|
<div className="flex flex-grow flex-row items-center gap-2 sm:items-center">
|
|
<h3 className="mr-1 text-xl font-bold sm:text-3xl">Skill Profile</h3>
|
|
{publicProfileUrl && (
|
|
<>
|
|
<a
|
|
href={publicProfileUrl}
|
|
target="_blank"
|
|
className="flex shrink-0 flex-row items-center gap-1 rounded-lg border border-black py-0.5 pl-1.5 pr-2.5 text-xs uppercase transition-colors hover:bg-black hover:text-white"
|
|
>
|
|
<ArrowUpRight className="h-3 w-3 stroke-[3]" />
|
|
Visit
|
|
</a>
|
|
<button
|
|
onClick={() => {
|
|
copyText(`${window.location.origin}${publicProfileUrl}`);
|
|
}}
|
|
className={cn(
|
|
'flex shrink-0 flex-row items-center gap-1 rounded-lg border border-black py-0.5 pl-1.5 pr-2.5 text-xs uppercase transition-colors hover:bg-black hover:text-white',
|
|
{
|
|
'bg-black text-white': isCopied,
|
|
},
|
|
)}
|
|
>
|
|
{!isCopied && <Copy className="h-3 w-3 stroke-[2.5]" />}
|
|
{isCopied && <Check className="h-3 w-3 stroke-[2.5]" />}
|
|
{!isCopied ? 'Copy URL' : 'Copied!'}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
<VisibilityDropdown
|
|
visibility={profileVisibility}
|
|
setVisibility={setProfileVisibility}
|
|
/>
|
|
</div>
|
|
<p className="mb-8 mt-2 hidden text-sm text-gray-400 sm:mt-0 sm:block sm:text-base">
|
|
Create your skill profile to showcase your skills.
|
|
</p>
|
|
|
|
<UploadProfilePicture
|
|
type="avatar"
|
|
label="Profile picture"
|
|
avatarUrl={
|
|
avatar
|
|
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}`
|
|
: '/images/default-avatar.png'
|
|
}
|
|
/>
|
|
|
|
<form className="mt-6 space-y-4 pb-10" onSubmit={handleSubmit}>
|
|
<div className="flex w-full flex-col">
|
|
<label
|
|
htmlFor="name"
|
|
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
|
|
>
|
|
Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
name="name"
|
|
id="name"
|
|
className="mt-2 block w-full appearance-none 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"
|
|
required
|
|
placeholder="John Doe"
|
|
value={name}
|
|
onInput={(e) => setName((e.target as HTMLInputElement).value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex w-full flex-col">
|
|
<div className="flex items-center justify-between">
|
|
<label
|
|
htmlFor="email"
|
|
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
|
|
>
|
|
Email
|
|
</label>
|
|
<a
|
|
href="/account/settings"
|
|
className="text-xs text-purple-700 underline hover:text-purple-800"
|
|
>
|
|
Visit settings page to change email
|
|
</a>
|
|
</div>
|
|
<input
|
|
type="email"
|
|
name="email"
|
|
id="email"
|
|
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"
|
|
required
|
|
disabled
|
|
placeholder="john@example.com"
|
|
value={email}
|
|
/>
|
|
<div className="flex items-center justify-end gap-2 rounded-md text-xs text-gray-400">
|
|
<div className="flex select-none items-center justify-end gap-2 rounded-md text-xs text-gray-400">
|
|
<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"
|
|
>
|
|
Show my email on profile
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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="dailydev"
|
|
className="text-sm leading-none text-slate-500"
|
|
>
|
|
daily.dev
|
|
</label>
|
|
<input
|
|
type="text"
|
|
name="dailydev"
|
|
id="dailydev"
|
|
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://app.daily.dev/username"
|
|
value={dailydev}
|
|
onChange={(e) => setDailydev((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-3 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..' : 'Save Profile'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|