Compare commits

..

1 Commits

Author SHA1 Message Date
Arik Chakma
d3e1324b31 Add DevOps forkable 2023-10-30 09:14:42 +06:00
1024 changed files with 33462 additions and 73238 deletions

View File

@@ -1,4 +1,4 @@
name: App Deployment
name: Deployment to GH Pages
on:
push:
branches: [ master ]

2
.gitignore vendored
View File

@@ -30,4 +30,4 @@ tests-examples
*.csv
/editor/*
!/editor/readonly-editor.tsx
!/editor/readonly-editor.tsx

11028
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "roadmap.sh",
"type": "module",
"version": "1.0.0",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "astro dev --port 3000",
@@ -22,52 +22,48 @@
"test:e2e": "playwright test"
},
"dependencies": {
"@astrojs/react": "^3.0.10",
"@astrojs/sitemap": "^3.0.5",
"@astrojs/tailwind": "^5.1.0",
"@fingerprintjs/fingerprintjs": "^4.2.2",
"@astrojs/react": "^3.0.3",
"@astrojs/sitemap": "^3.0.2",
"@astrojs/tailwind": "^5.0.2",
"@fingerprintjs/fingerprintjs": "^4.1.0",
"@nanostores/react": "^0.7.1",
"@types/react": "^18.2.56",
"@types/react-dom": "^18.2.19",
"astro": "^4.4.0",
"astro-compress": "^2.2.10",
"clsx": "^2.1.0",
"dom-to-image": "^2.6.0",
"dracula-prism": "^2.1.16",
"jose": "^5.2.2",
"@types/react": "^18.2.31",
"@types/react-dom": "^18.2.14",
"astro": "^3.3.3",
"astro-compress": "^2.1.5",
"clsx": "^2.0.0",
"dracula-prism": "^2.1.13",
"jose": "^4.15.4",
"js-cookie": "^3.0.5",
"lucide-react": "^0.334.0",
"nanoid": "^5.0.5",
"nanostores": "^0.9.5",
"node-html-parser": "^6.1.12",
"npm-check-updates": "^16.14.15",
"lucide-react": "^0.288.0",
"nanoid": "^5.0.2",
"nanostores": "^0.9.4",
"node-html-parser": "^6.1.10",
"npm-check-updates": "^16.14.6",
"prismjs": "^1.29.0",
"react": "^18.2.0",
"react-confetti": "^6.1.0",
"react-dom": "^18.2.0",
"reactflow": "^11.10.4",
"reactflow": "^11.9.4",
"rehype-external-links": "^3.0.0",
"remark-parse": "^11.0.0",
"roadmap-renderer": "^1.0.6",
"slugify": "^1.6.6",
"tailwind-merge": "^2.2.1",
"tailwindcss": "^3.4.1",
"unified": "^11.0.4",
"zustand": "^4.5.1"
"tailwind-merge": "^1.14.0",
"tailwindcss": "^3.3.3",
"zustand": "^4.4.4"
},
"devDependencies": {
"@playwright/test": "^1.41.2",
"@playwright/test": "^1.39.0",
"@tailwindcss/typography": "^0.5.10",
"@types/dom-to-image": "^2.6.7",
"@types/js-cookie": "^3.0.6",
"@types/prismjs": "^1.26.3",
"@types/js-cookie": "^3.0.5",
"@types/prismjs": "^1.26.2",
"csv-parser": "^3.0.0",
"gh-pages": "^6.1.1",
"gh-pages": "^6.0.0",
"js-yaml": "^4.1.0",
"markdown-it": "^14.0.0",
"openai": "^4.28.0",
"prettier": "^3.2.5",
"prettier-plugin-astro": "^0.13.0",
"prettier-plugin-tailwindcss": "^0.5.11"
"markdown-it": "^13.0.2",
"openai": "^4.13.0",
"prettier": "^3.0.3",
"prettier-plugin-astro": "^0.12.0",
"prettier-plugin-tailwindcss": "^0.5.6"
}
}

3615
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 32 32">
<path fill="#94a3b8" d="M5 5v22h22V5zm2 2h18v18H7zm4.5 4l3.5 6v5h2v-5l3.5-6h-2L16 15.281L13.5 11z"></path>
</svg>

Before

Width:  |  Height:  |  Size: 203 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 1024 1024"><path fill="#94a3b8" d="M288 568a56 56 0 1 0 112 0a56 56 0 1 0-112 0zm338.7 119.7c-23.1 18.2-68.9 37.8-114.7 37.8s-91.6-19.6-114.7-37.8c-14.4-11.3-35.3-8.9-46.7 5.5s-8.9 35.3 5.5 46.7C396.3 771.6 457.5 792 512 792s115.7-20.4 155.9-52.1a33.25 33.25 0 1 0-41.2-52.2zM960 456c0-61.9-50.1-112-112-112c-42.1 0-78.7 23.2-97.9 57.6c-57.6-31.5-127.7-51.8-204.1-56.5L612.9 195l127.9 36.9c11.5 32.6 42.6 56.1 79.2 56.1c46.4 0 84-37.6 84-84s-37.6-84-84-84c-32 0-59.8 17.9-74 44.2L603.5 123a33.2 33.2 0 0 0-39.6 18.4l-90.8 203.9c-74.5 5.2-142.9 25.4-199.2 56.2A111.94 111.94 0 0 0 176 344c-61.9 0-112 50.1-112 112c0 45.8 27.5 85.1 66.8 102.5c-7.1 21-10.8 43-10.8 65.5c0 154.6 175.5 280 392 280s392-125.4 392-280c0-22.6-3.8-44.5-10.8-65.5C932.5 541.1 960 501.8 960 456zM820 172.5a31.5 31.5 0 1 1 0 63a31.5 31.5 0 0 1 0-63zM120 456c0-30.9 25.1-56 56-56a56 56 0 0 1 50.6 32.1c-29.3 22.2-53.5 47.8-71.5 75.9a56.23 56.23 0 0 1-35.1-52zm392 381.5c-179.8 0-325.5-95.6-325.5-213.5S332.2 410.5 512 410.5S837.5 506.1 837.5 624S691.8 837.5 512 837.5zM868.8 508c-17.9-28.1-42.2-53.7-71.5-75.9c9-18.9 28.3-32.1 50.6-32.1c30.9 0 56 25.1 56 56c.1 23.5-14.5 43.7-35.1 52zM624 568a56 56 0 1 0 112 0a56 56 0 1 0-112 0z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

0
public/manifest/apple-touch-icon.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

0
public/manifest/favicon.ico Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

0
public/manifest/icon152.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

0
public/manifest/icon16.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 123 B

After

Width:  |  Height:  |  Size: 123 B

0
public/manifest/icon196.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

0
public/manifest/icon32.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 267 B

After

Width:  |  Height:  |  Size: 267 B

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 636 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 599 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 522 KiB

View File

@@ -31,17 +31,15 @@ Roadmaps are now interactive, you can click the nodes to read more about the top
Here is the list of available roadmaps with more being actively worked upon.
- [Frontend Roadmap](https://roadmap.sh/frontend) / [Frontend Beginner Roadmap](https://roadmap.sh/frontend?r=frontend-beginner)
- [Backend Roadmap](https://roadmap.sh/backend) / [Backend Beginner Roadmap](https://roadmap.sh/backend?r=backend-beginner)
- [Backend Roadmap](https://roadmap.sh/backend)
- [DevOps Roadmap](https://roadmap.sh/devops) / [DevOps Beginner Roadmap](https://roadmap.sh/devops?r=devops-beginner)
- [Full Stack Roadmap](https://roadmap.sh/full-stack)
- [Computer Science Roadmap](https://roadmap.sh/computer-science)
- [Data Structures and Algorithms Roadmap](https://roadmap.sh/datastructures-and-algorithms)
- [AI and Data Scientist Roadmap](https://roadmap.sh/ai-data-scientist)
- [MLOps Roadmap](https://roadmap.sh/mlops)
- [QA Roadmap](https://roadmap.sh/qa)
- [Python Roadmap](https://roadmap.sh/python)
- [Software Architect Roadmap](https://roadmap.sh/software-architect)
- [Game Developer Roadmap](https://roadmap.sh/game-developer) / [Server Side Game Developer](https://roadmap.sh/server-side-game-developer)
- [Game Developer Roadmap](https://roadmap.sh/game-developer)
- [Software Design and Architecture Roadmap](https://roadmap.sh/software-design-architecture)
- [JavaScript Roadmap](https://roadmap.sh/javascript)
- [TypeScript Roadmap](https://roadmap.sh/typescript)
@@ -55,7 +53,6 @@ Here is the list of available roadmaps with more being actively worked upon.
- [Android Roadmap](https://roadmap.sh/android)
- [Flutter Roadmap](https://roadmap.sh/flutter)
- [Go Roadmap](https://roadmap.sh/golang)
- [Rust Roadmap](https://roadmap.sh/rust)
- [Java Roadmap](https://roadmap.sh/java)
- [Spring Boot Roadmap](https://roadmap.sh/spring-boot)
- [Design System Roadmap](https://roadmap.sh/design-system)
@@ -70,7 +67,6 @@ Here is the list of available roadmaps with more being actively worked upon.
- [UX Design Roadmap](https://roadmap.sh/ux-design)
- [Docker Roadmap](https://roadmap.sh/docker)
- [Prompt Engineering Roadmap](https://roadmap.sh/prompt-engineering)
- [Technical Writer Roadmap](https://roadmap.sh/technical-writer)
There are also interactive best practices:

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env bash
-#!/usr/bin/env bash
set -e

View File

@@ -59,9 +59,9 @@ function writeTopicContent(currTopicUrl) {
const roadmapTitle = roadmapId.replace(/-/g, ' ');
let prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${childTopic}". Write me a brief paragraph for that. Your output should be strictly markdown. Do not include anything other than the description in your output. I already know the benefits of each so do not add benefits in the output.`;
let prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${childTopic}". Write me a brief paragraph for that. Content should be in markdown. I already know the benefits of each so do not add benefits in the output.`;
if (!childTopic) {
prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${parentTopic}". Write me a brief paragraph for that. Your output should be strictly markdown. Do not include anything other than the description in your output. I already know the benefits of each so do not add benefits in the output.`;
prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${parentTopic}". Write me a brief paragraph for that. Content should be in markdown. I already know the benefits of each so do not add benefits in the output.`;
}
console.log(`Generating '${childTopic || parentTopic}'...`);

View File

@@ -1,11 +1,14 @@
import { RoadmapIcon } from "../ReactIcons/RoadmapIcon";
import RoadmapIcon from '../../icons/roadmap.svg';
export function EmptyActivity() {
return (
<div className="rounded-md">
<div className="flex flex-col items-center p-7 text-center">
<RoadmapIcon className="mb-2 w-[60px] h-[60px] sm:h-[120px] sm:w-[120px] opacity-10" />
<img
alt="no roadmaps"
src={RoadmapIcon.src}
className="mb-2 w-[60px] h-[60px] sm:h-[120px] sm:w-[120px] opacity-10"
/>
<h2 className="text-lg sm:text-xl font-bold">No Progress</h2>
<p className="my-1 sm:my-2 max-w-[400px] text-gray-500 text-sm sm:text-base">
Progress will appear here as you start tracking your{' '}

View File

@@ -1,15 +0,0 @@
import { PartyPopper } from 'lucide-react';
export function AppChecklist() {
return (
<div className="fixed bottom-6 right-3">
<a
href="/get-started"
className="flex items-center gap-2 rounded-full border border-slate-900 bg-white py-2 pl-3 pr-4 text-sm font-medium hover:bg-zinc-200"
>
<PartyPopper className="relative -top-[2px] h-[20px] w-[20px] text-purple-600" />
Welcome! Start here
</a>
</div>
);
}

View File

@@ -11,16 +11,15 @@ async function getSVG(name: string) {
const filepath = `/src/icons/${name}.svg`;
const files = import.meta.glob<string>('/src/icons/**/*.svg', {
query: '?raw',
eager: true,
as: 'raw',
});
if (!(filepath in files)) {
throw new Error(`${filepath} not found`);
}
const root = parse(files[filepath].default as string);
const root = parse(files[filepath]);
const svg = root.querySelector('svg');
@@ -36,4 +35,4 @@ const { attributes: baseAttributes, innerHTML } = await getSVG(icon);
const svgAttributes = { ...baseAttributes, ...attributes };
---
<svg {...svgAttributes} set:html={innerHTML} />
<svg {...svgAttributes} set:html={innerHTML}></svg>

View File

@@ -1,41 +0,0 @@
import { useState } from 'react';
import { GitHubButton } from './GitHubButton';
import { GoogleButton } from './GoogleButton';
import { LinkedInButton } from './LinkedInButton';
import { EmailLoginForm } from './EmailLoginForm';
import { EmailSignupForm } from './EmailSignupForm';
type AuthenticationFormProps = {
type?: 'login' | 'signup';
};
export function AuthenticationForm(props: AuthenticationFormProps) {
const { type = 'login' } = props;
const [isDisabled, setIsDisabled] = useState(false);
return (
<>
<div className="flex w-full flex-col gap-2">
<GitHubButton isDisabled={isDisabled} setIsDisabled={setIsDisabled} />
<GoogleButton isDisabled={isDisabled} setIsDisabled={setIsDisabled} />
<LinkedInButton isDisabled={isDisabled} setIsDisabled={setIsDisabled} />
</div>
<div className="flex w-full items-center gap-2 py-6 text-sm text-slate-600">
<div className="h-px w-full bg-slate-200" />
OR
<div className="h-px w-full bg-slate-200" />
</div>
{type === 'login' ? (
<EmailLoginForm isDisabled={isDisabled} setIsDisabled={setIsDisabled} />
) : (
<EmailSignupForm
isDisabled={isDisabled}
setIsDisabled={setIsDisabled}
/>
)}
</>
);
}

View File

