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