parent
a48d39a863
commit
8fb778337d
19 changed files with 1310 additions and 987 deletions
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,42 @@ |
|||||||
|
import { useCopyText } from '../../hooks/use-copy-text'; |
||||||
|
import CopyIcon from '../../icons/copy.svg'; |
||||||
|
|
||||||
|
type EditorProps = { |
||||||
|
title: string; |
||||||
|
text: string; |
||||||
|
}; |
||||||
|
|
||||||
|
export function Editor(props: EditorProps) { |
||||||
|
const { text, title } = props; |
||||||
|
|
||||||
|
const { isCopied, copyText } = useCopyText(); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="flex w-full flex-grow flex-col overflow-hidden rounded border border-gray-300 bg-gray-50"> |
||||||
|
<div className="flex items-center justify-between gap-2 border-b border-gray-300 px-3 py-2"> |
||||||
|
<span className="text-xs uppercase leading-none text-gray-400"> |
||||||
|
{title} |
||||||
|
</span> |
||||||
|
<button className="flex items-center" onClick={() => copyText(text)}> |
||||||
|
{isCopied && ( |
||||||
|
<span className="mr-1 text-xs leading-none text-gray-700"> |
||||||
|
Copied! |
||||||
|
</span> |
||||||
|
)} |
||||||
|
|
||||||
|
<img src={CopyIcon} alt="Copy" className="inline-block h-4 w-4" /> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
<textarea |
||||||
|
className="no-scrollbar block h-12 w-full overflow-x-auto whitespace-nowrap bg-gray-200/70 p-3 text-sm text-gray-900 focus:bg-gray-50 focus:outline-0" |
||||||
|
readOnly |
||||||
|
onClick={(e: any) => { |
||||||
|
e.target.select(); |
||||||
|
copyText(e.target.value); |
||||||
|
}} |
||||||
|
> |
||||||
|
{text} |
||||||
|
</textarea> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,15 @@ |
|||||||
|
export function GitHubReadmeBanner() { |
||||||
|
return ( |
||||||
|
<p className="mt-3 rounded-md border p-2 text-sm w-full bg-yellow-100 border-yellow-400 text-yellow-900"> |
||||||
|
Add this badge to your{' '} |
||||||
|
<a |
||||||
|
href="https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-github-profile/customizing-your-profile/managing-your-profile-readme" |
||||||
|
target="_blank" |
||||||
|
rel="noopener noreferrer" |
||||||
|
className="text-blue-600 underline hover:text-blue-800" |
||||||
|
> |
||||||
|
GitHub profile readme. |
||||||
|
</a> |
||||||
|
</p> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,172 @@ |
|||||||
|
import { useState } from 'preact/hooks'; |
||||||
|
|
||||||
|
import { useCopyText } from '../../hooks/use-copy-text'; |
||||||
|
import { useAuth } from '../../hooks/use-auth'; |
||||||
|
import CopyIcon from '../../icons/copy.svg'; |
||||||
|
import { RoadmapSelect } from './RoadmapSelect'; |
||||||
|
import { GitHubReadmeBanner } from './GitHubReadmeBanner'; |
||||||
|
import { downloadImage } from '../../helper/download-image'; |
||||||
|
import { SelectionButton } from './SelectionButton'; |
||||||
|
import { StepCounter } from './StepCounter'; |
||||||
|
import { Editor } from './Editor'; |
||||||
|
|
||||||
|
type StepLabelProps = { |
||||||
|
label: string; |
||||||
|
}; |
||||||
|
function StepLabel(props: StepLabelProps) { |
||||||
|
const { label } = props; |
||||||
|
|
||||||
|
return ( |
||||||
|
<span className="mb-3 flex items-center gap-2 text-sm leading-none text-gray-400"> |
||||||
|
{label} |
||||||
|
</span> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function RoadCardPage() { |
||||||
|
const { isCopied, copyText } = useCopyText(); |
||||||
|
const [roadmaps, setRoadmaps] = useState<string[]>([]); |
||||||
|
const [version, setVersion] = useState<'tall' | 'wide'>('tall'); |
||||||
|
const [variant, setVariant] = useState<'dark' | 'light'>('dark'); |
||||||
|
const user = useAuth(); |
||||||
|
if (!user) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
const badgeUrl = new URL( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-badge/${version}/${user?.id}` |
||||||
|
); |
||||||
|
|
||||||
|
badgeUrl.searchParams.set('variant', variant); |
||||||
|
if (roadmaps.length > 0) { |
||||||
|
badgeUrl.searchParams.set('roadmaps', roadmaps.join(',')); |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
<div className="mb-5 flex items-start gap-4 pt-2"> |
||||||
|
<StepCounter step={1} /> |
||||||
|
<div> |
||||||
|
<StepLabel label="Pick progress to show (Max. 4)" /> |
||||||
|
|
||||||
|
<div className="flex min-h-[30px] flex-wrap"> |
||||||
|
<RoadmapSelect |
||||||
|
selectedRoadmaps={roadmaps} |
||||||
|
setSelectedRoadmaps={setRoadmaps} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="mb-5 flex items-start gap-4"> |
||||||
|
<StepCounter step={2} /> |
||||||
|
<div> |
||||||
|
<StepLabel label="Select Mode (Dark vs Light)" /> |
||||||
|
|
||||||
|
<div className="flex gap-2"> |
||||||
|
<SelectionButton |
||||||
|
text={'Dark'} |
||||||
|
isDisabled={false} |
||||||
|
isSelected={variant === 'dark'} |
||||||
|
onClick={() => { |
||||||
|
setVariant('dark'); |
||||||
|
}} |
||||||
|
/> |
||||||
|
|
||||||
|
<SelectionButton |
||||||
|
text={'Light'} |
||||||
|
isDisabled={false} |
||||||
|
isSelected={variant === 'light'} |
||||||
|
onClick={() => { |
||||||
|
setVariant('light'); |
||||||
|
}} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="mb-5 flex items-start gap-4"> |
||||||
|
<StepCounter step={3} /> |
||||||
|
<div> |
||||||
|
<StepLabel label="Select Version" /> |
||||||
|
|
||||||
|
<div className="flex gap-2"> |
||||||
|
<SelectionButton |
||||||
|
text={'Tall'} |
||||||
|
isDisabled={false} |
||||||
|
isSelected={version === 'tall'} |
||||||
|
onClick={() => { |
||||||
|
setVersion('tall'); |
||||||
|
}} |
||||||
|
/> |
||||||
|
<SelectionButton |
||||||
|
text={'Wide'} |
||||||
|
isDisabled={false} |
||||||
|
isSelected={version === 'wide'} |
||||||
|
onClick={() => { |
||||||
|
setVersion('wide'); |
||||||
|
}} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="mb-5 flex items-start gap-4"> |
||||||
|
<StepCounter step={4} /> |
||||||
|
<div class="flex-grow"> |
||||||
|
<StepLabel label="Share your #RoadCard with others" /> |
||||||
|
<div className={'rounded-md border bg-gray-50 p-2 text-center'}> |
||||||
|
<a |
||||||
|
href={badgeUrl.toString()} |
||||||
|
target="_blank" |
||||||
|
rel="noopener noreferrer" |
||||||
|
className={`relative block hover:cursor-pointer ${ |
||||||
|
version === 'tall' ? ' max-w-[270px] ' : ' w-full ' |
||||||
|
}`}
|
||||||
|
> |
||||||
|
<img src={badgeUrl.toString()} alt="RoadCard" /> |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-2"> |
||||||
|
<button |
||||||
|
className="flex items-center justify-center rounded border border-gray-300 p-1.5 px-2 text-sm font-medium" |
||||||
|
onClick={() => |
||||||
|
downloadImage({ |
||||||
|
url: badgeUrl.toString(), |
||||||
|
name: 'road-card', |
||||||
|
scale: 4, |
||||||
|
}) |
||||||
|
} |
||||||
|
> |
||||||
|
Download |
||||||
|
</button> |
||||||
|
<button |
||||||
|
disabled={isCopied} |
||||||
|
className="flex cursor-pointer items-center justify-center rounded border border-gray-300 p-1.5 px-2 text-sm font-medium disabled:bg-blue-50" |
||||||
|
onClick={() => copyText(badgeUrl.toString())} |
||||||
|
> |
||||||
|
<img alt="Copy" src={CopyIcon} className="mr-1" /> |
||||||
|
|
||||||
|
{isCopied ? 'Copied!' : 'Copy Link'} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="mt-3 flex flex-col gap-3"> |
||||||
|
<Editor |
||||||
|
title={'HTML'} |
||||||
|
text={`<a href="https://roadmap.sh"><img src="${badgeUrl}" alt="roadmap.sh"/></a>`.trim()} |
||||||
|
/> |
||||||
|
|
||||||
|
<Editor |
||||||
|
title={'Markdown'} |
||||||
|
text={`[![roadmap.sh](${badgeUrl})](https://roadmap.sh)`.trim()} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<GitHubReadmeBanner /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,69 @@ |
|||||||
|
import { httpGet } from '../../lib/http'; |
||||||
|
import { useEffect, useState } from 'preact/hooks'; |
||||||
|
import { pageProgressMessage } from '../../stores/page'; |
||||||
|
import type { UserProgressResponse } from '../HeroSection/FavoriteRoadmaps'; |
||||||
|
import { SelectionButton } from './SelectionButton'; |
||||||
|
|
||||||
|
type RoadmapSelectProps = { |
||||||
|
selectedRoadmaps: string[]; |
||||||
|
setSelectedRoadmaps: (updatedRoadmaps: string[]) => void; |
||||||
|
}; |
||||||
|
|
||||||
|
export function RoadmapSelect(props: RoadmapSelectProps) { |
||||||
|
const { selectedRoadmaps, setSelectedRoadmaps } = props; |
||||||
|
|
||||||
|
const [progressList, setProgressList] = useState<UserProgressResponse>(); |
||||||
|
|
||||||
|
const fetchProgress = async () => { |
||||||
|
const { response, error } = await httpGet<UserProgressResponse>( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-all-progress` |
||||||
|
); |
||||||
|
|
||||||
|
if (error || !response) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
setProgressList(response); |
||||||
|
}; |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
fetchProgress().finally(() => { |
||||||
|
pageProgressMessage.set(''); |
||||||
|
}); |
||||||
|
}, []); |
||||||
|
|
||||||
|
const canSelectMore = selectedRoadmaps.length < 4; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="flex flex-wrap gap-1"> |
||||||
|
{progressList |
||||||
|
?.filter((progress) => progress.resourceType === 'roadmap') |
||||||
|
.map((progress) => { |
||||||
|
const isSelected = selectedRoadmaps.includes(progress.resourceId); |
||||||
|
const canSelect = isSelected || canSelectMore; |
||||||
|
|
||||||
|
return ( |
||||||
|
<SelectionButton |
||||||
|
text={progress.resourceTitle} |
||||||
|
isDisabled={!canSelect} |
||||||
|
isSelected={isSelected} |
||||||
|
onClick={() => { |
||||||
|
if (isSelected) { |
||||||
|
setSelectedRoadmaps( |
||||||
|
selectedRoadmaps.filter( |
||||||
|
(roadmap) => roadmap !== progress.resourceId |
||||||
|
) |
||||||
|
); |
||||||
|
} else if (selectedRoadmaps.length < 4) { |
||||||
|
setSelectedRoadmaps([ |
||||||
|
...selectedRoadmaps, |
||||||
|
progress.resourceId, |
||||||
|
]); |
||||||
|
} |
||||||
|
}} |
||||||
|
/> |
||||||
|
); |
||||||
|
})} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,23 @@ |
|||||||
|
type SelectionButtonProps = { |
||||||
|
text: string; |
||||||
|
isDisabled: boolean; |
||||||
|
isSelected: boolean; |
||||||
|
onClick: () => void; |
||||||
|
}; |
||||||
|
|
||||||
|
export function SelectionButton(props: SelectionButtonProps) { |
||||||
|
const { text, isDisabled, isSelected, onClick } = props; |
||||||
|
|
||||||
|
return ( |
||||||
|
<button |
||||||
|
className={`rounded-md border p-1 px-2 text-sm ${ |
||||||
|
isSelected ? ' border-gray-500 bg-gray-300 ' : '' |
||||||
|
} ${ |
||||||
|
!isDisabled ? ' cursor-pointer ' : ' cursor-not-allowed opacity-40 ' |
||||||
|
}`}
|
||||||
|
onClick={onClick} |
||||||
|
> |
||||||
|
{text} |
||||||
|
</button> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,17 @@ |
|||||||
|
type StepCounterProps = { |
||||||
|
step: number; |
||||||
|
}; |
||||||
|
|
||||||
|
export function StepCounter(props: StepCounterProps) { |
||||||
|
const { step } = props; |
||||||
|
|
||||||
|
return ( |
||||||
|
<span |
||||||
|
className={ |
||||||
|
'flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-gray-300 text-white' |
||||||
|
} |
||||||
|
> |
||||||
|
{step} |
||||||
|
</span> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,36 @@ |
|||||||
|
type DownloadImageProps = { |
||||||
|
url: string; |
||||||
|
name: string; |
||||||
|
extension?: 'png' | 'jpg'; |
||||||
|
scale?: number; |
||||||
|
}; |
||||||
|
|
||||||
|
export async function downloadImage({ |
||||||
|
url, |
||||||
|
name, |
||||||
|
extension = 'png', |
||||||
|
scale = 1, |
||||||
|
}: DownloadImageProps) { |
||||||
|
try { |
||||||
|
const res = await fetch(url); |
||||||
|
const svg = await res.text(); |
||||||
|
|
||||||
|
const image = `data:image/svg+xml;base64,${window.btoa(svg)}`; |
||||||
|
const img = new Image(); |
||||||
|
img.src = image; |
||||||
|
img.onload = () => { |
||||||
|
const canvas = document.createElement('canvas'); |
||||||
|
canvas.width = img.width * scale; |
||||||
|
canvas.height = img.height * scale; |
||||||
|
const ctx = canvas.getContext('2d'); |
||||||
|
ctx?.drawImage(img, 0, 0, canvas.width, canvas.height); |
||||||
|
const png = canvas.toDataURL('image/png', 1.0); // Increase the quality by setting a higher value (0.0 - 1.0)
|
||||||
|
const a = document.createElement('a'); |
||||||
|
a.href = png; |
||||||
|
a.download = `${name}.${extension}`; |
||||||
|
a.click(); |
||||||
|
}; |
||||||
|
} catch (error) { |
||||||
|
alert('Error downloading image'); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,12 @@ |
|||||||
|
import Cookies from 'js-cookie'; |
||||||
|
import { TOKEN_COOKIE_NAME, decodeToken } from '../lib/jwt'; |
||||||
|
|
||||||
|
export function useAuth() { |
||||||
|
const token = Cookies.get(TOKEN_COOKIE_NAME); |
||||||
|
if (!token) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
const user = decodeToken(token); |
||||||
|
|
||||||
|
return user; |
||||||
|
} |
@ -0,0 +1,22 @@ |
|||||||
|
import { useEffect, useState } from 'preact/hooks'; |
||||||
|
|
||||||
|
export function useCopyText() { |
||||||
|
const [isCopied, setIsCopied] = useState(false); |
||||||
|
|
||||||
|
const copyText = (text: string) => { |
||||||
|
navigator.clipboard.writeText(text).then(); |
||||||
|
setIsCopied(true); |
||||||
|
}; |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
let timeout: ReturnType<typeof setTimeout>; |
||||||
|
if (isCopied) { |
||||||
|
timeout = setTimeout(() => { |
||||||
|
setIsCopied(false); |
||||||
|
}, 2000); |
||||||
|
} |
||||||
|
return () => clearTimeout(timeout); |
||||||
|
}, [isCopied]); |
||||||
|
|
||||||
|
return { isCopied, copyText }; |
||||||
|
} |
After Width: | Height: | Size: 571 B |
After Width: | Height: | Size: 419 B |
Before Width: | Height: | Size: 339 B After Width: | Height: | Size: 331 B |
@ -0,0 +1,15 @@ |
|||||||
|
--- |
||||||
|
import AccountSidebar from '../../components/AccountSidebar.astro'; |
||||||
|
import AccountLayout from '../../layouts/AccountLayout.astro'; |
||||||
|
import { RoadCardPage } from '../../components/RoadCard/RoadCardPage'; |
||||||
|
--- |
||||||
|
|
||||||
|
<AccountLayout |
||||||
|
title='Road Card' |
||||||
|
noIndex={true} |
||||||
|
initialLoadingMessage='Preparing card..' |
||||||
|
> |
||||||
|
<AccountSidebar activePageId='road-card' activePageTitle='Road Card'> |
||||||
|
<RoadCardPage client:load /> |
||||||
|
</AccountSidebar> |
||||||
|
</AccountLayout> |
Loading…
Reference in new issue