feat: implement share solution (#6821)
* wip: implement success modal * feat: share solution modal * fix: step count issue * fix: responsiveness share button * Update UI for shareable link * Update UI for shareable link * Update UI for share solutions * Project solution modal UI design * Update UI for share solutions --------- Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>pull/6885/head
parent
54a731aaa5
commit
6101f01055
11 changed files with 460 additions and 94 deletions
@ -0,0 +1,192 @@ |
|||||||
|
import { useEffect, useState } from 'react'; |
||||||
|
import { deleteUrlParam, getUrlParams } from '../../lib/browser'; |
||||||
|
import { ModalLoader } from '../UserProgress/ModalLoader'; |
||||||
|
import { Modal } from '../Modal'; |
||||||
|
import { httpGet, httpPost } from '../../lib/http'; |
||||||
|
import { |
||||||
|
submittedAlternatives, |
||||||
|
type AllowedVoteType, |
||||||
|
} from './ListProjectSolutions'; |
||||||
|
import { getRelativeTimeString } from '../../lib/date'; |
||||||
|
import { ArrowUpRight, ThumbsDown, ThumbsUp, Trophy } from 'lucide-react'; |
||||||
|
import { VoteButton } from './VoteButton'; |
||||||
|
import { GitHubIcon } from '../ReactIcons/GitHubIcon'; |
||||||
|
import { isLoggedIn } from '../../lib/jwt'; |
||||||
|
import { showLoginPopup } from '../../lib/popup'; |
||||||
|
import { pageProgressMessage } from '../../stores/page'; |
||||||
|
import { useToast } from '../../hooks/use-toast'; |
||||||
|
|
||||||
|
type UserProjectSolutionResponse = { |
||||||
|
id?: string; |
||||||
|
|
||||||
|
startedAt?: Date; |
||||||
|
submittedAt?: Date; |
||||||
|
repositoryUrl?: string; |
||||||
|
|
||||||
|
upvotes?: number; |
||||||
|
downvotes?: number; |
||||||
|
|
||||||
|
voteType?: AllowedVoteType | 'none'; |
||||||
|
user: { |
||||||
|
id: string; |
||||||
|
name: string; |
||||||
|
avatar: string; |
||||||
|
}; |
||||||
|
}; |
||||||
|
|
||||||
|
type ProjectSolutionModalProps = { |
||||||
|
projectId: string; |
||||||
|
projectTitle: string; |
||||||
|
projectDescription: string; |
||||||
|
}; |
||||||
|
|
||||||
|
export function ProjectSolutionModal(props: ProjectSolutionModalProps) { |
||||||
|
const { projectId, projectTitle, projectDescription } = props; |
||||||
|
|
||||||
|
const { u: userId } = getUrlParams(); |
||||||
|
if (!userId) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
const toast = useToast(); |
||||||
|
const [isLoading, setIsLoading] = useState(true); |
||||||
|
const [error, setError] = useState(''); |
||||||
|
const [solution, setSolution] = useState<UserProjectSolutionResponse>(); |
||||||
|
|
||||||
|
const loadUserProjectSolution = async () => { |
||||||
|
setIsLoading(true); |
||||||
|
setError(''); |
||||||
|
|
||||||
|
const { response, error } = await httpGet<UserProjectSolutionResponse>( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-project-solution/${projectId}/${userId}`, |
||||||
|
); |
||||||
|
|
||||||
|
if (error || !response) { |
||||||
|
setError(error?.message || 'Something went wrong'); |
||||||
|
setIsLoading(false); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
setSolution(response); |
||||||
|
setIsLoading(false); |
||||||
|
}; |
||||||
|
|
||||||
|
const handleSubmitVote = async ( |
||||||
|
solutionId: string, |
||||||
|
voteType: AllowedVoteType, |
||||||
|
) => { |
||||||
|
if (!isLoggedIn()) { |
||||||
|
showLoginPopup(); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
pageProgressMessage.set('Submitting vote'); |
||||||
|
const { response, error } = await httpPost( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-vote-project/${solutionId}`, |
||||||
|
{ |
||||||
|
voteType, |
||||||
|
}, |
||||||
|
); |
||||||
|
|
||||||
|
if (error || !response) { |
||||||
|
toast.error(error?.message || 'Failed to submit vote'); |
||||||
|
pageProgressMessage.set(''); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
pageProgressMessage.set(''); |
||||||
|
setSolution((prev) => { |
||||||
|
if (!prev) { |
||||||
|
return prev; |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
...prev, |
||||||
|
upvotes: response?.upvotes || 0, |
||||||
|
downvotes: response?.downvotes || 0, |
||||||
|
voteType, |
||||||
|
}; |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
loadUserProjectSolution().finally(); |
||||||
|
}, []); |
||||||
|
|
||||||
|
if (isLoading || error) { |
||||||
|
return ( |
||||||
|
<ModalLoader |
||||||
|
text="Loading project solution..." |
||||||
|
isLoading={isLoading} |
||||||
|
error={error} |
||||||
|
/> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
const avatar = solution?.user.avatar; |
||||||
|
|
||||||
|
return ( |
||||||
|
<Modal |
||||||
|
onClose={() => { |
||||||
|
deleteUrlParam('u'); |
||||||
|
window.location.reload(); |
||||||
|
}} |
||||||
|
wrapperClassName={'max-w-lg'} |
||||||
|
bodyClassName={'h-auto'} |
||||||
|
> |
||||||
|
<div className="relative p-6"> |
||||||
|
<h1 className="text-2xl text-balance mb-1 font-bold text-gray-900">{projectTitle}</h1> |
||||||
|
<p className="text-sm text-balance text-gray-600">{projectDescription}</p> |
||||||
|
|
||||||
|
<div className="my-5 rounded-lg bg-gray-100 p-4"> |
||||||
|
<div className="flex items-center gap-3"> |
||||||
|
<img |
||||||
|
src={ |
||||||
|
avatar |
||||||
|
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}` |
||||||
|
: '/images/default-avatar.png' |
||||||
|
} |
||||||
|
alt={solution?.user?.name} |
||||||
|
className="h-12 w-12 rounded-full border-2 border-white shadow-md" |
||||||
|
/> |
||||||
|
<div> |
||||||
|
<h2 className="text-lg font-semibold text-gray-900">{solution?.user.name}'s Solution</h2> |
||||||
|
<p className="text-sm text-gray-600"> |
||||||
|
Submitted their solution{' '} |
||||||
|
{getRelativeTimeString(solution?.submittedAt!)} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="flex items-center justify-between"> |
||||||
|
<a |
||||||
|
className="flex items-center gap-2 rounded-full bg-black px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-gray-800" |
||||||
|
href={solution?.repositoryUrl} |
||||||
|
target="_blank" |
||||||
|
> |
||||||
|
<GitHubIcon className="h-5 w-5 text-current" /> |
||||||
|
View Solution on GitHub |
||||||
|
<ArrowUpRight className="h-4 w-4" /> |
||||||
|
</a> |
||||||
|
|
||||||
|
<div className="flex overflow-hidden rounded-full border"> |
||||||
|
<VoteButton |
||||||
|
icon={ThumbsUp} |
||||||
|
isActive={solution?.voteType === 'upvote'} |
||||||
|
count={solution?.upvotes || 0} |
||||||
|
onClick={() => handleSubmitVote(solution?.id!, 'upvote')} |
||||||
|
/> |
||||||
|
<VoteButton |
||||||
|
icon={ThumbsDown} |
||||||
|
isActive={solution?.voteType === 'downvote'} |
||||||
|
count={solution?.downvotes || 0} |
||||||
|
hideCount={true} |
||||||
|
onClick={() => handleSubmitVote(solution?.id!, 'downvote')} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</Modal> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,69 @@ |
|||||||
|
import { CheckCircle, CheckCircle2, Clipboard, Copy } from 'lucide-react'; |
||||||
|
import { getUser } from '../../lib/jwt.ts'; |
||||||
|
import { Modal } from '../Modal'; |
||||||
|
import { CheckIcon as ReactCheckIcon } from '../ReactIcons/CheckIcon.tsx'; |
||||||
|
import { useCopyText } from '../../hooks/use-copy-text.ts'; |
||||||
|
import { cn } from '../../lib/classname.ts'; |
||||||
|
|
||||||
|
type SubmitSuccessModalProps = { |
||||||
|
projectId: string; |
||||||
|
onClose: () => void; |
||||||
|
}; |
||||||
|
|
||||||
|
export function SubmitSuccessModal(props: SubmitSuccessModalProps) { |
||||||
|
const { onClose, projectId } = props; |
||||||
|
|
||||||
|
const user = getUser(); |
||||||
|
|
||||||
|
const projectSolutionUrl = `${import.meta.env.DEV ? 'http://localhost:3000' : 'https://roadmap.sh'}/projects/${projectId}/solutions?u=${user?.id}`; |
||||||
|
|
||||||
|
const { isCopied, copyText } = useCopyText(); |
||||||
|
|
||||||
|
return ( |
||||||
|
<Modal onClose={onClose} bodyClassName="h-auto p-4"> |
||||||
|
<div className="flex flex-col items-center justify-center pb-3 pt-12"> |
||||||
|
<ReactCheckIcon additionalClasses="h-12 text-green-500 w-12" /> |
||||||
|
<p className="mt-4 text-lg font-medium">Solution Submitted</p> |
||||||
|
<p className="mt-0.5 text-center text-sm text-gray-500"> |
||||||
|
Congrats! Your solution has been submitted. |
||||||
|
</p> |
||||||
|
|
||||||
|
<div className="mt-4 w-full"> |
||||||
|
<input |
||||||
|
type="text" |
||||||
|
readOnly={true} |
||||||
|
value={projectSolutionUrl} |
||||||
|
className="w-full rounded-md border bg-gray-50 px-2.5 py-2 text-sm text-gray-700 focus:outline-none" |
||||||
|
onClick={(e) => { |
||||||
|
e.currentTarget.select(); |
||||||
|
}} |
||||||
|
/> |
||||||
|
|
||||||
|
<button |
||||||
|
className={cn( |
||||||
|
'mt-2 flex w-full items-center justify-center gap-1 rounded-md px-2 py-2 text-sm font-medium transition-colors', |
||||||
|
isCopied |
||||||
|
? 'bg-green-600 text-white hover:bg-green-700' |
||||||
|
: 'bg-black text-white hover:bg-gray-800' |
||||||
|
)} |
||||||
|
onClick={() => { |
||||||
|
copyText(projectSolutionUrl); |
||||||
|
}} |
||||||
|
> |
||||||
|
{isCopied ? ( |
||||||
|
<> |
||||||
|
<CheckCircle className="size-4 stroke-[2.5px]" /> |
||||||
|
URL Copied |
||||||
|
</> |
||||||
|
) : ( |
||||||
|
<> |
||||||
|
<Copy className="size-4 stroke-[2.5px]" /> |
||||||
|
Copy Shareable Link |
||||||
|
</> |
||||||
|
)} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</Modal> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,17 @@ |
|||||||
|
interface FacebookIconProps { |
||||||
|
className?: string; |
||||||
|
} |
||||||
|
|
||||||
|
export function FacebookIcon(props: FacebookIconProps) { |
||||||
|
const { className } = props; |
||||||
|
return ( |
||||||
|
<svg |
||||||
|
xmlns="http://www.w3.org/2000/svg" |
||||||
|
viewBox="0 0 448 512" |
||||||
|
fill="currentColor" |
||||||
|
className={className} |
||||||
|
> |
||||||
|
<path d="M400 32H48A48 48 0 0 0 0 80v352a48 48 0 0 0 48 48h137.25V327.69h-63V256h63v-54.64c0-62.15 37-96.48 93.67-96.48 27.14 0 55.52 4.84 55.52 4.84v61h-31.27c-30.81 0-40.42 19.12-40.42 38.73V256h68.78l-11 71.69h-57.78V480H400a48 48 0 0 0 48-48V80a48 48 0 0 0-48-48z" /> |
||||||
|
</svg> |
||||||
|
); |
||||||
|
} |
@ -1,49 +1,29 @@ |
|||||||
type LinkedInIconProps = { |
interface LinkedInIconProps { |
||||||
className?: string; |
className?: string; |
||||||
}; |
} |
||||||
|
|
||||||
export function LinkedInIcon(props: LinkedInIconProps) { |
export function LinkedInIcon(props: LinkedInIconProps) { |
||||||
const { className } = props; |
const { className } = props; |
||||||
|
|
||||||
return ( |
return ( |
||||||
<svg |
<svg |
||||||
|
width="24" |
||||||
|
height="24" |
||||||
|
viewBox="0 0 24 24" |
||||||
|
fill="none" |
||||||
xmlns="http://www.w3.org/2000/svg" |
xmlns="http://www.w3.org/2000/svg" |
||||||
className={className} |
className={className} |
||||||
x="0px" |
|
||||||
y="0px" |
|
||||||
width="100" |
|
||||||
height="100" |
|
||||||
viewBox="0,0,256,256" |
|
||||||
> |
> |
||||||
<g transform="translate(-26.66667,-26.66667) scale(1.20833,1.20833)"> |
<g clipPath="url(#clip0_2344_20)"> |
||||||
<g |
<path |
||||||
fill="none" |
d="M0 0V24H24V0H0ZM8 19H5V8H8V19ZM6.5 6.732C5.534 6.732 4.75 5.942 4.75 4.968C4.75 3.994 5.534 3.204 6.5 3.204C7.466 3.204 8.25 3.994 8.25 4.968C8.25 5.942 7.467 6.732 6.5 6.732ZM20 19H17V13.396C17 10.028 13 10.283 13 13.396V19H10V8H13V9.765C14.397 7.179 20 6.988 20 12.241V19Z" |
||||||
fillRule="nonzero" |
fill="currentColor" |
||||||
stroke="none" |
/> |
||||||
strokeWidth="1" |
|
||||||
strokeLinecap="butt" |
|
||||||
strokeLinejoin="miter" |
|
||||||
strokeMiterlimit="10" |
|
||||||
strokeDasharray="" |
|
||||||
strokeDashoffset="0" |
|
||||||
fontFamily="none" |
|
||||||
fontWeight="none" |
|
||||||
fontSize="none" |
|
||||||
textAnchor="none" |
|
||||||
style={{ mixBlendMode: 'normal' }} |
|
||||||
> |
|
||||||
<g transform="scale(5.33333,5.33333)"> |
|
||||||
<path |
|
||||||
d="M42,37c0,2.762 -2.238,5 -5,5h-26c-2.761,0 -5,-2.238 -5,-5v-26c0,-2.762 2.239,-5 5,-5h26c2.762,0 5,2.238 5,5z" |
|
||||||
fill="#0288d1" |
|
||||||
></path> |
|
||||||
<path |
|
||||||
d="M12,19h5v17h-5zM14.485,17h-0.028c-1.492,0 -2.457,-1.112 -2.457,-2.501c0,-1.419 0.995,-2.499 2.514,-2.499c1.521,0 2.458,1.08 2.486,2.499c0,1.388 -0.965,2.501 -2.515,2.501zM36,36h-5v-9.099c0,-2.198 -1.225,-3.698 -3.192,-3.698c-1.501,0 -2.313,1.012 -2.707,1.99c-0.144,0.35 -0.101,1.318 -0.101,1.807v9h-5v-17h5v2.616c0.721,-1.116 1.85,-2.616 4.738,-2.616c3.578,0 6.261,2.25 6.261,7.274l0.001,9.726z" |
|
||||||
fill="#ffffff" |
|
||||||
></path> |
|
||||||
</g> |
|
||||||
</g> |
|
||||||
</g> |
</g> |
||||||
|
<defs> |
||||||
|
<clipPath id="clip0_2344_20"> |
||||||
|
<rect width="24" height="24" rx="2" fill="white" /> |
||||||
|
</clipPath> |
||||||
|
</defs> |
||||||
</svg> |
</svg> |
||||||
); |
); |
||||||
} |
} |
||||||
|
@ -0,0 +1,53 @@ |
|||||||
|
--- |
||||||
|
title: 'Basic HTML Website' |
||||||
|
description: 'Create simple HTML only website with multiple pages.' |
||||||
|
isNew: false |
||||||
|
sort: 1 |
||||||
|
difficulty: 'beginner' |
||||||
|
nature: 'HTML' |
||||||
|
skills: |
||||||
|
- 'HTML' |
||||||
|
- 'Layouts' |
||||||
|
- 'semantic HTML' |
||||||
|
seo: |
||||||
|
title: 'Basic HTML Website Project' |
||||||
|
description: 'Create a simple HTML only website with multiple pages.' |
||||||
|
keywords: |
||||||
|
- 'basic html' |
||||||
|
- 'html project idea' |
||||||
|
roadmapIds: |
||||||
|
- 'frontend' |
||||||
|
--- |
||||||
|
|
||||||
|
> Goal of this project is to teach you how to structure a website using HTML i.e. different sections of a website like header, footer, navigation, main content, sidebars etc. Do not style the website, only focus on the structure. Styling will be done in separate projects. |
||||||
|
|
||||||
|
In this project, you are required to create a simple HTML only website with multiple pages. The website should have following pages: |
||||||
|
|
||||||
|
- Homepage |
||||||
|
- Projects |
||||||
|
- Articles |
||||||
|
- Contact |
||||||
|
|
||||||
|
The website should have a navigation bar that should be present on all pages and link to all the pages. |
||||||
|
|
||||||
|
You are not required to style the website, you are only required to create the structure of the website using HTML. Goals of this project are: |
||||||
|
|
||||||
|
- Learn how to create multiple pages in a website. |
||||||
|
- Structure a website using HTML in a semantic way. |
||||||
|
- Structure in a way that you can easily add styles later. |
||||||
|
- Add SEO meta tags to the website. |
||||||
|
|
||||||
|
You can use the following mockup example to create the structure of the website (remember, you are not required to style the website, only focus on the structure that you can style later): |
||||||
|
|
||||||
|
![Basic HTML Website](https://assets.roadmap.sh/guest/portfolio-design-83lku.png) |
||||||
|
|
||||||
|
Again, make sure that your submission includes the following: |
||||||
|
|
||||||
|
- Semantically correct HTML structure. |
||||||
|
- Multiple pages with a navigation bar. |
||||||
|
- SEO meta tags in the head of each page. |
||||||
|
- Contact page should have a form with fields like name, email, message etc. |
||||||
|
|
||||||
|
<hr /> |
||||||
|
|
||||||
|
After completing this project, you will have a good understanding of how to structure a website using HTML, basic SEO meta tags, HTML tags, forms etc. You can now move on to the next project where you will learn how to style this website using CSS. |
Loading…
Reference in new issue