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
Arik Chakma 3 months ago committed by GitHub
parent 54a731aaa5
commit 6101f01055
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      src/components/Projects/ListProjectSolutions.tsx
  2. 192
      src/components/Projects/ProjectSolutionModal.tsx
  3. 107
      src/components/Projects/StatusStepper/ProjectStepper.tsx
  4. 25
      src/components/Projects/SubmitProjectModal.tsx
  5. 69
      src/components/Projects/SubmitSuccessModal.tsx
  6. 17
      src/components/ReactIcons/FacebookIcon.tsx
  7. 52
      src/components/ReactIcons/LinkedInIcon.tsx
  8. 4
      src/components/ReactIcons/ShareIcon.tsx
  9. 16
      src/components/ReactIcons/TwitterIcon.tsx
  10. 53
      src/data/projects/basic-html-website.md
  11. 16
      src/pages/projects/[projectId]/solutions.astro

@ -15,6 +15,7 @@ import { VoteButton } from './VoteButton.tsx';
import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx'; import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx';
import { SelectLanguages } from './SelectLanguages.tsx'; import { SelectLanguages } from './SelectLanguages.tsx';
import type { ProjectFrontmatter } from '../../lib/project.ts'; import type { ProjectFrontmatter } from '../../lib/project.ts';
import { ProjectSolutionModal } from './ProjectSolutionModal.tsx';
export interface ProjectStatusDocument { export interface ProjectStatusDocument {
_id?: string; _id?: string;
@ -68,7 +69,7 @@ type ListProjectSolutionsProps = {
projectId: string; projectId: string;
}; };
const submittedAlternatives = [ export const submittedAlternatives = [
'submitted their solution', 'submitted their solution',
'got it done', 'got it done',
'submitted their take', 'submitted their take',

@ -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>
);
}

@ -1,4 +1,4 @@
import { Flag, Play, Send } from 'lucide-react'; import { Flag, Play, Send, Share } from 'lucide-react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { cn } from '../../../lib/classname.ts'; import { cn } from '../../../lib/classname.ts';
import { useStickyStuck } from '../../../hooks/use-sticky-stuck.tsx'; import { useStickyStuck } from '../../../hooks/use-sticky-stuck.tsx';
@ -8,9 +8,11 @@ import { MilestoneStep } from './MilestoneStep.tsx';
import { httpGet } from '../../../lib/http.ts'; import { httpGet } from '../../../lib/http.ts';
import { StartProjectModal } from '../StartProjectModal.tsx'; import { StartProjectModal } from '../StartProjectModal.tsx';
import { getRelativeTimeString } from '../../../lib/date.ts'; import { getRelativeTimeString } from '../../../lib/date.ts';
import { isLoggedIn } from '../../../lib/jwt.ts'; import { getUser, isLoggedIn } from '../../../lib/jwt.ts';
import { showLoginPopup } from '../../../lib/popup.ts'; import { showLoginPopup } from '../../../lib/popup.ts';
import { SubmitProjectModal } from '../SubmitProjectModal.tsx'; import { SubmitProjectModal } from '../SubmitProjectModal.tsx';
import { useCopyText } from '../../../hooks/use-copy-text.ts';
import { CheckIcon } from '../../ReactIcons/CheckIcon.tsx';
type ProjectStatusResponse = { type ProjectStatusResponse = {
id?: string; id?: string;
@ -32,9 +34,11 @@ export function ProjectStepper(props: ProjectStepperProps) {
const stickyElRef = useRef<HTMLDivElement>(null); const stickyElRef = useRef<HTMLDivElement>(null);
const isSticky = useStickyStuck(stickyElRef, 8); const isSticky = useStickyStuck(stickyElRef, 8);
const currentUser = getUser();
const [isStartingProject, setIsStartingProject] = useState(false); const [isStartingProject, setIsStartingProject] = useState(false);
const [isSubmittingProject, setIsSubmittingProject] = useState(false); const [isSubmittingProject, setIsSubmittingProject] = useState(false);
const { copyText, isCopied } = useCopyText();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [activeStep, setActiveStep] = useState<number>(0); const [activeStep, setActiveStep] = useState<number>(0);
@ -78,13 +82,16 @@ export function ProjectStepper(props: ProjectStepperProps) {
loadProjectStatus().finally(() => {}); loadProjectStatus().finally(() => {});
}, []); }, []);
const projectSolutionUrl = `${import.meta.env.DEV ? 'http://localhost:3000' : 'https://roadmap.sh'}/projects/${projectId}/solutions?u=${currentUser?.id}`;
return ( return (
<div <div
ref={stickyElRef} ref={stickyElRef}
className={cn( className={cn(
'relative sm:sticky top-0 my-5 -mx-4 sm:mx-0 overflow-hidden rounded-none border-x-0 sm:border-x sm:rounded-lg border bg-white transition-all', 'relative top-0 -mx-4 my-5 overflow-hidden rounded-none border border-x-0 bg-white transition-all sm:sticky sm:mx-0 sm:rounded-lg sm:border-x',
{ {
'sm:-mx-5 sm:rounded-none sm:border-x-0 sm:border-t-0 sm:bg-gray-50': isSticky, 'sm:-mx-5 sm:rounded-none sm:border-x-0 sm:border-t-0 sm:bg-gray-50':
isSticky,
}, },
)} )}
> >
@ -131,7 +138,7 @@ export function ProjectStepper(props: ProjectStepperProps) {
)} )}
<div <div
className={cn( className={cn(
'px-4 py-2 text-sm text-gray-500 transition-colors bg-gray-100', 'bg-gray-100 px-4 py-2 text-sm text-gray-500 transition-colors sm:flex sm:items-center',
{ {
'bg-purple-600 text-white': isSticky, 'bg-purple-600 text-white': isSticky,
}, },
@ -144,7 +151,7 @@ export function ProjectStepper(props: ProjectStepperProps) {
)} )}
{activeStep === 1 && ( {activeStep === 1 && (
<> <>
Started working{' '} Started working&nbsp;
<span <span
className={cn('font-medium text-gray-800', { className={cn('font-medium text-gray-800', {
'text-purple-200': isSticky, 'text-purple-200': isSticky,
@ -152,7 +159,7 @@ export function ProjectStepper(props: ProjectStepperProps) {
> >
{getRelativeTimeString(projectStatus.startedAt!)} {getRelativeTimeString(projectStatus.startedAt!)}
</span> </span>
. Follow{' '} . Follow&nbsp;
<button <button
className={cn('underline underline-offset-2 hover:text-black', { className={cn('underline underline-offset-2 hover:text-black', {
'text-purple-100 hover:text-white': isSticky, 'text-purple-100 hover:text-white': isSticky,
@ -162,13 +169,13 @@ export function ProjectStepper(props: ProjectStepperProps) {
}} }}
> >
these tips these tips
</button>{' '} </button>
to get most out of it. &nbsp;to get most out of it.
</> </>
)} )}
{activeStep >= 2 && ( {activeStep >= 2 && (
<> <>
Congrats on submitting your solution.{' '} Congrats on submitting your solution.&nbsp;
<button <button
className={cn('underline underline-offset-2 hover:text-black', { className={cn('underline underline-offset-2 hover:text-black', {
'text-purple-100 hover:text-white': isSticky, 'text-purple-100 hover:text-white': isSticky,
@ -181,9 +188,34 @@ export function ProjectStepper(props: ProjectStepperProps) {
</button> </button>
</> </>
)} )}
{activeStep >= 2 && (
<button
className={cn(
'ml-auto hidden items-center gap-1 text-sm sm:flex',
isCopied ? 'text-green-500' : '',
)}
onClick={() => {
copyText(projectSolutionUrl);
}}
>
{isCopied ? (
<>
<CheckIcon additionalClasses="h-3 w-3" />
Copied
</>
) : (
<>
<Share className="h-3 w-3 stroke-[2.5px]" />
<span className="hidden md:inline">Share your Solution</span>
<span className="md:hidden">Share</span>
</>
)}
</button>
)}
</div> </div>
<div className="flex flex-col sm:flex-row min-h-[60px] items-start sm:items-center justify-between gap-2 sm:gap-3 px-4 py-4 sm:py-0"> <div className="flex min-h-[60px] flex-col items-start justify-between gap-2 px-4 py-4 sm:flex-row sm:items-center sm:gap-3 sm:py-0">
<StepperAction <StepperAction
isActive={activeStep === 0} isActive={activeStep === 0}
isCompleted={activeStep > 0} isCompleted={activeStep > 0}
@ -200,21 +232,46 @@ export function ProjectStepper(props: ProjectStepperProps) {
}} }}
/> />
<StepperStepSeparator isActive={activeStep > 0} /> <StepperStepSeparator isActive={activeStep > 0} />
<StepperAction <div className="flex items-center gap-2">
isActive={activeStep === 1} <StepperAction
isCompleted={activeStep > 1} isActive={activeStep === 1}
icon={Send} isCompleted={activeStep > 1}
onClick={() => { icon={Send}
if (!isLoggedIn()) { onClick={() => {
showLoginPopup(); if (!isLoggedIn()) {
return; showLoginPopup();
} return;
}
setIsSubmittingProject(true); setIsSubmittingProject(true);
}} }}
text={activeStep > 1 ? 'Submitted' : 'Submit Solution'} text={activeStep > 1 ? 'Submitted' : 'Submit Solution'}
number={2} number={2}
/> />
<span className="text-gray-600 sm:hidden">&middot;</span>
<button
className={cn(
'flex items-center gap-2 text-sm sm:hidden',
isCopied ? 'text-green-500' : 'text-gray-600',
)}
onClick={() => {
copyText(projectSolutionUrl);
}}
>
{isCopied ? (
<>
<CheckIcon additionalClasses="h-3 w-3" />
URL Copied
</>
) : (
<>
<Share className="h-3 w-3 stroke-[2.5px]" />
Share your Solution
</>
)}
</button>
</div>
<StepperStepSeparator isActive={activeStep > 1} /> <StepperStepSeparator isActive={activeStep > 1} />
<MilestoneStep <MilestoneStep
isActive={activeStep === 2} isActive={activeStep === 2}

@ -1,5 +1,4 @@
import { CheckIcon, CopyIcon, X } from 'lucide-react'; import { CheckIcon, CopyIcon, X } from 'lucide-react';
import { CheckIcon as ReactCheckIcon } from '../ReactIcons/CheckIcon.tsx';
import { Modal } from '../Modal'; import { Modal } from '../Modal';
import { type FormEvent, useState } from 'react'; import { type FormEvent, useState } from 'react';
import { httpPost } from '../../lib/http'; import { httpPost } from '../../lib/http';
@ -7,6 +6,7 @@ import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx';
import { SubmissionRequirement } from './SubmissionRequirement.tsx'; import { SubmissionRequirement } from './SubmissionRequirement.tsx';
import { useCopyText } from '../../hooks/use-copy-text.ts'; import { useCopyText } from '../../hooks/use-copy-text.ts';
import { getTopGitHubLanguages } from '../../lib/github.ts'; import { getTopGitHubLanguages } from '../../lib/github.ts';
import { SubmitSuccessModal } from './SubmitSuccessModal.tsx';
type SubmitProjectResponse = { type SubmitProjectResponse = {
repositoryUrl: string; repositoryUrl: string;
@ -39,7 +39,7 @@ export function SubmitProjectModal(props: SubmitProjectModalProps) {
const { isCopied, copyText } = useCopyText(); const { isCopied, copyText } = useCopyText();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [successMessage, setSuccessMessage] = useState(''); const [isSuccess, setIsSuccess] = useState(false);
const [repoUrl, setRepoUrl] = useState(defaultRepositoryUrl); const [repoUrl, setRepoUrl] = useState(defaultRepositoryUrl);
const [verificationChecks, setVerificationChecks] = const [verificationChecks, setVerificationChecks] =
useState<VerificationChecksType>({ useState<VerificationChecksType>({
@ -61,7 +61,7 @@ export function SubmitProjectModal(props: SubmitProjectModalProps) {
setIsLoading(true); setIsLoading(true);
setError(''); setError('');
setSuccessMessage(''); setIsSuccess(false);
if (!repoUrl) { if (!repoUrl) {
setVerificationChecks({ setVerificationChecks({
@ -198,7 +198,7 @@ export function SubmitProjectModal(props: SubmitProjectModalProps) {
); );
} }
setSuccessMessage('Solution submitted successfully!'); setIsSuccess(true);
setIsLoading(false); setIsLoading(false);
onSubmit(submitResponse); onSubmit(submitResponse);
@ -209,15 +209,8 @@ export function SubmitProjectModal(props: SubmitProjectModalProps) {
} }
}; };
if (successMessage) { if (isSuccess) {
return ( return <SubmitSuccessModal projectId={projectId} onClose={onClose} />;
<Modal onClose={onClose} bodyClassName="h-auto p-4">
<div className="flex flex-col items-center justify-center gap-4 pb-10 pt-12">
<ReactCheckIcon additionalClasses={'h-12 text-green-500 w-12'} />
<p className="text-lg font-medium">{successMessage}</p>
</div>
</Modal>
);
} }
return ( return (
@ -296,12 +289,6 @@ export function SubmitProjectModal(props: SubmitProjectModalProps) {
{error && ( {error && (
<p className="mt-2 text-sm font-medium text-red-500">{error}</p> <p className="mt-2 text-sm font-medium text-red-500">{error}</p>
)} )}
{successMessage && (
<p className="mt-2 text-sm font-medium text-green-500">
{successMessage}
</p>
)}
</form> </form>
<button <button

@ -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>
); );
} }

@ -1,6 +1,6 @@
import type { JSX } from "preact/jsx-runtime"; import type { SVGAttributes } from 'react';
type ShareIconProps = JSX.SVGAttributes<SVGSVGElement> type ShareIconProps = SVGAttributes<SVGSVGElement>;
export function ShareIcon(props: ShareIconProps) { export function ShareIcon(props: ShareIconProps) {
return ( return (

@ -1,22 +1,22 @@
type TwitterIconProps = { interface TwitterIconProps {
className?: string; className?: string;
}; }
export function TwitterIcon(props: TwitterIconProps) { export function TwitterIcon(props: TwitterIconProps) {
const { className } = props; const { className } = props;
return ( return (
<svg <svg
width="15" width="23"
height="15" height="23"
viewBox="0 0 15 15" viewBox="0 0 23 23"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
className={className} className={className}
> >
<rect width="23" height="23" rx="3" fill="currentColor" />
<path <path
d="M8.9285 6.35221L14.5135 0H13.1905L8.339 5.5144L4.467 0H0L5.8565 8.33955L0 15H1.323L6.443 9.17535L10.533 15H15M1.8005 0.976187H3.833L13.1895 14.0718H11.1565" d="M12.9285 10.3522L18.5135 4H17.1905L12.339 9.5144L8.467 4H4L9.8565 12.3395L4 19H5.323L10.443 13.1754L14.533 19H19M5.8005 4.97619H7.833L17.1895 18.0718H15.1565"
fill="currentColor" fill="#E5E5E5"
/> />
</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.

@ -1,14 +1,13 @@
--- ---
import BaseLayout from '../../../layouts/BaseLayout.astro'; import BaseLayout from '../../../layouts/BaseLayout.astro';
import { Badge } from '../../../components/Badge';
import { import {
getAllProjects, getAllProjects,
getProjectById, getProjectById,
type ProjectFrontmatter, type ProjectFrontmatter,
} from '../../../lib/project'; } from '../../../lib/project';
import AstroIcon from '../../../components/AstroIcon.astro';
import { ProjectTabs } from '../../../components/Projects/ProjectTabs'; import { ProjectTabs } from '../../../components/Projects/ProjectTabs';
import { ListProjectSolutions } from '../../../components/Projects/ListProjectSolutions'; import { ListProjectSolutions } from '../../../components/Projects/ListProjectSolutions';
import { ProjectSolutionModal } from '../../../components/Projects/ProjectSolutionModal';
export async function getStaticPaths() { export async function getStaticPaths() {
const projects = await getAllProjects(); const projects = await getAllProjects();
@ -49,13 +48,24 @@ const githubUrl = `https://github.com/kamranahmedse/developer-roadmap/tree/maste
> >
<div class='bg-gray-50'> <div class='bg-gray-50'>
<div class='container'> <div class='container'>
<ProjectTabs parentRoadmapId={parentRoadmapId} projectId={projectId} activeTab='solutions' /> <ProjectTabs
parentRoadmapId={parentRoadmapId}
projectId={projectId}
activeTab='solutions'
/>
<ListProjectSolutions <ListProjectSolutions
project={projectData} project={projectData}
projectId={projectId} projectId={projectId}
client:load client:load
/> />
<ProjectSolutionModal
projectId={projectId}
projectTitle={projectData.title}
projectDescription={projectData.description}
client:only='react'
/>
</div> </div>
</div> </div>
</BaseLayout> </BaseLayout>

Loading…
Cancel
Save