@@ -2,16 +2,9 @@ import Cookies from 'js-cookie';
import type { FormEvent } from 'react';
import { useState } from 'react';
import { httpPost } from '../../lib/http';
import { TOKEN_COOKIE_NAME, setAuthToken } from '../../lib/jwt';
type EmailLoginFormProps = {
isDisabled?: boolean;
setIsDisabled?: (isDisabled: boolean) => void;
};
export function EmailLoginForm(props: EmailLoginFormProps) {
const { isDisabled, setIsDisabled } = props;
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
export function EmailLoginForm() {
const [email, setEmail] = useState<string>('');
const [password, setPassword] = useState<string>('');
const [error, setError] = useState('');
@@ -21,7 +14,6 @@ export function EmailLoginForm(props: EmailLoginFormProps) {
const handleFormSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setIsDisabled?.(true);
setError('');
const { response, error } = await httpPost<{ token: string }>(
@@ -34,7 +26,11 @@ export function EmailLoginForm(props: EmailLoginFormProps) {
// Log the user in and reload the page
if (response?.token) {
setAuthToken(response.token);
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
path: '/',
expires: 30,
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
});
window.location.reload();
return;
@@ -49,7 +45,6 @@ export function EmailLoginForm(props: EmailLoginFormProps) {
}
setIsLoading(false);
setIsDisabled?.(false);
setError(error?.message || 'Something went wrong. Please try again later.');
};
@@ -97,7 +92,7 @@ export function EmailLoginForm(props: EmailLoginFormProps) {
<button
type="submit"
disabled={isLoading || isDisabled}
disabled={isLoading}
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
>
{isLoading ? 'Please wait...' : 'Continue'}

View File

@@ -1,14 +1,7 @@
import { type FormEvent, useState } from 'react';
import { httpPost } from '../../lib/http';
type EmailSignupFormProps = {
isDisabled?: boolean;
setIsDisabled?: (isDisabled: boolean) => void;
};
export function EmailSignupForm(props: EmailSignupFormProps) {
const { isDisabled, setIsDisabled } = props;
export function EmailSignupForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
@@ -20,7 +13,6 @@ export function EmailSignupForm(props: EmailSignupFormProps) {
e.preventDefault();
setIsLoading(true);
setIsDisabled?.(true);
setError('');
const { response, error } = await httpPost<{ status: 'ok' }>(
@@ -29,21 +21,20 @@ export function EmailSignupForm(props: EmailSignupFormProps) {
email,
password,
name,
},
}
);
if (error || response?.status !== 'ok') {
setIsLoading(false);
setIsDisabled?.(false);
setError(
error?.message || 'Something went wrong. Please try again later.',
error?.message || 'Something went wrong. Please try again later.'
);
return;
}
window.location.href = `/verification-pending?email=${encodeURIComponent(
email,
email
)}`;
};
@@ -99,7 +90,7 @@ export function EmailSignupForm(props: EmailSignupFormProps) {
<button
type="submit"
disabled={isLoading || isDisabled}
disabled={isLoading}
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
>
{isLoading ? 'Please wait...' : 'Continue to Verify Email'}

View File

@@ -1,23 +1,19 @@
import { useEffect, useState } from 'react';
import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx';
import GitHubIcon from '../../icons/github.svg';
import SpinnerIcon from '../../icons/spinner.svg';
import Cookies from 'js-cookie';
import { TOKEN_COOKIE_NAME, setAuthToken } from '../../lib/jwt';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
import { httpGet } from '../../lib/http';
import { Spinner } from '../ReactIcons/Spinner.tsx';
type GitHubButtonProps = {
isDisabled?: boolean;
setIsDisabled?: (isDisabled: boolean) => void;
};
type GitHubButtonProps = {};
const GITHUB_REDIRECT_AT = 'githubRedirectAt';
const GITHUB_LAST_PAGE = 'githubLastPage';
export function GitHubButton(props: GitHubButtonProps) {
const { isDisabled, setIsDisabled } = props;
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const icon = isLoading ? SpinnerIcon : GitHubIcon;
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
@@ -30,18 +26,16 @@ export function GitHubButton(props: GitHubButtonProps) {
}
setIsLoading(true);
setIsDisabled?.(true);
httpGet<{ token: string }>(
`${import.meta.env.PUBLIC_API_URL}/v1-github-callback${
window.location.search
}`,
}`
)
.then(({ response, error }) => {
if (!response?.token) {
const errMessage = error?.message || 'Something went wrong.';
setError(errMessage);
setIsLoading(false);
setIsDisabled?.(false);
return;
}
@@ -70,39 +64,40 @@ export function GitHubButton(props: GitHubButtonProps) {
localStorage.removeItem(GITHUB_REDIRECT_AT);
localStorage.removeItem(GITHUB_LAST_PAGE);
setAuthToken(response.token);
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
path: '/',
expires: 30,
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
});
window.location.href = redirectUrl;
})
.catch((err) => {
setError('Something went wrong. Please try again later.');
setIsLoading(false);
setIsDisabled?.(false);
});
}, []);
const handleClick = async () => {
setIsLoading(true);
setIsDisabled?.(true);
const { response, error } = await httpGet<{ loginUrl: string }>(
`${import.meta.env.PUBLIC_API_URL}/v1-github-login`,
`${import.meta.env.PUBLIC_API_URL}/v1-github-login`
);
if (error || !response?.loginUrl) {
setError(
error?.message || 'Something went wrong. Please try again later.',
error?.message || 'Something went wrong. Please try again later.'
);
setIsLoading(false);
setIsDisabled?.(false);
return;
}
// For non authentication pages, we want to redirect back to the page
// the user was on before they clicked the social login button
if (!['/login', '/signup'].includes(window.location.pathname)) {
const pagePath = ['/respond-invite', '/befriend', '/r', '/ai'].includes(
window.location.pathname,
const pagePath = ['/respond-invite', '/befriend'].includes(
window.location.pathname
)
? window.location.pathname + window.location.search
: window.location.pathname;
@@ -118,14 +113,14 @@ export function GitHubButton(props: GitHubButtonProps) {
<>
<button
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
disabled={isLoading || isDisabled}
disabled={isLoading}
onClick={handleClick}
>
{isLoading ? (
<Spinner className={'h-[18px] w-[18px]'} isDualRing={false} />
) : (
<GitHubIcon className={'h-[18px] w-[18px]'} />
)}
<img
src={icon.src}
alt="GitHub"
className={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
/>
Continue with GitHub
</button>
{error && (

View File

@@ -1,23 +1,19 @@
import { useEffect, useState } from 'react';
import Cookies from 'js-cookie';
import { TOKEN_COOKIE_NAME, setAuthToken } from '../../lib/jwt';
import GoogleIcon from '../../icons/google.svg';
import SpinnerIcon from '../../icons/spinner.svg';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
import { httpGet } from '../../lib/http';
import { Spinner } from '../ReactIcons/Spinner.tsx';
import { GoogleIcon } from '../ReactIcons/GoogleIcon.tsx';
type GoogleButtonProps = {
isDisabled?: boolean;
setIsDisabled?: (isDisabled: boolean) => void;
};
type GoogleButtonProps = {};
const GOOGLE_REDIRECT_AT = 'googleRedirectAt';
const GOOGLE_LAST_PAGE = 'googleLastPage';
export function GoogleButton(props: GoogleButtonProps) {
const { isDisabled, setIsDisabled } = props;
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const icon = isLoading ? SpinnerIcon : GoogleIcon;
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
@@ -30,17 +26,15 @@ export function GoogleButton(props: GoogleButtonProps) {
}
setIsLoading(true);
setIsDisabled?.(true);
httpGet<{ token: string }>(
`${import.meta.env.PUBLIC_API_URL}/v1-google-callback${
window.location.search
}`,
}`
)
.then(({ response, error }) => {
if (!response?.token) {
setError(error?.message || 'Something went wrong.');
setIsLoading(false);
setIsDisabled?.(false);
return;
}
@@ -69,27 +63,28 @@ export function GoogleButton(props: GoogleButtonProps) {
localStorage.removeItem(GOOGLE_REDIRECT_AT);
localStorage.removeItem(GOOGLE_LAST_PAGE);
setAuthToken(response.token);
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
path: '/',
expires: 30,
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
});
window.location.href = redirectUrl;
})
.catch((err) => {
setError('Something went wrong. Please try again later.');
setIsLoading(false);
setIsDisabled?.(false);
});
}, []);
const handleClick = () => {
setIsLoading(true);
setIsDisabled?.(true);
httpGet<{ loginUrl: string }>(
`${import.meta.env.PUBLIC_API_URL}/v1-google-login`,
`${import.meta.env.PUBLIC_API_URL}/v1-google-login`
)
.then(({ response, error }) => {
if (!response?.loginUrl) {
setError(error?.message || 'Something went wrong.');
setIsLoading(false);
setIsDisabled?.(false);
return;
}
@@ -97,8 +92,8 @@ export function GoogleButton(props: GoogleButtonProps) {
// For non authentication pages, we want to redirect back to the page
// the user was on before they clicked the social login button
if (!['/login', '/signup'].includes(window.location.pathname)) {
const pagePath = ['/respond-invite', '/befriend', '/r', '/ai'].includes(
window.location.pathname,
const pagePath = ['/respond-invite', '/befriend'].includes(
window.location.pathname
)
? window.location.pathname + window.location.search
: window.location.pathname;
@@ -112,7 +107,6 @@ export function GoogleButton(props: GoogleButtonProps) {
.catch((err) => {
setError('Something went wrong. Please try again later.');
setIsLoading(false);
setIsDisabled?.(false);
});
};
@@ -120,14 +114,14 @@ export function GoogleButton(props: GoogleButtonProps) {
<>
<button
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
disabled={isLoading || isDisabled}
disabled={isLoading}
onClick={handleClick}
>
{isLoading ? (
<Spinner className={'h-[18px] w-[18px]'} isDualRing={false} />
) : (
<GoogleIcon className={'h-[18px] w-[18px]'} />
)}
<img
src={icon.src}
alt="Google"
className={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
/>
Continue with Google
</button>
{error && (

View File

@@ -1,23 +1,19 @@
import { useEffect, useState } from 'react';
import Cookies from 'js-cookie';
import { TOKEN_COOKIE_NAME, setAuthToken } from '../../lib/jwt';
import LinkedIn from '../../icons/linkedin.svg';
import SpinnerIcon from '../../icons/spinner.svg';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
import { httpGet } from '../../lib/http';
import { Spinner } from '../ReactIcons/Spinner.tsx';
import { LinkedInIcon } from '../ReactIcons/LinkedInIcon.tsx';
type LinkedInButtonProps = {
isDisabled?: boolean;
setIsDisabled?: (isDisabled: boolean) => void;
};
type LinkedInButtonProps = {};
const LINKEDIN_REDIRECT_AT = 'linkedInRedirectAt';
const LINKEDIN_LAST_PAGE = 'linkedInLastPage';
export function LinkedInButton(props: LinkedInButtonProps) {
const { isDisabled, setIsDisabled } = props;
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const icon = isLoading ? SpinnerIcon : LinkedIn;
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
@@ -30,17 +26,15 @@ export function LinkedInButton(props: LinkedInButtonProps) {
}
setIsLoading(true);
setIsDisabled?.(true);
httpGet<{ token: string }>(
`${import.meta.env.PUBLIC_API_URL}/v1-linkedin-callback${
window.location.search
}`,
}`
)
.then(({ response, error }) => {
if (!response?.token) {
setError(error?.message || 'Something went wrong.');
setIsLoading(false);
setIsDisabled?.(false);
return;
}
@@ -69,27 +63,28 @@ export function LinkedInButton(props: LinkedInButtonProps) {
localStorage.removeItem(LINKEDIN_REDIRECT_AT);
localStorage.removeItem(LINKEDIN_LAST_PAGE);
setAuthToken(response.token);
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
path: '/',
expires: 30,
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
});
window.location.href = redirectUrl;
})
.catch((err) => {
setError('Something went wrong. Please try again later.');
setIsLoading(false);
setIsDisabled?.(false);
});
}, []);
const handleClick = () => {
setIsLoading(true);
setIsDisabled?.(true);
httpGet<{ loginUrl: string }>(
`${import.meta.env.PUBLIC_API_URL}/v1-linkedin-login`,
`${import.meta.env.PUBLIC_API_URL}/v1-linkedin-login`
)
.then(({ response, error }) => {
if (!response?.loginUrl) {
setError(error?.message || 'Something went wrong.');
setIsLoading(false);
setIsDisabled?.(false);
return;
}
@@ -97,8 +92,8 @@ export function LinkedInButton(props: LinkedInButtonProps) {
// For non authentication pages, we want to redirect back to the page
// the user was on before they clicked the social login button
if (!['/login', '/signup'].includes(window.location.pathname)) {
const pagePath = ['/respond-invite', '/befriend', '/r', '/ai'].includes(
window.location.pathname,
const pagePath = ['/respond-invite', '/befriend'].includes(
window.location.pathname
)
? window.location.pathname + window.location.search
: window.location.pathname;
@@ -112,7 +107,6 @@ export function LinkedInButton(props: LinkedInButtonProps) {
.catch((err) => {
setError('Something went wrong. Please try again later.');
setIsLoading(false);
setIsDisabled?.(false);
});
};
@@ -120,14 +114,14 @@ export function LinkedInButton(props: LinkedInButtonProps) {
<>
<button
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
disabled={isLoading || isDisabled}
disabled={isLoading}
onClick={handleClick}
>
{isLoading ? (
<Spinner className={'h-[18px] w-[18px]'} isDualRing={false} />
) : (
<LinkedInIcon className={'h-[18px] w-[18px]'} />
)}
<img
src={icon.src}
alt="Google"
className={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
/>
Continue with LinkedIn
</button>
{error && (

View File

@@ -1,10 +1,14 @@
---
import Popup from '../Popup/Popup.astro';
import { AuthenticationForm } from './AuthenticationForm';
import { EmailLoginForm } from './EmailLoginForm';
import Divider from './Divider.astro';
import { GitHubButton } from './GitHubButton';
import { GoogleButton } from './GoogleButton';
import { LinkedInButton } from './LinkedInButton';
---
<Popup id='login-popup' title='' subtitle=''>
<div class='mb-7 text-center'>
<div class='text-center'>
<p class='mb-3 text-2xl font-semibold leading-5 text-slate-900'>
Login to your account
</p>
@@ -12,9 +16,19 @@ import { AuthenticationForm } from './AuthenticationForm';
You must be logged in to perform this action.
</p>
</div>
<AuthenticationForm client:load />
<div class='mt-7 flex flex-col gap-2'>
<GitHubButton client:load />
<GoogleButton client:load />
<LinkedInButton client:load />
</div>
<Divider />
<EmailLoginForm client:load />
<div class='mt-6 text-center text-sm text-slate-600'>
Don't have an account?{' '}
<a href='/signup' class='font-medium text-[#4285f4]'> Sign up</a>
<a href='/signup' class='font-medium text-[#4285f4]'>Sign up</a>
</div>
</Popup>

View File

@@ -1,7 +1,7 @@
import { type FormEvent, useEffect, useState } from 'react';
import { httpPost } from '../../lib/http';
import Cookies from 'js-cookie';
import { TOKEN_COOKIE_NAME, setAuthToken } from '../../lib/jwt';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
export function ResetPasswordForm() {
const [code, setCode] = useState('');
@@ -37,7 +37,7 @@ export function ResetPasswordForm() {
newPassword: password,
confirmPassword: passwordConfirm,
code,
},
}
);
if (error?.message) {
@@ -53,7 +53,11 @@ export function ResetPasswordForm() {
}
const token = response.token;
setAuthToken(response.token);
Cookies.set(TOKEN_COOKIE_NAME, token, {
path: '/',
expires: 30,
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
});
window.location.href = '/';
};

View File

@@ -1,9 +1,9 @@
import { useEffect, useState } from 'react';
import Cookies from 'js-cookie';
import { httpPost } from '../../lib/http';
import { TOKEN_COOKIE_NAME, setAuthToken } from '../../lib/jwt';
import { Spinner } from '../ReactIcons/Spinner';
import { ErrorIcon2 } from '../ReactIcons/ErrorIcon2';
import ErrorIcon from '../../icons/error.svg';
import SpinnerIcon from '../../icons/spinner.svg';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
export function TriggerVerifyAccount() {
const [isLoading, setIsLoading] = useState(true);
@@ -16,7 +16,7 @@ export function TriggerVerifyAccount() {
`${import.meta.env.PUBLIC_API_URL}/v1-verify-account`,
{
code,
},
}
)
.then(({ response, error }) => {
if (!response?.token) {
@@ -26,7 +26,11 @@ export function TriggerVerifyAccount() {
return;
}
setAuthToken(response.token);
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
path: '/',
expires: 30,
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
});
window.location.href = '/';
})
.catch((err) => {
@@ -51,8 +55,20 @@ export function TriggerVerifyAccount() {
return (
<div className="mx-auto flex max-w-md flex-col items-center pt-0 sm:pt-12">
<div className="mx-auto max-w-md text-center">
{isLoading && <Spinner className="mx-auto h-16 w-16" />}
{error && <ErrorIcon2 className="mx-auto h-16 w-16" />}
{isLoading && (
<img
alt={'Please wait.'}
src={SpinnerIcon.src}
className={'mx-auto h-16 w-16 animate-spin'}
/>
)}
{error && (
<img
alt={'Please wait.'}
src={ErrorIcon.src}
className={'mx-auto h-16 w-16'}
/>
)}
<h2 className="mb-1 mt-4 text-center text-xl font-semibold sm:mb-3 sm:mt-4 sm:text-2xl">
Verifying your account
</h2>

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import VerifyLetterIcon from '../../icons/verify-letter.svg';
import { httpPost } from '../../lib/http';
import { VerifyLetterIcon } from '../ReactIcons/VerifyLetterIcon';
export function VerificationEmailMessage() {
const [email, setEmail] = useState('..');
@@ -37,7 +37,11 @@ export function VerificationEmailMessage() {
return (
<div className="mx-auto max-w-md text-center">
<VerifyLetterIcon className="mx-auto mb-4 h-20 w-40 sm:h-40" />
<img
alt="Verify Email"
src={VerifyLetterIcon.src}
className="mx-auto mb-4 h-20 w-40 sm:h-40"
/>
<h2 className="my-2 text-center text-xl font-semibold sm:my-5 sm:text-2xl">
Verify your email address
</h2>

View File

@@ -1,47 +1,35 @@
import {
Fragment,
type ReactElement,
useEffect,
useRef,
useState,
} from 'react';
import { Fragment, useEffect, useRef, useState } from 'react';
import { useKeydown } from '../../hooks/use-keydown';
import { useOutsideClick } from '../../hooks/use-outside-click';
import BestPracticesIcon from '../../icons/best-practices.svg';
import ClipboardIcon from '../../icons/clipboard.svg';
import GuideIcon from '../../icons/guide.svg';
import HomeIcon from '../../icons/home.svg';
import RoadmapIcon from '../../icons/roadmap.svg';
import UserIcon from '../../icons/user.svg';
import GroupIcon from '../../icons/group.svg';
import VideoIcon from '../../icons/video.svg';
import { httpGet } from '../../lib/http';
import { isLoggedIn } from '../../lib/jwt';
import { BestPracticesIcon } from '../ReactIcons/BestPracticesIcon.tsx';
import { UserIcon } from '../ReactIcons/UserIcon.tsx';
import { GroupIcon } from '../ReactIcons/GroupIcon.tsx';
import { RoadmapIcon } from '../ReactIcons/RoadmapIcon.tsx';
import { ClipboardIcon } from '../ReactIcons/ClipboardIcon.tsx';
import { GuideIcon } from '../ReactIcons/GuideIcon.tsx';
import { HomeIcon } from '../ReactIcons/HomeIcon.tsx';
import { VideoIcon } from '../ReactIcons/VideoIcon.tsx';
export type PageType = {
id: string;
url: string;
title: string;
group: string;
icon?: ReactElement;
icon?: string;
isProtected?: boolean;
metadata?: Record<string, any>;
};
const defaultPages: PageType[] = [
{
id: 'home',
url: '/',
title: 'Home',
group: 'Pages',
icon: <HomeIcon className="mr-2 h-4 w-4 stroke-2" />,
},
{ id: 'home', url: '/', title: 'Home', group: 'Pages', icon: HomeIcon.src },
{
id: 'account',
url: '/account',
title: 'Account',
group: 'Pages',
icon: <UserIcon className="mr-2 h-4 w-4 stroke-2" />,
icon: UserIcon.src,
isProtected: true,
},
{
@@ -49,7 +37,7 @@ const defaultPages: PageType[] = [
url: '/team',
title: 'Teams',
group: 'Pages',
icon: <GroupIcon className="mr-2 h-4 w-4 stroke-2" />,
icon: GroupIcon.src,
isProtected: true,
},
{
@@ -57,7 +45,7 @@ const defaultPages: PageType[] = [
url: '/account/friends',
title: 'Friends',
group: 'Pages',
icon: <GroupIcon className="mr-2 h-4 w-4 stroke-2" />,
icon: GroupIcon.src,
isProtected: true,
},
{
@@ -65,14 +53,14 @@ const defaultPages: PageType[] = [
url: '/roadmaps',
title: 'Roadmaps',
group: 'Pages',
icon: <RoadmapIcon className="mr-2 h-4 w-4 stroke-2" />,
icon: RoadmapIcon.src,
},
{
id: 'account-roadmaps',
url: '/account/roadmaps',
title: 'Custom Roadmaps',
group: 'Pages',
icon: <RoadmapIcon className="mr-2 h-4 w-4 stroke-2" />,
icon: RoadmapIcon.src,
isProtected: true,
},
{
@@ -80,28 +68,28 @@ const defaultPages: PageType[] = [
url: '/best-practices',
title: 'Best Practices',
group: 'Pages',
icon: <BestPracticesIcon className="mr-2 h-4 w-4 stroke-2" />,
icon: BestPracticesIcon.src,
},
{
id: 'questions',
url: '/questions',
title: 'Questions',
group: 'Pages',
icon: <ClipboardIcon className="mr-2 h-4 w-4 stroke-2" />,
icon: ClipboardIcon.src,
},
{
id: 'guides',
url: '/guides',
title: 'Guides',
group: 'Pages',
icon: <GuideIcon className="mr-2 h-4 w-4 stroke-2" />,
icon: GuideIcon.src,
},
{
id: 'videos',
url: '/videos',
title: 'Videos',
group: 'Pages',
icon: <VideoIcon className="mr-2 h-4 w-4 stroke-2" />,
icon: VideoIcon.src,
},
];
@@ -211,7 +199,7 @@ export function CommandMenu() {
} else if (e.key === 'ArrowUp') {
const canGoPrev = activeCounter > 0;
setActiveCounter(
canGoPrev ? activeCounter - 1 : searchResults.length - 1,
canGoPrev ? activeCounter - 1 : searchResults.length - 1
);
} else if (e.key === 'Tab') {
e.preventDefault();
@@ -254,7 +242,13 @@ export function CommandMenu() {
{!page.icon && (
<span className="mr-2 text-gray-400">{page.group}</span>
)}
{page.icon && page.icon}
{page.icon && (
<img
alt={page.title}
src={page.icon}
className="mr-2 h-4 w-4"
/>
)}
{page.title}
</a>
</Fragment>

View File

@@ -1,4 +1,4 @@
import { ChevronDownIcon } from '../ReactIcons/ChevronDownIcon';
import ChevronDownIcon from '../../icons/chevron-down.svg';
type NotDropdownProps = {
onClick: () => void;
@@ -37,7 +37,11 @@ export function NotDropdown(props: NotDropdownProps) {
</div>
)}
<ChevronDownIcon className="relative top-[1px] h-[17px] w-[17px] opacity-40" />
<img
alt={singularName}
src={ChevronDownIcon.src}
className={'relative top-[1px] h-[17px] w-[17px] opacity-40'}
/>
</div>
);
}

View File

@@ -3,8 +3,8 @@ import { useKeydown } from '../../hooks/use-keydown';
import { useOutsideClick } from '../../hooks/use-outside-click';
import type { PageType } from '../CommandMenu/CommandMenu';
import type { TeamResourceConfig } from './RoadmapSelector';
import CloseIcon from '../../icons/close.svg';
import { SelectRoadmapModalItem } from './SelectRoadmapModalItem';
import { XIcon } from 'lucide-react';
export type SelectRoadmapModalProps = {
teamId: string;
@@ -60,11 +60,11 @@ export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
setSearchResults(searchResults);
}, [searchText, allRoadmaps]);
const roleBasedRoadmaps = searchResults.filter(
(roadmap) => roadmap?.metadata?.tags?.includes('role-roadmap'),
const roleBasedRoadmaps = searchResults.filter((roadmap) =>
roadmap?.metadata?.tags?.includes('role-roadmap')
);
const skillBasedRoadmaps = searchResults.filter(
(roadmap) => roadmap?.metadata?.tags?.includes('skill-roadmap'),
const skillBasedRoadmaps = searchResults.filter((roadmap) =>
roadmap?.metadata?.tags?.includes('skill-roadmap')
);
return (
@@ -79,7 +79,7 @@ export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
className="popup-close absolute right-2.5 top-3 ml-auto inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-100 hover:text-gray-900"
onClick={onClose}
>
<XIcon className="h-4 w-4" />
<img alt={'close'} src={CloseIcon.src} className="h-4 w-4" />
<span className="sr-only">Close modal</span>
</button>
<input
@@ -101,7 +101,7 @@ export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
<div className="mb-5 flex flex-wrap items-center gap-2">
{roleBasedRoadmaps.map((roadmap) => {
const isSelected = !!teamResourceConfig?.find(
(r) => r.resourceId === roadmap.id,
(r) => r.resourceId === roadmap.id
);
return (
@@ -127,7 +127,7 @@ export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
<div className="flex flex-wrap items-center gap-2">
{skillBasedRoadmaps.map((roadmap) => {
const isSelected = !!teamResourceConfig.find(
(r) => r.resourceId === roadmap.id,
(r) => r.resourceId === roadmap.id
);
return (

View File

@@ -1,22 +1,22 @@
import BuildingIcon from '../../icons/building.svg';
import UsersIcon from '../../icons/users.svg';
import type { TeamDocument } from './CreateTeamForm';
import { httpPut } from '../../lib/http';
import { useState } from 'react';
import { NextButton } from './NextButton';
import { BuildingIcon } from '../ReactIcons/BuildingIcon.tsx';
import { UsersIcon } from '../ReactIcons/UsersIcon.tsx';
export const validTeamTypes = [
{
value: 'company',
label: 'Company',
icon: BuildingIcon,
icon: BuildingIcon.src,
description:
'Track the skills and learning progress of the tech team at your company',
},
{
value: 'study_group',
label: 'Study Group',
icon: UsersIcon,
icon: UsersIcon.src,
description:
'Invite your friends or course-mates and track your learning progress together',
},
@@ -56,7 +56,7 @@ export function Step0(props: Step0Props) {
teamSize: team.teamSize,
linkedInUrl: team?.links?.linkedIn || undefined,
}),
},
}
);
if (error || !response) {
@@ -76,20 +76,21 @@ export function Step0(props: Step0Props) {
{validTeamTypes.map((validTeamType) => (
<button
key={validTeamType.value}
className={`flex flex-grow flex-col items-center rounded-lg border px-5 pb-10 pt-12 ${
className={`flex flex-grow flex-col items-center rounded-lg border px-5 pt-12 pb-10 ${
validTeamType.value == selectedTeamType
? 'border-gray-400 bg-gray-100'
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-50'
}`}
onClick={() => setSelectedTeamType(validTeamType.value)}
>
{
<validTeamType.icon
className={`mb-3 h-12 w-12 opacity-10 ${
validTeamType.value === selectedTeamType ? 'opacity-100' : ''
}`}
/>
}
<img
key={validTeamType.value}
alt={validTeamType.label}
src={validTeamType.icon}
className={`mb-3 h-12 w-12 opacity-10 ${
validTeamType.value === selectedTeamType ? 'opacity-100' : ''
}`}
/>
<span className="mb-2 block text-2xl font-bold">
{validTeamType.label}
</span>

View File

@@ -53,20 +53,14 @@ export type GetRoadmapResponse = RoadmapDocument & {
export function hideRoadmapLoader() {
const loaderEl = document.querySelector(
'[data-roadmap-loader]',
'[data-roadmap-loader]'
) as HTMLElement;
if (loaderEl) {
loaderEl.remove();
}
}
type CustomRoadmapProps = {
isEmbed?: boolean;
};
export function CustomRoadmap(props: CustomRoadmapProps) {
const { isEmbed = false } = props;
export function CustomRoadmap() {
const { id, secret } = getUrlParams() as { id: string; secret: string };
const [isLoading, setIsLoading] = useState(true);
@@ -77,15 +71,14 @@ export function CustomRoadmap(props: CustomRoadmapProps) {
setIsLoading(true);
const roadmapUrl = new URL(
`${import.meta.env.PUBLIC_API_URL}/v1-get-roadmap/${id}`,
`${import.meta.env.PUBLIC_API_URL}/v1-get-roadmap/${id}`
);
if (secret) {
roadmapUrl.searchParams.set('secret', secret);
}
const { response, error } = await httpGet<GetRoadmapResponse>(
roadmapUrl.toString(),
roadmapUrl.toString()
);
if (error || !response) {
@@ -101,10 +94,19 @@ export function CustomRoadmap(props: CustomRoadmapProps) {
setIsLoading(false);
}
async function trackVisit() {
if (!isLoggedIn()) return;
await httpPost(`${import.meta.env.PUBLIC_API_URL}/v1-visit`, {
resourceId: id,
resourceType: 'roadmap',
});
}
useEffect(() => {
getRoadmap().finally(() => {
hideRoadmapLoader();
});
trackVisit().then();
}, []);
if (isLoading) {
@@ -117,9 +119,9 @@ export function CustomRoadmap(props: CustomRoadmapProps) {
return (
<>
{!isEmbed && <RoadmapHeader />}
<FlowRoadmapRenderer isEmbed={isEmbed} roadmap={roadmap!} />
<TopicDetail isEmbed={isEmbed} canSubmitContribution={false} />
<RoadmapHeader />
<FlowRoadmapRenderer roadmap={roadmap!} />
<TopicDetail canSubmitContribution={false} />
</>
);
}

View File

@@ -1,55 +0,0 @@
import { BadgeCheck, MessageCircleHeart, PencilRuler } from 'lucide-react';
import { showLoginPopup } from '../../lib/popup.ts';
import { isLoggedIn } from '../../lib/jwt.ts';
import { useState } from 'react';
import { CreateRoadmapModal } from './CreateRoadmap/CreateRoadmapModal.tsx';
export function CustomRoadmapAlert() {
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
return (
<>
{isCreatingRoadmap && (
<CreateRoadmapModal
onClose={() => {
setIsCreatingRoadmap(false);
}}
/>
)}
<div className="relative mb-5 mt-0 rounded-md border border-yellow-500 bg-yellow-100 p-2 sm:-mt-6 sm:mb-7 sm:p-2.5">
<h2 className="text-base font-semibold text-yellow-800 sm:text-lg">
Community Roadmap
</h2>
<p className="mt-2 mb-2.5 sm:mb-1.5 sm:mt-1 text-sm text-yellow-800 sm:text-base">
This is a custom roadmap made by community and isn't verified by{' '}
<span className="font-semibold">roadmap.sh</span>
</p>
<div className="flex items-start sm:items-center flex-col sm:flex-row gap-2">
<a
href="/roadmaps"
className="inline-flex items-center gap-1.5 text-sm font-semibold text-yellow-700 underline-offset-2 hover:underline"
>
<BadgeCheck className="h-4 w-4 stroke-[2.5]" />
Visit Official Roadmaps
</a>
<span className="font-black text-yellow-700 hidden sm:block">&middot;</span>
<button
className="inline-flex items-center gap-1.5 text-sm font-semibold text-yellow-700 underline-offset-2 hover:underline"
onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
} else {
setIsCreatingRoadmap(true);
}
}}
>
<PencilRuler className="h-4 w-4 stroke-[2.5]" />
Create Your Own Roadmap
</button>
</div>
<MessageCircleHeart className="absolute bottom-2 right-2 hidden h-12 w-12 text-yellow-500 opacity-50 sm:block" />
</div>
</>
);
}

View File

@@ -1,81 +0,0 @@
import { useStore } from '@nanostores/react';
import { Check, Copy } from 'lucide-react';
import { Modal } from '../Modal';
import { useToast } from '../../hooks/use-toast';
import { useCopyText } from '../../hooks/use-copy-text';
import { currentRoadmap, isCurrentRoadmapPersonal } from '../../stores/roadmap';
import { cn } from '../../lib/classname.ts';
type ShareRoadmapModalProps = {
onClose: () => void;
};
export function EmbedRoadmapModal(props: ShareRoadmapModalProps) {
const { onClose } = props;
const toast = useToast();
const $currentRoadmap = useStore(currentRoadmap);
const $isCurrentRoadmapPersonal = useStore(isCurrentRoadmapPersonal);
const roadmapId = $currentRoadmap?._id!;
const { copyText, isCopied } = useCopyText();
const isDev = import.meta.env.DEV;
const baseUrl = isDev ? 'http://localhost:3000' : 'https://roadmap.sh';
const embedHtml = `<iframe src="${baseUrl}/r/embed?id=${roadmapId}" width="100%" height="500px" frameBorder="0"\n></iframe>`;
return (
<Modal onClose={onClose} wrapperClassName={'max-w-[500px]'}>
<div className="p-4 pb-0">
<h1 className="text-xl font-semibold leading-5 text-gray-900">
Embed Roadmap
</h1>
</div>
<div className="px-4 pt-3">
<p className={'mb-2 text-sm text-gray-500'}>
Copy the following HTML code and paste it into your website.
</p>
<input
type="text"
value={embedHtml}
readOnly={true}
onClick={(e) => {
e.currentTarget.select();
copyText(embedHtml);
}}
className="w-full resize-none rounded-md border bg-gray-50 p-2 text-sm"
/>
</div>
<div className="flex items-center justify-between px-4 pb-4 pt-2">
<button
className={cn(
'flex h-9 w-full items-center justify-center rounded-md border border-transparent px-4 py-2 text-sm font-medium text-white outline-none',
{
'bg-green-500 hover:bg-green-600 focus:bg-green-600': isCopied,
'bg-gray-900 hover:bg-gray-800 focus:bg-gray-800': !isCopied,
},
)}
onClick={() => {
copyText(embedHtml);
}}
>
{isCopied ? (
<>
<Check size={14} className="mr-2 stroke-[2.5]" />
Copied
</>
) : (
<>
<Copy size={14} className="mr-2 stroke-[2.5]" />
Copy Link
</>
)}
</button>
</div>
</Modal>
);
}

View File

@@ -1,27 +1,25 @@
import { ReadonlyEditor } from '../../../editor/readonly-editor';
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
import {
refreshProgressCounters,
renderResourceProgress,
renderTopicProgress,
type ResourceProgressType,
updateResourceProgress,
type ResourceProgressType,
renderTopicProgress,
refreshProgressCounters,
} from '../../lib/resource-progress';
import { pageProgressMessage } from '../../stores/page';
import { useToast } from '../../hooks/use-toast';
import type { Node } from 'reactflow';
import { type MouseEvent, useCallback, useRef, useState } from 'react';
import { useCallback, type MouseEvent, useMemo, useState, useRef } from 'react';
import { EmptyRoadmap } from './EmptyRoadmap';
import { cn } from '../../lib/classname';
import { totalRoadmapNodes } from '../../stores/roadmap.ts';
type FlowRoadmapRendererProps = {
isEmbed?: boolean;
roadmap: RoadmapDocument;
};
export function FlowRoadmapRenderer(props: FlowRoadmapRendererProps) {
const { roadmap, isEmbed = false } = props;
const { roadmap } = props;
const roadmapId = String(roadmap._id!);
const [hideRenderer, setHideRenderer] = useState(false);
@@ -33,10 +31,6 @@ export function FlowRoadmapRenderer(props: FlowRoadmapRendererProps) {
topicId: string,
newStatus: ResourceProgressType,
) {
if (isEmbed) {
return;
}
pageProgressMessage.set('Updating progress');
updateResourceProgress(
{
@@ -144,12 +138,6 @@ export function FlowRoadmapRenderer(props: FlowRoadmapRendererProps) {
)}
onRendered={() => {
renderResourceProgress('roadmap', roadmapId).then(() => {
totalRoadmapNodes.set(
roadmap?.nodes?.filter((node) => {
return ['topic', 'subtopic'].includes(node.type);
}).length || 0,
);
if (roadmap?.nodes?.length === 0) {
setHideRenderer(true);
editorWrapperRef?.current?.classList.add('hidden');

View File

@@ -1,7 +1,7 @@
import MoreIcon from '../../icons/more-vertical.svg';
import { useRef, useState } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { Lock, MoreVertical, Shapes, Trash2 } from 'lucide-react';
import { MoreVerticalIcon } from '../ReactIcons/MoreVerticalIcon.tsx';
type PersonalRoadmapActionDropdownProps = {
onDelete?: () => void;
@@ -9,9 +9,7 @@ type PersonalRoadmapActionDropdownProps = {
onUpdateSharing?: () => void;
};
export function PersonalRoadmapActionDropdown(
props: PersonalRoadmapActionDropdownProps,
) {
export function PersonalRoadmapActionDropdown(props: PersonalRoadmapActionDropdownProps) {
const { onDelete, onUpdateSharing, onCustomize } = props;
const menuRef = useRef<HTMLDivElement>(null);
@@ -28,7 +26,7 @@ export function PersonalRoadmapActionDropdown(
onClick={() => setIsOpen(!isOpen)}
className="hidden items-center opacity-60 transition-opacity hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-30 sm:flex"
>
<MoreVerticalIcon className={'h-4 w-4'} />
<img alt="menu" src={MoreIcon.src} className="h-4 w-4" />
</button>
<button

View File

@@ -14,11 +14,11 @@ import {
type AllowedRoadmapVisibility,
type RoadmapDocument,
} from './CreateRoadmap/CreateRoadmapModal';
import RoadmapIcon from '../../icons/roadmap.svg';
import { PersonalRoadmapActionDropdown } from './PersonalRoadmapActionDropdown';
import type { GetRoadmapListResponse } from './RoadmapListPage';
import { useState, type Dispatch, type SetStateAction } from 'react';
import { ShareOptionsModal } from '../ShareOptions/ShareOptionsModal';
import {RoadmapIcon} from "../ReactIcons/RoadmapIcon.tsx";
type PersonalRoadmapListType = {
roadmaps: GetRoadmapListResponse['personalRoadmaps'];
@@ -91,8 +91,11 @@ export function PersonalRoadmapList(props: PersonalRoadmapListType) {
if (roadmapList.length === 0) {
return (
<div className="flex flex-col items-center p-4 py-20">
<RoadmapIcon className="mb-4 h-24 w-24 opacity-10" />
<img
alt="roadmap"
src={RoadmapIcon.src}
className="mb-4 h-24 w-24 opacity-10"
/>
<h3 className="mb-1 text-2xl font-bold text-gray-900">No roadmaps</h3>
<p className="text-base text-gray-500">
Create a roadmap to get started

View File

@@ -11,8 +11,6 @@ import { RoadmapActionButton } from './RoadmapActionButton';
import { Lock, Shapes } from 'lucide-react';
import { Modal } from '../Modal';
import { ShareSuccess } from '../ShareOptions/ShareSuccess';
import { ShareRoadmapButton } from '../ShareRoadmapButton.tsx';
import { CustomRoadmapAlert } from './CustomRoadmapAlert.tsx';
type RoadmapHeaderProps = {};
@@ -46,11 +44,11 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
{
resourceId: roadmapId,
resourceType: 'roadmap',
},
}
));
} else {
({ error, response } = await httpDelete<TeamResourceConfig>(
`${baseApiUrl}/v1-delete-roadmap/${roadmapId}`,
`${baseApiUrl}/v1-delete-roadmap/${roadmapId}`
));
}
@@ -90,8 +88,6 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
return (
<div className="border-b">
<div className="container relative py-5 sm:py-12">
{!$canManageCurrentRoadmap && <CustomRoadmapAlert />}
{creator?.name && (
<div className="-mb-1 flex items-center gap-1.5 text-sm text-gray-500">
<img
@@ -123,7 +119,7 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
</div>
<div className="flex justify-between gap-2 sm:gap-0">
<div className="flex justify-stretch gap-1 sm:gap-2">
<div className="flex gap-1 sm:gap-2">
<a
href="/roadmaps"
className="rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm"
@@ -132,12 +128,14 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
&larr;<span className="hidden sm:inline">&nbsp;All Roadmaps</span>
</a>
<ShareRoadmapButton
roadmapId={roadmapId!}
description={description!}
pageUrl={`https://roadmap.sh/r?id=${roadmapId}`}
allowEmbed={true}
/>
<button
data-guest-required
data-popup="login-popup"
className="inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm"
aria-label="Subscribe for Updates"
>
<span className="ml-2">Subscribe</span>
</button>
</div>
<div className="flex items-center gap-2">
{$canManageCurrentRoadmap && (
@@ -164,9 +162,9 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
)}
<a
href={`${
import.meta.env.PUBLIC_EDITOR_APP_URL
}/${$currentRoadmap?._id}`}
href={`${import.meta.env.PUBLIC_EDITOR_APP_URL}/${
$currentRoadmap?._id
}`}
target="_blank"
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white py-1.5 pl-2 pr-2 text-xs font-medium text-black hover:border-gray-300 hover:bg-gray-300 sm:px-3 sm:text-sm"
>
@@ -185,7 +183,7 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
<RoadmapActionButton
onDelete={() => {
const confirmation = window.confirm(
'Are you sure you want to delete this roadmap?',
'Are you sure you want to delete this roadmap?'
);
if (!confirmation) {

View File

@@ -12,10 +12,7 @@ export function SkeletonRoadmapHeader() {
</div>
<div className="flex justify-between gap-2 sm:gap-0">
<div className='flex gap-1 sm:gap-2'>
<div className="h-7 w-[35.04px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-32" />
<div className="h-7 w-[35.04px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[85px]" />
</div>
<div className="h-7 w-[35.04px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-32" />
<div className="flex items-center gap-2">
<div className="h-7 w-[60.52px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[139.71px]" />
<div className="h-7 w-[71.48px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[100.34px]" />

View File

@@ -48,10 +48,11 @@ const {
{
isNew && (
<span class='absolute bottom-1.5 right-2 flex items-center rounded-br rounded-tl text-xs font-medium text-purple-300'>
<span class='flex h-2 w-2'>
<span class='mr-1.5 flex h-2 w-2'>
<span class='absolute inline-flex h-2 w-2 animate-ping rounded-full bg-purple-400 opacity-75' />
<span class='relative inline-flex h-2 w-2 rounded-full bg-purple-500' />
</span>
New
</span>
)
}

View File

@@ -105,7 +105,6 @@ import Icon from './AstroIcon.astro';
class='my-1.5 mr-auto sm:ml-auto sm:mr-0'
width='200'
height='24.8'
loading="lazy"
/>
</a>
<p class='my-4 text-slate-300/60'>

View File

@@ -1,7 +1,6 @@
---
import Loader from '../Loader.astro';
import './FrameRenderer.css';
import { ProgressNudge } from "./ProgressNudge";
export interface Props {
resourceType: 'roadmap' | 'best-practice';
@@ -28,6 +27,4 @@ const { resourceId, resourceType, dimensions = null } = Astro.props;
</div>
</div>
<ProgressNudge resourceId={resourceId} resourceType={resourceType} client:only="react" />
<script src='./renderer.ts'></script>

View File

@@ -1,65 +0,0 @@
import { cn } from '../../lib/classname.ts';
import { roadmapProgress, totalRoadmapNodes } from '../../stores/roadmap.ts';
import { useStore } from '@nanostores/react';
type ProgressNudgeProps = {
resourceType: 'roadmap' | 'best-practice';
resourceId: string;
};
export function ProgressNudge(props: ProgressNudgeProps) {
const $totalRoadmapNodes = useStore(totalRoadmapNodes);
const $roadmapProgress = useStore(roadmapProgress);
const done = $roadmapProgress?.done?.length || 0;
const hasProgress = done > 0;
if (!$totalRoadmapNodes) {
return null;
}
return (
<div
className={
'fixed bottom-5 left-1/2 z-30 hidden -translate-x-1/2 transform animate-fade-slide-up overflow-hidden rounded-full bg-stone-900 px-4 py-2 text-center text-white shadow-2xl transition-all duration-300 sm:block'
}
>
<span
className={cn('block', {
hidden: hasProgress,
})}
>
<span className="mr-2 text-sm font-semibold uppercase text-yellow-400">
Tip
</span>
<span className="text-sm text-gray-200">
Right-click on a topic to mark it as done.{' '}
<button
data-popup="progress-help"
className="cursor-pointer font-semibold text-yellow-500 underline"
>
Learn more.
</button>
</span>
</span>
<span
className={cn('relative z-20 block text-sm', {
hidden: !hasProgress,
})}
>
<span className="relative -top-[0.45px] mr-2 text-xs font-medium uppercase text-yellow-400">
Progress
</span>
<span>{done}</span> of <span>{$totalRoadmapNodes}</span> Done
</span>
<span
className="absolute bottom-0 left-0 top-0 z-10 bg-stone-700"
style={{
width: `${(done / $totalRoadmapNodes) * 100}%`,
}}
></span>
</div>
);
}

View File

@@ -7,13 +7,10 @@ import {
renderTopicProgress,
updateResourceProgress,
} from '../../lib/resource-progress';
import type {
ResourceProgressType,
ResourceType,
} from '../../lib/resource-progress';
import type { ResourceProgressType, ResourceType } from '../../lib/resource-progress';
import { pageProgressMessage } from '../../stores/page';
import { showLoginPopup } from '../../lib/popup';
import { replaceChildren } from '../../lib/dom.ts';
import {replaceChildren} from "../../lib/dom.ts";
export class Renderer {
resourceId: string;
@@ -98,7 +95,7 @@ export class Renderer {
.then(() => {
return renderResourceProgress(
this.resourceType as ResourceType,
this.resourceId,
this.resourceId
);
})
.catch((error) => {
@@ -117,6 +114,19 @@ export class Renderer {
});
}
trackVisit() {
if (!isLoggedIn()) {
return;
}
window.setTimeout(() => {
httpPost(`${import.meta.env.PUBLIC_API_URL}/v1-visit`, {
resourceId: this.resourceId,
resourceType: this.resourceType,
}).then(() => null);
}, 0);
}
onDOMLoaded() {
if (!this.prepareConfig()) {
return;
@@ -125,13 +135,15 @@ export class Renderer {
const urlParams = new URLSearchParams(window.location.search);
const roadmapType = urlParams.get('r');
this.trackVisit();
if (roadmapType) {
this.switchRoadmap(`/${roadmapType}.json`);
} else {
this.jsonToSvg(
this.resourceType === 'roadmap'
? `/${this.resourceId}.json`
: `/best-practices/${this.resourceId}.json`,
: `/best-practices/${this.resourceId}.json`
);
}
}
@@ -171,7 +183,7 @@ export class Renderer {
resourceType: this.resourceType as ResourceType,
topicId,
},
newStatus,
newStatus
)
.then(() => {
renderTopicProgress(topicId, newStatus);
@@ -203,14 +215,9 @@ export class Renderer {
const isCurrentStatusDone = targetGroup.classList.contains('done');
const normalizedGroupId = groupId.replace(/^\d+-/, '');
if (normalizedGroupId.startsWith('ext_link:')) {
return;
}
this.updateTopicStatus(
normalizedGroupId,
!isCurrentStatusDone ? 'done' : 'pending',
!isCurrentStatusDone ? 'done' : 'pending'
);
}
@@ -236,12 +243,9 @@ export class Renderer {
action: `${this.resourceType} / ${this.resourceId}`,
label: externalLink,
});
window.open(`https://${externalLink}`);
} else {
window.location.href = `https://${externalLink}`;
}
window.open(`https://${externalLink}`);
return;
}
@@ -261,7 +265,7 @@ export class Renderer {
resourceType: this.resourceType,
resourceId: this.resourceId,
},
}),
})
);
return;
}
@@ -276,7 +280,7 @@ export class Renderer {
e.preventDefault();
this.updateTopicStatus(
normalizedGroupId,
!isCurrentStatusLearning ? 'learning' : 'pending',
!isCurrentStatusLearning ? 'learning' : 'pending'
);
return;
}
@@ -285,7 +289,7 @@ export class Renderer {
e.preventDefault();
this.updateTopicStatus(
normalizedGroupId,
!isCurrentStatusSkipped ? 'skipped' : 'pending',
!isCurrentStatusSkipped ? 'skipped' : 'pending'
);
return;
@@ -298,7 +302,7 @@ export class Renderer {
resourceId: this.resourceId,
resourceType: this.resourceType,
},
}),
})
);
}

View File

@@ -1,5 +1,6 @@
import UserPlusIcon from '../../icons/user-plus.svg';
import CopyIcon from '../../icons/copy.svg';
import { useCopyText } from '../../hooks/use-copy-text';
import { CopyIcon, UserPlus2 } from 'lucide-react';
type EmptyFriendsProps = {
befriendUrl: string;
@@ -12,12 +13,14 @@ export function EmptyFriends(props: EmptyFriendsProps) {
return (
<div className="rounded-md">
<div className="mx-auto flex flex-col items-center p-7 text-center">
<UserPlus2 className="mb-2 h-[60px] w-[60px] opacity-10 sm:h-[120px] sm:w-[120px]" />
<img
alt="no friends"
src={UserPlusIcon.src}
className="mb-2 h-[60px] w-[60px] opacity-10 sm:h-[120px] sm:w-[120px]"
/>
<h2 className="text-lg font-bold sm:text-xl">Invite your Friends</h2>
<p className="mb-4 mt-1 max-w-[400px] text-sm leading-relaxed text-gray-500">
Share the unique link below with your friends to track their skills
and progress.
Share the unique link below with your friends to track their skills and progress.
</p>
<div className="flex w-full max-w-[352px] items-center justify-center gap-2 rounded-lg border-2 p-1 text-sm">
@@ -41,8 +44,7 @@ export function EmptyFriends(props: EmptyFriendsProps) {
copyText(befriendUrl);
}}
>
<CopyIcon className="mr-1 h-4 w-4" />
<img src={CopyIcon.src} className="h-4 w-4" alt="Invite Friends" />
{isCopied ? 'Copied' : 'Copy'}
</button>
</div>

View File

@@ -7,10 +7,10 @@ import type { FriendshipStatus } from '../Befriend';
import { useToast } from '../../hooks/use-toast';
import { EmptyFriends } from './EmptyFriends';
import { FriendProgressItem } from './FriendProgressItem';
import UserIcon from '../../icons/user.svg';
import { UserProgressModal } from '../UserProgress/UserProgressModal';
import { InviteFriendPopup } from './InviteFriendPopup';
import { UserCustomProgressModal } from '../UserProgress/UserCustomProgressModal';
import { UserIcon } from 'lucide-react';
type FriendResourceProgress = {
updatedAt: string;
@@ -64,7 +64,7 @@ export function FriendsPage() {
async function loadFriends() {
const { response, error } = await httpGet<ListFriendsResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-list-friends`,
`${import.meta.env.PUBLIC_API_URL}/v1-list-friends`
);
if (error || !response) {
@@ -89,15 +89,15 @@ export function FriendsPage() {
const befriendUrl = `${baseUrl}/befriend?u=${user?.id}`;
const selectedGroupingType = groupingTypes.find(
(grouping) => grouping.value === selectedGrouping,
(grouping) => grouping.value === selectedGrouping
);
const filteredFriends = friends.filter(
(friend) => selectedGroupingType?.statuses.includes(friend.status),
const filteredFriends = friends.filter((friend) =>
selectedGroupingType?.statuses.includes(friend.status)
);
const receivedRequests = friends.filter(
(friend) => friend.status === 'received',
(friend) => friend.status === 'received'
);
if (isLoading) {
@@ -203,8 +203,11 @@ export function FriendsPage() {
{filteredFriends.length === 0 && (
<div className="flex flex-col items-center justify-center py-12">
<UserIcon size={'60px'} className="mb-3 w-12 opacity-20" />
<img
src={UserIcon.src}
alt="Empty Friends"
className="mb-3 w-12 opacity-20"
/>
<h2 className="text-lg font-semibold">
{selectedGrouping === 'active' && 'No friends yet'}
{selectedGrouping === 'sent' && 'No requests sent'}

View File

@@ -1,8 +1,8 @@
import type { MouseEvent } from 'react';
import { useRef } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import CopyIcon from '../../icons/copy.svg';
import { useCopyText } from '../../hooks/use-copy-text';
import { CopyIcon } from 'lucide-react';
type InviteFriendPopupProps = {
befriendUrl: string;
@@ -54,7 +54,11 @@ export function InviteFriendPopup(props: InviteFriendPopupProps) {
copyText(befriendUrl);
}}
>
<CopyIcon className="mr-1 h-4 w-4" />
<img
src={CopyIcon.src}
className="h-4 w-4"
alt="Invite Friends"
/>
{isCopied ? 'Copied' : 'Copy URL'}
</button>
</div>

View File

@@ -1,58 +0,0 @@
@font-face {
font-family: 'balsamiq';
src: url('/fonts/balsamiq.woff2');
}
svg text tspan {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeSpeed;
}
svg > g[data-type='topic'],
svg > g[data-type='subtopic'],
svg > g > g[data-type='link-item'],
svg > g[data-type='button'] {
cursor: pointer;
}
svg > g[data-type='topic']:hover > rect {
fill: #d6d700;
}
svg > g[data-type='subtopic']:hover > rect {
fill: #f3c950;
}
svg > g[data-type='button']:hover {
opacity: 0.8;
}
svg .done rect {
fill: #cbcbcb !important;
}
svg .done text,
svg .skipped text {
text-decoration: line-through;
}
svg > g[data-type='topic'].learning > rect + text,
svg > g[data-type='topic'].done > rect + text {
fill: black;
}
svg > g[data-type='subtipic'].done > rect + text,
svg > g[data-type='subtipic'].learning > rect + text {
fill: #cbcbcb;
}
svg .learning rect {
fill: #dad1fd !important;
}
svg .learning text {
text-decoration: underline;
}
svg .skipped rect {
fill: #496b69 !important;
}

View File

@@ -1,361 +0,0 @@
import { type FormEvent, useEffect, useRef, useState } from 'react';
import './GenerateRoadmap.css';
import { useToast } from '../../hooks/use-toast';
import { generateAIRoadmapFromText } from '../../../editor/utils/roadmap-generator';
import { renderFlowJSON } from '../../../editor/renderer/renderer';
import { replaceChildren } from '../../lib/dom';
import { readAIRoadmapStream } from '../../helper/read-stream';
import { isLoggedIn, removeAuthToken } from '../../lib/jwt';
import { RoadmapSearch } from './RoadmapSearch.tsx';
import { Spinner } from '../ReactIcons/Spinner.tsx';
import { Ban, Download, PenSquare, Wand } from 'lucide-react';
import { ShareRoadmapButton } from '../ShareRoadmapButton.tsx';
import { httpGet, httpPost } from '../../lib/http.ts';
import { pageProgressMessage } from '../../stores/page.ts';
import {
deleteUrlParam,
getUrlParams,
setUrlParams,
} from '../../lib/browser.ts';
import { downloadGeneratedRoadmapImage } from '../../helper/download-image.ts';
import { showLoginPopup } from '../../lib/popup.ts';
import { cn } from '../../lib/classname.ts';
const ROADMAP_ID_REGEX = new RegExp('@ROADMAPID:(\\w+)@');
export function GenerateRoadmap() {
const roadmapContainerRef = useRef<HTMLDivElement>(null);
const { id: roadmapId } = getUrlParams() as { id: string };
const toast = useToast();
const [hasSubmitted, setHasSubmitted] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState(false);
const [roadmapTopic, setRoadmapTopic] = useState('');
const [generatedRoadmap, setGeneratedRoadmap] = useState('');
const [roadmapLimit, setRoadmapLimit] = useState(0);
const [roadmapLimitUsed, setRoadmapLimitUsed] = useState(0);
const renderRoadmap = async (roadmap: string) => {
const { nodes, edges } = generateAIRoadmapFromText(roadmap);
const svg = await renderFlowJSON({ nodes, edges });
if (roadmapContainerRef?.current) {
replaceChildren(roadmapContainerRef?.current, svg);
}
};
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!roadmapTopic) {
return;
}
setIsLoading(true);
setHasSubmitted(true);
if (roadmapLimitUsed >= roadmapLimit) {
toast.error('You have reached your limit of generating roadmaps');
setIsLoading(false);
return;
}
deleteUrlParam('id');
const response = await fetch(
`${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-roadmap`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ topic: roadmapTopic }),
},
);
if (!response.ok) {
const data = await response.json();
toast.error(data?.message || 'Something went wrong');
setIsLoading(false);
// Logout user if token is invalid
if (data.status === 401) {
removeAuthToken();
window.location.reload();
}
}
const reader = response.body?.getReader();
if (!reader) {
setIsLoading(false);
toast.error('Something went wrong');
return;
}
await readAIRoadmapStream(reader, {
onStream: async (result) => {
if (result.includes('@ROADMAPID')) {
// @ROADMAPID: is a special token that we use to identify the roadmap
// @ROADMAPID:1234@ is the format, we will remove the token and the id
// and replace it with a empty string
const roadmapId = result.match(ROADMAP_ID_REGEX)?.[1] || '';
setUrlParams({ id: roadmapId });
result = result.replace(ROADMAP_ID_REGEX, '');
}
await renderRoadmap(result);
},
onStreamEnd: async (result) => {
result = result.replace(ROADMAP_ID_REGEX, '');
setGeneratedRoadmap(result);
loadAIRoadmapLimit().finally(() => {});
},
});
setIsLoading(false);
};
const editGeneratedRoadmap = async () => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
pageProgressMessage.set('Redirecting to Editor');
const { nodes, edges } = generateAIRoadmapFromText(generatedRoadmap);
const { response, error } = await httpPost<{
roadmapId: string;
}>(`${import.meta.env.PUBLIC_API_URL}/v1-edit-ai-generated-roadmap`, {
title: roadmapTopic,
nodes: nodes.map((node) => ({
...node,
// To reset the width and height of the node
// so that it can be calculated based on the content in the editor
width: undefined,
height: undefined,
style: {
...node.style,
width: undefined,
height: undefined,
},
})),
edges,
});
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
setIsLoading(false);
return;
}
window.location.href = `${import.meta.env.PUBLIC_EDITOR_APP_URL}/${response.roadmapId}`;
};
const downloadGeneratedRoadmap = async () => {
pageProgressMessage.set('Downloading Roadmap');
const node = document.getElementById('roadmap-container');
if (!node) {
toast.error('Something went wrong');
return;
}
try {
await downloadGeneratedRoadmapImage(roadmapTopic, node);
pageProgressMessage.set('');
} catch (error) {
console.error(error);
toast.error('Something went wrong');
}
};
const loadAIRoadmapLimit = async () => {
const { response, error } = await httpGet<{
limit: number;
used: number;
}>(`${import.meta.env.PUBLIC_API_URL}/v1-get-ai-roadmap-limit`);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
return;
}
const { limit, used } = response;
setRoadmapLimit(limit);
setRoadmapLimitUsed(used);
};
const loadAIRoadmap = async (roadmapId: string) => {
pageProgressMessage.set('Loading Roadmap');
const { response, error } = await httpGet<{
topic: string;
data: string;
}>(`${import.meta.env.PUBLIC_API_URL}/v1-get-ai-roadmap/${roadmapId}`);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
setIsLoading(false);
return;
}
const { topic, data } = response;
await renderRoadmap(data);
setRoadmapTopic(topic);
setGeneratedRoadmap(data);
};
useEffect(() => {
loadAIRoadmapLimit().finally(() => {});
}, []);
useEffect(() => {
if (!roadmapId) {
return;
}
setHasSubmitted(true);
loadAIRoadmap(roadmapId).finally(() => {
pageProgressMessage.set('');
});
}, [roadmapId]);
if (!hasSubmitted) {
return (
<RoadmapSearch
roadmapTopic={roadmapTopic}
setRoadmapTopic={setRoadmapTopic}
handleSubmit={handleSubmit}
limit={roadmapLimit}
limitUsed={roadmapLimitUsed}
/>
);
}
const pageUrl = `https://roadmap.sh/ai?id=${roadmapId}`;
const canGenerateMore = roadmapLimitUsed < roadmapLimit;
return (
<section className="flex flex-grow flex-col bg-gray-100">
<div className="flex items-center justify-center border-b bg-white py-3 sm:py-6">
{isLoading && (
<span className="flex items-center gap-2 rounded-full bg-black px-3 py-1 text-white">
<Spinner isDualRing={false} innerFill={'white'} />
Generating roadmap ..
</span>
)}
{!isLoading && (
<div className="flex max-w-[600px] flex-grow flex-col items-center px-5">
<div className="mt-2 flex w-full items-center justify-between text-sm">
<span className="text-gray-800">
<span
className={cn(
'inline-block w-[65px] rounded-md border px-0.5 text-center text-sm tabular-nums text-gray-800',
{
'animate-pulse border-zinc-300 bg-zinc-300 text-zinc-300':
!roadmapLimit,
},
)}
>
{roadmapLimitUsed} of {roadmapLimit}
</span>{' '}
roadmaps generated
{!isLoggedIn() && (
<>
{' '}
<button
className="font-medium text-black underline underline-offset-2"
onClick={showLoginPopup}
>
Login to increase your limit
</button>
</>
)}
</span>
</div>
<form
onSubmit={handleSubmit}
className="my-3 flex w-full flex-col sm:flex-row sm:items-center sm:justify-center gap-2"
>
<input
type="text"
autoFocus
placeholder="e.g. Ansible"
className="flex-grow rounded-md border border-gray-400 px-3 py-2 transition-colors focus:border-black focus:outline-none"
value={roadmapTopic}
onInput={(e) =>
setRoadmapTopic((e.target as HTMLInputElement).value)
}
/>
<button
type={'submit'}
className={cn(
'flex min-w-[127px] flex-shrink-0 items-center gap-2 rounded-md bg-black px-4 py-2 text-white justify-center',
{
'cursor-not-allowed opacity-50':
!roadmapLimit ||
!roadmapTopic ||
roadmapLimitUsed >= roadmapLimit,
},
)}
>
{roadmapLimit > 0 && canGenerateMore && (
<>
<Wand size={20} />
Generate
</>
)}
{roadmapLimit === 0 && <span>Please wait..</span>}
{roadmapLimit > 0 && !canGenerateMore && (
<span className="flex items-center text-sm">
<Ban size={15} className="mr-2" />
Limit reached
</span>
)}
</button>
</form>
<div className="flex w-full items-center justify-between gap-2">
<div className="flex items-center justify-between gap-2">
<button
className="inline-flex items-center justify-center gap-2 rounded-md bg-yellow-400 py-1.5 pl-2.5 pr-3 text-xs font-medium transition-opacity duration-300 hover:bg-yellow-500 sm:text-sm"
onClick={downloadGeneratedRoadmap}
>
<Download size={15} />
<span className="hidden sm:inline">Download</span>
</button>
{roadmapId && (
<ShareRoadmapButton
description={`Check out ${roadmapTopic} roadmap I generated on roadmap.sh`}
pageUrl={pageUrl}
/>
)}
</div>
<button
className="inline-flex items-center justify-center gap-2 rounded-md bg-gray-200 py-1.5 pl-2.5 pr-3 text-xs font-medium text-black transition-colors duration-300 hover:bg-gray-300 sm:text-sm"
onClick={editGeneratedRoadmap}
disabled={isLoading}
>
<PenSquare size={15} />
Edit in Editor
</button>
</div>
</div>
)}
</div>
<div
ref={roadmapContainerRef}
id="roadmap-container"
className="relative px-4 py-5 [&>svg]:mx-auto [&>svg]:max-w-[1300px]"
/>
</section>
);
}

View File

@@ -1,121 +0,0 @@
import { Ban, Wand } from 'lucide-react';
import type { FormEvent } from 'react';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
import { cn } from '../../lib/classname.ts';
type RoadmapSearchProps = {
roadmapTopic: string;
setRoadmapTopic: (topic: string) => void;
handleSubmit: (e: FormEvent<HTMLFormElement>) => void;
limit: number;
limitUsed: number;
};
export function RoadmapSearch(props: RoadmapSearchProps) {
const {
roadmapTopic,
setRoadmapTopic,
handleSubmit,
limit = 0,
limitUsed = 0,
} = props;
const canGenerateMore = limitUsed < limit;
return (
<div className="flex flex-grow flex-col items-center justify-center px-4 py-6 sm:px-6">
<div className="flex flex-col gap-0 text-center sm:gap-2">
<h1 className="relative text-2xl font-medium sm:text-3xl">
<span className="hidden sm:inline">Generate roadmaps with AI</span>
<span className="inline sm:hidden">AI Roadmap Generator</span>
</h1>
<p className="text-base text-gray-500 sm:text-lg">
<span className="hidden sm:inline">
Enter a topic and let the AI generate a roadmap for you
</span>
<span className="inline sm:hidden">
Enter a topic to generate a roadmap
</span>
</p>
</div>
<form
onSubmit={(e) => {
if (limit > 0 && canGenerateMore) {
handleSubmit(e);
} else {
e.preventDefault();
}
}}
className="my-3 flex w-full max-w-[600px] flex-col gap-2 sm:my-5 sm:flex-row"
>
<input
autoFocus
type="text"
placeholder="e.g. Ansible"
className="w-full rounded-md border border-gray-400 px-3 py-2.5 transition-colors focus:border-black focus:outline-none"
value={roadmapTopic}
onInput={(e) => setRoadmapTopic((e.target as HTMLInputElement).value)}
/>
<button
className={cn(
'flex min-w-[143px] flex-shrink-0 items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-white',
{
'cursor-not-allowed opacity-50':
!limit || !roadmapTopic || limitUsed >= limit,
},
)}
>
{limit > 0 && canGenerateMore && (
<>
<Wand size={20} />
Generate
</>
)}
{limit === 0 && (
<>
<span>Please wait..</span>
</>
)}
{limit > 0 && !canGenerateMore && (
<span className="flex items-center text-base sm:text-sm">
<Ban size={15} className="mr-2" />
Limit reached
</span>
)}
</button>
</form>
<div className="mb-36">
<p className="text-gray-500">
<span className="inline sm:hidden">Generated </span>
<span className="hidden sm:inline">You have generated </span>
<span
className={cn(
'inline-block w-[65px] rounded-md border px-0.5 text-center text-sm tabular-nums text-gray-800',
{
'animate-pulse border-zinc-300 bg-zinc-300 text-zinc-300':
!limit,
},
)}
>
{limitUsed} of {limit}
</span>{' '}
roadmaps.
{!isLoggedIn && (
<>
{' '}
<button
className="font-semibold text-black underline underline-offset-2"
onClick={showLoginPopup}
>
Log in to increase your limit
</button>
</>
)}
</p>
</div>
</div>
);
}

View File

@@ -1,78 +0,0 @@
import { ExternalLink, Globe2, type LucideIcon } from 'lucide-react';
type RoadmapCardProps = {
title: string;
description: string;
icon: LucideIcon;
icon2?: LucideIcon;
link: string;
isUpcoming?: boolean;
};
export function RoadmapCard(props: RoadmapCardProps) {
const {
isUpcoming,
link,
title,
description,
icon: Icon,
icon2: Icon2,
} = props;
if (isUpcoming) {
return (
<div className="group relative block rounded-xl border border-gray-300 bg-gradient-to-br from-gray-100 to-gray-50 p-5 overflow-hidden">
<div className="mb-2 sm:mb-5 flex flex-row items-center">
<div className="flex h-7 w-7 sm:h-9 sm:w-9 items-center justify-center rounded-full bg-gray-900 text-white">
<Icon className="h-3 sm:h-5" />
</div>
{Icon2 && (
<>
<span className="mx-2 text-gray-400">+</span>
<div className="flex h-7 w-7 sm:h-9 sm:w-9 items-center justify-center rounded-full bg-gray-900 text-white">
<Icon2 className="h-3 sm:h-5" />
</div>
</>
)}
</div>
<span className="mb-0.5 block text-lg sm:text-xl font-semibold sm:mb-2">
{title}
</span>
<span className="text-sm text-gray-500">{description}</span>
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-100/70">
<span className="text-sm bg-black rounded-lg text-white font-semibold py-1 px-2 -rotate-45 transform">
Coming soon
</span>
</div>
</div>
);
}
return (
<a
href={link}
target={'_blank'}
className="group relative block rounded-xl border border-gray-300 bg-gradient-to-br from-gray-100 to-gray-50
p-3.5 sm:p-5 transition-colors duration-200 ease-in-out hover:cursor-pointer hover:border-black/30 hover:bg-gray-50/70 hover:shadow-sm"
>
<div className="mb-2 sm:mb-5 flex flex-row items-center">
<div className="flex h-7 w-7 sm:h-9 sm:w-9 items-center justify-center rounded-full bg-gray-900 text-white">
<Icon className="h-4 sm:h-5" />
</div>
{Icon2 && (
<>
<span className="mx-2 text-gray-400">+</span>
<div className="flex h-7 w-7 sm:h-9 sm:w-9 items-center justify-center rounded-full bg-gray-900 text-white">
<Icon2 className="h-4 sm:h-5" />
</div>
</>
)}
</div>
<ExternalLink className="lucide lucide-external-link absolute right-2 top-2 h-4 text-gray-300 transition group-hover:text-gray-700" />
<span className="mb-0 block text-lg sm:text-xl font-semibold sm:mb-2">
{title}
</span>
<span className="text-sm text-gray-500">{description}</span>
</a>
);
}

View File

@@ -1,63 +0,0 @@
import { ExternalLink } from 'lucide-react';
type RoadmapMultiCardProps = {
roadmaps: {
title: string;
link: string;
}[];
description: string;
secondaryRoadmaps?: {
title: string;
link: string;
}[];
secondaryDescription?: string;
};
export function RoadmapMultiCard(props: RoadmapMultiCardProps) {
const { roadmaps, description, secondaryRoadmaps, secondaryDescription } = props;
return (
<div
className="relative flex flex-col overflow-hidden rounded-xl border border-gray-300 bg-gradient-to-br from-gray-100
to-gray-50 ease-in-out"
>
<div className="flex flex-col divide-y">
{roadmaps.map((roadmap, index) => (
<a
target={'_blank'}
key={index}
href={roadmap.link}
className="group text-sm sm:text-base flex w-full items-center justify-between gap-2 bg-gradient-to-br from-gray-100 to-gray-50 px-4 sm:px-5 py-2 transition-colors duration-200"
>
{roadmap.title}
<ExternalLink className="lucide lucide-external-link h-4 text-gray-300 transition group-hover:text-gray-700" />
</a>
))}
</div>
<p className="flex-grow bg-gray-200/70 p-4 sm:p-5 text-sm text-gray-500">
{description}
</p>
{secondaryRoadmaps && (
<div className="flex flex-col divide-y">
{secondaryRoadmaps.map((roadmap, index) => (
<a
target={'_blank'}
key={index}
href={roadmap.link}
className="group text-sm sm:text-base flex w-full items-center justify-between gap-2 bg-gradient-to-br from-gray-100 to-gray-50 px-5 py-2 transition-colors duration-200"
>
{roadmap.title}
<ExternalLink className="lucide lucide-external-link h-4 text-gray-300 transition group-hover:text-gray-700" />
</a>
))}
</div>
)}
{secondaryDescription && (
<p className="flex-grow bg-gray-200/70 p-4 sm:p-5 text-sm text-gray-500">
{secondaryDescription}
</p>
)}
</div>
);
}

View File

@@ -1,29 +0,0 @@
import { type ReactNode } from 'react';
import { SectionBadge } from './SectionBadge.tsx';
type RoleRoadmapsProps = {
badge: string;
title: string;
description: string;
children: ReactNode;
};
export function RoleRoadmaps(props: RoleRoadmapsProps) {
const { badge, title, description, children } = props;
return (
<div className="bg-gradient-to-b from-gray-100 to-white py-5 sm:py-8 md:py-12">
<div className="container">
<div className="text-left">
<SectionBadge title={badge} />
</div>
<div className="my-4 sm:my-7 text-left">
<h2 className="mb-1 text-xl sm:text-3xl font-semibold">{title}</h2>
<p className="text-sm sm:text-base text-gray-500">{description}</p>
<div className="mt-4 sm:mt-7 grid sm:grid-cols-2 md:grid-cols-3 gap-3">{children}</div>
</div>
</div>
</div>
);
}

View File

@@ -1,12 +0,0 @@
type SectionBadgeProps = {
title: string;
};
export function SectionBadge(props: SectionBadgeProps) {
const { title } = props;
return (
<span className="rounded-full bg-black px-3 py-1 text-sm text-white">
{title}
</span>
);
}

View File

@@ -1,31 +0,0 @@
import { useState } from 'react';
type TipItemProps = {
title: string;
description: string;
};
export function TipItem(props: TipItemProps) {
const { title, description } = props;
const [isToggled, setIsToggled] = useState(false);
return (
<div className="flex flex-col">
{!isToggled && (
<div
onClick={() => setIsToggled(true)}
className="cursor-pointer rounded-lg sm:rounded-xl bg-black px-3 py-2 text-sm sm:text-base text-white"
>
{title}
</div>
)}
{isToggled && (
<p
className="rounded-lg sm:rounded-xl bg-gray-200 px-3 py-2 text-black text-sm sm:text-base"
>
{description}
</p>
)}
</div>
);
}

View File

@@ -6,32 +6,28 @@ export interface Props {
}
const { guide } = Astro.props;
const { frontmatter, author } = guide;
const { frontmatter } = guide;
const { author } = frontmatter;
---
<div class='border-b bg-white py-5 sm:py-12'>
<div class='bg-white border-b py-5 sm:py-12'>
<div class='container text-left sm:text-center'>
<p
class='hidden items-center justify-start text-gray-400 sm:flex sm:justify-center'
class='text-gray-400 hidden sm:flex items-center justify-start sm:justify-center'
>
{
author?.frontmatter && (
<>
<a
href={`/authors/${author.id}`}
class='inline-flex items-center font-medium hover:text-gray-600 hover:underline'
>
<img
alt={author.frontmatter.name}
src={author.frontmatter.imageUrl}
class='mr-2 inline h-5 w-5 rounded-full'
/>
{author.frontmatter.name}
</a>
<span class='mx-1.5'>&middot;</span>
</>
)
}
<a
href={author.url}
target='_blank'
class='font-medium hover:text-gray-600 inline-flex items-center hover:underline'
>
<img
alt={author.name}
src={author.imageUrl}
class='w-5 h-5 inline mr-2 rounded-full'
/>
{author.name}
</a>
<span class='mx-1.5'>&middot;</span>
<span class='capitalize'>{frontmatter.type} Guide</span>
<span class='mx-1.5'>&middot;</span>
<a
@@ -40,10 +36,10 @@ const { frontmatter, author } = guide;
target='_blank'>Improve this Guide</a
>
</p>
<h1 class='my-0 text-2xl font-bold sm:my-3.5 sm:text-5xl'>
<h1 class='text-2xl sm:text-5xl my-0 sm:my-3.5 font-bold'>
{frontmatter.title}
</h1>
<p class='hidden text-xl text-gray-400 sm:block'>
<p class='hidden sm:block text-gray-400 text-xl'>
{frontmatter.description}
</p>
</div>

View File

@@ -1,5 +1,5 @@
---
import type { GuideFileType } from '../lib/guide';
import type { GuideFileType } from "../lib/guide";
export interface Props {
guide: GuideFileType;
@@ -11,34 +11,30 @@ const { frontmatter, id } = guide;
<a
class:list={[
'text-md group block flex items-center justify-between border-b py-2 text-gray-600 no-underline hover:text-blue-600',
"block no-underline py-2 group text-md items-center text-gray-600 hover:text-blue-600 flex justify-between border-b",
]}
href={frontmatter.excludedBySlug
? frontmatter.excludedBySlug
: `/guides/${id}`}
href={`/guides/${id}`}
>
<span
class='text-sm transition-transform group-hover:translate-x-2 md:text-base'
>
<span class="group-hover:translate-x-2 transition-transform">
{frontmatter.title}
{
frontmatter.isNew && (
<span class='ml-1.5 rounded-sm bg-green-300 px-1.5 py-0.5 text-xs font-medium uppercase text-green-900'>
<span class="bg-green-300 text-green-900 text-xs font-medium px-1.5 py-0.5 rounded-sm uppercase ml-1.5">
New
<span class='hidden sm:inline'>
<span class="hidden sm:inline">
&middot;
{new Date(frontmatter.date).toLocaleString('default', {
month: 'long',
{new Date(frontmatter.date).toLocaleString("default", {
month: "long",
})}
</span>
</span>
)
}
</span>
<span class='hidden text-xs capitalize text-gray-500 sm:block'>
<span class="capitalize text-gray-500 text-xs hidden sm:block">
{frontmatter.type}
</span>
<span class='block text-xs text-gray-400 sm:hidden'> &raquo;</span>
<span class="text-gray-400 text-xs block sm:hidden"> &raquo;</span>
</a>

View File

@@ -121,7 +121,7 @@ export function HeroRoadmaps(props: ProgressListProps) {
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
{progress.map((resource) => (
<HeroRoadmap
key={`${resource.resourceType}-${resource.resourceId}`}
key={resource.resourceId}
resourceId={resource.resourceId}
resourceType={resource.resourceType}
resourceTitle={resource.resourceTitle}

View File

@@ -1,5 +1,5 @@
<div
class='prose-xl prose-blockquote:font-normal prose container prose-code:bg-transparent prose-h2:text-3xl prose-h2:mt-10 prose-h2:mb-3 prose-h5:font-medium prose-h3:mt-2 prose-img:mt-1'
class='prose-xl prose-blockquote:font-normal prose container prose-code:bg-transparent prose-h2:text-3xl prose-h2:mt-10 prose-h2:mb-3 prose-h3:mt-2 prose-img:mt-1'
>
<slot />
</div>

View File

@@ -1,56 +1,41 @@
---
import { Menu } from 'lucide-react';
import Icon from '../AstroIcon.astro';
import { NavigationDropdown } from '../NavigationDropdown';
import { AccountDropdown } from './AccountDropdown';
---
<div class='bg-slate-900 py-5 text-white sm:py-8'>
<nav class='container flex items-center justify-between'>
<div class='flex items-center gap-5'>
<a
class='flex items-center text-lg font-medium text-white'
href='/'
aria-label='roadmap.sh'
>
<Icon icon='logo' />
</a>
<a
class='flex items-center text-lg font-medium text-white'
href='/'
aria-label='roadmap.sh'
>
<Icon icon='logo' />
</a>
<a
target='_blank'
rel='noreferrer nofollow'
href='https://boards.greenhouse.io/insightmediagroupllc/jobs/4002116008'
class='group inline sm:hidden relative !mr-2 text-blue-300 hover:text-white'
>
We're Hiring
<span class='absolute -right-[11px] top-0'>
<span class='relative flex h-2 w-2'>
<span
class='absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75'
></span>
<span class='relative inline-flex h-2 w-2 rounded-full bg-sky-500'
></span>
</span>
</span>
</a>
<!-- Desktop navigation items -->
<div class='hidden space-x-5 sm:flex sm:items-center'>
<NavigationDropdown client:load />
<a href='/get-started' class='text-gray-400 hover:text-white'>
Start Here
</a>
<a href='/teams' class='text-gray-400 hover:text-white'> Teams</a>
<a
target='_blank'
rel='noreferrer nofollow'
href='https://boards.greenhouse.io/insightmediagroupllc/jobs/4002116008'
class='group relative !mr-2 text-blue-300 hover:text-white'
<!-- Desktop navigation items -->
<ul class='hidden space-x-5 sm:flex sm:items-center'>
<li>
<a href='/roadmaps' class='text-gray-400 hover:text-white'>Roadmaps</a>
</li>
<li>
<a href='/best-practices' class='text-gray-400 hover:text-white'
>Best Practices</a
>
We're Hiring
</li>
<li class='hidden lg:inline'>
<a href='/questions' class='text-gray-400 hover:text-white'>Questions</a
>
</li>
<li>
<a href='/teams' class='group relative text-blue-300 hover:text-white'>
Teams
<span
class='ml-0.5 hidden rounded-sm border-black bg-blue-300 px-1 py-0.5 text-xs font-semibold uppercase text-black group-hover:bg-white md:inline'
>
New
</span>
<span class='absolute -right-[11px] top-0'>
<span class='inline md:hidden absolute -right-[11px] top-0'>
<span class='relative flex h-2 w-2'>
<span
class='absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75'
@@ -60,16 +45,17 @@ import { AccountDropdown } from './AccountDropdown';
</span>
</span>
</a>
<button
</li>
<li>
<kbd
data-command-menu
class='hidden items-center rounded-md border border-gray-800 px-2.5 py-1.5 text-sm text-gray-400 hover:cursor-pointer hover:bg-gray-800 md:flex'
class='hidden items-center rounded-md border border-gray-800 px-2.5 py-1 text-sm text-gray-400 hover:cursor-pointer hover:bg-gray-800 sm:flex'
>
<Icon icon='search' class='h-3 w-3' />
<span class='ml-2'>Search</span>
</button>
</div>
</div>
<Icon icon='search' class='mr-2 h-3 w-3' />
<kbd class='mr-1 font-sans'>⌘</kbd><kbd class='font-sans'>K</kbd>
</kbd>
</li>
</ul>
<ul class='hidden h-8 w-[172px] items-center justify-end gap-5 sm:flex'>
<li data-guest-required class='hidden'>
<a href='/login' class='text-gray-400 hover:text-white'>Login</a>

View File

@@ -1,8 +1,11 @@
import Cookies from 'js-cookie';
import { TOKEN_COOKIE_NAME, removeAuthToken } from '../../lib/jwt';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
export function logout() {
removeAuthToken();
Cookies.remove(TOKEN_COOKIE_NAME, {
path: '/',
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
});
// Reloading will automatically redirect the user if required
window.location.reload();

View File

@@ -1,104 +0,0 @@
import {
BookOpenText,
CheckSquare,
FileQuestion,
Menu,
Shirt,
Video,
Waypoints,
} from 'lucide-react';
import { useRef, useState } from 'react';
import { cn } from '../lib/classname.ts';
import { useOutsideClick } from '../hooks/use-outside-click.ts';
const links = [
{
link: '/roadmaps',
label: 'Roadmaps',
description: 'Step by step learning paths',
Icon: Waypoints,
},
{
link: '/best-practices',
label: 'Best Practices',
description: "Do's and don'ts",
Icon: CheckSquare,
},
{
link: '/questions',
label: 'Questions',
description: 'Test and Practice your knowledge',
Icon: FileQuestion,
},
{
link: '/guides',
label: 'Guides',
description: 'In-depth articles and tutorials',
Icon: BookOpenText,
},
{
link: 'https://youtube.com/@roadmapsh',
label: 'Videos',
description: 'Animated and interactive content',
Icon: Video,
isExternal: true,
},
{
link: 'https://cottonbureau.com/people/roadmapsh',
label: 'Shop',
description: 'Get some cool swag',
Icon: Shirt,
isExternal: true,
},
];
export function NavigationDropdown() {
const dropdownRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
useOutsideClick(dropdownRef, () => {
setIsOpen(false);
});
return (
<div className="relative flex items-center" ref={dropdownRef}>
<button
className={cn('text-gray-400 hover:text-white', {
'text-white': isOpen,
})}
onClick={() => setIsOpen(true)}
onMouseOver={() => setIsOpen(true)}
>
<Menu className="h-5 w-5" />
</button>
<div
className={cn(
'absolute pointer-events-none left-0 top-full z-[999] mt-2 w-48 min-w-[320px] -translate-y-1 rounded-lg bg-slate-800 py-2 opacity-0 shadow-xl transition-all duration-100',
{
'pointer-events-auto translate-y-2.5 opacity-100': isOpen,
},
)}
>
{links.map((link) => (
<a
href={link.link}
target={link.isExternal ? '_blank' : undefined}
rel={link.isExternal ? 'noopener noreferrer' : undefined}
key={link.link}
className="group flex items-center gap-3 px-4 py-2.5 text-gray-400 transition-colors hover:bg-slate-700"
>
<span className="flex h-[40px] w-[40px] items-center justify-center rounded-full bg-slate-600 transition-colors group-hover:bg-slate-500 group-hover:text-slate-100">
<link.Icon className="inline-block h-5 w-5" />
</span>
<span className="flex flex-col">
<span className="font-medium text-slate-300 transition-colors group-hover:text-slate-100">
{link.label}
</span>
<span className="text-sm">{link.description}</span>
</span>
</a>
))}
</div>
</div>
);
}

View File

@@ -1,10 +1,10 @@
import { useEffect, useState } from 'react';
import { httpGet, httpPatch } from '../../lib/http';
import { httpGet, httpPatch, httpPost } from '../../lib/http';
import { pageProgressMessage } from '../../stores/page';
import type { TeamMemberDocument } from '../TeamMembers/TeamMembersPage';
import XIcon from '../../icons/close-dark.svg';
import AcceptIcon from '../../icons/accept.svg';
import { useToast } from '../../hooks/use-toast';
import { AcceptIcon } from '../ReactIcons/AcceptIcon.tsx';
import { XIcon } from 'lucide-react';
interface NotificationList extends TeamMemberDocument {
name: string;
@@ -18,7 +18,7 @@ export function NotificationPage() {
const lostNotifications = async () => {
const { error, response } = await httpGet<NotificationList[]>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-invitation-list`,
`${import.meta.env.PUBLIC_API_URL}/v1-get-invitation-list`
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
@@ -28,37 +28,28 @@ export function NotificationPage() {
setNotifications(response);
};
async function respondInvitation(
status: 'accept' | 'reject',
inviteId: string,
) {
async function respondInvitation(status: 'accept' | 'reject', inviteId: string) {
setIsLoading(true);
setError('');
const { response, error } = await httpPatch<{ teamId: string }>(
`${import.meta.env.PUBLIC_API_URL}/v1-respond-invite/${inviteId}`,
{
status,
},
);
`${import.meta.env.PUBLIC_API_URL}/v1-respond-invite/${inviteId}`, {
status
});
if (error || !response) {
setError(error?.message || 'Something went wrong');
setIsLoading(false);
setError(error?.message || 'Something went wrong')
setIsLoading(false)
return;
}
if (status === 'accept') {
window.location.href = `/team/progress?t=${response.teamId}`;
} else {
window.dispatchEvent(
new CustomEvent('refresh-notification', {
detail: {
count: notifications.length - 1,
},
}),
);
setNotifications(
notifications.filter((notification) => notification._id !== inviteId),
);
window.dispatchEvent(new CustomEvent('refresh-notification', {
detail: {
count: notifications.length - 1
}
}));
setNotifications(notifications.filter((notification) => notification._id !== inviteId));
setIsLoading(false);
}
}
@@ -75,20 +66,15 @@ export function NotificationPage() {
<h2 className="text-3xl font-bold sm:text-4xl">Notification</h2>
<p className="mt-2 text-gray-400">Manage your notifications</p>
</div>
{notifications.length === 0 && (
<div className="mt-6 flex items-center justify-center">
<p className="text-gray-400">
No notifications, you can{' '}
<a
href="/team/new"
className="text-blue-500 underline hover:no-underline"
>
create a team
</a>{' '}
and invite your friends to join.
</p>
</div>
)}
{
notifications.length === 0 && (
<div className="flex items-center justify-center mt-6">
<p className="text-gray-400">
No notifications, you can <a href="/team/new" className="text-blue-500 underline hover:no-underline">create a team</a> and invite your friends to join.
</p>
</div>
)
}
<div className="space-y-4">
{notifications.map((notification) => (
<div className="flex items-center justify-between rounded-md border p-2">
@@ -100,21 +86,19 @@ export function NotificationPage() {
</div>
</div>
<div className="flex items-center space-x-2">
<button
type="button"
<button type="button"
disabled={isLoading}
className="inline-flex rounded border p-1 hover:bg-gray-50 disabled:opacity-75"
className="inline-flex border p-1 rounded hover:bg-gray-50 disabled:opacity-75"
onClick={() => respondInvitation('accept', notification?._id!)}
>
<AcceptIcon className="h-4 w-4" />
<img src={AcceptIcon.src} className="h-4 w-4" />
</button>
<button
type="button"
<button type="button"
disabled={isLoading}
className="inline-flex rounded border p-1 hover:bg-gray-50 disabled:opacity-75"
className="inline-flex border p-1 rounded hover:bg-gray-50 disabled:opacity-75"
onClick={() => respondInvitation('reject', notification?._id!)}
>
<XIcon className="h-4 w-4" />
<img alt={'Close'} src={XIcon.src} className="h-4 w-4" />
</button>
</div>
</div>

View File

@@ -1,34 +1,42 @@
---
import { getFormattedStars } from '../lib/github';
import Icon from './AstroIcon.astro';
import { getDiscordInfo } from '../lib/discord';
import OpenSourceStat from './OpenSourceStat.astro';
const starCount = await getFormattedStars('kamranahmedse/developer-roadmap');
const discordInfo = await getDiscordInfo();
---
<div class='border-b border-t bg-white py-6 text-left sm:py-16 sm:text-center'>
<div class='container !max-w-[650px]'>
<p class='text-2xl font-bold sm:text-5xl'>Join the Community</p>
<p class='my-2.5 text-sm leading-relaxed text-gray-600 sm:my-5 sm:text-lg'>
<div class='py-6 sm:py-16 border-b border-t text-left sm:text-center bg-white'>
<div class='!max-w-[600px] container'>
<p class='text-2xl sm:text-5xl font-bold'>Community</p>
<p class='text-gray-600 text-sm sm:text-lg leading-relaxed my-2.5 sm:my-5'>
roadmap.sh is the <a
href='https://github.com/search?o=desc&q=stars%3A%3E100000&s=stars&type=Repositories'
target='_blank'
class='font-medium text-gray-600 underline underline-offset-2 hover:text-black'
class='font-medium text-gray-600 hover:text-black underline underline-offset-2'
>6th most starred project on GitHub</a
> and is visited by hundreds of thousands of developers every month.
</p>
<div
class='mt-5 grid grid-cols-1 justify-between gap-2 divide-x-0 sm:my-11 sm:grid-cols-3 sm:gap-0 sm:divide-x'
>
<OpenSourceStat text='GitHub Stars' value={starCount} />
<OpenSourceStat text='Registered Users' value={'850k'} />
<OpenSourceStat
text='Discord Members'
value={discordInfo.totalFormatted}
/>
<div class='flex justify-start flex-col sm:flex-row sm:justify-center gap-2 sm:gap-3 mb-1.5 sm:mb-0'>
<a
href='https://github.com/kamranahmedse/developer-roadmap'
target='_blank'
class='inline-flex items-center border border-black py-1.5 px-3 rounded-lg text-sm hover:text-white hover:bg-black bg-white'
>
<Icon icon='star' class='mr-1 -ml-1 fill-current' />
<span class='lowercase'>{starCount}</span>
<span class='ml-2 hover:block'>GitHub Stars</span>
</a>
<a
href="https://discord.gg/cJpEt5Qbwa"
target='_blank'
class='relative pointer inline-flex items-center border border-black py-1.5 px-3 rounded-lg text-sm hover:text-white hover:bg-black bg-white group'
>
<Icon icon='discord' class='h-[14px] mr-2 -ml-1 fill-current' />
Join on Discord
</a>
</div>
</div>
</div>

View File

@@ -1,105 +0,0 @@
---
import { ChevronUp } from 'lucide-react';
import Icon from './AstroIcon.astro';
export interface Props {
value: string;
text: string;
}
const { value, text } = Astro.props;
const isGitHubStars = text.toLowerCase() === 'github stars';
const isRegistered = text.toLowerCase() === 'registered users';
const isDiscordMembers = text.toLowerCase() === 'discord members';
---
<div
class='flex items-start sm:items-center justify-start flex-col sm:justify-center sm:gap-0 gap-2 sm:bg-transparent bg-gray-200 sm:rounded-none rounded-xl p-4'
>
{
isGitHubStars && (
<p class='flex items-center text-sm text-blue-500 sm:flex'>
<span class='rounded-md bg-blue-500 px-1 text-white'>Rank 6th</span>
&nbsp;out of 28M!
</p>
)
}
{
isRegistered && (
<p class='flex items-center text-sm text-blue-500 sm:flex'>
<span class='mr-1.5 rounded-md bg-blue-500 px-1 text-white'>+55k</span>
every month
</p>
)
}
{
isDiscordMembers && (
<p class='flex items-center text-sm text-blue-500 sm:flex'>
<span class='mr-1.5 rounded-md bg-blue-500 px-1 text-white'>+1.5k</span>
every month
</p>
)
}
<div class="flex flex-row items-center sm:flex-col my-1 sm:my-0">
<p
class='relative my-0 sm:my-4 mr-1 sm:mr-0 text-base font-bold lowercase sm:w-auto sm:text-5xl'
>
{value}
</p>
<p class='mb-0 mt-0 text-base sm:text-xs tracking-wide text-black sm:-mt-3 sm:mb-5'>
{text}
</p>
</div>
{
isGitHubStars && (
<a
href='https://github.com/kamranahmedse/developer-roadmap'
target='_blank'
class='group mt-0 flex flex-col items-center rounded-lg border border-black bg-white px-3 py-2 text-sm hover:bg-black hover:text-white'
>
<div class='mb-0.5 flex items-center font-semibold'>
<Icon icon='star' class='-ml-1 fill-current' />
<span class='ml-1.5 hover:block'>Star us on GitHub</span>
</div>
<span class='text-xs text-gray-500 group-hover:text-gray-100'>
Help us reach #1
</span>
</a>
)
}
{
isRegistered && (
<a
href='/signup'
class='group mt-0 flex flex-col items-center rounded-lg border border-black bg-white px-3 py-2 text-sm hover:bg-black hover:text-white'
>
<div class='mb-0.5 flex items-center font-semibold'>
<Icon icon='users' class='-ml-1 h-[15px] fill-current' />
<span class='ml-1 hover:block'>Register yourself</span>
</div>
<span class='text-xs text-gray-500 group-hover:text-gray-100'>
Commit to your growth
</span>
</a>
)
}
{
isDiscordMembers && (
<a
href='https://discord.gg/cJpEt5Qbwa'
target='_blank'
class='group mt-0 flex flex-col items-center rounded-lg border border-black bg-white px-3 py-2 text-sm hover:bg-black hover:text-white'
>
<div class='mb-0.5 flex items-center font-semibold'>
<Icon icon='discord' class='-ml-1 h-[13px] fill-current' />
<span class='ml-1 hover:block'>Join on Discord</span>
</div>
<span class='text-xs text-gray-500 group-hover:text-gray-100'>
Join the community
</span>
</a>
)
}
</div>

View File

@@ -1,7 +1,7 @@
import { useStore } from '@nanostores/react';
import SpinnerIcon from '../icons/spinner.svg';
import { pageProgressMessage } from '../stores/page';
import { useEffect, useState } from 'react';
import { Spinner } from './ReactIcons/Spinner';
export interface Props {
initialMessage: string;
@@ -30,10 +30,10 @@ export function PageProgress(props: Props) {
{/* Tailwind based spinner for full page */}
<div className="fixed left-0 top-0 z-50 flex h-full w-full items-center justify-center bg-white bg-opacity-75">
<div className="flex items-center justify-center rounded-md border bg-white px-4 py-2 ">
<Spinner
className="h-4 w-4 sm:h-4 sm:w-4"
outerFill="#e5e7eb"
innerFill="#2563eb"
<img
src={SpinnerIcon.src}
alt="Loading"
className="h-4 w-4 animate-spin fill-blue-600 text-gray-200 sm:h-4 sm:w-4"
/>
<h1 className="ml-2">
{message}

View File

@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react';
import CloseIcon from '../icons/close.svg';
import { httpGet } from '../lib/http';
import { sponsorHidden } from '../stores/page';
import { useStore } from '@nanostores/react';
import { X } from 'lucide-react';
export type PageSponsorType = {
company: string;
@@ -46,7 +46,7 @@ export function PageSponsor(props: PageSponsorProps) {
`${import.meta.env.PUBLIC_API_URL}/v1-get-sponsor`,
{
href: window.location.pathname,
},
}
);
if (error) {
@@ -101,11 +101,11 @@ export function PageSponsor(props: PageSponsorProps) {
sponsorHidden.set(true);
}}
>
<X className="h-4 w-4" />
<img alt="Close" className="h-4 w-4" src={CloseIcon.src} />
</span>
<img
src={imageUrl}
className="block h-[150px] object-cover lg:h-[169px] lg:w-[118.18px]"
className="block h-[150px] object-fill lg:h-[169px] lg:w-[118.18px]"
alt="Sponsor Banner"
/>
<span className="flex flex-1 flex-col justify-between text-sm">

View File

@@ -1,25 +0,0 @@
import { useEffect } from 'react';
import { isLoggedIn } from '../../lib/jwt';
import { httpPost } from '../../lib/http';
import type { ResourceType } from '../../lib/resource-progress';
type PageVisitProps = {
resourceId?: string;
resourceType?: ResourceType;
};
export function PageVisit(props: PageVisitProps) {
const { resourceId, resourceType } = props;
useEffect(() => {
if (!isLoggedIn()) {
return;
}
httpPost(`${import.meta.env.PUBLIC_API_URL}/v1-visit`, {
...(resourceType && { resourceType, resourceId }),
}).finally(() => {});
}, []);
return null;
}

View File

@@ -15,7 +15,7 @@ const { id, title, subtitle } = Astro.props;
<div
id={id}
tabindex='-1'
class='hidden bg-black/50 overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-[999] h-full items-center justify-center popup'
class='hidden bg-black/50 overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 h-full items-center justify-center popup'
>
<div class='relative p-4 w-full max-w-md h-full md:h-auto'>
<div class='relative bg-white rounded-lg shadow popup-body'>

View File

@@ -1,24 +0,0 @@
type AcceptIconProps = {
className?: string;
};
export function AcceptIcon(props: AcceptIconProps) {
const { className } = props;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="#000"
className={className}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
);
}

View File

@@ -1,28 +0,0 @@
type BestPracticesIconProps = {
className?: string;
};
export function BestPracticesIcon(props: BestPracticesIconProps) {
const { className } = props;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<line x1="10" x2="21" y1="6" y2="6"></line>
<line x1="10" x2="21" y1="12" y2="12"></line>
<line x1="10" x2="21" y1="18" y2="18"></line>
<polyline points="3 6 4 7 6 5"></polyline>
<polyline points="3 12 4 13 6 11"></polyline>
<polyline points="3 18 4 19 6 17"></polyline>
</svg>
);
}

View File

@@ -1,29 +0,0 @@
type BuildingIconProps = {
className?: string;
};
export function BuildingIcon(props: BuildingIconProps) {
const { className } = props;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"></path>
<path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"></path>
<path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"></path>
<path d="M10 6h4"></path>
<path d="M10 10h4"></path>
<path d="M10 14h4"></path>
<path d="M10 18h4"></path>
</svg>
);
}

View File

@@ -1,28 +0,0 @@
type ClipboardIconProps = {
className?: string;
};
export function ClipboardIcon(props: ClipboardIconProps) {
const { className } = props;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<rect width="8" height="4" x="8" y="2" rx="1" ry="1" />
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
<path d="M12 11h4" />
<path d="M12 16h4" />
<path d="M8 11h.01" />
<path d="M8 16h.01" />
</svg>
);
}

View File

@@ -1,28 +0,0 @@
type CogIconProps = {
className?: string;
};
export function CogIcon(props: CogIconProps) {
const { className } = props;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="currentColor"
className={className}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
);
}

View File

@@ -1,26 +0,0 @@
import { cn } from '../../lib/classname';
type DropdownIconProps = {
className?: string;
};
export function DropdownIcon(props: DropdownIconProps) {
const { className } = props;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
className={cn('h-5 w-5', className)}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>
);
}

View File

@@ -1,48 +0,0 @@
type ErrorIcon2Props = {
className?: string;
};
export function ErrorIcon2(props: ErrorIcon2Props) {
const { className } = props;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 48 48"
className={className}
>
<linearGradient
id="wRKXFJsqHCxLE9yyOYHkza"
x1="9.858"
x2="38.142"
y1="9.858"
y2="38.142"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#f44f5a" />
<stop offset=".443" stopColor="#ee3d4a" />
<stop offset="1" stopColor="#e52030" />
</linearGradient>
<path
fill="url(#wRKXFJsqHCxLE9yyOYHkza)"
d="M44,24c0,11.045-8.955,20-20,20S4,35.045,4,24S12.955,4,24,4S44,12.955,44,24z"
/>
<path
d="M33.192,28.95L28.243,24l4.95-4.95c0.781-0.781,0.781-2.047,0-2.828l-1.414-1.414 c-0.781-0.781-2.047-0.781-2.828,0L24,19.757l-4.95-4.95c-0.781-0.781-2.047-0.781-2.828,0l-1.414,1.414 c-0.781,0.781-0.781,2.047,0,2.828l4.95,4.95l-4.95,4.95c-0.781,0.781-0.781,2.047,0,2.828l1.414,1.414 c0.781,0.781,2.047,0.781,2.828,0l4.95-4.95l4.95,4.95c0.781,0.781,2.047,0.781,2.828,0l1.414-1.414 C33.973,30.997,33.973,29.731,33.192,28.95z"
opacity=".05"
/>
<path
d="M32.839,29.303L27.536,24l5.303-5.303c0.586-0.586,0.586-1.536,0-2.121l-1.414-1.414 c-0.586-0.586-1.536-0.586-2.121,0L24,20.464l-5.303-5.303c-0.586-0.586-1.536-0.586-2.121,0l-1.414,1.414 c-0.586,0.586-0.586,1.536,0,2.121L20.464,24l-5.303,5.303c-0.586,0.586-0.586,1.536,0,2.121l1.414,1.414 c0.586,0.586,1.536,0.586,2.121,0L24,27.536l5.303,5.303c0.586,0.586,1.536,0.586,2.121,0l1.414-1.414 C33.425,30.839,33.425,29.889,32.839,29.303z"
opacity=".07"
/>
<path
fill="#fff"
d="M31.071,15.515l1.414,1.414c0.391,0.391,0.391,1.024,0,1.414L18.343,32.485 c-0.391,0.391-1.024,0.391-1.414,0l-1.414-1.414c-0.391-0.391-0.391-1.024,0-1.414l14.142-14.142 C30.047,15.124,30.681,15.124,31.071,15.515z"
/>
<path
fill="#fff"
d="M32.485,31.071l-1.414,1.414c-0.391,0.391-1.024,0.391-1.414,0L15.515,18.343 c-0.391-0.391-0.391-1.024,0-1.414l1.414-1.414c0.391-0.391,1.024-0.391,1.414,0l14.142,14.142 C32.876,30.047,32.876,30.681,32.485,31.071z"
/>
</svg>
);
}

View File

@@ -1,20 +0,0 @@
type GitHubIconProps = {
className?: string;
};
export function GitHubIcon(props: GitHubIconProps) {
const { className } = props;
return (
<svg
className={className || ''}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 98 96"
>
<path
fillRule="evenodd"
d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362l-.08-9.127c-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126l-.08 13.526c0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z"
fill={ className?.indexOf('text-') !== -1 ? 'currentColor' : '#24292f' }
/>
</svg>
);
}

View File

@@ -1,32 +0,0 @@
type GoogleIconProps = {
className?: string;
};
export function GoogleIcon(props: GoogleIconProps) {
const { className } = props;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 90 92"
fill="none"
className={className}
>
<path
d="M90 47.1c0-3.1-.3-6.3-.8-9.3H45.9v17.7h24.8c-1 5.7-4.3 10.7-9.2 13.9l14.8 11.5C85 72.8 90 61 90 47.1z"
fill="#4280ef"
/>
<path
d="M45.9 91.9c12.4 0 22.8-4.1 30.4-11.1L61.5 69.4c-4.1 2.8-9.4 4.4-15.6 4.4-12 0-22.1-8.1-25.8-18.9L4.9 66.6c7.8 15.5 23.6 25.3 41 25.3z"
fill="#34a353"
/>
<path
d="M20.1 54.8c-1.9-5.7-1.9-11.9 0-17.6L4.9 25.4c-6.5 13-6.5 28.3 0 41.2l15.2-11.8z"
fill="#f6b704"
/>
<path
d="M45.9 18.3c6.5-.1 12.9 2.4 17.6 6.9L76.6 12C68.3 4.2 57.3 0 45.9.1c-17.4 0-33.2 9.8-41 25.3l15.2 11.8c3.7-10.9 13.8-18.9 25.8-18.9z"
fill="#e54335"
/>
</svg>
);
}

Some files were not shown because too many files have changed in this diff Show More