Badge for users

feat/roadcards
Kamran Ahmed 1 year ago
parent 17b4b05368
commit 5ef8a3f82a
  1. 42
      src/components/RoadCard/Editor.tsx
  2. 4
      src/components/RoadCard/GitHubReadmeBanner.tsx
  3. 200
      src/components/RoadCard/RoadCardPage.tsx
  4. 43
      src/components/RoadCard/RoadmapSelect.tsx
  5. 23
      src/components/RoadCard/SelectionButton.tsx
  6. 17
      src/components/RoadCard/StepCounter.tsx
  7. 41
      src/components/RoadCard/TallBadge.tsx
  8. 90
      src/components/RoadCard/TallBadgeTab.tsx
  9. 40
      src/components/RoadCard/WideBadge.tsx
  10. 67
      src/components/RoadCard/WideBadgeTab.tsx
  11. 43
      src/helper/get-badge-link.ts
  12. 3
      src/icons/download.svg

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

@ -1,6 +1,6 @@
export function GithubReadmeBanner() {
export function GitHubReadmeBanner() {
return (
<p className="mt-3 rounded-md border p-2 text-sm">
<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"

@ -2,70 +2,30 @@ import { useState } from 'preact/hooks';
import { useCopyText } from '../../hooks/use-copy-text';
import { useAuth } from '../../hooks/use-auth';
import { TallBadgeTab } from './TallBadgeTab';
import { WideBadgeTab } from './WideBadgeTab';
import CopyIcon from '../../icons/copy.svg';
import { RoadmapSelect } from './RoadmapSelect';
type StepCounterProps = {
step: number;
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 StepCounter(props: StepCounterProps) {
const { step } = props;
function StepLabel(props: StepLabelProps) {
const { label } = 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 className="mb-3 flex items-center gap-2 text-sm leading-none text-gray-400">
{label}
</span>
);
}
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-green-500">
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"
readOnly
>
{text}
</textarea>
</div>
);
}
export type BadgeProps = {
badgeUrl: string;
};
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();
@ -78,67 +38,133 @@ export function RoadCardPage() {
);
badgeUrl.searchParams.set('variant', variant);
if (roadmaps.length > 0) {
badgeUrl.searchParams.set('roadmaps', roadmaps.join(','));
}
return (
<>
<div className="mb-4 flex items-start gap-4">
<div className="mb-5 flex items-start gap-4 pt-2">
<StepCounter step={1} />
<div>
<span className="mb-3 flex items-center gap-2 text-sm leading-none text-gray-400">
Select progress to show (maximum 4 items)
</span>
<StepLabel label="Pick progress to show (Max. 4)" />
<div className="flex min-h-[30px] flex-wrap">
<RoadmapSelect />
<RoadmapSelect
selectedRoadmaps={roadmaps}
setSelectedRoadmaps={setRoadmaps}
/>
</div>
</div>
</div>
<div className="mb-4 flex items-start gap-4">
<div className="mb-5 flex items-start gap-4">
<StepCounter step={2} />
<div>
<span className="mb-3 flex items-center gap-2 text-sm leading-none text-gray-400">
Select Mode (Dark vs Light)
</span>
<StepLabel label="Select Mode (Dark vs Light)" />
<div className="flex gap-2">
<button className="rounded-md border p-1 px-2 text-sm">Dark</button>
<button className="rounded-md border p-1 px-2 text-sm">
Light
</button>
<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-4 flex items-start gap-4">
<div className="mb-5 flex items-start gap-4">
<StepCounter step={3} />
<div>
<span className="mb-3 flex items-center gap-2 text-sm leading-none text-gray-400">
Select Variant
</span>
<StepLabel label="Select Version" />
<div className="flex gap-2">
<button className="rounded-md border p-1 px-2 text-sm">Tall</button>
<button className="rounded-md border p-1 px-2 text-sm">Wide</button>
<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-4 flex items-start gap-4">
<div className="mb-5 flex items-start gap-4">
<StepCounter step={4} />
<div>
<span className="mb-3 flex items-center gap-2 text-sm leading-none text-gray-400">
Share your #RoadCard with others
</span>
<a
href={badgeUrl.toString()}
target="_blank"
rel="noopener noreferrer"
className="relative block w-[270px] hover:cursor-pointer"
>
<img src={badgeUrl.toString()} alt="RoadCard" />
</a>
<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>
</>

@ -2,8 +2,16 @@ 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;
export function RoadmapSelect() {
const [progressList, setProgressList] = useState<UserProgressResponse>();
const fetchProgress = async () => {
@ -24,15 +32,38 @@ export function RoadmapSelect() {
});
}, []);
const canSelectMore = selectedRoadmaps.length < 4;
return (
<div className="flex flex-wrap gap-1">
{progressList
?.filter((progress) => progress.resourceType === 'roadmap')
.map((progress) => (
<button className="rounded-md border p-1 px-2 text-sm">
{progress.resourceTitle}
</button>
))}
.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>
);
}

@ -1,41 +0,0 @@
import { downloadImage } from '../../helper/download-image';
import { useCopyText } from '../../hooks/use-copy-text';
import type { BadgeProps } from './RoadCardPage';
export function LongBadge({ badgeUrl }: BadgeProps) {
const { isCopied, copyText } = useCopyText();
return (
<div className="col-span-2">
<a
href={badgeUrl}
target="_blank"
rel="noopener noreferrer"
className="relative block aspect-[422/551] w-full hover:cursor-pointer"
>
<img
src={badgeUrl}
alt="Road Card"
className="absolute left-0 top-0 h-full w-full object-cover"
/>
</a>
<div className="mt-3 grid grid-cols-2 gap-2">
<button
className="flex h-8 items-center justify-center whitespace-nowrap rounded border border-gray-300 bg-gray-50 px-2 text-sm font-medium leading-none hover:opacity-75"
onClick={() =>
downloadImage({ url: badgeUrl, name: 'road-card', scale: 4 })
}
>
Download
</button>
<button
className="flex h-8 cursor-pointer items-center justify-center whitespace-nowrap rounded border border-gray-300 bg-gray-50 px-2 text-sm font-medium leading-none hover:opacity-75"
onClick={() => copyText(badgeUrl)}
>
{isCopied ? 'Copied!' : 'Copy Link'}
</button>
</div>
</div>
);
}

@ -1,90 +0,0 @@
import { useState } from 'preact/hooks';
import { LongBadge } from './TallBadge';
import { Editor } from './RoadCardPage';
import { GithubReadmeBanner } from './GithubReadmeBanner';
import { useAuth } from '../../hooks/use-auth';
import { getBadgeLink } from '../../helper/get-badge-link';
import type { RoadmapOptionProps } from './RoadmapSelect';
export function TallBadgeTab({
selectedRoadmaps,
}: {
selectedRoadmaps: RoadmapOptionProps[];
}) {
const [selectedVariant, setSelectedVariant] = useState<'dark' | 'light'>(
'dark'
);
const user = useAuth();
if (!user) {
return null;
}
const { badgeUrl, textareaContent, markdownSnippet } = getBadgeLink({
user,
variant: selectedVariant,
badge: 'tall',
roadmaps: selectedRoadmaps,
});
return (
<div className="sm:grid sm:grid-cols-5 sm:gap-6">
<div className="block sm:hidden mb-6">
<span className="text-xs uppercase leading-none text-gray-400">
Variant
</span>
<div className="mt-2 flex items-center gap-2">
<button
className={`flex h-7 items-center justify-center rounded-lg border border-gray-200 px-3 text-sm leading-none hover:opacity-80 ${selectedVariant === 'dark' && 'border-gray-300 bg-gray-100'
}`}
onClick={() => setSelectedVariant('dark')}
>
Dark
</button>
<button
className={`flex h-7 items-center justify-center rounded-lg border border-gray-200 px-3 text-sm leading-none hover:opacity-80 ${selectedVariant === 'light' && 'border-gray-300 bg-gray-100'
}`}
onClick={() => setSelectedVariant('light')}
>
Light
</button>
</div>
</div>
<LongBadge badgeUrl={badgeUrl} />
<div className="mt-6 sm:col-span-3 sm:mt-0">
<div className="hidden sm:block">
<span className="text-xs uppercase leading-none text-gray-400">
Variant
</span>
<div className="mt-2 flex items-center gap-2">
<button
className={`flex h-7 items-center justify-center rounded-lg border border-gray-200 px-3 text-sm leading-none hover:opacity-80 ${selectedVariant === 'dark' && 'border-gray-300 bg-gray-100'
}`}
onClick={() => setSelectedVariant('dark')}
>
Dark
</button>
<button
className={`flex h-7 items-center justify-center rounded-lg border border-gray-200 px-3 text-sm leading-none hover:opacity-80 ${selectedVariant === 'light' && 'border-gray-300 bg-gray-100'
}`}
onClick={() => setSelectedVariant('light')}
>
Light
</button>
</div>
</div>
<div className="sm:mt-4 flex flex-col gap-3">
<Editor title={'HTML'} text={textareaContent} />
<Editor title={'Markdown'} text={markdownSnippet} />
</div>
<GithubReadmeBanner />
</div>
</div>
);
}

@ -1,40 +0,0 @@
import { downloadImage } from '../../helper/download-image';
import { useCopyText } from '../../hooks/use-copy-text';
import type { BadgeProps } from './RoadCardPage';
export function WideBadge({ badgeUrl }: BadgeProps) {
const { isCopied, copyText } = useCopyText();
return (
<div>
<a
href={badgeUrl}
target="_blank"
rel="noopener noreferrer"
className="relative block aspect-[2.63/1] w-full hover:cursor-pointer"
>
<img
src={badgeUrl}
alt="Road Card"
className="absolute left-0 top-0 h-full w-full object-cover"
/>
</a>
<div className="mt-3 grid grid-cols-2 gap-4">
<button
className="flex h-8 items-center justify-center whitespace-nowrap rounded border border-gray-300 bg-gray-50 px-2 text-sm font-medium leading-none hover:opacity-75"
onClick={() =>
downloadImage({ url: badgeUrl, name: 'road-card', scale: 4 })
}
>
Download
</button>
<button
className="flex h-8 cursor-pointer items-center justify-center whitespace-nowrap rounded border border-gray-300 bg-gray-50 px-2 text-sm font-medium leading-none hover:opacity-75"
onClick={() => copyText(badgeUrl)}
>
{isCopied ? 'Copied!' : 'Copy Link'}
</button>
</div>
</div>
);
}

@ -1,67 +0,0 @@
import { useState } from 'preact/hooks';
import { useAuth } from '../../hooks/use-auth';
import { WideBadge } from './WideBadge';
import { Editor } from './RoadCardPage';
import { GithubReadmeBanner } from './GithubReadmeBanner';
import { getBadgeLink } from '../../helper/get-badge-link';
import type { RoadmapOptionProps } from './RoadmapSelect';
export function WideBadgeTab({
selectedRoadmaps,
}: {
selectedRoadmaps: RoadmapOptionProps[];
}) {
const [selectedVariant, setSelectedVariant] = useState<'dark' | 'light'>(
'dark'
);
const user = useAuth();
if (!user) {
return null;
}
const { badgeUrl, textareaContent, markdownSnippet } = getBadgeLink({
user,
variant: selectedVariant,
badge: 'wide',
roadmaps: selectedRoadmaps,
});
return (
<div className="flex flex-col gap-6">
<div>
<span className="text-xs uppercase leading-none text-gray-400">
Variant
</span>
<div className="mt-2 flex items-center gap-2">
<button
className={`flex h-7 items-center justify-center rounded-lg border border-gray-200 px-3 text-sm leading-none hover:opacity-80 ${selectedVariant === 'dark' && 'border-gray-300 bg-gray-100'
}`}
onClick={() => setSelectedVariant('dark')}
>
Dark
</button>
<button
className={`flex h-7 items-center justify-center rounded-lg border border-gray-200 px-3 text-sm leading-none hover:opacity-80 ${selectedVariant === 'light' && 'border-gray-300 bg-gray-100'
}`}
onClick={() => setSelectedVariant('light')}
>
Light
</button>
</div>
</div>
<WideBadge badgeUrl={badgeUrl} />
<div>
<div className={`flex flex-col gap-3 sm:flex-row`}>
<Editor title={'HTML'} text={textareaContent} />
<Editor title={'Markdown'} text={markdownSnippet} />
</div>
<GithubReadmeBanner />
</div>
</div>
);
}

@ -1,43 +0,0 @@
import type { useAuth } from '../hooks/use-auth';
export type GetBadgeLinkProps = {
user: ReturnType<typeof useAuth>;
variant: 'dark' | 'light';
badge: 'tall' | 'wide';
roadmaps?: string[];
};
export function getBadgeLink({
user,
variant,
badge,
roadmaps,
}: GetBadgeLinkProps) {
const badgeUrl = new URL(
`${import.meta.env.PUBLIC_API_URL}/v1-badge/${badge}/${user?.id}`
);
if (variant) {
badgeUrl.searchParams.set('variant', variant);
}
if (roadmaps?.length) {
badgeUrl.searchParams.set('roadmaps', roadmaps.join(','));
}
const textareaContent = `
<a href="${badgeUrl}">
<img src="${badgeUrl}" alt="${user?.name}${user?.name && "'s"} Road Card"/>
</a>
`.trim();
const markdownSnippet = `
[![${user?.name}${
user?.name && "'s"
} Road Card](${badgeUrl})](${badgeUrl})
`.trim();
return {
badgeUrl: badgeUrl.toString(),
textareaContent,
markdownSnippet,
};
}

@ -1,4 +1,3 @@
<svg viewBox="0 0 14 14" focusable="false" class='h-3 w-3' aria-hidden="true">
<path fill="currentColor"
d="M11.2857,6.05714 L10.08571,4.85714 L7.85714,7.14786 L7.85714,1 L6.14286,1 L6.14286,7.14786 L3.91429,4.85714 L2.71429,6.05714 L7,10.42857 L11.2857,6.05714 Z M1,11.2857 L1,13 L13,13 L13,11.2857 L1,11.2857 Z"></path>
<path fill="currentColor" d="M11.2857,6.05714 L10.08571,4.85714 L7.85714,7.14786 L7.85714,1 L6.14286,1 L6.14286,7.14786 L3.91429,4.85714 L2.71429,6.05714 L7,10.42857 L11.2857,6.05714 Z M1,11.2857 L1,13 L13,13 L13,11.2857 L1,11.2857 Z"></path>
</svg>

Before

Width:  |  Height:  |  Size: 339 B

After

Width:  |  Height:  |  Size: 331 B

Loading…
Cancel
Save